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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'}`;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">@{{ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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` : '';
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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})` : ''}`;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user