feat: Add VEX compact fixture and implement offline verifier for Findings Ledger exports
- Introduced a new VEX compact fixture for testing purposes. - Implemented `verify_export.py` script to validate Findings Ledger exports, ensuring deterministic ordering and applying redaction manifests. - Added a lightweight stub `HarnessRunner` for unit tests to validate ledger hashing expectations. - Documented tasks related to the Mirror Creator. - Created models for entropy signals and implemented the `EntropyPenaltyCalculator` to compute penalties based on scanner outputs. - Developed unit tests for `EntropyPenaltyCalculator` to ensure correct penalty calculations and handling of edge cases. - Added tests for symbol ID normalization in the reachability scanner. - Enhanced console status service with comprehensive unit tests for connection handling and error recovery. - Included Cosign tool version 2.6.0 with checksums for various platforms.
This commit is contained in:
@@ -66,6 +66,8 @@ describe('ConsoleStatusClient', () => {
|
||||
const req = httpMock.expectOne('/console/status');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBeTruthy();
|
||||
expect(req.request.headers.get('X-Stella-Request-Id')).toBeTruthy();
|
||||
req.flush(sample);
|
||||
});
|
||||
|
||||
@@ -75,7 +77,8 @@ describe('ConsoleStatusClient', () => {
|
||||
|
||||
expect(eventSourceFactory).toHaveBeenCalled();
|
||||
const url = eventSourceFactory.calls.mostRecent().args[0];
|
||||
expect(url).toBe('/console/runs/run-123/stream?tenant=tenant-dev');
|
||||
expect(url).toContain('/console/runs/run-123/stream?tenant=tenant-dev');
|
||||
expect(url).toContain('traceId=');
|
||||
|
||||
// Simulate incoming message
|
||||
const fakeSource = eventSourceFactory.calls.mostRecent().returnValue as unknown as FakeEventSource;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { map } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { ConsoleRunEventDto, ConsoleStatusDto } from './console-status.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
export const CONSOLE_API_BASE_URL = new InjectionToken<string>('CONSOLE_API_BASE_URL');
|
||||
|
||||
@@ -29,9 +30,15 @@ export class ConsoleStatusClient {
|
||||
/**
|
||||
* Poll console status (queue lag, backlog, run counts).
|
||||
*/
|
||||
getStatus(tenantId?: string): Observable<ConsoleStatusDto> {
|
||||
getStatus(tenantId?: string, traceId?: string): Observable<ConsoleStatusDto> {
|
||||
const tenant = this.resolveTenant(tenantId);
|
||||
const headers = new HttpHeaders({ 'X-StellaOps-Tenant': tenant });
|
||||
const trace = traceId ?? generateTraceId();
|
||||
const headers = new HttpHeaders({
|
||||
'X-StellaOps-Tenant': tenant,
|
||||
'X-Stella-Trace-Id': trace,
|
||||
'X-Stella-Request-Id': trace,
|
||||
});
|
||||
|
||||
return this.http.get<ConsoleStatusDto>(`${this.baseUrl}/status`, { headers }).pipe(
|
||||
map((dto) => ({
|
||||
...dto,
|
||||
@@ -50,9 +57,10 @@ export class ConsoleStatusClient {
|
||||
* Subscribe to streaming updates for a specific run via SSE.
|
||||
* Caller is responsible for unsubscribing to close the connection.
|
||||
*/
|
||||
streamRun(runId: string, tenantId?: string): Observable<ConsoleRunEventDto> {
|
||||
streamRun(runId: string, tenantId?: string, traceId?: string): Observable<ConsoleRunEventDto> {
|
||||
const tenant = this.resolveTenant(tenantId);
|
||||
const params = new HttpParams().set('tenant', tenant);
|
||||
const trace = traceId ?? generateTraceId();
|
||||
const params = new HttpParams().set('tenant', tenant).set('traceId', trace);
|
||||
const url = `${this.baseUrl}/runs/${encodeURIComponent(runId)}/stream?${params.toString()}`;
|
||||
|
||||
return new Observable<ConsoleRunEventDto>((observer) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { Observable, catchError, map, throwError } from 'rxjs';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { RiskApi } from './risk.client';
|
||||
@@ -9,6 +9,12 @@ import { generateTraceId } from './trace.util';
|
||||
|
||||
export const RISK_API_BASE_URL = new InjectionToken<string>('RISK_API_BASE_URL');
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(public readonly retryAfterMs?: number) {
|
||||
super('rate-limit');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RiskHttpClient implements RiskApi {
|
||||
constructor(
|
||||
@@ -35,7 +41,8 @@ export class RiskHttpClient implements RiskApi {
|
||||
...page,
|
||||
page: page.page ?? 1,
|
||||
pageSize: page.pageSize ?? 20,
|
||||
}))
|
||||
}),
|
||||
catchError((err) => throwError(() => this.normalizeError(err)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,10 +57,23 @@ export class RiskHttpClient implements RiskApi {
|
||||
map((stats) => ({
|
||||
countsBySeverity: stats.countsBySeverity,
|
||||
lastComputation: stats.lastComputation ?? '1970-01-01T00:00:00Z',
|
||||
}))
|
||||
})),
|
||||
catchError((err) => throwError(() => this.normalizeError(err)))
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeError(err: unknown): Error {
|
||||
if (err instanceof RateLimitError) return err;
|
||||
if (err instanceof HttpErrorResponse && err.status === 429) {
|
||||
const retryAfter = err.headers.get('Retry-After');
|
||||
const retryAfterMs = retryAfter ? Number(retryAfter) * 1000 : undefined;
|
||||
return new RateLimitError(Number.isFinite(retryAfterMs) ? retryAfterMs : undefined);
|
||||
}
|
||||
|
||||
if (err instanceof Error) return err;
|
||||
return new Error('Risk API request failed');
|
||||
}
|
||||
|
||||
private buildHeaders(tenantId: string, projectId?: string, traceId?: string): HttpHeaders {
|
||||
let headers = new HttpHeaders({ 'X-Stella-Tenant': tenantId });
|
||||
if (projectId) headers = headers.set('X-Stella-Project', projectId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { RISK_API } from './risk.client';
|
||||
import { RateLimitError } from './risk-http.client';
|
||||
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
|
||||
import { RiskStore } from './risk.store';
|
||||
|
||||
@@ -47,6 +48,14 @@ describe('RiskStore', () => {
|
||||
expect(store.error()).toBe('boom');
|
||||
});
|
||||
|
||||
it('reports rate limit errors with retry hint', () => {
|
||||
apiSpy.list.and.returnValue(throwError(() => new RateLimitError(5000)));
|
||||
|
||||
store.fetchList(defaultOptions);
|
||||
|
||||
expect(store.error()).toContain('retry after 5s');
|
||||
});
|
||||
|
||||
it('stores stats results', () => {
|
||||
const stats: RiskStats = {
|
||||
countsBySeverity: { none: 0, info: 0, low: 1, medium: 0, high: 1, critical: 0 },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, Injectable, Signal, computed, signal } from '@angular/core';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { RISK_API, RiskApi } from './risk.client';
|
||||
import { RateLimitError } from './risk-http.client';
|
||||
import { RiskQueryOptions, RiskResultPage, RiskStats } from './risk.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -47,6 +48,13 @@ export class RiskStore {
|
||||
}
|
||||
|
||||
private normalizeError(err: unknown): string {
|
||||
if (err instanceof RateLimitError) {
|
||||
if (err.retryAfterMs && Number.isFinite(err.retryAfterMs)) {
|
||||
const seconds = Math.ceil(err.retryAfterMs / 1000);
|
||||
return `Rate limited; retry after ${seconds}s`;
|
||||
}
|
||||
return 'Rate limited; retry shortly';
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Unknown error fetching risk data';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Subject, of } from 'rxjs';
|
||||
|
||||
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
|
||||
import { ConsoleStatusClient } from '../api/console-status.client';
|
||||
import { ConsoleStatusService } from './console-status.service';
|
||||
import { ConsoleStatusStore } from './console-status.store';
|
||||
|
||||
class FakeConsoleStatusClient {
|
||||
public streams: { subject: Subject<ConsoleRunEventDto>; traceId?: string }[] = [];
|
||||
|
||||
getStatus(): any {
|
||||
const dto: ConsoleStatusDto = {
|
||||
backlog: 0,
|
||||
queueLagMs: 0,
|
||||
activeRuns: 0,
|
||||
pendingRuns: 0,
|
||||
healthy: true,
|
||||
lastCompletedRunId: null,
|
||||
lastCompletedAt: null,
|
||||
};
|
||||
return of(dto);
|
||||
}
|
||||
|
||||
streamRun(_runId: string, _tenantId?: string, traceId?: string) {
|
||||
const subject = new Subject<ConsoleRunEventDto>();
|
||||
this.streams.push({ subject, traceId });
|
||||
return subject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConsoleStatusService', () => {
|
||||
let service: ConsoleStatusService;
|
||||
let client: FakeConsoleStatusClient;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ConsoleStatusStore,
|
||||
ConsoleStatusService,
|
||||
{ provide: ConsoleStatusClient, useClass: FakeConsoleStatusClient },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(ConsoleStatusService);
|
||||
client = TestBed.inject(ConsoleStatusClient) as unknown as FakeConsoleStatusClient;
|
||||
jasmine.clock().install();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it('reconnects when heartbeat is missed', () => {
|
||||
const sub = service.subscribeToRun('run-1', { heartbeatMs: 5, maxRetries: 2, traceId: 'trace-heartbeat' });
|
||||
|
||||
expect(client.streams.length).toBe(1);
|
||||
jasmine.clock().tick(6);
|
||||
expect(client.streams.length).toBe(2);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('retries after stream errors with backoff', () => {
|
||||
const sub = service.subscribeToRun('run-2', { maxRetries: 1, heartbeatMs: 50, traceId: 'trace-error' });
|
||||
|
||||
expect(client.streams.length).toBe(1);
|
||||
client.streams[0].subject.error(new Error('boom'));
|
||||
|
||||
jasmine.clock().tick(1001);
|
||||
expect(client.streams.length).toBe(2);
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,14 @@ import { switchMap } from 'rxjs/operators';
|
||||
import { ConsoleStatusClient } from '../api/console-status.client';
|
||||
import { ConsoleRunEventDto, ConsoleStatusDto } from '../api/console-status.models';
|
||||
import { ConsoleStatusStore } from './console-status.store';
|
||||
import { generateTraceId } from '../api/trace.util';
|
||||
|
||||
export interface RunStreamOptions {
|
||||
heartbeatMs?: number;
|
||||
maxRetries?: number;
|
||||
traceId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -58,14 +66,65 @@ export class ConsoleStatusService {
|
||||
/**
|
||||
* Subscribe to run stream events for a given run id.
|
||||
*/
|
||||
subscribeToRun(runId: string): Subscription {
|
||||
subscribeToRun(runId: string, options?: RunStreamOptions): Subscription {
|
||||
this.store.clearEvents();
|
||||
return this.client.streamRun(runId).subscribe({
|
||||
next: (evt: ConsoleRunEventDto) => this.store.appendRunEvent(evt),
|
||||
error: (err) => {
|
||||
console.error('console run stream error', err);
|
||||
this.store.setError('Run stream disconnected');
|
||||
},
|
||||
|
||||
const traceId = options?.traceId ?? generateTraceId();
|
||||
const heartbeatMs = options?.heartbeatMs ?? 15000;
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const tenantId = options?.tenantId;
|
||||
|
||||
let retries = 0;
|
||||
let heartbeatHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let innerSub: Subscription | null = null;
|
||||
let disposed = false;
|
||||
|
||||
const clearHeartbeat = () => {
|
||||
if (heartbeatHandle) {
|
||||
clearTimeout(heartbeatHandle);
|
||||
heartbeatHandle = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleHeartbeat = () => {
|
||||
clearHeartbeat();
|
||||
heartbeatHandle = setTimeout(() => {
|
||||
handleError(new Error('heartbeat-timeout'));
|
||||
}, heartbeatMs);
|
||||
};
|
||||
|
||||
const handleError = (err: unknown) => {
|
||||
console.error('console run stream error', err);
|
||||
this.store.setError('Run stream disconnected');
|
||||
if (disposed || retries >= maxRetries) {
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(1000 * Math.pow(2, retries), 30000);
|
||||
retries += 1;
|
||||
setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return;
|
||||
innerSub?.unsubscribe();
|
||||
const stream$ = this.client.streamRun(runId, tenantId, traceId);
|
||||
innerSub = stream$.subscribe({
|
||||
next: (evt: ConsoleRunEventDto) => {
|
||||
retries = 0;
|
||||
this.store.appendRunEvent(evt);
|
||||
scheduleHeartbeat();
|
||||
},
|
||||
error: handleError,
|
||||
});
|
||||
scheduleHeartbeat();
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return new Subscription(() => {
|
||||
disposed = true;
|
||||
clearHeartbeat();
|
||||
innerSub?.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user