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

- 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:
master
2025-12-16 16:40:19 +02:00
parent 415eff1207
commit 2170a58734
206 changed files with 30547 additions and 534 deletions

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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 });
}
}

View File

@@ -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 {

View File

@@ -535,24 +535,24 @@
aria-label="Previous page"
>
&larr; 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>

View File

@@ -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;

View File

@@ -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;
}

View 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' });
}