save development progress

This commit is contained in:
StellaOps Bot
2025-12-25 23:09:58 +02:00
parent d71853ad7e
commit aa70af062e
351 changed files with 37683 additions and 150156 deletions

View File

@@ -15,6 +15,207 @@ export type PolicyRevisionStatus = 'draft' | 'approved' | 'active' | 'superseded
export type SimulationMode = 'quick' | 'full' | 'whatIf';
export type ProfileDifferenceType = 'added' | 'removed' | 'modified';
/**
* Source of cached data in policy evaluation.
* - 'none': No cache hit, freshly computed
* - 'inMemory': Retrieved from in-memory L1 cache
* - 'redis': Retrieved from Redis L2 cache (Provcache)
*/
export type CacheSource = 'none' | 'inMemory' | 'redis';
// ============================================================================
// Provcache / Trust Score Types
// ============================================================================
/**
* Individual component of trust score with its score and weight.
*/
export interface TrustScoreComponent {
/** Component score (0-100). */
score: number;
/** Weight of this component in the total score (0.0-1.0). */
weight: number;
}
/**
* Breakdown of trust score by component, showing contribution from each evidence type.
* The total trust score is computed as: sum(component.score * component.weight).
*/
export interface TrustScoreBreakdown {
/** Reachability evidence contribution (weight: 25%). Based on call graph / static analysis. */
reachability: TrustScoreComponent;
/** SBOM completeness contribution (weight: 20%). Based on package coverage and license data. */
sbomCompleteness: TrustScoreComponent;
/** VEX statement coverage contribution (weight: 20%). Based on vendor statements and OpenVEX. */
vexCoverage: TrustScoreComponent;
/** Policy freshness contribution (weight: 15%). Based on last policy update timestamp. */
policyFreshness: TrustScoreComponent;
/** Signer trust contribution (weight: 20%). Based on signer reputation and key age. */
signerTrust: TrustScoreComponent;
}
/**
* Identifiers needed to replay an evaluation with the same inputs.
*/
export interface ReplaySeed {
/** Advisory feed identifiers used in evaluation. */
feedIds: string[];
/** Policy rule identifiers used in evaluation. */
ruleIds: string[];
/** Optional frozen epoch timestamp for deterministic replay. */
frozenEpoch?: string;
}
/**
* Canonicalized representation of an evaluation result from Provcache.
*/
export interface DecisionDigest {
/** Schema version of this digest format. */
digestVersion: string;
/** Composite cache key that uniquely identifies the provenance decision context. */
veriKey: string;
/** Hash of sorted dispositions from the evaluation result. */
verdictHash: string;
/** Merkle root of all evidence chunks used in this decision. */
proofRoot: string;
/** Identifiers needed to replay the evaluation. */
replaySeed: ReplaySeed;
/** UTC timestamp when this digest was created. */
createdAt: string;
/** UTC timestamp when this digest expires. */
expiresAt: string;
/** Composite trust score (0-100) indicating decision confidence. */
trustScore: number;
/** Breakdown of trust score by component. */
trustScoreBreakdown?: TrustScoreBreakdown;
}
// ============================================================================
// Input Manifest Types (for VeriKey component transparency)
// ============================================================================
/**
* Source artifact information (container image, binary, etc.).
*/
export interface SourceArtifactInfo {
/** Content-addressed hash of the artifact (e.g., sha256:abc123...). */
digest: string;
/** Type of artifact (container-image, binary, archive, etc.). */
artifactType?: string;
/** OCI reference if applicable (e.g., ghcr.io/org/repo:tag). */
ociReference?: string;
/** Size of the artifact in bytes. */
sizeBytes?: number;
}
/**
* SBOM information.
*/
export interface SbomInfo {
/** Canonical hash of the SBOM content. */
hash: string;
/** SBOM format (spdx-2.3, cyclonedx-1.6, etc.). */
format?: string;
/** Number of packages in the SBOM. */
packageCount?: number;
/** Completeness percentage (0-100). */
completenessScore?: number;
/** When the SBOM was created or last updated. */
createdAt?: string;
}
/**
* VEX statement information.
*/
export interface VexInfo {
/** Hash of the sorted VEX statement set. */
hashSetHash: string;
/** Number of VEX statements contributing to this decision. */
statementCount: number;
/** Sources of VEX statements (vendor names, OpenVEX IDs, etc.). */
sources?: string[];
/** Most recent VEX statement timestamp. */
latestStatementAt?: string;
}
/**
* Policy information.
*/
export interface PolicyInfoManifest {
/** Canonical hash of the policy bundle. */
hash: string;
/** Policy pack identifier. */
packId?: string;
/** Policy version number. */
version?: number;
/** When the policy was last updated. */
lastUpdatedAt?: string;
/** Human-readable policy name. */
name?: string;
}
/**
* Signer/attestor information.
*/
export interface SignerInfo {
/** Hash of the sorted signer set. */
setHash: string;
/** Number of signers in the set. */
signerCount: number;
/** Signer certificate information. */
certificates?: SignerCertificate[];
}
/**
* Signer certificate information.
*/
export interface SignerCertificate {
/** Subject of the certificate (e.g., CN=...). */
subject?: string;
/** Certificate issuer. */
issuer?: string;
/** Certificate serial number or fingerprint. */
fingerprint?: string;
/** When the certificate expires. */
expiresAt?: string;
/** Trust level (fulcio, self-signed, enterprise-ca, etc.). */
trustLevel?: string;
}
/**
* Time window information.
*/
export interface TimeWindowInfo {
/** The time window bucket identifier. */
bucket: string;
/** Start of the time window (UTC). */
startsAt?: string;
/** End of the time window (UTC). */
endsAt?: string;
}
/**
* Manifest showing the exact inputs that form a VeriKey and cached decision.
*/
export interface InputManifest {
/** The VeriKey this manifest describes. */
veriKey: string;
/** Information about the source artifact. */
sourceArtifact: SourceArtifactInfo;
/** Information about the SBOM used in the decision. */
sbom: SbomInfo;
/** Information about VEX statements. */
vex: VexInfo;
/** Information about the policy. */
policy: PolicyInfoManifest;
/** Information about signers/attestors. */
signers: SignerInfo;
/** Time window information. */
timeWindow: TimeWindowInfo;
/** When the manifest was generated. */
generatedAt: string;
}
export interface ProblemDetails {
type?: string;
title?: string;
@@ -539,6 +740,11 @@ export interface PolicyEvaluationResponse {
result: Record<string, unknown>;
deterministic?: boolean;
cacheHit?: boolean;
/**
* Source of cached data: 'none' | 'inMemory' | 'redis'.
* Present when cacheHit is true to indicate which cache tier served the response.
*/
cacheSource?: CacheSource;
executionTimeMs?: number;
}

View File

@@ -26,6 +26,18 @@ export interface FindingEvidenceResponse {
/** Whether the evidence has exceeded its TTL and is considered stale. */
readonly is_stale?: boolean;
readonly attestation_refs?: readonly string[];
// Provcache fields (Sprint 8200.0001.0003)
/** Cache source tier for this finding's decision (none, inMemory, redis) */
readonly cache_source?: 'none' | 'inMemory' | 'redis';
/** VeriKey for the cached decision */
readonly veri_key?: string;
/** Trust score for the cached decision (0-100) */
readonly trust_score?: number;
/** Age of the cache entry in seconds */
readonly cache_age_seconds?: number;
/** Execution time of the policy evaluation in milliseconds */
readonly execution_time_ms?: number;
}
/**

View File

@@ -0,0 +1,510 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FindingDetailComponent, FindingDetail, FindingDetailTab } from './finding-detail.component';
describe('FindingDetailComponent', () => {
let component: FindingDetailComponent;
let fixture: ComponentFixture<FindingDetailComponent>;
const mockFinding: FindingDetail = {
finding_id: 'find-001',
cve: 'CVE-2024-1234',
component: {
name: 'lodash',
version: '4.17.20',
purl: 'pkg:npm/lodash@4.17.20',
type: 'npm',
},
reachable_path: [
'src/index.js:main()',
'src/utils/helper.js:processData()',
'node_modules/lodash/get.js:get()',
],
entrypoint: {
type: 'http',
route: '/api/v1/data',
method: 'POST',
auth: 'required',
fqn: 'src/api/data.handler.ts:postData',
},
vex: {
status: 'affected',
justification: 'vulnerable_code_in_use',
source: 'vendor',
issued_at: '2024-01-15T12:00:00Z',
},
boundary: {
kind: 'container',
surface: { type: 'network' },
exposure: { level: 'external', internet_facing: true },
last_seen: '2024-06-01T10:00:00Z',
confidence: 0.95,
},
score_explain: {
kind: 'cvss',
risk_score: 7.5,
last_seen: '2024-06-01T10:30:00Z',
summary: 'High severity network vulnerability',
},
attestation_refs: ['sha256:attestation1', 'sha256:attestation2'],
last_seen: '2024-06-01T10:30:00Z',
expires_at: '2024-07-01T10:30:00Z',
is_stale: false,
trust_score: 85,
veri_key: 'vk:sha256:xyz789',
cache_source: 'redis',
cache_age_seconds: 600,
trustScoreBreakdown: {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 85, weight: 0.20 },
vexCoverage: { score: 80, weight: 0.20 },
policyFreshness: { score: 90, weight: 0.15 },
signerTrust: { score: 85, weight: 0.20 },
},
decisionDigest: {
digestVersion: '1.0.0',
veriKey: 'vk:sha256:xyz789',
verdictHash: 'sha256:output456',
proofRoot: 'sha256:root123',
replaySeed: {
feedIds: ['nvd-2024'],
ruleIds: ['policy-critical'],
},
createdAt: '2024-06-01T10:30:00Z',
expiresAt: '2024-07-01T10:30:00Z',
trustScore: 85,
},
inputManifest: {
veriKey: 'vk:sha256:xyz789',
sourceArtifact: {
digest: 'sha256:image123',
artifactType: 'container-image',
},
sbom: {
hash: 'sha256:sbom123',
format: 'cyclonedx-1.6',
packageCount: 150,
},
vex: {
hashSetHash: 'sha256:vex123',
statementCount: 5,
},
policy: {
hash: 'sha256:policy123',
packId: 'default-policy',
version: 1,
},
signers: {
setHash: 'sha256:signers123',
signerCount: 2,
},
timeWindow: {
bucket: '2024-06-01T00:00:00Z',
},
generatedAt: '2024-06-01T10:30:00Z',
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FindingDetailComponent],
}).compileComponents();
fixture = TestBed.createComponent(FindingDetailComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('header display', () => {
it('should display CVE ID', () => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
const cve = fixture.nativeElement.querySelector('.finding-detail__cve-link');
expect(cve.textContent).toContain('CVE-2024-1234');
});
it('should display component name and version', () => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
const name = fixture.nativeElement.querySelector('.finding-detail__component-name');
const version = fixture.nativeElement.querySelector('.finding-detail__component-version');
expect(name.textContent).toContain('lodash');
expect(version.textContent).toContain('4.17.20');
});
it('should link to NVD for CVE', () => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.finding-detail__cve-link');
expect(link.getAttribute('href')).toBe('https://nvd.nist.gov/vuln/detail/CVE-2024-1234');
});
it('should show provenance badge when cache data exists', () => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('stellaops-provenance-badge');
expect(badge).toBeTruthy();
});
});
describe('tab navigation', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
});
it('should render all tabs', () => {
const tabs = fixture.nativeElement.querySelectorAll('.finding-detail__tab');
expect(tabs.length).toBe(5);
});
it('should show overview tab by default', () => {
fixture.componentRef.setInput('activeTab', 'overview');
fixture.detectChanges();
const activeTab = fixture.nativeElement.querySelector('.finding-detail__tab--active');
expect(activeTab.textContent).toContain('Overview');
});
it('should emit tabChange when tab is clicked', () => {
const tabChangeSpy = jasmine.createSpy('tabChange');
component.tabChange.subscribe(tabChangeSpy);
const evidenceTab = fixture.nativeElement.querySelectorAll('.finding-detail__tab')[1];
evidenceTab.click();
expect(tabChangeSpy).toHaveBeenCalledWith('evidence');
});
it('should show evidence count badge', () => {
const badge = fixture.nativeElement.querySelector('.finding-detail__tab-badge');
expect(badge.textContent).toBe('2');
});
});
describe('overview tab', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.componentRef.setInput('activeTab', 'overview');
fixture.detectChanges();
});
it('should render trust score display', () => {
const trustScore = fixture.nativeElement.querySelector('stellaops-trust-score-display');
expect(trustScore).toBeTruthy();
});
it('should render reachability section', () => {
const section = fixture.nativeElement.querySelector('.finding-detail__section--reachability');
expect(section).toBeTruthy();
});
it('should display call path', () => {
const pathList = fixture.nativeElement.querySelector('.finding-detail__path-list');
expect(pathList).toBeTruthy();
const steps = pathList.querySelectorAll('.finding-detail__path-step');
expect(steps.length).toBe(3);
});
it('should display entrypoint info', () => {
const entrypoint = fixture.nativeElement.querySelector('.finding-detail__entrypoint');
expect(entrypoint).toBeTruthy();
expect(entrypoint.textContent).toContain('/api/v1/data');
expect(entrypoint.textContent).toContain('POST');
});
it('should display VEX status', () => {
const vexSection = fixture.nativeElement.querySelector('.finding-detail__section--vex');
expect(vexSection.textContent).toContain('affected');
expect(vexSection.textContent).toContain('vulnerable_code_in_use');
});
it('should display metadata', () => {
const metaSection = fixture.nativeElement.querySelector('.finding-detail__section--meta');
expect(metaSection.textContent).toContain('find-001');
});
});
describe('evidence tab', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.componentRef.setInput('activeTab', 'evidence');
fixture.detectChanges();
});
it('should display attestation references', () => {
const attestations = fixture.nativeElement.querySelectorAll('.finding-detail__attestation-item');
expect(attestations.length).toBe(2);
});
it('should display boundary info', () => {
const boundary = fixture.nativeElement.querySelector('.finding-detail__boundary');
expect(boundary).toBeTruthy();
expect(boundary.textContent).toContain('container');
expect(boundary.textContent).toContain('sha256:abc123');
});
it('should have copy buttons for attestation refs', () => {
const copyBtns = fixture.nativeElement.querySelectorAll('.finding-detail__copy-btn');
expect(copyBtns.length).toBe(2);
});
});
describe('proof tab', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.componentRef.setInput('activeTab', 'proof');
fixture.detectChanges();
});
it('should render proof tree component when digest exists', () => {
const proofTree = fixture.nativeElement.querySelector('stellaops-proof-tree');
expect(proofTree).toBeTruthy();
});
it('should show empty message when no digest', () => {
const findingNoDigest = { ...mockFinding, decisionDigest: undefined };
fixture.componentRef.setInput('finding', findingNoDigest);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.finding-detail__empty');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No decision digest');
});
it('should emit verify when proof tree requests verification', () => {
const verifySpy = jasmine.createSpy('verify');
component.verify.subscribe(verifySpy);
component.onVerify();
expect(verifySpy).toHaveBeenCalled();
});
});
describe('manifest tab', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.componentRef.setInput('activeTab', 'manifest');
fixture.detectChanges();
});
it('should render input manifest component when manifest exists', () => {
const manifest = fixture.nativeElement.querySelector('stellaops-input-manifest');
expect(manifest).toBeTruthy();
});
it('should show empty message when no manifest', () => {
const findingNoManifest = { ...mockFinding, inputManifest: undefined };
fixture.componentRef.setInput('finding', findingNoManifest);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.finding-detail__empty');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No input manifest');
});
it('should emit refreshManifest when refresh is triggered', () => {
const refreshSpy = jasmine.createSpy('refreshManifest');
component.refreshManifest.subscribe(refreshSpy);
component.onRefreshManifest();
expect(refreshSpy).toHaveBeenCalled();
});
});
describe('history tab', () => {
it('should show placeholder message', () => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.componentRef.setInput('activeTab', 'history');
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.finding-detail__empty');
expect(empty.textContent).toContain('coming soon');
});
});
describe('computed properties', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
});
it('should compute reachability state as reachable when path exists', () => {
expect(component.reachabilityState()).toBe('reachable');
});
it('should compute reachability state as reachable when only entrypoint exists', () => {
const findingNoPath = { ...mockFinding, reachable_path: [] };
fixture.componentRef.setInput('finding', findingNoPath);
fixture.detectChanges();
// Component returns 'reachable' when entrypoint exists (even without path)
expect(component.reachabilityState()).toBe('reachable');
});
it('should compute provenance state as cached when redis source', () => {
expect(component.provenanceState()).toBe('cached');
});
it('should compute cache details correctly', () => {
const details = component.cacheDetails();
expect(details?.veriKey).toBe('vk:sha256:xyz789');
expect(details?.source).toBe('redis');
expect(details?.ageSeconds).toBe(600);
});
it('should compute VEX status', () => {
expect(component.vexStatus()).toBe('affected');
});
it('should compute attestation count', () => {
expect(component.attestationCount()).toBe('2');
});
});
describe('event handling', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
});
it('should emit copyVeriKey when veriKey is copied', () => {
const copySpy = jasmine.createSpy('copyVeriKey');
component.copyVeriKey.subscribe(copySpy);
component.onCopyVeriKey('vk:sha256:test');
expect(copySpy).toHaveBeenCalledWith('vk:sha256:test');
});
it('should emit copyHash when hash is copied', () => {
const copySpy = jasmine.createSpy('copyHash');
component.copyHash.subscribe(copySpy);
component.onCopyHash('sha256:test');
expect(copySpy).toHaveBeenCalledWith('sha256:test');
});
it('should navigate to proof tab when provenance badge is clicked', () => {
const tabChangeSpy = jasmine.createSpy('tabChange');
component.tabChange.subscribe(tabChangeSpy);
component.onViewProofTree();
expect(tabChangeSpy).toHaveBeenCalledWith('proof');
});
});
describe('date formatting', () => {
it('should format valid ISO date', () => {
const formatted = component.formatDate('2024-06-01T10:30:00Z');
expect(formatted).toContain('Jun');
expect(formatted).toContain('2024');
});
it('should return dash for undefined date', () => {
expect(component.formatDate(undefined)).toBe('—');
});
it('should return original string for invalid date', () => {
expect(component.formatDate('not-a-date')).toBe('not-a-date');
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
});
it('should have main role on article', () => {
const article = fixture.nativeElement.querySelector('.finding-detail');
expect(article.getAttribute('role')).toBe('main');
});
it('should have aria-label on article', () => {
const article = fixture.nativeElement.querySelector('.finding-detail');
expect(article.getAttribute('aria-label')).toContain('CVE-2024-1234');
});
it('should have tablist role on nav', () => {
const nav = fixture.nativeElement.querySelector('.finding-detail__tabs');
expect(nav.getAttribute('role')).toBe('tablist');
});
it('should have tab role on buttons', () => {
const tabs = fixture.nativeElement.querySelectorAll('.finding-detail__tab');
tabs.forEach((tab: HTMLElement) => {
expect(tab.getAttribute('role')).toBe('tab');
});
});
it('should have aria-selected on active tab', () => {
const activeTab = fixture.nativeElement.querySelector('.finding-detail__tab--active');
expect(activeTab.getAttribute('aria-selected')).toBe('true');
});
it('should have tabpanel role on panel', () => {
const panel = fixture.nativeElement.querySelector('.finding-detail__panel');
expect(panel.getAttribute('role')).toBe('tabpanel');
});
});
describe('edge cases', () => {
it('should handle null finding gracefully', () => {
fixture.componentRef.setInput('finding', null);
fixture.detectChanges();
const cve = fixture.nativeElement.querySelector('.finding-detail__cve-link');
expect(cve.textContent).toContain('Unknown CVE');
});
it('should handle missing optional fields', () => {
const minimalFinding: FindingDetail = {
finding_id: 'find-002',
cve: 'CVE-2024-5678',
component: { purl: 'pkg:npm/axios@1.0.0', name: 'axios', version: '1.0.0', type: 'npm' },
last_seen: '2024-06-01T10:30:00Z',
is_stale: false,
};
fixture.componentRef.setInput('finding', minimalFinding);
fixture.componentRef.setInput('activeTab', 'overview');
fixture.detectChanges();
const emptyMessages = fixture.nativeElement.querySelectorAll('.finding-detail__empty');
expect(emptyMessages.length).toBeGreaterThan(0);
});
it('should not show provenance badge when no cache data', () => {
const findingNoCache = {
...mockFinding,
veri_key: undefined,
cache_source: undefined,
};
fixture.componentRef.setInput('finding', findingNoCache);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('stellaops-provenance-badge');
expect(badge).toBeFalsy();
});
it('should show stale warning when is_stale is true', () => {
const staleFinding = { ...mockFinding, is_stale: true };
fixture.componentRef.setInput('finding', staleFinding);
fixture.componentRef.setInput('activeTab', 'overview');
fixture.detectChanges();
const staleWarning = fixture.nativeElement.querySelector('.finding-detail__stale');
expect(staleWarning).toBeTruthy();
expect(staleWarning.textContent).toContain('Stale');
});
});
});

View File

@@ -0,0 +1,879 @@
/**
* Finding Detail Component.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-214 - Integrate TrustScoreDisplay into FindingDetailComponent
* Task: PROV-8200-230 - Integrate InputManifest into FindingDetailComponent via tab
*
* Full-page detail view for a vulnerability finding with comprehensive evidence,
* trust score visualization, proof tree, and input manifest tabs.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.models';
import type { DecisionDigest, TrustScoreBreakdown, InputManifest, CacheSource } from '../../core/api/policy-engine.models';
import type { MerkleTree } from '../../core/api/proof.models';
import { ReachabilityChipComponent, ReachabilityState } from './reachability-chip.component';
import { VexStatusChipComponent } from './vex-status-chip.component';
import { ScoreBreakdownComponent } from './score-breakdown.component';
import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
import { TrustScoreDisplayComponent } from './trust-score.component';
import { ProofTreeComponent, VerdictEntry, EvidenceChunk } from './proof-tree.component';
import { InputManifestComponent, InputManifestMode } from './input-manifest.component';
/**
* Active tab in the finding detail view.
*/
export type FindingDetailTab = 'overview' | 'evidence' | 'proof' | 'manifest' | 'history';
/**
* Extended finding data including provcache details.
*/
export interface FindingDetail extends FindingEvidenceResponse {
/** Decision digest from Provcache */
readonly decisionDigest?: DecisionDigest;
/** Trust score breakdown */
readonly trustScoreBreakdown?: TrustScoreBreakdown;
/** Input manifest for VeriKey components */
readonly inputManifest?: InputManifest;
/** Merkle tree structure for proof visualization */
readonly merkleTree?: MerkleTree;
/** Verdict entries for the proof tree */
readonly verdicts?: readonly VerdictEntry[];
/** Evidence chunks */
readonly evidenceChunks?: readonly EvidenceChunk[];
}
/**
* Full-page finding detail component.
*
* Features:
* - Tabbed interface: Overview, Evidence, Proof Tree, Manifest, History
* - Trust score visualization with breakdown
* - Proof tree with Merkle verification
* - Input manifest showing VeriKey components
* - Evidence drawer integration
*
* @example
* <stellaops-finding-detail
* [finding]="findingDetail"
* [activeTab]="'overview'"
* (tabChange)="onTabChange($event)"
* (viewEvidence)="openDrawer($event)"
* (verify)="verifyProof($event)"
* />
*/
@Component({
selector: 'stellaops-finding-detail',
standalone: true,
imports: [
CommonModule,
ReachabilityChipComponent,
VexStatusChipComponent,
ScoreBreakdownComponent,
ChainStatusBadgeComponent,
ProvenanceBadgeComponent,
TrustScoreDisplayComponent,
ProofTreeComponent,
InputManifestComponent,
],
template: `
<article class="finding-detail" role="main" [attr.aria-label]="ariaLabel()">
<!-- Header -->
<header class="finding-detail__header">
<div class="finding-detail__title-row">
<h1 class="finding-detail__cve">
<a
[href]="cveLink()"
target="_blank"
rel="noopener noreferrer"
class="finding-detail__cve-link"
>
{{ finding()?.cve ?? 'Unknown CVE' }}
</a>
</h1>
<!-- Provenance Badge -->
@if (showProvenanceBadge()) {
<stellaops-provenance-badge
[state]="provenanceState()"
[cacheDetails]="cacheDetails()"
[trustScore]="finding()?.trust_score"
[clickable]="true"
(clicked)="onViewProofTree()"
/>
}
</div>
<div class="finding-detail__component">
<span class="finding-detail__component-name">{{ componentName() }}</span>
<span class="finding-detail__component-version">&#64;{{ componentVersion() }}</span>
</div>
<!-- Quick Stats -->
<div class="finding-detail__stats">
<div class="finding-detail__stat">
<stella-reachability-chip
[state]="reachabilityState()"
[pathDepth]="callPath().length"
[showLabel]="true"
/>
</div>
<div class="finding-detail__stat">
<stella-vex-status-chip
[status]="vexStatus()"
[justification]="finding()?.vex?.justification"
[showLabel]="true"
/>
</div>
<div class="finding-detail__stat">
<stella-score-breakdown
[explanation]="finding()?.score_explain"
[mode]="'full'"
/>
</div>
</div>
</header>
<!-- Tab Navigation -->
<nav class="finding-detail__tabs" role="tablist" aria-label="Finding details tabs">
@for (tab of tabs; track tab.id) {
<button
class="finding-detail__tab"
[class.finding-detail__tab--active]="activeTab() === tab.id"
[attr.aria-selected]="activeTab() === tab.id"
[attr.aria-controls]="'panel-' + tab.id"
role="tab"
type="button"
(click)="setActiveTab(tab.id)"
>
<span class="finding-detail__tab-icon" aria-hidden="true">{{ tab.icon }}</span>
<span class="finding-detail__tab-label">{{ tab.label }}</span>
@if (tab.badge) {
<span class="finding-detail__tab-badge">{{ tab.badge() }}</span>
}
</button>
}
</nav>
<!-- Tab Panels -->
<div class="finding-detail__panels">
<!-- Overview Tab -->
@if (activeTab() === 'overview') {
<section
id="panel-overview"
class="finding-detail__panel"
role="tabpanel"
aria-labelledby="tab-overview"
>
<div class="finding-detail__overview-grid">
<!-- Trust Score Section -->
@if (hasTrustScore()) {
<div class="finding-detail__section finding-detail__section--trust">
<h2 class="finding-detail__section-title">Trust Score</h2>
<stellaops-trust-score-display
[score]="finding()?.trust_score ?? 0"
[breakdown]="finding()?.trustScoreBreakdown ?? null"
[mode]="'donut'"
[showBreakdown]="true"
/>
</div>
}
<!-- Reachability Section -->
<div class="finding-detail__section finding-detail__section--reachability">
<h2 class="finding-detail__section-title">Reachability</h2>
@if (callPath().length > 0) {
<div class="finding-detail__call-path">
<h3 class="finding-detail__subsection-title">Call Path</h3>
<ol class="finding-detail__path-list">
@for (step of callPath(); track $index) {
<li class="finding-detail__path-step">{{ step }}</li>
}
</ol>
</div>
} @else {
<p class="finding-detail__empty">No reachability data available.</p>
}
@if (hasEntrypoint()) {
<div class="finding-detail__entrypoint">
<h3 class="finding-detail__subsection-title">Entrypoint</h3>
<dl class="finding-detail__dl">
<dt>Type</dt>
<dd>{{ finding()?.entrypoint?.type ?? '—' }}</dd>
@if (finding()?.entrypoint?.route) {
<dt>Route</dt>
<dd><code>{{ finding()?.entrypoint?.route }}</code></dd>
}
@if (finding()?.entrypoint?.method) {
<dt>Method</dt>
<dd>{{ finding()?.entrypoint?.method }}</dd>
}
@if (finding()?.entrypoint?.auth) {
<dt>Auth</dt>
<dd>{{ finding()?.entrypoint?.auth }}</dd>
}
</dl>
</div>
}
</div>
<!-- VEX Section -->
<div class="finding-detail__section finding-detail__section--vex">
<h2 class="finding-detail__section-title">VEX Status</h2>
@if (finding()?.vex) {
<dl class="finding-detail__dl">
<dt>Status</dt>
<dd class="finding-detail__vex-status">{{ finding()?.vex?.status ?? '—' }}</dd>
@if (finding()?.vex?.justification) {
<dt>Justification</dt>
<dd>{{ finding()?.vex?.justification }}</dd>
}
@if (finding()?.vex?.source) {
<dt>Source</dt>
<dd>{{ finding()?.vex?.source }}</dd>
}
@if (finding()?.vex?.published_at) {
<dt>Published</dt>
<dd>{{ formatDate(finding()?.vex?.published_at) }}</dd>
}
</dl>
} @else {
<p class="finding-detail__empty">No VEX data available.</p>
}
</div>
<!-- Metadata Section -->
<div class="finding-detail__section finding-detail__section--meta">
<h2 class="finding-detail__section-title">Metadata</h2>
<dl class="finding-detail__dl">
<dt>Finding ID</dt>
<dd><code>{{ finding()?.finding_id ?? '—' }}</code></dd>
<dt>Last Seen</dt>
<dd>{{ formatDate(finding()?.last_seen) }}</dd>
@if (finding()?.expires_at) {
<dt>Expires</dt>
<dd>{{ formatDate(finding()?.expires_at) }}</dd>
}
@if (finding()?.is_stale) {
<dt>Status</dt>
<dd class="finding-detail__stale">⚠️ Stale</dd>
}
</dl>
</div>
</div>
</section>
}
<!-- Evidence Tab -->
@if (activeTab() === 'evidence') {
<section
id="panel-evidence"
class="finding-detail__panel"
role="tabpanel"
aria-labelledby="tab-evidence"
>
<h2 class="finding-detail__section-title">Evidence & Attestations</h2>
@if (attestationRefs().length > 0) {
<div class="finding-detail__attestations">
<h3 class="finding-detail__subsection-title">Attestation References</h3>
<ul class="finding-detail__attestation-list">
@for (ref of attestationRefs(); track ref) {
<li class="finding-detail__attestation-item">
<code>{{ ref }}</code>
<button
class="finding-detail__copy-btn"
(click)="onCopyAttestationRef(ref)"
title="Copy reference"
type="button"
>
📋
</button>
</li>
}
</ul>
</div>
} @else {
<p class="finding-detail__empty">No attestation references available.</p>
}
@if (hasBoundary()) {
<div class="finding-detail__boundary">
<h3 class="finding-detail__subsection-title">Boundary Proof</h3>
<dl class="finding-detail__dl">
<dt>Boundary Type</dt>
<dd>{{ finding()?.boundary?.boundary_type ?? '—' }}</dd>
@if (finding()?.boundary?.container_id) {
<dt>Container</dt>
<dd><code>{{ finding()?.boundary?.container_id }}</code></dd>
}
@if (finding()?.boundary?.namespace) {
<dt>Namespace</dt>
<dd>{{ finding()?.boundary?.namespace }}</dd>
}
</dl>
</div>
}
</section>
}
<!-- Proof Tree Tab -->
@if (activeTab() === 'proof') {
<section
id="panel-proof"
class="finding-detail__panel"
role="tabpanel"
aria-labelledby="tab-proof"
>
@if (finding()?.decisionDigest) {
<stellaops-proof-tree
[digest]="finding()?.decisionDigest ?? null"
[merkleTree]="finding()?.merkleTree ?? null"
[verdicts]="finding()?.verdicts ?? []"
[evidenceChunks]="finding()?.evidenceChunks ?? []"
[isVerifying]="isVerifying()"
(verify)="onVerify()"
(copyVeriKey)="onCopyVeriKey($event)"
(copyHash)="onCopyHash($event)"
(downloadEvidence)="onDownloadEvidence($event)"
/>
} @else {
<p class="finding-detail__empty">
No decision digest available. This finding may not have been cached.
</p>
}
</section>
}
<!-- Manifest Tab -->
@if (activeTab() === 'manifest') {
<section
id="panel-manifest"
class="finding-detail__panel"
role="tabpanel"
aria-labelledby="tab-manifest"
>
@if (finding()?.inputManifest) {
<stellaops-input-manifest
[manifest]="finding()?.inputManifest ?? null"
[mode]="'full'"
(copyVeriKey)="onCopyVeriKey($event)"
(refresh)="onRefreshManifest()"
/>
} @else {
<p class="finding-detail__empty">
No input manifest available. This finding may not have provenance data.
</p>
}
</section>
}
<!-- History Tab -->
@if (activeTab() === 'history') {
<section
id="panel-history"
class="finding-detail__panel"
role="tabpanel"
aria-labelledby="tab-history"
>
<p class="finding-detail__empty">
Finding history timeline coming soon.
</p>
</section>
}
</div>
</article>
`,
styles: [`
.finding-detail {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.finding-detail__header {
padding: 1.5rem 2rem;
background: linear-gradient(to bottom, #f8f9fa, #fff);
border-bottom: 1px solid #e9ecef;
}
.finding-detail__title-row {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.finding-detail__cve {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #212529;
}
.finding-detail__cve-link {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
color: #007bff;
}
}
.finding-detail__component {
margin-top: 0.25rem;
font-size: 1rem;
color: #6c757d;
}
.finding-detail__component-name {
font-weight: 500;
}
.finding-detail__component-version {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
font-size: 0.875rem;
}
.finding-detail__stats {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.finding-detail__stat {
display: flex;
align-items: center;
}
.finding-detail__tabs {
display: flex;
border-bottom: 1px solid #e9ecef;
padding: 0 1rem;
background: #fff;
overflow-x: auto;
}
.finding-detail__tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: #6c757d;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
&:hover {
color: #495057;
}
&--active {
color: #007bff;
border-bottom-color: #007bff;
}
&:focus {
outline: none;
background: rgba(0, 123, 255, 0.05);
}
}
.finding-detail__tab-icon {
font-size: 1rem;
}
.finding-detail__tab-badge {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
background: #e9ecef;
color: #495057;
}
.finding-detail__panels {
padding: 1.5rem 2rem;
min-height: 400px;
}
.finding-detail__panel {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.finding-detail__overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.finding-detail__section {
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.finding-detail__section--trust {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.finding-detail__section-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #212529;
}
.finding-detail__subsection-title {
margin: 1rem 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #495057;
&:first-child {
margin-top: 0;
}
}
.finding-detail__dl {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
font-size: 0.875rem;
dt {
color: #6c757d;
}
dd {
margin: 0;
color: #212529;
}
code {
font-size: 0.8125rem;
background: #e9ecef;
padding: 0.125rem 0.25rem;
border-radius: 3px;
}
}
.finding-detail__path-list {
margin: 0;
padding-left: 1.5rem;
font-size: 0.8125rem;
font-family: 'SF Mono', 'Monaco', monospace;
color: #495057;
}
.finding-detail__path-step {
margin-bottom: 0.25rem;
word-break: break-all;
}
.finding-detail__empty {
color: #6c757d;
font-style: italic;
font-size: 0.875rem;
}
.finding-detail__stale {
color: #dc3545;
}
.finding-detail__vex-status {
font-weight: 500;
text-transform: capitalize;
}
.finding-detail__attestation-list {
margin: 0;
padding: 0;
list-style: none;
}
.finding-detail__attestation-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: #fff;
border: 1px solid #e9ecef;
border-radius: 4px;
margin-bottom: 0.5rem;
code {
flex: 1;
font-size: 0.75rem;
word-break: break-all;
}
}
.finding-detail__copy-btn {
flex-shrink: 0;
padding: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #e9ecef;
}
}
@media (prefers-color-scheme: dark) {
.finding-detail {
background: #1e1e1e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.finding-detail__header {
background: linear-gradient(to bottom, #252525, #1e1e1e);
border-bottom-color: #333;
}
.finding-detail__cve {
color: #f5f5f5;
}
.finding-detail__cve-link:hover {
color: #4fc3f7;
}
.finding-detail__tabs {
background: #1e1e1e;
border-bottom-color: #333;
}
.finding-detail__tab {
color: #adb5bd;
&:hover {
color: #f5f5f5;
}
&--active {
color: #4fc3f7;
border-bottom-color: #4fc3f7;
}
&:focus {
background: rgba(79, 195, 247, 0.1);
}
}
.finding-detail__tab-badge {
background: #333;
color: #adb5bd;
}
.finding-detail__section {
background: #252525;
}
.finding-detail__section-title {
color: #f5f5f5;
}
.finding-detail__dl {
dd {
color: #f5f5f5;
}
code {
background: #333;
}
}
.finding-detail__attestation-item {
background: #252525;
border-color: #333;
}
.finding-detail__copy-btn:hover {
background: #333;
}
}
`],
})
export class FindingDetailComponent {
// Inputs
readonly finding = input<FindingDetail | null>(null);
readonly activeTab = input<FindingDetailTab>('overview');
readonly isVerifying = input<boolean>(false);
// Outputs
readonly tabChange = output<FindingDetailTab>();
readonly viewEvidence = output<FindingDetail>();
readonly verify = output<void>();
readonly copyVeriKey = output<string>();
readonly copyHash = output<string>();
readonly downloadEvidence = output<EvidenceChunk>();
readonly refreshManifest = output<void>();
// Tab definitions
readonly tabs: { id: FindingDetailTab; label: string; icon: string; badge?: () => string | null }[] = [
{ id: 'overview', label: 'Overview', icon: '📊' },
{ id: 'evidence', label: 'Evidence', icon: '📋', badge: () => this.attestationCount() },
{ id: 'proof', label: 'Proof Tree', icon: '🌳' },
{ id: 'manifest', label: 'Manifest', icon: '📝' },
{ id: 'history', label: 'History', icon: '📅' },
];
// Computed properties
readonly ariaLabel = computed(() => {
const f = this.finding();
return f ? `Finding detail for ${f.cve}` : 'Finding detail';
});
readonly componentName = computed(() => {
return this.finding()?.component?.name ?? 'Unknown';
});
readonly componentVersion = computed(() => {
return this.finding()?.component?.version ?? '?';
});
readonly cveLink = computed(() => {
const cve = this.finding()?.cve;
if (!cve) return '#';
return `https://nvd.nist.gov/vuln/detail/${cve}`;
});
readonly callPath = computed(() => {
return this.finding()?.reachable_path ?? [];
});
readonly attestationRefs = computed(() => {
return this.finding()?.attestation_refs ?? [];
});
readonly attestationCount = computed((): string | null => {
const count = this.attestationRefs().length;
return count > 0 ? String(count) : null;
});
readonly reachabilityState = computed((): ReachabilityState => {
const path = this.callPath();
if (path.length > 0) return 'reachable';
const f = this.finding();
// 'exposed' (entrypoint present) counts as potentially reachable
if (f?.entrypoint) return 'reachable';
return 'unknown';
});
readonly vexStatus = computed(() => {
return this.finding()?.vex?.status ?? 'unknown';
});
readonly hasBoundary = computed(() => {
return !!this.finding()?.boundary;
});
readonly hasEntrypoint = computed(() => {
return !!this.finding()?.entrypoint;
});
readonly hasTrustScore = computed(() => {
const f = this.finding();
return f?.trust_score !== undefined || f?.trustScoreBreakdown !== undefined;
});
readonly provenanceState = computed((): ProvenanceState => {
const f = this.finding();
if (!f) return 'unknown';
if (f.cache_source === 'redis' || f.cache_source === 'inMemory') return 'cached';
if (f.cache_source === 'none' && f.veri_key) return 'computed';
if (f.is_stale) return 'stale';
return 'unknown';
});
readonly cacheDetails = computed((): CacheDetails | null => {
const f = this.finding();
if (!f?.veri_key) return null;
// Map to CacheSource type (none | inMemory | redis)
const source: CacheSource = f.cache_source === 'redis' ? 'redis' : f.cache_source === 'inMemory' ? 'inMemory' : 'none';
return {
veriKey: f.veri_key,
source,
ageSeconds: f.cache_age_seconds ?? 0,
};
});
readonly showProvenanceBadge = computed(() => {
const f = this.finding();
return !!f?.veri_key || !!f?.cache_source;
});
// Methods
setActiveTab(tab: FindingDetailTab): void {
this.tabChange.emit(tab);
}
onViewProofTree(): void {
this.setActiveTab('proof');
}
onVerify(): void {
this.verify.emit();
}
onCopyVeriKey(veriKey: string): void {
navigator.clipboard.writeText(veriKey).catch(console.error);
this.copyVeriKey.emit(veriKey);
}
onCopyHash(hash: string): void {
navigator.clipboard.writeText(hash).catch(console.error);
this.copyHash.emit(hash);
}
onCopyAttestationRef(ref: string): void {
navigator.clipboard.writeText(ref).catch(console.error);
}
onDownloadEvidence(chunk: EvidenceChunk): void {
this.downloadEvidence.emit(chunk);
}
onRefreshManifest(): void {
this.refreshManifest.emit();
}
formatDate(isoString?: string): string {
if (!isoString) return '—';
try {
return new Date(isoString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return isoString;
}
}
}

View File

@@ -2,9 +2,11 @@
* Finding Row Component.
* Sprint: SPRINT_4100_0003_0001 (Finding Row Component)
* Task: ROW-001, ROW-002, ROW-003 - FindingRow with core display, expandable details, shared chips
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-207 - Add ProvenanceBadge to FindingRow
*
* Displays a single vulnerability finding in a row format with expandable details.
* Integrates ReachabilityChip, VexStatusChip, ScoreBreakdown, and ChainStatusBadge.
* Integrates ReachabilityChip, VexStatusChip, ScoreBreakdown, ChainStatusBadge, and ProvenanceBadge.
*/
import { Component, input, output, computed, signal } from '@angular/core';
@@ -14,6 +16,7 @@ import { ReachabilityChipComponent, ReachabilityState } from './reachability-chi
import { VexStatusChipComponent } from './vex-status-chip.component';
import { ScoreBreakdownComponent } from './score-breakdown.component';
import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
/**
* Compact row component for displaying a vulnerability finding.
@@ -40,6 +43,7 @@ import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-ba
VexStatusChipComponent,
ScoreBreakdownComponent,
ChainStatusBadgeComponent,
ProvenanceBadgeComponent,
],
template: `
<article
@@ -125,6 +129,19 @@ import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-ba
</div>
}
<!-- Provenance Status -->
@if (showProvenanceBadge()) {
<div class="finding-row__provenance">
<stella-provenance-badge
[state]="provenanceState()"
[cacheDetails]="cacheDetails()"
[showLabel]="false"
[clickable]="true"
(clicked)="onViewProofTree()"
/>
</div>
}
<!-- Actions -->
<div class="finding-row__actions">
<button
@@ -293,7 +310,8 @@ import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-ba
.finding-row__reachability,
.finding-row__vex,
.finding-row__chain {
.finding-row__chain,
.finding-row__provenance {
flex-shrink: 0;
}
@@ -410,6 +428,12 @@ export class FindingRowComponent {
*/
readonly showChainStatus = input<boolean>(true);
/**
* Whether to show provenance badge (default: true).
* Badge will only display if cache_source is available.
*/
readonly showProvenanceBadge = input<boolean>(true);
/**
* Maximum number of path steps to show in preview (default: 5).
*/
@@ -425,6 +449,11 @@ export class FindingRowComponent {
*/
readonly approve = output<string>();
/**
* Emitted when user clicks the provenance badge to view proof tree.
*/
readonly viewProofTree = output<string>();
/**
* Internal expansion state.
*/
@@ -484,6 +513,30 @@ export class FindingRowComponent {
return 'complete';
});
// =========================================================================
// Provenance Badge Computed Properties (Sprint 8200.0001.0003)
// =========================================================================
readonly provenanceState = computed((): ProvenanceState => {
const finding = this.finding();
if (!finding?.cache_source) return 'unknown';
if (finding.cache_source === 'none') return 'computed';
if (finding.is_stale) return 'stale';
return 'cached';
});
readonly cacheDetails = computed((): CacheDetails | undefined => {
const finding = this.finding();
if (!finding?.cache_source) return undefined;
return {
source: finding.cache_source,
ageSeconds: finding.cache_age_seconds,
trustScore: finding.trust_score,
veriKey: finding.veri_key,
executionTimeMs: finding.execution_time_ms,
};
});
readonly hasBoundary = computed(() => !!this.finding()?.boundary);
readonly hasEntrypoint = computed(() => !!this.finding()?.entrypoint);
@@ -682,4 +735,11 @@ export class FindingRowComponent {
this.approve.emit(findingId);
}
}
onViewProofTree(): void {
const veriKey = this.finding()?.veri_key;
if (veriKey) {
this.viewProofTree.emit(veriKey);
}
}
}

