feat: Add VEX Status Chip component and integration tests for reachability drift detection

- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips.
- Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling.
- Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation.
- Updated project references to include the new Reachability Drift library.
This commit is contained in:
StellaOps Bot
2025-12-20 01:26:42 +02:00
parent edc91ea96f
commit 5fc469ad98
159 changed files with 41116 additions and 2305 deletions

View File

@@ -23,6 +23,8 @@ export interface FindingEvidenceResponse {
readonly score_explain?: ScoreExplanation;
readonly last_seen: string; // ISO 8601
readonly expires_at?: string;
/** Whether the evidence has exceeded its TTL and is considered stale. */
readonly is_stale?: boolean;
readonly attestation_refs?: readonly string[];
}
@@ -263,3 +265,27 @@ export function isVexValid(vex?: VexEvidence): boolean {
if (!vex.expires_at) return true;
return new Date(vex.expires_at) > new Date();
}
/**
* Checks if finding evidence is stale (exceeded TTL).
*/
export function isEvidenceStale(evidence?: FindingEvidenceResponse): boolean {
if (!evidence) return true;
// Use explicit is_stale flag if available
if (evidence.is_stale !== undefined) return evidence.is_stale;
// Otherwise calculate from expires_at
if (!evidence.expires_at) return false;
return new Date(evidence.expires_at) <= new Date();
}
/**
* Checks if finding evidence is nearing expiry (within 1 day).
*/
export function isEvidenceNearExpiry(evidence?: FindingEvidenceResponse): boolean {
if (!evidence || !evidence.expires_at) return false;
if (evidence.is_stale) return false; // Already stale
const expiresAt = new Date(evidence.expires_at);
const now = new Date();
const oneDayMs = 24 * 60 * 60 * 1000;
return (expiresAt.getTime() - now.getTime()) <= oneDayMs;
}

View File

