Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management. - Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management. - Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support. - Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Evidence Panel Metrics Service Unit Tests
|
||||
* SPRINT_0341_0001_0001 - T12: Secondary metrics tracking tests
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
|
||||
import {
|
||||
EvidencePanelMetricsService,
|
||||
EvidencePanelAction,
|
||||
} from './evidence-panel-metrics.service';
|
||||
import { APP_CONFIG } from '../config/app.config';
|
||||
|
||||
describe('EvidencePanelMetricsService', () => {
|
||||
let service: EvidencePanelMetricsService;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
const mockConfig = {
|
||||
apiBaseUrl: 'http://localhost:5000/api',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
EvidencePanelMetricsService,
|
||||
{ provide: APP_CONFIG, useValue: mockConfig },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(EvidencePanelMetricsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
service.reset();
|
||||
});
|
||||
|
||||
describe('session management', () => {
|
||||
it('should start a new session', () => {
|
||||
service.startSession('ADV-001');
|
||||
|
||||
const session = service.currentSession();
|
||||
expect(session).toBeTruthy();
|
||||
expect(session?.advisoryId).toBe('ADV-001');
|
||||
expect(session?.actions.length).toBe(0);
|
||||
expect(session?.bounced).toBe(false);
|
||||
});
|
||||
|
||||
it('should close previous session when starting new one', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.startSession('ADV-002');
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.totalSessions).toBe(1);
|
||||
expect(service.currentSession()?.advisoryId).toBe('ADV-002');
|
||||
});
|
||||
|
||||
it('should end session and record metrics', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
expect(service.currentSession()).toBeNull();
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.totalSessions).toBe(1);
|
||||
expect(metrics.engagedSessions).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action tracking', () => {
|
||||
it('should track action in current session', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch', { tab: 'linkset' });
|
||||
|
||||
const session = service.currentSession();
|
||||
expect(session?.actions.length).toBe(1);
|
||||
expect(session?.actions[0].action).toBe('tab_switch');
|
||||
expect(session?.actions[0].metadata).toEqual({ tab: 'linkset' });
|
||||
});
|
||||
|
||||
it('should record first action timestamp', () => {
|
||||
service.startSession('ADV-001');
|
||||
|
||||
expect(service.currentSession()?.firstActionAt).toBeUndefined();
|
||||
|
||||
service.trackAction('observation_expand');
|
||||
|
||||
expect(service.currentSession()?.firstActionAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not overwrite first action timestamp', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
const firstActionAt = service.currentSession()?.firstActionAt;
|
||||
|
||||
// Small delay to ensure different timestamps
|
||||
service.trackAction('copy_verification_cmd');
|
||||
|
||||
expect(service.currentSession()?.firstActionAt).toBe(firstActionAt);
|
||||
});
|
||||
|
||||
it('should ignore action when no session active', () => {
|
||||
service.trackAction('tab_switch');
|
||||
expect(service.currentSession()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bounce detection', () => {
|
||||
it('should mark session as bounced when no actions and short duration', async () => {
|
||||
service.startSession('ADV-001');
|
||||
// Immediately close (< BOUNCE_THRESHOLD_MS)
|
||||
service.endSession();
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.bounceRate).toBe(100);
|
||||
});
|
||||
|
||||
it('should not mark session as bounced when actions taken', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.bounceRate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics calculation', () => {
|
||||
it('should calculate open→action rate correctly', () => {
|
||||
// Session 1: engaged
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
// Session 2: not engaged
|
||||
service.startSession('ADV-002');
|
||||
service.endSession();
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.openToActionRate).toBe(50);
|
||||
});
|
||||
|
||||
it('should calculate action distribution', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.trackAction('tab_switch');
|
||||
service.trackAction('copy_verification_cmd');
|
||||
service.endSession();
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.actionDistribution.tab_switch).toBe(2);
|
||||
expect(metrics.actionDistribution.copy_verification_cmd).toBe(1);
|
||||
});
|
||||
|
||||
it('should identify most common first action', () => {
|
||||
// Session 1
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
// Session 2
|
||||
service.startSession('ADV-002');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
// Session 3
|
||||
service.startSession('ADV-003');
|
||||
service.trackAction('copy_verification_cmd');
|
||||
service.endSession();
|
||||
|
||||
const metrics = service.getMetricsSummary();
|
||||
expect(metrics.mostCommonFirstAction).toBe('tab_switch');
|
||||
});
|
||||
|
||||
it('should return empty metrics when no sessions', () => {
|
||||
const metrics = service.getMetricsSummary();
|
||||
|
||||
expect(metrics.totalSessions).toBe(0);
|
||||
expect(metrics.engagedSessions).toBe(0);
|
||||
expect(metrics.openToActionRate).toBe(0);
|
||||
expect(metrics.bounceRate).toBe(0);
|
||||
expect(metrics.mostCommonFirstAction).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backend reporting', () => {
|
||||
it('should flush to backend when buffer reaches threshold', () => {
|
||||
// Create 10 sessions to trigger flush
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.startSession(`ADV-${i}`);
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
}
|
||||
|
||||
// Expect POST to metrics endpoint
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body.sessions.length).toBe(10);
|
||||
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('should include session summary in flush payload', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
service.startSession(`ADV-${i}`);
|
||||
if (i % 2 === 0) {
|
||||
service.trackAction('tab_switch');
|
||||
}
|
||||
service.endSession();
|
||||
}
|
||||
|
||||
const req = httpMock.expectOne(`${mockConfig.apiBaseUrl}/metrics/evidence-panel`);
|
||||
const sessions = req.request.body.sessions;
|
||||
|
||||
expect(sessions[0]).toEqual(jasmine.objectContaining({
|
||||
advisoryId: 'ADV-0',
|
||||
actionCount: 1,
|
||||
}));
|
||||
|
||||
expect(sessions[1]).toEqual(jasmine.objectContaining({
|
||||
advisoryId: 'ADV-1',
|
||||
actionCount: 0,
|
||||
}));
|
||||
|
||||
req.flush({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear all sessions and metrics', () => {
|
||||
service.startSession('ADV-001');
|
||||
service.trackAction('tab_switch');
|
||||
service.endSession();
|
||||
|
||||
service.reset();
|
||||
|
||||
expect(service.currentSession()).toBeNull();
|
||||
expect(service.getMetricsSummary().totalSessions).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Evidence Panel Secondary Metrics Tracking Service
|
||||
* SPRINT_0341_0001_0001 - T12: Open→Action tracking, bounce rate
|
||||
*
|
||||
* Tracks user engagement metrics for the Evidence Panel:
|
||||
* - Open→Action rate: How often users take actions after opening
|
||||
* - Bounce rate: Users who open and immediately close
|
||||
* - Time to first action: Latency from open to first interaction
|
||||
* - Action distribution: Which actions are most common
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { APP_CONFIG, AppConfig } from '../config/app.config';
|
||||
|
||||
/**
|
||||
* Types of actions tracked in the Evidence Panel
|
||||
*/
|
||||
export type EvidencePanelAction =
|
||||
| 'tab_switch'
|
||||
| 'filter_apply'
|
||||
| 'observation_expand'
|
||||
| 'copy_verification_cmd'
|
||||
| 'download_document'
|
||||
| 'export_vex'
|
||||
| 'export_bundle'
|
||||
| 'copy_permalink'
|
||||
| 'aoc_expand'
|
||||
| 'conflict_expand';
|
||||
|
||||
/**
|
||||
* Engagement session for a single panel open event
|
||||
*/
|
||||
export interface EngagementSession {
|
||||
sessionId: string;
|
||||
advisoryId: string;
|
||||
openedAt: number;
|
||||
closedAt?: number;
|
||||
firstActionAt?: number;
|
||||
actions: SessionAction[];
|
||||
bounced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single action within a session
|
||||
*/
|
||||
export interface SessionAction {
|
||||
action: EvidencePanelAction;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated metrics for reporting
|
||||
*/
|
||||
export interface EvidencePanelMetrics {
|
||||
/** Total sessions tracked */
|
||||
totalSessions: number;
|
||||
/** Sessions with at least one action */
|
||||
engagedSessions: number;
|
||||
/** Open→Action rate (percentage) */
|
||||
openToActionRate: number;
|
||||
/** Bounce rate (percentage) - sessions with no actions */
|
||||
bounceRate: number;
|
||||
/** Average time to first action (ms) */
|
||||
avgTimeToFirstAction: number;
|
||||
/** Median session duration (ms) */
|
||||
medianSessionDuration: number;
|
||||
/** Action distribution by type */
|
||||
actionDistribution: Record<EvidencePanelAction, number>;
|
||||
/** Most common first action */
|
||||
mostCommonFirstAction: EvidencePanelAction | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce threshold in milliseconds
|
||||
* Sessions shorter than this with no actions are considered bounces
|
||||
*/
|
||||
const BOUNCE_THRESHOLD_MS = 3000;
|
||||
|
||||
/**
|
||||
* Session buffer size before flushing to backend
|
||||
*/
|
||||
const FLUSH_BUFFER_SIZE = 10;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EvidencePanelMetricsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly config = inject<AppConfig>(APP_CONFIG);
|
||||
|
||||
/** Current active session */
|
||||
private readonly _currentSession = signal<EngagementSession | null>(null);
|
||||
|
||||
/** Completed sessions buffer (for batch reporting) */
|
||||
private readonly _sessionBuffer = signal<EngagementSession[]>([]);
|
||||
|
||||
/** All tracked sessions (for local metrics) */
|
||||
private readonly _allSessions = signal<EngagementSession[]>([]);
|
||||
|
||||
/** Current session accessor */
|
||||
readonly currentSession = this._currentSession.asReadonly();
|
||||
|
||||
/**
|
||||
* Computed aggregated metrics
|
||||
*/
|
||||
readonly metrics = computed((): EvidencePanelMetrics => {
|
||||
const sessions = this._allSessions();
|
||||
if (sessions.length === 0) {
|
||||
return this.emptyMetrics();
|
||||
}
|
||||
|
||||
const engagedSessions = sessions.filter(s => s.actions.length > 0);
|
||||
const bouncedSessions = sessions.filter(s => s.bounced);
|
||||
|
||||
// Time to first action for engaged sessions
|
||||
const timesToFirstAction = engagedSessions
|
||||
.filter(s => s.firstActionAt !== undefined)
|
||||
.map(s => s.firstActionAt! - s.openedAt);
|
||||
|
||||
const avgTimeToFirstAction = timesToFirstAction.length > 0
|
||||
? timesToFirstAction.reduce((a, b) => a + b, 0) / timesToFirstAction.length
|
||||
: 0;
|
||||
|
||||
// Session durations
|
||||
const durations = sessions
|
||||
.filter(s => s.closedAt !== undefined)
|
||||
.map(s => s.closedAt! - s.openedAt)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const medianSessionDuration = durations.length > 0
|
||||
? durations[Math.floor(durations.length / 2)]
|
||||
: 0;
|
||||
|
||||
// Action distribution
|
||||
const actionDistribution = this.computeActionDistribution(sessions);
|
||||
|
||||
// Most common first action
|
||||
const firstActions = engagedSessions
|
||||
.map(s => s.actions[0]?.action)
|
||||
.filter((a): a is EvidencePanelAction => a !== undefined);
|
||||
|
||||
const mostCommonFirstAction = this.findMostCommon(firstActions);
|
||||
|
||||
return {
|
||||
totalSessions: sessions.length,
|
||||
engagedSessions: engagedSessions.length,
|
||||
openToActionRate: sessions.length > 0
|
||||
? (engagedSessions.length / sessions.length) * 100
|
||||
: 0,
|
||||
bounceRate: sessions.length > 0
|
||||
? (bouncedSessions.length / sessions.length) * 100
|
||||
: 0,
|
||||
avgTimeToFirstAction,
|
||||
medianSessionDuration,
|
||||
actionDistribution,
|
||||
mostCommonFirstAction,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Start a new engagement session when panel opens
|
||||
*/
|
||||
startSession(advisoryId: string): void {
|
||||
// Close any existing session first
|
||||
this.endSession();
|
||||
|
||||
const session: EngagementSession = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
advisoryId,
|
||||
openedAt: Date.now(),
|
||||
actions: [],
|
||||
bounced: false,
|
||||
};
|
||||
|
||||
this._currentSession.set(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an action in the current session
|
||||
*/
|
||||
trackAction(action: EvidencePanelAction, metadata?: Record<string, unknown>): void {
|
||||
const session = this._currentSession();
|
||||
if (!session) return;
|
||||
|
||||
const now = Date.now();
|
||||
const sessionAction: SessionAction = {
|
||||
action,
|
||||
timestamp: now,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Update session with new action
|
||||
const updatedSession: EngagementSession = {
|
||||
...session,
|
||||
actions: [...session.actions, sessionAction],
|
||||
firstActionAt: session.firstActionAt ?? now,
|
||||
bounced: false, // No longer a bounce if they took action
|
||||
};
|
||||
|
||||
this._currentSession.set(updatedSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current session when panel closes
|
||||
*/
|
||||
endSession(): void {
|
||||
const session = this._currentSession();
|
||||
if (!session) return;
|
||||
|
||||
const closedAt = Date.now();
|
||||
const duration = closedAt - session.openedAt;
|
||||
|
||||
// Determine if this was a bounce
|
||||
const bounced = session.actions.length === 0 && duration < BOUNCE_THRESHOLD_MS;
|
||||
|
||||
const completedSession: EngagementSession = {
|
||||
...session,
|
||||
closedAt,
|
||||
bounced,
|
||||
};
|
||||
|
||||
// Add to all sessions
|
||||
this._allSessions.update(sessions => [...sessions, completedSession]);
|
||||
|
||||
// Add to buffer for batch reporting
|
||||
this._sessionBuffer.update(buffer => {
|
||||
const newBuffer = [...buffer, completedSession];
|
||||
if (newBuffer.length >= FLUSH_BUFFER_SIZE) {
|
||||
this.flushToBackend(newBuffer);
|
||||
return [];
|
||||
}
|
||||
return newBuffer;
|
||||
});
|
||||
|
||||
this._currentSession.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush session data to backend for aggregation
|
||||
*/
|
||||
private flushToBackend(sessions: EngagementSession[]): void {
|
||||
if (sessions.length === 0) return;
|
||||
|
||||
// Fire-and-forget POST to metrics endpoint
|
||||
this.http.post(
|
||||
`${this.config.apiBaseUrl}/metrics/evidence-panel`,
|
||||
{
|
||||
sessions: sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
advisoryId: s.advisoryId,
|
||||
durationMs: s.closedAt ? s.closedAt - s.openedAt : 0,
|
||||
actionCount: s.actions.length,
|
||||
bounced: s.bounced,
|
||||
firstActionDelayMs: s.firstActionAt ? s.firstActionAt - s.openedAt : null,
|
||||
actions: s.actions.map(a => ({
|
||||
action: a.action,
|
||||
relativeMs: a.timestamp - s.openedAt,
|
||||
})),
|
||||
})),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
).subscribe({
|
||||
error: (err) => console.warn('Failed to flush evidence panel metrics:', err),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current metrics summary for debugging/display
|
||||
*/
|
||||
getMetricsSummary(): EvidencePanelMetrics {
|
||||
return this.metrics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all tracked metrics (for testing)
|
||||
*/
|
||||
reset(): void {
|
||||
this.endSession();
|
||||
this._allSessions.set([]);
|
||||
this._sessionBuffer.set([]);
|
||||
}
|
||||
|
||||
private emptyMetrics(): EvidencePanelMetrics {
|
||||
const emptyDistribution: Record<EvidencePanelAction, number> = {
|
||||
tab_switch: 0,
|
||||
filter_apply: 0,
|
||||
observation_expand: 0,
|
||||
copy_verification_cmd: 0,
|
||||
download_document: 0,
|
||||
export_vex: 0,
|
||||
export_bundle: 0,
|
||||
copy_permalink: 0,
|
||||
aoc_expand: 0,
|
||||
conflict_expand: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
totalSessions: 0,
|
||||
engagedSessions: 0,
|
||||
openToActionRate: 0,
|
||||
bounceRate: 0,
|
||||
avgTimeToFirstAction: 0,
|
||||
medianSessionDuration: 0,
|
||||
actionDistribution: emptyDistribution,
|
||||
mostCommonFirstAction: null,
|
||||
};
|
||||
}
|
||||
|
||||
private computeActionDistribution(
|
||||
sessions: EngagementSession[]
|
||||
): Record<EvidencePanelAction, number> {
|
||||
const distribution: Record<EvidencePanelAction, number> = {
|
||||
tab_switch: 0,
|
||||
filter_apply: 0,
|
||||
observation_expand: 0,
|
||||
copy_verification_cmd: 0,
|
||||
download_document: 0,
|
||||
export_vex: 0,
|
||||
export_bundle: 0,
|
||||
copy_permalink: 0,
|
||||
aoc_expand: 0,
|
||||
conflict_expand: 0,
|
||||
};
|
||||
|
||||
for (const session of sessions) {
|
||||
for (const action of session.actions) {
|
||||
distribution[action.action]++;
|
||||
}
|
||||
}
|
||||
|
||||
return distribution;
|
||||
}
|
||||
|
||||
private findMostCommon<T>(items: T[]): T | null {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const counts = new Map<T, number>();
|
||||
for (const item of items) {
|
||||
counts.set(item, (counts.get(item) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let maxCount = 0;
|
||||
let mostCommon: T | null = null;
|
||||
for (const [item, count] of counts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mostCommon = item;
|
||||
}
|
||||
}
|
||||
|
||||
return mostCommon;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ export interface EvidenceApi {
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
@@ -320,4 +325,28 @@ export class MockEvidenceApiService implements EvidenceApi {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full evidence bundle as tar.gz or zip
|
||||
* SPRINT_0341_0001_0001 - T14: One-click evidence export
|
||||
*/
|
||||
async exportEvidenceBundle(advisoryId: string, format: 'tar.gz' | 'zip'): Promise<Blob> {
|
||||
// In mock implementation, return a JSON blob with all evidence data
|
||||
const data = {
|
||||
advisoryId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
observations: MOCK_OBSERVATIONS,
|
||||
linkset: MOCK_LINKSET,
|
||||
policyEvidence: MOCK_POLICY_EVIDENCE,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const mimeType = format === 'tar.gz' ? 'application/gzip' : 'application/zip';
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
return new Blob([json], { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,11 @@ export interface Linkset {
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
// Artifact and verification fields (SPRINT_0341_0001_0001)
|
||||
readonly artifactRef?: string; // e.g., registry.example.com/image:tag
|
||||
readonly artifactDigest?: string; // e.g., sha256:abc123...
|
||||
readonly sbomDigest?: string; // SBOM attestation digest
|
||||
readonly rekorLogIndex?: number; // Rekor transparency log index
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
@@ -115,6 +120,9 @@ export interface PolicyEvidence {
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
// Decision verification fields (SPRINT_0341_0001_0001)
|
||||
readonly decisionDigest?: string; // Hash of the decision for verification
|
||||
readonly rekorLogIndex?: number; // Rekor log index if logged
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
@@ -143,13 +151,13 @@ export interface AocChainEntry {
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||
export type VexStatus =
|
||||
| 'NOT_AFFECTED'
|
||||
| 'UNDER_INVESTIGATION'
|
||||
| 'AFFECTED_MITIGATED'
|
||||
| 'AFFECTED_UNMITIGATED'
|
||||
| 'FIXED';
|
||||
|
||||
export type VexJustificationType =
|
||||
| 'CODE_NOT_PRESENT'
|
||||
@@ -207,14 +215,14 @@ export interface VexDecision {
|
||||
}
|
||||
|
||||
// VEX status summary for UI display
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
export interface VexStatusSummary {
|
||||
readonly notAffected: number;
|
||||
readonly underInvestigation: number;
|
||||
readonly affectedMitigated: number;
|
||||
readonly affectedUnmitigated: number;
|
||||
readonly fixed: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
// VEX conflict indicator
|
||||
export interface VexConflict {
|
||||
|
||||
@@ -535,24 +535,24 @@
|
||||
aria-label="Previous page"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
|
||||
<!-- Page number buttons (show max 5) -->
|
||||
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn pagination-btn--number"
|
||||
[class.active]="currentPage() === getPageNumberForIndex(i)"
|
||||
(click)="goToPage(getPageNumberForIndex(i))"
|
||||
[attr.aria-current]="currentPage() === getPageNumberForIndex(i) ? 'page' : null"
|
||||
aria-label="Page {{ getPageNumberForIndex(i) + 1 }}"
|
||||
>
|
||||
{{ getPageNumberForIndex(i) + 1 }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
</button>
|
||||
|
||||
<!-- Page number buttons (show max 5) -->
|
||||
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn pagination-btn--number"
|
||||
[class.active]="currentPage() === getPageNumberForIndex(i)"
|
||||
(click)="goToPage(getPageNumberForIndex(i))"
|
||||
[attr.aria-current]="currentPage() === getPageNumberForIndex(i) ? 'page' : null"
|
||||
aria-label="Page {{ getPageNumberForIndex(i) + 1 }}"
|
||||
>
|
||||
{{ getPageNumberForIndex(i) + 1 }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="pagination-btn"
|
||||
[disabled]="!hasNextPage()"
|
||||
(click)="nextPage()"
|
||||
@@ -710,6 +710,37 @@
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Verify Locally Commands (SPRINT_0341_0001_0001 - T5, T7) -->
|
||||
@if (verificationCommands().length > 0) {
|
||||
<div class="linkset-panel__verify">
|
||||
<h4>Verify Locally</h4>
|
||||
<p class="linkset-panel__verify-description">
|
||||
Use these commands to independently verify the evidence chain on your local machine.
|
||||
</p>
|
||||
<div class="verify-commands">
|
||||
@for (cmd of verificationCommands(); track trackByCommandId($index, cmd)) {
|
||||
<div class="verify-command">
|
||||
<div class="verify-command__header">
|
||||
<span class="verify-command__icon" aria-hidden="true">{{ cmd.icon }}</span>
|
||||
<span class="verify-command__label">{{ cmd.label }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="verify-command__copy"
|
||||
[class.copied]="isCommandCopied(cmd.id)"
|
||||
(click)="copyVerificationCommand(cmd.id)"
|
||||
[attr.aria-label]="isCommandCopied(cmd.id) ? 'Copied!' : 'Copy command'"
|
||||
>
|
||||
{{ isCommandCopied(cmd.id) ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="verify-command__description">{{ cmd.description }}</p>
|
||||
<pre class="verify-command__code"><code>{{ cmd.command }}</code></pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -759,19 +790,19 @@
|
||||
</header>
|
||||
|
||||
<!-- Status Summary Cards -->
|
||||
<div class="vex-panel__summary">
|
||||
<div class="vex-summary-card vex-summary-card--not-affected">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
||||
<span class="vex-summary-card__label">Not Affected</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--under-investigation">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
|
||||
<span class="vex-summary-card__label">Under Investigation</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--mitigated">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
|
||||
<span class="vex-summary-card__label">Mitigated</span>
|
||||
</div>
|
||||
<div class="vex-panel__summary">
|
||||
<div class="vex-summary-card vex-summary-card--not-affected">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
||||
<span class="vex-summary-card__label">Not Affected</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--under-investigation">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
|
||||
<span class="vex-summary-card__label">Under Investigation</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--mitigated">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
|
||||
<span class="vex-summary-card__label">Mitigated</span>
|
||||
</div>
|
||||
<div class="vex-summary-card vex-summary-card--unmitigated">
|
||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
|
||||
<span class="vex-summary-card__label">Unmitigated</span>
|
||||
|
||||
@@ -991,6 +991,109 @@ $color-text-muted: #6b7280;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Locally Section (SPRINT_0341_0001_0001)
|
||||
&__verify {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid $color-border;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Commands
|
||||
.verify-commands {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.verify-command {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 6px;
|
||||
background: $color-bg-muted;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__copy {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: #dcfce7;
|
||||
border-color: #22c55e;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
&__code {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Policy Panel
|
||||
@@ -1450,29 +1553,29 @@ $color-text-muted: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&--not-affected {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--under-investigation {
|
||||
background: #f5f3ff;
|
||||
border-color: #c4b5fd;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #6d28d9;
|
||||
}
|
||||
}
|
||||
|
||||
&--mitigated {
|
||||
background: #fef9c3;
|
||||
border-color: #fde047;
|
||||
|
||||
.vex-summary-card__count {
|
||||
&--not-affected {
|
||||
background: #f0fdf4;
|
||||
border-color: #86efac;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
&--under-investigation {
|
||||
background: #f5f3ff;
|
||||
border-color: #c4b5fd;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #6d28d9;
|
||||
}
|
||||
}
|
||||
|
||||
&--mitigated {
|
||||
background: #fef9c3;
|
||||
border-color: #fde047;
|
||||
|
||||
.vex-summary-card__count {
|
||||
color: #a16207;
|
||||
}
|
||||
}
|
||||
@@ -1625,20 +1728,20 @@ $color-text-muted: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.vex-status--not-affected {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.vex-status--under-investigation {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.vex-status--mitigated {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
&.vex-status--not-affected {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.vex-status--under-investigation {
|
||||
background: #f5f3ff;
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
&.vex-status--mitigated {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&.vex-status--unmitigated {
|
||||
background: #fee2e2;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
import { ConfidenceBadgeComponent } from '../../shared/components/confidence-badge.component';
|
||||
import { QuietProvenanceIndicatorComponent } from '../../shared/components/quiet-provenance-indicator.component';
|
||||
import { EvidencePanelMetricsService } from '../../core/analytics/evidence-panel-metrics.service';
|
||||
|
||||
type TabId = 'observations' | 'linkset' | 'vex' | 'policy' | 'aoc';
|
||||
type ObservationView = 'side-by-side' | 'stacked';
|
||||
@@ -47,6 +48,7 @@ type ObservationView = 'side-by-side' | 'stacked';
|
||||
})
|
||||
export class EvidencePanelComponent {
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
private readonly metricsService = inject(EvidencePanelMetricsService);
|
||||
|
||||
// Expose Math for template usage
|
||||
readonly Math = Math;
|
||||
@@ -59,6 +61,13 @@ export class EvidencePanelComponent {
|
||||
readonly close = output<void>();
|
||||
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
|
||||
|
||||
// One-click evidence bundle export (SPRINT_0341_0001_0001 - T14)
|
||||
readonly exportBundle = output<{ advisoryId: string; format: 'tar.gz' | 'zip' }>();
|
||||
|
||||
// Export state
|
||||
readonly exportInProgress = signal(false);
|
||||
readonly exportError = signal<string | null>(null);
|
||||
|
||||
// UI State
|
||||
readonly activeTab = signal<TabId>('observations');
|
||||
readonly observationView = signal<ObservationView>('side-by-side');
|
||||
@@ -171,21 +180,21 @@ export class EvidencePanelComponent {
|
||||
});
|
||||
|
||||
// Whether there are more pages
|
||||
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
|
||||
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
|
||||
|
||||
getPageNumberForIndex(i: number): number {
|
||||
const totalPages = this.totalPages();
|
||||
if (totalPages <= 0) return 0;
|
||||
|
||||
const current = this.currentPage();
|
||||
const base = current < 2 ? i : current - 2 + i;
|
||||
return Math.min(base, totalPages - 1);
|
||||
}
|
||||
|
||||
// Active filter count for badge
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const f = this.filters();
|
||||
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
|
||||
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
|
||||
|
||||
getPageNumberForIndex(i: number): number {
|
||||
const totalPages = this.totalPages();
|
||||
if (totalPages <= 0) return 0;
|
||||
|
||||
const current = this.currentPage();
|
||||
const base = current < 2 ? i : current - 2 + i;
|
||||
return Math.min(base, totalPages - 1);
|
||||
}
|
||||
|
||||
// Active filter count for badge
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const f = this.filters();
|
||||
let count = 0;
|
||||
if (f.sources.length > 0) count++;
|
||||
if (f.severityBucket !== 'all') count++;
|
||||
@@ -204,17 +213,17 @@ export class EvidencePanelComponent {
|
||||
readonly showPermalink = signal(false);
|
||||
readonly permalinkCopied = signal(false);
|
||||
|
||||
readonly vexStatusSummary = computed((): VexStatusSummary => {
|
||||
const decisions = this.vexDecisions();
|
||||
return {
|
||||
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
|
||||
underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length,
|
||||
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
|
||||
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
||||
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
||||
total: decisions.length,
|
||||
};
|
||||
});
|
||||
readonly vexStatusSummary = computed((): VexStatusSummary => {
|
||||
const decisions = this.vexDecisions();
|
||||
return {
|
||||
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
|
||||
underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length,
|
||||
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
|
||||
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
||||
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
||||
total: decisions.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Permalink computed value
|
||||
readonly permalink = computed(() => {
|
||||
@@ -242,6 +251,7 @@ export class EvidencePanelComponent {
|
||||
// Tab methods
|
||||
setActiveTab(tab: TabId): void {
|
||||
this.activeTab.set(tab);
|
||||
this.metricsService.trackAction('tab_switch', { tab });
|
||||
}
|
||||
|
||||
isActiveTab(tab: TabId): boolean {
|
||||
@@ -256,6 +266,9 @@ export class EvidencePanelComponent {
|
||||
toggleObservationExpanded(observationId: string): void {
|
||||
const current = this.expandedObservation();
|
||||
this.expandedObservation.set(current === observationId ? null : observationId);
|
||||
if (current !== observationId) {
|
||||
this.metricsService.trackAction('observation_expand', { observationId });
|
||||
}
|
||||
}
|
||||
|
||||
isObservationExpanded(observationId: string): boolean {
|
||||
@@ -450,34 +463,34 @@ export class EvidencePanelComponent {
|
||||
}
|
||||
|
||||
// VEX helpers
|
||||
getVexStatusLabel(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'Not Affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'Under Investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'Affected (Mitigated)';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'Affected (Unmitigated)';
|
||||
case 'FIXED':
|
||||
getVexStatusLabel(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'Not Affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'Under Investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'Affected (Mitigated)';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'Affected (Unmitigated)';
|
||||
case 'FIXED':
|
||||
return 'Fixed';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
getVexStatusClass(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'vex-status--not-affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'vex-status--under-investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'vex-status--mitigated';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'vex-status--unmitigated';
|
||||
case 'FIXED':
|
||||
getVexStatusClass(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'NOT_AFFECTED':
|
||||
return 'vex-status--not-affected';
|
||||
case 'UNDER_INVESTIGATION':
|
||||
return 'vex-status--under-investigation';
|
||||
case 'AFFECTED_MITIGATED':
|
||||
return 'vex-status--mitigated';
|
||||
case 'AFFECTED_UNMITIGATED':
|
||||
return 'vex-status--unmitigated';
|
||||
case 'FIXED':
|
||||
return 'vex-status--fixed';
|
||||
default:
|
||||
return '';
|
||||
@@ -555,14 +568,57 @@ export class EvidencePanelComponent {
|
||||
// Download handlers
|
||||
onDownloadObservation(observationId: string): void {
|
||||
this.downloadDocument.emit({ type: 'observation', id: observationId });
|
||||
this.metricsService.trackAction('download_document', { type: 'observation', id: observationId });
|
||||
}
|
||||
|
||||
onDownloadLinkset(linksetId: string): void {
|
||||
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
|
||||
this.metricsService.trackAction('download_document', { type: 'linkset', id: linksetId });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// One-Click Evidence Bundle Export (SPRINT_0341_0001_0001 - T14)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Export evidence bundle as tar.gz (includes all observations, linkset, VEX, policy)
|
||||
*/
|
||||
async onExportEvidenceBundle(format: 'tar.gz' | 'zip' = 'tar.gz'): Promise<void> {
|
||||
const advisoryId = this.advisoryId();
|
||||
if (!advisoryId) return;
|
||||
|
||||
this.exportInProgress.set(true);
|
||||
this.exportError.set(null);
|
||||
this.metricsService.trackAction('export_bundle', { format, advisoryId });
|
||||
|
||||
try {
|
||||
// Request bundle generation from API
|
||||
const blob = await this.evidenceApi.exportEvidenceBundle(advisoryId, format);
|
||||
|
||||
// Trigger download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `evidence-${advisoryId}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Emit event for parent component
|
||||
this.exportBundle.emit({ advisoryId, format });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to export evidence bundle';
|
||||
this.exportError.set(message);
|
||||
console.error('Evidence bundle export failed:', err);
|
||||
} finally {
|
||||
this.exportInProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Close handler
|
||||
onClose(): void {
|
||||
this.metricsService.endSession();
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
@@ -607,4 +663,204 @@ export class EvidencePanelComponent {
|
||||
trackByVexConflictId(_: number, conflict: VexConflict): string {
|
||||
return conflict.vulnerabilityId;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// "Verify locally" commands (SPRINT_0341_0001_0001 - T5, T7)
|
||||
// ============================================================================
|
||||
|
||||
/** State for copy confirmation */
|
||||
readonly verifyCommandCopied = signal<string | null>(null);
|
||||
|
||||
/**
|
||||
* Verification command templates for local verification
|
||||
*/
|
||||
readonly verificationCommands = computed(() => {
|
||||
const linkset = this.linkset();
|
||||
const policy = this.policyEvidence();
|
||||
const aocChain = this.aocChain();
|
||||
|
||||
if (!linkset) return [];
|
||||
|
||||
const commands: VerificationCommand[] = [];
|
||||
|
||||
// 1. Cosign verify command for artifact signature
|
||||
if (linkset.artifactDigest) {
|
||||
commands.push({
|
||||
id: 'cosign-verify',
|
||||
label: 'Verify artifact signature (cosign)',
|
||||
icon: 'shield-check',
|
||||
command: this.buildCosignVerifyCommand(linkset.artifactDigest, linkset.artifactRef),
|
||||
description: 'Verify the artifact signature using cosign',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Rekor log verification
|
||||
if (linkset.rekorLogIndex) {
|
||||
commands.push({
|
||||
id: 'rekor-get',
|
||||
label: 'Verify Rekor transparency log',
|
||||
icon: 'search',
|
||||
command: this.buildRekorGetCommand(linkset.rekorLogIndex),
|
||||
description: 'Retrieve and verify the Rekor transparency log entry',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. SBOM verification (if SBOM digest present)
|
||||
if (linkset.sbomDigest) {
|
||||
commands.push({
|
||||
id: 'sbom-verify',
|
||||
label: 'Verify SBOM attestation',
|
||||
icon: 'file-text',
|
||||
command: this.buildSbomVerifyCommand(linkset.artifactRef, linkset.sbomDigest),
|
||||
description: 'Verify the SBOM attestation attached to the artifact',
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Attestation chain verification
|
||||
if (aocChain.length > 0) {
|
||||
commands.push({
|
||||
id: 'attestation-verify',
|
||||
label: 'Verify attestation chain',
|
||||
icon: 'link',
|
||||
command: this.buildAttestationChainCommand(aocChain, linkset.artifactRef),
|
||||
description: 'Verify the complete attestation chain (DSSE envelope)',
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Policy decision verification
|
||||
if (policy?.policyId && policy?.decisionDigest) {
|
||||
commands.push({
|
||||
id: 'policy-verify',
|
||||
label: 'Verify policy decision',
|
||||
icon: 'clipboard-check',
|
||||
command: this.buildPolicyVerifyCommand(policy.policyId, policy.decisionDigest),
|
||||
description: 'Verify the policy decision attestation',
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
});
|
||||
|
||||
/**
|
||||
* Build cosign verify command
|
||||
*/
|
||||
private buildCosignVerifyCommand(digest: string, artifactRef?: string): string {
|
||||
const ref = artifactRef ?? `<image-ref>@${digest}`;
|
||||
return [
|
||||
`# Verify artifact signature with cosign`,
|
||||
`cosign verify \\`,
|
||||
` --certificate-identity-regexp='.*' \\`,
|
||||
` --certificate-oidc-issuer-regexp='.*' \\`,
|
||||
` ${ref}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Rekor log retrieval command
|
||||
*/
|
||||
private buildRekorGetCommand(logIndex: number | string): string {
|
||||
return [
|
||||
`# Retrieve Rekor transparency log entry`,
|
||||
`rekor-cli get --log-index ${logIndex} --format json`,
|
||||
``,
|
||||
`# Alternative: verify inclusion proof`,
|
||||
`rekor-cli verify --log-index ${logIndex}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SBOM attestation verification command
|
||||
*/
|
||||
private buildSbomVerifyCommand(artifactRef?: string, sbomDigest?: string): string {
|
||||
const ref = artifactRef ?? '<image-ref>';
|
||||
return [
|
||||
`# Verify SBOM attestation`,
|
||||
`cosign verify-attestation \\`,
|
||||
` --type spdxjson \\`,
|
||||
` --certificate-identity-regexp='.*' \\`,
|
||||
` --certificate-oidc-issuer-regexp='.*' \\`,
|
||||
` ${ref}`,
|
||||
``,
|
||||
`# Expected SBOM digest: ${sbomDigest ?? 'N/A'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build attestation chain verification command
|
||||
*/
|
||||
private buildAttestationChainCommand(chain: readonly AocChainEntry[], artifactRef?: string): string {
|
||||
const ref = artifactRef ?? '<image-ref>';
|
||||
const attestationTypes = [...new Set(chain.map(e => e.type))].join(', ');
|
||||
return [
|
||||
`# Verify attestation chain (types: ${attestationTypes})`,
|
||||
`cosign verify-attestation \\`,
|
||||
` --type custom \\`,
|
||||
` --certificate-identity-regexp='.*' \\`,
|
||||
` --certificate-oidc-issuer-regexp='.*' \\`,
|
||||
` ${ref}`,
|
||||
``,
|
||||
`# Or use stellaops CLI for full chain verification:`,
|
||||
`stellaops evidence verify --artifact ${ref} --chain`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build policy decision verification command
|
||||
*/
|
||||
private buildPolicyVerifyCommand(policyId: string, decisionDigest: string): string {
|
||||
return [
|
||||
`# Verify policy decision attestation`,
|
||||
`stellaops policy verify \\`,
|
||||
` --policy-id ${policyId} \\`,
|
||||
` --decision-digest ${decisionDigest}`,
|
||||
``,
|
||||
`# Alternatively, use rekor to verify the decision was logged:`,
|
||||
`rekor-cli search --artifact ${decisionDigest}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy verification command to clipboard
|
||||
*/
|
||||
async copyVerificationCommand(commandId: string): Promise<void> {
|
||||
const commands = this.verificationCommands();
|
||||
const cmd = commands.find(c => c.id === commandId);
|
||||
if (!cmd) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd.command);
|
||||
this.verifyCommandCopied.set(commandId);
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => this.verifyCommandCopied.set(null), 2000);
|
||||
} catch {
|
||||
this.fallbackCopyToClipboard(cmd.command);
|
||||
this.verifyCommandCopied.set(commandId);
|
||||
setTimeout(() => this.verifyCommandCopied.set(null), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command was recently copied
|
||||
*/
|
||||
isCommandCopied(commandId: string): boolean {
|
||||
return this.verifyCommandCopied() === commandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track verification commands for ngFor
|
||||
*/
|
||||
trackByCommandId(_: number, cmd: VerificationCommand): string {
|
||||
return cmd.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification command model for "Verify locally" feature
|
||||
*/
|
||||
interface VerificationCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
359
src/Web/StellaOps.Web/tests/fixtures/ttfs/deterministic-fixtures.ts
vendored
Normal file
359
src/Web/StellaOps.Web/tests/fixtures/ttfs/deterministic-fixtures.ts
vendored
Normal file
@@ -0,0 +1,359 @@
|
||||
// =============================================================================
|
||||
// deterministic-fixtures.ts
|
||||
// Deterministic test fixtures for TTFS testing
|
||||
// Part of Task T15: Create deterministic test fixtures
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Frozen timestamp used across all fixtures.
|
||||
* ISO 8601 format: 2025-12-04T12:00:00.000Z
|
||||
*/
|
||||
export const FROZEN_TIMESTAMP = '2025-12-04T12:00:00.000Z';
|
||||
export const FROZEN_TIMESTAMP_MS = new Date(FROZEN_TIMESTAMP).getTime();
|
||||
|
||||
/**
|
||||
* Deterministic seed for reproducible random generation.
|
||||
*/
|
||||
export const DETERMINISTIC_SEED = 42;
|
||||
|
||||
/**
|
||||
* Pre-generated deterministic UUIDs.
|
||||
*/
|
||||
export const FIXTURE_IDS = {
|
||||
TENANT_ID: '11111111-1111-1111-1111-111111111111',
|
||||
RUN_ID: '22222222-2222-2222-2222-222222222222',
|
||||
JOB_ID: '33333333-3333-3333-3333-333333333333',
|
||||
SOURCE_ID: '44444444-4444-4444-4444-444444444444',
|
||||
SIGNATURE_ID: '55555555-5555-5555-5555-555555555555',
|
||||
TENANT_ID_STRING: 'test-tenant-deterministic',
|
||||
CORRELATION_ID: 'corr-deterministic-001',
|
||||
SIGNAL_ID: 'sig-deterministic-001',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Deterministic digest values.
|
||||
*/
|
||||
export const DIGESTS = {
|
||||
/** 64-character lowercase hex digest (SHA-256). */
|
||||
PAYLOAD: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
/** Image digest reference. */
|
||||
IMAGE: 'sha256:abc123def456789012345678901234567890123456789012345678901234abcd',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* FirstSignal kind values.
|
||||
*/
|
||||
export type FirstSignalKind =
|
||||
| 'queued'
|
||||
| 'started'
|
||||
| 'phase'
|
||||
| 'blocked'
|
||||
| 'failed'
|
||||
| 'succeeded'
|
||||
| 'canceled'
|
||||
| 'unavailable';
|
||||
|
||||
/**
|
||||
* FirstSignal phase values.
|
||||
*/
|
||||
export type FirstSignalPhase =
|
||||
| 'resolve'
|
||||
| 'fetch'
|
||||
| 'restore'
|
||||
| 'analyze'
|
||||
| 'policy'
|
||||
| 'report'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* FirstSignal scope interface.
|
||||
*/
|
||||
export interface FirstSignalScope {
|
||||
type: 'repo' | 'image' | 'artifact';
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LastKnownOutcome interface.
|
||||
*/
|
||||
export interface LastKnownOutcome {
|
||||
signatureId: string;
|
||||
errorCode?: string;
|
||||
token: string;
|
||||
excerpt?: string;
|
||||
confidence: 'low' | 'medium' | 'high';
|
||||
firstSeenAt: string;
|
||||
hitCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* NextAction interface.
|
||||
*/
|
||||
export interface NextAction {
|
||||
type: 'open_logs' | 'open_job' | 'docs' | 'retry' | 'cli_command';
|
||||
label: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FirstSignalDiagnostics interface.
|
||||
*/
|
||||
export interface FirstSignalDiagnostics {
|
||||
cacheHit: boolean;
|
||||
source: 'snapshot' | 'failure_index' | 'cold_start';
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FirstSignal interface.
|
||||
*/
|
||||
export interface FirstSignal {
|
||||
version: '1.0';
|
||||
signalId: string;
|
||||
jobId: string;
|
||||
timestamp: string;
|
||||
kind: FirstSignalKind;
|
||||
phase: FirstSignalPhase;
|
||||
scope: FirstSignalScope;
|
||||
summary: string;
|
||||
etaSeconds?: number;
|
||||
lastKnownOutcome?: LastKnownOutcome;
|
||||
nextActions?: NextAction[];
|
||||
diagnostics: FirstSignalDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-built FirstSignal fixtures for different scenarios.
|
||||
*/
|
||||
export const FIRST_SIGNAL_FIXTURES: Record<string, FirstSignal> = {
|
||||
queued: {
|
||||
version: '1.0',
|
||||
signalId: FIXTURE_IDS.SIGNAL_ID,
|
||||
jobId: FIXTURE_IDS.JOB_ID,
|
||||
timestamp: FROZEN_TIMESTAMP,
|
||||
kind: 'queued',
|
||||
phase: 'resolve',
|
||||
scope: { type: 'image', id: DIGESTS.IMAGE },
|
||||
summary: 'Job queued, waiting for available worker',
|
||||
etaSeconds: 120,
|
||||
diagnostics: {
|
||||
cacheHit: true,
|
||||
source: 'snapshot',
|
||||
correlationId: FIXTURE_IDS.CORRELATION_ID,
|
||||
},
|
||||
},
|
||||
|
||||
failed: {
|
||||
version: '1.0',
|
||||
signalId: FIXTURE_IDS.SIGNAL_ID,
|
||||
jobId: FIXTURE_IDS.JOB_ID,
|
||||
timestamp: FROZEN_TIMESTAMP,
|
||||
kind: 'failed',
|
||||
phase: 'analyze',
|
||||
scope: { type: 'image', id: DIGESTS.IMAGE },
|
||||
summary: 'Analysis failed: dependency resolution error',
|
||||
lastKnownOutcome: {
|
||||
signatureId: FIXTURE_IDS.SIGNATURE_ID,
|
||||
errorCode: 'EDEPNOTFOUND',
|
||||
token: 'EDEPNOTFOUND',
|
||||
excerpt: 'Could not resolve dependency @types/node@^18.0.0',
|
||||
confidence: 'high',
|
||||
firstSeenAt: '2025-12-01T10:00:00.000Z',
|
||||
hitCount: 15,
|
||||
},
|
||||
nextActions: [
|
||||
{ type: 'open_logs', label: 'View Logs', target: `/logs/${FIXTURE_IDS.JOB_ID}` },
|
||||
{ type: 'retry', label: 'Retry Job', target: `/retry/${FIXTURE_IDS.JOB_ID}` },
|
||||
],
|
||||
diagnostics: {
|
||||
cacheHit: false,
|
||||
source: 'failure_index',
|
||||
correlationId: FIXTURE_IDS.CORRELATION_ID,
|
||||
},
|
||||
},
|
||||
|
||||
succeeded: {
|
||||
version: '1.0',
|
||||
signalId: FIXTURE_IDS.SIGNAL_ID,
|
||||
jobId: FIXTURE_IDS.JOB_ID,
|
||||
timestamp: FROZEN_TIMESTAMP,
|
||||
kind: 'succeeded',
|
||||
phase: 'report',
|
||||
scope: { type: 'image', id: DIGESTS.IMAGE },
|
||||
summary: 'Scan completed: 3 critical, 12 high, 45 medium findings',
|
||||
nextActions: [
|
||||
{ type: 'open_job', label: 'View Results', target: `/jobs/${FIXTURE_IDS.JOB_ID}` },
|
||||
],
|
||||
diagnostics: {
|
||||
cacheHit: true,
|
||||
source: 'snapshot',
|
||||
correlationId: FIXTURE_IDS.CORRELATION_ID,
|
||||
},
|
||||
},
|
||||
|
||||
blocked: {
|
||||
version: '1.0',
|
||||
signalId: FIXTURE_IDS.SIGNAL_ID,
|
||||
jobId: FIXTURE_IDS.JOB_ID,
|
||||
timestamp: FROZEN_TIMESTAMP,
|
||||
kind: 'blocked',
|
||||
phase: 'policy',
|
||||
scope: { type: 'image', id: DIGESTS.IMAGE },
|
||||
summary: 'Blocked by policy: critical-vuln-gate',
|
||||
nextActions: [
|
||||
{ type: 'docs', label: 'Policy Details', target: '/docs/policies/critical-vuln-gate' },
|
||||
],
|
||||
diagnostics: {
|
||||
cacheHit: true,
|
||||
source: 'snapshot',
|
||||
correlationId: FIXTURE_IDS.CORRELATION_ID,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* API response fixtures for TTFS measurement.
|
||||
*/
|
||||
export const API_RESPONSE_FIXTURES = {
|
||||
firstSignalSuccess: (signal: FirstSignal) => ({
|
||||
runId: FIXTURE_IDS.RUN_ID,
|
||||
firstSignal: signal,
|
||||
summaryEtag: 'W/"deterministic-etag-001"',
|
||||
}),
|
||||
|
||||
firstSignalNotFound: {
|
||||
error: 'Run not found',
|
||||
code: 'RUN_NOT_FOUND',
|
||||
},
|
||||
|
||||
firstSignalUnavailable: {
|
||||
runId: FIXTURE_IDS.RUN_ID,
|
||||
firstSignal: null,
|
||||
summaryEtag: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Timing fixtures for TTFS measurement.
|
||||
*/
|
||||
export const TIMING_FIXTURES = {
|
||||
cacheHit: {
|
||||
ttfsMs: 120,
|
||||
cacheStatus: 'hit' as const,
|
||||
},
|
||||
coldStart: {
|
||||
ttfsMs: 850,
|
||||
cacheStatus: 'miss' as const,
|
||||
},
|
||||
sloBreachP50: {
|
||||
ttfsMs: 2500, // > 2000ms P50 target
|
||||
cacheStatus: 'miss' as const,
|
||||
},
|
||||
sloBreachP95: {
|
||||
ttfsMs: 6000, // > 5000ms P95 target
|
||||
cacheStatus: 'miss' as const,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Seeded random number generator for deterministic test data.
|
||||
*/
|
||||
export class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: number = DETERMINISTIC_SEED) {
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a pseudo-random number between 0 and 1.
|
||||
*/
|
||||
next(): number {
|
||||
// Simple LCG implementation
|
||||
this.seed = (this.seed * 1103515245 + 12345) % 2147483648;
|
||||
return this.seed / 2147483648;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a pseudo-random integer between 0 and max (exclusive).
|
||||
*/
|
||||
nextInt(max: number): number {
|
||||
return Math.floor(this.next() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic UUID.
|
||||
*/
|
||||
nextUuid(): string {
|
||||
const hex = () => this.nextInt(16).toString(16);
|
||||
return [
|
||||
Array(8).fill(0).map(hex).join(''),
|
||||
Array(4).fill(0).map(hex).join(''),
|
||||
'4' + Array(3).fill(0).map(hex).join(''),
|
||||
((this.nextInt(4) + 8).toString(16)) + Array(3).fill(0).map(hex).join(''),
|
||||
Array(12).fill(0).map(hex).join(''),
|
||||
].join('-');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jest/Vitest setup helper to freeze time and random.
|
||||
*/
|
||||
export function setupDeterministicEnvironment(): () => void {
|
||||
const originalDate = global.Date;
|
||||
const originalRandom = Math.random;
|
||||
const rng = new SeededRandom(DETERMINISTIC_SEED);
|
||||
|
||||
// Freeze Date
|
||||
const FrozenDate = class extends originalDate {
|
||||
constructor(...args: ConstructorParameters<typeof Date>) {
|
||||
if (args.length === 0) {
|
||||
super(FROZEN_TIMESTAMP_MS);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
|
||||
static now(): number {
|
||||
return FROZEN_TIMESTAMP_MS;
|
||||
}
|
||||
} as DateConstructor;
|
||||
|
||||
global.Date = FrozenDate;
|
||||
Math.random = () => rng.next();
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
global.Date = originalDate;
|
||||
Math.random = originalRandom;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright setup helper to freeze browser time.
|
||||
*/
|
||||
export async function setupPlaywrightDeterministic(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.addInitScript(`{
|
||||
const FROZEN_TIME = ${FROZEN_TIMESTAMP_MS};
|
||||
const OriginalDate = Date;
|
||||
|
||||
Date = class extends OriginalDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) {
|
||||
super(FROZEN_TIME);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
static now() { return FROZEN_TIME; }
|
||||
};
|
||||
|
||||
// Also freeze performance.now relative to start
|
||||
const perfStart = performance.now();
|
||||
const originalPerfNow = performance.now.bind(performance);
|
||||
performance.now = () => originalPerfNow() - perfStart;
|
||||
}`);
|
||||
|
||||
// Disable animations for deterministic screenshots
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}
|
||||
Reference in New Issue
Block a user