save development progress
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">@{{ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}%`;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
388
src/Web/StellaOps.Web/tests/e2e/quiet-triage-a11y.spec.ts
Normal file
388
src/Web/StellaOps.Web/tests/e2e/quiet-triage-a11y.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
335
src/Web/StellaOps.Web/tests/e2e/quiet-triage.spec.ts
Normal file
335
src/Web/StellaOps.Web/tests/e2e/quiet-triage.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user