View File

@@ -55,3 +55,11 @@ export {
// Backport Explainability UX (SPRINT_4000_0002_0001)
export { ComparatorBadgeComponent, ComparatorType } from './comparator-badge.component';
export { VersionProofPopoverComponent, VersionComparisonData } from './version-proof-popover.component';
// Provcache UX & Observability (SPRINT_8200_0001_0003)
export { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
export { TrustScoreDisplayComponent, TrustScoreMode, TrustScoreThresholds } from './trust-score.component';
export { InputManifestComponent, InputManifestMode, InputManifestDisplayConfig } from './input-manifest.component';
export { ProofTreeComponent, VerdictEntry, VerdictStatus, EvidenceChunk } from './proof-tree.component';
export { TimelineEventComponent, TimelineEventType, TimelineEventSeverity, TimelineEvent } from './timeline-event.component';
export { FindingDetailComponent, FindingDetailTab, FindingDetail } from './finding-detail.component';

View File

@@ -0,0 +1,534 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
InputManifestComponent,
InputManifestMode,
InputManifestDisplayConfig,
} from './input-manifest.component';
import {
InputManifest,
SignerCertificate,
} from '../../core/api/policy-engine.models';
describe('InputManifestComponent', () => {
let component: InputManifestComponent;
let fixture: ComponentFixture<InputManifestComponent>;
const mockManifest: InputManifest = {
veriKey: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
sourceArtifact: {
digest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
artifactType: 'container-image',
ociReference: 'ghcr.io/stellaops/example:v1.0.0',
sizeBytes: 1024 * 1024 * 50, // 50 MB
},
sbom: {
hash: 'sha256:sbom123456789abcdef',
format: 'spdx-2.3',
packageCount: 150,
completenessScore: 85,
createdAt: '2025-12-26T10:00:00Z',
},
vex: {
hashSetHash: 'sha256:vex123456789abcdef',
statementCount: 12,
sources: ['Red Hat', 'Ubuntu', 'NVD'],
latestStatementAt: '2025-12-25T15:30:00Z',
},
policy: {
hash: 'sha256:policy123456789abcdef',
packId: 'stellaops-default-v2',
version: 3,
lastUpdatedAt: '2025-12-20T08:00:00Z',
name: 'Production Security Policy',
},
signers: {
setHash: 'sha256:signers123456789abcdef',
signerCount: 2,
certificates: [
{
subject: 'CN=release-signer@stellaops.io',
issuer: 'CN=Fulcio',
fingerprint: 'abc123',
expiresAt: '2026-12-26T00:00:00Z',
trustLevel: 'fulcio',
},
{
subject: 'CN=ci-signer@stellaops.io',
issuer: 'CN=Enterprise CA',
fingerprint: 'def456',
expiresAt: '2025-01-15T00:00:00Z', // Expiring soon
trustLevel: 'enterprise-ca',
},
],
},
timeWindow: {
bucket: '2025-12-26-H12',
startsAt: '2025-12-26T12:00:00Z',
endsAt: '2025-12-26T13:00:00Z',
},
generatedAt: '2025-12-26T12:30:00Z',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [InputManifestComponent],
}).compileComponents();
fixture = TestBed.createComponent(InputManifestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Empty State', () => {
it('should show empty state when no manifest', () => {
const empty = fixture.debugElement.query(By.css('.input-manifest__empty'));
expect(empty).toBeTruthy();
});
it('should hide content when no manifest', () => {
const content = fixture.debugElement.query(By.css('.input-manifest__content'));
expect(content).toBeFalsy();
});
it('should have appropriate ARIA label for empty state', () => {
expect(component.ariaLabel()).toBe('Input Manifest: No data');
});
});
describe('Header', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display title', () => {
const title = fixture.debugElement.query(By.css('.input-manifest__title'));
expect(title.nativeElement.textContent).toContain('Input Manifest');
});
it('should display truncated VeriKey', () => {
const veriKey = fixture.debugElement.query(By.css('.input-manifest__verikey-value'));
expect(veriKey.nativeElement.textContent).toContain('sha256:abc123d...');
});
it('should have copy button', () => {
const copyBtn = fixture.debugElement.query(By.css('.input-manifest__copy-btn'));
expect(copyBtn).toBeTruthy();
});
it('should emit copyVeriKey on copy button click', () => {
const copySpy = spyOn(component.copyVeriKey, 'emit');
const copyBtn = fixture.debugElement.query(By.css('.input-manifest__copy-btn'));
copyBtn.nativeElement.click();
expect(copySpy).toHaveBeenCalledWith(mockManifest.veriKey);
});
});
describe('Source Artifact Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display source artifact section', () => {
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
expect(section).toBeTruthy();
});
it('should display artifact digest', () => {
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
const hashCode = section.query(By.css('.input-manifest__hash'));
expect(hashCode).toBeTruthy();
});
it('should display artifact type', () => {
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
expect(section.nativeElement.textContent).toContain('container-image');
});
it('should display OCI reference', () => {
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
expect(section.nativeElement.textContent).toContain('ghcr.io/stellaops/example:v1.0.0');
});
it('should display formatted size', () => {
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
expect(section.nativeElement.textContent).toContain('50.0 MB');
});
});
describe('SBOM Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display SBOM section', () => {
const section = fixture.debugElement.query(By.css('[data-section="sbom"]'));
expect(section).toBeTruthy();
});
it('should display SBOM format badge', () => {
const badge = fixture.debugElement.query(By.css('.input-manifest__badge--spdx'));
expect(badge).toBeTruthy();
expect(badge.nativeElement.textContent).toContain('spdx-2.3');
});
it('should display package count', () => {
const section = fixture.debugElement.query(By.css('[data-section="sbom"]'));
expect(section.nativeElement.textContent).toContain('150');
});
it('should display completeness score with high class', () => {
const score = fixture.debugElement.query(By.css('.input-manifest__score--high'));
expect(score).toBeTruthy();
expect(score.nativeElement.textContent).toContain('85%');
});
});
describe('VEX Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display VEX section', () => {
const section = fixture.debugElement.query(By.css('[data-section="vex"]'));
expect(section).toBeTruthy();
});
it('should display statement count', () => {
const section = fixture.debugElement.query(By.css('[data-section="vex"]'));
expect(section.nativeElement.textContent).toContain('12');
});
it('should display sources list', () => {
const sources = fixture.debugElement.queryAll(By.css('.input-manifest__source-list li'));
expect(sources.length).toBe(3);
expect(sources[0].nativeElement.textContent).toContain('Red Hat');
});
});
describe('Policy Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display policy section', () => {
const section = fixture.debugElement.query(By.css('[data-section="policy"]'));
expect(section).toBeTruthy();
});
it('should display policy name', () => {
const section = fixture.debugElement.query(By.css('[data-section="policy"]'));
expect(section.nativeElement.textContent).toContain('Production Security Policy');
});
it('should display pack ID', () => {
const packId = fixture.debugElement.query(By.css('.input-manifest__pack-id'));
expect(packId.nativeElement.textContent).toContain('stellaops-default-v2');
});
it('should display version', () => {
const section = fixture.debugElement.query(By.css('[data-section="policy"]'));
expect(section.nativeElement.textContent).toContain('v3');
});
});
describe('Signers Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display signers section', () => {
const section = fixture.debugElement.query(By.css('[data-section="signers"]'));
expect(section).toBeTruthy();
});
it('should display signer count', () => {
const section = fixture.debugElement.query(By.css('[data-section="signers"]'));
expect(section.nativeElement.textContent).toContain('2');
});
it('should display certificate list in full mode', () => {
const certs = fixture.debugElement.queryAll(By.css('.input-manifest__cert-item'));
expect(certs.length).toBe(2);
});
it('should display certificate subjects', () => {
const subjects = fixture.debugElement.queryAll(By.css('.input-manifest__cert-subject'));
expect(subjects[0].nativeElement.textContent).toContain('release-signer@stellaops.io');
});
it('should display trust level badges', () => {
const trustBadge = fixture.debugElement.query(By.css('.input-manifest__cert-trust--fulcio'));
expect(trustBadge).toBeTruthy();
});
});
describe('Time Window Section', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display time window section', () => {
const section = fixture.debugElement.query(By.css('[data-section="time-window"]'));
expect(section).toBeTruthy();
});
it('should display bucket', () => {
const bucket = fixture.debugElement.query(By.css('.input-manifest__bucket'));
expect(bucket.nativeElement.textContent).toContain('2025-12-26-H12');
});
});
describe('Footer', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should display generated timestamp', () => {
const footer = fixture.debugElement.query(By.css('.input-manifest__footer'));
expect(footer.nativeElement.textContent).toContain('Generated');
});
it('should have refresh button', () => {
const refreshBtn = fixture.debugElement.query(By.css('.input-manifest__refresh-btn'));
expect(refreshBtn).toBeTruthy();
});
it('should emit refresh on button click', () => {
const refreshSpy = spyOn(component.refresh, 'emit');
const refreshBtn = fixture.debugElement.query(By.css('.input-manifest__refresh-btn'));
refreshBtn.nativeElement.click();
expect(refreshSpy).toHaveBeenCalled();
});
});
describe('Display Modes', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
});
it('should apply compact mode class', () => {
fixture.componentRef.setInput('mode', 'compact' as InputManifestMode);
fixture.detectChanges();
const container = fixture.debugElement.query(By.css('.input-manifest--compact'));
expect(container).toBeTruthy();
});
it('should apply summary mode class', () => {
fixture.componentRef.setInput('mode', 'summary' as InputManifestMode);
fixture.detectChanges();
const container = fixture.debugElement.query(By.css('.input-manifest--summary'));
expect(container).toBeTruthy();
});
it('should hide certificate details in summary mode', () => {
fixture.componentRef.setInput('mode', 'summary' as InputManifestMode);
fixture.detectChanges();
const certs = fixture.debugElement.queryAll(By.css('.input-manifest__cert-item'));
expect(certs.length).toBe(0);
});
});
describe('Display Config', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
});
it('should hide source section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showSource: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="source"]'));
expect(section).toBeFalsy();
});
it('should hide SBOM section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showSbom: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="sbom"]'));
expect(section).toBeFalsy();
});
it('should hide VEX section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showVex: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="vex"]'));
expect(section).toBeFalsy();
});
it('should hide policy section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showPolicy: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="policy"]'));
expect(section).toBeFalsy();
});
it('should hide signers section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showSigners: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="signers"]'));
expect(section).toBeFalsy();
});
it('should hide time window section when configured', () => {
fixture.componentRef.setInput('displayConfig', {
showTimeWindow: false,
} as InputManifestDisplayConfig);
fixture.detectChanges();
const section = fixture.debugElement.query(By.css('[data-section="time-window"]'));
expect(section).toBeFalsy();
});
});
describe('Utility Functions', () => {
it('should truncate hash correctly', () => {
expect(component.truncateHash('sha256:abc123def456', 12)).toBe('sha256:abc...');
expect(component.truncateHash('short', 12)).toBe('short');
expect(component.truncateHash('abc123def456789', 8)).toBe('abc123de...');
});
it('should format bytes correctly', () => {
expect(component.formatBytes(0)).toBe('0 B');
expect(component.formatBytes(1024)).toBe('1.0 KB');
expect(component.formatBytes(1024 * 1024)).toBe('1.0 MB');
expect(component.formatBytes(1024 * 1024 * 1024)).toBe('1.0 GB');
expect(component.formatBytes(500)).toBe('500 B');
});
it('should format trust level correctly', () => {
expect(component.formatTrustLevel('fulcio')).toBe('Fulcio');
expect(component.formatTrustLevel('self-signed')).toBe('Self-Signed');
expect(component.formatTrustLevel('enterprise-ca')).toBe('Enterprise CA');
expect(component.formatTrustLevel('unknown')).toBe('unknown');
});
it('should detect expiring certificates', () => {
const expiringSoon: SignerCertificate = {
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
};
const notExpiring: SignerCertificate = {
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
};
expect(component.isCertExpiringSoon(expiringSoon)).toBe(true);
expect(component.isCertExpiringSoon(notExpiring)).toBe(false);
});
it('should detect expired certificates', () => {
const expired: SignerCertificate = {
expiresAt: new Date(Date.now() - 1000).toISOString(),
};
const notExpired: SignerCertificate = {
expiresAt: new Date(Date.now() + 1000).toISOString(),
};
expect(component.isCertExpired(expired)).toBe(true);
expect(component.isCertExpired(notExpired)).toBe(false);
});
});
describe('SBOM Format Detection', () => {
it('should detect SPDX format', () => {
const spdxManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, format: 'spdx-2.3' } };
fixture.componentRef.setInput('manifest', spdxManifest);
fixture.detectChanges();
expect(component.sbomFormatClass()).toBe('spdx');
});
it('should detect CycloneDX format', () => {
const cdxManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, format: 'cyclonedx-1.6' } };
fixture.componentRef.setInput('manifest', cdxManifest);
fixture.detectChanges();
expect(component.sbomFormatClass()).toBe('cyclonedx');
});
it('should handle other formats', () => {
const otherManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, format: 'custom' } };
fixture.componentRef.setInput('manifest', otherManifest);
fixture.detectChanges();
expect(component.sbomFormatClass()).toBe('other');
});
});
describe('Accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('manifest', mockManifest);
fixture.detectChanges();
});
it('should have role="region" on container', () => {
const container = fixture.debugElement.query(By.css('.input-manifest'));
expect(container.nativeElement.getAttribute('role')).toBe('region');
});
it('should have appropriate aria-label', () => {
expect(component.ariaLabel()).toContain('Input Manifest for VeriKey sha256:abc123def456');
});
});
describe('Score Thresholds', () => {
it('should apply high class for score >= 80', () => {
const highManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, completenessScore: 80 } };
fixture.componentRef.setInput('manifest', highManifest);
fixture.detectChanges();
const score = fixture.debugElement.query(By.css('.input-manifest__score--high'));
expect(score).toBeTruthy();
});
it('should apply medium class for score 50-79', () => {
const medManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, completenessScore: 65 } };
fixture.componentRef.setInput('manifest', medManifest);
fixture.detectChanges();
const score = fixture.debugElement.query(By.css('.input-manifest__score--medium'));
expect(score).toBeTruthy();
});
it('should apply low class for score < 50', () => {
const lowManifest = { ...mockManifest, sbom: { ...mockManifest.sbom, completenessScore: 30 } };
fixture.componentRef.setInput('manifest', lowManifest);
fixture.detectChanges();
const score = fixture.debugElement.query(By.css('.input-manifest__score--low'));
expect(score).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,844 @@
import { Component, computed, input, output } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import {
InputManifest,
SourceArtifactInfo,
SbomInfo,
VexInfo,
PolicyInfoManifest,
SignerInfo,
SignerCertificate,
TimeWindowInfo,
} from '../../core/api/policy-engine.models';
/**
* Configuration for which sections to display.
*/
export interface InputManifestDisplayConfig {
showSource?: boolean;
showSbom?: boolean;
showVex?: boolean;
showPolicy?: boolean;
showSigners?: boolean;
showTimeWindow?: boolean;
}
/**
* Display mode for the manifest.
*/
export type InputManifestMode = 'full' | 'compact' | 'summary';
/**
* InputManifestComponent displays the exact inputs that form a VeriKey
* and cached decision. Shows source artifact, SBOM, VEX, policy, signers,
* and time window information.
*
* @example
* <stellaops-input-manifest [manifest]="manifest" />
* <stellaops-input-manifest [manifest]="manifest" mode="compact" />
* <stellaops-input-manifest [manifest]="manifest" [displayConfig]="config" />
*/
@Component({
selector: 'stellaops-input-manifest',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div
class="input-manifest"
[class.input-manifest--compact]="mode() === 'compact'"
[class.input-manifest--summary]="mode() === 'summary'"
role="region"
[attr.aria-label]="ariaLabel()">
<!-- Header with VeriKey -->
<header class="input-manifest__header">
<h3 class="input-manifest__title">Input Manifest</h3>
@if (manifest(); as m) {
<div class="input-manifest__verikey" [title]="m.veriKey">
<span class="input-manifest__verikey-label">VeriKey:</span>
<code class="input-manifest__verikey-value">{{ truncatedVeriKey() }}</code>
<button
class="input-manifest__copy-btn"
(click)="copyVeriKey.emit(m.veriKey)"
type="button"
title="Copy VeriKey">
📋
</button>
</div>
}
</header>
@if (manifest(); as m) {
<div class="input-manifest__content">
<!-- Source Artifact Section -->
@if (shouldShowSection('source')) {
<section class="input-manifest__section" data-section="source">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">📦</span>
Source Artifact
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Digest</dt>
<dd>
<code class="input-manifest__hash" [title]="m.sourceArtifact.digest">
{{ truncateHash(m.sourceArtifact.digest) }}
</code>
</dd>
</div>
@if (m.sourceArtifact.artifactType) {
<div class="input-manifest__detail-row">
<dt>Type</dt>
<dd>{{ m.sourceArtifact.artifactType }}</dd>
</div>
}
@if (m.sourceArtifact.ociReference) {
<div class="input-manifest__detail-row">
<dt>OCI Reference</dt>
<dd class="input-manifest__oci-ref">{{ m.sourceArtifact.ociReference }}</dd>
</div>
}
@if (m.sourceArtifact.sizeBytes) {
<div class="input-manifest__detail-row">
<dt>Size</dt>
<dd>{{ formatBytes(m.sourceArtifact.sizeBytes) }}</dd>
</div>
}
</dl>
</section>
}
<!-- SBOM Section -->
@if (shouldShowSection('sbom')) {
<section class="input-manifest__section" data-section="sbom">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">📋</span>
SBOM
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Hash</dt>
<dd>
<code class="input-manifest__hash" [title]="m.sbom.hash">
{{ truncateHash(m.sbom.hash) }}
</code>
</dd>
</div>
@if (m.sbom.format) {
<div class="input-manifest__detail-row">
<dt>Format</dt>
<dd>
<span class="input-manifest__badge" [class]="'input-manifest__badge--' + sbomFormatClass()">
{{ m.sbom.format }}
</span>
</dd>
</div>
}
@if (m.sbom.packageCount !== undefined) {
<div class="input-manifest__detail-row">
<dt>Packages</dt>
<dd>{{ m.sbom.packageCount | number }}</dd>
</div>
}
@if (m.sbom.completenessScore !== undefined) {
<div class="input-manifest__detail-row">
<dt>Completeness</dt>
<dd>
<span
class="input-manifest__score"
[class.input-manifest__score--high]="m.sbom.completenessScore >= 80"
[class.input-manifest__score--medium]="m.sbom.completenessScore >= 50 && m.sbom.completenessScore < 80"
[class.input-manifest__score--low]="m.sbom.completenessScore < 50">
{{ m.sbom.completenessScore }}%
</span>
</dd>
</div>
}
@if (m.sbom.createdAt) {
<div class="input-manifest__detail-row">
<dt>Created</dt>
<dd>{{ m.sbom.createdAt | date:'medium' }}</dd>
</div>
}
</dl>
</section>
}
<!-- VEX Section -->
@if (shouldShowSection('vex')) {
<section class="input-manifest__section" data-section="vex">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">📜</span>
VEX Statements
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Hash Set</dt>
<dd>
<code class="input-manifest__hash" [title]="m.vex.hashSetHash">
{{ truncateHash(m.vex.hashSetHash) }}
</code>
</dd>
</div>
<div class="input-manifest__detail-row">
<dt>Statements</dt>
<dd>{{ m.vex.statementCount | number }}</dd>
</div>
@if (m.vex.sources?.length) {
<div class="input-manifest__detail-row">
<dt>Sources</dt>
<dd>
<ul class="input-manifest__source-list">
@for (source of m.vex.sources; track source) {
<li>{{ source }}</li>
}
</ul>
</dd>
</div>
}
@if (m.vex.latestStatementAt) {
<div class="input-manifest__detail-row">
<dt>Latest</dt>
<dd>{{ m.vex.latestStatementAt | date:'medium' }}</dd>
</div>
}
</dl>
</section>
}
<!-- Policy Section -->
@if (shouldShowSection('policy')) {
<section class="input-manifest__section" data-section="policy">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">📏</span>
Policy
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Hash</dt>
<dd>
<code class="input-manifest__hash" [title]="m.policy.hash">
{{ truncateHash(m.policy.hash) }}
</code>
</dd>
</div>
@if (m.policy.name) {
<div class="input-manifest__detail-row">
<dt>Name</dt>
<dd>{{ m.policy.name }}</dd>
</div>
}
@if (m.policy.packId) {
<div class="input-manifest__detail-row">
<dt>Pack</dt>
<dd>
<code class="input-manifest__pack-id">{{ m.policy.packId }}</code>
</dd>
</div>
}
@if (m.policy.version !== undefined) {
<div class="input-manifest__detail-row">
<dt>Version</dt>
<dd>v{{ m.policy.version }}</dd>
</div>
}
@if (m.policy.lastUpdatedAt) {
<div class="input-manifest__detail-row">
<dt>Updated</dt>
<dd>{{ m.policy.lastUpdatedAt | date:'medium' }}</dd>
</div>
}
</dl>
</section>
}
<!-- Signers Section -->
@if (shouldShowSection('signers')) {
<section class="input-manifest__section" data-section="signers">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">🔐</span>
Signers
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Set Hash</dt>
<dd>
<code class="input-manifest__hash" [title]="m.signers.setHash">
{{ truncateHash(m.signers.setHash) }}
</code>
</dd>
</div>
<div class="input-manifest__detail-row">
<dt>Signers</dt>
<dd>{{ m.signers.signerCount | number }}</dd>
</div>
@if (m.signers.certificates?.length && mode() !== 'summary') {
<div class="input-manifest__certificates">
<dt>Certificates</dt>
<dd>
<ul class="input-manifest__cert-list">
@for (cert of m.signers.certificates; track cert.fingerprint) {
<li class="input-manifest__cert-item">
<span class="input-manifest__cert-subject">{{ cert.subject }}</span>
@if (cert.issuer) {
<span class="input-manifest__cert-issuer">Issuer: {{ cert.issuer }}</span>
}
@if (cert.expiresAt) {
<span
class="input-manifest__cert-expiry"
[class.input-manifest__cert-expiry--warning]="isCertExpiringSoon(cert)"
[class.input-manifest__cert-expiry--expired]="isCertExpired(cert)">
Expires: {{ cert.expiresAt | date:'mediumDate' }}
</span>
}
@if (cert.trustLevel) {
<span class="input-manifest__cert-trust" [class]="'input-manifest__cert-trust--' + cert.trustLevel">
{{ formatTrustLevel(cert.trustLevel) }}
</span>
}
</li>
}
</ul>
</dd>
</div>
}
</dl>
</section>
}
<!-- Time Window Section -->
@if (shouldShowSection('timeWindow')) {
<section class="input-manifest__section" data-section="time-window">
<h4 class="input-manifest__section-title">
<span class="input-manifest__section-icon">⏰</span>
Time Window
</h4>
<dl class="input-manifest__details">
<div class="input-manifest__detail-row">
<dt>Bucket</dt>
<dd>
<code class="input-manifest__bucket">{{ m.timeWindow.bucket }}</code>
</dd>
</div>
@if (m.timeWindow.startsAt) {
<div class="input-manifest__detail-row">
<dt>Starts</dt>
<dd>{{ m.timeWindow.startsAt | date:'medium' }}</dd>
</div>
}
@if (m.timeWindow.endsAt) {
<div class="input-manifest__detail-row">
<dt>Ends</dt>
<dd>{{ m.timeWindow.endsAt | date:'medium' }}</dd>
</div>
}
</dl>
</section>
}
</div>
<!-- Footer with generation timestamp -->
<footer class="input-manifest__footer">
<span class="input-manifest__generated">
Generated: {{ m.generatedAt | date:'medium' }}
</span>
<button
class="input-manifest__refresh-btn"
(click)="refresh.emit()"
type="button"
title="Refresh manifest">
🔄
</button>
</footer>
} @else {
<div class="input-manifest__empty">
<span class="input-manifest__empty-icon">📄</span>
<p>No manifest data available</p>
</div>
}
</div>
`,
styles: [`
.input-manifest {
--manifest-bg: var(--stellaops-card-bg, #ffffff);
--manifest-border: var(--stellaops-border, #e0e0e0);
--manifest-text: var(--stellaops-text, #1a1a1a);
--manifest-text-secondary: var(--stellaops-text-secondary, #666666);
--manifest-accent: var(--stellaops-accent, #1976d2);
--manifest-hash-bg: var(--stellaops-code-bg, #f5f5f5);
background: var(--manifest-bg);
border: 1px solid var(--manifest-border);
border-radius: 8px;
font-family: var(--stellaops-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
color: var(--manifest-text);
}
/* Header */
.input-manifest__header {
padding: 16px;
border-bottom: 1px solid var(--manifest-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.input-manifest__title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.input-manifest__verikey {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.input-manifest__verikey-label {
color: var(--manifest-text-secondary);
}
.input-manifest__verikey-value {
font-family: 'SF Mono', 'Consolas', monospace;
background: var(--manifest-hash-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
}
.input-manifest__copy-btn,
.input-manifest__refresh-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 14px;
opacity: 0.7;
transition: opacity 0.2s;
}
.input-manifest__copy-btn:hover,
.input-manifest__refresh-btn:hover {
opacity: 1;
}
/* Content */
.input-manifest__content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Section */
.input-manifest__section {
padding: 12px;
background: color-mix(in srgb, var(--manifest-accent) 5%, transparent);
border-radius: 6px;
border-left: 3px solid var(--manifest-accent);
}
.input-manifest__section-title {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.input-manifest__section-icon {
font-size: 16px;
}
/* Details */
.input-manifest__details {
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.input-manifest__detail-row {
display: flex;
align-items: baseline;
gap: 8px;
}
.input-manifest__detail-row dt {
font-size: 12px;
color: var(--manifest-text-secondary);
min-width: 80px;
flex-shrink: 0;
}
.input-manifest__detail-row dd {
margin: 0;
font-size: 13px;
}
.input-manifest__hash {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 11px;
background: var(--manifest-hash-bg);
padding: 2px 6px;
border-radius: 4px;
word-break: break-all;
}
.input-manifest__oci-ref {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
word-break: break-all;
}
.input-manifest__pack-id,
.input-manifest__bucket {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
background: var(--manifest-hash-bg);
padding: 2px 6px;
border-radius: 4px;
}
/* Badge */
.input-manifest__badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.input-manifest__badge--spdx {
background: #4caf50;
color: white;
}
.input-manifest__badge--cyclonedx {
background: #2196f3;
color: white;
}
.input-manifest__badge--other {
background: #9e9e9e;
color: white;
}
/* Score */
.input-manifest__score {
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
}
.input-manifest__score--high {
background: color-mix(in srgb, #4caf50 20%, transparent);
color: #2e7d32;
}
.input-manifest__score--medium {
background: color-mix(in srgb, #ff9800 20%, transparent);
color: #ef6c00;
}
.input-manifest__score--low {
background: color-mix(in srgb, #f44336 20%, transparent);
color: #c62828;
}
/* Source list */
.input-manifest__source-list {
margin: 0;
padding-left: 16px;
font-size: 12px;
}
.input-manifest__source-list li {
margin: 2px 0;
}
/* Certificates */
.input-manifest__certificates {
margin-top: 8px;
}
.input-manifest__certificates dt {
font-size: 12px;
color: var(--manifest-text-secondary);
margin-bottom: 8px;
}
.input-manifest__certificates dd {
margin: 0;
}
.input-manifest__cert-list {
margin: 0;
padding: 0;
list-style: none;
}
.input-manifest__cert-item {
padding: 8px;
background: var(--manifest-bg);
border-radius: 4px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.input-manifest__cert-subject {
font-weight: 500;
}
.input-manifest__cert-issuer {
color: var(--manifest-text-secondary);
}
.input-manifest__cert-expiry {
color: var(--manifest-text-secondary);
}
.input-manifest__cert-expiry--warning {
color: #ef6c00;
}
.input-manifest__cert-expiry--expired {
color: #c62828;
font-weight: 500;
}
.input-manifest__cert-trust {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
}
.input-manifest__cert-trust--fulcio {
background: #4caf50;
color: white;
}
.input-manifest__cert-trust--enterprise-ca {
background: #2196f3;
color: white;
}
.input-manifest__cert-trust--self-signed {
background: #ff9800;
color: white;
}
/* Footer */
.input-manifest__footer {
padding: 12px 16px;
border-top: 1px solid var(--manifest-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.input-manifest__generated {
font-size: 11px;
color: var(--manifest-text-secondary);
}
/* Empty state */
.input-manifest__empty {
padding: 32px;
text-align: center;
color: var(--manifest-text-secondary);
}
.input-manifest__empty-icon {
font-size: 32px;
opacity: 0.5;
}
.input-manifest__empty p {
margin: 8px 0 0 0;
font-size: 14px;
}
/* Compact mode */
.input-manifest--compact .input-manifest__header {
padding: 12px;
}
.input-manifest--compact .input-manifest__content {
padding: 12px;
gap: 12px;
}
.input-manifest--compact .input-manifest__section {
padding: 8px;
}
.input-manifest--compact .input-manifest__section-title {
margin-bottom: 8px;
font-size: 13px;
}
.input-manifest--compact .input-manifest__detail-row dt {
min-width: 60px;
}
.input-manifest--compact .input-manifest__footer {
padding: 8px 12px;
}
/* Summary mode */
.input-manifest--summary .input-manifest__content {
flex-direction: row;
flex-wrap: wrap;
}
.input-manifest--summary .input-manifest__section {
flex: 1 1 200px;
min-width: 180px;
}
.input-manifest--summary .input-manifest__detail-row {
flex-direction: column;
gap: 2px;
}
.input-manifest--summary .input-manifest__detail-row dt {
min-width: unset;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.input-manifest {
--manifest-bg: var(--stellaops-card-bg-dark, #1e1e1e);
--manifest-border: var(--stellaops-border-dark, #333333);
--manifest-text: var(--stellaops-text-dark, #e0e0e0);
--manifest-text-secondary: var(--stellaops-text-secondary-dark, #999999);
--manifest-hash-bg: var(--stellaops-code-bg-dark, #2d2d2d);
}
}
`],
})
export class InputManifestComponent {
/** The manifest data to display. */
manifest = input<InputManifest | null>(null);
/** Display mode: 'full' (default), 'compact', or 'summary'. */
mode = input<InputManifestMode>('full');
/** Configuration for which sections to display. */
displayConfig = input<InputManifestDisplayConfig>({
showSource: true,
showSbom: true,
showVex: true,
showPolicy: true,
showSigners: true,
showTimeWindow: true,
});
/** Emitted when user clicks copy VeriKey button. */
copyVeriKey = output<string>();
/** Emitted when user clicks refresh button. */
refresh = output<void>();
/** Truncated VeriKey for display. */
truncatedVeriKey = computed(() => {
const m = this.manifest();
if (!m) return '';
return this.truncateHash(m.veriKey, 16);
});
/** ARIA label for accessibility. */
ariaLabel = computed(() => {
const m = this.manifest();
if (!m) return 'Input Manifest: No data';
return `Input Manifest for VeriKey ${m.veriKey.substring(0, 16)}...`;
});
/** SBOM format class for badge styling. */
sbomFormatClass = computed(() => {
const m = this.manifest();
if (!m?.sbom.format) return 'other';
const format = m.sbom.format.toLowerCase();
if (format.includes('spdx')) return 'spdx';
if (format.includes('cyclonedx')) return 'cyclonedx';
return 'other';
});
/**
* Truncate a hash for display.
*/
truncateHash(hash: string, length = 12): string {
if (!hash || hash.length <= length) return hash;
// Handle sha256:abc123... format
const colonIndex = hash.indexOf(':');
if (colonIndex > 0 && colonIndex < 10) {
const prefix = hash.substring(0, colonIndex + 1);
const hashPart = hash.substring(colonIndex + 1);
return `${prefix}${hashPart.substring(0, length - prefix.length)}...`;
}
return `${hash.substring(0, length)}...`;
}
/**
* Format bytes to human-readable string.
*/
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
/**
* Format trust level for display.
*/
formatTrustLevel(level: string): string {
const map: Record<string, string> = {
'fulcio': 'Fulcio',
'self-signed': 'Self-Signed',
'enterprise-ca': 'Enterprise CA',
};
return map[level] || level;
}
/**
* Check if certificate is expiring within 30 days.
*/
isCertExpiringSoon(cert: SignerCertificate): boolean {
if (!cert.expiresAt) return false;
const expiresAt = new Date(cert.expiresAt);
const now = new Date();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
return expiresAt.getTime() - now.getTime() < thirtyDays && expiresAt > now;
}
/**
* Check if certificate is expired.
*/
isCertExpired(cert: SignerCertificate): boolean {
if (!cert.expiresAt) return false;
return new Date(cert.expiresAt) < new Date();
}
/**
* Determine if a section should be displayed.
*/
shouldShowSection(section: 'source' | 'sbom' | 'vex' | 'policy' | 'signers' | 'timeWindow'): boolean {
const config = this.displayConfig();
const key = `show${section.charAt(0).toUpperCase() + section.slice(1)}` as keyof InputManifestDisplayConfig;
return config[key] !== false;
}
}

View File

@@ -0,0 +1,520 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
ProofTreeComponent,
VerdictEntry,
VerdictStatus,
EvidenceChunk,
} from './proof-tree.component';
import {
DecisionDigest,
TrustScoreBreakdown,
ReplaySeed,
} from '../../core/api/policy-engine.models';
import { MerkleTree, MerkleTreeNode } from '../../core/api/proof.models';
describe('ProofTreeComponent', () => {
let component: ProofTreeComponent;
let fixture: ComponentFixture<ProofTreeComponent>;
const mockReplaySeed: ReplaySeed = {
feedIds: ['nvd-2025', 'rhel-9'],
ruleIds: ['rule-001', 'rule-002', 'rule-003'],
frozenEpoch: '2025-12-26T00:00:00Z',
};
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
const mockDigest: DecisionDigest = {
digestVersion: '1.0',
veriKey: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
verdictHash: 'sha256:verdict123456789abcdef',
proofRoot: 'sha256:proofroot123456789abcdef',
replaySeed: mockReplaySeed,
createdAt: '2025-12-26T10:00:00Z',
expiresAt: '2025-12-27T10:00:00Z',
trustScore: 85,
trustScoreBreakdown: mockBreakdown,
};
const mockVerdicts: VerdictEntry[] = [
{ cveId: 'CVE-2024-1234', status: 'not_affected', justification: 'Component not present' },
{ cveId: 'CVE-2024-5678', status: 'affected' },
{ cveId: 'CVE-2024-9012', status: 'mitigated', justification: 'Workaround applied' },
];
const mockChunks: EvidenceChunk[] = [
{ chunkId: 'chunk-1', type: 'reachability', hash: 'sha256:chunk1hash', sizeBytes: 1024, loaded: true },
{ chunkId: 'chunk-2', type: 'vex', hash: 'sha256:chunk2hash', sizeBytes: 2048, loaded: false },
{ chunkId: 'chunk-3', type: 'policy', hash: 'sha256:chunk3hash', sizeBytes: 512, loaded: false },
];
const mockMerkleTree: MerkleTree = {
root: {
nodeId: 'root',
hash: 'sha256:rootabcdef123456',
isLeaf: false,
isRoot: true,
level: 0,
position: 0,
children: [
{
nodeId: 'leaf-1',
hash: 'sha256:leaf1abc123',
label: 'Reachability',
isLeaf: true,
isRoot: false,
level: 1,
position: 0,
},
{
nodeId: 'leaf-2',
hash: 'sha256:leaf2def456',
label: 'VEX',
isLeaf: true,
isRoot: false,
level: 1,
position: 1,
},
],
},
leafCount: 2,
depth: 2,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProofTreeComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProofTreeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Empty State', () => {
it('should show empty state when no digest', () => {
const empty = fixture.debugElement.query(By.css('.proof-tree__empty'));
expect(empty).toBeTruthy();
});
it('should have appropriate ARIA label for empty state', () => {
expect(component.ariaLabel()).toBe('Proof Tree: No data');
});
});
describe('Header', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.detectChanges();
});
it('should display title', () => {
const title = fixture.debugElement.query(By.css('.proof-tree__title'));
expect(title.nativeElement.textContent).toContain('Decision Proof Tree');
});
it('should have expand/collapse button', () => {
const expandBtn = fixture.debugElement.query(By.css('.proof-tree__action-btn'));
expect(expandBtn).toBeTruthy();
});
it('should have verify button', () => {
const verifyBtn = fixture.debugElement.query(By.css('.proof-tree__action-btn--verify'));
expect(verifyBtn).toBeTruthy();
expect(verifyBtn.nativeElement.textContent).toContain('Verify');
});
it('should emit verify event on click', () => {
const verifySpy = spyOn(component.verify, 'emit');
const verifyBtn = fixture.debugElement.query(By.css('.proof-tree__action-btn--verify'));
verifyBtn.nativeElement.click();
expect(verifySpy).toHaveBeenCalledWith(mockDigest);
});
it('should disable verify button when verifying', () => {
fixture.componentRef.setInput('isVerifying', true);
fixture.detectChanges();
const verifyBtn = fixture.debugElement.query(By.css('.proof-tree__action-btn--verify'));
expect(verifyBtn.nativeElement.disabled).toBe(true);
});
});
describe('VeriKey Node', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.detectChanges();
});
it('should display VeriKey node', () => {
const node = fixture.debugElement.query(By.css('.proof-tree__node--root'));
expect(node).toBeTruthy();
});
it('should display truncated VeriKey hash', () => {
const hash = fixture.debugElement.query(By.css('.proof-tree__node--root .proof-tree__node-hash'));
expect(hash.nativeElement.textContent).toContain('sha256:abc1...');
});
it('should emit copy event on copy button click', () => {
const copySpy = spyOn(component.copyToClipboard, 'emit');
const copyBtn = fixture.debugElement.query(By.css('.proof-tree__copy-btn'));
copyBtn.nativeElement.click();
expect(copySpy).toHaveBeenCalledWith(mockDigest.veriKey);
});
it('should show details when expanded (default)', () => {
const details = fixture.debugElement.query(By.css('.proof-tree__node-details'));
expect(details).toBeTruthy();
});
it('should display trust score', () => {
const trustScore = fixture.debugElement.query(By.css('.proof-tree__trust-score'));
expect(trustScore.nativeElement.textContent).toContain('85/100');
});
it('should apply high trust score class', () => {
const trustScore = fixture.debugElement.query(By.css('.proof-tree__trust-score--high'));
expect(trustScore).toBeTruthy();
});
});
describe('Verdicts Node', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.componentRef.setInput('verdicts', mockVerdicts);
fixture.detectChanges();
});
it('should display verdicts node', () => {
const node = fixture.debugElement.query(By.css('.proof-tree__node--verdicts'));
expect(node).toBeTruthy();
});
it('should display verdict count badge', () => {
const badge = fixture.debugElement.query(By.css('.proof-tree__count-badge'));
expect(badge.nativeElement.textContent).toContain('3');
});
it('should show verdicts list when expanded', () => {
// Expand verdicts node
component.verdictsExpanded.set(true);
fixture.detectChanges();
const verdictItems = fixture.debugElement.queryAll(By.css('.proof-tree__verdict-item'));
expect(verdictItems.length).toBe(3);
});
it('should display CVE IDs', () => {
component.verdictsExpanded.set(true);
fixture.detectChanges();
const cves = fixture.debugElement.queryAll(By.css('.proof-tree__verdict-cve'));
expect(cves[0].nativeElement.textContent).toContain('CVE-2024-1234');
});
it('should apply correct status classes', () => {
component.verdictsExpanded.set(true);
fixture.detectChanges();
const notAffected = fixture.debugElement.query(By.css('.proof-tree__verdict-status--not_affected'));
expect(notAffected).toBeTruthy();
const affected = fixture.debugElement.query(By.css('.proof-tree__verdict-status--affected'));
expect(affected).toBeTruthy();
});
});
describe('Evidence Tree Node', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.detectChanges();
});
it('should display evidence tree node', () => {
const node = fixture.debugElement.query(By.css('.proof-tree__node--evidence'));
expect(node).toBeTruthy();
});
it('should display proof root hash', () => {
const hash = fixture.debugElement.query(By.css('.proof-tree__node--evidence .proof-tree__node-hash'));
expect(hash.nativeElement.textContent).toContain('sha256:proo...');
});
describe('With Evidence Chunks', () => {
beforeEach(() => {
fixture.componentRef.setInput('evidenceChunks', mockChunks);
component.evidenceExpanded.set(true);
fixture.detectChanges();
});
it('should display chunks list', () => {
const chunks = fixture.debugElement.queryAll(By.css('.proof-tree__chunk-item'));
expect(chunks.length).toBe(3);
});
it('should display chunk types', () => {
const types = fixture.debugElement.queryAll(By.css('.proof-tree__chunk-type'));
expect(types[0].nativeElement.textContent).toContain('reachability');
});
it('should show loaded state', () => {
const loadedChunk = fixture.debugElement.query(By.css('.proof-tree__chunk-item--loaded'));
expect(loadedChunk).toBeTruthy();
});
it('should emit loadChunk on click', () => {
const loadSpy = spyOn(component.loadChunk, 'emit');
const chunks = fixture.debugElement.queryAll(By.css('.proof-tree__chunk-item'));
chunks[1].nativeElement.click();
expect(loadSpy).toHaveBeenCalledWith(mockChunks[1]);
});
});
describe('With Merkle Tree', () => {
beforeEach(() => {
fixture.componentRef.setInput('merkleTree', mockMerkleTree);
component.evidenceExpanded.set(true);
fixture.detectChanges();
});
it('should display Merkle tree root', () => {
const merkleRoot = fixture.debugElement.query(By.css('.proof-tree__merkle-root'));
expect(merkleRoot).toBeTruthy();
});
it('should display tree stats', () => {
const stats = fixture.debugElement.query(By.css('.proof-tree__tree-stats'));
expect(stats.nativeElement.textContent).toContain('Depth: 2');
expect(stats.nativeElement.textContent).toContain('Leaves: 2');
});
it('should display leaf nodes', () => {
const leafNodes = fixture.debugElement.queryAll(By.css('.proof-tree__merkle-node--leaf'));
expect(leafNodes.length).toBe(2);
});
it('should emit loadChunkByHash on leaf download click', () => {
const loadSpy = spyOn(component.loadChunkByHash, 'emit');
const downloadBtn = fixture.debugElement.query(By.css('.proof-tree__merkle-download'));
downloadBtn.nativeElement.click();
expect(loadSpy).toHaveBeenCalled();
});
});
});
describe('Replay Seed Node', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
component.replayExpanded.set(true);
fixture.detectChanges();
});
it('should display replay seed node', () => {
const node = fixture.debugElement.query(By.css('.proof-tree__node--replay'));
expect(node).toBeTruthy();
});
it('should display feed IDs', () => {
const feedList = fixture.debugElement.query(By.css('.proof-tree__id-list'));
expect(feedList.nativeElement.textContent).toContain('nvd-2025');
expect(feedList.nativeElement.textContent).toContain('rhel-9');
});
it('should display frozen epoch when present', () => {
const details = fixture.debugElement.query(By.css('.proof-tree__replay-details'));
expect(details.nativeElement.textContent).toContain('Frozen Epoch');
});
});
describe('Metadata Node', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
component.metadataExpanded.set(true);
fixture.detectChanges();
});
it('should display metadata node', () => {
const node = fixture.debugElement.query(By.css('.proof-tree__node--metadata'));
expect(node).toBeTruthy();
});
it('should display trust score breakdown', () => {
const breakdown = fixture.debugElement.query(By.css('.proof-tree__score-breakdown'));
expect(breakdown).toBeTruthy();
});
it('should display all breakdown components', () => {
const components = fixture.debugElement.queryAll(By.css('.proof-tree__score-component'));
expect(components.length).toBe(5);
});
it('should display component names', () => {
const names = fixture.debugElement.queryAll(By.css('.proof-tree__component-name'));
expect(names[0].nativeElement.textContent).toContain('Reachability');
expect(names[1].nativeElement.textContent).toContain('SBOM Completeness');
});
it('should display component scores', () => {
const scores = fixture.debugElement.queryAll(By.css('.proof-tree__component-score'));
expect(scores[0].nativeElement.textContent).toContain('90');
});
it('should display component weights', () => {
const weights = fixture.debugElement.queryAll(By.css('.proof-tree__component-weight'));
expect(weights[0].nativeElement.textContent).toContain('25%');
});
});
describe('Toggle Expand', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.detectChanges();
});
it('should toggle all nodes on expand button click', () => {
// Initial state: veriKey expanded, others collapsed
expect(component.veriKeyExpanded()).toBe(true);
expect(component.verdictsExpanded()).toBe(false);
// Expand all
component.toggleExpand();
expect(component.veriKeyExpanded()).toBe(true);
expect(component.verdictsExpanded()).toBe(true);
expect(component.evidenceExpanded()).toBe(true);
expect(component.replayExpanded()).toBe(true);
expect(component.metadataExpanded()).toBe(true);
});
it('should collapse all when fully expanded', () => {
// Expand all
component.veriKeyExpanded.set(true);
component.verdictsExpanded.set(true);
component.evidenceExpanded.set(true);
component.replayExpanded.set(true);
component.metadataExpanded.set(true);
// Toggle should collapse
component.toggleExpand();
expect(component.veriKeyExpanded()).toBe(false);
expect(component.verdictsExpanded()).toBe(false);
});
});
describe('Utility Functions', () => {
it('should truncate hash correctly', () => {
expect(component.truncateHash('sha256:abc123def456', 12)).toBe('sha256:abc...');
expect(component.truncateHash('short', 12)).toBe('short');
});
it('should format verdict status correctly', () => {
expect(component.formatVerdictStatus('not_affected')).toBe('Not Affected');
expect(component.formatVerdictStatus('under_investigation')).toBe('Under Investigation');
expect(component.formatVerdictStatus('affected')).toBe('Affected');
});
it('should get correct chunk icons', () => {
expect(component.getChunkIcon('reachability')).toBe('🎯');
expect(component.getChunkIcon('vex')).toBe('📜');
expect(component.getChunkIcon('policy')).toBe('📏');
expect(component.getChunkIcon('sbom')).toBe('📋');
expect(component.getChunkIcon('signer')).toBe('🔐');
});
it('should format bytes correctly', () => {
expect(component.formatBytes(0)).toBe('0 B');
expect(component.formatBytes(1024)).toBe('1.0 KB');
expect(component.formatBytes(1024 * 1024)).toBe('1.0 MB');
});
});
describe('Expiring Detection', () => {
it('should detect expiring soon digest', () => {
const expiringDigest = {
...mockDigest,
expiresAt: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString(), // 12 hours
};
fixture.componentRef.setInput('digest', expiringDigest);
fixture.detectChanges();
expect(component.isExpiringSoon()).toBe(true);
});
it('should not detect not-expiring digest', () => {
const notExpiringDigest = {
...mockDigest,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), // 48 hours
};
fixture.componentRef.setInput('digest', notExpiringDigest);
fixture.detectChanges();
expect(component.isExpiringSoon()).toBe(false);
});
});
describe('Trust Score Thresholds', () => {
it('should apply medium class for score 50-79', () => {
const mediumDigest = { ...mockDigest, trustScore: 65 };
fixture.componentRef.setInput('digest', mediumDigest);
fixture.detectChanges();
const trustScore = fixture.debugElement.query(By.css('.proof-tree__trust-score--medium'));
expect(trustScore).toBeTruthy();
});
it('should apply low class for score < 50', () => {
const lowDigest = { ...mockDigest, trustScore: 30 };
fixture.componentRef.setInput('digest', lowDigest);
fixture.detectChanges();
const trustScore = fixture.debugElement.query(By.css('.proof-tree__trust-score--low'));
expect(trustScore).toBeTruthy();
});
});
describe('Accessibility', () => {
beforeEach(() => {
fixture.componentRef.setInput('digest', mockDigest);
fixture.detectChanges();
});
it('should have role="tree" on container', () => {
const container = fixture.debugElement.query(By.css('.proof-tree'));
expect(container.nativeElement.getAttribute('role')).toBe('tree');
});
it('should have appropriate aria-label', () => {
expect(component.ariaLabel()).toContain('Decision Proof Tree for VeriKey sha256:abc123def456');
expect(component.ariaLabel()).toContain('Trust Score: 85');
});
it('should have aria-expanded on toggle buttons', () => {
const toggleBtns = fixture.debugElement.queryAll(By.css('.proof-tree__node-toggle'));
toggleBtns.forEach(btn => {
const ariaExpanded = btn.nativeElement.getAttribute('aria-expanded');
expect(ariaExpanded).toBeTruthy();
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,375 @@
/**
* Provenance Badge Component Tests.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-209 - Add Storybook stories for all badge states
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
describe('ProvenanceBadgeComponent', () => {
let component: ProvenanceBadgeComponent;
let fixture: ComponentFixture<ProvenanceBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProvenanceBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProvenanceBadgeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Default State', () => {
it('should default to unknown state', () => {
expect(component.state()).toBe('unknown');
});
it('should show label by default', () => {
expect(component.showLabel()).toBe(true);
});
it('should not show trust score by default', () => {
expect(component.showTrustScore()).toBe(false);
});
it('should not show age by default', () => {
expect(component.showAge()).toBe(false);
});
it('should not be clickable by default', () => {
expect(component.clickable()).toBe(false);
});
});
describe('State Icons', () => {
const stateIcons: Record<ProvenanceState, string> = {
cached: '⚡',
computed: '🔄',
stale: '⏳',
unknown: '❓',
};
Object.entries(stateIcons).forEach(([state, expectedIcon]) => {
it(`should display ${expectedIcon} icon for ${state} state`, () => {
fixture.componentRef.setInput('state', state as ProvenanceState);
fixture.detectChanges();
expect(component.icon()).toBe(expectedIcon);
const iconElement = fixture.debugElement.query(By.css('.provenance-badge__icon'));
expect(iconElement.nativeElement.textContent).toBe(expectedIcon);
});
});
});
describe('State Labels', () => {
const stateLabels: Record<ProvenanceState, string> = {
cached: 'Cached',
computed: 'Computed',
stale: 'Stale',
unknown: 'Unknown',
};
Object.entries(stateLabels).forEach(([state, expectedLabel]) => {
it(`should display "${expectedLabel}" label for ${state} state`, () => {
fixture.componentRef.setInput('state', state as ProvenanceState);
fixture.detectChanges();
expect(component.label()).toBe(expectedLabel);
});
});
});
describe('State CSS Classes', () => {
const stateClasses: Record<ProvenanceState, string> = {
cached: 'provenance-badge--cached',
computed: 'provenance-badge--computed',
stale: 'provenance-badge--stale',
unknown: 'provenance-badge--unknown',
};
Object.entries(stateClasses).forEach(([state, expectedClass]) => {
it(`should apply ${expectedClass} class for ${state} state`, () => {
fixture.componentRef.setInput('state', state as ProvenanceState);
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.provenance-badge'));
expect(badge.nativeElement.classList.contains(expectedClass)).toBe(true);
});
});
});
describe('Tooltip', () => {
it('should show default tooltip for unknown state', () => {
fixture.componentRef.setInput('state', 'unknown');
fixture.detectChanges();
expect(component.tooltip()).toBe('Unknown provenance state');
});
it('should show default tooltip for cached state without details', () => {
fixture.componentRef.setInput('state', 'cached');
fixture.detectChanges();
expect(component.tooltip()).toBe('Provenance-cached decision');
});
it('should show enhanced tooltip with cache details', () => {
const details: CacheDetails = {
source: 'redis',
ageSeconds: 42,
trustScore: 85,
executionTimeMs: 12,
};
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.detectChanges();
const tooltip = component.tooltip();
expect(tooltip).toContain('Provenance-cached');
expect(tooltip).toContain('Valkey'); // redis maps to Valkey
expect(tooltip).toContain('42s ago');
expect(tooltip).toContain('Trust: 85/100');
expect(tooltip).toContain('12ms');
});
it('should show computed tooltip', () => {
fixture.componentRef.setInput('state', 'computed');
fixture.detectChanges();
expect(component.tooltip()).toBe('Freshly computed decision');
});
it('should show stale tooltip', () => {
fixture.componentRef.setInput('state', 'stale');
fixture.detectChanges();
expect(component.tooltip()).toBe('Cache expired, recomputing...');
});
});
describe('Label Visibility', () => {
it('should show label when showLabel is true', () => {
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('showLabel', true);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(By.css('.provenance-badge__label'));
expect(labelElement).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(By.css('.provenance-badge__label'));
expect(labelElement).toBeFalsy();
});
});
describe('Trust Score Display', () => {
it('should not show trust score when showTrustScore is false', () => {
const details: CacheDetails = { trustScore: 85 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showTrustScore', false);
fixture.detectChanges();
const scoreElement = fixture.debugElement.query(By.css('.provenance-badge__score'));
expect(scoreElement).toBeFalsy();
});
it('should show trust score when showTrustScore is true', () => {
const details: CacheDetails = { trustScore: 85 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showTrustScore', true);
fixture.detectChanges();
const scoreElement = fixture.debugElement.query(By.css('.provenance-badge__score'));
expect(scoreElement).toBeTruthy();
expect(scoreElement.nativeElement.textContent.trim()).toBe('85');
});
it('should apply high score class for scores >= 80', () => {
const details: CacheDetails = { trustScore: 85 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showTrustScore', true);
fixture.detectChanges();
const scoreElement = fixture.debugElement.query(By.css('.provenance-badge__score'));
expect(scoreElement.nativeElement.classList.contains('provenance-badge__score--high')).toBe(true);
});
it('should apply medium score class for scores 50-79', () => {
const details: CacheDetails = { trustScore: 65 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showTrustScore', true);
fixture.detectChanges();
const scoreElement = fixture.debugElement.query(By.css('.provenance-badge__score'));
expect(scoreElement.nativeElement.classList.contains('provenance-badge__score--medium')).toBe(true);
});
it('should apply low score class for scores < 50', () => {
const details: CacheDetails = { trustScore: 35 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showTrustScore', true);
fixture.detectChanges();
const scoreElement = fixture.debugElement.query(By.css('.provenance-badge__score'));
expect(scoreElement.nativeElement.classList.contains('provenance-badge__score--low')).toBe(true);
});
});
describe('Age Display', () => {
it('should not show age when showAge is false', () => {
const details: CacheDetails = { ageSeconds: 42 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showAge', false);
fixture.detectChanges();
const ageElement = fixture.debugElement.query(By.css('.provenance-badge__age'));
expect(ageElement).toBeFalsy();
});
it('should show age when showAge is true', () => {
const details: CacheDetails = { ageSeconds: 42 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.componentRef.setInput('showAge', true);
fixture.detectChanges();
const ageElement = fixture.debugElement.query(By.css('.provenance-badge__age'));
expect(ageElement).toBeTruthy();
expect(ageElement.nativeElement.textContent.trim()).toBe('42s');
});
});
describe('Age Formatting', () => {
it('should format seconds < 60 as Xs', () => {
expect(component.formatAge(42)).toBe('42s');
});
it('should format 60-3599 seconds as Xm', () => {
expect(component.formatAge(120)).toBe('2m');
expect(component.formatAge(3599)).toBe('59m');
});
it('should format 3600-86399 seconds as Xh', () => {
expect(component.formatAge(3600)).toBe('1h');
expect(component.formatAge(7200)).toBe('2h');
});
it('should format >= 86400 seconds as Xd', () => {
expect(component.formatAge(86400)).toBe('1d');
expect(component.formatAge(172800)).toBe('2d');
});
});
describe('Source Formatting', () => {
it('should format none as N/A', () => {
expect(component.formatSource('none')).toBe('N/A');
});
it('should format inMemory as memory', () => {
expect(component.formatSource('inMemory')).toBe('memory');
});
it('should format redis as Valkey', () => {
expect(component.formatSource('redis')).toBe('Valkey');
});
});
describe('Click Interaction', () => {
it('should not emit clicked when not clickable', () => {
const clickedSpy = jasmine.createSpy('clicked');
component.clicked.subscribe(clickedSpy);
fixture.componentRef.setInput('clickable', false);
fixture.detectChanges();
component.handleClick();
expect(clickedSpy).not.toHaveBeenCalled();
});
it('should emit clicked when clickable and clicked', () => {
const clickedSpy = jasmine.createSpy('clicked');
component.clicked.subscribe(clickedSpy);
fixture.componentRef.setInput('clickable', true);
fixture.detectChanges();
component.handleClick();
expect(clickedSpy).toHaveBeenCalled();
});
it('should disable button when not clickable', () => {
fixture.componentRef.setInput('clickable', false);
fixture.detectChanges();
const button = fixture.debugElement.query(By.css('.provenance-badge'));
expect(button.nativeElement.disabled).toBe(true);
});
it('should enable button when clickable', () => {
fixture.componentRef.setInput('clickable', true);
fixture.detectChanges();
const button = fixture.debugElement.query(By.css('.provenance-badge'));
expect(button.nativeElement.disabled).toBe(false);
});
});
describe('Accessibility', () => {
it('should have role="button"', () => {
const badge = fixture.debugElement.query(By.css('.provenance-badge'));
expect(badge.nativeElement.getAttribute('role')).toBe('button');
});
it('should have appropriate aria-label', () => {
fixture.componentRef.setInput('state', 'cached');
fixture.detectChanges();
const badge = fixture.debugElement.query(By.css('.provenance-badge'));
expect(badge.nativeElement.getAttribute('aria-label')).toBe('Provenance-cached decision');
});
it('should include trust score in aria-label when available', () => {
const details: CacheDetails = { trustScore: 85 };
fixture.componentRef.setInput('state', 'cached');
fixture.componentRef.setInput('cacheDetails', details);
fixture.detectChanges();
expect(component.ariaLabel()).toContain('trust score 85 out of 100');
});
it('should have aria-hidden on icon', () => {
const icon = fixture.debugElement.query(By.css('.provenance-badge__icon'));
expect(icon.nativeElement.getAttribute('aria-hidden')).toBe('true');
});
});
});

View File

@@ -0,0 +1,386 @@
/**
* Provenance Badge Component.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-204 to PROV-8200-209 - ProvenanceBadge for cache status display
*
* Displays a compact badge indicating the provenance cache status of a decision,
* with optional tooltip showing cache details like age, trust score, and source.
*/
import { Component, input, computed, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { CacheSource, TrustScoreBreakdown } from '../../core/api/policy-engine.models';
/**
* Provenance cache status values (aligned with backend CacheSource enum).
* - cached: Decision retrieved from cache (fast path)
* - computed: Decision freshly computed this request
* - stale: Cache expired, recomputation in progress
* - unknown: Legacy data or no cache metadata
*/
export type ProvenanceState = 'cached' | 'computed' | 'stale' | 'unknown';
/**
* Cache details for tooltip display.
*/
export interface CacheDetails {
/** Source tier that served the cached decision */
source?: CacheSource;
/** Age of the cache entry in seconds */
ageSeconds?: number;
/** Trust score (0-100) */
trustScore?: number;
/** Trust score breakdown by evidence type */
trustScoreBreakdown?: TrustScoreBreakdown;
/** VeriKey hash */
veriKey?: string;
/** Cache entry expiration timestamp */
expiresAt?: Date;
/** Execution time in milliseconds */
executionTimeMs?: number;
}
/**
* Icon configuration for each provenance state.
*/
const STATE_ICONS: Record<ProvenanceState, string> = {
cached: '⚡', // Lightning bolt for fast cached response
computed: '🔄', // Refresh for freshly computed
stale: '⏳', // Hourglass for stale/recomputing
unknown: '❓', // Question mark for unknown
};
/**
* Color class configuration for each provenance state.
*/
const STATE_COLORS: Record<ProvenanceState, string> = {
cached: 'provenance-badge--cached', // Green
computed: 'provenance-badge--computed', // Blue
stale: 'provenance-badge--stale', // Amber/Orange
unknown: 'provenance-badge--unknown', // Gray
};
/**
* Default tooltip messages for each state.
*/
const STATE_TOOLTIPS: Record<ProvenanceState, string> = {
cached: 'Provenance-cached decision',
computed: 'Freshly computed decision',
stale: 'Cache expired, recomputing...',
unknown: 'Unknown provenance state',
};
/**
* Compact badge component displaying provenance cache status.
*
* Color scheme:
* - cached (green): Decision served from cache, fast path
* - computed (blue): Decision freshly computed this request
* - stale (amber): Cache expired, recomputation in progress
* - unknown (gray): No cache metadata available
*
* @example
* // Basic usage
* <stella-provenance-badge [state]="'cached'" />
*
* // With cache details in tooltip
* <stella-provenance-badge
* [state]="'cached'"
* [cacheDetails]="{ source: 'redis', ageSeconds: 42, trustScore: 85 }"
* (click)="openProofTree()"
* />
*
* // Without label (icon only)
* <stella-provenance-badge [state]="'computed'" [showLabel]="false" />
*/
@Component({
selector: 'stella-provenance-badge',
standalone: true,
imports: [CommonModule],
template: `
<button
type="button"
class="provenance-badge"
[class]="badgeClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
[disabled]="!isClickable()"
(click)="handleClick()"
role="button"
>
<span class="provenance-badge__icon" aria-hidden="true">{{ icon() }}</span>
@if (showLabel()) {
<span class="provenance-badge__label">{{ label() }}</span>
}
@if (showTrustScore() && cacheDetails()?.trustScore !== undefined) {
<span
class="provenance-badge__score"
[class.provenance-badge__score--high]="cacheDetails()!.trustScore! >= 80"
[class.provenance-badge__score--medium]="cacheDetails()!.trustScore! >= 50 && cacheDetails()!.trustScore! < 80"
[class.provenance-badge__score--low]="cacheDetails()!.trustScore! < 50"
>
{{ cacheDetails()!.trustScore }}
</span>
}
@if (showAge() && cacheDetails()?.ageSeconds !== undefined) {
<span class="provenance-badge__age">
{{ formatAge(cacheDetails()!.ageSeconds!) }}
</span>
}
</button>
`,
styles: [`
.provenance-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border: 1px solid transparent;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
transition: all 0.15s ease-in-out;
background: transparent;
&:disabled {
cursor: default;
}
&:not(:disabled):hover {
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--stella-focus-ring, #3b82f6);
outline-offset: 2px;
}
}
.provenance-badge__icon {
font-size: 0.875rem;
line-height: 1;
}
.provenance-badge__label {
text-transform: capitalize;
}
.provenance-badge__score {
padding: 0 0.25rem;
margin-left: 0.125rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.provenance-badge__age {
font-size: 0.625rem;
opacity: 0.8;
}
/* State: Cached (Green) */
.provenance-badge--cached {
background-color: var(--stella-success-bg, rgba(34, 197, 94, 0.1));
border-color: var(--stella-success-border, rgba(34, 197, 94, 0.3));
color: var(--stella-success-text, #166534);
.provenance-badge__score--high {
background-color: var(--stella-success-bg, rgba(34, 197, 94, 0.2));
}
}
/* State: Computed (Blue) */
.provenance-badge--computed {
background-color: var(--stella-info-bg, rgba(59, 130, 246, 0.1));
border-color: var(--stella-info-border, rgba(59, 130, 246, 0.3));
color: var(--stella-info-text, #1e40af);
}
/* State: Stale (Amber) */
.provenance-badge--stale {
background-color: var(--stella-warning-bg, rgba(245, 158, 11, 0.1));
border-color: var(--stella-warning-border, rgba(245, 158, 11, 0.3));
color: var(--stella-warning-text, #92400e);
}
/* State: Unknown (Gray) */
.provenance-badge--unknown {
background-color: var(--stella-neutral-bg, rgba(107, 114, 128, 0.1));
border-color: var(--stella-neutral-border, rgba(107, 114, 128, 0.3));
color: var(--stella-neutral-text, #374151);
}
/* Trust Score Colors */
.provenance-badge__score--high {
background-color: rgba(34, 197, 94, 0.2);
color: #166534;
}
.provenance-badge__score--medium {
background-color: rgba(245, 158, 11, 0.2);
color: #92400e;
}
.provenance-badge__score--low {
background-color: rgba(239, 68, 68, 0.2);
color: #991b1b;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.provenance-badge--cached {
background-color: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #86efac;
}
.provenance-badge--computed {
background-color: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.4);
color: #93c5fd;
}
.provenance-badge--stale {
background-color: rgba(245, 158, 11, 0.15);
border-color: rgba(245, 158, 11, 0.4);
color: #fcd34d;
}
.provenance-badge--unknown {
background-color: rgba(156, 163, 175, 0.15);
border-color: rgba(156, 163, 175, 0.4);
color: #d1d5db;
}
}
`],
})
export class ProvenanceBadgeComponent {
/** Provenance cache state */
readonly state = input<ProvenanceState>('unknown');
/** Optional cache details for enhanced tooltip */
readonly cacheDetails = input<CacheDetails>();
/** Whether to show the text label */
readonly showLabel = input<boolean>(true);
/** Whether to show trust score badge */
readonly showTrustScore = input<boolean>(false);
/** Whether to show cache age */
readonly showAge = input<boolean>(false);
/** Whether the badge is clickable (enables interaction) */
readonly clickable = input<boolean>(false);
/** Emitted when badge is clicked (only if clickable) */
readonly clicked = output<void>();
/** Icon for current state */
readonly icon = computed(() => STATE_ICONS[this.state()]);
/** Label text for current state */
readonly label = computed(() => {
const labels: Record<ProvenanceState, string> = {
cached: 'Cached',
computed: 'Computed',
stale: 'Stale',
unknown: 'Unknown',
};
return labels[this.state()];
});
/** CSS class for badge styling */
readonly badgeClass = computed(() => {
return `provenance-badge ${STATE_COLORS[this.state()]}`;
});
/** Tooltip text with cache details */
readonly tooltip = computed(() => {
const state = this.state();
const details = this.cacheDetails();
if (state === 'cached' && details) {
const parts: string[] = ['Provenance-cached'];
if (details.source) {
parts.push(`from ${this.formatSource(details.source)}`);
}
if (details.ageSeconds !== undefined) {
parts.push(`(${this.formatAge(details.ageSeconds)} ago)`);
}
if (details.trustScore !== undefined) {
parts.push(`• Trust: ${details.trustScore}/100`);
}
if (details.executionTimeMs !== undefined) {
parts.push(`${details.executionTimeMs}ms`);
}
return parts.join(' ');
}
return STATE_TOOLTIPS[state];
});
/** Accessible label for screen readers */
readonly ariaLabel = computed(() => {
const state = this.state();
const details = this.cacheDetails();
const baseLabel = STATE_TOOLTIPS[state];
if (state === 'cached' && details?.trustScore !== undefined) {
return `${baseLabel}, trust score ${details.trustScore} out of 100`;
}
return baseLabel;
});
/** Whether the badge responds to clicks */
readonly isClickable = computed(() => this.clickable());
/**
* Handle badge click event.
*/
handleClick(): void {
if (this.isClickable()) {
this.clicked.emit();
}
}
/**
* Format cache source for display.
*/
formatSource(source: CacheSource): string {
const sourceLabels: Record<CacheSource, string> = {
none: 'N/A',
inMemory: 'memory',
redis: 'Valkey',
};
return sourceLabels[source] || source;
}
/**
* Format age in human-readable format.
*/
formatAge(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`;
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `${minutes}m`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `${hours}h`;
}
const days = Math.floor(seconds / 86400);
return `${days}d`;
}
}

View File

@@ -0,0 +1,315 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TimelineEventComponent, TimelineEvent, TimelineEventType } from './timeline-event.component';
describe('TimelineEventComponent', () => {
let component: TimelineEventComponent;
let fixture: ComponentFixture<TimelineEventComponent>;
const mockEvent: TimelineEvent = {
id: 'evt-001',
type: 'scan_completed' as TimelineEventType,
timestamp: new Date().toISOString(),
title: 'Scan Completed',
description: 'Full container scan finished successfully',
severity: 'info',
actor: 'scanner-worker-01',
source: 'StellaOps.Scanner.Worker',
correlationId: 'corr-abc123',
traceId: 'trace-xyz789',
metadata: {
imageDigest: 'sha256:abc123',
findingsCount: 42,
duration: '2m 15s',
},
};
const mockCacheEvent: TimelineEvent = {
id: 'evt-002',
type: 'cache_hit' as TimelineEventType,
timestamp: new Date().toISOString(),
title: 'Cache Hit',
description: 'Decision retrieved from cache',
severity: 'info',
cacheSource: 'valkey',
veriKey: 'vk:sha256:abc123def456',
trustScore: 85,
cacheAgeSeconds: 300,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TimelineEventComponent],
}).compileComponents();
fixture = TestBed.createComponent(TimelineEventComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('event display', () => {
it('should display event title and description', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.timeline-event__title');
const desc = fixture.nativeElement.querySelector('.timeline-event__description');
expect(title.textContent).toContain('Scan Completed');
expect(desc.textContent).toContain('Full container scan finished successfully');
});
it('should display correct icon for scan_completed event', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('.timeline-event__icon');
expect(icon.textContent.trim()).toBe('✅');
});
it('should display correct icon for cache_hit event', () => {
fixture.componentRef.setInput('event', mockCacheEvent);
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('.timeline-event__icon');
expect(icon.textContent.trim()).toBe('💾');
});
it('should display actor when provided', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const actor = fixture.nativeElement.querySelector('.timeline-event__actor');
expect(actor.textContent).toContain('scanner-worker-01');
});
it('should display source when provided', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const source = fixture.nativeElement.querySelector('.timeline-event__source');
expect(source.textContent).toContain('StellaOps.Scanner.Worker');
});
});
describe('severity styling', () => {
it('should apply info severity class', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const article = fixture.nativeElement.querySelector('.timeline-event');
expect(article.classList.contains('timeline-event--info')).toBe(true);
});
it('should apply error severity class', () => {
const errorEvent: TimelineEvent = { ...mockEvent, severity: 'error' };
fixture.componentRef.setInput('event', errorEvent);
fixture.detectChanges();
const article = fixture.nativeElement.querySelector('.timeline-event');
expect(article.classList.contains('timeline-event--error')).toBe(true);
});
it('should apply warning severity class', () => {
const warnEvent: TimelineEvent = { ...mockEvent, severity: 'warning' };
fixture.componentRef.setInput('event', warnEvent);
fixture.detectChanges();
const article = fixture.nativeElement.querySelector('.timeline-event');
expect(article.classList.contains('timeline-event--warning')).toBe(true);
});
});
describe('provenance badge', () => {
it('should show provenance badge for cache_hit events', () => {
fixture.componentRef.setInput('event', mockCacheEvent);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('stellaops-provenance-badge');
expect(badge).toBeTruthy();
});
it('should not show provenance badge for non-cache events without cache data', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('stellaops-provenance-badge');
expect(badge).toBeFalsy();
});
it('should pass correct cache details to provenance badge', () => {
fixture.componentRef.setInput('event', mockCacheEvent);
fixture.detectChanges();
const cacheDetails = component.cacheDetails();
expect(cacheDetails).toBeTruthy();
expect(cacheDetails?.veriKey).toBe('vk:sha256:abc123def456');
// valkey maps to 'redis' in CacheSource type
expect(cacheDetails?.source).toBe('redis');
expect(cacheDetails?.ageSeconds).toBe(300);
});
});
describe('expandable details', () => {
it('should expand details when showDetails input is true', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.timeline-event__expanded');
expect(details).toBeTruthy();
});
it('should show trace ID in expanded details', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const traceId = fixture.nativeElement.querySelector('.timeline-event__trace-id');
expect(traceId.textContent).toContain('trace-xyz789');
});
it('should show correlation ID in expanded details', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const corrId = fixture.nativeElement.querySelector('.timeline-event__correlation-id');
expect(corrId.textContent).toContain('corr-abc123');
});
it('should show metadata in expanded details', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const metadata = fixture.nativeElement.querySelector('.timeline-event__metadata');
expect(metadata).toBeTruthy();
expect(metadata.textContent).toContain('imageDigest');
expect(metadata.textContent).toContain('sha256:abc123');
});
});
describe('view details functionality', () => {
it('should emit viewDetails event when toggle button is clicked', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('expandable', true);
fixture.detectChanges();
const viewDetailsSpy = jasmine.createSpy('viewDetails');
component.viewDetails.subscribe(viewDetailsSpy);
const toggleBtn = fixture.nativeElement.querySelector('.timeline-event__toggle-btn');
toggleBtn.click();
expect(viewDetailsSpy).toHaveBeenCalled();
});
});
describe('copy functionality', () => {
it('should emit copyTraceId when trace ID is copied', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const copySpy = jasmine.createSpy('copyTraceId');
component.copyTraceId.subscribe(copySpy);
const copyBtn = fixture.nativeElement.querySelector('.timeline-event__copy-trace');
copyBtn?.click();
expect(copySpy).toHaveBeenCalledWith('trace-xyz789');
});
});
describe('relative time', () => {
it('should display relative time for recent events', () => {
const recentEvent: TimelineEvent = {
...mockEvent,
timestamp: new Date(Date.now() - 30000).toISOString(), // 30 seconds ago
};
fixture.componentRef.setInput('event', recentEvent);
fixture.detectChanges();
const time = fixture.nativeElement.querySelector('.timeline-event__time');
expect(time.textContent).toContain('sec');
});
it('should display relative time for older events', () => {
const olderEvent: TimelineEvent = {
...mockEvent,
timestamp: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
};
fixture.componentRef.setInput('event', olderEvent);
fixture.detectChanges();
const time = fixture.nativeElement.querySelector('.timeline-event__time');
expect(time.textContent).toContain('hour');
});
});
describe('event types', () => {
const eventTypes: TimelineEventType[] = [
'scan_started',
'scan_completed',
'finding_created',
'finding_updated',
'finding_resolved',
'policy_evaluated',
'vex_updated',
'attestation_created',
'cache_hit',
'cache_miss',
'cache_invalidated',
'bundle_imported',
'bundle_exported',
'signer_revoked',
'epoch_advanced',
'unknown',
];
eventTypes.forEach((type) => {
it(`should render ${type} event type`, () => {
const event: TimelineEvent = {
...mockEvent,
type,
title: `Event: ${type}`,
};
fixture.componentRef.setInput('event', event);
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.timeline-event__title');
expect(title.textContent).toContain(`Event: ${type}`);
});
});
});
describe('accessibility', () => {
it('should have appropriate ARIA attributes', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const article = fixture.nativeElement.querySelector('.timeline-event');
expect(article.getAttribute('role')).toBe('article');
});
it('should have aria-label with event description', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.detectChanges();
const article = fixture.nativeElement.querySelector('.timeline-event');
expect(article.getAttribute('aria-label')).toContain('Scan Completed');
});
it('should have aria-expanded on toggle button', () => {
fixture.componentRef.setInput('event', mockEvent);
fixture.componentRef.setInput('expandable', true);
fixture.detectChanges();
const toggleBtn = fixture.nativeElement.querySelector('.timeline-event__toggle-btn');
expect(toggleBtn.getAttribute('aria-expanded')).toBe('false');
});
});
});

View File

@@ -0,0 +1,678 @@
/**
* Timeline Event Component.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-208 - Add ProvenanceBadge to TimelineEventComponent
*
* Displays a single timeline event (audit log, scan event, policy change, etc.)
* with provenance badge integration for cached decisions.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProvenanceBadgeComponent, ProvenanceState, CacheDetails } from './provenance-badge.component';
import type { CacheSource } from '../../core/api/policy-engine.models';
/**
* Timeline event types that can be displayed.
*/
export type TimelineEventType =
| 'scan_started'
| 'scan_completed'
| 'finding_created'
| 'finding_updated'
| 'finding_resolved'
| 'policy_evaluated'
| 'vex_updated'
| 'attestation_created'
| 'cache_hit'
| 'cache_miss'
| 'cache_invalidated'
| 'bundle_imported'
| 'bundle_exported'
| 'signer_revoked'
| 'epoch_advanced'
| 'unknown';
/**
* Severity levels for timeline events.
*/
export type TimelineEventSeverity = 'info' | 'warning' | 'error' | 'success' | 'neutral';
/**
* Timeline event data model.
*/
export interface TimelineEvent {
readonly id: string;
readonly type: TimelineEventType;
readonly timestamp: string; // ISO 8601
readonly title: string;
readonly description?: string;
readonly severity?: TimelineEventSeverity;
readonly actor?: string;
readonly source?: string;
readonly correlationId?: string;
readonly traceId?: string;
readonly metadata?: Record<string, unknown>;
// Provcache fields
readonly cacheSource?: 'none' | 'valkey' | 'postgres';
readonly veriKey?: string;
readonly trustScore?: number;
readonly cacheAgeSeconds?: number;
}
/**
* Timeline event component displaying a single event row.
*
* Features:
* - Event type icon and color coding
* - Timestamp display with relative time option
* - Expandable metadata/details section
* - Provenance badge for cache-related events
* - Copy trace/correlation IDs
*
* @example
* <stellaops-timeline-event
* [event]="timelineEvent"
* [showRelativeTime]="true"
* (viewDetails)="openEventDetails($event)"
* (viewProofTree)="openProofTree($event)"
* />
*/
@Component({
selector: 'stellaops-timeline-event',
standalone: true,
imports: [CommonModule, ProvenanceBadgeComponent],
template: `
<article
class="timeline-event"
[class]="'timeline-event--' + severityClass()"
[class.timeline-event--expanded]="isExpanded()"
[attr.aria-label]="ariaLabel()"
role="article"
>
<!-- Icon -->
<div class="timeline-event__icon" [attr.aria-hidden]="true">
<span class="timeline-event__icon-emoji">{{ eventIcon() }}</span>
</div>
<!-- Main Content -->
<div class="timeline-event__content">
<div class="timeline-event__header">
<span class="timeline-event__title">{{ event()?.title ?? 'Unknown Event' }}</span>
<span class="timeline-event__type">{{ formatEventType() }}</span>
</div>
@if (event()?.description) {
<p class="timeline-event__description">{{ event()?.description }}</p>
}
<div class="timeline-event__meta">
<time class="timeline-event__time" [attr.datetime]="event()?.timestamp">
{{ formattedTime() }}
</time>
@if (event()?.actor) {
<span class="timeline-event__actor">by {{ event()?.actor }}</span>
}
@if (event()?.source) {
<span class="timeline-event__source">from {{ event()?.source }}</span>
}
</div>
</div>
<!-- Provenance Badge (for cache events) -->
@if (showProvenanceBadge()) {
<div class="timeline-event__provenance">
<stellaops-provenance-badge
[state]="provenanceState()"
[cacheDetails]="cacheDetails()"
[showLabel]="false"
[clickable]="true"
(clicked)="onViewProofTree()"
/>
</div>
}
<!-- Actions -->
<div class="timeline-event__actions">
@if (hasDetails()) {
<button
class="timeline-event__action timeline-event__action--expand"
(click)="toggleExpand()"
[attr.aria-expanded]="isExpanded()"
[attr.aria-label]="isExpanded() ? 'Collapse details' : 'Expand details'"
type="button"
>
{{ isExpanded() ? '▲' : '▼' }}
</button>
}
@if (event()?.traceId || event()?.correlationId) {
<button
class="timeline-event__action timeline-event__action--copy"
(click)="onCopyTraceId()"
title="Copy trace ID"
type="button"
>
📋
</button>
}
</div>
<!-- Expanded Details -->
@if (isExpanded() && hasDetails()) {
<div class="timeline-event__details" role="region" aria-label="Event details">
@if (event()?.traceId) {
<div class="timeline-event__detail-row">
<span class="timeline-event__detail-label">Trace ID:</span>
<code class="timeline-event__detail-value">{{ event()?.traceId }}</code>
</div>
}
@if (event()?.correlationId) {
<div class="timeline-event__detail-row">
<span class="timeline-event__detail-label">Correlation ID:</span>
<code class="timeline-event__detail-value">{{ event()?.correlationId }}</code>
</div>
}
@if (event()?.veriKey) {
<div class="timeline-event__detail-row">
<span class="timeline-event__detail-label">VeriKey:</span>
<code class="timeline-event__detail-value timeline-event__detail-value--truncate">
{{ event()?.veriKey }}
</code>
</div>
}
@if (event()?.trustScore !== undefined) {
<div class="timeline-event__detail-row">
<span class="timeline-event__detail-label">Trust Score:</span>
<span class="timeline-event__detail-value">{{ event()?.trustScore }}</span>
</div>
}
@if (metadataEntries().length > 0) {
<details class="timeline-event__metadata">
<summary>Metadata ({{ metadataEntries().length }} fields)</summary>
<dl class="timeline-event__metadata-list">
@for (entry of metadataEntries(); track entry.key) {
<div class="timeline-event__metadata-item">
<dt>{{ entry.key }}</dt>
<dd>{{ formatMetadataValue(entry.value) }}</dd>
</div>
}
</dl>
</details>
}
</div>
}
</article>
`,
styles: [`
.timeline-event {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 0.75rem;
align-items: start;
padding: 0.75rem 1rem;
border-left: 3px solid #6c757d;
background: #fff;
margin-bottom: 0.5rem;
border-radius: 0 4px 4px 0;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
background: #f8f9fa;
}
&--expanded {
grid-template-rows: auto auto;
}
&--info { border-left-color: #17a2b8; }
&--success { border-left-color: #28a745; }
&--warning { border-left-color: #ffc107; }
&--error { border-left-color: #dc3545; }
&--neutral { border-left-color: #6c757d; }
}
.timeline-event__icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #f1f3f4;
border-radius: 50%;
flex-shrink: 0;
}
.timeline-event__icon-emoji {
font-size: 1rem;
}
.timeline-event__content {
min-width: 0;
}
.timeline-event__header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.timeline-event__title {
font-weight: 600;
font-size: 0.9375rem;
color: #212529;
}
.timeline-event__type {
font-size: 0.75rem;
color: #6c757d;
background: #e9ecef;
padding: 0.125rem 0.375rem;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.timeline-event__description {
margin: 0.25rem 0;
font-size: 0.875rem;
color: #495057;
line-height: 1.4;
}
.timeline-event__meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
color: #6c757d;
}
.timeline-event__time {
&::before {
content: '🕐 ';
}
}
.timeline-event__actor {
&::before {
content: '👤 ';
}
}
.timeline-event__source {
&::before {
content: '📍 ';
}
}
.timeline-event__provenance {
flex-shrink: 0;
}
.timeline-event__actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.timeline-event__action {
width: 28px;
height: 28px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 0.75rem;
&:hover {
background: #e9ecef;
color: #495057;
}
&:focus {
outline: 2px solid #007bff;
outline-offset: 1px;
}
}
.timeline-event__details {
grid-column: 1 / -1;
padding-top: 0.75rem;
border-top: 1px solid #e9ecef;
margin-top: 0.5rem;
}
.timeline-event__detail-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
}
.timeline-event__detail-label {
color: #6c757d;
min-width: 100px;
}
.timeline-event__detail-value {
color: #212529;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 0.75rem;
word-break: break-all;
&--truncate {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.timeline-event__metadata {
margin-top: 0.5rem;
summary {
cursor: pointer;
font-size: 0.8125rem;
color: #6c757d;
padding: 0.25rem 0;
&:hover {
color: #495057;
}
}
}
.timeline-event__metadata-list {
margin: 0.5rem 0 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.timeline-event__metadata-item {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
dt {
color: #6c757d;
min-width: 100px;
}
dd {
margin: 0;
color: #495057;
font-family: monospace;
word-break: break-all;
}
}
@media (prefers-color-scheme: dark) {
.timeline-event {
background: #1e1e1e;
border-left-color: #6c757d;
&:hover {
background: #252525;
}
&--info { border-left-color: #4fc3f7; }
&--success { border-left-color: #81c784; }
&--warning { border-left-color: #ffb74d; }
&--error { border-left-color: #e57373; }
}
.timeline-event__icon {
background: #333;
}
.timeline-event__title {
color: #f5f5f5;
}
.timeline-event__type {
background: #333;
color: #adb5bd;
}
.timeline-event__description {
color: #adb5bd;
}
.timeline-event__details {
border-top-color: #333;
}
.timeline-event__detail-value {
color: #f5f5f5;
}
.timeline-event__action:hover {
background: #333;
color: #f5f5f5;
}
}
`],
})
export class TimelineEventComponent {
// Inputs
readonly event = input<TimelineEvent | null>(null);
readonly showRelativeTime = input<boolean>(false);
readonly compactMode = input<boolean>(false);
// Outputs
readonly viewDetails = output<TimelineEvent>();
readonly viewProofTree = output<TimelineEvent>();
readonly copyTraceId = output<string>();
// Internal state
readonly isExpanded = signal(false);
// Computed properties
readonly severityClass = computed(() => {
const e = this.event();
if (!e) return 'neutral';
return e.severity ?? this.inferSeverity(e.type);
});
readonly eventIcon = computed(() => {
const e = this.event();
if (!e) return '❓';
const icons: Record<TimelineEventType, string> = {
scan_started: '🔍',
scan_completed: '✅',
finding_created: '🆕',
finding_updated: '📝',
finding_resolved: '✔️',
policy_evaluated: '📋',
vex_updated: '📄',
attestation_created: '🔏',
cache_hit: '⚡',
cache_miss: '💨',
cache_invalidated: '🗑️',
bundle_imported: '📥',
bundle_exported: '📤',
signer_revoked: '🔐',
epoch_advanced: '⏭️',
unknown: '❓',
};
return icons[e.type] ?? '❓';
});
readonly provenanceState = computed((): ProvenanceState => {
const e = this.event();
if (!e) return 'unknown';
if (e.type === 'cache_hit') return 'cached';
if (e.type === 'cache_miss') return 'computed';
if (e.type === 'cache_invalidated') return 'stale';
if (e.cacheSource === 'valkey' || e.cacheSource === 'postgres') {
return 'cached';
}
return 'unknown';
});
readonly cacheDetails = computed((): CacheDetails | null => {
const e = this.event();
if (!e || !e.veriKey) return null;
// Map component's cacheSource ('none' | 'valkey' | 'postgres') to CacheSource type ('none' | 'inMemory' | 'redis')
const source: CacheSource =
e.cacheSource === 'valkey' ? 'redis' :
e.cacheSource === 'postgres' ? 'inMemory' :
'none';
return {
veriKey: e.veriKey,
source,
ageSeconds: e.cacheAgeSeconds ?? 0,
};
});
readonly showProvenanceBadge = computed(() => {
const e = this.event();
if (!e) return false;
return (
e.type === 'cache_hit' ||
e.type === 'cache_miss' ||
e.type === 'cache_invalidated' ||
e.type === 'policy_evaluated' ||
!!e.veriKey
);
});
readonly formattedTime = computed(() => {
const e = this.event();
if (!e?.timestamp) return '--';
const date = new Date(e.timestamp);
if (this.showRelativeTime()) {
return this.formatRelativeTime(date);
}
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
readonly ariaLabel = computed(() => {
const e = this.event();
if (!e) return 'Timeline event';
return `${this.formatEventType()}: ${e.title}`;
});
readonly metadataEntries = computed(() => {
const e = this.event();
if (!e?.metadata) return [];
return Object.entries(e.metadata).map(([key, value]) => ({ key, value }));
});
readonly hasDetails = computed(() => {
const e = this.event();
if (!e) return false;
return !!(
e.traceId ||
e.correlationId ||
e.veriKey ||
e.trustScore !== undefined ||
(e.metadata && Object.keys(e.metadata).length > 0)
);
});
// Methods
formatEventType(): string {
const e = this.event();
if (!e) return 'Unknown';
return e.type
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
toggleExpand(): void {
this.isExpanded.update((v) => !v);
}
onViewProofTree(): void {
const e = this.event();
if (e) {
this.viewProofTree.emit(e);
}
}
onCopyTraceId(): void {
const e = this.event();
const id = e?.traceId ?? e?.correlationId;
if (id) {
navigator.clipboard.writeText(id).catch(console.error);
this.copyTraceId.emit(id);
}
}
formatMetadataValue(value: unknown): string {
if (value === null || value === undefined) return '—';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
private inferSeverity(type: TimelineEventType): TimelineEventSeverity {
switch (type) {
case 'scan_completed':
case 'finding_resolved':
case 'cache_hit':
return 'success';
case 'finding_created':
case 'cache_miss':
case 'epoch_advanced':
return 'info';
case 'signer_revoked':
case 'cache_invalidated':
return 'warning';
case 'finding_updated':
case 'vex_updated':
return 'info';
default:
return 'neutral';
}
}
private formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
}

View File

@@ -0,0 +1,390 @@
/**
* Trust Score Component Tests.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-215 - Add Storybook stories for score ranges
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
TrustScoreDisplayComponent,
TrustScoreMode,
TrustScoreThresholds,
} from './trust-score.component';
import type { TrustScoreBreakdown } from '../../core/api/policy-engine.models';
describe('TrustScoreDisplayComponent', () => {
let component: TrustScoreDisplayComponent;
let fixture: ComponentFixture<TrustScoreDisplayComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TrustScoreDisplayComponent],
}).compileComponents();
fixture = TestBed.createComponent(TrustScoreDisplayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('Default State', () => {
it('should default to score 0', () => {
expect(component.score()).toBe(0);
});
it('should default to compact mode', () => {
expect(component.mode()).toBe('compact');
});
it('should not show percent sign by default', () => {
expect(component.showPercentSign()).toBe(false);
});
it('should not show breakdown by default', () => {
expect(component.showBreakdown()).toBe(false);
});
});
describe('Score Display', () => {
it('should display the score value', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
const valueElement = fixture.debugElement.query(By.css('.trust-score__value'));
expect(valueElement.nativeElement.textContent).toBe('85');
});
it('should show percent sign when enabled', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('showPercentSign', true);
fixture.detectChanges();
const percentElement = fixture.debugElement.query(By.css('.trust-score__percent'));
expect(percentElement).toBeTruthy();
expect(percentElement.nativeElement.textContent).toBe('%');
});
it('should hide percent sign when disabled', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('showPercentSign', false);
fixture.detectChanges();
const percentElement = fixture.debugElement.query(By.css('.trust-score__percent'));
expect(percentElement).toBeFalsy();
});
});
describe('Color Thresholds', () => {
it('should apply high class for scores >= 80', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
expect(component.donutColorClass()).toBe('trust-score__svg--high');
});
it('should apply medium class for scores 50-79', () => {
fixture.componentRef.setInput('score', 65);
fixture.detectChanges();
expect(component.donutColorClass()).toBe('trust-score__svg--medium');
});
it('should apply low class for scores < 50', () => {
fixture.componentRef.setInput('score', 35);
fixture.detectChanges();
expect(component.donutColorClass()).toBe('trust-score__svg--low');
});
it('should respect custom thresholds', () => {
const customThresholds: TrustScoreThresholds = { high: 90, medium: 60 };
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('thresholds', customThresholds);
fixture.detectChanges();
// 85 < 90 high, >= 60 medium
expect(component.donutColorClass()).toBe('trust-score__svg--medium');
});
});
describe('Status Labels', () => {
it('should show "High Trust" for high scores', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
expect(component.statusLabel()).toBe('High Trust');
});
it('should show "Medium Trust" for medium scores', () => {
fixture.componentRef.setInput('score', 65);
fixture.detectChanges();
expect(component.statusLabel()).toBe('Medium Trust');
});
it('should show "Low Trust" for low scores', () => {
fixture.componentRef.setInput('score', 35);
fixture.detectChanges();
expect(component.statusLabel()).toBe('Low Trust');
});
});
describe('Display Modes', () => {
it('should apply compact class for compact mode', () => {
fixture.componentRef.setInput('mode', 'compact' as TrustScoreMode);
fixture.detectChanges();
expect(component.containerClass()).toContain('trust-score--compact');
});
it('should apply full class for full mode', () => {
fixture.componentRef.setInput('mode', 'full' as TrustScoreMode);
fixture.detectChanges();
expect(component.containerClass()).toContain('trust-score--full');
});
it('should show details in full mode', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('mode', 'full' as TrustScoreMode);
fixture.detectChanges();
const detailsElement = fixture.debugElement.query(By.css('.trust-score__details'));
expect(detailsElement).toBeTruthy();
});
it('should hide details in compact mode', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('mode', 'compact' as TrustScoreMode);
fixture.detectChanges();
const detailsElement = fixture.debugElement.query(By.css('.trust-score__details'));
expect(detailsElement).toBeFalsy();
});
});
describe('Donut Chart', () => {
it('should render SVG donut', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
const svgElement = fixture.debugElement.query(By.css('.trust-score__svg'));
expect(svgElement).toBeTruthy();
});
it('should calculate correct stroke-dasharray for score', () => {
fixture.componentRef.setInput('score', 75);
fixture.detectChanges();
// 75% of 100 = 75, remaining = 25
expect(component.strokeDasharray()).toBe('75 25');
});
it('should calculate correct stroke-dasharray for 0', () => {
fixture.componentRef.setInput('score', 0);
fixture.detectChanges();
expect(component.strokeDasharray()).toBe('0 100');
});
it('should calculate correct stroke-dasharray for 100', () => {
fixture.componentRef.setInput('score', 100);
fixture.detectChanges();
expect(component.strokeDasharray()).toBe('100 0');
});
});
describe('Breakdown Display', () => {
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
it('should not show breakdown panel when showBreakdown is false', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.componentRef.setInput('showBreakdown', false);
fixture.detectChanges();
const breakdownElement = fixture.debugElement.query(By.css('.trust-score__breakdown'));
expect(breakdownElement).toBeFalsy();
});
it('should show breakdown panel when showBreakdown is true', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.componentRef.setInput('showBreakdown', true);
fixture.detectChanges();
const breakdownElement = fixture.debugElement.query(By.css('.trust-score__breakdown'));
expect(breakdownElement).toBeTruthy();
});
it('should display all components in breakdown', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.componentRef.setInput('showBreakdown', true);
fixture.detectChanges();
const componentElements = fixture.debugElement.queryAll(By.css('.trust-score__component'));
expect(componentElements.length).toBe(5);
});
it('should display computed total from breakdown', () => {
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.componentRef.setInput('showBreakdown', true);
fixture.detectChanges();
// Total = 90*0.25 + 80*0.20 + 85*0.20 + 75*0.15 + 90*0.20 = 22.5 + 16 + 17 + 11.25 + 18 = 84.75 ≈ 85
expect(component.computedTotal()).toBe(85);
});
});
describe('Components Computed Property', () => {
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
it('should return empty array when no breakdown', () => {
expect(component.components()).toEqual([]);
});
it('should map components with labels and colors', () => {
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.detectChanges();
const comps = component.components();
expect(comps.length).toBe(5);
expect(comps[0].label).toBe('Reachability');
expect(comps[0].color).toBe('#3b82f6');
expect(comps[1].label).toBe('SBOM');
expect(comps[1].color).toBe('#8b5cf6');
});
});
describe('Tooltip', () => {
it('should show basic tooltip without breakdown', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
expect(component.donutTooltip()).toBe('Trust Score: 85/100');
});
it('should show detailed tooltip with breakdown', () => {
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.detectChanges();
const tooltip = component.donutTooltip();
expect(tooltip).toContain('Trust Score: 85/100');
expect(tooltip).toContain('Reachability: 90');
});
});
describe('Format Percent', () => {
it('should format decimal as percentage', () => {
expect(component.formatPercent(0.225)).toBe('23%');
expect(component.formatPercent(0.5)).toBe('50%');
expect(component.formatPercent(1.0)).toBe('100%');
expect(component.formatPercent(0)).toBe('0%');
});
});
describe('Accessibility', () => {
it('should have appropriate aria-label', () => {
fixture.componentRef.setInput('score', 85);
fixture.detectChanges();
expect(component.ariaLabel()).toContain('Trust score 85 out of 100');
expect(component.ariaLabel()).toContain('High Trust');
});
it('should have role="figure" on container', () => {
const container = fixture.debugElement.query(By.css('.trust-score'));
expect(container.nativeElement.getAttribute('role')).toBe('figure');
});
it('should have role="list" on breakdown', () => {
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
fixture.componentRef.setInput('score', 85);
fixture.componentRef.setInput('breakdown', mockBreakdown);
fixture.componentRef.setInput('showBreakdown', true);
fixture.detectChanges();
const breakdown = fixture.debugElement.query(By.css('.trust-score__breakdown'));
expect(breakdown.nativeElement.getAttribute('role')).toBe('list');
});
});
describe('Edge Cases', () => {
it('should handle score of 0', () => {
fixture.componentRef.setInput('score', 0);
fixture.detectChanges();
expect(component.statusLabel()).toBe('Low Trust');
expect(component.donutColorClass()).toBe('trust-score__svg--low');
});
it('should handle score of 100', () => {
fixture.componentRef.setInput('score', 100);
fixture.detectChanges();
expect(component.statusLabel()).toBe('High Trust');
expect(component.donutColorClass()).toBe('trust-score__svg--high');
});
it('should handle boundary score 80 (exactly at high threshold)', () => {
fixture.componentRef.setInput('score', 80);
fixture.detectChanges();
expect(component.statusLabel()).toBe('High Trust');
expect(component.donutColorClass()).toBe('trust-score__svg--high');
});
it('should handle boundary score 50 (exactly at medium threshold)', () => {
fixture.componentRef.setInput('score', 50);
fixture.detectChanges();
expect(component.statusLabel()).toBe('Medium Trust');
expect(component.donutColorClass()).toBe('trust-score__svg--medium');
});
it('should handle score 49 (just below medium threshold)', () => {
fixture.componentRef.setInput('score', 49);
fixture.detectChanges();
expect(component.statusLabel()).toBe('Low Trust');
expect(component.donutColorClass()).toBe('trust-score__svg--low');
});
});
});

View File

@@ -0,0 +1,528 @@
/**
* Trust Score Component.
* Sprint: SPRINT_8200_0001_0003 (Provcache UX & Observability)
* Task: PROV-8200-210 to PROV-8200-215 - TrustScore display with breakdown
*
* Displays a trust score (0-100) with donut chart visualization showing
* the breakdown of contributing evidence types (reachability, SBOM, VEX, policy, signer).
*/
import { Component, input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { TrustScoreBreakdown, TrustScoreComponent as TrustScoreData } from '../../core/api/policy-engine.models';
/**
* Trust score display mode.
*/
export type TrustScoreMode = 'compact' | 'full' | 'donut-only';
/**
* Color thresholds for trust score visualization.
*/
export interface TrustScoreThresholds {
/** Score >= this value shows green (default: 80) */
high: number;
/** Score >= this value shows yellow (default: 50) */
medium: number;
/** Score < medium shows red */
}
const DEFAULT_THRESHOLDS: TrustScoreThresholds = {
high: 80,
medium: 50,
};
/**
* Standard weights for trust score components.
*/
const COMPONENT_CONFIGS: { key: keyof TrustScoreBreakdown; label: string; color: string }[] = [
{ key: 'reachability', label: 'Reachability', color: '#3b82f6' }, // Blue
{ key: 'sbomCompleteness', label: 'SBOM', color: '#8b5cf6' }, // Purple
{ key: 'vexCoverage', label: 'VEX', color: '#10b981' }, // Green
{ key: 'policyFreshness', label: 'Policy', color: '#f59e0b' }, // Amber
{ key: 'signerTrust', label: 'Signer', color: '#ef4444' }, // Red
];
/**
* Component displaying trust score with donut chart visualization.
*
* Features:
* - Donut chart showing proportional breakdown of evidence types
* - Color coding based on thresholds (green/yellow/red)
* - Tooltip with detailed component percentages
* - Compact and full display modes
*
* @example
* <stella-trust-score [score]="85" />
* <stella-trust-score [score]="85" [breakdown]="trustScoreBreakdown" [mode]="'full'" />
* <stella-trust-score [score]="65" [thresholds]="{ high: 75, medium: 45 }" />
*/
@Component({
selector: 'stella-trust-score',
standalone: true,
imports: [CommonModule],
template: `
<div
class="trust-score"
[class]="containerClass()"
[attr.aria-label]="ariaLabel()"
role="figure"
>
<!-- Donut Chart -->
<div class="trust-score__donut" [attr.title]="donutTooltip()">
<svg
viewBox="0 0 36 36"
class="trust-score__svg"
[class]="donutColorClass()"
>
<!-- Background circle -->
<circle
class="trust-score__bg"
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke-width="3"
/>
<!-- Score arc -->
<circle
class="trust-score__arc"
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke-width="3"
stroke-linecap="round"
[attr.stroke-dasharray]="strokeDasharray()"
stroke-dashoffset="25"
/>
@if (showBreakdownArcs() && breakdown()) {
<!-- Breakdown arcs (each component as a segment) -->
@for (segment of donutSegments(); track segment.name) {
<circle
class="trust-score__segment"
cx="18"
cy="18"
r="12"
fill="none"
stroke-width="4"
[attr.stroke]="segment.color"
[attr.stroke-dasharray]="segment.dasharray"
[attr.stroke-dashoffset]="segment.dashoffset"
/>
}
}
</svg>
<!-- Center text -->
<div class="trust-score__center">
<span class="trust-score__value">{{ score() }}</span>
@if (showPercentSign()) {
<span class="trust-score__percent" aria-hidden="true">%</span>
}
</div>
</div>
<!-- Label & Details (for full mode) -->
@if (mode() === 'full') {
<div class="trust-score__details">
<span class="trust-score__label">Trust Score</span>
<span class="trust-score__status" [class]="statusClass()">
{{ statusLabel() }}
</span>
</div>
}
<!-- Breakdown tooltip (expandable) -->
@if (showBreakdown() && breakdown()) {
<div class="trust-score__breakdown" role="list" aria-label="Score breakdown">
@for (component of components(); track component.name) {
<div class="trust-score__component" role="listitem">
<span
class="trust-score__component-indicator"
[style.background-color]="component.color"
></span>
<span class="trust-score__component-name">{{ component.label }}</span>
<span class="trust-score__component-score">{{ component.score }}</span>
<span class="trust-score__component-contribution">
({{ formatPercent(component.contribution) }})
</span>
</div>
}
<div class="trust-score__total">
<span>Total:</span>
<span class="trust-score__total-value">{{ computedTotal() }}</span>
</div>
</div>
}
</div>
`,
styles: [`
.trust-score {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.trust-score--full {
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.trust-score--donut-only .trust-score__details,
.trust-score--compact .trust-score__details {
display: none;
}
.trust-score__donut {
position: relative;
width: 48px;
height: 48px;
cursor: help;
}
.trust-score--full .trust-score__donut {
width: 80px;
height: 80px;
}
.trust-score__svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.trust-score__bg {
stroke: rgba(156, 163, 175, 0.2);
}
.trust-score__arc {
stroke: currentColor;
transition: stroke-dasharray 0.3s ease-in-out;
}
.trust-score__segment {
transition: stroke-dasharray 0.3s ease-in-out;
}
/* Color classes for the main arc */
.trust-score__svg--high {
color: #22c55e; /* Green */
}
.trust-score__svg--medium {
color: #f59e0b; /* Amber */
}
.trust-score__svg--low {
color: #ef4444; /* Red */
}
.trust-score__center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: baseline;
justify-content: center;
}
.trust-score__value {
font-size: 0.875rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.trust-score--full .trust-score__value {
font-size: 1.25rem;
}
.trust-score__percent {
font-size: 0.5rem;
font-weight: 500;
opacity: 0.7;
}
.trust-score--full .trust-score__percent {
font-size: 0.625rem;
}
.trust-score__details {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
}
.trust-score__label {
font-size: 0.75rem;
color: #6b7280;
}
.trust-score__status {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
}
.trust-score__status--high {
background-color: rgba(34, 197, 94, 0.1);
color: #166534;
}
.trust-score__status--medium {
background-color: rgba(245, 158, 11, 0.1);
color: #92400e;
}
.trust-score__status--low {
background-color: rgba(239, 68, 68, 0.1);
color: #991b1b;
}
.trust-score__breakdown {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem;
background: rgba(249, 250, 251, 0.9);
border: 1px solid rgba(156, 163, 175, 0.2);
border-radius: 4px;
font-size: 0.75rem;
min-width: 180px;
}
.trust-score__component {
display: flex;
align-items: center;
gap: 0.375rem;
}
.trust-score__component-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.trust-score__component-name {
flex: 1;
color: #374151;
}
.trust-score__component-score {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #111827;
}
.trust-score__component-contribution {
color: #6b7280;
font-size: 0.625rem;
}
.trust-score__total {
display: flex;
justify-content: space-between;
padding-top: 0.25rem;
border-top: 1px solid rgba(156, 163, 175, 0.3);
font-weight: 600;
}
.trust-score__total-value {
font-variant-numeric: tabular-nums;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.trust-score__bg {
stroke: rgba(75, 85, 99, 0.3);
}
.trust-score__label {
color: #9ca3af;
}
.trust-score__breakdown {
background: rgba(31, 41, 55, 0.9);
border-color: rgba(75, 85, 99, 0.3);
}
.trust-score__component-name {
color: #d1d5db;
}
.trust-score__component-score {
color: #f9fafb;
}
.trust-score__status--high {
background-color: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.trust-score__status--medium {
background-color: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.trust-score__status--low {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
}
`],
})
export class TrustScoreDisplayComponent {
/** Trust score value (0-100) */
readonly score = input<number>(0);
/** Optional breakdown with component scores */
readonly breakdown = input<TrustScoreBreakdown | undefined>(undefined);
/** Display mode */
readonly mode = input<TrustScoreMode>('compact');
/** Whether to show percent sign */
readonly showPercentSign = input<boolean>(false);
/** Whether to show the breakdown panel */
readonly showBreakdown = input<boolean>(false);
/** Whether to show breakdown arcs in donut */
readonly showBreakdownArcs = input<boolean>(false);
/** Color thresholds */
readonly thresholds = input<TrustScoreThresholds>(DEFAULT_THRESHOLDS);
// =========================================================================
// Computed Properties
// =========================================================================
/** Container CSS class based on mode */
readonly containerClass = computed(() => {
return `trust-score trust-score--${this.mode()}`;
});
/** Donut color class based on score thresholds */
readonly donutColorClass = computed(() => {
const score = this.score();
const thresh = this.thresholds();
if (score >= thresh.high) return 'trust-score__svg--high';
if (score >= thresh.medium) return 'trust-score__svg--medium';
return 'trust-score__svg--low';
});
/** Status class for the label */
readonly statusClass = computed(() => {
const score = this.score();
const thresh = this.thresholds();
if (score >= thresh.high) return 'trust-score__status trust-score__status--high';
if (score >= thresh.medium) return 'trust-score__status trust-score__status--medium';
return 'trust-score__status trust-score__status--low';
});
/** Status label text */
readonly statusLabel = computed(() => {
const score = this.score();
const thresh = this.thresholds();
if (score >= thresh.high) return 'High Trust';
if (score >= thresh.medium) return 'Medium Trust';
return 'Low Trust';
});
/** SVG stroke-dasharray for score arc */
readonly strokeDasharray = computed(() => {
const score = this.score();
const circumference = 100; // Using percentage-based units
const filled = (score / 100) * circumference;
return `${filled} ${circumference - filled}`;
});
/** Components with their scores, weights, and colors */
readonly components = computed(() => {
const breakdown = this.breakdown();
if (!breakdown) return [];
return COMPONENT_CONFIGS.map((config) => {
const component = breakdown[config.key];
const contribution = component.score * component.weight;
return {
name: config.key,
label: config.label,
score: component.score,
weight: component.weight,
contribution: contribution / 100, // Normalize to 0-1 range for display
color: config.color,
};
});
});
/** Donut segments for breakdown arcs */
readonly donutSegments = computed(() => {
const comps = this.components();
if (comps.length === 0) return [];
const innerCircumference = 2 * Math.PI * 12; // r=12
let offset = 0;
return comps.map((c) => {
const segmentLength = (c.weight * innerCircumference);
const dasharray = `${segmentLength} ${innerCircumference - segmentLength}`;
const dashoffset = -offset + (innerCircumference / 4); // Start at top
offset += segmentLength;
return {
name: c.name,
color: c.color,
dasharray,
dashoffset,
};
});
});
/** Computed total from breakdown (sum of score * weight for all components) */
readonly computedTotal = computed(() => {
const comps = this.components();
if (comps.length === 0) return this.score();
return Math.round(comps.reduce((sum, c) => sum + c.score * c.weight, 0));
});
/** Tooltip for donut showing breakdown percentages */
readonly donutTooltip = computed(() => {
const score = this.score();
const comps = this.components();
if (comps.length === 0) {
return `Trust Score: ${score}/100`;
}
const lines = comps.map(
(c) => `${c.label}: ${c.score} (${this.formatPercent(c.contribution)})`
);
return `Trust Score: ${score}/100\n${lines.join('\n')}`;
});
/** Aria label for accessibility */
readonly ariaLabel = computed(() => {
const score = this.score();
const status = this.statusLabel();
return `Trust score ${score} out of 100, ${status}`;
});
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Format a decimal contribution as percentage.
*/
formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
}
}

View File

@@ -0,0 +1,371 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import {
InputManifestComponent,
InputManifestMode,
InputManifestDisplayConfig,
} from '../../app/shared/components/input-manifest.component';
import { InputManifest } from '../../app/core/api/policy-engine.models';
const mockManifest: InputManifest = {
veriKey: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
sourceArtifact: {
digest: 'sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
artifactType: 'container-image',
ociReference: 'ghcr.io/stellaops/example:v1.0.0',
sizeBytes: 1024 * 1024 * 50, // 50 MB
},
sbom: {
hash: 'sha256:sbom123456789abcdef',
format: 'spdx-2.3',
packageCount: 150,
completenessScore: 85,
createdAt: '2025-12-26T10:00:00Z',
},
vex: {
hashSetHash: 'sha256:vex123456789abcdef',
statementCount: 12,
sources: ['Red Hat', 'Ubuntu', 'NVD'],
latestStatementAt: '2025-12-25T15:30:00Z',
},
policy: {
hash: 'sha256:policy123456789abcdef',
packId: 'stellaops-default-v2',
version: 3,
lastUpdatedAt: '2025-12-20T08:00:00Z',
name: 'Production Security Policy',
},
signers: {
setHash: 'sha256:signers123456789abcdef',
signerCount: 2,
certificates: [
{
subject: 'CN=release-signer@stellaops.io',
issuer: 'CN=Fulcio',
fingerprint: 'abc123',
expiresAt: '2026-12-26T00:00:00Z',
trustLevel: 'fulcio',
},
{
subject: 'CN=ci-signer@stellaops.io',
issuer: 'CN=Enterprise CA',
fingerprint: 'def456',
expiresAt: '2025-02-15T00:00:00Z',
trustLevel: 'enterprise-ca',
},
],
},
timeWindow: {
bucket: '2025-12-26-H12',
startsAt: '2025-12-26T12:00:00Z',
endsAt: '2025-12-26T13:00:00Z',
},
generatedAt: '2025-12-26T12:30:00Z',
};
const meta: Meta<InputManifestComponent> = {
title: 'Provcache/Input Manifest',
component: InputManifestComponent,
decorators: [
moduleMetadata({
imports: [InputManifestComponent],
}),
],
argTypes: {
mode: { control: 'select', options: ['full', 'compact', 'summary'] as InputManifestMode[] },
copyVeriKey: { action: 'copyVeriKey' },
refresh: { action: 'refresh' },
},
parameters: {
a11y: {
element: '#input-manifest-story',
},
docs: {
description: {
component: `
InputManifestComponent displays the exact inputs that form a VeriKey and cached decision.
**Sections:**
- **Source Artifact** - Image digest, type, OCI reference, size
- **SBOM** - Hash, format (SPDX/CycloneDX), package count, completeness score
- **VEX** - Statement set hash, count, sources list
- **Policy** - Bundle hash, pack ID, version, name
- **Signers** - Set hash, count, certificate details with expiry warnings
- **Time Window** - Bucket ID, start/end timestamps
**Modes:**
- **full** - All sections with details
- **compact** - Condensed spacing
- **summary** - Grid layout for quick overview
**Features:**
- VeriKey copy button
- Refresh button
- Configurable section visibility
- Certificate expiry warnings
- SBOM format badges
- Completeness score color coding
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="input-manifest-story" style="padding: 24px; background: #f5f5f5; max-width: 800px;">
<stellaops-input-manifest
[manifest]="manifest"
[mode]="mode"
[displayConfig]="displayConfig"
(copyVeriKey)="copyVeriKey($event)"
(refresh)="refresh()">
</stellaops-input-manifest>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<InputManifestComponent>;
// --- Basic Examples ---
export const FullMode: Story = {
args: {
manifest: mockManifest,
mode: 'full',
},
};
export const CompactMode: Story = {
args: {
manifest: mockManifest,
mode: 'compact',
},
};
export const SummaryMode: Story = {
args: {
manifest: mockManifest,
mode: 'summary',
},
};
// --- Empty State ---
export const EmptyState: Story = {
args: {
manifest: null,
mode: 'full',
},
};
// --- SBOM Formats ---
export const SpdxFormat: Story = {
args: {
manifest: {
...mockManifest,
sbom: { ...mockManifest.sbom, format: 'spdx-2.3' },
},
mode: 'full',
},
};
export const CycloneDxFormat: Story = {
args: {
manifest: {
...mockManifest,
sbom: { ...mockManifest.sbom, format: 'cyclonedx-1.6' },
},
mode: 'full',
},
};
// --- Completeness Scores ---
export const HighCompleteness: Story = {
args: {
manifest: {
...mockManifest,
sbom: { ...mockManifest.sbom, completenessScore: 95 },
},
mode: 'full',
},
};
export const MediumCompleteness: Story = {
args: {
manifest: {
...mockManifest,
sbom: { ...mockManifest.sbom, completenessScore: 65 },
},
mode: 'full',
},
};
export const LowCompleteness: Story = {
args: {
manifest: {
...mockManifest,
sbom: { ...mockManifest.sbom, completenessScore: 30 },
},
mode: 'full',
},
};
// --- Certificate States ---
export const CertificateExpiringSoon: Story = {
name: 'Certificate Expiring Soon',
args: {
manifest: {
...mockManifest,
signers: {
...mockManifest.signers,
certificates: [
{
subject: 'CN=expiring-signer@stellaops.io',
issuer: 'CN=Fulcio',
fingerprint: 'exp123',
expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), // 15 days
trustLevel: 'fulcio',
},
],
},
},
mode: 'full',
},
};
export const CertificateExpired: Story = {
name: 'Certificate Expired',
args: {
manifest: {
...mockManifest,
signers: {
...mockManifest.signers,
certificates: [
{
subject: 'CN=expired-signer@stellaops.io',
issuer: 'CN=Fulcio',
fingerprint: 'exp456',
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Yesterday
trustLevel: 'fulcio',
},
],
},
},
mode: 'full',
},
};
// --- Trust Levels ---
export const FulcioSigner: Story = {
args: {
manifest: {
...mockManifest,
signers: {
...mockManifest.signers,
certificates: [{ ...mockManifest.signers.certificates![0], trustLevel: 'fulcio' }],
},
},
mode: 'full',
},
};
export const EnterpriseCaSigner: Story = {
args: {
manifest: {
...mockManifest,
signers: {
...mockManifest.signers,
certificates: [{ ...mockManifest.signers.certificates![0], trustLevel: 'enterprise-ca' }],
},
},
mode: 'full',
},
};
export const SelfSignedSigner: Story = {
args: {
manifest: {
...mockManifest,
signers: {
...mockManifest.signers,
certificates: [{ ...mockManifest.signers.certificates![0], trustLevel: 'self-signed' }],
},
},
mode: 'full',
},
};
// --- Section Visibility ---
export const SourceArtifactOnly: Story = {
args: {
manifest: mockManifest,
mode: 'full',
displayConfig: {
showSource: true,
showSbom: false,
showVex: false,
showPolicy: false,
showSigners: false,
showTimeWindow: false,
} as InputManifestDisplayConfig,
},
};
export const SecurityFocused: Story = {
name: 'Security Focused (VEX, Policy, Signers)',
args: {
manifest: mockManifest,
mode: 'full',
displayConfig: {
showSource: false,
showSbom: false,
showVex: true,
showPolicy: true,
showSigners: true,
showTimeWindow: false,
} as InputManifestDisplayConfig,
},
};
// --- VEX Sources ---
export const ManyVexSources: Story = {
args: {
manifest: {
...mockManifest,
vex: {
...mockManifest.vex,
statementCount: 45,
sources: ['Red Hat', 'Ubuntu', 'Debian', 'NVD', 'GitHub Advisory', 'OSV', 'CERT/CC'],
},
},
mode: 'full',
},
};
// --- Large Artifact ---
export const LargeArtifact: Story = {
args: {
manifest: {
...mockManifest,
sourceArtifact: {
...mockManifest.sourceArtifact,
sizeBytes: 1024 * 1024 * 1024 * 2.5, // 2.5 GB
},
sbom: {
...mockManifest.sbom,
packageCount: 2500,
},
},
mode: 'full',
},
};

View File

@@ -0,0 +1,602 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import {
ProofTreeComponent,
VerdictEntry,
EvidenceChunk,
} from '../../app/shared/components/proof-tree.component';
import { DecisionDigest, TrustScoreBreakdown, ReplaySeed } from '../../app/core/api/policy-engine.models';
import { MerkleTree, MerkleTreeNode } from '../../app/core/api/proof.models';
const createMerkleTree = (): MerkleTree => ({
root: {
nodeId: 'root',
hash: 'sha256:root123456789abcdef1234567890abcdef1234567890',
isLeaf: false,
isRoot: true,
level: 0,
position: 0,
children: [
{
nodeId: 'left',
hash: 'sha256:left1234567890abcdef1234567890abcdef12345678',
isLeaf: false,
isRoot: false,
level: 1,
position: 0,
children: [
{
nodeId: 'leaf1',
hash: 'sha256:leaf11234567890abcdef1234567890abcdef12345',
label: 'sbom-hash',
isLeaf: true,
isRoot: false,
level: 2,
position: 0,
},
{
nodeId: 'leaf2',
hash: 'sha256:leaf21234567890abcdef1234567890abcdef12345',
label: 'vex-set-hash',
isLeaf: true,
isRoot: false,
level: 2,
position: 1,
},
],
},
{
nodeId: 'right',
hash: 'sha256:right234567890abcdef1234567890abcdef12345678',
isLeaf: false,
isRoot: false,
level: 1,
position: 1,
children: [
{
nodeId: 'leaf3',
hash: 'sha256:leaf31234567890abcdef1234567890abcdef12345',
label: 'policy-hash',
isLeaf: true,
isRoot: false,
level: 2,
position: 0,
},
{
nodeId: 'leaf4',
hash: 'sha256:leaf41234567890abcdef1234567890abcdef12345',
label: 'signers-hash',
isLeaf: true,
isRoot: false,
level: 2,
position: 1,
},
],
},
],
},
leafCount: 4,
depth: 3,
});
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 95, weight: 0.25 },
sbomCompleteness: { score: 85, weight: 0.20 },
vexCoverage: { score: 90, weight: 0.20 },
policyFreshness: { score: 88, weight: 0.15 },
signerTrust: { score: 92, weight: 0.20 },
};
const mockReplaySeed: ReplaySeed = {
feedIds: ['nvd-2025', 'ghsa-2025', 'redhat-oval-9'],
ruleIds: ['policy-critical-cves', 'policy-license-check'],
frozenEpoch: '2025-12-26T12:00:00Z',
};
const mockDigest: DecisionDigest = {
digestVersion: '1.0.0',
veriKey: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef12345678',
verdictHash: 'sha256:verdict123456789abcdef1234567890abcdef1234567890',
proofRoot: 'sha256:root123456789abcdef1234567890abcdef1234567890',
replaySeed: mockReplaySeed,
createdAt: '2025-12-26T12:00:00Z',
expiresAt: '2025-12-27T12:00:00Z',
trustScore: 88,
trustScoreBreakdown: mockBreakdown,
};
const mockVerdicts: VerdictEntry[] = [
{
cveId: 'CVE-2025-1234',
status: 'not_affected',
justification: 'Component not in execution path',
vexStatementId: 'vex-redhat-2025-001',
},
{
cveId: 'CVE-2025-5678',
status: 'fixed',
justification: 'Patched in v2.1.0',
vexStatementId: 'vex-ubuntu-2025-002',
},
{
cveId: 'CVE-2025-9999',
status: 'affected',
},
{
cveId: 'GHSA-1234-5678-abcd',
status: 'under_investigation',
vexStatementId: 'vex-github-2025-003',
},
];
const mockEvidenceChunks: EvidenceChunk[] = [
{
chunkId: 'chunk-001',
type: 'sbom',
hash: 'sha256:sbom123456789abcdef',
sizeBytes: 45000,
loaded: true,
},
{
chunkId: 'chunk-002',
type: 'vex',
hash: 'sha256:vex123456789abcdef',
sizeBytes: 12000,
loaded: true,
},
{
chunkId: 'chunk-003',
type: 'signer',
hash: 'sha256:sig123456789abcdef',
sizeBytes: 2500,
loaded: true,
},
{
chunkId: 'chunk-004',
type: 'policy',
hash: 'sha256:policy123456789abcdef',
sizeBytes: 8000,
loaded: true,
},
{
chunkId: 'chunk-005',
type: 'reachability',
hash: 'sha256:reach123456789abcdef',
sizeBytes: 15000,
loaded: false,
},
];
const meta: Meta<ProofTreeComponent> = {
title: 'Provcache/Proof Tree',
component: ProofTreeComponent,
decorators: [
moduleMetadata({
imports: [ProofTreeComponent],
}),
],
argTypes: {
copyToClipboard: { action: 'copyToClipboard' },
verify: { action: 'verify' },
loadChunk: { action: 'loadChunk' },
loadChunkByHash: { action: 'loadChunkByHash' },
},
parameters: {
a11y: {
element: '#proof-tree-story',
},
docs: {
description: {
component: `
ProofTreeComponent renders a cached decision (DecisionDigest) as a collapsible proof tree.
**Root Node (VeriKey):**
- Full VeriKey hash with copy button
- Collapsible sections for detailed proof
**Trust Score:**
- Overall score display (0-100)
- Visual donut chart
- Breakdown by component (reachability, SBOM, VEX, policy, signers)
**Verdicts Section:**
- List of vulnerability verdicts
- Status badges: not_affected, fixed, affected, under_investigation
- Source and timestamp per verdict
**Merkle Tree Visualization:**
- Recursive tree structure
- Hash and label for each node
- Expand/collapse subtrees
**Evidence Chunks:**
- List of evidence artifacts
- Type icons: sbom, vex, signature, policy, attestation
- Hash, description, size, timestamp
- Download links
**Verify Button:**
- Triggers proof verification
- Loading state during verification
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="proof-tree-story" style="padding: 24px; background: #f5f5f5; max-width: 900px;">
<stellaops-proof-tree
[digest]="digest"
[merkleTree]="merkleTree"
[verdicts]="verdicts"
[evidenceChunks]="evidenceChunks"
[isVerifying]="isVerifying"
(copyVeriKey)="copyVeriKey($event)"
(copyHash)="copyHash($event)"
(verify)="verify()"
(downloadEvidence)="downloadEvidence($event)">
</stellaops-proof-tree>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<ProofTreeComponent>;
// --- Basic Examples ---
export const FullProofTree: Story = {
name: 'Full Proof Tree',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const DigestOnly: Story = {
name: 'Digest Only (Minimal)',
args: {
digest: mockDigest,
isVerifying: false,
},
};
export const EmptyState: Story = {
args: {
digest: null,
isVerifying: false,
},
};
// --- Trust Scores ---
export const HighTrustScore: Story = {
args: {
digest: {
...mockDigest,
trustScore: 95,
trustScoreBreakdown: {
reachability: { score: 98, weight: 0.25 },
sbomCompleteness: { score: 95, weight: 0.20 },
vexCoverage: { score: 92, weight: 0.20 },
policyFreshness: { score: 96, weight: 0.15 },
signerTrust: { score: 94, weight: 0.20 },
},
},
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const MediumTrustScore: Story = {
args: {
digest: {
...mockDigest,
trustScore: 65,
trustScoreBreakdown: {
reachability: { score: 70, weight: 0.25 },
sbomCompleteness: { score: 60, weight: 0.20 },
vexCoverage: { score: 65, weight: 0.20 },
policyFreshness: { score: 68, weight: 0.15 },
signerTrust: { score: 62, weight: 0.20 },
},
},
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const LowTrustScore: Story = {
args: {
digest: {
...mockDigest,
trustScore: 35,
trustScoreBreakdown: {
reachability: { score: 40, weight: 0.25 },
sbomCompleteness: { score: 30, weight: 0.20 },
vexCoverage: { score: 35, weight: 0.20 },
policyFreshness: { score: 38, weight: 0.15 },
signerTrust: { score: 32, weight: 0.20 },
},
},
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
// --- Verdict Statuses ---
export const AllNotAffected: Story = {
name: 'All Verdicts Not Affected',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: [
{ cveId: 'CVE-2025-0001', status: 'not_affected', justification: 'Not reachable', vexStatementId: 'vex-redhat-001' },
{ cveId: 'CVE-2025-0002', status: 'not_affected', justification: 'Not applicable', vexStatementId: 'vex-ubuntu-001' },
{ cveId: 'CVE-2025-0003', status: 'not_affected', justification: 'Mitigated', vexStatementId: 'vex-debian-001' },
] as VerdictEntry[],
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const AllFixed: Story = {
name: 'All Verdicts Fixed',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: [
{ cveId: 'CVE-2025-0001', status: 'fixed', justification: 'Patched in v1.2.0', vexStatementId: 'vex-redhat-002' },
{ cveId: 'CVE-2025-0002', status: 'fixed', justification: 'Patched in v2.0.0', vexStatementId: 'vex-ubuntu-002' },
] as VerdictEntry[],
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const AllAffected: Story = {
name: 'All Verdicts Affected',
args: {
digest: {
...mockDigest,
trustScore: 25,
},
merkleTree: createMerkleTree(),
verdicts: [
{ cveId: 'CVE-2025-0001', status: 'affected' },
{ cveId: 'CVE-2025-0002', status: 'affected' },
{ cveId: 'CVE-2025-0003', status: 'affected' },
] as VerdictEntry[],
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const MixedVerdicts: Story = {
name: 'Mixed Verdict Statuses',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
// --- Evidence Chunks ---
export const AllEvidenceTypes: Story = {
name: 'All Evidence Types',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const SbomOnlyEvidence: Story = {
name: 'SBOM Only Evidence',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: [mockEvidenceChunks[0]],
isVerifying: false,
},
};
export const NoEvidence: Story = {
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: [],
isVerifying: false,
},
};
// --- Merkle Tree Variations ---
export const DeepMerkleTree: Story = {
name: 'Deep Merkle Tree (4 levels)',
args: {
digest: mockDigest,
merkleTree: {
root: {
nodeId: 'root',
hash: 'sha256:root000',
isLeaf: false,
isRoot: true,
level: 0,
position: 0,
children: [
{
nodeId: 'l1-left',
hash: 'sha256:l1-left',
isLeaf: false,
isRoot: false,
level: 1,
position: 0,
children: [
{
nodeId: 'l2-left-left',
hash: 'sha256:l2-left-left',
isLeaf: false,
isRoot: false,
level: 2,
position: 0,
children: [
{ nodeId: 'leaf-1', hash: 'sha256:leaf-1', label: 'artifact-1', isLeaf: true, isRoot: false, level: 3, position: 0 },
{ nodeId: 'leaf-2', hash: 'sha256:leaf-2', label: 'artifact-2', isLeaf: true, isRoot: false, level: 3, position: 1 },
],
},
{
nodeId: 'l2-left-right',
hash: 'sha256:l2-left-right',
isLeaf: false,
isRoot: false,
level: 2,
position: 1,
children: [
{ nodeId: 'leaf-3', hash: 'sha256:leaf-3', label: 'artifact-3', isLeaf: true, isRoot: false, level: 3, position: 0 },
{ nodeId: 'leaf-4', hash: 'sha256:leaf-4', label: 'artifact-4', isLeaf: true, isRoot: false, level: 3, position: 1 },
],
},
],
},
{
nodeId: 'l1-right',
hash: 'sha256:l1-right',
isLeaf: false,
isRoot: false,
level: 1,
position: 1,
children: [
{
nodeId: 'l2-right-left',
hash: 'sha256:l2-right-left',
isLeaf: false,
isRoot: false,
level: 2,
position: 0,
children: [
{ nodeId: 'leaf-5', hash: 'sha256:leaf-5', label: 'artifact-5', isLeaf: true, isRoot: false, level: 3, position: 0 },
{ nodeId: 'leaf-6', hash: 'sha256:leaf-6', label: 'artifact-6', isLeaf: true, isRoot: false, level: 3, position: 1 },
],
},
{
nodeId: 'l2-right-right',
hash: 'sha256:l2-right-right',
isLeaf: false,
isRoot: false,
level: 2,
position: 1,
children: [
{ nodeId: 'leaf-7', hash: 'sha256:leaf-7', label: 'artifact-7', isLeaf: true, isRoot: false, level: 3, position: 0 },
{ nodeId: 'leaf-8', hash: 'sha256:leaf-8', label: 'artifact-8', isLeaf: true, isRoot: false, level: 3, position: 1 },
],
},
],
},
],
},
leafCount: 8,
depth: 4,
} as MerkleTree,
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const FlatMerkleTree: Story = {
name: 'Flat Merkle Tree (2 leaves)',
args: {
digest: mockDigest,
merkleTree: {
root: {
nodeId: 'root',
hash: 'sha256:root000',
isLeaf: false,
isRoot: true,
level: 0,
position: 0,
children: [
{ nodeId: 'leaf-1', hash: 'sha256:leaf-1', label: 'sbom', isLeaf: true, isRoot: false, level: 1, position: 0 },
{ nodeId: 'leaf-2', hash: 'sha256:leaf-2', label: 'vex', isLeaf: true, isRoot: false, level: 1, position: 1 },
],
},
leafCount: 2,
depth: 2,
} as MerkleTree,
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
export const NoMerkleTree: Story = {
args: {
digest: mockDigest,
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
// --- Verification States ---
export const VerifyingInProgress: Story = {
name: 'Verification In Progress',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: true,
},
};
export const ReadyToVerify: Story = {
name: 'Ready to Verify',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: mockVerdicts,
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};
// --- Many Verdicts ---
export const ManyVerdicts: Story = {
name: 'Many Verdicts (20+)',
args: {
digest: mockDigest,
merkleTree: createMerkleTree(),
verdicts: Array.from({ length: 25 }, (_, i) => ({
cveId: `CVE-2025-${String(i + 1000).padStart(4, '0')}`,
status: ['not_affected', 'fixed', 'affected', 'under_investigation'][i % 4] as VerdictEntry['status'],
justification: i % 4 !== 2 ? `Justification for item ${i + 1}` : undefined,
vexStatementId: i % 4 !== 2 ? `vex-auto-${i + 1}` : undefined,
})) as VerdictEntry[],
evidenceChunks: mockEvidenceChunks,
isVerifying: false,
},
};

View File

@@ -0,0 +1,245 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import {
ProvenanceBadgeComponent,
ProvenanceState,
CacheDetails,
} from '../../app/shared/components/provenance-badge.component';
const meta: Meta<ProvenanceBadgeComponent> = {
title: 'Provcache/Provenance Badge',
component: ProvenanceBadgeComponent,
decorators: [
moduleMetadata({
imports: [ProvenanceBadgeComponent],
}),
],
argTypes: {
state: {
control: 'select',
options: ['cached', 'computed', 'stale', 'unknown'] as ProvenanceState[],
},
showLabel: { control: 'boolean' },
showTrustScore: { control: 'boolean' },
showAge: { control: 'boolean' },
clickable: { control: 'boolean' },
clicked: { action: 'clicked' },
},
parameters: {
a11y: {
element: '#provenance-badge-story',
},
docs: {
description: {
component: `
ProvenanceBadgeComponent displays the cache state of a provenance decision.
**States:**
- ⚡ **cached** - Decision from cache, fast path
- 🔄 **computed** - Freshly computed decision
- ⏳ **stale** - Cache expired, recomputation in progress
- ❓ **unknown** - Legacy data, no cache metadata
**Features:**
- Tooltip with cache details (source, age, trust score)
- Optional trust score badge overlay
- Clickable to view proof tree
- Full accessibility support
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="provenance-badge-story" style="padding: 24px; background: var(--stellaops-card-bg, #fff);">
<stella-provenance-badge
[state]="state"
[cacheDetails]="cacheDetails"
[showLabel]="showLabel"
[showTrustScore]="showTrustScore"
[showAge]="showAge"
[clickable]="clickable"
(clicked)="clicked($event)">
</stella-provenance-badge>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<ProvenanceBadgeComponent>;
// --- Basic States ---
export const Cached: Story = {
args: {
state: 'cached',
showLabel: true,
},
};
export const Computed: Story = {
args: {
state: 'computed',
showLabel: true,
},
};
export const Stale: Story = {
args: {
state: 'stale',
showLabel: true,
},
};
export const Unknown: Story = {
args: {
state: 'unknown',
showLabel: true,
},
};
// --- With Cache Details ---
const mockCacheDetails: CacheDetails = {
source: 'redis',
ageSeconds: 120,
trustScore: 85,
executionTimeMs: 15,
};
export const CachedWithDetails: Story = {
args: {
state: 'cached',
cacheDetails: mockCacheDetails,
showLabel: true,
showTrustScore: true,
showAge: true,
},
};
export const StaleCacheExpired: Story = {
args: {
state: 'stale',
cacheDetails: {
source: 'redis',
ageSeconds: 3700, // Over 1 hour
trustScore: 72,
executionTimeMs: 45,
},
showLabel: true,
showTrustScore: true,
showAge: true,
},
};
// --- With Trust Score Overlay ---
export const HighTrustScore: Story = {
args: {
state: 'cached',
cacheDetails: { trustScore: 92 },
showLabel: true,
showTrustScore: true,
},
};
export const MediumTrustScore: Story = {
args: {
state: 'cached',
cacheDetails: { trustScore: 65 },
showLabel: true,
showTrustScore: true,
},
};
export const LowTrustScore: Story = {
args: {
state: 'cached',
cacheDetails: { trustScore: 35 },
showLabel: true,
showTrustScore: true,
},
};
// --- Icon Only (no label) ---
export const IconOnly: Story = {
args: {
state: 'cached',
showLabel: false,
},
};
export const IconOnlyWithScore: Story = {
args: {
state: 'cached',
showLabel: false,
cacheDetails: { trustScore: 88 },
showTrustScore: true,
},
};
// --- All States Gallery ---
export const AllStatesGallery: Story = {
render: () => ({
template: `
<div id="provenance-badge-story" style="padding: 24px; background: var(--stellaops-card-bg, #fff); display: flex; flex-direction: column; gap: 24px;">
<h3 style="margin: 0; font-size: 14px; color: #666;">Basic States</h3>
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [showLabel]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Cached</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="computed" [showLabel]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Computed</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="stale" [showLabel]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Stale</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="unknown" [showLabel]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Unknown</span>
</div>
</div>
<h3 style="margin: 16px 0 0 0; font-size: 14px; color: #666;">With Trust Score</h3>
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [cacheDetails]="{ trustScore: 95 }" [showTrustScore]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">High (95)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [cacheDetails]="{ trustScore: 65 }" [showTrustScore]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Medium (65)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [cacheDetails]="{ trustScore: 25 }" [showTrustScore]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Low (25)</span>
</div>
</div>
<h3 style="margin: 16px 0 0 0; font-size: 14px; color: #666;">Display Variants</h3>
<div style="display: flex; gap: 24px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [showLabel]="false"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Icon Only</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [showLabel]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">With Label</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-provenance-badge state="cached" [showLabel]="true" [cacheDetails]="{ trustScore: 88, ageSeconds: 120 }" [showTrustScore]="true" [showAge]="true"></stella-provenance-badge>
<span style="font-size: 11px; color: #999;">Full Details</span>
</div>
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1,304 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import {
TrustScoreDisplayComponent,
TrustScoreMode,
TrustScoreThresholds,
} from '../../app/shared/components/trust-score.component';
import { TrustScoreBreakdown } from '../../app/core/api/policy-engine.models';
const mockBreakdown: TrustScoreBreakdown = {
reachability: { score: 90, weight: 0.25 },
sbomCompleteness: { score: 80, weight: 0.20 },
vexCoverage: { score: 85, weight: 0.20 },
policyFreshness: { score: 75, weight: 0.15 },
signerTrust: { score: 90, weight: 0.20 },
};
const meta: Meta<TrustScoreDisplayComponent> = {
title: 'Provcache/Trust Score Display',
component: TrustScoreDisplayComponent,
decorators: [
moduleMetadata({
imports: [TrustScoreDisplayComponent],
}),
],
argTypes: {
score: { control: { type: 'range', min: 0, max: 100, step: 1 } },
mode: { control: 'select', options: ['compact', 'full', 'donut-only'] as TrustScoreMode[] },
showBreakdown: { control: 'boolean' },
showPercentSign: { control: 'boolean' },
showBreakdownArcs: { control: 'boolean' },
},
parameters: {
a11y: {
element: '#trust-score-story',
},
docs: {
description: {
component: `
TrustScoreDisplayComponent visualizes a composite trust score (0-100) with optional breakdown.
**Modes:**
- **donut** - SVG donut chart showing score as filled arc
- **badge** - Compact badge with score number
- **minimal** - Score number only
**Features:**
- Color coding: Green (≥80), Yellow (50-79), Red (<50)
- Optional breakdown showing 5 components:
- Reachability (25%)
- SBOM Completeness (20%)
- VEX Coverage (20%)
- Policy Freshness (15%)
- Signer Trust (20%)
- Configurable thresholds
- Animation support
- Full accessibility
`,
},
},
},
render: (args) => ({
props: args,
template: `
<div id="trust-score-story" style="padding: 24px; background: var(--stellaops-card-bg, #fff);">
<stella-trust-score
[score]="score"
[breakdown]="breakdown"
[mode]="mode"
[showBreakdown]="showBreakdown"
[showPercentSign]="showPercentSign"
[showBreakdownArcs]="showBreakdownArcs"
[thresholds]="thresholds">
</stella-trust-score>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<TrustScoreDisplayComponent>;
// --- Score Ranges ---
export const HighScore: Story = {
args: {
score: 92,
mode: 'compact',
showBreakdown: false,
},
};
export const MediumScore: Story = {
args: {
score: 65,
mode: 'compact',
showBreakdown: false,
},
};
export const LowScore: Story = {
args: {
score: 35,
mode: 'compact',
showBreakdown: false,
},
};
export const BoundaryHigh: Story = {
name: 'Boundary: Exactly 80 (High)',
args: {
score: 80,
mode: 'compact',
showBreakdown: false,
},
};
export const BoundaryMedium: Story = {
name: 'Boundary: Exactly 50 (Medium)',
args: {
score: 50,
mode: 'compact',
showBreakdown: false,
},
};
export const MinimumScore: Story = {
args: {
score: 0,
mode: 'compact',
showBreakdown: false,
},
};
export const MaximumScore: Story = {
args: {
score: 100,
mode: 'compact',
showBreakdown: false,
},
};
// --- With Breakdown ---
export const WithBreakdown: Story = {
args: {
score: 85,
breakdown: mockBreakdown,
mode: 'full',
showBreakdown: true,
},
};
export const BreakdownHighScores: Story = {
args: {
score: 92,
breakdown: {
reachability: { score: 95, weight: 0.25 },
sbomCompleteness: { score: 90, weight: 0.20 },
vexCoverage: { score: 88, weight: 0.20 },
policyFreshness: { score: 92, weight: 0.15 },
signerTrust: { score: 95, weight: 0.20 },
},
mode: 'full',
showBreakdown: true,
},
};
export const BreakdownMixedScores: Story = {
args: {
score: 65,
breakdown: {
reachability: { score: 85, weight: 0.25 },
sbomCompleteness: { score: 60, weight: 0.20 },
vexCoverage: { score: 45, weight: 0.20 },
policyFreshness: { score: 70, weight: 0.15 },
signerTrust: { score: 80, weight: 0.20 },
},
mode: 'full',
showBreakdown: true,
},
};
export const BreakdownLowScores: Story = {
args: {
score: 35,
breakdown: {
reachability: { score: 40, weight: 0.25 },
sbomCompleteness: { score: 30, weight: 0.20 },
vexCoverage: { score: 25, weight: 0.20 },
policyFreshness: { score: 45, weight: 0.15 },
signerTrust: { score: 35, weight: 0.20 },
},
mode: 'full',
showBreakdown: true,
},
};
// --- Display Modes ---
export const CompactMode: Story = {
args: {
score: 85,
mode: 'compact',
showBreakdown: false,
},
};
export const FullMode: Story = {
args: {
score: 85,
mode: 'full',
showBreakdown: false,
},
};
export const DonutOnlyMode: Story = {
args: {
score: 85,
mode: 'donut-only',
showBreakdown: false,
},
};
// --- With Breakdown Arcs ---
export const WithBreakdownArcs: Story = {
args: {
score: 85,
breakdown: mockBreakdown,
mode: 'full',
showBreakdown: true,
showBreakdownArcs: true,
},
};
// --- Custom Thresholds ---
export const CustomThresholds: Story = {
args: {
score: 75,
mode: 'compact',
thresholds: {
high: 90,
medium: 70,
} as TrustScoreThresholds,
showBreakdown: false,
},
};
// --- All Scores Gallery ---
export const ScoreRangesGallery: Story = {
render: () => ({
template: `
<div id="trust-score-story" style="padding: 24px; background: var(--stellaops-card-bg, #fff); display: flex; flex-direction: column; gap: 24px;">
<h3 style="margin: 0; font-size: 14px; color: #666;">Score Ranges</h3>
<div style="display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="95" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">High (95)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="80" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">High (80)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="65" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Medium (65)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="50" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Medium (50)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="35" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Low (35)</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="10" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Low (10)</span>
</div>
</div>
<h3 style="margin: 16px 0 0 0; font-size: 14px; color: #666;">Display Modes</h3>
<div style="display: flex; gap: 32px; align-items: center; flex-wrap: wrap;">
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="85" mode="compact"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Compact</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="85" mode="full"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Full</span>
</div>
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<stella-trust-score [score]="85" mode="donut-only"></stella-trust-score>
<span style="font-size: 11px; color: #999;">Donut Only</span>
</div>
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1,388 @@
// -----------------------------------------------------------------------------
// quiet-triage-a11y.spec.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Accessibility tests for the Quiet-by-Design triage workflow.
// Tests WCAG 2.0 AA compliance for gated buckets, VEX trust, replay command.
// -----------------------------------------------------------------------------
import { test, expect, Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'node:fs';
import path from 'node:path';
import { policyAuthorSession } from '../../src/app/testing';
const shouldFail = process.env.FAIL_ON_A11Y === '1';
const reportDir = path.join(process.cwd(), 'test-results');
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
// Mock responses for quiet triage components
const mockGatedBuckets = {
unreachableCount: 42,
policyDismissedCount: 15,
backportedCount: 8,
vexNotAffectedCount: 23,
supersededCount: 3,
userMutedCount: 5,
totalHiddenCount: 96,
actionableCount: 12,
};
const mockVexTrust = {
status: 'not_affected',
justification: 'vulnerable_code_not_in_execute_path',
issuedBy: 'vendor.example',
issuedAt: '2025-12-15T10:00:00Z',
trustScore: 0.85,
policyTrustThreshold: 0.80,
meetsPolicyThreshold: true,
trustBreakdown: {
authority: 0.90,
accuracy: 0.85,
timeliness: 0.80,
verification: 0.85,
},
};
const mockReplayCommand = {
findingId: 'f-abc123',
scanId: 'scan-xyz789',
fullCommand: {
type: 'full',
command: 'stella scan replay --artifact sha256:a1b2c3... --manifest sha256:def456...',
shell: 'bash',
requiresNetwork: false,
},
shortCommand: {
type: 'short',
command: 'stella replay snapshot --verdict V-12345',
shell: 'bash',
requiresNetwork: false,
},
generatedAt: '2025-12-15T10:30:00Z',
expectedVerdictHash: 'sha256:verdict123...',
};
async function writeReport(filename: string, data: unknown) {
fs.mkdirSync(reportDir, { recursive: true });
fs.writeFileSync(path.join(reportDir, filename), JSON.stringify(data, null, 2));
}
async function runA11y(page: Page, selector?: string) {
let builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
if (selector) {
builder = builder.include(selector);
}
const results = await builder.analyze();
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
return violations;
}
test.describe('quiet-triage-a11y', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
// Mock gating API endpoints
await page.route('**/api/v1/triage/findings/*/gated-buckets', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockGatedBuckets),
})
);
await page.route('**/api/v1/triage/findings/*/vex-trust', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockVexTrust),
})
);
await page.route('**/api/v1/triage/findings/*/replay-command', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockReplayCommand),
})
);
});
test('gated buckets component: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const violations = await runA11y(page, '.gated-buckets');
await writeReport('a11y-quiet_triage_gated_buckets.json', { url: page.url(), violations });
if (shouldFail) {
expect(violations).toEqual([]);
}
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (gated buckets component)`,
});
// Specific checks for gated buckets
const gatedBuckets = page.locator('.gated-buckets');
// Check role and aria-label
await expect(gatedBuckets).toHaveAttribute('role', 'group');
await expect(gatedBuckets).toHaveAttribute('aria-label', 'Gated findings summary');
// Check bucket chips have aria-expanded
const chips = gatedBuckets.locator('.bucket-chip');
const chipCount = await chips.count();
for (let i = 0; i < chipCount; i++) {
await expect(chips.nth(i)).toHaveAttribute('aria-expanded');
await expect(chips.nth(i)).toHaveAttribute('aria-label');
}
// Check show all toggle has aria-pressed
const showAllToggle = gatedBuckets.locator('.show-all-toggle');
await expect(showAllToggle).toHaveAttribute('aria-pressed');
});
test('VEX trust display: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const violations = await runA11y(page, '.vex-trust-display');
await writeReport('a11y-quiet_triage_vex_trust.json', { url: page.url(), violations });
if (shouldFail) {
expect(violations).toEqual([]);
}
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (VEX trust display)`,
});
// Check color contrast for trust score
const trustScore = page.locator('.vex-trust-display .trust-score');
await expect(trustScore).toBeVisible();
// Check expand button has proper labeling
const expandBtn = page.locator('.vex-trust-display').getByRole('button');
await expect(expandBtn).toHaveAttribute('aria-expanded');
});
test('replay command component: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const violations = await runA11y(page, '.replay-command');
await writeReport('a11y-quiet_triage_replay_command.json', { url: page.url(), violations });
if (shouldFail) {
expect(violations).toEqual([]);
}
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (replay command)`,
});
// Check tabs have proper ARIA roles
const tabs = page.locator('.replay-command .command-tabs');
await expect(tabs).toHaveAttribute('role', 'tablist');
const tabButtons = tabs.locator('.tab');
const tabCount = await tabButtons.count();
for (let i = 0; i < tabCount; i++) {
await expect(tabButtons.nth(i)).toHaveAttribute('role', 'tab');
await expect(tabButtons.nth(i)).toHaveAttribute('aria-selected');
}
});
test('gating explainer modal: WCAG 2.0 AA compliance', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
// Open the modal
const whyHiddenLink = page.getByRole('button', { name: /Why hidden/ });
await whyHiddenLink.click();
const modal = page.getByRole('dialog', { name: 'Gating explanation' });
await expect(modal).toBeVisible();
const violations = await runA11y(page, '[role="dialog"]');
await writeReport('a11y-quiet_triage_gating_modal.json', { url: page.url(), violations });
if (shouldFail) {
expect(violations).toEqual([]);
}
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (gating explainer modal)`,
});
// Check modal has proper ARIA attributes
await expect(modal).toHaveAttribute('role', 'dialog');
await expect(modal).toHaveAttribute('aria-modal', 'true');
// Check close button is accessible
const closeBtn = modal.getByRole('button', { name: 'Close' });
await expect(closeBtn).toBeVisible();
await expect(closeBtn).toBeFocused();
});
test('keyboard navigation: focus management', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Tab through gated buckets
const gatedBuckets = page.locator('.gated-buckets');
const chips = gatedBuckets.locator('.bucket-chip');
// Focus first chip
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const firstChip = chips.first();
await expect(firstChip).toBeFocused();
// Check focus indicator is visible (outline)
const focusStyles = await firstChip.evaluate((el) => {
const styles = window.getComputedStyle(el);
return {
outline: styles.outline,
outlineOffset: styles.outlineOffset,
};
});
// Focus should have visible outline
expect(focusStyles.outline).not.toBe('none');
testInfo.annotations.push({
type: 'a11y',
description: 'Focus indicators verified',
});
});
test('keyboard navigation: enter activates bucket chip', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
// Focus the chip
await unreachableChip.focus();
await expect(unreachableChip).toBeFocused();
// Activate with Enter
await page.keyboard.press('Enter');
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
// Deactivate with Enter
await page.keyboard.press('Enter');
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'false');
});
test('keyboard navigation: space activates bucket chip', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const policyChip = page.getByRole('button', { name: /policy-dismissed/ });
// Focus the chip
await policyChip.focus();
await expect(policyChip).toBeFocused();
// Activate with Space
await page.keyboard.press('Space');
await expect(policyChip).toHaveAttribute('aria-expanded', 'true');
});
test('screen reader: live region announces changes', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Check for aria-live region for dynamic updates
const liveRegion = page.locator('[aria-live]');
const liveRegionCount = await liveRegion.count();
testInfo.annotations.push({
type: 'a11y',
description: `${liveRegionCount} aria-live regions found`,
});
// At minimum, status updates should have aria-live
expect(liveRegionCount).toBeGreaterThanOrEqual(0);
});
test('color contrast: bucket chips meet WCAG AA', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const violations = await runA11y(page, '.bucket-chips');
// Filter for color-contrast violations
const contrastViolations = violations.filter((v) => v.id === 'color-contrast');
// No contrast violations should exist in bucket chips
expect(contrastViolations.length).toBe(0);
});
test('reduced motion: respects prefers-reduced-motion', async ({ page }) => {
// Emulate reduced motion preference
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const chip = page.locator('.bucket-chip').first();
// Check transitions are instant or disabled
const transitionDuration = await chip.evaluate((el) => {
return window.getComputedStyle(el).transitionDuration;
});
// With reduced motion, transitions should be instant (0s) or very fast
const duration = parseFloat(transitionDuration);
expect(duration).toBeLessThanOrEqual(0.01);
});
});

View File

@@ -0,0 +1,335 @@
// -----------------------------------------------------------------------------
// quiet-triage.spec.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: E2E tests for the Quiet-by-Design triage workflow.
// Tests gated bucket chips, VEX trust display, replay command, and gating explainer.
// -----------------------------------------------------------------------------
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
// Mock gated buckets response
const mockGatedBuckets = {
unreachableCount: 42,
policyDismissedCount: 15,
backportedCount: 8,
vexNotAffectedCount: 23,
supersededCount: 3,
userMutedCount: 5,
totalHiddenCount: 96,
actionableCount: 12,
};
// Mock VEX trust response
const mockVexTrust = {
status: 'not_affected',
justification: 'vulnerable_code_not_in_execute_path',
issuedBy: 'vendor.example',
issuedAt: '2025-12-15T10:00:00Z',
trustScore: 0.85,
policyTrustThreshold: 0.80,
meetsPolicyThreshold: true,
trustBreakdown: {
authority: 0.90,
accuracy: 0.85,
timeliness: 0.80,
verification: 0.85,
},
};
// Mock replay command response
const mockReplayCommand = {
findingId: 'f-abc123',
scanId: 'scan-xyz789',
fullCommand: {
type: 'full',
command: 'stella scan replay --artifact sha256:a1b2c3... --manifest sha256:def456...',
shell: 'bash',
requiresNetwork: false,
},
shortCommand: {
type: 'short',
command: 'stella replay snapshot --verdict V-12345',
shell: 'bash',
requiresNetwork: false,
},
generatedAt: '2025-12-15T10:30:00Z',
expectedVerdictHash: 'sha256:verdict123...',
};
test.describe('quiet-triage', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
// Mock gating API endpoints
await page.route('**/api/v1/triage/findings/*/gated-buckets', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockGatedBuckets),
})
);
await page.route('**/api/v1/triage/findings/*/vex-trust', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockVexTrust),
})
);
await page.route('**/api/v1/triage/findings/*/replay-command', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockReplayCommand),
})
);
});
test('gated buckets: displays actionable count and hidden summary', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Check gated buckets component
const gatedBuckets = page.locator('[role="group"][aria-label="Gated findings summary"]');
await expect(gatedBuckets).toBeVisible();
// Check actionable count
await expect(gatedBuckets.locator('.actionable-count')).toContainText('12');
await expect(gatedBuckets.locator('.hidden-hint')).toContainText('96 hidden');
});
test('gated buckets: bucket chips show correct counts', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Check unreachable chip
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
await expect(unreachableChip).toBeVisible();
await expect(unreachableChip).toContainText('+42');
await expect(unreachableChip).toContainText('unreachable');
// Check VEX chip
const vexChip = page.getByRole('button', { name: /Show 23 VEX not-affected findings/ });
await expect(vexChip).toBeVisible();
await expect(vexChip).toContainText('+23');
});
test('gated buckets: clicking chip expands bucket', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
// Click to expand
await unreachableChip.click();
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
await expect(unreachableChip).toHaveClass(/expanded/);
// Click again to collapse
await unreachableChip.click();
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'false');
await expect(unreachableChip).not.toHaveClass(/expanded/);
});
test('gated buckets: show all toggle reveals hidden findings', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
const showAllToggle = page.getByRole('button', { name: 'Show all' });
await expect(showAllToggle).toBeVisible();
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
// Click to show all
await showAllToggle.click();
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'true');
await expect(showAllToggle).toContainText('Hide gated');
// Click to hide again
await showAllToggle.click();
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
await expect(showAllToggle).toContainText('Show all');
});
test('VEX trust display: shows trust score and threshold', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
// Check VEX trust display
const vexTrust = page.locator('.vex-trust-display');
await expect(vexTrust).toBeVisible();
// Check trust score
await expect(vexTrust.locator('.trust-score')).toContainText('0.85');
await expect(vexTrust.locator('.threshold')).toContainText('0.80');
await expect(vexTrust.locator('.meets-threshold')).toBeVisible();
});
test('VEX trust display: shows trust breakdown on expand', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const expandBtn = page.getByRole('button', { name: 'Show trust breakdown' });
await expandBtn.click();
const breakdown = page.locator('.trust-breakdown');
await expect(breakdown).toBeVisible();
await expect(breakdown).toContainText('Authority');
await expect(breakdown).toContainText('0.90');
await expect(breakdown).toContainText('Accuracy');
await expect(breakdown).toContainText('0.85');
});
test('replay command: displays command and copy button', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const replaySection = page.locator('.replay-command');
await expect(replaySection).toBeVisible();
// Check command text
await expect(replaySection.locator('.command-text')).toContainText('stella scan replay');
// Check copy button
const copyBtn = replaySection.getByRole('button', { name: /Copy/ });
await expect(copyBtn).toBeVisible();
await expect(copyBtn).toBeEnabled();
});
test('replay command: tab switching between full and short', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const replaySection = page.locator('.replay-command');
// Full tab active by default
const fullTab = replaySection.getByRole('tab', { name: 'Full' });
await expect(fullTab).toHaveAttribute('aria-selected', 'true');
await expect(replaySection.locator('.command-text')).toContainText('--artifact sha256');
// Switch to short tab
const shortTab = replaySection.getByRole('tab', { name: 'Short' });
await shortTab.click();
await expect(shortTab).toHaveAttribute('aria-selected', 'true');
await expect(replaySection.locator('.command-text')).toContainText('--verdict V-12345');
});
test('replay command: copy to clipboard', async ({ page, context }) => {
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
const replaySection = page.locator('.replay-command');
const copyBtn = replaySection.getByRole('button', { name: /Copy/ });
await copyBtn.click();
// Button should show copied state
await expect(copyBtn).toContainText('Copied!');
// Verify clipboard content
const clipboardContent = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardContent).toContain('stella scan replay');
});
test('gating explainer: opens modal with explanation', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod/findings/f-abc123');
await expect(page.getByRole('heading', { name: 'Finding detail' })).toBeVisible({ timeout: 10000 });
// Click "Why hidden?" link
const whyHiddenLink = page.getByRole('button', { name: /Why hidden/ });
await whyHiddenLink.click();
// Modal should open
const modal = page.getByRole('dialog', { name: 'Gating explanation' });
await expect(modal).toBeVisible();
// Check content
await expect(modal).toContainText('This finding is hidden because');
await expect(modal.getByRole('button', { name: 'Close' })).toBeVisible();
});
test('keyboard navigation: bucket chips focusable', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Tab to first bucket chip
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
await expect(unreachableChip).toBeFocused();
// Enter to activate
await page.keyboard.press('Enter');
await expect(unreachableChip).toHaveAttribute('aria-expanded', 'true');
// Tab to next chip
await page.keyboard.press('Tab');
const policyChip = page.getByRole('button', { name: /policy-dismissed/ });
await expect(policyChip).toBeFocused();
});
test('screen reader: proper ARIA labels on components', async ({ page }) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });
// Check gated buckets group label
const gatedBuckets = page.locator('[role="group"][aria-label="Gated findings summary"]');
await expect(gatedBuckets).toHaveAttribute('aria-label', 'Gated findings summary');
// Check bucket chip labels
const unreachableChip = page.getByRole('button', { name: /Show 42 unreachable findings/ });
await expect(unreachableChip).toHaveAttribute('aria-label', /Show 42 unreachable findings/);
// Check show all toggle
const showAllToggle = page.getByRole('button', { name: 'Show all' });
await expect(showAllToggle).toHaveAttribute('aria-pressed', 'false');
});
});