@@ -0,0 +1,293 @@
/**
* Approval Button Component Tests.
* Sprint: SPRINT_4100_0005_0001 (Evidence-Gated Approval Button)
* Task: AB-005 - Unit tests for ApprovalButtonComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ApprovalButtonComponent, ApprovalRequest } from './approval-button.component';
import type { ChainStatusDisplay } from './chain-status-badge.component';
describe('ApprovalButtonComponent', () => {
let component: ApprovalButtonComponent;
let fixture: ComponentFixture<ApprovalButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ApprovalButtonComponent, FormsModule],
}).compileComponents();
fixture = TestBed.createComponent(ApprovalButtonComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('findingId', 'CVE-2024-12345@pkg:npm/stripe@6.1.2');
fixture.componentRef.setInput('digestRef', 'sha256:abc123def456');
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('button state', () => {
it('should be enabled when chain is complete', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
expect(component.isDisabled()).toBe(false);
});
it('should be disabled when chain is empty', () => {
fixture.componentRef.setInput('chainStatus', 'empty' as ChainStatusDisplay);
fixture.detectChanges();
expect(component.isDisabled()).toBe(true);
});
it('should be disabled when chain is broken', () => {
fixture.componentRef.setInput('chainStatus', 'broken' as ChainStatusDisplay);
fixture.detectChanges();
expect(component.isDisabled()).toBe(true);
});
it('should be disabled when disabled input is true', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
expect(component.isDisabled()).toBe(true);
});
});
describe('button labels', () => {
it('should show "Approve" when ready', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.approval-button');
expect(button.textContent).toContain('Approve');
});
it('should show "Approved" after successful approval', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
component.state.set('approved');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.approval-button');
expect(button.textContent).toContain('Approved');
});
it('should show "Approving..." when submitting', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
component.state.set('submitting');
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.approval-button');
expect(button.textContent).toContain('Approving');
});
});
describe('confirmation modal', () => {
it('should open modal on button click when enabled', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.approval-button');
button.click();
fixture.detectChanges();
expect(component.state()).toBe('confirming');
});
it('should not open modal when disabled', () => {
fixture.componentRef.setInput('chainStatus', 'empty' as ChainStatusDisplay);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.approval-button');
button.click();
fixture.detectChanges();
expect(component.state()).not.toBe('confirming');
});
it('should close modal on cancel', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.onButtonClick();
fixture.detectChanges();
expect(component.state()).toBe('confirming');
component.cancelConfirmation();
fixture.detectChanges();
expect(component.state()).toBe('idle');
});
});
describe('approval submission', () => {
it('should emit approve event with request data', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
const spy = jasmine.createSpy('approve');
component.approve.subscribe(spy);
component.onButtonClick();
fixture.detectChanges();
component.reason = 'Accepted residual risk';
component.submitApproval();
expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({
findingId: 'CVE-2024-12345@pkg:npm/stripe@6.1.2',
digestRef: 'sha256:abc123def456',
reason: 'Accepted residual risk',
} as ApprovalRequest));
});
it('should require reason to submit', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.onButtonClick();
fixture.detectChanges();
component.reason = '';
expect(component.canSubmit()).toBe(false);
component.reason = 'Some reason';
expect(component.canSubmit()).toBe(true);
});
});
describe('missing attestations tooltip', () => {
it('should show missing attestations when provided', () => {
fixture.componentRef.setInput('chainStatus', 'partial' as ChainStatusDisplay);
fixture.componentRef.setInput('missingAttestations', ['VEX', 'Decision']);
fixture.detectChanges();
const tooltip = component.buttonTooltip();
expect(tooltip).toContain('VEX');
expect(tooltip).toContain('Decision');
});
});
describe('accessibility', () => {
it('should have aria-label on button', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
expect(component.ariaLabel()).toBeTruthy();
});
it('should mark modal as aria-modal', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.onButtonClick();
fixture.detectChanges();
const modal = fixture.nativeElement.querySelector('[role="dialog"]');
expect(modal.getAttribute('aria-modal')).toBe('true');
});
});
describe('expiry selection', () => {
it('should default to 30 days expiry', () => {
expect(component.expiryDays).toBe(30);
});
it('should include expiry in approval request', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
const spy = jasmine.createSpy('approve');
component.approve.subscribe(spy);
component.onButtonClick();
component.reason = 'Test reason';
component.expiryDays = 60;
component.submitApproval();
expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({
expiresInDays: 60,
}));
});
});
describe('digest display', () => {
it('should truncate long digests', () => {
fixture.componentRef.setInput('digestRef', 'sha256:abc123def456789012345678901234567890abcdef');
fixture.detectChanges();
const shortDigest = component.shortDigest();
expect(shortDigest.length).toBeLessThan(64);
expect(shortDigest).toContain('...');
});
it('should not truncate short digests', () => {
fixture.componentRef.setInput('digestRef', 'sha256:abc');
fixture.detectChanges();
const shortDigest = component.shortDigest();
expect(shortDigest).toBe('sha256:abc');
});
});
describe('button classes', () => {
it('should have enabled class when chain complete', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
const className = component.buttonClass();
expect(className).toContain('enabled');
});
it('should have disabled class when chain incomplete', () => {
fixture.componentRef.setInput('chainStatus', 'empty' as ChainStatusDisplay);
fixture.detectChanges();
const className = component.buttonClass();
expect(className).toContain('disabled');
});
});
describe('state transitions', () => {
it('should transition idle → confirming on click', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
expect(component.state()).toBe('idle');
component.onButtonClick();
expect(component.state()).toBe('confirming');
});
it('should transition confirming → submitting on submit', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.onButtonClick();
component.reason = 'Test';
component.submitApproval();
expect(component.state()).toBe('submitting');
});
it('should transition to approved on success', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.markApproved();
expect(component.state()).toBe('approved');
});
it('should transition to error on failure', () => {
fixture.componentRef.setInput('chainStatus', 'complete' as ChainStatusDisplay);
fixture.detectChanges();
component.markError();
expect(component.state()).toBe('error');
});
});
});

View File

@@ -0,0 +1,623 @@
/**
* Approval Button Component.
* Sprint: SPRINT_4100_0005_0001 (Evidence-Gated Approval)
* Task: AB-001, AB-002, AB-003, AB-004 - Evidence-gated approval workflow
*
* Displays an approval button that is disabled until the attestation chain
* is complete. Opens a confirmation modal when clicked.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { ChainStatusDisplay } from './chain-status-badge.component';
/**
* Approval request data.
*/
export interface ApprovalRequest {
readonly findingId: string;
readonly digestRef: string;
readonly reason: string;
readonly expiresInDays: number;
}
/**
* Approval state enum.
*/
export type ApprovalState = 'idle' | 'confirming' | 'submitting' | 'approved' | 'error';
/**
* Evidence-gated approval button component.
*
* Features:
* - Disabled until attestation chain is complete
* - Shows missing attestation types in tooltip
* - Opens confirmation modal with reason input
* - Displays loading state during API call
* - Shows success/error feedback
*
* @example
* <stella-approval-button
* [findingId]="finding.id"
* [digestRef]="finding.digestRef"
* [chainStatus]="'complete'"
* (approve)="onApprove($event)"
* />
*/
@Component({
selector: 'stella-approval-button',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<!-- Main Button -->
<button
class="approval-button"
[class]="buttonClass()"
[disabled]="isDisabled()"
[title]="buttonTooltip()"
(click)="onButtonClick()"
type="button"
[attr.aria-label]="ariaLabel()"
>
@switch (state()) {
@case ('submitting') {
<span class="approval-button__spinner" aria-hidden="true">⏳</span>
<span class="approval-button__text">Approving...</span>
}
@case ('approved') {
<span class="approval-button__icon" aria-hidden="true">✓</span>
<span class="approval-button__text">Approved</span>
}
@case ('error') {
<span class="approval-button__icon" aria-hidden="true">⚠</span>
<span class="approval-button__text">Error</span>
}
@default {
<span class="approval-button__icon" aria-hidden="true">✓</span>
<span class="approval-button__text">{{ buttonLabel() }}</span>
}
}
</button>
<!-- Confirmation Modal -->
@if (state() === 'confirming') {
<div class="approval-modal__backdrop" (click)="onBackdropClick($event)">
<div class="approval-modal" role="dialog" aria-modal="true" aria-labelledby="approval-modal-title">
<header class="approval-modal__header">
<h2 id="approval-modal-title" class="approval-modal__title">Approve Finding</h2>
<button
class="approval-modal__close"
(click)="cancelConfirmation()"
aria-label="Close"
type="button"
>
×
</button>
</header>
<div class="approval-modal__body">
<p class="approval-modal__intro">
You are approving acceptance of residual risk for:
</p>
<div class="approval-modal__finding">
<strong>{{ cveId() || findingId() }}</strong>
@if (componentName()) {
<span class="approval-modal__component">in {{ componentName() }}</span>
}
<br />
<code class="approval-modal__digest">Digest: {{ shortDigest() }}</code>
</div>
<div class="approval-modal__field">
<label for="approval-reason" class="approval-modal__label">
Reason for approval (required):
</label>
<textarea
id="approval-reason"
class="approval-modal__textarea"
[(ngModel)]="reason"
placeholder="Accepted residual risk for production release"
rows="3"
required
></textarea>
</div>
<div class="approval-modal__field">
<label for="approval-expiry" class="approval-modal__label">
Approval expires in:
</label>
<select
id="approval-expiry"
class="approval-modal__select"
[(ngModel)]="expiryDays"
>
<option [value]="7">7 days</option>
<option [value]="14">14 days</option>
<option [value]="30">30 days</option>
<option [value]="60">60 days</option>
<option [value]="90">90 days</option>
</select>
</div>
<p class="approval-modal__warning">
⚠ This will create a signed human-approval attestation
linked to the current policy decision.
</p>
</div>
<footer class="approval-modal__footer">
<button
class="approval-modal__btn approval-modal__btn--cancel"
(click)="cancelConfirmation()"
type="button"
>
Cancel
</button>
<button
class="approval-modal__btn approval-modal__btn--submit"
[disabled]="!canSubmit()"
(click)="submitApproval()"
type="button"
>
Approve & Sign
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.approval-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s, opacity 0.15s;
&--enabled {
background: var(--success, #28a745);
color: white;
border-color: var(--success, #28a745);
&:hover:not(:disabled) {
background: var(--success-dark, #218838);
}
}
&--disabled {
background: var(--bg-muted, #e9ecef);
color: var(--text-muted, #6c757d);
border-color: var(--border-color, #e0e0e0);
cursor: not-allowed;
}
&--submitting {
background: var(--primary, #007bff);
color: white;
border-color: var(--primary, #007bff);
cursor: wait;
}
&--approved {
background: var(--success-light, #d4edda);
color: var(--success-dark, #155724);
border-color: var(--success, #28a745);
cursor: default;
}
&--error {
background: var(--danger-light, #f8d7da);
color: var(--danger-dark, #721c24);
border-color: var(--danger, #dc3545);
cursor: pointer;
}
}
.approval-button__spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.approval-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.approval-modal {
background: var(--bg-surface, #ffffff);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow: auto;
}
.approval-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.approval-modal__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.approval-modal__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--text-muted, #6c757d);
padding: 0.25rem;
&:hover {
color: var(--text-primary, #212529);
}
}
.approval-modal__body {
padding: 1.5rem;
}
.approval-modal__intro {
margin: 0 0 1rem;
color: var(--text-secondary, #6c757d);
}
.approval-modal__finding {
background: var(--bg-subtle, #f8f9fa);
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.approval-modal__component {
color: var(--text-secondary, #6c757d);
}
.approval-modal__digest {
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
}
.approval-modal__field {
margin-bottom: 1rem;
}
.approval-modal__label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
}
.approval-modal__textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font: inherit;
resize: vertical;
&:focus {
outline: none;
border-color: var(--primary, #007bff);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
}
.approval-modal__select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font: inherit;
&:focus {
outline: none;
border-color: var(--primary, #007bff);
}
}
.approval-modal__warning {
font-size: 0.8125rem;
color: var(--warning-dark, #856404);
background: var(--warning-light, #fff3cd);
padding: 0.75rem 1rem;
border-radius: 4px;
margin: 1rem 0 0;
}
.approval-modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.approval-modal__btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
&--cancel {
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-primary, #212529);
&:hover {
background: var(--bg-hover, #f8f9fa);
}
}
&--submit {
background: var(--success, #28a745);
border: 1px solid var(--success, #28a745);
color: white;
&:hover:not(:disabled) {
background: var(--success-dark, #218838);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
`],
})
export class ApprovalButtonComponent {
// =========================================================================
// Inputs
// =========================================================================
/** Finding ID. */
readonly findingId = input.required<string>();
/** Artifact digest reference. */
readonly digestRef = input.required<string>();
/** CVE ID for display. */
readonly cveId = input<string>();
/** Component name for display. */
readonly componentName = input<string>();
/** Chain status. */
readonly chainStatus = input<ChainStatusDisplay>('empty');
/** Missing attestation types. */
readonly missingAttestations = input<string[]>([]);
/** External loading state. */
readonly loading = input<boolean>(false);
/** External disabled state. */
readonly disabled = input<boolean>(false);
/** Whether finding is already approved. */
readonly alreadyApproved = input<boolean>(false);
/** Custom button label. */
readonly label = input<string>('Approve');
// =========================================================================
// Outputs
// =========================================================================
/** Emitted when approval is confirmed. */
readonly approve = output<ApprovalRequest>();
/** Emitted when confirmation modal opens. */
readonly confirmationOpened = output<void>();
/** Emitted when confirmation modal closes. */
readonly confirmationClosed = output<void>();
// =========================================================================
// Internal State
// =========================================================================
/** Current approval state. */
readonly state = signal<ApprovalState>('idle');
/** Approval reason. */
reason = '';
/** Expiry days. */
expiryDays = 30;
// =========================================================================
// Computed Properties
// =========================================================================
/** Whether the chain is complete and approval is possible. */
readonly canApprove = computed(() => {
return (
this.chainStatus() === 'complete' &&
!this.disabled() &&
!this.loading() &&
!this.alreadyApproved()
);
});
/** Whether the button is disabled. */
readonly isDisabled = computed(() => {
return (
!this.canApprove() ||
this.state() === 'submitting' ||
this.state() === 'approved'
);
});
/** Button CSS class. */
readonly buttonClass = computed(() => {
const classes = ['approval-button'];
const currentState = this.state();
if (this.alreadyApproved() || currentState === 'approved') {
classes.push('approval-button--approved');
} else if (currentState === 'submitting' || this.loading()) {
classes.push('approval-button--submitting');
} else if (currentState === 'error') {
classes.push('approval-button--error');
} else if (this.canApprove()) {
classes.push('approval-button--enabled');
} else {
classes.push('approval-button--disabled');
}
return classes.join(' ');
});
/** Button label. */
readonly buttonLabel = computed(() => {
if (this.alreadyApproved()) return 'Approved';
return this.label();
});
/** Button tooltip. */
readonly buttonTooltip = computed(() => {
if (this.alreadyApproved()) {
return 'This finding has already been approved';
}
if (this.state() === 'error') {
return 'Approval failed. Click to retry.';
}
const missing = this.missingAttestations();
if (missing.length > 0) {
return `Missing attestations: ${missing.join(', ')}`;
}
if (this.chainStatus() !== 'complete') {
return 'Attestation chain must be complete to approve';
}
if (this.disabled()) {
return 'Approval is disabled';
}
return 'Click to approve this finding';
});
/** ARIA label. */
readonly ariaLabel = computed(() => {
if (this.alreadyApproved()) {
return 'Finding already approved';
}
if (!this.canApprove()) {
return `Approve button disabled. ${this.buttonTooltip()}`;
}
return `Approve finding ${this.cveId() || this.findingId()}`;
});
/** Short digest for display. */
readonly shortDigest = computed(() => {
const d = this.digestRef();
if (d.length <= 20) return d;
return `${d.slice(0, 12)}...${d.slice(-8)}`;
});
/** Whether the confirmation form can be submitted. */
readonly canSubmit = computed(() => {
return this.reason.trim().length > 0;
});
// =========================================================================
// Methods
// =========================================================================
/** Handle button click. */
onButtonClick(): void {
if (this.state() === 'error') {
// Retry - go back to idle
this.state.set('idle');
return;
}
if (!this.canApprove()) {
return;
}
this.state.set('confirming');
this.confirmationOpened.emit();
}
/** Handle backdrop click. */
onBackdropClick(event: Event): void {
if ((event.target as HTMLElement).classList.contains('approval-modal__backdrop')) {
this.cancelConfirmation();
}
}
/** Cancel confirmation and close modal. */
cancelConfirmation(): void {
this.state.set('idle');
this.reason = '';
this.confirmationClosed.emit();
}
/** Submit approval. */
submitApproval(): void {
if (!this.canSubmit()) {
return;
}
this.state.set('submitting');
// Emit approval request - parent handles API call
this.approve.emit({
findingId: this.findingId(),
digestRef: this.digestRef(),
reason: this.reason.trim(),
expiresInDays: this.expiryDays,
});
}
/** Mark approval as complete (called by parent after API success). */
markApproved(): void {
this.state.set('approved');
this.reason = '';
this.confirmationClosed.emit();
}
/** Mark approval as failed (called by parent after API error). */
markError(): void {
this.state.set('error');
}
/** Reset to idle state. */
reset(): void {
this.state.set('idle');
this.reason = '';
}
}

View File

@@ -0,0 +1,209 @@
/**
* Attestation Node Component Tests.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab and Chain Viewer)
* Task: PROOF-006 - Unit tests for AttestationNodeComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AttestationNodeComponent, SignerInfo, RekorRef } from './attestation-node.component';
describe('AttestationNodeComponent', () => {
let component: AttestationNodeComponent;
let fixture: ComponentFixture<AttestationNodeComponent>;
const mockSigner: SignerInfo = {
keyId: 'key-abc123',
identity: 'signer@org.com',
algorithm: 'ECDSA-P256',
};
const mockRekorRef: RekorRef = {
logIndex: 12345,
logId: 'rekor-log-id',
url: 'https://rekor.sigstore.dev/api/v1/log/entries/12345',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AttestationNodeComponent],
}).compileComponents();
fixture = TestBed.createComponent(AttestationNodeComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('type', 'policy');
fixture.componentRef.setInput('digest', 'sha256:abc123def456');
fixture.componentRef.setInput('predicateType', 'stella.ops/policy-decision@v1');
fixture.componentRef.setInput('verified', true);
fixture.componentRef.setInput('signer', mockSigner);
fixture.componentRef.setInput('timestamp', '2025-12-18T09:22:00Z');
fixture.componentRef.setInput('rekorRef', mockRekorRef);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('type display', () => {
it('should display policy icon and label', () => {
expect(component.icon()).toBe('⚖️');
expect(component.typeLabel()).toBe('Policy Decision');
});
it('should display SBOM icon and label', () => {
fixture.componentRef.setInput('type', 'sbom');
fixture.detectChanges();
expect(component.icon()).toBe('📦');
expect(component.typeLabel()).toBe('SBOM');
});
it('should display VEX icon and label', () => {
fixture.componentRef.setInput('type', 'vex');
fixture.detectChanges();
expect(component.icon()).toBe('📋');
expect(component.typeLabel()).toBe('VEX');
});
it('should display Approval icon and label', () => {
fixture.componentRef.setInput('type', 'approval');
fixture.detectChanges();
expect(component.icon()).toBe('✅');
expect(component.typeLabel()).toBe('Human Approval');
});
});
describe('verification status', () => {
it('should show verified status when verified', () => {
expect(component.statusIcon()).toBe('✓');
expect(component.statusLabel()).toBe('Verified');
expect(component.statusClass()).toContain('verified');
});
it('should show unverified status when not verified', () => {
fixture.componentRef.setInput('verified', false);
fixture.detectChanges();
expect(component.statusIcon()).toBe('?');
expect(component.statusLabel()).toBe('Unverified');
expect(component.statusClass()).toContain('unverified');
});
it('should show expired status when expired', () => {
fixture.componentRef.setInput('expired', true);
fixture.detectChanges();
expect(component.statusIcon()).toBe('⚠');
expect(component.statusLabel()).toBe('Expired');
expect(component.statusClass()).toContain('expired');
});
});
describe('digest display', () => {
it('should truncate long digests', () => {
fixture.componentRef.setInput('digest', 'sha256:abcdefghijklmnopqrstuvwxyz123456');
fixture.detectChanges();
const short = component.shortDigest();
expect(short).toContain('...');
expect(short?.length).toBeLessThan(30);
});
it('should not truncate short digests', () => {
fixture.componentRef.setInput('digest', 'short');
fixture.detectChanges();
expect(component.shortDigest()).toBe('short');
});
});
describe('predicate type display', () => {
it('should extract short predicate type', () => {
expect(component.shortPredicateType()).toBe('policy-decision');
});
});
describe('expand/collapse', () => {
it('should start collapsed', () => {
expect(component.isExpanded()).toBe(false);
});
it('should toggle expand on click', () => {
component.toggleExpand();
expect(component.isExpanded()).toBe(true);
component.toggleExpand();
expect(component.isExpanded()).toBe(false);
});
it('should emit expand event on expand', () => {
const spy = jasmine.createSpy('expand');
component.expand.subscribe(spy);
component.toggleExpand();
expect(spy).toHaveBeenCalled();
});
});
describe('expanded details', () => {
beforeEach(() => {
component.toggleExpand();
fixture.detectChanges();
});
it('should show signer information', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('key-abc123');
expect(compiled.textContent).toContain('signer@org.com');
expect(compiled.textContent).toContain('ECDSA-P256');
});
it('should show Rekor reference', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('12345');
});
it('should show timestamp', () => {
const compiled = fixture.nativeElement;
// Timestamp is formatted, so check for partial content
expect(compiled.textContent).toContain('2025');
});
});
describe('timestamp formatting', () => {
it('should format valid ISO timestamp', () => {
const formatted = component.formatTimestamp('2025-12-18T09:22:00Z');
expect(formatted).toContain('2025');
expect(formatted).toContain('Dec');
});
it('should return original string for invalid timestamp', () => {
const formatted = component.formatTimestamp('invalid');
expect(formatted).toBe('invalid');
});
});
describe('accessibility', () => {
it('should have appropriate ARIA label', () => {
expect(component.ariaLabel()).toContain('Policy Decision');
expect(component.ariaLabel()).toContain('verified');
});
it('should have aria-expanded attribute', () => {
const compiled = fixture.nativeElement;
const header = compiled.querySelector('.attestation-node__header');
expect(header.getAttribute('aria-expanded')).toBe('false');
component.toggleExpand();
fixture.detectChanges();
expect(header.getAttribute('aria-expanded')).toBe('true');
});
});
describe('node class', () => {
it('should include verified class when verified', () => {
expect(component.nodeClass()).toContain('attestation-node--verified');
});
it('should include expired class when expired', () => {
fixture.componentRef.setInput('expired', true);
fixture.detectChanges();
expect(component.nodeClass()).toContain('attestation-node--expired');
});
});
});

View File

@@ -0,0 +1,500 @@
/**
* Attestation Node Component.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab and Chain Viewer)
* Task: PROOF-002 - AttestationNodeComponent for individual chain nodes
*
* Displays a single attestation in the proof chain with expandable details.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Attestation type enum.
*/
export type AttestationType = 'sbom' | 'vex' | 'policy' | 'approval' | 'graph' | 'unknown';
/**
* Signer information for an attestation.
*/
export interface SignerInfo {
readonly keyId: string;
readonly identity?: string;
readonly algorithm: string;
}
/**
* Rekor transparency log reference.
*/
export interface RekorRef {
readonly logIndex: number;
readonly logId: string;
readonly url?: string;
}
/**
* Single attestation node in the proof chain.
*
* Features:
* - Type icon (SBOM, VEX, Policy, Approval, Graph)
* - Verification status (verified, unverified, expired)
* - Expandable DSSE envelope details
* - Rekor log reference link
*
* @example
* <stella-attestation-node
* [type]="'policy'"
* [digest]="'sha256:abc123'"
* [predicateType]="'stella.ops/policy-decision@v1'"
* [verified]="true"
* [signer]="signerInfo"
* [timestamp]="'2025-12-18T09:22:00Z'"
* />
*/
@Component({
selector: 'stella-attestation-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="attestation-node"
[class]="nodeClass()"
role="article"
[attr.aria-label]="ariaLabel()"
>
<!-- Header -->
<button
class="attestation-node__header"
(click)="toggleExpand()"
type="button"
[attr.aria-expanded]="isExpanded()"
>
<span class="attestation-node__icon" aria-hidden="true">{{ icon() }}</span>
<span class="attestation-node__type">{{ typeLabel() }}</span>
<span class="attestation-node__status" [class]="statusClass()">
{{ statusIcon() }} {{ statusLabel() }}
</span>
<span class="attestation-node__chevron" aria-hidden="true">
{{ isExpanded() ? '▼' : '▶' }}
</span>
</button>
<!-- Summary (always visible) -->
<div class="attestation-node__summary">
<span class="attestation-node__predicate" [title]="predicateType()">
{{ shortPredicateType() }}
</span>
@if (digest()) {
<code class="attestation-node__digest" [title]="digest()">
{{ shortDigest() }}
</code>
}
</div>
<!-- Expanded Details -->
@if (isExpanded()) {
<div class="attestation-node__details" role="region" aria-label="Attestation details">
<dl class="attestation-node__properties">
<dt>Predicate Type</dt>
<dd><code>{{ predicateType() }}</code></dd>
<dt>Subject Digest</dt>
<dd><code>{{ digest() }}</code></dd>
@if (signer()) {
<dt>Signer</dt>
<dd>
<code>{{ signer()!.keyId }}</code>
@if (signer()!.identity) {
<span class="attestation-node__signer-identity">{{ signer()!.identity }}</span>
}
<span class="attestation-node__signer-algo">({{ signer()!.algorithm }})</span>
</dd>
}
@if (timestamp()) {
<dt>Signed At</dt>
<dd>{{ formatTimestamp(timestamp()!) }}</dd>
}
@if (rekorRef()) {
<dt>Rekor Log</dt>
<dd>
@if (rekorRef()!.url) {
<a
[href]="rekorRef()!.url"
target="_blank"
rel="noopener noreferrer"
class="attestation-node__rekor-link"
>
#{{ rekorRef()!.logIndex }}
</a>
} @else {
<span>#{{ rekorRef()!.logIndex }}</span>
}
</dd>
}
@if (expired()) {
<dt>Status</dt>
<dd class="attestation-node__expired-warning">⚠ Attestation has expired</dd>
}
</dl>
@if (showRawEnvelope()) {
<details class="attestation-node__raw">
<summary>Raw DSSE Envelope</summary>
<pre class="attestation-node__raw-content">{{ rawEnvelope() }}</pre>
</details>
}
</div>
}
</div>
`,
styles: [`
.attestation-node {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-surface, #ffffff);
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: var(--border-hover, #c0c0c0);
}
&--verified {
border-left: 3px solid var(--success, #28a745);
}
&--unverified {
border-left: 3px solid var(--warning, #ffc107);
}
&--expired {
border-left: 3px solid var(--danger, #dc3545);
opacity: 0.85;
}
}
.attestation-node__header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
font: inherit;
&:hover {
background: var(--bg-hover, #f8f9fa);
}
&:focus {
outline: 2px solid var(--focus-ring, #007bff);
outline-offset: -2px;
}
}
.attestation-node__icon {
font-size: 1.25rem;
line-height: 1;
}
.attestation-node__type {
font-weight: 600;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.attestation-node__status {
margin-left: auto;
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 3px;
&--verified {
background: rgba(40, 167, 69, 0.1);
color: var(--success, #28a745);
}
&--unverified {
background: rgba(255, 193, 7, 0.15);
color: var(--warning-dark, #856404);
}
&--expired {
background: rgba(220, 53, 69, 0.1);
color: var(--danger, #dc3545);
}
}
.attestation-node__chevron {
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
}
.attestation-node__summary {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0 1rem 0.75rem 2.75rem;
font-size: 0.8125rem;
}
.attestation-node__predicate {
color: var(--text-secondary, #6c757d);
}
.attestation-node__digest {
font-size: 0.75rem;
background: var(--bg-code, #f1f3f4);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.attestation-node__details {
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-subtle, #fafafa);
}
.attestation-node__properties {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
font-size: 0.8125rem;
margin: 0;
dt {
font-weight: 600;
color: var(--text-muted, #6c757d);
}
dd {
margin: 0;
word-break: break-all;
code {
font-size: 0.75rem;
background: var(--bg-code, #f1f3f4);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
}
}
.attestation-node__signer-identity {
margin-left: 0.5rem;
color: var(--text-secondary, #6c757d);
}
.attestation-node__signer-algo {
margin-left: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
}
.attestation-node__rekor-link {
color: var(--link, #007bff);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.attestation-node__expired-warning {
color: var(--danger, #dc3545);
}
.attestation-node__raw {
margin-top: 0.75rem;
summary {
cursor: pointer;
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
&:hover {
color: var(--text-primary, #212529);
}
}
}
.attestation-node__raw-content {
margin: 0.5rem 0 0;
padding: 0.5rem;
background: var(--bg-code, #f1f3f4);
border-radius: 4px;
font-size: 0.6875rem;
overflow-x: auto;
max-height: 200px;
}
`],
})
export class AttestationNodeComponent {
// =========================================================================
// Inputs
// =========================================================================
/** Attestation type. */
readonly type = input<AttestationType>('unknown');
/** Subject digest. */
readonly digest = input<string>();
/** Predicate type (e.g., stella.ops/policy-decision@v1). */
readonly predicateType = input<string>();
/** Whether the attestation signature is verified. */
readonly verified = input<boolean>(false);
/** Whether the attestation has expired. */
readonly expired = input<boolean>(false);
/** Signer information. */
readonly signer = input<SignerInfo>();
/** Signature timestamp (ISO 8601). */
readonly timestamp = input<string>();
/** Rekor transparency log reference. */
readonly rekorRef = input<RekorRef>();
/** Raw DSSE envelope JSON (for advanced view). */
readonly rawEnvelope = input<string>();
/** Whether to show raw envelope option. */
readonly showRawEnvelope = input<boolean>(false);
// =========================================================================
// Outputs
// =========================================================================
/** Emitted when expand is requested. */
readonly expand = output<void>();
// =========================================================================
// Internal State
// =========================================================================
/** Whether the node is expanded. */
readonly isExpanded = signal<boolean>(false);
// =========================================================================
// Computed Properties
// =========================================================================
/** Icon for attestation type. */
readonly icon = computed(() => {
switch (this.type()) {
case 'sbom': return '📦';
case 'vex': return '📋';
case 'policy': return '⚖️';
case 'approval': return '✅';
case 'graph': return '🔗';
default: return '📄';
}
});
/** Label for attestation type. */
readonly typeLabel = computed(() => {
switch (this.type()) {
case 'sbom': return 'SBOM';
case 'vex': return 'VEX';
case 'policy': return 'Policy Decision';
case 'approval': return 'Human Approval';
case 'graph': return 'Rich Graph';
default: return 'Attestation';
}
});
/** Status icon. */
readonly statusIcon = computed(() => {
if (this.expired()) return '⚠';
return this.verified() ? '✓' : '?';
});
/** Status label. */
readonly statusLabel = computed(() => {
if (this.expired()) return 'Expired';
return this.verified() ? 'Verified' : 'Unverified';
});
/** Status CSS class. */
readonly statusClass = computed(() => {
if (this.expired()) return 'attestation-node__status--expired';
return this.verified()
? 'attestation-node__status--verified'
: 'attestation-node__status--unverified';
});
/** Node CSS class. */
readonly nodeClass = computed(() => {
const classes = ['attestation-node'];
if (this.expired()) {
classes.push('attestation-node--expired');
} else if (this.verified()) {
classes.push('attestation-node--verified');
} else {
classes.push('attestation-node--unverified');
}
return classes.join(' ');
});
/** Shortened digest for display. */
readonly shortDigest = computed(() => {
const d = this.digest();
if (!d || d.length <= 20) return d;
return `${d.slice(0, 12)}...${d.slice(-8)}`;
});
/** Shortened predicate type. */
readonly shortPredicateType = computed(() => {
const pt = this.predicateType();
if (!pt) return '';
// Extract just the type name without namespace/version
const match = pt.match(/\/([^@]+)/);
return match?.[1] ?? pt;
});
/** ARIA label. */
readonly ariaLabel = computed(() => {
const status = this.expired() ? 'expired' : (this.verified() ? 'verified' : 'unverified');
return `${this.typeLabel()} attestation, ${status}`;
});
// =========================================================================
// Methods
// =========================================================================
/** Toggle expanded state. */
toggleExpand(): void {
const newState = !this.isExpanded();
this.isExpanded.set(newState);
if (newState) {
this.expand.emit();
}
}
/** Format timestamp for display. */
formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
} catch {
return iso;
}
}
}

View File

@@ -0,0 +1,229 @@
/**
* Chain Status Badge Component Tests.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-005 - Unit tests for ChainStatusBadgeComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
import type { AttestationChainStatus } from '../../core/api/attestation-chain.models';
describe('ChainStatusBadgeComponent', () => {
let component: ChainStatusBadgeComponent;
let fixture: ComponentFixture<ChainStatusBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChainStatusBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(ChainStatusBadgeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('status rendering', () => {
it('should display empty state by default', () => {
expect(component.status()).toBeUndefined();
expect(component.displayStatus()).toBe('empty');
expect(component.label()).toBe('No Chain');
expect(component.icon()).toBe('○');
});
it('should display verified status correctly', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.detectChanges();
expect(component.displayStatus()).toBe('complete');
expect(component.label()).toBe('Verified');
expect(component.icon()).toBe('🔗');
expect(component.badgeClass()).toContain('complete');
});
it('should display complete status correctly', () => {
fixture.componentRef.setInput('status', 'complete');
fixture.detectChanges();
expect(component.displayStatus()).toBe('complete');
expect(component.label()).toBe('Verified');
});
it('should display expired status correctly', () => {
fixture.componentRef.setInput('status', 'expired');
fixture.detectChanges();
expect(component.displayStatus()).toBe('expired');
expect(component.label()).toBe('Expired');
expect(component.icon()).toBe('⏰');
expect(component.badgeClass()).toContain('expired');
});
it('should display signature_invalid as invalid', () => {
fixture.componentRef.setInput('status', 'signature_invalid');
fixture.detectChanges();
expect(component.displayStatus()).toBe('invalid');
expect(component.label()).toBe('Invalid');
expect(component.icon()).toBe('✗');
expect(component.badgeClass()).toContain('invalid');
});
it('should display untrusted_signer as invalid', () => {
fixture.componentRef.setInput('status', 'untrusted_signer');
fixture.detectChanges();
expect(component.displayStatus()).toBe('invalid');
});
it('should display chain_broken as broken', () => {
fixture.componentRef.setInput('status', 'chain_broken');
fixture.detectChanges();
expect(component.displayStatus()).toBe('broken');
expect(component.label()).toBe('Broken');
expect(component.icon()).toBe('💔');
expect(component.badgeClass()).toContain('broken');
});
it('should display pending status correctly', () => {
fixture.componentRef.setInput('status', 'pending');
fixture.detectChanges();
expect(component.displayStatus()).toBe('pending');
expect(component.label()).toBe('Pending');
expect(component.icon()).toBe('⏳');
expect(component.badgeClass()).toContain('pending');
});
it('should display partial status correctly', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.detectChanges();
expect(component.displayStatus()).toBe('partial');
expect(component.label()).toBe('Partial');
expect(component.icon()).toBe('⚡');
expect(component.badgeClass()).toContain('partial');
});
});
describe('missing steps', () => {
it('should not display count when no missing steps', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.chain-badge__count')).toBeNull();
});
it('should display count when missing steps provided', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.componentRef.setInput('missingSteps', ['policy', 'richgraph']);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const countEl = compiled.querySelector('.chain-badge__count');
expect(countEl).toBeTruthy();
expect(countEl.textContent).toContain('2');
});
it('should hide count when showCount is false', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.componentRef.setInput('missingSteps', ['policy']);
fixture.componentRef.setInput('showCount', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.chain-badge__count')).toBeNull();
});
});
describe('tooltip', () => {
it('should have base tooltip for each status', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.detectChanges();
expect(component.tooltip()).toContain('fully verified');
});
it('should include missing steps in tooltip', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.componentRef.setInput('missingSteps', ['policy', 'approval']);
fixture.detectChanges();
expect(component.tooltip()).toContain('Missing: policy, approval');
});
it('should include expiration in tooltip', () => {
fixture.componentRef.setInput('status', 'expired');
fixture.componentRef.setInput('expiresAt', '2025-12-25T00:00:00Z');
fixture.detectChanges();
expect(component.tooltip()).toContain('Expires: 2025-12-25T00:00:00Z');
});
it('should use custom tooltip when provided', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.componentRef.setInput('customTooltip', 'Custom chain info');
fixture.detectChanges();
expect(component.tooltip()).toBe('Custom chain info');
});
});
describe('label visibility', () => {
it('should show label by default', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.chain-badge__label')).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.chain-badge__label')).toBeNull();
});
});
describe('accessibility', () => {
it('should have aria-label', () => {
fixture.componentRef.setInput('status', 'verified');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const badge = compiled.querySelector('.chain-badge');
expect(badge.getAttribute('aria-label')).toContain('verified');
});
it('should include missing step count in aria-label', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.componentRef.setInput('missingSteps', ['a', 'b', 'c']);
fixture.detectChanges();
expect(component.ariaLabel()).toContain('3 steps');
});
it('should use singular "step" for one missing step', () => {
fixture.componentRef.setInput('status', 'partial');
fixture.componentRef.setInput('missingSteps', ['policy']);
fixture.detectChanges();
expect(component.ariaLabel()).toContain('1 step');
expect(component.ariaLabel()).not.toContain('steps');
});
it('should have role="status"', () => {
const compiled = fixture.nativeElement;
const badge = compiled.querySelector('.chain-badge');
expect(badge.getAttribute('role')).toBe('status');
});
});
});

View File

@@ -0,0 +1,333 @@
/**
* Chain Status Badge Component.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-004 - ChainStatusBadge for attestation chain validity status
*
* Displays a compact badge indicating the health status of an attestation chain,
* with optional tooltip showing missing steps or expiration details.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { AttestationChainStatus } from '../../core/api/attestation-chain.models';
/**
* Internal chain status type that extends backend status values.
* Maps backend status to UI-friendly values.
*/
export type ChainStatusDisplay =
| 'complete' // verified
| 'partial' // some attestations present
| 'expired' // expired
| 'invalid' // signature_invalid or untrusted_signer
| 'broken' // chain_broken
| 'pending' // pending
| 'empty'; // no attestations
/**
* Compact badge component displaying attestation chain health.
*
* Color scheme:
* - complete (green): Full chain verified
* - partial (yellow): Some attestations present
* - pending (blue): Verification in progress
* - expired (orange): Attestations have expired
* - invalid (red): Signature verification failed
* - broken (red): Chain integrity broken
* - empty (gray): No attestations
*
* @example
* <stella-chain-status-badge [status]="'verified'" />
* <stella-chain-status-badge [status]="'chain_broken'" [missingSteps]="['policy', 'richgraph']" />
*/
@Component({
selector: 'stella-chain-status-badge',
standalone: true,
imports: [CommonModule],
template: `
<span
class="chain-badge"
[class]="badgeClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
role="status"
>
<span class="chain-badge__icon" aria-hidden="true">{{ icon() }}</span>
@if (showLabel()) {
<span class="chain-badge__label">{{ label() }}</span>
}
@if (showCount() && missingSteps() && missingSteps()!.length > 0) {
<span class="chain-badge__count" [attr.aria-label]="countAriaLabel()">
({{ missingSteps()!.length }})
</span>
}
</span>
`,
styles: [`
.chain-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: default;
transition: opacity 0.15s;
&:hover {
opacity: 0.9;
}
}
.chain-badge__icon {
font-size: 0.875rem;
line-height: 1;
}
.chain-badge__label {
letter-spacing: 0.02em;
}
.chain-badge__count {
font-size: 0.6875rem;
opacity: 0.85;
font-variant-numeric: tabular-nums;
}
// Status-specific colors (high contrast for accessibility)
.chain-badge--complete {
background: rgba(40, 167, 69, 0.15);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.chain-badge--partial {
background: rgba(255, 193, 7, 0.15);
color: #856404;
border: 1px solid rgba(255, 193, 7, 0.4);
}
.chain-badge--pending {
background: rgba(0, 123, 255, 0.15);
color: #007bff;
border: 1px solid rgba(0, 123, 255, 0.3);
}
.chain-badge--expired {
background: rgba(253, 126, 20, 0.15);
color: #fd7e14;
border: 1px solid rgba(253, 126, 20, 0.3);
}
.chain-badge--invalid,
.chain-badge--broken {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.chain-badge--empty {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
border: 1px solid rgba(108, 117, 125, 0.3);
}
`]
})
export class ChainStatusBadgeComponent {
/**
* Attestation chain status from backend.
*/
readonly status = input<AttestationChainStatus | ChainStatusDisplay | undefined>(undefined);
/**
* List of missing steps in the chain.
*/
readonly missingSteps = input<readonly string[] | undefined>(undefined);
/**
* Optional expiration timestamp.
*/
readonly expiresAt = input<string | undefined>(undefined);
/**
* Whether to show the text label (default: true).
*/
readonly showLabel = input<boolean>(true);
/**
* Whether to show count of missing steps (default: true).
*/
readonly showCount = input<boolean>(true);
/**
* Optional custom tooltip override.
*/
readonly customTooltip = input<string | undefined>(undefined);
/**
* Normalize status to display value.
*/
readonly displayStatus = computed((): ChainStatusDisplay => {
const status = this.status();
if (!status) return 'empty';
switch (status) {
case 'verified':
case 'complete':
return 'complete';
case 'expired':
return 'expired';
case 'signature_invalid':
case 'untrusted_signer':
case 'invalid':
return 'invalid';
case 'chain_broken':
case 'broken':
return 'broken';
case 'pending':
return 'pending';
case 'partial':
return 'partial';
default:
return 'empty';
}
});
/**
* Computed CSS class for status.
*/
readonly badgeClass = computed(() => {
const status = this.displayStatus();
return `chain-badge chain-badge--${status}`;
});
/**
* Computed icon based on status.
*/
readonly icon = computed(() => {
switch (this.displayStatus()) {
case 'complete':
return '🔗'; // Chain link - complete chain
case 'partial':
return '⚡'; // Lightning - partial
case 'pending':
return '⏳'; // Hourglass - pending
case 'expired':
return '⏰'; // Clock - expired
case 'invalid':
return '✗'; // Cross - invalid signature
case 'broken':
return '💔'; // Broken heart - broken chain
case 'empty':
default:
return '○'; // Empty circle
}
});
/**
* Computed label text.
*/
readonly label = computed(() => {
switch (this.displayStatus()) {
case 'complete':
return 'Verified';
case 'partial':
return 'Partial';
case 'pending':
return 'Pending';
case 'expired':
return 'Expired';
case 'invalid':
return 'Invalid';
case 'broken':
return 'Broken';
case 'empty':
default:
return 'No Chain';
}
});
/**
* Computed tooltip text.
*/
readonly tooltip = computed(() => {
if (this.customTooltip()) {
return this.customTooltip();
}
const missing = this.missingSteps();
const expires = this.expiresAt();
const parts: string[] = [];
switch (this.displayStatus()) {
case 'complete':
parts.push('Attestation chain fully verified');
break;
case 'partial':
parts.push('Attestation chain partially complete');
break;
case 'pending':
parts.push('Attestation chain verification in progress');
break;
case 'expired':
parts.push('Attestation chain has expired');
break;
case 'invalid':
parts.push('Attestation chain has invalid signature(s)');
break;
case 'broken':
parts.push('Attestation chain integrity is broken');
break;
case 'empty':
default:
parts.push('No attestation chain available');
}
if (missing && missing.length > 0) {
parts.push(`Missing: ${missing.join(', ')}`);
}
if (expires) {
parts.push(`Expires: ${expires}`);
}
return parts.join('\n');
});
/**
* Aria label for screen readers.
*/
readonly ariaLabel = computed(() => {
const missing = this.missingSteps();
switch (this.displayStatus()) {
case 'complete':
return 'Attestation chain verified';
case 'partial':
return missing && missing.length > 0
? `Attestation chain partial, missing ${missing.length} step${missing.length === 1 ? '' : 's'}`
: 'Attestation chain partial';
case 'pending':
return 'Attestation chain verification pending';
case 'expired':
return 'Attestation chain expired';
case 'invalid':
return 'Attestation chain has invalid signature';
case 'broken':
return 'Attestation chain is broken';
case 'empty':
default:
return 'No attestation chain';
}
});
/**
* Aria label for count span.
*/
readonly countAriaLabel = computed(() => {
const missing = this.missingSteps();
if (!missing || missing.length === 0) return '';
return `${missing.length} missing step${missing.length === 1 ? '' : 's'}`;
});
}

View File

@@ -0,0 +1,291 @@
/**
* DSSE Envelope Viewer Component Tests.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab)
* Task: PROOF-006 - Unit tests for DsseEnvelopeViewerComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DsseEnvelopeViewerComponent, DsseEnvelope, EnvelopeDisplayData } from './dsse-envelope-viewer.component';
describe('DsseEnvelopeViewerComponent', () => {
let component: DsseEnvelopeViewerComponent;
let fixture: ComponentFixture<DsseEnvelopeViewerComponent>;
const mockEnvelope: DsseEnvelope = {
payloadType: 'application/vnd.in-toto+json',
payload: 'eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEifQ==',
signatures: [
{ keyid: 'SHA256:abc123def456', sig: 'MEUCIQD...' },
{ keyid: 'SHA256:xyz789uvw012', sig: 'MEYCIQDe...' },
],
};
const mockDisplayData: EnvelopeDisplayData = {
predicateType: 'stella.ops/policy-decision@v1',
subject: [
{
name: 'registry.example.com/app/frontend',
digest: { sha256: 'abc123def456789012345678901234567890abcdef1234567890abcdef12345678' },
},
],
predicate: {
policy: { id: 'risk-gate-v1', version: '1.0.0' },
result: { allowed: true, score: 61 },
},
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DsseEnvelopeViewerComponent],
}).compileComponents();
fixture = TestBed.createComponent(DsseEnvelopeViewerComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('initial state', () => {
it('should be collapsed by default', () => {
expect(component.isExpanded()).toBe(false);
});
it('should not show predicate JSON by default', () => {
expect(component.showPredicateJson()).toBe(false);
});
it('should not show raw envelope by default', () => {
expect(component.showRaw()).toBe(false);
});
});
describe('payload type', () => {
it('should display payload type from envelope', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
expect(component.payloadType()).toBe('application/vnd.in-toto+json');
});
it('should show "unknown" when no envelope', () => {
expect(component.payloadType()).toBe('unknown');
});
});
describe('signatures', () => {
it('should return signature count', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
expect(component.signatureCount()).toBe(2);
});
it('should return empty array when no signatures', () => {
expect(component.signatures()).toEqual([]);
});
});
describe('verification status', () => {
it('should show "verified" when verified is true', () => {
fixture.componentRef.setInput('verified', true);
fixture.detectChanges();
expect(component.verificationStatus()).toBe('verified');
expect(component.verificationLabel()).toBe('✓ Verified');
});
it('should show "invalid" when verified is false', () => {
fixture.componentRef.setInput('verified', false);
fixture.detectChanges();
expect(component.verificationStatus()).toBe('invalid');
expect(component.verificationLabel()).toBe('✗ Invalid');
});
it('should show "unknown" when verified is undefined', () => {
expect(component.verificationStatus()).toBe('unknown');
expect(component.verificationLabel()).toBe('? Unknown');
});
});
describe('subjects', () => {
it('should return subjects from display data', () => {
fixture.componentRef.setInput('displayData', mockDisplayData);
fixture.detectChanges();
expect(component.subjects().length).toBe(1);
expect(component.subjects()[0].name).toBe('registry.example.com/app/frontend');
});
it('should return empty array when no display data', () => {
expect(component.subjects()).toEqual([]);
});
});
describe('predicate', () => {
it('should return predicate type', () => {
fixture.componentRef.setInput('displayData', mockDisplayData);
fixture.detectChanges();
expect(component.predicateType()).toBe('stella.ops/policy-decision@v1');
});
it('should detect predicate presence', () => {
fixture.componentRef.setInput('displayData', mockDisplayData);
fixture.detectChanges();
expect(component.hasPredicate()).toBe(true);
});
it('should format predicate as JSON', () => {
fixture.componentRef.setInput('displayData', mockDisplayData);
fixture.detectChanges();
const json = component.predicateJson();
expect(json).toContain('risk-gate-v1');
expect(json).toContain('"allowed": true');
});
});
describe('expand/collapse', () => {
it('should toggle expand state', () => {
component.toggleExpand();
expect(component.isExpanded()).toBe(true);
component.toggleExpand();
expect(component.isExpanded()).toBe(false);
});
it('should emit expandChanged', () => {
const spy = jasmine.createSpy('expandChanged');
component.expandChanged.subscribe(spy);
component.toggleExpand();
expect(spy).toHaveBeenCalledWith(true);
});
});
describe('predicate JSON toggle', () => {
it('should toggle predicate JSON visibility', () => {
component.togglePredicateJson();
expect(component.showPredicateJson()).toBe(true);
component.togglePredicateJson();
expect(component.showPredicateJson()).toBe(false);
});
});
describe('raw envelope toggle', () => {
it('should toggle raw envelope visibility', () => {
component.toggleRaw();
expect(component.showRaw()).toBe(true);
component.toggleRaw();
expect(component.showRaw()).toBe(false);
});
});
describe('digest helpers', () => {
it('should extract digest entries', () => {
const digest = { sha256: 'abc123', sha512: 'def456' };
const entries = component.getDigestEntries(digest);
expect(entries.length).toBe(2);
expect(entries).toContain(jasmine.objectContaining({ algo: 'sha256', value: 'abc123' }));
});
it('should truncate long digests', () => {
const longDigest = 'abc123def456789012345678901234567890abcdef1234567890abcdef12345678';
const truncated = component.truncateDigest(longDigest);
expect(truncated).toBe('abc123de…12345678');
});
it('should not truncate short digests', () => {
const shortDigest = 'abc123';
expect(component.truncateDigest(shortDigest)).toBe('abc123');
});
});
describe('key ID helpers', () => {
it('should truncate long key IDs', () => {
const longKeyId = 'SHA256:abc123def456789012345678901234567890';
const truncated = component.truncateKeyId(longKeyId);
expect(truncated.length).toBeLessThan(longKeyId.length);
expect(truncated).toContain('…');
});
it('should not truncate short key IDs', () => {
const shortKeyId = 'SHA256:abc123';
expect(component.truncateKeyId(shortKeyId)).toBe('SHA256:abc123');
});
});
describe('signature status', () => {
it('should return status from array', () => {
fixture.componentRef.setInput('signatureStatuses', ['verified', 'invalid']);
fixture.detectChanges();
expect(component.getSignatureStatus(0)).toBe('verified');
expect(component.getSignatureStatus(1)).toBe('invalid');
});
it('should default to unknown for missing index', () => {
fixture.componentRef.setInput('signatureStatuses', ['verified']);
fixture.detectChanges();
expect(component.getSignatureStatus(5)).toBe('unknown');
});
it('should return appropriate labels', () => {
fixture.componentRef.setInput('signatureStatuses', ['verified', 'invalid', 'unknown']);
fixture.detectChanges();
expect(component.getSignatureStatusLabel(0)).toBe('✓ Verified');
expect(component.getSignatureStatusLabel(1)).toBe('✗ Invalid');
expect(component.getSignatureStatusLabel(2)).toBe('? Unknown');
});
});
describe('raw envelope', () => {
it('should serialize envelope to JSON', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
const raw = component.rawEnvelope();
expect(raw).toContain('payloadType');
expect(raw).toContain('application/vnd.in-toto+json');
});
it('should return empty string when no envelope', () => {
expect(component.rawEnvelope()).toBe('');
});
});
describe('accessibility', () => {
it('should have aria-expanded on header', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.envelope-viewer__header');
expect(header.getAttribute('aria-expanded')).toBe('false');
component.toggleExpand();
fixture.detectChanges();
expect(header.getAttribute('aria-expanded')).toBe('true');
});
it('should have aria-controls on header', () => {
fixture.componentRef.setInput('envelope', mockEnvelope);
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.envelope-viewer__header');
expect(header.getAttribute('aria-controls')).toBe('envelope-content');
});
});
});

View File

@@ -0,0 +1,596 @@
/**
* DSSE Envelope Viewer Component
* Sprint: SPRINT_4100_0004_0002 (Proof Tab)
* Task: PROOF-004 - Add DSSE envelope expansion (JSON viewer)
*
* Displays a DSSE (Dead Simple Signing Envelope) with:
* - Header information (payload type, signatures)
* - Expandable JSON payload
* - Copy functionality
* - Signature verification status
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* DSSE Envelope structure.
*/
export interface DsseEnvelope {
payloadType: string;
payload: string; // Base64 encoded
signatures: DsseSignature[];
}
/**
* DSSE Signature structure.
*/
export interface DsseSignature {
keyid: string;
sig: string; // Base64 encoded
}
/**
* Parsed envelope display data.
*/
export interface EnvelopeDisplayData {
predicateType?: string;
subject?: Array<{ name: string; digest: Record<string, string> }>;
predicate?: unknown;
raw?: string;
}
@Component({
selector: 'stella-dsse-envelope-viewer',
standalone: true,
imports: [CommonModule],
template: `
<div class="envelope-viewer" [class.envelope-viewer--expanded]="isExpanded()">
<!-- Header -->
<button
class="envelope-viewer__header"
type="button"
(click)="toggleExpand()"
[attr.aria-expanded]="isExpanded()"
aria-controls="envelope-content"
>
<span class="envelope-viewer__icon" aria-hidden="true">
{{ isExpanded() ? '▼' : '▶' }}
</span>
<span class="envelope-viewer__type">{{ payloadType() }}</span>
<span class="envelope-viewer__badge" [class]="'envelope-viewer__badge--' + verificationStatus()">
{{ verificationLabel() }}
</span>
<span class="envelope-viewer__sig-count" *ngIf="signatureCount() > 0">
{{ signatureCount() }} signature(s)
</span>
</button>
<!-- Expanded content -->
<div
id="envelope-content"
class="envelope-viewer__content"
*ngIf="isExpanded()"
role="region"
aria-label="Envelope details"
>
<!-- Subjects -->
<section class="envelope-viewer__section" *ngIf="subjects().length > 0">
<h4 class="envelope-viewer__section-title">Subjects</h4>
<ul class="envelope-viewer__subjects">
<li *ngFor="let subject of subjects()" class="envelope-viewer__subject">
<span class="envelope-viewer__subject-name">{{ subject.name }}</span>
<span
*ngFor="let digest of getDigestEntries(subject.digest)"
class="envelope-viewer__digest"
>
<code class="envelope-viewer__digest-algo">{{ digest.algo }}:</code>
<code class="envelope-viewer__digest-value" [title]="digest.value">
{{ truncateDigest(digest.value) }}
</code>
<button
class="envelope-viewer__copy"
type="button"
(click)="copyToClipboard(digest.value, $event)"
title="Copy digest"
aria-label="Copy digest to clipboard"
>
📋
</button>
</span>
</li>
</ul>
</section>
<!-- Predicate -->
<section class="envelope-viewer__section" *ngIf="predicateType()">
<h4 class="envelope-viewer__section-title">Predicate</h4>
<div class="envelope-viewer__predicate-type">
<strong>Type:</strong>
<code>{{ predicateType() }}</code>
</div>
<div class="envelope-viewer__predicate-json" *ngIf="showPredicateJson()">
<pre><code>{{ predicateJson() }}</code></pre>
</div>
<button
class="envelope-viewer__toggle-json"
type="button"
(click)="togglePredicateJson()"
*ngIf="hasPredicate()"
>
{{ showPredicateJson() ? 'Hide' : 'Show' }} predicate JSON
</button>
</section>
<!-- Signatures -->
<section class="envelope-viewer__section">
<h4 class="envelope-viewer__section-title">Signatures</h4>
<ul class="envelope-viewer__signatures">
<li *ngFor="let sig of signatures(); let i = index" class="envelope-viewer__signature">
<div class="envelope-viewer__sig-header">
<span class="envelope-viewer__sig-num">#{{ i + 1 }}</span>
<span
class="envelope-viewer__sig-status"
[class]="'envelope-viewer__sig-status--' + getSignatureStatus(i)"
>
{{ getSignatureStatusLabel(i) }}
</span>
</div>
<dl class="envelope-viewer__sig-details">
<dt>Key ID</dt>
<dd>
<code [title]="sig.keyid">{{ truncateKeyId(sig.keyid) }}</code>
<button
class="envelope-viewer__copy"
type="button"
(click)="copyToClipboard(sig.keyid, $event)"
title="Copy Key ID"
aria-label="Copy key ID to clipboard"
>
📋
</button>
</dd>
<dt>Signature</dt>
<dd>
<code class="envelope-viewer__sig-value">{{ truncateSignature(sig.sig) }}</code>
<button
class="envelope-viewer__copy"
type="button"
(click)="copyToClipboard(sig.sig, $event)"
title="Copy Signature"
aria-label="Copy signature to clipboard"
>
📋
</button>
</dd>
</dl>
</li>
</ul>
</section>
<!-- Raw envelope -->
<section class="envelope-viewer__section">
<button
class="envelope-viewer__toggle-raw"
type="button"
(click)="toggleRaw()"
>
{{ showRaw() ? 'Hide' : 'Show' }} raw envelope
</button>
<div class="envelope-viewer__raw" *ngIf="showRaw()">
<pre><code>{{ rawEnvelope() }}</code></pre>
<button
class="envelope-viewer__copy envelope-viewer__copy-all"
type="button"
(click)="copyToClipboard(rawEnvelope(), $event)"
title="Copy raw envelope"
>
📋 Copy
</button>
</div>
</section>
</div>
</div>
`,
styles: [`
.envelope-viewer {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
margin-bottom: 8px;
background: var(--surface-color, #fff);
}
.envelope-viewer--expanded {
border-color: var(--primary-color, #0066cc);
}
.envelope-viewer__header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 14px;
}
.envelope-viewer__header:hover {
background: var(--hover-bg, #f5f5f5);
}
.envelope-viewer__icon {
font-size: 10px;
color: var(--text-secondary, #666);
width: 12px;
}
.envelope-viewer__type {
font-family: monospace;
font-weight: 500;
flex: 1;
}
.envelope-viewer__badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.envelope-viewer__badge--verified {
background: #e6f4ea;
color: #1e7e34;
}
.envelope-viewer__badge--invalid {
background: #fce8e6;
color: #c5221f;
}
.envelope-viewer__badge--unknown {
background: #f1f3f4;
color: #5f6368;
}
.envelope-viewer__sig-count {
color: var(--text-secondary, #666);
font-size: 12px;
}
.envelope-viewer__content {
padding: 0 12px 12px;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.envelope-viewer__section {
margin-top: 12px;
}
.envelope-viewer__section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
margin: 0 0 8px;
}
.envelope-viewer__subjects {
list-style: none;
margin: 0;
padding: 0;
}
.envelope-viewer__subject {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--surface-alt, #f8f9fa);
border-radius: 4px;
margin-bottom: 4px;
}
.envelope-viewer__subject-name {
font-weight: 500;
flex: 1 0 100%;
font-family: monospace;
font-size: 13px;
}
.envelope-viewer__digest {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
}
.envelope-viewer__digest-algo {
color: var(--text-secondary, #666);
}
.envelope-viewer__digest-value {
color: var(--primary-color, #0066cc);
}
.envelope-viewer__copy {
border: none;
background: transparent;
cursor: pointer;
padding: 2px 4px;
font-size: 12px;
opacity: 0.7;
}
.envelope-viewer__copy:hover {
opacity: 1;
}
.envelope-viewer__predicate-type {
margin-bottom: 8px;
}
.envelope-viewer__predicate-type code {
background: var(--surface-alt, #f8f9fa);
padding: 2px 6px;
border-radius: 3px;
}
.envelope-viewer__predicate-json,
.envelope-viewer__raw {
position: relative;
background: var(--surface-alt, #f8f9fa);
border-radius: 4px;
padding: 12px;
overflow-x: auto;
max-height: 300px;
margin-top: 8px;
}
.envelope-viewer__predicate-json pre,
.envelope-viewer__raw pre {
margin: 0;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
.envelope-viewer__toggle-json,
.envelope-viewer__toggle-raw {
border: 1px solid var(--border-color, #e0e0e0);
background: var(--surface-color, #fff);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.envelope-viewer__toggle-json:hover,
.envelope-viewer__toggle-raw:hover {
background: var(--hover-bg, #f5f5f5);
}
.envelope-viewer__signatures {
list-style: none;
margin: 0;
padding: 0;
}
.envelope-viewer__signature {
padding: 8px;
background: var(--surface-alt, #f8f9fa);
border-radius: 4px;
margin-bottom: 4px;
}
.envelope-viewer__sig-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.envelope-viewer__sig-num {
font-weight: 600;
color: var(--text-secondary, #666);
}
.envelope-viewer__sig-status {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
.envelope-viewer__sig-status--verified {
background: #e6f4ea;
color: #1e7e34;
}
.envelope-viewer__sig-status--invalid {
background: #fce8e6;
color: #c5221f;
}
.envelope-viewer__sig-status--unknown {
background: #f1f3f4;
color: #5f6368;
}
.envelope-viewer__sig-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 12px;
margin: 0;
}
.envelope-viewer__sig-details dt {
color: var(--text-secondary, #666);
}
.envelope-viewer__sig-details dd {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
}
.envelope-viewer__sig-value {
font-family: monospace;
word-break: break-all;
}
.envelope-viewer__copy-all {
position: absolute;
top: 8px;
right: 8px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 3px;
padding: 4px 8px;
background: var(--surface-color, #fff);
}
`]
})
export class DsseEnvelopeViewerComponent {
// Inputs
envelope = input<DsseEnvelope>();
displayData = input<EnvelopeDisplayData>();
verified = input<boolean>();
signatureStatuses = input<Array<'verified' | 'invalid' | 'unknown'>>([]);
// Outputs
copySuccess = output<string>();
expandChanged = output<boolean>();
// State
isExpanded = signal(false);
showPredicateJson = signal(false);
showRaw = signal(false);
// Computed values
payloadType = computed(() => {
const env = this.envelope();
return env?.payloadType ?? 'unknown';
});
signatureCount = computed(() => {
const env = this.envelope();
return env?.signatures?.length ?? 0;
});
signatures = computed(() => {
const env = this.envelope();
return env?.signatures ?? [];
});
verificationStatus = computed(() => {
const v = this.verified();
if (v === true) return 'verified';
if (v === false) return 'invalid';
return 'unknown';
});
verificationLabel = computed(() => {
const status = this.verificationStatus();
switch (status) {
case 'verified': return '✓ Verified';
case 'invalid': return '✗ Invalid';
default: return '? Unknown';
}
});
subjects = computed(() => {
const data = this.displayData();
return data?.subject ?? [];
});
predicateType = computed(() => {
const data = this.displayData();
return data?.predicateType;
});
hasPredicate = computed(() => {
const data = this.displayData();
return data?.predicate != null;
});
predicateJson = computed(() => {
const data = this.displayData();
if (!data?.predicate) return '';
try {
return JSON.stringify(data.predicate, null, 2);
} catch {
return String(data.predicate);
}
});
rawEnvelope = computed(() => {
const env = this.envelope();
if (!env) return '';
try {
return JSON.stringify(env, null, 2);
} catch {
return '';
}
});
// Methods
toggleExpand(): void {
const newValue = !this.isExpanded();
this.isExpanded.set(newValue);
this.expandChanged.emit(newValue);
}
togglePredicateJson(): void {
this.showPredicateJson.update(v => !v);
}
toggleRaw(): void {
this.showRaw.update(v => !v);
}
getDigestEntries(digest: Record<string, string>): Array<{ algo: string; value: string }> {
return Object.entries(digest).map(([algo, value]) => ({ algo, value }));
}
truncateDigest(value: string): string {
if (value.length <= 16) return value;
return `${value.slice(0, 8)}${value.slice(-8)}`;
}
truncateKeyId(keyid: string): string {
if (keyid.length <= 24) return keyid;
return `${keyid.slice(0, 12)}${keyid.slice(-8)}`;
}
truncateSignature(sig: string): string {
if (sig.length <= 32) return sig;
return `${sig.slice(0, 16)}${sig.slice(-8)}`;
}
getSignatureStatus(index: number): 'verified' | 'invalid' | 'unknown' {
const statuses = this.signatureStatuses();
return statuses[index] ?? 'unknown';
}
getSignatureStatusLabel(index: number): string {
const status = this.getSignatureStatus(index);
switch (status) {
case 'verified': return '✓ Verified';
case 'invalid': return '✗ Invalid';
default: return '? Unknown';
}
}
async copyToClipboard(text: string, event: Event): Promise<void> {
event.stopPropagation();
try {
await navigator.clipboard.writeText(text);
this.copySuccess.emit(text);
} catch (err) {
console.error('Failed to copy:', err);
}
}
}

View File

@@ -0,0 +1,341 @@
/**
* Evidence Drawer Component Tests.
* Sprint: SPRINT_4100_0004_0001 (Evidence Drawer)
* Task: ED-007 - Unit tests for EvidenceDrawerComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceDrawerComponent, EvidenceDrawerData, ProofNode, VexDecision, AttestationInfo } from './evidence-drawer.component';
describe('EvidenceDrawerComponent', () => {
let component: EvidenceDrawerComponent;
let fixture: ComponentFixture<EvidenceDrawerComponent>;
const mockData: EvidenceDrawerData = {
findingId: 'f123',
cveId: 'CVE-2024-12345',
packageName: 'stripe',
packageVersion: '6.1.2',
severity: 'high',
score: 72,
proofNodes: [
{
id: 'node-1',
kind: 'input',
delta: 9.8,
total: 9.8,
parentIds: [],
evidenceRefs: ['sha256:abc123'],
timestamp: '2025-12-18T09:22:00Z',
},
{
id: 'node-2',
kind: 'rule',
ruleId: 'reachability-boost',
delta: 10,
total: 19.8,
parentIds: ['node-1'],
evidenceRefs: [],
timestamp: '2025-12-18T09:23:00Z',
},
] as ProofNode[],
proofRootHash: 'sha256:rootabc123',
reachabilityPath: {
nodes: [
{ id: 'entry', label: 'BillingController.Pay', type: 'entrypoint' },
{ id: 'mid', label: 'StripeClient.Create', type: 'call' },
{ id: 'sink', label: 'HttpClient.Post', type: 'sink' },
],
edges: [
{ from: 'entry', to: 'mid' },
{ from: 'mid', to: 'sink' },
],
},
confidenceTier: 'high',
gates: [
{ kind: 'auth', passed: true, message: 'JWT required' },
{ kind: 'rate_limit', passed: true, message: '100 req/min' },
],
vexDecisions: [
{
status: 'not_affected',
justification: 'Vulnerable code path not reachable',
source: 'internal-review',
timestamp: '2025-12-18T09:22:00Z',
confidence: 0.95,
} as VexDecision,
],
mergedVexStatus: 'not_affected',
attestations: [
{
envelopeType: 'DSSE',
predicateType: 'stella.ops/policy-decision@v1',
signedAt: '2025-12-18T09:22:00Z',
keyId: 'key-abc123',
algorithm: 'ECDSA-P256',
verified: true,
rekorLogIndex: 12345,
} as AttestationInfo,
],
falsificationConditions: [
'Component is removed from deployment',
'Vulnerability is patched upstream',
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceDrawerComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceDrawerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('data', mockData);
fixture.componentRef.setInput('open', true);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('tabs', () => {
it('should have 5 tabs', () => {
expect(component.tabs.length).toBe(5);
});
it('should default to summary tab', () => {
expect(component.activeTab()).toBe('summary');
});
it('should switch tabs', () => {
component.activeTab.set('proof');
expect(component.activeTab()).toBe('proof');
});
it('should detect tabs with data', () => {
const proofTab = component.tabs.find(t => t.id === 'proof');
expect(proofTab?.hasData?.()).toBe(true);
const reachabilityTab = component.tabs.find(t => t.id === 'reachability');
expect(reachabilityTab?.hasData?.()).toBe(true);
const vexTab = component.tabs.find(t => t.id === 'vex');
expect(vexTab?.hasData?.()).toBe(true);
const attestationTab = component.tabs.find(t => t.id === 'attestation');
expect(attestationTab?.hasData?.()).toBe(true);
});
});
describe('summary tab', () => {
it('should display finding ID', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('f123');
});
it('should display CVE ID', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('CVE-2024-12345');
});
it('should display package info', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('stripe');
});
it('should display severity', () => {
const compiled = fixture.nativeElement;
const severityEl = compiled.querySelector('.evidence-drawer__severity');
expect(severityEl.textContent.toLowerCase()).toContain('high');
});
});
describe('proof chain tab', () => {
beforeEach(() => {
component.activeTab.set('proof');
fixture.detectChanges();
});
it('should display proof root hash', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('sha256:rootabc123');
});
it('should display proof nodes', () => {
const compiled = fixture.nativeElement;
const nodes = compiled.querySelectorAll('.evidence-drawer__proof-node');
expect(nodes.length).toBe(2);
});
});
describe('reachability tab', () => {
beforeEach(() => {
component.activeTab.set('reachability');
fixture.detectChanges();
});
it('should display confidence tier', () => {
const compiled = fixture.nativeElement;
const tierBadge = compiled.querySelector('app-confidence-tier-badge');
expect(tierBadge).toBeTruthy();
});
it('should display path visualization', () => {
const compiled = fixture.nativeElement;
const pathViz = compiled.querySelector('app-path-visualization');
expect(pathViz).toBeTruthy();
});
});
describe('VEX tab', () => {
beforeEach(() => {
component.activeTab.set('vex');
fixture.detectChanges();
});
it('should display merged VEX status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent.toLowerCase()).toContain('not_affected');
});
it('should display VEX decisions', () => {
const compiled = fixture.nativeElement;
const decisions = compiled.querySelectorAll('.evidence-drawer__vex-decision');
expect(decisions.length).toBe(1);
});
it('should display justification', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Vulnerable code path not reachable');
});
});
describe('attestation tab', () => {
beforeEach(() => {
component.activeTab.set('attestation');
fixture.detectChanges();
});
it('should display attestations', () => {
const compiled = fixture.nativeElement;
const attestations = compiled.querySelectorAll('.evidence-drawer__attestation');
expect(attestations.length).toBe(1);
});
it('should display envelope type', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('DSSE');
});
it('should display verified status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Verified');
});
it('should display predicate type', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('stella.ops/policy-decision@v1');
});
});
describe('visibility', () => {
it('should show drawer when open is true', () => {
fixture.componentRef.setInput('open', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const drawer = compiled.querySelector('.evidence-drawer--open');
expect(drawer).toBeTruthy();
});
it('should hide drawer when open is false', () => {
fixture.componentRef.setInput('open', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const drawer = compiled.querySelector('.evidence-drawer--open');
expect(drawer).toBeFalsy();
});
});
describe('close behavior', () => {
it('should emit close event when backdrop clicked', () => {
const spy = jasmine.createSpy('close');
component.close.subscribe(spy);
const compiled = fixture.nativeElement;
const backdrop = compiled.querySelector('.evidence-drawer__backdrop');
backdrop?.click();
expect(spy).toHaveBeenCalled();
});
it('should emit close event when close button clicked', () => {
const spy = jasmine.createSpy('close');
component.close.subscribe(spy);
const compiled = fixture.nativeElement;
const closeBtn = compiled.querySelector('.evidence-drawer__close');
closeBtn?.click();
expect(spy).toHaveBeenCalled();
});
});
describe('empty states', () => {
it('should show empty message when no proof nodes', () => {
const emptyData = { ...mockData, proofNodes: [] };
fixture.componentRef.setInput('data', emptyData);
component.activeTab.set('proof');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const emptyEl = compiled.querySelector('.evidence-drawer__empty');
expect(emptyEl).toBeTruthy();
});
it('should show empty message when no reachability path', () => {
const emptyData = { ...mockData, reachabilityPath: undefined };
fixture.componentRef.setInput('data', emptyData);
component.activeTab.set('reachability');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const emptyEl = compiled.querySelector('.evidence-drawer__empty');
expect(emptyEl).toBeTruthy();
});
it('should show empty message when no VEX decisions', () => {
const emptyData = { ...mockData, vexDecisions: [] };
fixture.componentRef.setInput('data', emptyData);
component.activeTab.set('vex');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const emptyEl = compiled.querySelector('.evidence-drawer__empty');
expect(emptyEl).toBeTruthy();
});
});
describe('falsification conditions', () => {
it('should display falsification conditions on summary tab', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Component is removed from deployment');
});
});
describe('accessibility', () => {
it('should have accessible tab buttons', () => {
const compiled = fixture.nativeElement;
const tabs = compiled.querySelectorAll('.evidence-drawer__tab');
expect(tabs.length).toBe(5);
});
it('should show active tab state', () => {
const compiled = fixture.nativeElement;
const activeTab = compiled.querySelector('.evidence-drawer__tab--active');
expect(activeTab).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,227 @@
/**
* Finding List Component Tests.
* Sprint: SPRINT_4100_0003_0001 (Finding Row Component)
* Task: ROW-005 - Unit tests for FindingListComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FindingListComponent, FindingSort } from './finding-list.component';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.models';
describe('FindingListComponent', () => {
let component: FindingListComponent;
let fixture: ComponentFixture<FindingListComponent>;
const mockFindings: FindingEvidenceResponse[] = [
{
finding_id: 'f1',
cve: 'CVE-2024-12345',
component: { purl: 'pkg:npm/stripe@6.1.2', name: 'stripe', version: '6.1.2', type: 'npm' },
reachable_path: ['A', 'B', 'C'],
vex: { status: 'not_affected' },
score_explain: { kind: 'additive', risk_score: 85, last_seen: '2025-12-18T09:22:00Z' },
last_seen: '2025-12-18T09:22:00Z',
},
{
finding_id: 'f2',
cve: 'CVE-2024-12346',
component: { purl: 'pkg:npm/axios@1.0.0', name: 'axios', version: '1.0.0', type: 'npm' },
reachable_path: undefined,
vex: { status: 'affected' },
score_explain: { kind: 'additive', risk_score: 45, last_seen: '2025-12-17T09:22:00Z' },
last_seen: '2025-12-17T09:22:00Z',
},
{
finding_id: 'f3',
cve: 'CVE-2024-12347',
component: { purl: 'pkg:npm/lodash@4.17.21', name: 'lodash', version: '4.17.21', type: 'npm' },
reachable_path: ['X'],
vex: { status: 'under_investigation' },
score_explain: { kind: 'additive', risk_score: 60, last_seen: '2025-12-16T09:22:00Z' },
last_seen: '2025-12-16T09:22:00Z',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FindingListComponent],
}).compileComponents();
fixture = TestBed.createComponent(FindingListComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('findings', mockFindings);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('rendering', () => {
it('should render all findings', () => {
const rows = fixture.nativeElement.querySelectorAll('stella-finding-row');
expect(rows.length).toBe(3);
});
it('should show loading state', () => {
fixture.componentRef.setInput('loading', true);
fixture.detectChanges();
const loading = fixture.nativeElement.querySelector('.finding-list__loading');
expect(loading).toBeTruthy();
});
it('should show empty state when no findings', () => {
fixture.componentRef.setInput('findings', []);
fixture.detectChanges();
const empty = fixture.nativeElement.querySelector('.finding-list__empty');
expect(empty).toBeTruthy();
});
});
describe('sorting', () => {
it('should sort by score descending by default', () => {
const sorted = component.sortedFindings();
expect(sorted[0].finding_id).toBe('f1'); // score 85
expect(sorted[1].finding_id).toBe('f3'); // score 60
expect(sorted[2].finding_id).toBe('f2'); // score 45
});
it('should emit sortChange when header clicked', () => {
const spy = jasmine.createSpy('sortChange');
component.sortChange.subscribe(spy);
component.onSortChange('component');
expect(spy).toHaveBeenCalledWith(jasmine.objectContaining({ field: 'component' }));
});
it('should toggle direction when same field clicked', () => {
const initialSort: FindingSort = { field: 'score', direction: 'desc' };
fixture.componentRef.setInput('sort', initialSort);
fixture.detectChanges();
const spy = jasmine.createSpy('sortChange');
component.sortChange.subscribe(spy);
component.onSortChange('score');
expect(spy).toHaveBeenCalledWith({ field: 'score', direction: 'asc' });
});
});
describe('events', () => {
it('should emit findingSelected when row viewEvidence is triggered', () => {
const spy = jasmine.createSpy('findingSelected');
component.findingSelected.subscribe(spy);
component.onFindingSelected('f1');
expect(spy).toHaveBeenCalledWith('f1');
});
it('should emit approveRequested when row approve is triggered', () => {
const spy = jasmine.createSpy('approveRequested');
component.approveRequested.subscribe(spy);
component.onApproveRequested('f2');
expect(spy).toHaveBeenCalledWith('f2');
});
});
describe('summary', () => {
it('should calculate summary statistics', () => {
expect(component.totalCount()).toBe(3);
});
it('should calculate critical/high count', () => {
const criticalHighCount = component.criticalHighCount();
// f1 has score 85 (critical), f3 has 60 (high)
expect(criticalHighCount).toBe(2);
});
});
describe('virtual scroll', () => {
it('should use virtual scroll for large lists', () => {
fixture.componentRef.setInput('virtualScrollThreshold', 2);
fixture.detectChanges();
expect(component.useVirtualScroll()).toBe(true);
});
it('should use regular list for small datasets', () => {
fixture.componentRef.setInput('virtualScrollThreshold', 100);
fixture.detectChanges();
expect(component.useVirtualScroll()).toBe(false);
});
});
describe('header visibility', () => {
it('should show header by default', () => {
const header = fixture.nativeElement.querySelector('.finding-list__header');
expect(header).toBeTruthy();
});
it('should hide header when showHeader is false', () => {
fixture.componentRef.setInput('showHeader', false);
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.finding-list__header');
expect(header).toBeFalsy();
});
});
describe('accessibility', () => {
it('should have list role', () => {
const list = fixture.nativeElement.querySelector('.finding-list');
expect(list.getAttribute('role')).toBe('list');
});
it('should provide aria-label', () => {
const list = fixture.nativeElement.querySelector('.finding-list');
expect(list.getAttribute('aria-label')).toBeTruthy();
});
it('should provide aria-sort on sortable headers', () => {
const scoreHeader = fixture.nativeElement.querySelector('.finding-list__header-score');
expect(scoreHeader.getAttribute('aria-sort')).toBe('descending');
});
});
describe('trackBy function', () => {
it('should track by finding_id', () => {
const trackFn = component.trackByFinding;
const result = trackFn(0, mockFindings[0]);
expect(result).toBe('f1');
});
});
describe('sort indicators', () => {
it('should show sort indicator for active field', () => {
fixture.componentRef.setInput('sort', { field: 'score', direction: 'desc' });
fixture.detectChanges();
const indicator = component.getSortIndicator('score');
expect(indicator).toBe('▼');
});
it('should show ascending indicator', () => {
fixture.componentRef.setInput('sort', { field: 'cve', direction: 'asc' });
fixture.detectChanges();
const indicator = component.getSortIndicator('cve');
expect(indicator).toBe('▲');
});
it('should return empty for inactive fields', () => {
fixture.componentRef.setInput('sort', { field: 'score', direction: 'desc' });
fixture.detectChanges();
const indicator = component.getSortIndicator('component');
expect(indicator).toBe('');
});
});
});

View File

@@ -0,0 +1,446 @@
/**
* Finding List Component.
* Sprint: SPRINT_4100_0003_0001 (Finding Row Component)
* Task: ROW-004 - FindingListComponent for rendering lists of findings
*
* Displays a list of vulnerability findings with sorting, filtering,
* and virtual scrolling support for performance.
*/
import { Component, input, output, computed, signal, TrackByFunction } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.models';
import { FindingRowComponent } from './finding-row.component';
/**
* Sort field options for findings.
*/
export type FindingSortField = 'cve' | 'component' | 'score' | 'reachability' | 'vex' | 'last_seen';
/**
* Sort direction.
*/
export type SortDirection = 'asc' | 'desc';
/**
* Sort configuration.
*/
export interface FindingSort {
field: FindingSortField;
direction: SortDirection;
}
/**
* List component for displaying multiple vulnerability findings.
*
* Features:
* - Virtual scrolling for large lists
* - Sort by various fields
* - Empty state handling
* - Loading state
* - Selection support
*
* @example
* <stella-finding-list
* [findings]="findings"
* [loading]="isLoading"
* [sort]="{ field: 'score', direction: 'desc' }"
* (findingSelected)="openEvidence($event)"
* (approveRequested)="startApproval($event)"
* />
*/
@Component({
selector: 'stella-finding-list',
standalone: true,
imports: [CommonModule, FindingRowComponent],
template: `
<div class="finding-list" role="list" [attr.aria-label]="listLabel()">
<!-- Header -->
@if (showHeader()) {
<header class="finding-list__header">
<div class="finding-list__header-cell finding-list__header-toggle"></div>
<button
class="finding-list__header-cell finding-list__header-cve"
(click)="onSortChange('cve')"
[attr.aria-sort]="getAriaSortValue('cve')"
>
CVE {{ getSortIndicator('cve') }}
</button>
<button
class="finding-list__header-cell finding-list__header-component"
(click)="onSortChange('component')"
[attr.aria-sort]="getAriaSortValue('component')"
>
Component {{ getSortIndicator('component') }}
</button>
<button
class="finding-list__header-cell finding-list__header-score"
(click)="onSortChange('score')"
[attr.aria-sort]="getAriaSortValue('score')"
>
Score {{ getSortIndicator('score') }}
</button>
<div class="finding-list__header-cell finding-list__header-reachability">
Reachability
</div>
<div class="finding-list__header-cell finding-list__header-vex">
VEX
</div>
<div class="finding-list__header-cell finding-list__header-chain">
Chain
</div>
<div class="finding-list__header-cell finding-list__header-actions">
Actions
</div>
</header>
}
<!-- Loading State -->
@if (loading()) {
<div class="finding-list__loading" role="status" aria-label="Loading findings">
<span class="finding-list__spinner">⏳</span>
<span>Loading findings...</span>
</div>
}
<!-- Empty State -->
@else if (sortedFindings().length === 0) {
<div class="finding-list__empty" role="status">
<span class="finding-list__empty-icon">📋</span>
<span class="finding-list__empty-text">{{ emptyMessage() }}</span>
</div>
}
<!-- Findings List -->
@else {
<!-- Regular list (virtual scroll requires @angular/cdk, add if needed) -->
<div class="finding-list__content">
@for (finding of sortedFindings(); track trackByFinding($index, finding)) {
<stella-finding-row
[finding]="finding"
[showChipLabels]="showChipLabels()"
[showChainStatus]="showChainStatus()"
[showApprove]="showApprove()"
(viewEvidence)="onFindingSelected($event)"
(approve)="onApproveRequested($event)"
/>
}
</div>
}
<!-- Summary Footer -->
@if (showSummary() && !loading() && sortedFindings().length > 0) {
<footer class="finding-list__footer">
<span class="finding-list__count">
Showing {{ sortedFindings().length }} finding(s)
</span>
@if (totalCount() && totalCount()! > sortedFindings().length) {
<span class="finding-list__total">
of {{ totalCount() }} total
</span>
}
</footer>
}
</div>
`,
styles: [`
.finding-list {
display: flex;
flex-direction: column;
min-height: 200px;
}
.finding-list__header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background: #f8f9fa;
border-bottom: 1px solid rgba(108, 117, 125, 0.2);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6c757d;
}
.finding-list__header-cell {
border: none;
background: transparent;
font: inherit;
color: inherit;
cursor: default;
padding: 0.25rem 0;
text-align: left;
}
button.finding-list__header-cell {
cursor: pointer;
&:hover {
color: #495057;
}
}
.finding-list__header-toggle {
width: 24px;
flex-shrink: 0;
}
.finding-list__header-cve {
min-width: 140px;
flex-shrink: 0;
}
.finding-list__header-component {
flex: 1;
min-width: 0;
}
.finding-list__header-score {
flex-shrink: 0;
min-width: 60px;
}
.finding-list__header-reachability,
.finding-list__header-vex,
.finding-list__header-chain {
flex-shrink: 0;
min-width: 80px;
}
.finding-list__header-actions {
flex-shrink: 0;
min-width: 80px;
}
.finding-list__loading,
.finding-list__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
color: #6c757d;
}
.finding-list__spinner {
font-size: 2rem;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.finding-list__empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.finding-list__empty-text {
font-size: 0.9375rem;
}
.finding-list__viewport {
flex: 1;
overflow-y: auto;
}
.finding-list__content {
padding: 0.5rem;
}
.finding-list__footer {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-top: 1px solid rgba(108, 117, 125, 0.2);
font-size: 0.8125rem;
color: #6c757d;
}
.finding-list__total {
opacity: 0.7;
}
`]
})
export class FindingListComponent {
/**
* Array of findings to display.
*/
readonly findings = input<readonly FindingEvidenceResponse[]>([]);
/**
* Whether the list is loading.
*/
readonly loading = input<boolean>(false);
/**
* Current sort configuration.
*/
readonly sort = input<FindingSort | undefined>(undefined);
/**
* Total count for pagination display.
*/
readonly totalCount = input<number | undefined>(undefined);
/**
* Message to show when list is empty.
*/
readonly emptyMessage = input<string>('No findings found');
/**
* Whether to show the header row.
*/
readonly showHeader = input<boolean>(true);
/**
* Whether to show the summary footer.
*/
readonly showSummary = input<boolean>(true);
// NOTE: Virtual scrolling requires @angular/cdk package.
// These inputs are kept for future implementation but currently unused.
// readonly useVirtualScroll = input<boolean>(true);
// readonly virtualScrollThreshold = input<number>(50);
// readonly itemHeight = input<number>(64);
// readonly viewportHeight = input<number>(400);
/**
* Whether to show chip labels.
*/
readonly showChipLabels = input<boolean>(true);
/**
* Whether to show chain status.
*/
readonly showChainStatus = input<boolean>(true);
/**
* Whether to show approve button.
*/
readonly showApprove = input<boolean>(true);
/**
* Emitted when a finding is selected for viewing.
*/
readonly findingSelected = output<string>();
/**
* Emitted when approval is requested for a finding.
*/
readonly approveRequested = output<string>();
/**
* Emitted when sort changes.
*/
readonly sortChange = output<FindingSort>();
/**
* Sorted findings based on current sort configuration.
*/
readonly sortedFindings = computed(() => {
const findings = [...this.findings()];
const sortConfig = this.sort();
if (!sortConfig) return findings;
const { field, direction } = sortConfig;
const multiplier = direction === 'asc' ? 1 : -1;
return findings.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'cve':
comparison = (a.cve ?? '').localeCompare(b.cve ?? '');
break;
case 'component':
comparison = (a.component?.name ?? '').localeCompare(b.component?.name ?? '');
break;
case 'score':
comparison = (a.score_explain?.risk_score ?? 0) - (b.score_explain?.risk_score ?? 0);
break;
case 'reachability':
const aReach = (a.reachable_path?.length ?? 0) > 0 ? 1 : 0;
const bReach = (b.reachable_path?.length ?? 0) > 0 ? 1 : 0;
comparison = aReach - bReach;
break;
case 'vex':
comparison = (a.vex?.status ?? '').localeCompare(b.vex?.status ?? '');
break;
case 'last_seen':
comparison = (a.last_seen ?? '').localeCompare(b.last_seen ?? '');
break;
}
return comparison * multiplier;
});
});
/**
* Aria label for the list.
*/
readonly listLabel = computed(() => {
const count = this.sortedFindings().length;
return `Vulnerability findings list, ${count} item${count === 1 ? '' : 's'}`;
});
/**
* Track by function for ngFor.
*/
readonly trackByFinding: TrackByFunction<FindingEvidenceResponse> = (
index: number,
finding: FindingEvidenceResponse
) => finding.finding_id ?? index;
/**
* Handle sort column click.
*/
onSortChange(field: FindingSortField): void {
const currentSort = this.sort();
let newDirection: SortDirection = 'desc';
if (currentSort?.field === field) {
newDirection = currentSort.direction === 'asc' ? 'desc' : 'asc';
}
this.sortChange.emit({ field, direction: newDirection });
}
/**
* Get sort indicator for column header.
*/
getSortIndicator(field: FindingSortField): string {
const currentSort = this.sort();
if (currentSort?.field !== field) return '';
return currentSort.direction === 'asc' ? '↑' : '↓';
}
/**
* Get ARIA sort value for column header.
*/
getAriaSortValue(field: FindingSortField): 'ascending' | 'descending' | 'none' {
const currentSort = this.sort();
if (currentSort?.field !== field) return 'none';
return currentSort.direction === 'asc' ? 'ascending' : 'descending';
}
/**
* Handle finding selection.
*/
onFindingSelected(findingId: string): void {
this.findingSelected.emit(findingId);
}
/**
* Handle approval request.
*/
onApproveRequested(findingId: string): void {
this.approveRequested.emit(findingId);
}
}

View File

@@ -0,0 +1,256 @@
/**
* Finding Row Component Tests.
* Sprint: SPRINT_4100_0003_0001 (Finding Row Component)
* Task: ROW-005 - Unit tests for FindingRowComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FindingRowComponent } from './finding-row.component';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.models';
describe('FindingRowComponent', () => {
let component: FindingRowComponent;
let fixture: ComponentFixture<FindingRowComponent>;
const mockFinding: FindingEvidenceResponse = {
finding_id: 'f123',
cve: 'CVE-2024-12345',
component: {
purl: 'pkg:npm/stripe@6.1.2',
name: 'stripe',
version: '6.1.2',
type: 'npm',
},
reachable_path: ['BillingController.Pay', 'StripeClient.Create', 'HttpClient.Post'],
entrypoint: {
type: 'http_handler',
route: '/billing/charge',
method: 'POST',
auth: 'required',
fqn: 'BillingController.Pay',
},
boundary: {
kind: 'http',
surface: { type: 'http', protocol: 'https', port: 443 },
exposure: { level: 'public', internet_facing: true, zone: 'dmz' },
auth: { required: true, type: 'jwt', roles: ['payments:write'] },
controls: [{ type: 'waf', active: true }],
last_seen: '2025-12-18T09:22:00Z',
confidence: 0.95,
},
vex: {
status: 'not_affected',
justification: 'Vulnerable code path not reachable in production',
issued_at: '2025-12-18T09:22:00Z',
},
score_explain: {
kind: 'additive',
risk_score: 72,
contributions: [
{ factor: 'cvss_base', weight: 0.5, raw_value: 9.8, contribution: 41 },
{ factor: 'reachability', weight: 0.3, raw_value: 1, contribution: 18 },
{ factor: 'exposure_surface', weight: 0.2, raw_value: 1, contribution: 10 },
],
last_seen: '2025-12-18T09:22:00Z',
},
last_seen: '2025-12-18T09:22:00Z',
expires_at: '2025-12-25T09:22:00Z',
attestation_refs: ['sha256:abc123', 'sha256:def456', 'sha256:ghi789'],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FindingRowComponent],
}).compileComponents();
fixture = TestBed.createComponent(FindingRowComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('finding', mockFinding);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('display values', () => {
it('should display CVE ID', () => {
expect(component.cveId()).toBe('CVE-2024-12345');
});
it('should display component name and version', () => {
expect(component.componentName()).toBe('stripe');
expect(component.componentVersion()).toBe('6.1.2');
});
it('should calculate severity class from score', () => {
expect(component.severityClass()).toBe('high'); // 72 is high severity
});
});
describe('reachability', () => {
it('should detect reachable state when path exists', () => {
expect(component.reachabilityState()).toBe('reachable');
});
it('should provide path depth', () => {
expect(component.pathDepth()).toBe(3);
});
it('should return unknown state when no path', () => {
const noPathFinding = { ...mockFinding, reachable_path: undefined };
fixture.componentRef.setInput('finding', noPathFinding);
fixture.detectChanges();
expect(component.reachabilityState()).toBe('unknown');
});
});
describe('VEX status', () => {
it('should return VEX status from finding', () => {
expect(component.vexStatus()).toBe('not_affected');
});
it('should return justification', () => {
expect(component.vexJustification()).toBe('Vulnerable code path not reachable in production');
});
it('should default to under_investigation when no VEX', () => {
const noVexFinding = { ...mockFinding, vex: undefined };
fixture.componentRef.setInput('finding', noVexFinding);
fixture.detectChanges();
expect(component.vexStatus()).toBe('under_investigation');
});
});
describe('chain status', () => {
it('should return complete when 3+ attestations', () => {
expect(component.chainStatus()).toBe('complete');
});
it('should return partial when 1-2 attestations', () => {
const partialFinding = { ...mockFinding, attestation_refs: ['sha256:abc123'] };
fixture.componentRef.setInput('finding', partialFinding);
fixture.detectChanges();
expect(component.chainStatus()).toBe('partial');
});
it('should return empty when no attestations', () => {
const emptyFinding = { ...mockFinding, attestation_refs: undefined };
fixture.componentRef.setInput('finding', emptyFinding);
fixture.detectChanges();
expect(component.chainStatus()).toBe('empty');
});
});
describe('expand/collapse', () => {
it('should start collapsed', () => {
expect(component.isExpanded()).toBe(false);
});
it('should toggle expand state', () => {
component.toggleExpand();
expect(component.isExpanded()).toBe(true);
component.toggleExpand();
expect(component.isExpanded()).toBe(false);
});
});
describe('actions', () => {
it('should emit viewEvidence event', () => {
const spy = jasmine.createSpy('viewEvidence');
component.viewEvidence.subscribe(spy);
component.onViewEvidence();
expect(spy).toHaveBeenCalledWith('f123');
});
it('should emit approve event', () => {
const spy = jasmine.createSpy('approve');
component.approve.subscribe(spy);
component.onApprove();
expect(spy).toHaveBeenCalledWith('f123');
});
});
describe('boundary info', () => {
it('should detect internet-facing boundary', () => {
expect(component.isInternetFacing()).toBe(true);
});
it('should detect auth required', () => {
expect(component.hasAuthRequired()).toBe(true);
});
it('should format boundary surface', () => {
const surface = component.boundarySurface();
expect(surface).toContain('https');
expect(surface).toContain('443');
});
});
describe('entrypoint info', () => {
it('should return entrypoint type', () => {
expect(component.entrypointType()).toBe('http_handler');
});
it('should format entrypoint route with method', () => {
expect(component.entrypointRoute()).toBe('POST /billing/charge');
});
});
describe('path preview', () => {
it('should limit path preview to maxPathSteps', () => {
fixture.componentRef.setInput('maxPathSteps', 2);
fixture.detectChanges();
expect(component.pathPreview().length).toBe(2);
expect(component.pathTruncated()).toBe(true);
});
});
describe('accessibility', () => {
it('should have appropriate ARIA attributes', () => {
const compiled = fixture.nativeElement;
const row = compiled.querySelector('article');
expect(row.getAttribute('role')).toBe('article');
expect(row.getAttribute('aria-expanded')).toBe('false');
});
it('should update aria-expanded on toggle', () => {
component.toggleExpand();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const row = compiled.querySelector('article');
expect(row.getAttribute('aria-expanded')).toBe('true');
});
});
describe('staleness detection', () => {
it('should detect stale evidence', () => {
const staleFinding = {
...mockFinding,
expires_at: '2020-01-01T00:00:00Z', // Past date
};
fixture.componentRef.setInput('finding', staleFinding);
fixture.detectChanges();
expect(component.isStale()).toBe(true);
});
it('should detect near-expiry evidence', () => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 12 * 60 * 60 * 1000); // 12 hours from now
const nearExpiryFinding = {
...mockFinding,
expires_at: tomorrow.toISOString(),
};
fixture.componentRef.setInput('finding', nearExpiryFinding);
fixture.detectChanges();
expect(component.isNearExpiry()).toBe(true);
});
});
});

View File

@@ -0,0 +1,570 @@
/**
* 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
*
* Displays a single vulnerability finding in a row format with expandable details.
* Integrates ReachabilityChip, VexStatusChip, ScoreBreakdown, and ChainStatusBadge.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { FindingEvidenceResponse } from '../../core/api/triage-evidence.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';
/**
* Compact row component for displaying a vulnerability finding.
*
* Features:
* - Core display: CVE ID, component name/version, risk score
* - Integrated chips: Reachability, VEX status, Chain status
* - Expandable: Call path preview, boundary info, attestation refs
* - Actions: View evidence, approve/reject buttons
*
* @example
* <stella-finding-row
* [finding]="findingEvidence"
* (viewEvidence)="openDrawer($event)"
* (approve)="startApproval($event)"
* />
*/
@Component({
selector: 'stella-finding-row',
standalone: true,
imports: [
CommonModule,
ReachabilityChipComponent,
VexStatusChipComponent,
ScoreBreakdownComponent,
ChainStatusBadgeComponent,
],
template: `
<article
class="finding-row"
[class.finding-row--expanded]="isExpanded()"
[class.finding-row--critical]="severityClass() === 'critical'"
[class.finding-row--high]="severityClass() === 'high'"
[class.finding-row--medium]="severityClass() === 'medium'"
[class.finding-row--low]="severityClass() === 'low'"
[attr.aria-expanded]="isExpanded()"
role="article"
[attr.aria-label]="ariaLabel()"
>
<!-- Main Row -->
<div class="finding-row__main">
<!-- Expand Toggle -->
@if (showExpand()) {
<button
class="finding-row__toggle"
(click)="toggleExpand()"
[attr.aria-label]="isExpanded() ? 'Collapse details' : 'Expand details'"
type="button"
>
<span class="finding-row__chevron" aria-hidden="true">
{{ isExpanded() ? '▼' : '▶' }}
</span>
</button>
}
<!-- CVE ID -->
<div class="finding-row__cve">
<a
[href]="cveLink()"
class="finding-row__cve-link"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="'View ' + cveId() + ' on NVD'"
>
{{ cveId() }}
</a>
</div>
<!-- Component -->
<div class="finding-row__component" [title]="componentPurl()">
<span class="finding-row__component-name">{{ componentName() }}</span>
<span class="finding-row__component-version">&#64;{{ componentVersion() }}</span>
</div>
<!-- Risk Score -->
<div class="finding-row__score">
<stella-score-breakdown
[explanation]="finding()?.score_explain"
[mode]="'compact'"
[showLabel]="false"
/>
</div>
<!-- Reachability -->
<div class="finding-row__reachability">
<stella-reachability-chip
[state]="reachabilityState()"
[pathDepth]="pathDepth()"
[showLabel]="showChipLabels()"
/>
</div>
<!-- VEX Status -->
<div class="finding-row__vex">
<stella-vex-status-chip
[status]="vexStatus()"
[justification]="vexJustification()"
[showLabel]="showChipLabels()"
/>
</div>
<!-- Chain Status -->
@if (showChainStatus()) {
<div class="finding-row__chain">
<stella-chain-status-badge
[status]="chainStatus()"
[showLabel]="false"
/>
</div>
}
<!-- Actions -->
<div class="finding-row__actions">
<button
class="finding-row__action finding-row__action--evidence"
(click)="onViewEvidence()"
title="View evidence details"
type="button"
>
📋
</button>
@if (showApprove()) {
<button
class="finding-row__action finding-row__action--approve"
(click)="onApprove()"
title="Approve/triage this finding"
type="button"
>
</button>
}
</div>
</div>
<!-- Expanded Details -->
@if (isExpanded()) {
<div class="finding-row__details" role="region" aria-label="Finding details">
<!-- Call Path Preview -->
@if (callPath().length > 0) {
<div class="finding-row__detail-section">
<span class="finding-row__detail-label">Call Path:</span>
<span class="finding-row__detail-value finding-row__path">
{{ formatCallPath() }}
</span>
</div>
}
<!-- Boundary Info -->
@if (hasBoundary()) {
<div class="finding-row__detail-section">
<span class="finding-row__detail-label">Boundary:</span>
<span class="finding-row__detail-value">
{{ boundaryDescription() }}
</span>
</div>
}
<!-- Entrypoint -->
@if (hasEntrypoint()) {
<div class="finding-row__detail-section">
<span class="finding-row__detail-label">Entrypoint:</span>
<span class="finding-row__detail-value">
{{ entrypointDescription() }}
</span>
</div>
}
<!-- Attestation Refs -->
@if (attestationRefs().length > 0) {
<div class="finding-row__detail-section">
<span class="finding-row__detail-label">Attestations:</span>
<span class="finding-row__detail-value">
{{ attestationRefs().length }} attestation(s)
</span>
</div>
}
<!-- Last Seen -->
<div class="finding-row__detail-section">
<span class="finding-row__detail-label">Last Seen:</span>
<span class="finding-row__detail-value">{{ formattedLastSeen() }}</span>
</div>
</div>
}
</article>
`,
styles: [`
.finding-row {
display: block;
border: 1px solid rgba(108, 117, 125, 0.2);
border-radius: 4px;
margin-bottom: 0.5rem;
background: #fff;
transition: box-shadow 0.15s, border-color 0.15s;
&:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&--expanded {
border-color: rgba(0, 123, 255, 0.3);
}
}
.finding-row__main {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
min-height: 48px;
}
.finding-row__toggle {
flex-shrink: 0;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: rgba(108, 117, 125, 0.1);
}
&:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}
}
.finding-row__chevron {
font-size: 0.75rem;
color: #6c757d;
}
.finding-row__cve {
flex-shrink: 0;
min-width: 140px;
}
.finding-row__cve-link {
font-weight: 600;
color: #007bff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.finding-row__component {
flex: 1;
min-width: 0;
font-family: monospace;
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finding-row__component-name {
color: #495057;
}
.finding-row__component-version {
color: #6c757d;
}
.finding-row__score {
flex-shrink: 0;
}
.finding-row__reachability,
.finding-row__vex,
.finding-row__chain {
flex-shrink: 0;
}
.finding-row__actions {
flex-shrink: 0;
display: flex;
gap: 0.25rem;
}
.finding-row__action {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid rgba(108, 117, 125, 0.3);
border-radius: 4px;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background-color 0.15s;
&:hover {
background: rgba(108, 117, 125, 0.1);
}
&:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}
&--approve:hover {
background: rgba(40, 167, 69, 0.1);
border-color: rgba(40, 167, 69, 0.3);
}
}
.finding-row__details {
padding: 0.75rem 1rem;
padding-left: calc(24px + 1rem + 0.75rem); // Align with content after toggle
border-top: 1px dashed rgba(108, 117, 125, 0.2);
background: rgba(248, 249, 250, 0.5);
}
.finding-row__detail-section {
display: flex;
gap: 0.5rem;
margin-bottom: 0.375rem;
font-size: 0.8125rem;
&:last-child {
margin-bottom: 0;
}
}
.finding-row__detail-label {
flex-shrink: 0;
min-width: 100px;
font-weight: 500;
color: #6c757d;
}
.finding-row__detail-value {
color: #495057;
}
.finding-row__path {
font-family: monospace;
font-size: 0.75rem;
}
// Severity indicators (subtle left border)
.finding-row--critical {
border-left: 3px solid #dc3545;
}
.finding-row--high {
border-left: 3px solid #fd7e14;
}
.finding-row--medium {
border-left: 3px solid #ffc107;
}
.finding-row--low {
border-left: 3px solid #28a745;
}
`]
})
export class FindingRowComponent {
/**
* The finding evidence data to display.
*/
readonly finding = input<FindingEvidenceResponse | undefined>(undefined);
/**
* Whether to show the expand toggle (default: true).
*/
readonly showExpand = input<boolean>(true);
/**
* Whether to show the approve button (default: true).
*/
readonly showApprove = input<boolean>(true);
/**
* Whether to show chip labels or icons only (default: true).
*/
readonly showChipLabels = input<boolean>(true);
/**
* Whether to show chain status badge (default: true).
*/
readonly showChainStatus = input<boolean>(true);
/**
* Emitted when user clicks to view evidence details.
*/
readonly viewEvidence = output<string>();
/**
* Emitted when user clicks the approve button.
*/
readonly approve = output<string>();
/**
* Internal expansion state.
*/
private readonly _expanded = signal(false);
readonly isExpanded = computed(() => this._expanded());
// =========================================================================
// Computed Properties
// =========================================================================
readonly cveId = computed(() => this.finding()?.cve ?? 'Unknown CVE');
readonly cveLink = computed(() => {
const cve = this.finding()?.cve;
return cve ? `https://nvd.nist.gov/vuln/detail/${cve}` : '#';
});
readonly componentPurl = computed(() => this.finding()?.component?.purl ?? '');
readonly componentName = computed(() => this.finding()?.component?.name ?? 'unknown');
readonly componentVersion = computed(() => this.finding()?.component?.version ?? '?');
readonly riskScore = computed(() => this.finding()?.score_explain?.risk_score ?? 0);
readonly severityClass = computed(() => {
const score = this.riskScore();
if (score >= 9.0) return 'critical';
if (score >= 7.0) return 'high';
if (score >= 4.0) return 'medium';
if (score > 0) return 'low';
return 'none';
});
readonly reachabilityState = computed((): ReachabilityState => {
const path = this.finding()?.reachable_path;
if (!path || path.length === 0) return 'unknown';
return 'reachable';
});
readonly pathDepth = computed(() => {
const path = this.finding()?.reachable_path;
return path?.length ?? 0;
});
readonly callPath = computed(() => this.finding()?.reachable_path ?? []);
readonly vexStatus = computed(() => this.finding()?.vex?.status);
readonly vexJustification = computed(() => this.finding()?.vex?.justification);
readonly chainStatus = computed((): ChainStatusDisplay => {
const refs = this.finding()?.attestation_refs;
if (!refs || refs.length === 0) return 'empty';
// Simplified - in real impl would check actual chain status
return 'complete';
});
readonly hasBoundary = computed(() => !!this.finding()?.boundary);
readonly hasEntrypoint = computed(() => !!this.finding()?.entrypoint);
readonly attestationRefs = computed(() => this.finding()?.attestation_refs ?? []);
readonly formattedLastSeen = computed(() => {
const lastSeen = this.finding()?.last_seen;
if (!lastSeen) return 'Unknown';
try {
return new Date(lastSeen).toLocaleString();
} catch {
return lastSeen;
}
});
readonly ariaLabel = computed(() => {
const cve = this.cveId();
const component = this.componentName();
const score = this.riskScore();
return `${cve} in ${component}, risk score ${score.toFixed(1)}`;
});
// =========================================================================
// Computed Descriptions
// =========================================================================
formatCallPath(): string {
const path = this.callPath();
if (path.length === 0) return '';
if (path.length <= 3) return path.join(' → ');
return `${path[0]} → ... → ${path[path.length - 1]} (${path.length} steps)`;
}
readonly boundaryDescription = computed(() => {
const boundary = this.finding()?.boundary;
if (!boundary) return '';
const parts: string[] = [];
if (boundary.surface?.type) {
parts.push(boundary.surface.type.toUpperCase());
}
if (boundary.exposure?.level) {
parts.push(boundary.exposure.level);
}
if (boundary.exposure?.internet_facing) {
parts.push('internet-facing');
}
return parts.join(' | ') || 'Boundary available';
});
readonly entrypointDescription = computed(() => {
const entry = this.finding()?.entrypoint;
if (!entry) return '';
const parts: string[] = [];
if (entry.method && entry.route) {
parts.push(`${entry.method} ${entry.route}`);
} else if (entry.fqn) {
parts.push(entry.fqn);
}
if (entry.type) {
parts.push(`(${entry.type})`);
}
return parts.join(' ') || 'Entrypoint available';
});
// =========================================================================
// Actions
// =========================================================================
toggleExpand(): void {
this._expanded.update(v => !v);
}
onViewEvidence(): void {
const findingId = this.finding()?.finding_id;
if (findingId) {
this.viewEvidence.emit(findingId);
}
}
onApprove(): void {
const findingId = this.finding()?.finding_id;
if (findingId) {
this.approve.emit(findingId);
}
}
}

View File

@@ -18,3 +18,36 @@ export { EvidenceDrawerComponent, EvidenceDrawerData, EvidenceTab, ProofNode, Ve
// Unknowns UI (SPRINT_3850_0001_0001)
export { UnknownChipComponent, UnknownItem, UnknownType, UnknownTriageAction } from './unknown-chip.component';
// Triage Shared Components (SPRINT_4100_0002_0001)
export { ReachabilityChipComponent, ReachabilityState } from './reachability-chip.component';
export { VexStatusChipComponent } from './vex-status-chip.component';
export { ScoreBreakdownComponent, ScoreBreakdownMode } from './score-breakdown.component';
export { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
// Finding Components (SPRINT_4100_0003_0001)
export { FindingRowComponent } from './finding-row.component';
export { FindingListComponent, FindingSortField, SortDirection, FindingSort } from './finding-list.component';
// Proof Tab Components (SPRINT_4100_0004_0002)
export { DsseEnvelopeViewerComponent, DsseEnvelope, DsseSignature, EnvelopeDisplayData } from './dsse-envelope-viewer.component';
export { RekorLinkComponent, RekorReference } from './rekor-link.component';
export { AttestationNodeComponent, AttestationType, SignerInfo, RekorRef } from './attestation-node.component';
export { ProofChainViewerComponent, ChainNode, ChainSummary } from './proof-chain-viewer.component';
// Approval Components (SPRINT_4100_0005_0001)
export { ApprovalButtonComponent, ApprovalRequest, ApprovalState } from './approval-button.component';
// Metrics Dashboard (SPRINT_4100_0006_0001)
export {
MetricsDashboardComponent,
CoverageAttestationType,
FindingSeverity,
FindingStatus,
TimeRange,
CoverageStats,
TrendPoint,
ApprovalResult,
GapEntry,
MetricsFindingData,
} from './metrics-dashboard.component';

View File

@@ -0,0 +1,421 @@
/**
* MetricsDashboardComponent Tests.
* Sprint: SPRINT_4100_0006_0001 (Attestation Coverage Metrics)
* Task: METR-006 - Unit tests for metrics dashboard component.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import {
MetricsDashboardComponent,
MetricsFindingData,
ApprovalResult,
FindingSeverity,
FindingStatus,
} from './metrics-dashboard.component';
import type { ChainStatusDisplay } from './chain-status-badge.component';
describe('MetricsDashboardComponent', () => {
let fixture: ComponentFixture<MetricsDashboardComponent>;
let component: MetricsDashboardComponent;
const createFinding = (
id: string,
severity: FindingSeverity = 'high',
chainStatus: ChainStatusDisplay = 'partial',
options: Partial<MetricsFindingData> = {}
): MetricsFindingData => ({
findingId: id,
cveId: `CVE-2024-${id}`,
componentName: `pkg-${id}@1.0.0`,
severity,
status: 'pending' as FindingStatus,
chainStatus,
hasSbom: false,
hasVex: false,
hasPolicyDecision: false,
hasHumanApproval: false,
...options,
});
const createApproval = (id: string, date: string): ApprovalResult => ({
findingId: id,
digestRef: `sha256:${id}`,
approvedAt: date,
approver: 'test@example.com',
expiresAt: '2025-12-31T00:00:00Z',
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MetricsDashboardComponent, FormsModule],
}).compileComponents();
fixture = TestBed.createComponent(MetricsDashboardComponent);
component = fixture.componentInstance;
});
// ===========================================================================
// Basic Rendering Tests
// ===========================================================================
describe('basic rendering', () => {
it('should create the component', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should render title', () => {
fixture.detectChanges();
const title = fixture.nativeElement.querySelector('.metrics-dashboard__title');
expect(title.textContent).toContain('Attestation Coverage Metrics');
});
it('should render filter section', () => {
fixture.detectChanges();
const filters = fixture.nativeElement.querySelector('.metrics-dashboard__filters');
expect(filters).toBeTruthy();
});
it('should render coverage gauges section', () => {
fixture.detectChanges();
const coverage = fixture.nativeElement.querySelector('.metrics-dashboard__coverage');
expect(coverage).toBeTruthy();
});
});
// ===========================================================================
// Coverage Calculation Tests
// ===========================================================================
describe('coverage calculation', () => {
it('should calculate 0% coverage when no findings', () => {
fixture.componentRef.setInput('findings', []);
fixture.detectChanges();
expect(component.overallCoverage()).toBe(0);
expect(component.totalFindings()).toBe(0);
});
it('should calculate coverage based on complete chains', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'complete', { hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
createFinding('2', 'high', 'complete', { hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
createFinding('3', 'high', 'partial'),
createFinding('4', 'high', 'partial'),
]);
fixture.detectChanges();
expect(component.totalFindings()).toBe(4);
expect(component.completeChains()).toBe(2);
expect(component.overallCoverage()).toBe(50);
});
it('should calculate SBOM coverage correctly', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'partial', { hasSbom: true }),
createFinding('2', 'high', 'partial', { hasSbom: true }),
createFinding('3', 'high', 'partial', { hasSbom: false }),
]);
fixture.detectChanges();
const stats = component.coverageStats();
const sbomStat = stats.find(s => s.type === 'sbom');
expect(sbomStat).toBeTruthy();
expect(sbomStat!.count).toBe(2);
expect(sbomStat!.total).toBe(3);
expect(sbomStat!.percentage).toBeCloseTo(66.67, 0);
});
it('should calculate VEX coverage correctly', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'partial', { hasVex: true }),
createFinding('2', 'high', 'partial', { hasVex: false }),
createFinding('3', 'high', 'partial', { hasVex: false }),
createFinding('4', 'high', 'partial', { hasVex: false }),
]);
fixture.detectChanges();
const stats = component.coverageStats();
const vexStat = stats.find(s => s.type === 'vex');
expect(vexStat!.count).toBe(1);
expect(vexStat!.percentage).toBe(25);
});
});
// ===========================================================================
// Filter Tests
// ===========================================================================
describe('filtering', () => {
beforeEach(() => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'critical', 'partial', { status: 'pending' }),
createFinding('2', 'high', 'complete', { status: 'approved', hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
createFinding('3', 'medium', 'partial', { status: 'blocked' }),
createFinding('4', 'low', 'partial', { status: 'pending' }),
]);
fixture.detectChanges();
});
it('should filter by severity', () => {
expect(component.filteredFindings().length).toBe(4);
// Remove critical
component.toggleSeverity('critical');
expect(component.filteredFindings().length).toBe(3);
// Remove high
component.toggleSeverity('high');
expect(component.filteredFindings().length).toBe(2);
});
it('should filter by status', () => {
expect(component.filteredFindings().length).toBe(4);
// Remove approved
component.toggleStatus('approved');
expect(component.filteredFindings().length).toBe(3);
});
it('should check severity selection state', () => {
expect(component.isSeveritySelected('critical')).toBe(true);
component.toggleSeverity('critical');
expect(component.isSeveritySelected('critical')).toBe(false);
});
it('should check status selection state', () => {
expect(component.isStatusSelected('pending')).toBe(true);
component.toggleStatus('pending');
expect(component.isStatusSelected('pending')).toBe(false);
});
});
// ===========================================================================
// Gap Analysis Tests
// ===========================================================================
describe('gap analysis', () => {
it('should identify findings with gaps', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'critical', 'partial', { hasSbom: true }),
createFinding('2', 'high', 'complete', { hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
createFinding('3', 'medium', 'empty'),
]);
fixture.detectChanges();
const gaps = component.gapEntries();
expect(gaps.length).toBe(2); // Only partial and empty, not complete
});
it('should sort gaps by severity', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'low', 'partial'),
createFinding('2', 'critical', 'partial'),
createFinding('3', 'medium', 'partial'),
]);
fixture.detectChanges();
const gaps = component.gapEntries();
expect(gaps[0].severity).toBe('critical');
expect(gaps[1].severity).toBe('medium');
expect(gaps[2].severity).toBe('low');
});
it('should list missing attestation types', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'partial', { hasSbom: true, hasVex: false, hasPolicyDecision: false, hasHumanApproval: false }),
]);
fixture.detectChanges();
const gaps = component.gapEntries();
expect(gaps[0].missingTypes).toContain('vex');
expect(gaps[0].missingTypes).toContain('policyDecision');
expect(gaps[0].missingTypes).toContain('humanApproval');
expect(gaps[0].missingTypes).not.toContain('sbom');
});
});
// ===========================================================================
// Velocity Data Tests
// ===========================================================================
describe('velocity data', () => {
it('should return empty when no approvals', () => {
fixture.componentRef.setInput('approvals', []);
fixture.detectChanges();
expect(component.velocityData().length).toBe(0);
});
it('should group approvals by date', () => {
fixture.componentRef.setInput('approvals', [
createApproval('1', '2025-01-15T10:00:00Z'),
createApproval('2', '2025-01-15T14:00:00Z'),
createApproval('3', '2025-01-16T10:00:00Z'),
]);
fixture.detectChanges();
const velocity = component.velocityData();
expect(velocity.length).toBe(2); // 2 unique dates
});
it('should count approvals per date', () => {
fixture.componentRef.setInput('approvals', [
createApproval('1', '2025-01-15T10:00:00Z'),
createApproval('2', '2025-01-15T14:00:00Z'),
createApproval('3', '2025-01-15T16:00:00Z'),
]);
fixture.detectChanges();
const velocity = component.velocityData();
expect(velocity[0].value).toBe(3);
});
it('should sort velocity data by date', () => {
fixture.componentRef.setInput('approvals', [
createApproval('1', '2025-01-17T10:00:00Z'),
createApproval('2', '2025-01-15T10:00:00Z'),
createApproval('3', '2025-01-16T10:00:00Z'),
]);
fixture.detectChanges();
const velocity = component.velocityData();
expect(velocity[0].date).toBe('2025-01-15');
expect(velocity[1].date).toBe('2025-01-16');
expect(velocity[2].date).toBe('2025-01-17');
});
});
// ===========================================================================
// Summary Stats Tests
// ===========================================================================
describe('summary stats', () => {
it('should count pending approvals (complete chains pending status)', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'complete', { status: 'pending', hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
createFinding('2', 'high', 'partial', { status: 'pending' }),
createFinding('3', 'high', 'complete', { status: 'approved', hasSbom: true, hasVex: true, hasPolicyDecision: true, hasHumanApproval: true }),
]);
fixture.detectChanges();
expect(component.pendingApprovals()).toBe(1);
});
});
// ===========================================================================
// Utility Method Tests
// ===========================================================================
describe('utility methods', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should get correct coverage level class', () => {
expect(component.getCoverageLevel(100)).toBe('high');
expect(component.getCoverageLevel(80)).toBe('high');
expect(component.getCoverageLevel(79)).toBe('medium');
expect(component.getCoverageLevel(50)).toBe('medium');
expect(component.getCoverageLevel(49)).toBe('low');
expect(component.getCoverageLevel(0)).toBe('low');
});
it('should format attestation types correctly', () => {
expect(component.formatAttestationType('sbom')).toBe('SBOM');
expect(component.formatAttestationType('vex')).toBe('VEX');
expect(component.formatAttestationType('policyDecision')).toBe('Policy');
expect(component.formatAttestationType('humanApproval')).toBe('Approval');
});
it('should render sparkline from trend data', () => {
const trend = [
{ date: '2025-01-01', value: 0 },
{ date: '2025-01-02', value: 50 },
{ date: '2025-01-03', value: 100 },
];
const sparkline = component.renderSparkline(trend);
expect(sparkline.length).toBe(3);
// First should be lowest bar, last should be highest
expect(sparkline[0]).toBe('▁');
expect(sparkline[2]).toBe('█');
});
it('should calculate velocity bar height', () => {
fixture.componentRef.setInput('approvals', [
createApproval('1', '2025-01-15T10:00:00Z'),
createApproval('2', '2025-01-15T14:00:00Z'),
createApproval('3', '2025-01-16T10:00:00Z'),
]);
fixture.detectChanges();
// Max is 2 (on 2025-01-15), so height of 2 should be 100%
expect(component.getVelocityBarHeight(2)).toBe(100);
expect(component.getVelocityBarHeight(1)).toBe(50);
});
});
// ===========================================================================
// Export Tests
// ===========================================================================
describe('export', () => {
it('should not export when no gaps', () => {
fixture.componentRef.setInput('findings', []);
fixture.detectChanges();
// Just ensure no error thrown
component.exportCsv();
});
it('should create CSV blob when gaps exist', () => {
fixture.componentRef.setInput('findings', [
createFinding('1', 'high', 'partial'),
]);
fixture.detectChanges();
// Spy on URL.createObjectURL
const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
const revokeObjectURLSpy = spyOn(URL, 'revokeObjectURL');
component.exportCsv();
expect(createObjectURLSpy).toHaveBeenCalled();
expect(revokeObjectURLSpy).toHaveBeenCalled();
});
});
// ===========================================================================
// Time Range Tests
// ===========================================================================
describe('time range', () => {
it('should default to 30d', () => {
fixture.detectChanges();
expect(component.timeRangeValue).toBe('30d');
});
it('should sync with input', () => {
fixture.componentRef.setInput('timeRange', '7d');
fixture.detectChanges();
expect(component.timeRangeValue).toBe('7d');
});
it('should update on selection change', () => {
fixture.detectChanges();
component.onTimeRangeChange('90d');
expect(component.timeRangeValue).toBe('90d');
});
});
});

View File

@@ -0,0 +1,995 @@
/**
* Attestation Metrics Dashboard Component.
* Sprint: SPRINT_4100_0006_0001 (Attestation Coverage Metrics)
* Task: METR-001, METR-002, METR-003, METR-004, METR-005
*
* Dashboard showing attestation coverage statistics including:
* - Coverage rates by attestation type (SBOM, VEX, Policy, Approval)
* - Approval velocity over time
* - Gap analysis table
* - Filtering by severity, status, and time range
*/
import { Component, input, computed, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import type { ChainStatusDisplay } from './chain-status-badge.component';
// ============================================================================
// Types
// ============================================================================
/**
* Attestation type for coverage tracking.
*/
export type CoverageAttestationType = 'sbom' | 'vex' | 'policyDecision' | 'humanApproval';
/**
* Finding severity level.
*/
export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low' | 'informational';
/**
* Finding status for filtering.
*/
export type FindingStatus = 'pending' | 'approved' | 'blocked' | 'triaged';
/**
* Time range for metrics.
*/
export type TimeRange = '1d' | '7d' | '30d' | '90d' | 'all';
/**
* Individual coverage stats for an attestation type.
*/
export interface CoverageStats {
readonly type: CoverageAttestationType;
readonly label: string;
readonly count: number;
readonly total: number;
readonly percentage: number;
readonly verified: number;
readonly expired: number;
readonly missing: number;
readonly trend?: readonly TrendPoint[];
}
/**
* Trend data point.
*/
export interface TrendPoint {
readonly date: string; // ISO date
readonly value: number;
}
/**
* Approval result for velocity tracking.
*/
export interface ApprovalResult {
readonly findingId: string;
readonly digestRef: string;
readonly approvedAt: string; // ISO datetime
readonly approver: string;
readonly expiresAt: string;
}
/**
* Gap analysis entry.
*/
export interface GapEntry {
readonly findingId: string;
readonly cveId: string;
readonly componentName: string;
readonly severity: FindingSeverity;
readonly missingTypes: readonly CoverageAttestationType[];
readonly chainStatus: ChainStatusDisplay;
readonly lastChecked: string;
}
/**
* Finding evidence response (simplified for metrics).
*/
export interface MetricsFindingData {
readonly findingId: string;
readonly cveId: string;
readonly componentName: string;
readonly severity: FindingSeverity;
readonly status: FindingStatus;
readonly chainStatus: ChainStatusDisplay;
readonly hasSbom: boolean;
readonly hasVex: boolean;
readonly hasPolicyDecision: boolean;
readonly hasHumanApproval: boolean;
readonly approvedAt?: string;
}
/**
* Attestation coverage metrics dashboard component.
*
* Features:
* - Coverage rate gauges for SBOM, VEX, Policy Decision, Human Approval
* - Approval velocity chart showing approvals over time
* - Gap analysis table showing findings missing attestations
* - Filtering by severity, status, and time range
* - Export to CSV for compliance reports
*
* @example
* <stella-metrics-dashboard
* [findings]="findingsData"
* [approvals]="approvalHistory"
* [timeRange]="'30d'"
* />
*/
@Component({
selector: 'stella-metrics-dashboard',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="metrics-dashboard">
<!-- Header -->
<header class="metrics-dashboard__header">
<h2 class="metrics-dashboard__title">Attestation Coverage Metrics</h2>
<div class="metrics-dashboard__actions">
<button
class="metrics-dashboard__btn metrics-dashboard__btn--secondary"
(click)="refresh()"
[disabled]="loading()"
>
{{ loading() ? 'Loading...' : 'Refresh' }}
</button>
<button
class="metrics-dashboard__btn metrics-dashboard__btn--primary"
(click)="exportCsv()"
>
Export CSV
</button>
</div>
</header>
<!-- Filters -->
<section class="metrics-dashboard__filters">
<div class="metrics-dashboard__filter">
<label for="time-range">Time Range:</label>
<select
id="time-range"
[(ngModel)]="timeRangeValue"
(ngModelChange)="onTimeRangeChange($event)"
>
<option value="1d">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="all">All time</option>
</select>
</div>
<div class="metrics-dashboard__filter">
<label>Severity:</label>
<div class="metrics-dashboard__checkbox-group">
@for (sev of severityOptions; track sev) {
<label class="metrics-dashboard__checkbox">
<input
type="checkbox"
[checked]="isSeveritySelected(sev)"
(change)="toggleSeverity(sev)"
/>
{{ sev | titlecase }}
</label>
}
</div>
</div>
<div class="metrics-dashboard__filter">
<label>Status:</label>
<div class="metrics-dashboard__checkbox-group">
@for (status of statusOptions; track status) {
<label class="metrics-dashboard__checkbox">
<input
type="checkbox"
[checked]="isStatusSelected(status)"
(change)="toggleStatus(status)"
/>
{{ status | titlecase }}
</label>
}
</div>
</div>
</section>
<!-- Coverage Gauges -->
<section class="metrics-dashboard__coverage">
<h3 class="metrics-dashboard__section-title">Coverage by Attestation Type</h3>
<div class="metrics-dashboard__gauges">
@for (stat of coverageStats(); track stat.type) {
<div class="gauge" [class]="'gauge--' + getCoverageLevel(stat.percentage)">
<div class="gauge__header">
<span class="gauge__label">{{ stat.label }}</span>
<span class="gauge__percentage">{{ stat.percentage | number:'1.0-0' }}%</span>
</div>
<div class="gauge__bar">
<div
class="gauge__fill"
[style.width.%]="stat.percentage"
></div>
</div>
<div class="gauge__details">
<span class="gauge__count">{{ stat.count | number }} / {{ stat.total | number }}</span>
<span class="gauge__breakdown">
✓ {{ stat.verified | number }} |
⚠ {{ stat.expired | number }} |
✗ {{ stat.missing | number }}
</span>
</div>
@if (stat.trend && stat.trend.length > 0) {
<div class="gauge__trend">
{{ renderSparkline(stat.trend) }}
</div>
}
</div>
}
</div>
</section>
<!-- Summary Stats -->
<section class="metrics-dashboard__summary">
<div class="summary-card">
<span class="summary-card__value">{{ totalFindings() | number }}</span>
<span class="summary-card__label">Total Findings</span>
</div>
<div class="summary-card">
<span class="summary-card__value">{{ completeChains() | number }}</span>
<span class="summary-card__label">Complete Chains</span>
</div>
<div class="summary-card">
<span class="summary-card__value">{{ overallCoverage() | number:'1.0-0' }}%</span>
<span class="summary-card__label">Overall Coverage</span>
</div>
<div class="summary-card">
<span class="summary-card__value">{{ pendingApprovals() | number }}</span>
<span class="summary-card__label">Pending Approvals</span>
</div>
</section>
<!-- Approval Velocity -->
<section class="metrics-dashboard__velocity">
<h3 class="metrics-dashboard__section-title">Approval Velocity</h3>
<div class="velocity-chart">
@if (velocityData().length > 0) {
<div class="velocity-chart__bars">
@for (point of velocityData(); track point.date) {
<div
class="velocity-chart__bar"
[style.height.%]="getVelocityBarHeight(point.value)"
[title]="point.date + ': ' + point.value + ' approvals'"
></div>
}
</div>
<div class="velocity-chart__labels">
<span>{{ velocityData()[0]?.date }}</span>
<span>{{ velocityData()[velocityData().length - 1]?.date }}</span>
</div>
} @else {
<div class="velocity-chart__empty">
No approval data available for selected time range
</div>
}
</div>
</section>
<!-- Gap Analysis Table -->
<section class="metrics-dashboard__gaps">
<h3 class="metrics-dashboard__section-title">
Gap Analysis
<span class="metrics-dashboard__section-count">({{ gapEntries().length | number }} findings with gaps)</span>
</h3>
@if (gapEntries().length > 0) {
<table class="gap-table">
<thead>
<tr>
<th>CVE ID</th>
<th>Component</th>
<th>Severity</th>
<th>Missing Attestations</th>
<th>Chain Status</th>
<th>Last Checked</th>
</tr>
</thead>
<tbody>
@for (gap of gapEntries(); track gap.findingId) {
<tr class="gap-table__row" [class]="'gap-table__row--' + gap.severity">
<td>
<a [href]="'/findings/' + gap.findingId" class="gap-table__link">
{{ gap.cveId }}
</a>
</td>
<td>{{ gap.componentName }}</td>
<td>
<span [class]="'severity-badge severity-badge--' + gap.severity">
{{ gap.severity | titlecase }}
</span>
</td>
<td>
<div class="missing-types">
@for (type of gap.missingTypes; track type) {
<span class="missing-types__tag">{{ formatAttestationType(type) }}</span>
}
</div>
</td>
<td>
<span [class]="'chain-status chain-status--' + gap.chainStatus">
{{ gap.chainStatus | titlecase }}
</span>
</td>
<td>{{ formatDate(gap.lastChecked) }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="metrics-dashboard__empty">
🎉 No attestation gaps found in the filtered data!
</div>
}
</section>
</div>
`,
styles: [`
.metrics-dashboard {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.metrics-dashboard__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.metrics-dashboard__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.metrics-dashboard__actions {
display: flex;
gap: 0.75rem;
}
.metrics-dashboard__btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
&--primary {
background: var(--primary, #007bff);
border: 1px solid var(--primary, #007bff);
color: white;
&:hover {
background: var(--primary-dark, #0056b3);
}
}
&--secondary {
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-primary, #212529);
&:hover {
background: var(--bg-hover, #f8f9fa);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.metrics-dashboard__filters {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 1rem;
background: var(--bg-subtle, #f8f9fa);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.metrics-dashboard__filter {
display: flex;
flex-direction: column;
gap: 0.375rem;
label {
font-size: 0.875rem;
font-weight: 500;
}
select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font: inherit;
}
}
.metrics-dashboard__checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.metrics-dashboard__checkbox {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
input {
cursor: pointer;
}
}
.metrics-dashboard__section-title {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1rem;
}
.metrics-dashboard__section-count {
font-size: 0.875rem;
font-weight: 400;
color: var(--text-muted, #6c757d);
}
// Coverage Gauges
.metrics-dashboard__coverage {
margin-bottom: 2rem;
}
.metrics-dashboard__gauges {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.gauge {
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
&--high .gauge__fill { background: var(--success, #28a745); }
&--medium .gauge__fill { background: var(--warning, #ffc107); }
&--low .gauge__fill { background: var(--danger, #dc3545); }
}
.gauge__header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.gauge__label {
font-weight: 500;
}
.gauge__percentage {
font-size: 1.25rem;
font-weight: 600;
}
.gauge__bar {
height: 8px;
background: var(--bg-muted, #e9ecef);
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.gauge__fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.gauge__details {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
}
.gauge__trend {
margin-top: 0.5rem;
font-family: monospace;
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
// Summary Stats
.metrics-dashboard__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.summary-card__value {
display: block;
font-size: 1.75rem;
font-weight: 600;
color: var(--primary, #007bff);
}
.summary-card__label {
display: block;
font-size: 0.875rem;
color: var(--text-muted, #6c757d);
margin-top: 0.25rem;
}
// Velocity Chart
.metrics-dashboard__velocity {
margin-bottom: 2rem;
}
.velocity-chart {
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
}
.velocity-chart__bars {
display: flex;
align-items: flex-end;
height: 120px;
gap: 2px;
}
.velocity-chart__bar {
flex: 1;
background: var(--primary, #007bff);
border-radius: 2px 2px 0 0;
min-height: 4px;
cursor: pointer;
&:hover {
background: var(--primary-dark, #0056b3);
}
}
.velocity-chart__labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
margin-top: 0.5rem;
}
.velocity-chart__empty {
text-align: center;
padding: 2rem;
color: var(--text-muted, #6c757d);
}
// Gap Table
.metrics-dashboard__gaps {
margin-bottom: 2rem;
}
.gap-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
overflow: hidden;
th, td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
th {
background: var(--bg-subtle, #f8f9fa);
font-weight: 600;
font-size: 0.875rem;
}
td {
font-size: 0.875rem;
}
}
.gap-table__row {
&--critical { border-left: 3px solid var(--danger, #dc3545); }
&--high { border-left: 3px solid var(--warning, #fd7e14); }
&--medium { border-left: 3px solid var(--warning, #ffc107); }
&--low { border-left: 3px solid var(--info, #17a2b8); }
}
.gap-table__link {
color: var(--primary, #007bff);
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.severity-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&--critical { background: #f8d7da; color: #721c24; }
&--high { background: #ffe5d0; color: #8a4500; }
&--medium { background: #fff3cd; color: #856404; }
&--low { background: #d1ecf1; color: #0c5460; }
&--informational { background: #e2e3e5; color: #383d41; }
}
.chain-status {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
&--complete { background: #d4edda; color: #155724; }
&--partial { background: #fff3cd; color: #856404; }
&--empty { background: #e2e3e5; color: #383d41; }
&--expired { background: #f8d7da; color: #721c24; }
&--error { background: #f8d7da; color: #721c24; }
}
.missing-types {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.missing-types__tag {
display: inline-block;
padding: 0.125rem 0.375rem;
background: var(--bg-muted, #e9ecef);
border-radius: 3px;
font-size: 0.7rem;
}
.metrics-dashboard__empty {
text-align: center;
padding: 2rem;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
color: var(--text-muted, #6c757d);
}
`],
})
export class MetricsDashboardComponent {
// =========================================================================
// Inputs
// =========================================================================
/** Finding data for metrics calculation. */
readonly findings = input<readonly MetricsFindingData[]>([]);
/** Approval history for velocity tracking. */
readonly approvals = input<readonly ApprovalResult[]>([]);
/** Initial time range. */
readonly timeRange = input<TimeRange>('30d');
/** Loading state. */
readonly loading = input<boolean>(false);
// =========================================================================
// Internal State
// =========================================================================
/** Selected time range. */
timeRangeValue: TimeRange = '30d';
/** Selected severities. */
readonly selectedSeverities = signal<Set<FindingSeverity>>(
new Set(['critical', 'high', 'medium', 'low', 'informational'])
);
/** Selected statuses. */
readonly selectedStatuses = signal<Set<FindingStatus>>(
new Set(['pending', 'approved', 'blocked', 'triaged'])
);
/** Options for severity filter. */
readonly severityOptions: readonly FindingSeverity[] = ['critical', 'high', 'medium', 'low', 'informational'];
/** Options for status filter. */
readonly statusOptions: readonly FindingStatus[] = ['pending', 'approved', 'blocked', 'triaged'];
// =========================================================================
// Computed Properties
// =========================================================================
/** Filtered findings based on current filters. */
readonly filteredFindings = computed(() => {
const sevs = this.selectedSeverities();
const stats = this.selectedStatuses();
return this.findings().filter(f =>
sevs.has(f.severity) && stats.has(f.status)
);
});
/** Total findings count. */
readonly totalFindings = computed(() => this.filteredFindings().length);
/** Findings with complete attestation chains. */
readonly completeChains = computed(() =>
this.filteredFindings().filter(f => f.chainStatus === 'complete').length
);
/** Overall coverage percentage. */
readonly overallCoverage = computed(() => {
const total = this.totalFindings();
if (total === 0) return 0;
return (this.completeChains() / total) * 100;
});
/** Pending approvals count. */
readonly pendingApprovals = computed(() =>
this.filteredFindings().filter(f => f.status === 'pending' && f.chainStatus === 'complete').length
);
/** Coverage statistics by attestation type. */
readonly coverageStats = computed((): readonly CoverageStats[] => {
const all = this.filteredFindings();
const total = all.length;
if (total === 0) {
return this.getEmptyCoverageStats();
}
return [
this.computeCoverageStat('sbom', 'SBOM', all, f => f.hasSbom),
this.computeCoverageStat('vex', 'VEX', all, f => f.hasVex),
this.computeCoverageStat('policyDecision', 'Policy Decision', all, f => f.hasPolicyDecision),
this.computeCoverageStat('humanApproval', 'Human Approval', all, f => f.hasHumanApproval),
];
});
/** Velocity data points. */
readonly velocityData = computed((): readonly TrendPoint[] => {
const apps = this.approvals();
if (apps.length === 0) return [];
// Group by date
const byDate = new Map<string, number>();
for (const app of apps) {
const date = app.approvedAt.split('T')[0];
byDate.set(date, (byDate.get(date) || 0) + 1);
}
// Sort by date
const points: TrendPoint[] = [];
for (const [date, value] of byDate) {
points.push({ date, value });
}
points.sort((a, b) => a.date.localeCompare(b.date));
return points;
});
/** Gap analysis entries - findings missing attestations. */
readonly gapEntries = computed((): readonly GapEntry[] => {
return this.filteredFindings()
.filter(f => f.chainStatus !== 'complete')
.map(f => ({
findingId: f.findingId,
cveId: f.cveId,
componentName: f.componentName,
severity: f.severity,
missingTypes: this.getMissingTypes(f),
chainStatus: f.chainStatus,
lastChecked: new Date().toISOString(),
}))
.sort((a, b) => {
const sevOrder: Record<FindingSeverity, number> = {
critical: 0, high: 1, medium: 2, low: 3, informational: 4,
};
return sevOrder[a.severity] - sevOrder[b.severity];
});
});
// =========================================================================
// Constructor
// =========================================================================
constructor() {
// Sync timeRange input to local value
effect(() => {
this.timeRangeValue = this.timeRange();
});
}
// =========================================================================
// Methods
// =========================================================================
/** Check if severity is selected. */
isSeveritySelected(sev: FindingSeverity): boolean {
return this.selectedSeverities().has(sev);
}
/** Toggle severity filter. */
toggleSeverity(sev: FindingSeverity): void {
const current = new Set(this.selectedSeverities());
if (current.has(sev)) {
current.delete(sev);
} else {
current.add(sev);
}
this.selectedSeverities.set(current);
}
/** Check if status is selected. */
isStatusSelected(status: FindingStatus): boolean {
return this.selectedStatuses().has(status);
}
/** Toggle status filter. */
toggleStatus(status: FindingStatus): void {
const current = new Set(this.selectedStatuses());
if (current.has(status)) {
current.delete(status);
} else {
current.add(status);
}
this.selectedStatuses.set(current);
}
/** Handle time range change. */
onTimeRangeChange(range: TimeRange): void {
this.timeRangeValue = range;
// Parent should reload data based on range
}
/** Refresh data. */
refresh(): void {
// Emit event or call service - parent handles this
}
/** Export to CSV. */
exportCsv(): void {
const gaps = this.gapEntries();
if (gaps.length === 0) return;
const headers = ['CVE ID', 'Component', 'Severity', 'Missing Attestations', 'Chain Status', 'Last Checked'];
const rows = gaps.map(g => [
g.cveId,
g.componentName,
g.severity,
g.missingTypes.join('; '),
g.chainStatus,
g.lastChecked,
]);
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attestation-gaps-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** Get coverage level class. */
getCoverageLevel(percentage: number): string {
if (percentage >= 80) return 'high';
if (percentage >= 50) return 'medium';
return 'low';
}
/** Get velocity bar height as percentage. */
getVelocityBarHeight(value: number): number {
const max = Math.max(...this.velocityData().map(p => p.value), 1);
return (value / max) * 100;
}
/** Format attestation type for display. */
formatAttestationType(type: CoverageAttestationType): string {
const labels: Record<CoverageAttestationType, string> = {
sbom: 'SBOM',
vex: 'VEX',
policyDecision: 'Policy',
humanApproval: 'Approval',
};
return labels[type];
}
/** Format date for display. */
formatDate(iso: string): string {
return new Date(iso).toLocaleDateString();
}
/** Render sparkline for trend. */
renderSparkline(trend: readonly TrendPoint[]): string {
const bars = '▁▂▃▄▅▆▇█';
const max = Math.max(...trend.map(p => p.value), 1);
return trend.map(p => {
const index = Math.round((p.value / max) * (bars.length - 1));
return bars[index];
}).join('');
}
// =========================================================================
// Private Methods
// =========================================================================
private getEmptyCoverageStats(): readonly CoverageStats[] {
return [
{ type: 'sbom', label: 'SBOM', count: 0, total: 0, percentage: 0, verified: 0, expired: 0, missing: 0 },
{ type: 'vex', label: 'VEX', count: 0, total: 0, percentage: 0, verified: 0, expired: 0, missing: 0 },
{ type: 'policyDecision', label: 'Policy Decision', count: 0, total: 0, percentage: 0, verified: 0, expired: 0, missing: 0 },
{ type: 'humanApproval', label: 'Human Approval', count: 0, total: 0, percentage: 0, verified: 0, expired: 0, missing: 0 },
];
}
private computeCoverageStat(
type: CoverageAttestationType,
label: string,
findings: readonly MetricsFindingData[],
hasAttestation: (f: MetricsFindingData) => boolean,
): CoverageStats {
const total = findings.length;
const withAttestation = findings.filter(hasAttestation);
const count = withAttestation.length;
const percentage = total > 0 ? (count / total) * 100 : 0;
// For now, assume all are verified (detailed status would come from API)
return {
type,
label,
count,
total,
percentage,
verified: count,
expired: 0,
missing: total - count,
};
}
private getMissingTypes(finding: MetricsFindingData): readonly CoverageAttestationType[] {
const missing: CoverageAttestationType[] = [];
if (!finding.hasSbom) missing.push('sbom');
if (!finding.hasVex) missing.push('vex');
if (!finding.hasPolicyDecision) missing.push('policyDecision');
if (!finding.hasHumanApproval) missing.push('humanApproval');
return missing;
}
}

View File

@@ -0,0 +1,269 @@
/**
* Proof Chain Viewer Component Tests.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab and Chain Viewer)
* Task: PROOF-006 - Unit tests for ProofChainViewerComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProofChainViewerComponent, ChainNode } from './proof-chain-viewer.component';
describe('ProofChainViewerComponent', () => {
let component: ProofChainViewerComponent;
let fixture: ComponentFixture<ProofChainViewerComponent>;
const mockNodes: ChainNode[] = [
{
id: 'node-1',
type: 'sbom',
predicateType: 'stella.ops/sbom@v1',
digest: 'sha256:sbom123',
verified: true,
signer: { keyId: 'key-1', algorithm: 'ECDSA-P256' },
timestamp: '2025-12-18T09:22:00Z',
},
{
id: 'node-2',
type: 'vex',
predicateType: 'stella.ops/vex@v1',
digest: 'sha256:vex456',
verified: true,
parentId: 'node-1',
signer: { keyId: 'key-2', algorithm: 'ECDSA-P256' },
timestamp: '2025-12-18T09:25:00Z',
},
{
id: 'node-3',
type: 'policy',
predicateType: 'stella.ops/policy-decision@v1',
digest: 'sha256:policy789',
verified: true,
parentId: 'node-2',
signer: { keyId: 'key-3', algorithm: 'ECDSA-P256' },
timestamp: '2025-12-18T09:30:00Z',
rekorRef: { logIndex: 12345, logId: 'rekor-log' },
},
{
id: 'node-4',
type: 'approval',
predicateType: 'stella.ops/human-approval@v1',
digest: 'sha256:approval000',
verified: true,
parentId: 'node-3',
signer: { keyId: 'key-4', identity: 'approver@org.com', algorithm: 'ECDSA-P256' },
timestamp: '2025-12-18T10:00:00Z',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProofChainViewerComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProofChainViewerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('nodes', mockNodes);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('chain summary', () => {
it('should calculate complete status for full verified chain', () => {
const summary = component.summary();
expect(summary.status).toBe('complete');
expect(summary.totalNodes).toBe(4);
expect(summary.verifiedNodes).toBe(4);
expect(summary.missingTypes).toEqual([]);
});
it('should detect missing types', () => {
const incompleteNodes = [mockNodes[0], mockNodes[1]]; // Only SBOM and VEX
fixture.componentRef.setInput('nodes', incompleteNodes);
fixture.detectChanges();
const summary = component.summary();
expect(summary.status).toBe('partial');
expect(summary.missingTypes).toContain('policy');
expect(summary.missingTypes).toContain('approval');
});
it('should detect expired nodes', () => {
const expiredNodes = [{ ...mockNodes[0], expired: true }];
fixture.componentRef.setInput('nodes', expiredNodes);
fixture.detectChanges();
const summary = component.summary();
expect(summary.status).toBe('expired');
expect(summary.expiredNodes).toBe(1);
});
it('should return empty status when no nodes', () => {
fixture.componentRef.setInput('nodes', []);
fixture.detectChanges();
expect(component.summary().status).toBe('empty');
});
});
describe('node ordering', () => {
it('should order nodes by type (sbom → vex → policy → approval)', () => {
// Shuffle the input order
const shuffled = [mockNodes[2], mockNodes[0], mockNodes[3], mockNodes[1]];
fixture.componentRef.setInput('nodes', shuffled);
fixture.detectChanges();
const ordered = component.orderedNodes();
expect(ordered[0].type).toBe('sbom');
expect(ordered[1].type).toBe('vex');
expect(ordered[2].type).toBe('policy');
expect(ordered[3].type).toBe('approval');
});
});
describe('rendering', () => {
it('should render all nodes', () => {
const compiled = fixture.nativeElement;
const nodes = compiled.querySelectorAll('stella-attestation-node');
expect(nodes.length).toBe(4);
});
it('should show chain connectors between nodes', () => {
const compiled = fixture.nativeElement;
const connectors = compiled.querySelectorAll('.proof-chain__connector');
expect(connectors.length).toBe(3); // 4 nodes, 3 connectors between them
});
it('should show chain status badge', () => {
const compiled = fixture.nativeElement;
const badge = compiled.querySelector('stella-chain-status-badge');
expect(badge).toBeTruthy();
});
it('should show header title', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('DSSE Attestation Chain');
});
});
describe('empty state', () => {
it('should show empty state when no nodes', () => {
fixture.componentRef.setInput('nodes', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const empty = compiled.querySelector('.proof-chain__empty');
expect(empty).toBeTruthy();
expect(compiled.textContent).toContain('No attestations in chain');
});
});
describe('expected chain display', () => {
it('should show expected chain when types are missing', () => {
const incompleteNodes = [mockNodes[0]]; // Only SBOM
fixture.componentRef.setInput('nodes', incompleteNodes);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const expected = compiled.querySelector('.proof-chain__expected');
expect(expected).toBeTruthy();
});
it('should mark present types as present', () => {
expect(component.isTypePresent('sbom')).toBe(true);
expect(component.isTypePresent('vex')).toBe(true);
});
it('should mark missing types as missing', () => {
const incompleteNodes = [mockNodes[0]]; // Only SBOM
fixture.componentRef.setInput('nodes', incompleteNodes);
fixture.detectChanges();
expect(component.isTypePresent('vex')).toBe(false);
});
});
describe('type helpers', () => {
it('should return correct icons for types', () => {
expect(component.getTypeIcon('sbom')).toBe('📦');
expect(component.getTypeIcon('vex')).toBe('📋');
expect(component.getTypeIcon('policy')).toBe('⚖️');
expect(component.getTypeIcon('approval')).toBe('✅');
expect(component.getTypeIcon('graph')).toBe('🔗');
});
it('should return correct labels for types', () => {
expect(component.getTypeLabel('sbom')).toBe('SBOM');
expect(component.getTypeLabel('vex')).toBe('VEX');
expect(component.getTypeLabel('policy')).toBe('Policy');
expect(component.getTypeLabel('approval')).toBe('Approval');
});
});
describe('events', () => {
it('should emit nodeSelected when node is expanded', () => {
const spy = jasmine.createSpy('nodeSelected');
component.nodeSelected.subscribe(spy);
component.onNodeExpand('node-1');
expect(spy).toHaveBeenCalledWith('node-1');
});
it('should emit refresh when refresh is clicked', () => {
const spy = jasmine.createSpy('refresh');
component.refresh.subscribe(spy);
component.onRefresh();
expect(spy).toHaveBeenCalled();
});
it('should emit exportChain with nodes when export is clicked', () => {
const spy = jasmine.createSpy('exportChain');
component.exportChain.subscribe(spy);
component.onExportChain();
expect(spy).toHaveBeenCalledWith(mockNodes);
});
});
describe('compact mode', () => {
it('should apply compact class when compact is true', () => {
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const chain = compiled.querySelector('.proof-chain--compact');
expect(chain).toBeTruthy();
});
it('should hide stats in compact mode', () => {
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const stats = compiled.querySelector('.proof-chain__stats');
expect(stats).toBeFalsy();
});
});
describe('actions', () => {
it('should show actions by default', () => {
const compiled = fixture.nativeElement;
const actions = compiled.querySelector('.proof-chain__actions');
expect(actions).toBeTruthy();
});
it('should hide actions when showActions is false', () => {
fixture.componentRef.setInput('showActions', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const actions = compiled.querySelector('.proof-chain__actions');
expect(actions).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,472 @@
/**
* Proof Chain Viewer Component.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab and Chain Viewer)
* Task: PROOF-001 - ProofChainViewerComponent for attestation chain visualization
*
* Displays the full attestation chain: SBOM → VEX → Policy Decision → Human Approval
* with verification status and linking between nodes.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AttestationNodeComponent, AttestationType, SignerInfo, RekorRef } from './attestation-node.component';
import { ChainStatusBadgeComponent, ChainStatusDisplay } from './chain-status-badge.component';
/**
* Attestation chain node data.
*/
export interface ChainNode {
readonly id: string;
readonly type: AttestationType;
readonly predicateType: string;
readonly digest: string;
readonly verified: boolean;
readonly expired?: boolean;
readonly signer?: SignerInfo;
readonly timestamp?: string;
readonly rekorRef?: RekorRef;
readonly parentId?: string;
readonly rawEnvelope?: string;
}
/**
* Chain status summary.
*/
export interface ChainSummary {
readonly status: ChainStatusDisplay;
readonly totalNodes: number;
readonly verifiedNodes: number;
readonly expiredNodes: number;
readonly missingTypes: string[];
}
/**
* Proof chain viewer component.
*
* Features:
* - Visualizes DSSE attestation chain
* - Shows SBOM → VEX → Policy Decision → Human Approval flow
* - Displays verification status per node
* - Links to Rekor transparency log
* - One-click "Show DSSE chain" action
*
* @example
* <stella-proof-chain-viewer
* [nodes]="chainNodes"
* [compact]="false"
* (nodeSelected)="onNodeClick($event)"
* />
*/
@Component({
selector: 'stella-proof-chain-viewer',
standalone: true,
imports: [CommonModule, AttestationNodeComponent, ChainStatusBadgeComponent],
template: `
<div class="proof-chain" [class.proof-chain--compact]="compact()">
<!-- Header -->
<header class="proof-chain__header">
<h3 class="proof-chain__title">
<span class="proof-chain__icon" aria-hidden="true">🔗</span>
DSSE Attestation Chain
</h3>
<stella-chain-status-badge
[status]="summary().status"
[missingSteps]="summary().missingTypes"
[showLabel]="true"
/>
</header>
<!-- Summary Stats -->
@if (!compact()) {
<div class="proof-chain__stats" role="status" aria-label="Chain statistics">
<span class="proof-chain__stat">
<strong>{{ summary().verifiedNodes }}</strong>/{{ summary().totalNodes }} verified
</span>
@if (summary().expiredNodes > 0) {
<span class="proof-chain__stat proof-chain__stat--warning">
{{ summary().expiredNodes }} expired
</span>
}
@if (summary().missingTypes.length > 0) {
<span class="proof-chain__stat proof-chain__stat--error">
Missing: {{ summary().missingTypes.join(', ') }}
</span>
}
</div>
}
<!-- Chain Nodes -->
@if (orderedNodes().length === 0) {
<div class="proof-chain__empty" role="status">
<span class="proof-chain__empty-icon">📭</span>
<span class="proof-chain__empty-text">No attestations in chain</span>
</div>
} @else {
<div class="proof-chain__nodes" role="list">
@for (node of orderedNodes(); track node.id; let idx = $index; let last = $last) {
<div class="proof-chain__node-wrapper" role="listitem">
<!-- Chain connector -->
@if (idx > 0) {
<div class="proof-chain__connector" aria-hidden="true">
<div class="proof-chain__connector-line"></div>
<span class="proof-chain__connector-arrow">▼</span>
</div>
}
<!-- Attestation node -->
<stella-attestation-node
[type]="node.type"
[digest]="node.digest"
[predicateType]="node.predicateType"
[verified]="node.verified"
[expired]="node.expired ?? false"
[signer]="node.signer"
[timestamp]="node.timestamp"
[rekorRef]="node.rekorRef"
[rawEnvelope]="node.rawEnvelope"
[showRawEnvelope]="showRawEnvelopes()"
(expand)="onNodeExpand(node.id)"
/>
</div>
}
</div>
}
<!-- Expected chain (when missing nodes) -->
@if (summary().missingTypes.length > 0 && !compact()) {
<div class="proof-chain__expected">
<h4 class="proof-chain__expected-title">Expected Chain</h4>
<div class="proof-chain__expected-flow">
@for (type of expectedTypes; track type; let last = $last) {
<span
class="proof-chain__expected-type"
[class.proof-chain__expected-type--present]="isTypePresent(type)"
[class.proof-chain__expected-type--missing]="!isTypePresent(type)"
>
{{ getTypeIcon(type) }} {{ getTypeLabel(type) }}
</span>
@if (!last) {
<span class="proof-chain__expected-arrow" aria-hidden="true">→</span>
}
}
</div>
</div>
}
<!-- Actions -->
@if (!compact() && showActions()) {
<div class="proof-chain__actions">
<button
class="proof-chain__action"
(click)="onRefresh()"
type="button"
title="Refresh chain status"
>
🔄 Refresh
</button>
<button
class="proof-chain__action"
(click)="onExportChain()"
type="button"
title="Export chain as JSON"
>
📋 Export
</button>
</div>
}
</div>
`,
styles: [`
.proof-chain {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-surface, #ffffff);
padding: 1rem;
&--compact {
padding: 0.75rem;
}
}
.proof-chain__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.proof-chain__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.proof-chain__icon {
font-size: 1.25rem;
}
.proof-chain__stats {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.proof-chain__stat {
&--warning {
color: var(--warning-dark, #856404);
}
&--error {
color: var(--danger, #dc3545);
}
}
.proof-chain__nodes {
display: flex;
flex-direction: column;
}
.proof-chain__node-wrapper {
display: flex;
flex-direction: column;
}
.proof-chain__connector {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0;
}
.proof-chain__connector-line {
width: 2px;
height: 1rem;
background: var(--border-color, #e0e0e0);
}
.proof-chain__connector-arrow {
font-size: 0.75rem;
color: var(--text-muted, #6c757d);
}
.proof-chain__empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
color: var(--text-muted, #6c757d);
}
.proof-chain__empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.proof-chain__expected {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed var(--border-color, #e0e0e0);
}
.proof-chain__expected-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #6c757d);
margin: 0 0 0.5rem 0;
}
.proof-chain__expected-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.proof-chain__expected-type {
font-size: 0.8125rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--bg-subtle, #f8f9fa);
&--present {
background: rgba(40, 167, 69, 0.1);
color: var(--success, #28a745);
}
&--missing {
background: rgba(220, 53, 69, 0.1);
color: var(--danger, #dc3545);
text-decoration: line-through;
opacity: 0.7;
}
}
.proof-chain__expected-arrow {
color: var(--text-muted, #6c757d);
}
.proof-chain__actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.proof-chain__action {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-surface, #ffffff);
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
background: var(--bg-hover, #f8f9fa);
border-color: var(--border-hover, #c0c0c0);
}
}
`],
})
export class ProofChainViewerComponent {
// =========================================================================
// Inputs
// =========================================================================
/** Chain nodes to display. */
readonly nodes = input<readonly ChainNode[]>([]);
/** Whether to display in compact mode. */
readonly compact = input<boolean>(false);
/** Whether to show action buttons. */
readonly showActions = input<boolean>(true);
/** Whether to show raw envelope option. */
readonly showRawEnvelopes = input<boolean>(false);
// =========================================================================
// Outputs
// =========================================================================
/** Emitted when a node is selected/expanded. */
readonly nodeSelected = output<string>();
/** Emitted when refresh is requested. */
readonly refresh = output<void>();
/** Emitted when export is requested. */
readonly exportChain = output<ChainNode[]>();
// =========================================================================
// Constants
// =========================================================================
/** Expected attestation types in chain order. */
readonly expectedTypes: AttestationType[] = ['sbom', 'vex', 'policy', 'approval'];
// =========================================================================
// Computed Properties
// =========================================================================
/** Nodes ordered by chain position. */
readonly orderedNodes = computed(() => {
const nodes = this.nodes();
// Sort by expected type order
const typeOrder: Record<AttestationType, number> = {
sbom: 0,
vex: 1,
graph: 2,
policy: 3,
approval: 4,
unknown: 99,
};
return [...nodes].sort((a, b) => typeOrder[a.type] - typeOrder[b.type]);
});
/** Chain summary. */
readonly summary = computed((): ChainSummary => {
const nodes = this.nodes();
const verifiedNodes = nodes.filter((n) => n.verified).length;
const expiredNodes = nodes.filter((n) => n.expired).length;
const presentTypes = new Set(nodes.map((n) => n.type));
const missingTypes = this.expectedTypes.filter((t) => !presentTypes.has(t));
let status: ChainStatusDisplay;
if (nodes.length === 0) {
status = 'empty';
} else if (expiredNodes > 0) {
status = 'expired';
} else if (missingTypes.length === 0 && verifiedNodes === nodes.length) {
status = 'complete';
} else if (nodes.some((n) => !n.verified)) {
status = 'invalid';
} else {
status = 'partial';
}
return {
status,
totalNodes: nodes.length,
verifiedNodes,
expiredNodes,
missingTypes,
};
});
// =========================================================================
// Methods
// =========================================================================
/** Check if a type is present in the chain. */
isTypePresent(type: AttestationType): boolean {
return this.nodes().some((n) => n.type === type);
}
/** Get icon for attestation type. */
getTypeIcon(type: AttestationType): string {
switch (type) {
case 'sbom': return '📦';
case 'vex': return '📋';
case 'policy': return '⚖️';
case 'approval': return '✅';
case 'graph': return '🔗';
default: return '📄';
}
}
/** Get label for attestation type. */
getTypeLabel(type: AttestationType): string {
switch (type) {
case 'sbom': return 'SBOM';
case 'vex': return 'VEX';
case 'policy': return 'Policy';
case 'approval': return 'Approval';
case 'graph': return 'Graph';
default: return 'Unknown';
}
}
/** Handle node expand. */
onNodeExpand(nodeId: string): void {
this.nodeSelected.emit(nodeId);
}
/** Handle refresh click. */
onRefresh(): void {
this.refresh.emit();
}
/** Handle export click. */
onExportChain(): void {
this.exportChain.emit([...this.nodes()]);
}
}

View File

@@ -0,0 +1,146 @@
/**
* Reachability Chip Component Tests.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-005 - Unit tests for ReachabilityChipComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReachabilityChipComponent, ReachabilityState } from './reachability-chip.component';
describe('ReachabilityChipComponent', () => {
let component: ReachabilityChipComponent;
let fixture: ComponentFixture<ReachabilityChipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReachabilityChipComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityChipComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('state rendering', () => {
it('should display unknown state by default', () => {
expect(component.state()).toBe('unknown');
expect(component.label()).toBe('Unknown');
expect(component.icon()).toBe('?');
});
it('should display reachable state correctly', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.detectChanges();
expect(component.label()).toBe('Reachable');
expect(component.icon()).toBe('⚠');
expect(component.chipClass()).toContain('reachable');
});
it('should display unreachable state correctly', () => {
fixture.componentRef.setInput('state', 'unreachable');
fixture.detectChanges();
expect(component.label()).toBe('Unreachable');
expect(component.icon()).toBe('✓');
expect(component.chipClass()).toContain('unreachable');
});
});
describe('path depth', () => {
it('should not display path depth when not provided', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.reachability-chip__depth')).toBeNull();
});
it('should display path depth when provided', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.componentRef.setInput('pathDepth', 3);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const depthEl = compiled.querySelector('.reachability-chip__depth');
expect(depthEl).toBeTruthy();
expect(depthEl.textContent).toContain('3 hops');
});
it('should use singular "hop" for depth of 1', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.componentRef.setInput('pathDepth', 1);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const depthEl = compiled.querySelector('.reachability-chip__depth');
expect(depthEl.textContent).toContain('1 hop');
expect(depthEl.textContent).not.toContain('hops');
});
});
describe('label visibility', () => {
it('should show label by default', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.reachability-chip__label')).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.reachability-chip__label')).toBeNull();
});
});
describe('tooltip', () => {
it('should have tooltip for reachable state', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.detectChanges();
expect(component.tooltip()).toContain('reachable');
});
it('should include hop count in tooltip when path depth provided', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.componentRef.setInput('pathDepth', 5);
fixture.detectChanges();
expect(component.tooltip()).toContain('5 hops');
});
it('should use custom tooltip when provided', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.componentRef.setInput('customTooltip', 'Custom message');
fixture.detectChanges();
expect(component.tooltip()).toBe('Custom message');
});
});
describe('accessibility', () => {
it('should have aria-label', () => {
fixture.componentRef.setInput('state', 'reachable');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.reachability-chip');
expect(chip.getAttribute('aria-label')).toBeTruthy();
});
it('should have role="status"', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.reachability-chip');
expect(chip.getAttribute('role')).toBe('status');
});
});
});

View File

@@ -0,0 +1,212 @@
/**
* Reachability Chip Component.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-001 - ReachabilityChip displaying reachable/unreachable state with call path depth
*
* Displays a compact chip indicating whether a vulnerability is reachable from
* an entrypoint, with optional hop count for the call path.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Reachability state values.
*/
export type ReachabilityState = 'reachable' | 'unreachable' | 'unknown';
/**
* Compact chip component displaying reachability status.
*
* Color scheme:
* - reachable (red): Vulnerability is reachable from an entrypoint (high risk)
* - unreachable (green): Vulnerability is not reachable (lower risk)
* - unknown (gray): Reachability could not be determined
*
* @example
* <stella-reachability-chip [state]="'reachable'" [pathDepth]="3" />
* <stella-reachability-chip [state]="'unreachable'" />
* <stella-reachability-chip [state]="'unknown'" [showLabel]="false" />
*/
@Component({
selector: 'stella-reachability-chip',
standalone: true,
imports: [CommonModule],
template: `
<span
class="reachability-chip"
[class]="chipClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
role="status"
>
<span class="reachability-chip__icon" aria-hidden="true">{{ icon() }}</span>
@if (showLabel()) {
<span class="reachability-chip__label">{{ label() }}</span>
}
@if (pathDepth() && pathDepth()! > 0) {
<span class="reachability-chip__depth" [attr.aria-label]="depthAriaLabel()">
({{ pathDepth() }} {{ pathDepth() === 1 ? 'hop' : 'hops' }})
</span>
}
</span>
`,
styles: [`
.reachability-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: default;
transition: opacity 0.15s;
&:hover {
opacity: 0.9;
}
}
.reachability-chip__icon {
font-size: 0.875rem;
line-height: 1;
}
.reachability-chip__label {
text-transform: capitalize;
letter-spacing: 0.02em;
}
.reachability-chip__depth {
font-size: 0.6875rem;
opacity: 0.85;
font-variant-numeric: tabular-nums;
}
// State-specific colors (high contrast for accessibility)
.reachability-chip--reachable {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.reachability-chip--unreachable {
background: rgba(40, 167, 69, 0.15);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.reachability-chip--unknown {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
border: 1px solid rgba(108, 117, 125, 0.3);
}
`]
})
export class ReachabilityChipComponent {
/**
* Reachability state: reachable, unreachable, or unknown.
*/
readonly state = input<ReachabilityState>('unknown');
/**
* Number of hops (call depth) from entrypoint to vulnerable code.
* Only displayed if greater than 0.
*/
readonly pathDepth = input<number | undefined>(undefined);
/**
* Whether to show the text label (default: true).
* Set to false for a more compact icon-only display.
*/
readonly showLabel = input<boolean>(true);
/**
* Optional custom tooltip override.
*/
readonly customTooltip = input<string | undefined>(undefined);
/**
* Computed CSS class for state.
*/
readonly chipClass = computed(() => `reachability-chip reachability-chip--${this.state()}`);
/**
* Computed icon based on state.
*/
readonly icon = computed(() => {
switch (this.state()) {
case 'reachable':
return '⚠'; // Warning - reachable is high risk
case 'unreachable':
return '✓'; // Check - unreachable is safer
case 'unknown':
default:
return '?'; // Question mark for unknown
}
});
/**
* Computed label text.
*/
readonly label = computed(() => {
switch (this.state()) {
case 'reachable':
return 'Reachable';
case 'unreachable':
return 'Unreachable';
case 'unknown':
default:
return 'Unknown';
}
});
/**
* Computed tooltip text.
*/
readonly tooltip = computed(() => {
if (this.customTooltip()) {
return this.customTooltip();
}
const depth = this.pathDepth();
switch (this.state()) {
case 'reachable':
return depth && depth > 0
? `Vulnerability is reachable via ${depth} hop${depth === 1 ? '' : 's'} from an entrypoint`
: 'Vulnerability is reachable from an entrypoint';
case 'unreachable':
return 'Vulnerability is not reachable from any known entrypoint';
case 'unknown':
default:
return 'Reachability analysis was not performed or is inconclusive';
}
});
/**
* Aria label for screen readers.
*/
readonly ariaLabel = computed(() => {
const depth = this.pathDepth();
switch (this.state()) {
case 'reachable':
return depth && depth > 0
? `Reachable, ${depth} hop${depth === 1 ? '' : 's'} from entrypoint`
: 'Reachable from entrypoint';
case 'unreachable':
return 'Not reachable from any entrypoint';
case 'unknown':
default:
return 'Reachability unknown';
}
});
/**
* Aria label for depth span.
*/
readonly depthAriaLabel = computed(() => {
const depth = this.pathDepth();
return depth ? `${depth} hop${depth === 1 ? '' : 's'} from entrypoint` : '';
});
}

View File

@@ -0,0 +1,232 @@
/**
* Rekor Link Component Tests.
* Sprint: SPRINT_4100_0004_0002 (Proof Tab)
* Task: PROOF-006 - Unit tests for RekorLinkComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RekorLinkComponent, RekorReference } from './rekor-link.component';
describe('RekorLinkComponent', () => {
let component: RekorLinkComponent;
let fixture: ComponentFixture<RekorLinkComponent>;
const mockReference: RekorReference = {
logId: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
logIndex: 12345678,
integratedTime: 1703074800, // 2023-12-20T13:00:00Z
verified: true,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RekorLinkComponent],
}).compileComponents();
fixture = TestBed.createComponent(RekorLinkComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('log index', () => {
it('should return log index from reference', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
expect(component.effectiveLogIndex()).toBe(12345678);
});
it('should fall back to direct input', () => {
fixture.componentRef.setInput('logIndex', 99999);
fixture.detectChanges();
expect(component.effectiveLogIndex()).toBe(99999);
});
it('should prefer reference over direct input', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.componentRef.setInput('logIndex', 99999);
fixture.detectChanges();
expect(component.effectiveLogIndex()).toBe(12345678);
});
});
describe('log ID', () => {
it('should return log ID from reference', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
expect(component.effectiveLogId()).toBe(mockReference.logId);
});
it('should fall back to direct input', () => {
fixture.componentRef.setInput('logId', 'custom-id');
fixture.detectChanges();
expect(component.effectiveLogId()).toBe('custom-id');
});
});
describe('verification status', () => {
it('should return verified from reference', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
expect(component.isVerified()).toBe(true);
});
it('should fall back to direct input', () => {
fixture.componentRef.setInput('verified', true);
fixture.detectChanges();
expect(component.isVerified()).toBe(true);
});
it('should default to false when not set', () => {
expect(component.isVerified()).toBe(false);
});
});
describe('Rekor URL', () => {
it('should generate Sigstore search URL by default', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const url = component.rekorUrl();
expect(url).toBe('https://search.sigstore.dev?logIndex=12345678');
});
it('should use custom host', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.componentRef.setInput('rekorHost', 'https://rekor.internal.example.com');
fixture.detectChanges();
const url = component.rekorUrl();
expect(url).toBe('https://rekor.internal.example.com/api/v1/log/entries?logIndex=12345678');
});
});
describe('formatted time', () => {
it('should format Unix timestamp to ISO string', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const time = component.formattedTime();
expect(time).toContain('2023-12-20');
expect(time).toContain('UTC');
});
it('should return null when no time', () => {
expect(component.formattedTime()).toBeNull();
});
});
describe('truncated log ID', () => {
it('should truncate long log IDs', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const truncated = component.truncatedLogId();
expect(truncated).toContain('…');
expect(truncated.length).toBeLessThan(mockReference.logId.length);
});
it('should not truncate short log IDs', () => {
fixture.componentRef.setInput('logId', 'short123');
fixture.detectChanges();
expect(component.truncatedLogId()).toBe('short123');
});
});
describe('aria label', () => {
it('should include log index', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const label = component.ariaLabel();
expect(label).toContain('12345678');
});
it('should include verified status', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const label = component.ariaLabel();
expect(label).toContain('verified');
});
});
describe('compact mode', () => {
it('should default to non-compact', () => {
expect(component.compact()).toBe(false);
});
it('should render compact when set', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.rekor-link');
expect(link.classList.contains('rekor-link--compact')).toBe(true);
});
});
describe('link rendering', () => {
it('should render as anchor element', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('a.rekor-link');
expect(link).toBeTruthy();
});
it('should open in new tab', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('a.rekor-link');
expect(link.getAttribute('target')).toBe('_blank');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});
it('should apply verified class when verified', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const link = fixture.nativeElement.querySelector('.rekor-link');
expect(link.classList.contains('rekor-link--verified')).toBe(true);
});
it('should show log index in link', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.detectChanges();
const linkText = fixture.nativeElement.textContent;
expect(linkText).toContain('#12345678');
});
});
describe('meta display', () => {
it('should show meta by default', () => {
expect(component.showMeta()).toBe(true);
});
it('should hide log ID by default', () => {
expect(component.showLogId()).toBe(false);
});
it('should show time when available', () => {
fixture.componentRef.setInput('reference', mockReference);
fixture.componentRef.setInput('showMeta', true);
fixture.detectChanges();
expect(component.formattedTime()).not.toBeNull();
});
});
});

View File

@@ -0,0 +1,231 @@
/**
* Rekor Link Component
* Sprint: SPRINT_4100_0004_0002 (Proof Tab)
* Task: PROOF-005 - Add Rekor reference links (clickable)
*
* Displays a clickable link to the Sigstore Rekor transparency log.
* Supports both public Rekor (rekor.sigstore.dev) and self-hosted instances.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Rekor entry reference.
*/
export interface RekorReference {
logId: string;
logIndex: number;
integratedTime?: number;
verified?: boolean;
}
@Component({
selector: 'stella-rekor-link',
standalone: true,
imports: [CommonModule],
template: `
<a
class="rekor-link"
[class.rekor-link--verified]="isVerified()"
[class.rekor-link--compact]="compact()"
[href]="rekorUrl()"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="ariaLabel()"
>
<span class="rekor-link__icon" aria-hidden="true">
{{ isVerified() ? '🔒' : '📋' }}
</span>
<span class="rekor-link__content" *ngIf="!compact()">
<span class="rekor-link__label">Rekor Log</span>
<span class="rekor-link__index">#{{ logIndex() }}</span>
</span>
<span class="rekor-link__index-only" *ngIf="compact()">
#{{ logIndex() }}
</span>
<span class="rekor-link__external" aria-hidden="true">↗</span>
</a>
<span class="rekor-link__meta" *ngIf="showMeta() && !compact()">
<span class="rekor-link__time" *ngIf="formattedTime()">
{{ formattedTime() }}
</span>
<span class="rekor-link__id" *ngIf="showLogId()">
{{ truncatedLogId() }}
</span>
</span>
`,
styles: [`
:host {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rekor-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 4px;
background: var(--surface-alt, #f8f9fa);
color: var(--primary-color, #0066cc);
text-decoration: none;
font-size: 13px;
border: 1px solid var(--border-color, #e0e0e0);
transition: background-color 0.15s, border-color 0.15s;
}
.rekor-link:hover {
background: var(--primary-light, #e3f2fd);
border-color: var(--primary-color, #0066cc);
}
.rekor-link--verified {
background: #e6f4ea;
border-color: #34a853;
}
.rekor-link--verified:hover {
background: #ceead6;
}
.rekor-link--compact {
padding: 2px 6px;
font-size: 12px;
gap: 4px;
}
.rekor-link__icon {
font-size: 12px;
}
.rekor-link--compact .rekor-link__icon {
font-size: 10px;
}
.rekor-link__content {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.rekor-link__label {
font-size: 10px;
color: var(--text-secondary, #666);
text-transform: uppercase;
}
.rekor-link__index {
font-weight: 600;
font-family: monospace;
}
.rekor-link__index-only {
font-weight: 600;
font-family: monospace;
}
.rekor-link__external {
font-size: 10px;
opacity: 0.7;
}
.rekor-link__meta {
display: flex;
flex-direction: column;
font-size: 11px;
color: var(--text-secondary, #666);
line-height: 1.3;
}
.rekor-link__time {
font-family: monospace;
}
.rekor-link__id {
font-family: monospace;
opacity: 0.8;
}
`]
})
export class RekorLinkComponent {
// Inputs
reference = input<RekorReference>();
logIndex = input<number>();
logId = input<string>();
integratedTime = input<number>();
verified = input<boolean>();
// Display options
compact = input(false);
showMeta = input(true);
showLogId = input(false);
// Custom Rekor host (default: public Sigstore Rekor)
rekorHost = input('https://search.sigstore.dev');
// Computed values
effectiveLogIndex = computed(() => {
const ref = this.reference();
return ref?.logIndex ?? this.logIndex() ?? 0;
});
effectiveLogId = computed(() => {
const ref = this.reference();
return ref?.logId ?? this.logId() ?? '';
});
effectiveIntegratedTime = computed(() => {
const ref = this.reference();
return ref?.integratedTime ?? this.integratedTime();
});
isVerified = computed(() => {
const ref = this.reference();
return ref?.verified ?? this.verified() ?? false;
});
rekorUrl = computed(() => {
const host = this.rekorHost();
const index = this.effectiveLogIndex();
// Sigstore search UI uses query params
if (host.includes('search.sigstore.dev')) {
return `${host}?logIndex=${index}`;
}
// Generic Rekor API endpoint
return `${host}/api/v1/log/entries?logIndex=${index}`;
});
formattedTime = computed(() => {
const time = this.effectiveIntegratedTime();
if (!time) return null;
try {
// Rekor integratedTime is Unix seconds
const date = new Date(time * 1000);
return date.toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
} catch {
return null;
}
});
truncatedLogId = computed(() => {
const id = this.effectiveLogId();
if (!id) return '';
if (id.length <= 16) return id;
return `${id.slice(0, 8)}${id.slice(-4)}`;
});
ariaLabel = computed(() => {
const index = this.effectiveLogIndex();
const verified = this.isVerified() ? 'verified' : '';
return `View Rekor transparency log entry ${index} ${verified}`.trim();
});
}

View File

@@ -0,0 +1,288 @@
/**
* Score Breakdown Component Tests.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-005 - Unit tests for ScoreBreakdownComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ScoreBreakdownComponent } from './score-breakdown.component';
import type { ScoreExplanation, ScoreContribution, ScoreModifier } from '../../core/api/triage-evidence.models';
describe('ScoreBreakdownComponent', () => {
let component: ScoreBreakdownComponent;
let fixture: ComponentFixture<ScoreBreakdownComponent>;
const mockExplanation: ScoreExplanation = {
kind: 'composite',
risk_score: 7.5,
last_seen: '2025-12-20T10:00:00Z',
algorithm_version: 'v2.1.0',
contributions: [
{ factor: 'cvss_base', weight: 0.4, raw_value: 9.0, contribution: 3.6, explanation: 'Critical CVSS score' },
{ factor: 'epss', weight: 0.2, raw_value: 0.5, contribution: 1.0, explanation: 'High exploitation probability' },
{ factor: 'reachability', weight: 0.3, raw_value: 1.0, contribution: 2.9, explanation: 'Reachable from entrypoint' },
],
modifiers: [
{ type: 'vex_override', before: 8.5, after: 7.5, reason: 'VEX: not_affected' },
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ScoreBreakdownComponent],
}).compileComponents();
fixture = TestBed.createComponent(ScoreBreakdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('score display', () => {
it('should display 0.0 when no explanation provided', () => {
expect(component.formattedScore()).toBe('0.0');
});
it('should display the risk score from explanation', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
expect(component.formattedScore()).toBe('7.5');
});
it('should apply critical class for scores >= 9.0', () => {
const criticalExplanation = { ...mockExplanation, risk_score: 9.5 };
fixture.componentRef.setInput('explanation', criticalExplanation);
fixture.detectChanges();
expect(component.scoreClass()).toContain('critical');
});
it('should apply high class for scores >= 7.0', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
expect(component.scoreClass()).toContain('high');
});
it('should apply medium class for scores >= 4.0', () => {
const mediumExplanation = { ...mockExplanation, risk_score: 5.0 };
fixture.componentRef.setInput('explanation', mediumExplanation);
fixture.detectChanges();
expect(component.scoreClass()).toContain('medium');
});
it('should apply low class for scores > 0 and < 4.0', () => {
const lowExplanation = { ...mockExplanation, risk_score: 2.5 };
fixture.componentRef.setInput('explanation', lowExplanation);
fixture.detectChanges();
expect(component.scoreClass()).toContain('low');
});
});
describe('expansion', () => {
it('should not be expanded by default in compact mode', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
expect(component.isExpanded()).toBe(false);
});
it('should toggle expansion on click in compact mode', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
component.toggleExpand();
expect(component.isExpanded()).toBe(true);
component.toggleExpand();
expect(component.isExpanded()).toBe(false);
});
it('should always be expanded in expanded mode', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.detectChanges();
expect(component.isExpanded()).toBe(true);
});
it('should never be expanded in inline mode', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'inline');
fixture.detectChanges();
expect(component.isExpanded()).toBe(false);
component.toggleExpand();
expect(component.isExpanded()).toBe(false);
});
});
describe('contributions', () => {
it('should display contributions when expanded', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const factors = compiled.querySelectorAll('.score-breakdown__factor');
expect(factors.length).toBe(3);
});
it('should format factor names correctly', () => {
expect(component.formatFactorName('cvss_base')).toBe('Cvss Base');
expect(component.formatFactorName('known_exploitation')).toBe('Known Exploitation');
});
it('should format positive contributions with + sign', () => {
const contribution: ScoreContribution = {
factor: 'test',
weight: 0.5,
raw_value: 1.0,
contribution: 2.5,
};
expect(component.formatContribution(contribution)).toBe('+2.50');
});
it('should format negative contributions correctly', () => {
const contribution: ScoreContribution = {
factor: 'test',
weight: 0.5,
raw_value: 1.0,
contribution: -1.5,
};
expect(component.formatContribution(contribution)).toBe('-1.50');
});
it('should apply positive class for positive contributions', () => {
const contribution: ScoreContribution = {
factor: 'test',
weight: 0.5,
raw_value: 1.0,
contribution: 2.5,
};
expect(component.factorValueClass(contribution)).toContain('positive');
});
it('should apply negative class for negative contributions', () => {
const contribution: ScoreContribution = {
factor: 'test',
weight: 0.5,
raw_value: 1.0,
contribution: -1.5,
};
expect(component.factorValueClass(contribution)).toContain('negative');
});
});
describe('modifiers', () => {
it('should display modifiers when expanded', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__modifiers')).toBeTruthy();
});
it('should format modifiers correctly', () => {
const modifier: ScoreModifier = {
type: 'vex_override',
before: 8.5,
after: 7.5,
reason: 'Not affected',
};
expect(component.formatModifier(modifier)).toContain('vex_override');
expect(component.formatModifier(modifier)).toContain('-1.00');
expect(component.formatModifier(modifier)).toContain('Not affected');
});
});
describe('formula', () => {
it('should not show formula by default', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__formula')).toBeNull();
});
it('should show formula when showFormula is true', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.componentRef.setInput('showFormula', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__formula')).toBeTruthy();
});
});
describe('metadata', () => {
it('should not show algorithm version by default', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__metadata')).toBeNull();
});
it('should show algorithm version when showMetadata is true', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('mode', 'expanded');
fixture.componentRef.setInput('showMetadata', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const metadata = compiled.querySelector('.score-breakdown__metadata');
expect(metadata).toBeTruthy();
expect(metadata.textContent).toContain('v2.1.0');
});
});
describe('label visibility', () => {
it('should show label by default', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__label')).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.score-breakdown__label')).toBeNull();
});
});
describe('accessibility', () => {
it('should have aria-expanded attribute', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const container = compiled.querySelector('.score-breakdown');
expect(container.getAttribute('aria-expanded')).toBe('false');
});
it('should have descriptive aria-label on header button', () => {
fixture.componentRef.setInput('explanation', mockExplanation);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const header = compiled.querySelector('.score-breakdown__header');
expect(header.getAttribute('aria-label')).toContain('Risk score');
});
});
});

View File

@@ -0,0 +1,450 @@
/**
* Score Breakdown Component.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-003 - ScoreBreakdown showing additive score contributions
*
* Displays a risk score with an expandable breakdown of contributing factors,
* helping users understand how the final score was calculated.
*/
import { Component, input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { ScoreExplanation, ScoreContribution, ScoreModifier } from '../../core/api/triage-evidence.models';
/**
* Score breakdown display mode.
*/
export type ScoreBreakdownMode = 'compact' | 'expanded' | 'inline';
/**
* Component displaying risk score with expandable factor breakdown.
*
* Shows the final score prominently, with the ability to expand and see
* each contributing factor (CVSS, EPSS, reachability, etc.) and its weight.
*
* @example
* <stella-score-breakdown [explanation]="scoreExplanation" />
* <stella-score-breakdown [explanation]="scoreExplanation" [mode]="'expanded'" />
* <stella-score-breakdown [explanation]="scoreExplanation" [showFormula]="true" />
*/
@Component({
selector: 'stella-score-breakdown',
standalone: true,
imports: [CommonModule],
template: `
<div
class="score-breakdown"
[class]="containerClass()"
[attr.aria-expanded]="isExpanded()"
>
<!-- Compact/Collapsed View -->
<button
class="score-breakdown__header"
[class.score-breakdown__header--expandable]="mode() !== 'inline'"
(click)="toggleExpand()"
[attr.aria-label]="headerAriaLabel()"
[disabled]="mode() === 'inline'"
type="button"
>
<span
class="score-breakdown__score"
[class]="scoreClass()"
>
{{ formattedScore() }}
</span>
@if (showLabel()) {
<span class="score-breakdown__label">Risk Score</span>
}
@if (mode() !== 'inline' && hasContributions()) {
<span class="score-breakdown__chevron" aria-hidden="true">
{{ isExpanded() ? '▲' : '▼' }}
</span>
}
</button>
<!-- Expanded Details -->
@if (isExpanded() && hasContributions()) {
<div class="score-breakdown__details" role="region" aria-label="Score breakdown details">
<!-- Summary formula (optional) -->
@if (showFormula()) {
<div class="score-breakdown__formula" aria-label="Score formula">
{{ formulaSummary() }}
</div>
}
<!-- Contributing factors -->
<ul class="score-breakdown__factors" role="list">
@for (contribution of contributions(); track contribution.factor) {
<li class="score-breakdown__factor">
<span class="score-breakdown__factor-name">{{ formatFactorName(contribution.factor) }}</span>
<span class="score-breakdown__factor-value" [class]="factorValueClass(contribution)">
{{ formatContribution(contribution) }}
</span>
@if (contribution.explanation) {
<span class="score-breakdown__factor-explain">{{ contribution.explanation }}</span>
}
</li>
}
</ul>
<!-- Modifiers (if any) -->
@if (modifiers().length > 0) {
<div class="score-breakdown__modifiers">
<span class="score-breakdown__modifiers-label">Modifiers:</span>
@for (modifier of modifiers(); track modifier.type) {
<span class="score-breakdown__modifier">
{{ formatModifier(modifier) }}
</span>
}
</div>
}
<!-- Algorithm version (optional metadata) -->
@if (showMetadata() && algorithmVersion()) {
<div class="score-breakdown__metadata">
Algorithm: {{ algorithmVersion() }}
</div>
}
</div>
}
</div>
`,
styles: [`
.score-breakdown {
display: inline-block;
}
.score-breakdown--expanded {
display: block;
}
.score-breakdown__header {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid rgba(108, 117, 125, 0.3);
border-radius: 4px;
font-size: 0.875rem;
cursor: default;
transition: background-color 0.15s;
}
.score-breakdown__header--expandable {
cursor: pointer;
&:hover {
background: rgba(108, 117, 125, 0.1);
}
&:focus-visible {
outline: 2px solid #007bff;
outline-offset: 2px;
}
}
.score-breakdown__score {
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.score-breakdown__score--critical {
background: rgba(220, 53, 69, 0.2);
color: #dc3545;
}
.score-breakdown__score--high {
background: rgba(253, 126, 20, 0.2);
color: #fd7e14;
}
.score-breakdown__score--medium {
background: rgba(255, 193, 7, 0.2);
color: #856404;
}
.score-breakdown__score--low {
background: rgba(40, 167, 69, 0.2);
color: #28a745;
}
.score-breakdown__score--none {
background: rgba(108, 117, 125, 0.2);
color: #6c757d;
}
.score-breakdown__label {
font-size: 0.75rem;
color: #6c757d;
}
.score-breakdown__chevron {
font-size: 0.625rem;
color: #6c757d;
}
.score-breakdown__details {
margin-top: 0.5rem;
padding: 0.75rem;
background: rgba(248, 249, 250, 0.8);
border: 1px solid rgba(108, 117, 125, 0.2);
border-radius: 4px;
}
.score-breakdown__formula {
font-family: monospace;
font-size: 0.75rem;
color: #495057;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px dashed rgba(108, 117, 125, 0.3);
}
.score-breakdown__factors {
list-style: none;
margin: 0;
padding: 0;
}
.score-breakdown__factor {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.75rem;
border-bottom: 1px solid rgba(108, 117, 125, 0.1);
&:last-child {
border-bottom: none;
}
}
.score-breakdown__factor-name {
font-weight: 500;
color: #495057;
min-width: 120px;
}
.score-breakdown__factor-value {
font-family: monospace;
font-weight: 600;
}
.score-breakdown__factor-value--positive {
color: #dc3545;
}
.score-breakdown__factor-value--negative {
color: #28a745;
}
.score-breakdown__factor-value--neutral {
color: #6c757d;
}
.score-breakdown__factor-explain {
flex-basis: 100%;
font-size: 0.6875rem;
color: #6c757d;
font-style: italic;
}
.score-breakdown__modifiers {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed rgba(108, 117, 125, 0.3);
font-size: 0.6875rem;
color: #6c757d;
}
.score-breakdown__modifiers-label {
font-weight: 500;
}
.score-breakdown__modifier {
margin-left: 0.5rem;
font-style: italic;
}
.score-breakdown__metadata {
margin-top: 0.5rem;
font-size: 0.625rem;
color: #adb5bd;
}
`]
})
export class ScoreBreakdownComponent {
/**
* Score explanation data from the backend.
*/
readonly explanation = input<ScoreExplanation | undefined>(undefined);
/**
* Display mode: compact (default), expanded, or inline.
*/
readonly mode = input<ScoreBreakdownMode>('compact');
/**
* Whether to show the "Risk Score" label next to the score.
*/
readonly showLabel = input<boolean>(true);
/**
* Whether to show the additive formula summary.
*/
readonly showFormula = input<boolean>(false);
/**
* Whether to show metadata like algorithm version.
*/
readonly showMetadata = input<boolean>(false);
/**
* Internal expansion state.
*/
private readonly _expanded = signal(false);
/**
* Whether the breakdown is expanded.
*/
readonly isExpanded = computed(() => {
if (this.mode() === 'expanded') return true;
if (this.mode() === 'inline') return false;
return this._expanded();
});
/**
* The risk score value.
*/
readonly score = computed(() => this.explanation()?.risk_score ?? 0);
/**
* Formatted score for display.
*/
readonly formattedScore = computed(() => {
const score = this.score();
return score.toFixed(1);
});
/**
* Score contributions.
*/
readonly contributions = computed(() => this.explanation()?.contributions ?? []);
/**
* Score modifiers.
*/
readonly modifiers = computed(() => this.explanation()?.modifiers ?? []);
/**
* Algorithm version.
*/
readonly algorithmVersion = computed(() => this.explanation()?.algorithm_version);
/**
* Whether there are contributions to show.
*/
readonly hasContributions = computed(() => this.contributions().length > 0);
/**
* Container CSS class.
*/
readonly containerClass = computed(() => {
const classes = ['score-breakdown'];
if (this.isExpanded()) {
classes.push('score-breakdown--expanded');
}
return classes.join(' ');
});
/**
* Score severity class for coloring.
*/
readonly scoreClass = computed(() => {
const score = this.score();
if (score >= 9.0) return 'score-breakdown__score score-breakdown__score--critical';
if (score >= 7.0) return 'score-breakdown__score score-breakdown__score--high';
if (score >= 4.0) return 'score-breakdown__score score-breakdown__score--medium';
if (score > 0) return 'score-breakdown__score score-breakdown__score--low';
return 'score-breakdown__score score-breakdown__score--none';
});
/**
* Header aria label.
*/
readonly headerAriaLabel = computed(() => {
const score = this.formattedScore();
const count = this.contributions().length;
if (count > 0) {
return `Risk score ${score}, click to ${this.isExpanded() ? 'collapse' : 'expand'} breakdown of ${count} factors`;
}
return `Risk score ${score}`;
});
/**
* Formula summary string.
*/
readonly formulaSummary = computed(() => {
const contributions = this.contributions();
if (contributions.length === 0) return '';
const parts = contributions.map(c => {
const sign = c.contribution >= 0 ? '+' : '';
return `${sign}${c.contribution.toFixed(2)} (${this.formatFactorName(c.factor)})`;
});
return `= ${parts.join(' ')}${this.formattedScore()}`;
});
/**
* Toggle expansion state.
*/
toggleExpand(): void {
if (this.mode() !== 'inline') {
this._expanded.update(v => !v);
}
}
/**
* Format a factor name for display.
*/
formatFactorName(factor: string): string {
return factor
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
/**
* Format a contribution for display.
*/
formatContribution(contribution: ScoreContribution): string {
const sign = contribution.contribution >= 0 ? '+' : '';
return `${sign}${contribution.contribution.toFixed(2)}`;
}
/**
* Get CSS class for factor value based on positive/negative contribution.
*/
factorValueClass(contribution: ScoreContribution): string {
if (contribution.contribution > 0) {
return 'score-breakdown__factor-value score-breakdown__factor-value--positive';
}
if (contribution.contribution < 0) {
return 'score-breakdown__factor-value score-breakdown__factor-value--negative';
}
return 'score-breakdown__factor-value score-breakdown__factor-value--neutral';
}
/**
* Format a modifier for display.
*/
formatModifier(modifier: ScoreModifier): string {
const delta = modifier.after - modifier.before;
const sign = delta >= 0 ? '+' : '';
return `${modifier.type}: ${sign}${delta.toFixed(2)}${modifier.reason ? ` (${modifier.reason})` : ''}`;
}
}

View File

@@ -0,0 +1,171 @@
/**
* VEX Status Chip Component Tests.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-005 - Unit tests for VexStatusChipComponent
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VexStatusChipComponent } from './vex-status-chip.component';
import type { VexStatus } from '../../core/api/triage-evidence.models';
describe('VexStatusChipComponent', () => {
let component: VexStatusChipComponent;
let fixture: ComponentFixture<VexStatusChipComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VexStatusChipComponent],
}).compileComponents();
fixture = TestBed.createComponent(VexStatusChipComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('status rendering', () => {
it('should display "No VEX" when status is undefined', () => {
expect(component.status()).toBeUndefined();
expect(component.label()).toBe('No VEX');
expect(component.icon()).toBe('?');
});
it('should display affected status correctly', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.detectChanges();
expect(component.label()).toBe('Affected');
expect(component.icon()).toBe('✗');
expect(component.chipClass()).toContain('affected');
});
it('should display not_affected status correctly', () => {
fixture.componentRef.setInput('status', 'not_affected');
fixture.detectChanges();
expect(component.label()).toBe('Not Affected');
expect(component.icon()).toBe('✓');
expect(component.chipClass()).toContain('not_affected');
});
it('should display fixed status correctly', () => {
fixture.componentRef.setInput('status', 'fixed');
fixture.detectChanges();
expect(component.label()).toBe('Fixed');
expect(component.icon()).toBe('🔧');
expect(component.chipClass()).toContain('fixed');
});
it('should display under_investigation status correctly', () => {
fixture.componentRef.setInput('status', 'under_investigation');
fixture.detectChanges();
expect(component.label()).toBe('Investigating');
expect(component.icon()).toBe('🔍');
expect(component.chipClass()).toContain('under_investigation');
});
});
describe('justification', () => {
it('should show info icon when justification is provided', () => {
fixture.componentRef.setInput('status', 'not_affected');
fixture.componentRef.setInput('justification', 'No vulnerable code path');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.vex-chip__info')).toBeTruthy();
});
it('should not show info icon when justification is not provided', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.vex-chip__info')).toBeNull();
});
it('should hide info icon when showJustificationIcon is false', () => {
fixture.componentRef.setInput('status', 'not_affected');
fixture.componentRef.setInput('justification', 'Some justification');
fixture.componentRef.setInput('showJustificationIcon', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.vex-chip__info')).toBeNull();
});
});
describe('tooltip', () => {
it('should include justification in tooltip when provided', () => {
fixture.componentRef.setInput('status', 'not_affected');
fixture.componentRef.setInput('justification', 'Component not used');
fixture.detectChanges();
expect(component.tooltip()).toContain('Justification: Component not used');
});
it('should include impact in tooltip when provided', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.componentRef.setInput('impact', 'Remote code execution');
fixture.detectChanges();
expect(component.tooltip()).toContain('Impact: Remote code execution');
});
it('should use custom tooltip when provided', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.componentRef.setInput('customTooltip', 'Custom VEX info');
fixture.detectChanges();
expect(component.tooltip()).toBe('Custom VEX info');
});
});
describe('label visibility', () => {
it('should show label by default', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.vex-chip__label')).toBeTruthy();
});
it('should hide label when showLabel is false', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.componentRef.setInput('showLabel', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.vex-chip__label')).toBeNull();
});
});
describe('accessibility', () => {
it('should have aria-label', () => {
fixture.componentRef.setInput('status', 'affected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.vex-chip');
expect(chip.getAttribute('aria-label')).toContain('Affected');
});
it('should include justification in aria-label when provided', () => {
fixture.componentRef.setInput('status', 'not_affected');
fixture.componentRef.setInput('justification', 'Not vulnerable');
fixture.detectChanges();
expect(component.ariaLabel()).toContain('Not vulnerable');
});
it('should have role="status"', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.vex-chip');
expect(chip.getAttribute('role')).toBe('status');
});
});
});

View File

@@ -0,0 +1,254 @@
/**
* VEX Status Chip Component.
* Sprint: SPRINT_4100_0002_0001 (Shared UI Components)
* Task: UI-002 - VexStatusChip showing VEX status with appropriate color coding
*
* Displays a compact chip indicating the VEX (Vulnerability Exploitability eXchange)
* status of a vulnerability, with tooltip for justification.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import type { VexStatus } from '../../core/api/triage-evidence.models';
/**
* Compact chip component displaying VEX status.
*
* Color scheme per OpenVEX specification semantics:
* - affected (red): Vulnerability affects this component
* - not_affected (green): Vulnerability does not affect this component
* - fixed (blue): Vulnerability was fixed in this version
* - under_investigation (yellow): Still being evaluated
*
* @example
* <stella-vex-status-chip [status]="'not_affected'" [justification]="'No vulnerable code path'" />
* <stella-vex-status-chip [status]="'affected'" />
* <stella-vex-status-chip [status]="'under_investigation'" [showLabel]="false" />
*/
@Component({
selector: 'stella-vex-status-chip',
standalone: true,
imports: [CommonModule],
template: `
<span
class="vex-chip"
[class]="chipClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
role="status"
>
<span class="vex-chip__icon" aria-hidden="true">{{ icon() }}</span>
@if (showLabel()) {
<span class="vex-chip__label">{{ label() }}</span>
}
@if (justification() && showJustificationIcon()) {
<span class="vex-chip__info" aria-hidden="true"></span>
}
</span>
`,
styles: [`
.vex-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: default;
transition: opacity 0.15s;
&:hover {
opacity: 0.9;
}
}
.vex-chip__icon {
font-size: 0.875rem;
line-height: 1;
}
.vex-chip__label {
text-transform: capitalize;
letter-spacing: 0.02em;
}
.vex-chip__info {
font-size: 0.625rem;
opacity: 0.7;
cursor: help;
}
// Status-specific colors (high contrast for accessibility)
.vex-chip--affected {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
border: 1px solid rgba(220, 53, 69, 0.3);
}
.vex-chip--not_affected {
background: rgba(40, 167, 69, 0.15);
color: #28a745;
border: 1px solid rgba(40, 167, 69, 0.3);
}
.vex-chip--fixed {
background: rgba(0, 123, 255, 0.15);
color: #007bff;
border: 1px solid rgba(0, 123, 255, 0.3);
}
.vex-chip--under_investigation {
background: rgba(255, 193, 7, 0.15);
color: #856404;
border: 1px solid rgba(255, 193, 7, 0.4);
}
.vex-chip--unknown {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
border: 1px solid rgba(108, 117, 125, 0.3);
}
`]
})
export class VexStatusChipComponent {
/**
* VEX status value from OpenVEX specification.
*/
readonly status = input<VexStatus | undefined>(undefined);
/**
* Optional justification text (shown in tooltip).
*/
readonly justification = input<string | undefined>(undefined);
/**
* Optional impact description.
*/
readonly impact = input<string | undefined>(undefined);
/**
* Whether to show the text label (default: true).
*/
readonly showLabel = input<boolean>(true);
/**
* Whether to show info icon when justification is available (default: true).
*/
readonly showJustificationIcon = input<boolean>(true);
/**
* Optional custom tooltip override.
*/
readonly customTooltip = input<string | undefined>(undefined);
/**
* Computed CSS class for status.
*/
readonly chipClass = computed(() => {
const statusValue = this.status();
const cssClass = statusValue ? statusValue.replace(/ /g, '_') : 'unknown';
return `vex-chip vex-chip--${cssClass}`;
});
/**
* Computed icon based on status.
*/
readonly icon = computed(() => {
switch (this.status()) {
case 'affected':
return '✗'; // Cross - vulnerability affects component
case 'not_affected':
return '✓'; // Check - not affected
case 'fixed':
return '🔧'; // Wrench - fixed
case 'under_investigation':
return '🔍'; // Magnifying glass - investigating
default:
return '?'; // Question - unknown/no VEX
}
});
/**
* Computed display label.
*/
readonly label = computed(() => {
switch (this.status()) {
case 'affected':
return 'Affected';
case 'not_affected':
return 'Not Affected';
case 'fixed':
return 'Fixed';
case 'under_investigation':
return 'Investigating';
default:
return 'No VEX';
}
});
/**
* Computed tooltip text.
*/
readonly tooltip = computed(() => {
if (this.customTooltip()) {
return this.customTooltip();
}
const justification = this.justification();
const impact = this.impact();
const parts: string[] = [];
switch (this.status()) {
case 'affected':
parts.push('Vulnerability affects this component');
break;
case 'not_affected':
parts.push('Vulnerability does not affect this component');
break;
case 'fixed':
parts.push('Vulnerability is fixed in this version');
break;
case 'under_investigation':
parts.push('Vulnerability impact is under investigation');
break;
default:
parts.push('No VEX statement available for this vulnerability');
}
if (justification) {
parts.push(`Justification: ${justification}`);
}
if (impact) {
parts.push(`Impact: ${impact}`);
}
return parts.join('\n');
});
/**
* Aria label for screen readers.
*/
readonly ariaLabel = computed(() => {
const status = this.status();
const justification = this.justification();
switch (status) {
case 'affected':
return justification
? `VEX status: Affected. ${justification}`
: 'VEX status: Affected';
case 'not_affected':
return justification
? `VEX status: Not affected. ${justification}`
: 'VEX status: Not affected';
case 'fixed':
return 'VEX status: Fixed';
case 'under_investigation':
return 'VEX status: Under investigation';
default:
return 'VEX status: Unknown';
}
});
}