test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -613,6 +613,38 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/configuration-pane/configuration-pane.routes').then((m) => m.CONFIGURATION_PANE_ROUTES),
},
// SBOM Diff View (SPRINT_0127_0001_FE - FE-PERSONA-02)
{
path: 'sbom/diff',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/sbom-diff/sbom-diff.routes').then((m) => m.SBOM_DIFF_ROUTES),
},
// VEX Timeline (SPRINT_0127_0001_FE - FE-PERSONA-03)
{
path: 'vex/timeline',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/vex-timeline/vex-timeline.routes').then((m) => m.VEX_TIMELINE_ROUTES),
},
// Developer Workspace (SPRINT_0127_0001_FE - FE-PERSONA-04)
{
path: 'workspace/dev',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/workspaces/developer/developer-workspace.routes').then(
(m) => m.DEVELOPER_WORKSPACE_ROUTES
),
},
// Auditor Workspace (SPRINT_0127_0001_FE - FE-PERSONA-05)
{
path: 'workspace/audit',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/workspaces/auditor/auditor-workspace.routes').then(
(m) => m.AUDITOR_WORKSPACE_ROUTES
),
},
// ==========================================================================
// LEGACY REDIRECT ROUTES
// Redirects for renamed/consolidated routes to prevent bookmark breakage

View File

@@ -0,0 +1,7 @@
/**
* Registry components barrel export.
*/
export * from './registry-health-card.component';
export * from './registry-capability-matrix.component';
export * from './registry-check-details.component';
export * from './registry-checks-panel.component';

View File

@@ -0,0 +1,199 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryCapabilityMatrixComponent } from './registry-capability-matrix.component';
import { RegistryInstance } from '../../models/registry.models';
describe('RegistryCapabilityMatrixComponent', () => {
let component: RegistryCapabilityMatrixComponent;
let fixture: ComponentFixture<RegistryCapabilityMatrixComponent>;
const mockRegistries: RegistryInstance[] = [
{
id: 'registry-1',
name: 'Harbor Registry',
url: 'https://harbor.example.com',
type: 'harbor',
healthStatus: 'healthy',
overallSeverity: 'pass',
capabilities: [
{ id: 'v2-endpoint', name: 'V2 API', description: 'V2 endpoint', status: 'supported' },
{ id: 'referrers-api', name: 'Referrers', description: 'Referrers API', status: 'supported' },
{ id: 'manifest-delete', name: 'Delete', description: 'Manifest delete', status: 'partial' },
],
checkResults: [],
},
{
id: 'registry-2',
name: 'Generic Registry',
url: 'https://generic.example.com',
type: 'generic-oci',
healthStatus: 'degraded',
overallSeverity: 'warn',
capabilities: [
{ id: 'v2-endpoint', name: 'V2 API', description: 'V2 endpoint', status: 'supported' },
{ id: 'referrers-api', name: 'Referrers', description: 'Referrers API', status: 'unsupported' },
{ id: 'manifest-delete', name: 'Delete', description: 'Manifest delete', status: 'unsupported' },
],
checkResults: [],
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegistryCapabilityMatrixComponent],
}).compileComponents();
fixture = TestBed.createComponent(RegistryCapabilityMatrixComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('with no registries', () => {
beforeEach(() => {
component.registries = [];
fixture.detectChanges();
});
it('should show empty state', () => {
const emptyState = fixture.nativeElement.querySelector('.matrix-empty');
expect(emptyState).toBeTruthy();
});
it('should not show the matrix table', () => {
const table = fixture.nativeElement.querySelector('.matrix-table');
expect(table).toBeFalsy();
});
});
describe('with registries', () => {
beforeEach(() => {
component.registries = mockRegistries;
fixture.detectChanges();
});
it('should display matrix table', () => {
const table = fixture.nativeElement.querySelector('.matrix-table');
expect(table).toBeTruthy();
});
it('should display registry column headers', () => {
const headers = fixture.nativeElement.querySelectorAll('.registry-header');
expect(headers.length).toBe(2);
});
it('should display registry names in headers', () => {
const names = fixture.nativeElement.querySelectorAll('.registry-name');
expect(names[0].textContent).toContain('Harbor Registry');
expect(names[1].textContent).toContain('Generic Registry');
});
it('should display capability rows', () => {
const rows = fixture.nativeElement.querySelectorAll('.capability-row');
expect(rows.length).toBe(component.capabilities.length);
});
it('should display legend', () => {
const legend = fixture.nativeElement.querySelector('.matrix-legend');
expect(legend).toBeTruthy();
});
it('should show supported status cells', () => {
const supportedCells = fixture.nativeElement.querySelectorAll('.status-supported');
expect(supportedCells.length).toBeGreaterThan(0);
});
});
describe('capability expansion', () => {
beforeEach(() => {
component.registries = mockRegistries;
fixture.detectChanges();
});
it('should start with no expanded capabilities', () => {
expect(component.isExpanded('v2-endpoint')).toBe(false);
});
it('should toggle expansion on click', () => {
component.toggleExpand('v2-endpoint');
expect(component.isExpanded('v2-endpoint')).toBe(true);
component.toggleExpand('v2-endpoint');
expect(component.isExpanded('v2-endpoint')).toBe(false);
});
it('should allow multiple expanded capabilities', () => {
component.toggleExpand('v2-endpoint');
component.toggleExpand('referrers-api');
expect(component.isExpanded('v2-endpoint')).toBe(true);
expect(component.isExpanded('referrers-api')).toBe(true);
});
it('should show description when expanded', () => {
component.toggleExpand('v2-endpoint');
fixture.detectChanges();
const description = fixture.nativeElement.querySelector('.capability-description');
expect(description).toBeTruthy();
});
});
describe('capability status display', () => {
beforeEach(() => {
component.registries = mockRegistries;
fixture.detectChanges();
});
it('should return correct status class for supported', () => {
const statusClass = component.getStatusClass(mockRegistries[0], 'v2-endpoint');
expect(statusClass).toBe('status-supported');
});
it('should return correct status class for unsupported', () => {
const statusClass = component.getStatusClass(mockRegistries[1], 'referrers-api');
expect(statusClass).toBe('status-unsupported');
});
it('should return unknown status for missing capability', () => {
const statusClass = component.getStatusClass(mockRegistries[0], 'nonexistent');
expect(statusClass).toBe('status-unknown');
});
it('should return correct status icon for supported', () => {
const icon = component.getStatusIcon(mockRegistries[0], 'v2-endpoint');
expect(icon).toContain('10004'); // checkmark
});
it('should return correct status icon for unsupported', () => {
const icon = component.getStatusIcon(mockRegistries[1], 'referrers-api');
expect(icon).toContain('10008'); // x mark
});
it('should return correct status label', () => {
const label = component.getStatusLabel(mockRegistries[0], 'v2-endpoint');
expect(label).toBe('Supported');
});
});
describe('capabilities list', () => {
it('should have all standard OCI capabilities', () => {
const capabilityIds = component.capabilities.map((c) => c.id);
expect(capabilityIds).toContain('v2-endpoint');
expect(capabilityIds).toContain('referrers-api');
expect(capabilityIds).toContain('manifest-delete');
expect(capabilityIds).toContain('chunked-upload');
expect(capabilityIds).toContain('tag-listing');
});
it('should have descriptions for all capabilities', () => {
for (const cap of component.capabilities) {
expect(cap.description).toBeTruthy();
expect(cap.description.length).toBeGreaterThan(10);
}
});
});
});

View File

@@ -0,0 +1,416 @@
import { CommonModule } from '@angular/common';
import { Component, Input, signal } from '@angular/core';
import {
CapabilityStatus,
getCapabilityStatusDisplay,
OciCapabilityId,
RegistryInstance,
} from '../../models/registry.models';
/**
* Capability definition with display metadata.
*/
interface CapabilityDefinition {
id: OciCapabilityId;
name: string;
description: string;
}
/**
* Registry capability matrix component.
* Displays a table comparing OCI capabilities across multiple registries.
*/
@Component({
standalone: true,
selector: 'st-registry-capability-matrix',
imports: [CommonModule],
template: `
<div class="capability-matrix">
<div class="matrix-header">
<h3>OCI Capability Matrix</h3>
<p class="matrix-subtitle">Compare OCI 1.1 feature support across configured registries</p>
</div>
@if (registries.length === 0) {
<div class="matrix-empty">
<span class="empty-icon">&#128269;</span>
<p>No registries configured. Run Doctor checks to detect registry capabilities.</p>
</div>
} @else {
<div class="matrix-table-wrapper">
<table class="matrix-table">
<thead>
<tr>
<th class="capability-header">Capability</th>
@for (registry of registries; track registry.id) {
<th class="registry-header">
<div class="registry-header-content">
<span class="registry-name">{{ registry.name }}</span>
<span class="registry-type">{{ registry.type }}</span>
</div>
</th>
}
</tr>
</thead>
<tbody>
@for (capability of capabilities; track capability.id) {
<tr
class="capability-row"
[class.expanded]="isExpanded(capability.id)"
(click)="toggleExpand(capability.id)">
<td class="capability-cell">
<div class="capability-info">
<span class="capability-name">{{ capability.name }}</span>
<span class="expand-icon">{{ isExpanded(capability.id) ? '&#9660;' : '&#9654;' }}</span>
</div>
@if (isExpanded(capability.id)) {
<p class="capability-description">{{ capability.description }}</p>
}
</td>
@for (registry of registries; track registry.id) {
<td class="status-cell" [class]="getStatusClass(registry, capability.id)">
<span
class="status-icon"
[innerHTML]="getStatusIcon(registry, capability.id)"
[title]="getStatusLabel(registry, capability.id)">
</span>
</td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="matrix-legend">
<div class="legend-item status-supported">
<span class="legend-icon">&#10004;</span>
<span>Supported</span>
</div>
<div class="legend-item status-partial">
<span class="legend-icon">&#9898;</span>
<span>Partial</span>
</div>
<div class="legend-item status-unsupported">
<span class="legend-icon">&#10008;</span>
<span>Not Supported</span>
</div>
<div class="legend-item status-unknown">
<span class="legend-icon">&#63;</span>
<span>Unknown</span>
</div>
</div>
}
</div>
`,
styles: [
`
.capability-matrix {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.matrix-header {
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
h3 {
margin: 0 0 var(--space-1) 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.matrix-subtitle {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.matrix-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
text-align: center;
.empty-icon {
font-size: 2rem;
margin-bottom: var(--space-2);
opacity: 0.5;
}
p {
margin: 0;
color: var(--color-text-secondary);
}
}
.matrix-table-wrapper {
overflow-x: auto;
}
.matrix-table {
width: 100%;
border-collapse: collapse;
min-width: 600px;
th,
td {
padding: var(--space-3);
text-align: center;
border-bottom: 1px solid var(--color-border-primary);
}
th {
background: var(--color-surface-secondary);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
}
}
.capability-header {
text-align: left !important;
min-width: 200px;
}
.registry-header {
min-width: 120px;
}
.registry-header-content {
display: flex;
flex-direction: column;
gap: var(--space-0-5);
.registry-name {
color: var(--color-text-primary);
}
.registry-type {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-weight: var(--font-weight-normal);
}
}
.capability-row {
cursor: pointer;
transition: background var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-secondary);
}
&.expanded {
background: var(--color-surface-secondary);
}
}
.capability-cell {
text-align: left !important;
vertical-align: top;
}
.capability-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
.capability-name {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.expand-icon {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.capability-description {
margin: var(--space-2) 0 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
line-height: 1.5;
}
.status-cell {
vertical-align: middle;
&.status-supported {
background: var(--color-status-success-bg);
}
&.status-partial {
background: var(--color-status-warning-bg);
}
&.status-unsupported {
background: var(--color-status-error-bg);
}
&.status-unknown {
background: var(--color-surface-secondary);
}
}
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: var(--font-size-sm);
.status-supported & {
color: var(--color-status-success);
}
.status-partial & {
color: var(--color-status-warning);
}
.status-unsupported & {
color: var(--color-status-error);
}
.status-unknown & {
color: var(--color-text-muted);
}
}
.matrix-legend {
display: flex;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: var(--space-1-5);
font-size: var(--font-size-sm);
.legend-icon {
font-size: var(--font-size-xs);
}
&.status-supported {
color: var(--color-status-success);
}
&.status-partial {
color: var(--color-status-warning);
}
&.status-unsupported {
color: var(--color-status-error);
}
&.status-unknown {
color: var(--color-text-muted);
}
}
`,
],
})
export class RegistryCapabilityMatrixComponent {
@Input() registries: RegistryInstance[] = [];
private readonly expandedCapabilities = signal<Set<string>>(new Set());
/**
* Standard OCI capabilities with descriptions.
*/
readonly capabilities: CapabilityDefinition[] = [
{
id: 'v2-endpoint',
name: 'V2 API Endpoint',
description: 'The registry supports the OCI Distribution Spec /v2/ endpoint for API discovery.',
},
{
id: 'push-manifest',
name: 'Push Manifest',
description: 'The registry supports pushing OCI image manifests.',
},
{
id: 'pull-manifest',
name: 'Pull Manifest',
description: 'The registry supports pulling OCI image manifests.',
},
{
id: 'chunked-upload',
name: 'Chunked Upload',
description: 'The registry supports chunked blob uploads for large layers.',
},
{
id: 'cross-repo-mount',
name: 'Cross-Repo Mount',
description: 'The registry supports mounting blobs from other repositories to avoid re-upload.',
},
{
id: 'manifest-delete',
name: 'Manifest Delete',
description: 'The registry supports deleting manifests (images/artifacts).',
},
{
id: 'referrers-api',
name: 'Referrers API',
description: 'OCI 1.1 Referrers API for discovering artifacts that reference a manifest.',
},
{
id: 'tag-listing',
name: 'Tag Listing',
description: 'The registry supports listing tags for a repository.',
},
{
id: 'content-discovery',
name: 'Content Discovery',
description: 'The registry supports catalog API for content discovery.',
},
];
isExpanded(capabilityId: string): boolean {
return this.expandedCapabilities().has(capabilityId);
}
toggleExpand(capabilityId: string): void {
this.expandedCapabilities.update((set) => {
const newSet = new Set(set);
if (newSet.has(capabilityId)) {
newSet.delete(capabilityId);
} else {
newSet.add(capabilityId);
}
return newSet;
});
}
getCapabilityStatus(registry: RegistryInstance, capabilityId: string): CapabilityStatus {
const capability = registry.capabilities.find((c) => c.id === capabilityId);
return capability?.status ?? 'unknown';
}
getStatusClass(registry: RegistryInstance, capabilityId: string): string {
return `status-${this.getCapabilityStatus(registry, capabilityId)}`;
}
getStatusIcon(registry: RegistryInstance, capabilityId: string): string {
return getCapabilityStatusDisplay(this.getCapabilityStatus(registry, capabilityId)).icon;
}
getStatusLabel(registry: RegistryInstance, capabilityId: string): string {
return getCapabilityStatusDisplay(this.getCapabilityStatus(registry, capabilityId)).label;
}
}

View File

@@ -0,0 +1,326 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryCheckDetailsComponent } from './registry-check-details.component';
import { RegistryInstance } from '../../models/registry.models';
describe('RegistryCheckDetailsComponent', () => {
let component: RegistryCheckDetailsComponent;
let fixture: ComponentFixture<RegistryCheckDetailsComponent>;
const mockRegistry: RegistryInstance = {
id: 'test-registry',
name: 'Test Registry',
url: 'https://registry.example.com',
type: 'harbor',
healthStatus: 'degraded',
overallSeverity: 'warn',
capabilities: [
{ id: 'v2-endpoint', name: 'V2 API', description: 'V2 endpoint support', status: 'supported' },
{ id: 'referrers', name: 'Referrers API', description: 'OCI 1.1 referrers', status: 'partial' },
],
checkResults: [
{
checkId: 'integration.registry.v2-endpoint',
name: 'V2 Endpoint Check',
severity: 'pass',
diagnosis: 'V2 endpoint is accessible and responding correctly',
durationMs: 150,
evidence: {
description: 'Registry responded with valid v2 API',
data: {
status_code: '200',
response_time_ms: '150',
docker_distribution_api_version: 'registry/2.0',
},
},
},
{
checkId: 'integration.registry.auth-config',
name: 'Auth Config',
severity: 'warn',
diagnosis: 'Authentication uses basic auth, consider using token auth',
durationMs: 85,
evidence: {
description: 'Authentication configuration analysis',
data: {
auth_type: 'basic',
recommendation: 'Use token-based authentication',
},
},
},
{
checkId: 'integration.registry.tls-cert',
name: 'TLS Certificate',
severity: 'fail',
diagnosis: 'TLS certificate expires in 7 days',
durationMs: 200,
evidence: {
description: 'TLS certificate validity check',
data: {
expires_at: '2026-02-03T00:00:00Z',
days_remaining: '7',
issuer: 'Let\'s Encrypt',
},
},
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegistryCheckDetailsComponent],
}).compileComponents();
fixture = TestBed.createComponent(RegistryCheckDetailsComponent);
component = fixture.componentInstance;
component.registry = mockRegistry;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('header display', () => {
it('should display registry name', () => {
const header = fixture.nativeElement.querySelector('h3');
expect(header.textContent).toContain('Test Registry');
});
it('should display registry type and URL', () => {
const meta = fixture.nativeElement.querySelector('.registry-meta');
expect(meta.textContent).toContain('harbor');
expect(meta.textContent).toContain('registry.example.com');
});
it('should apply correct health class', () => {
expect(component.healthClass).toBe('health-degraded');
});
});
describe('tab navigation', () => {
it('should default to checks tab', () => {
expect(component.activeTab()).toBe('checks');
});
it('should switch to capabilities tab', () => {
component.activeTab.set('capabilities');
fixture.detectChanges();
const capabilitiesList = fixture.nativeElement.querySelector('.capabilities-list');
expect(capabilitiesList).toBeTruthy();
});
it('should display correct tab counts', () => {
const tabs = fixture.nativeElement.querySelectorAll('.tab-btn');
expect(tabs[0].textContent).toContain('3'); // 3 checks
expect(tabs[1].textContent).toContain('2'); // 2 capabilities
});
});
describe('checks tab', () => {
it('should display all check results', () => {
const checkItems = fixture.nativeElement.querySelectorAll('.check-item');
expect(checkItems.length).toBe(3);
});
it('should display check IDs', () => {
const checkIds = fixture.nativeElement.querySelectorAll('.check-id');
expect(checkIds[0].textContent).toContain('integration.registry.v2-endpoint');
});
it('should display diagnoses', () => {
const diagnoses = fixture.nativeElement.querySelectorAll('.check-diagnosis');
expect(diagnoses[0].textContent).toContain('V2 endpoint is accessible');
});
it('should display duration', () => {
const durations = fixture.nativeElement.querySelectorAll('.check-duration');
expect(durations[0].textContent).toContain('150ms');
});
it('should apply severity classes', () => {
const checkItems = fixture.nativeElement.querySelectorAll('.check-item');
expect(checkItems[0].classList).toContain('severity-pass');
expect(checkItems[1].classList).toContain('severity-warn');
expect(checkItems[2].classList).toContain('severity-fail');
});
});
describe('check expansion', () => {
it('should start with no expanded checks', () => {
expect(component.isCheckExpanded('integration.registry.v2-endpoint')).toBe(false);
});
it('should toggle check expansion', () => {
component.toggleCheck('integration.registry.v2-endpoint');
expect(component.isCheckExpanded('integration.registry.v2-endpoint')).toBe(true);
component.toggleCheck('integration.registry.v2-endpoint');
expect(component.isCheckExpanded('integration.registry.v2-endpoint')).toBe(false);
});
it('should show evidence when expanded', () => {
component.toggleCheck('integration.registry.v2-endpoint');
fixture.detectChanges();
const evidence = fixture.nativeElement.querySelector('.check-evidence');
expect(evidence).toBeTruthy();
});
it('should display evidence data table', () => {
component.toggleCheck('integration.registry.v2-endpoint');
fixture.detectChanges();
const evidenceTable = fixture.nativeElement.querySelector('.evidence-table');
expect(evidenceTable).toBeTruthy();
});
});
describe('capabilities tab', () => {
beforeEach(() => {
component.activeTab.set('capabilities');
fixture.detectChanges();
});
it('should display all capabilities', () => {
const capItems = fixture.nativeElement.querySelectorAll('.capability-item');
expect(capItems.length).toBe(2);
});
it('should display capability names', () => {
const names = fixture.nativeElement.querySelectorAll('.capability-name');
expect(names[0].textContent).toContain('V2 API');
});
it('should display capability status', () => {
const statuses = fixture.nativeElement.querySelectorAll('.capability-status');
expect(statuses[0].textContent).toContain('Supported');
});
it('should apply status classes', () => {
const capItems = fixture.nativeElement.querySelectorAll('.capability-item');
expect(capItems[0].classList).toContain('status-supported');
expect(capItems[1].classList).toContain('status-partial');
});
});
describe('close functionality', () => {
it('should emit close event', () => {
const closeSpy = jest.spyOn(component.close, 'emit');
component.onClose();
expect(closeSpy).toHaveBeenCalled();
});
it('should emit close on button click', () => {
const closeSpy = jest.spyOn(component.close, 'emit');
const closeBtn = fixture.nativeElement.querySelector('.close-btn');
closeBtn.click();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('helper methods', () => {
describe('getSeverityIcon', () => {
it('should return checkmark for pass', () => {
expect(component.getSeverityIcon('pass')).toContain('10004');
});
it('should return warning for warn', () => {
expect(component.getSeverityIcon('warn')).toContain('9888');
});
it('should return x for fail', () => {
expect(component.getSeverityIcon('fail')).toContain('10008');
});
it('should return info for info', () => {
expect(component.getSeverityIcon('info')).toContain('8505');
});
it('should return arrow for skip', () => {
expect(component.getSeverityIcon('skip')).toContain('8594');
});
});
describe('getCapabilityIcon', () => {
it('should return checkmark for supported', () => {
expect(component.getCapabilityIcon('supported')).toContain('10004');
});
it('should return circle for partial', () => {
expect(component.getCapabilityIcon('partial')).toContain('9898');
});
it('should return x for unsupported', () => {
expect(component.getCapabilityIcon('unsupported')).toContain('10008');
});
});
describe('formatDuration', () => {
it('should format milliseconds under 1 second', () => {
expect(component.formatDuration(150)).toBe('150ms');
});
it('should format seconds', () => {
expect(component.formatDuration(2500)).toBe('2.50s');
});
});
describe('formatValue', () => {
it('should return short values as-is', () => {
expect(component.formatValue('short value')).toBe('short value');
});
it('should truncate long values', () => {
const longValue = 'a'.repeat(250);
const result = component.formatValue(longValue);
expect(result.length).toBe(203); // 200 + '...'
expect(result.endsWith('...')).toBe(true);
});
});
describe('hasEvidenceData', () => {
it('should return true when evidence has data', () => {
const check = mockRegistry.checkResults[0];
expect(component.hasEvidenceData(check)).toBe(true);
});
it('should return false when evidence is undefined', () => {
const check = { ...mockRegistry.checkResults[0], evidence: undefined };
expect(component.hasEvidenceData(check)).toBe(false);
});
it('should return false when evidence data is empty', () => {
const check = {
...mockRegistry.checkResults[0],
evidence: { description: 'Test', data: {} },
};
expect(component.hasEvidenceData(check)).toBe(false);
});
});
});
describe('empty states', () => {
it('should show empty message when no check results', () => {
component.registry = { ...mockRegistry, checkResults: [] };
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
});
it('should show empty message when no capabilities', () => {
component.registry = { ...mockRegistry, capabilities: [] };
component.activeTab.set('capabilities');
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,568 @@
import { CommonModule, KeyValuePipe } from '@angular/common';
import { Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { DoctorSeverity } from '../../models/doctor.models';
import {
getHealthStatusDisplay,
RegistryCheckSummary,
RegistryInstance,
} from '../../models/registry.models';
/**
* Registry check details component.
* Shows detailed view with evidence for a selected registry's checks.
*/
@Component({
standalone: true,
selector: 'st-registry-check-details',
imports: [CommonModule, KeyValuePipe],
template: `
<div class="check-details-panel">
<div class="panel-header">
<div class="registry-info">
<div class="status-indicator" [class]="healthClass" [innerHTML]="healthIcon"></div>
<div class="info-content">
<h3>{{ registry.name }}</h3>
<p class="registry-meta">{{ registry.type }} - {{ registry.url }}</p>
</div>
</div>
<button class="close-btn" (click)="onClose()" title="Close">
&#10005;
</button>
</div>
<div class="panel-tabs">
<button
class="tab-btn"
[class.active]="activeTab() === 'checks'"
(click)="activeTab.set('checks')">
Checks ({{ registry.checkResults.length }})
</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'capabilities'"
(click)="activeTab.set('capabilities')">
Capabilities ({{ registry.capabilities.length }})
</button>
</div>
<div class="panel-content">
@if (activeTab() === 'checks') {
<div class="checks-list">
@for (check of registry.checkResults; track check.checkId) {
<div
class="check-item"
[class]="getSeverityClass(check.severity)"
[class.expanded]="isCheckExpanded(check.checkId)"
(click)="toggleCheck(check.checkId)">
<div class="check-header">
<span class="severity-icon" [innerHTML]="getSeverityIcon(check.severity)"></span>
<div class="check-info">
<span class="check-id">{{ check.checkId }}</span>
<span class="check-diagnosis">{{ check.diagnosis }}</span>
</div>
<span class="check-duration">{{ formatDuration(check.durationMs) }}</span>
<span class="expand-icon">{{ isCheckExpanded(check.checkId) ? '&#9650;' : '&#9660;' }}</span>
</div>
@if (isCheckExpanded(check.checkId) && check.evidence) {
<div class="check-evidence">
<h4>Evidence</h4>
<p class="evidence-description">{{ check.evidence.description }}</p>
@if (hasEvidenceData(check)) {
<table class="evidence-table">
<tbody>
@for (item of check.evidence.data | keyvalue; track item.key) {
<tr>
<td class="evidence-key">{{ item.key }}</td>
<td class="evidence-value">
<code>{{ formatValue(item.value) }}</code>
</td>
</tr>
}
</tbody>
</table>
}
</div>
}
</div>
}
@if (registry.checkResults.length === 0) {
<div class="empty-state">
<p>No check results available. Run Doctor checks to analyze this registry.</p>
</div>
}
</div>
}
@if (activeTab() === 'capabilities') {
<div class="capabilities-list">
@for (cap of registry.capabilities; track cap.id) {
<div class="capability-item" [class]="'status-' + cap.status">
<div class="capability-header">
<span class="status-icon" [innerHTML]="getCapabilityIcon(cap.status)"></span>
<div class="capability-info">
<span class="capability-name">{{ cap.name }}</span>
<span class="capability-status">{{ cap.status | titlecase }}</span>
</div>
</div>
<p class="capability-description">{{ cap.description }}</p>
</div>
}
@if (registry.capabilities.length === 0) {
<div class="empty-state">
<p>No capability information available.</p>
</div>
}
</div>
}
</div>
</div>
`,
styles: [
`
.check-details-panel {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.registry-info {
display: flex;
align-items: center;
gap: var(--space-3);
}
.status-indicator {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
&.health-healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.health-degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.health-unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.health-unknown {
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.info-content {
h3 {
margin: 0;
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
.registry-meta {
margin: var(--space-0-5) 0 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.close-btn {
background: transparent;
border: none;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
cursor: pointer;
padding: var(--space-2);
border-radius: var(--radius-sm);
&:hover {
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
}
.panel-tabs {
display: flex;
border-bottom: 1px solid var(--color-border-primary);
}
.tab-btn {
flex: 1;
padding: var(--space-3) var(--space-4);
background: transparent;
border: none;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
&.active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.checks-list,
.capabilities-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.check-item {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: var(--color-brand-primary);
}
&.severity-pass {
border-left: 3px solid var(--color-status-success);
}
&.severity-warn {
border-left: 3px solid var(--color-status-warning);
}
&.severity-fail {
border-left: 3px solid var(--color-status-error);
}
&.severity-info {
border-left: 3px solid var(--color-status-info);
}
&.severity-skip {
border-left: 3px solid var(--color-text-muted);
}
}
.check-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
}
.severity-icon {
font-size: var(--font-size-lg);
.severity-pass & {
color: var(--color-status-success);
}
.severity-warn & {
color: var(--color-status-warning);
}
.severity-fail & {
color: var(--color-status-error);
}
.severity-info & {
color: var(--color-status-info);
}
.severity-skip & {
color: var(--color-text-muted);
}
}
.check-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-0-5);
.check-id {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
.check-diagnosis {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.check-duration {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.expand-icon {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.check-evidence {
padding: var(--space-3);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
h4 {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.evidence-description {
margin: 0 0 var(--space-3) 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.evidence-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
tr:not(:last-child) {
border-bottom: 1px solid var(--color-border-primary);
}
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
}
.evidence-key {
width: 35%;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
background: var(--color-surface-secondary);
border-right: 1px solid var(--color-border-primary);
}
.evidence-value {
code {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
word-break: break-all;
}
}
}
.capability-item {
padding: var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
&.status-supported {
border-left: 3px solid var(--color-status-success);
}
&.status-partial {
border-left: 3px solid var(--color-status-warning);
}
&.status-unsupported {
border-left: 3px solid var(--color-status-error);
}
&.status-unknown {
border-left: 3px solid var(--color-text-muted);
}
}
.capability-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
.status-icon {
font-size: var(--font-size-md);
.status-supported & {
color: var(--color-status-success);
}
.status-partial & {
color: var(--color-status-warning);
}
.status-unsupported & {
color: var(--color-status-error);
}
.status-unknown & {
color: var(--color-text-muted);
}
}
}
.capability-info {
display: flex;
align-items: center;
gap: var(--space-2);
.capability-name {
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.capability-status {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
}
}
.capability-description {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.empty-state {
text-align: center;
padding: var(--space-8);
color: var(--color-text-secondary);
p {
margin: 0;
}
}
`,
],
})
export class RegistryCheckDetailsComponent {
@Input({ required: true }) registry!: RegistryInstance;
@Output() close = new EventEmitter<void>();
readonly activeTab = signal<'checks' | 'capabilities'>('checks');
private readonly expandedChecks = signal<Set<string>>(new Set());
get healthClass(): string {
return `health-${this.registry.healthStatus}`;
}
get healthIcon(): string {
return getHealthStatusDisplay(this.registry.healthStatus).icon;
}
isCheckExpanded(checkId: string): boolean {
return this.expandedChecks().has(checkId);
}
toggleCheck(checkId: string): void {
this.expandedChecks.update((set) => {
const newSet = new Set(set);
if (newSet.has(checkId)) {
newSet.delete(checkId);
} else {
newSet.add(checkId);
}
return newSet;
});
}
getSeverityClass(severity: DoctorSeverity): string {
return `severity-${severity}`;
}
getSeverityIcon(severity: DoctorSeverity): string {
switch (severity) {
case 'pass':
return '&#10004;';
case 'info':
return '&#8505;';
case 'warn':
return '&#9888;';
case 'fail':
return '&#10008;';
case 'skip':
return '&#8594;';
default:
return '&#63;';
}
}
getCapabilityIcon(status: string): string {
switch (status) {
case 'supported':
return '&#10004;';
case 'partial':
return '&#9898;';
case 'unsupported':
return '&#10008;';
default:
return '&#63;';
}
}
hasEvidenceData(check: RegistryCheckSummary): boolean {
return check.evidence !== undefined && Object.keys(check.evidence.data).length > 0;
}
formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
formatValue(value: string): string {
if (value.length > 200) {
return value.substring(0, 200) + '...';
}
return value;
}
onClose(): void {
this.close.emit();
}
}

View File

@@ -0,0 +1,350 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { RegistryChecksPanelComponent } from './registry-checks-panel.component';
import { DoctorStore } from '../../services/doctor.store';
import { DoctorReport, CheckResult } from '../../models/doctor.models';
describe('RegistryChecksPanelComponent', () => {
let component: RegistryChecksPanelComponent;
let fixture: ComponentFixture<RegistryChecksPanelComponent>;
let mockStore: Partial<DoctorStore>;
const mockRegistryResults: CheckResult[] = [
{
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'V2 endpoint accessible',
evidence: {
description: 'V2 API test',
data: {
registry_url: 'https://registry1.example.com',
registry_name: 'Production Registry',
status_code: '200',
},
},
durationMs: 150,
executedAt: '2026-01-27T10:00:00Z',
},
{
checkId: 'integration.registry.auth-config',
pluginId: 'integration.registry',
category: 'integration',
severity: 'warn',
diagnosis: 'Basic auth configured',
evidence: {
description: 'Auth configuration check',
data: {
registry_url: 'https://registry1.example.com',
auth_type: 'basic',
},
},
durationMs: 85,
executedAt: '2026-01-27T10:00:01Z',
},
{
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'fail',
diagnosis: 'V2 endpoint unreachable',
evidence: {
description: 'V2 API test',
data: {
registry_url: 'https://registry2.example.com',
error: 'Connection refused',
},
},
durationMs: 3000,
executedAt: '2026-01-27T10:00:02Z',
},
];
const mockReport: DoctorReport = {
runId: 'test-run-1',
status: 'completed',
startedAt: '2026-01-27T10:00:00Z',
completedAt: '2026-01-27T10:01:00Z',
durationMs: 60000,
summary: {
passed: 1,
info: 0,
warnings: 1,
failed: 1,
skipped: 0,
total: 3,
},
overallSeverity: 'fail',
results: mockRegistryResults,
};
beforeEach(async () => {
mockStore = {
report: signal(null),
};
await TestBed.configureTestingModule({
imports: [RegistryChecksPanelComponent],
providers: [{ provide: DoctorStore, useValue: mockStore }],
}).compileComponents();
fixture = TestBed.createComponent(RegistryChecksPanelComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('with no registry results', () => {
beforeEach(() => {
(mockStore.report as any).set(null);
fixture.detectChanges();
});
it('should show empty state', () => {
expect(component.hasRegistryResults()).toBe(false);
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
});
it('should have empty registries array', () => {
expect(component.registries().length).toBe(0);
});
it('should return null health summary', () => {
expect(component.healthSummary()).toBeNull();
});
});
describe('with registry results', () => {
beforeEach(() => {
(mockStore.report as any).set(mockReport);
fixture.detectChanges();
});
it('should extract registries from results', () => {
const registries = component.registries();
expect(registries.length).toBe(2); // Two unique URLs
});
it('should have registry results', () => {
expect(component.hasRegistryResults()).toBe(true);
});
it('should display registry grid', () => {
const grid = fixture.nativeElement.querySelector('.registry-grid');
expect(grid).toBeTruthy();
});
it('should display capability matrix', () => {
const matrix = fixture.nativeElement.querySelector('st-registry-capability-matrix');
expect(matrix).toBeTruthy();
});
describe('health summary', () => {
it('should calculate health summary', () => {
const summary = component.healthSummary();
expect(summary).toBeTruthy();
expect(summary!.totalRegistries).toBe(2);
});
it('should count healthy registries', () => {
const summary = component.healthSummary();
// First registry has warn (degraded), second has fail (unhealthy)
expect(summary!.degradedCount).toBe(1);
expect(summary!.unhealthyCount).toBe(1);
});
});
describe('registry extraction', () => {
it('should group results by registry URL', () => {
const registries = component.registries();
const registry1 = registries.find((r) => r.url === 'https://registry1.example.com');
expect(registry1).toBeTruthy();
expect(registry1!.checkResults.length).toBe(2);
});
it('should extract registry name from evidence', () => {
const registries = component.registries();
const registry1 = registries.find((r) => r.url === 'https://registry1.example.com');
expect(registry1!.name).toBe('Production Registry');
});
it('should calculate overall severity per registry', () => {
const registries = component.registries();
const registry1 = registries.find((r) => r.url === 'https://registry1.example.com');
const registry2 = registries.find((r) => r.url === 'https://registry2.example.com');
expect(registry1!.overallSeverity).toBe('warn'); // has pass and warn
expect(registry2!.overallSeverity).toBe('fail'); // has fail
});
it('should set health status based on severity', () => {
const registries = component.registries();
const registry1 = registries.find((r) => r.url === 'https://registry1.example.com');
const registry2 = registries.find((r) => r.url === 'https://registry2.example.com');
expect(registry1!.healthStatus).toBe('degraded');
expect(registry2!.healthStatus).toBe('unhealthy');
});
});
});
describe('registry selection', () => {
beforeEach(() => {
(mockStore.report as any).set(mockReport);
fixture.detectChanges();
});
it('should start with no selection', () => {
expect(component.selectedRegistry()).toBeNull();
});
it('should select registry', () => {
const registries = component.registries();
component.selectRegistry(registries[0]);
expect(component.selectedRegistry()).toBe(registries[0]);
});
it('should clear selection', () => {
const registries = component.registries();
component.selectRegistry(registries[0]);
component.clearSelection();
expect(component.selectedRegistry()).toBeNull();
});
it('should show details sidebar when selected', () => {
const registries = component.registries();
component.selectRegistry(registries[0]);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector('.details-sidebar');
expect(sidebar).toBeTruthy();
});
it('should add has-details class to content when selected', () => {
const registries = component.registries();
component.selectRegistry(registries[0]);
fixture.detectChanges();
const content = fixture.nativeElement.querySelector('.panel-content');
expect(content.classList).toContain('has-details');
});
});
describe('registry type detection', () => {
it('should detect harbor from server header', () => {
const result: CheckResult = {
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'Test',
evidence: {
description: 'Test',
data: {
registry_url: 'https://test.example.com',
server_header: 'Harbor',
},
},
durationMs: 100,
executedAt: '2026-01-27T10:00:00Z',
};
(mockStore.report as any).set({
...mockReport,
results: [result],
});
fixture.detectChanges();
expect(component.registries()[0].type).toBe('harbor');
});
it('should detect zot from URL', () => {
const result: CheckResult = {
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'Test',
evidence: {
description: 'Test',
data: {
registry_url: 'https://zot.example.com',
},
},
durationMs: 100,
executedAt: '2026-01-27T10:00:00Z',
};
(mockStore.report as any).set({
...mockReport,
results: [result],
});
fixture.detectChanges();
expect(component.registries()[0].type).toBe('zot');
});
it('should default to generic-oci for unknown registries', () => {
const result: CheckResult = {
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'Test',
evidence: {
description: 'Test',
data: {
registry_url: 'https://unknown.example.com',
},
},
durationMs: 100,
executedAt: '2026-01-27T10:00:00Z',
};
(mockStore.report as any).set({
...mockReport,
results: [result],
});
fixture.detectChanges();
expect(component.registries()[0].type).toBe('generic-oci');
});
});
describe('non-registry results filtering', () => {
it('should only include registry checks', () => {
const mixedResults: CheckResult[] = [
...mockRegistryResults,
{
checkId: 'core.database.connection',
pluginId: 'core.database',
category: 'database',
severity: 'pass',
diagnosis: 'Database connected',
evidence: {
description: 'DB test',
data: {},
},
durationMs: 50,
executedAt: '2026-01-27T10:00:00Z',
},
];
(mockStore.report as any).set({
...mockReport,
results: mixedResults,
});
fixture.detectChanges();
// Should still only have 2 registries
expect(component.registries().length).toBe(2);
});
});
});

View File

@@ -0,0 +1,460 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
import { CheckResult } from '../../models/doctor.models';
import {
RegistryInstance,
RegistryHealthSummary,
severityToHealthStatus,
REGISTRY_CHECK_IDS,
} from '../../models/registry.models';
import { DoctorStore } from '../../services/doctor.store';
import { RegistryHealthCardComponent } from './registry-health-card.component';
import { RegistryCapabilityMatrixComponent } from './registry-capability-matrix.component';
import { RegistryCheckDetailsComponent } from './registry-check-details.component';
/**
* Registry checks panel component.
* Main container that integrates registry health cards, capability matrix,
* and detailed check views from Doctor results.
*/
@Component({
standalone: true,
selector: 'st-registry-checks-panel',
imports: [
CommonModule,
RegistryHealthCardComponent,
RegistryCapabilityMatrixComponent,
RegistryCheckDetailsComponent,
],
template: `
<div class="registry-panel">
<header class="panel-header">
<div class="header-content">
<h2>Registry Health</h2>
<p class="subtitle">OCI registry connectivity and capability status</p>
</div>
@if (healthSummary(); as summary) {
<div class="health-summary">
<span class="summary-item healthy">
<span class="count">{{ summary.healthyCount }}</span>
<span class="label">Healthy</span>
</span>
<span class="summary-item degraded">
<span class="count">{{ summary.degradedCount }}</span>
<span class="label">Degraded</span>
</span>
<span class="summary-item unhealthy">
<span class="count">{{ summary.unhealthyCount }}</span>
<span class="label">Unhealthy</span>
</span>
</div>
}
</header>
@if (!hasRegistryResults()) {
<div class="empty-state">
<div class="empty-icon">&#128269;</div>
<h3>No Registry Checks Available</h3>
<p>
Run Doctor diagnostics to analyze registry connectivity and capabilities.
Registry checks include authentication, OCI compliance, and referrers API support.
</p>
</div>
} @else {
<div class="panel-content" [class.has-details]="selectedRegistry() !== null">
<div class="main-content">
<!-- Registry Cards Grid -->
<section class="registries-section">
<h3>Configured Registries</h3>
<div class="registry-grid">
@for (registry of registries(); track registry.id) {
<st-registry-health-card
[registry]="registry"
(select)="selectRegistry($event)" />
}
</div>
</section>
<!-- Capability Matrix -->
@if (registries().length > 0) {
<section class="matrix-section">
<st-registry-capability-matrix [registries]="registries()" />
</section>
}
</div>
<!-- Details Sidebar -->
@if (selectedRegistry(); as selected) {
<aside class="details-sidebar">
<st-registry-check-details
[registry]="selected"
(close)="clearSelection()" />
</aside>
}
</div>
}
</div>
`,
styles: [
`
.registry-panel {
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-6);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-4);
flex-wrap: wrap;
gap: var(--space-3);
.header-content {
h2 {
margin: 0 0 var(--space-1) 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.subtitle {
margin: 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
}
.health-summary {
display: flex;
gap: var(--space-4);
}
.summary-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
min-width: 70px;
.count {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.label {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.025em;
}
&.healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-12) var(--space-8);
text-align: center;
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px dashed var(--color-border-primary);
.empty-icon {
font-size: 3rem;
margin-bottom: var(--space-4);
opacity: 0.5;
}
h3 {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
p {
margin: 0;
max-width: 450px;
color: var(--color-text-secondary);
line-height: 1.6;
}
}
.panel-content {
display: flex;
gap: var(--space-4);
&.has-details {
.main-content {
flex: 1;
min-width: 0;
}
.details-sidebar {
width: 400px;
flex-shrink: 0;
}
}
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.registries-section {
h3 {
margin: 0 0 var(--space-3) 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.registry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-4);
}
.matrix-section {
margin-top: var(--space-2);
}
.details-sidebar {
position: sticky;
top: var(--space-4);
max-height: calc(100vh - var(--space-8));
overflow: hidden;
}
@media (max-width: 1024px) {
.panel-content {
flex-direction: column;
&.has-details {
.details-sidebar {
width: 100%;
position: static;
max-height: none;
}
}
}
}
`,
],
})
export class RegistryChecksPanelComponent {
private readonly store = inject(DoctorStore);
readonly selectedRegistry = signal<RegistryInstance | null>(null);
/**
* Extract registry instances from Doctor results.
*/
readonly registries = computed<RegistryInstance[]>(() => {
const report = this.store.report();
if (!report) return [];
// Filter results for registry checks
const registryResults = report.results.filter((r) =>
r.checkId.startsWith('integration.registry.')
);
if (registryResults.length === 0) return [];
// Group by registry URL (extracted from evidence)
const registryMap = new Map<string, CheckResult[]>();
for (const result of registryResults) {
const url = result.evidence?.data['registry_url'] || 'unknown';
const existing = registryMap.get(url) || [];
existing.push(result);
registryMap.set(url, existing);
}
// Convert to RegistryInstance objects
const instances: RegistryInstance[] = [];
let index = 0;
for (const [url, results] of Array.from(registryMap.entries())) {
const firstResult = results[0];
const registryType = this.detectRegistryType(firstResult);
const overallSeverity = this.calculateOverallSeverity(results);
instances.push({
id: `registry-${index++}`,
name: this.extractRegistryName(url, firstResult),
url,
type: registryType,
healthStatus: severityToHealthStatus(overallSeverity),
overallSeverity,
capabilities: this.extractCapabilities(results),
lastCheckedAt: firstResult.executedAt,
checkResults: results.map((r) => ({
checkId: r.checkId,
name: this.checkIdToName(r.checkId),
severity: r.severity,
diagnosis: r.diagnosis,
durationMs: r.durationMs,
evidence: r.evidence,
})),
});
}
return instances;
});
/**
* Aggregated health summary across all registries.
*/
readonly healthSummary = computed<RegistryHealthSummary | null>(() => {
const regs = this.registries();
if (regs.length === 0) return null;
return {
totalRegistries: regs.length,
healthyCount: regs.filter((r) => r.healthStatus === 'healthy').length,
degradedCount: regs.filter((r) => r.healthStatus === 'degraded').length,
unhealthyCount: regs.filter((r) => r.healthStatus === 'unhealthy').length,
unknownCount: regs.filter((r) => r.healthStatus === 'unknown').length,
lastUpdated: this.store.report()?.completedAt,
};
});
hasRegistryResults(): boolean {
return this.registries().length > 0;
}
selectRegistry(registry: RegistryInstance): void {
this.selectedRegistry.set(registry);
}
clearSelection(): void {
this.selectedRegistry.set(null);
}
private detectRegistryType(result: CheckResult): RegistryInstance['type'] {
const evidence = result.evidence?.data || {};
const serverHeader = evidence['server_header']?.toLowerCase() || '';
const url = evidence['registry_url']?.toLowerCase() || '';
if (serverHeader.includes('harbor') || url.includes('harbor')) return 'harbor';
if (serverHeader.includes('zot') || url.includes('zot')) return 'zot';
if (serverHeader.includes('quay') || url.includes('quay')) return 'quay';
if (serverHeader.includes('artifactory') || url.includes('artifactory')) return 'jfrog';
if (serverHeader.includes('distribution')) return 'distribution';
return 'generic-oci';
}
private extractRegistryName(url: string, result: CheckResult): string {
const evidence = result.evidence?.data || {};
if (evidence['registry_name']) return evidence['registry_name'];
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return url;
}
}
private calculateOverallSeverity(results: CheckResult[]): CheckResult['severity'] {
if (results.some((r) => r.severity === 'fail')) return 'fail';
if (results.some((r) => r.severity === 'warn')) return 'warn';
if (results.some((r) => r.severity === 'info')) return 'info';
if (results.every((r) => r.severity === 'pass')) return 'pass';
return 'skip';
}
private extractCapabilities(results: CheckResult[]): RegistryInstance['capabilities'] {
const capabilities: RegistryInstance['capabilities'] = [];
// Map check IDs to capabilities
const capabilityMap: Record<string, { id: string; name: string; description: string }> = {
[REGISTRY_CHECK_IDS.V2_ENDPOINT]: {
id: 'v2-endpoint',
name: 'V2 API Endpoint',
description: 'OCI Distribution Spec /v2/ endpoint support',
},
[REGISTRY_CHECK_IDS.AUTH_CONFIG]: {
id: 'auth-config',
name: 'Authentication',
description: 'Registry authentication configuration',
},
[REGISTRY_CHECK_IDS.PUSH_PULL]: {
id: 'push-pull',
name: 'Push/Pull',
description: 'Image push and pull operations',
},
[REGISTRY_CHECK_IDS.REFERRERS_API]: {
id: 'referrers-api',
name: 'Referrers API',
description: 'OCI 1.1 Referrers API for artifact discovery',
},
[REGISTRY_CHECK_IDS.TLS_CERT]: {
id: 'tls-cert',
name: 'TLS Certificate',
description: 'TLS certificate validity',
},
};
for (const result of results) {
const capDef = capabilityMap[result.checkId];
if (capDef) {
capabilities.push({
id: capDef.id,
name: capDef.name,
description: capDef.description,
status:
result.severity === 'pass'
? 'supported'
: result.severity === 'warn'
? 'partial'
: result.severity === 'fail'
? 'unsupported'
: 'unknown',
checkId: result.checkId,
evidence: result.evidence,
});
}
}
return capabilities;
}
private checkIdToName(checkId: string): string {
const names: Record<string, string> = {
[REGISTRY_CHECK_IDS.V2_ENDPOINT]: 'V2 Endpoint Check',
[REGISTRY_CHECK_IDS.AUTH_CONFIG]: 'Authentication Config',
[REGISTRY_CHECK_IDS.PUSH_PULL]: 'Push/Pull Operations',
[REGISTRY_CHECK_IDS.REFERRERS_API]: 'Referrers API Support',
[REGISTRY_CHECK_IDS.TLS_CERT]: 'TLS Certificate',
};
return names[checkId] || checkId.split('.').pop() || checkId;
}
}

View File

@@ -0,0 +1,186 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryHealthCardComponent } from './registry-health-card.component';
import { RegistryInstance } from '../../models/registry.models';
describe('RegistryHealthCardComponent', () => {
let component: RegistryHealthCardComponent;
let fixture: ComponentFixture<RegistryHealthCardComponent>;
const mockRegistry: RegistryInstance = {
id: 'test-registry-1',
name: 'Test Registry',
url: 'https://registry.example.com',
type: 'harbor',
healthStatus: 'healthy',
overallSeverity: 'pass',
capabilities: [
{ id: 'v2-endpoint', name: 'V2 API', description: 'Test', status: 'supported' },
{ id: 'referrers', name: 'Referrers', description: 'Test', status: 'partial' },
{ id: 'delete', name: 'Delete', description: 'Test', status: 'unsupported' },
],
lastCheckedAt: '2026-01-27T10:00:00Z',
checkResults: [
{
checkId: 'integration.registry.v2-endpoint',
name: 'V2 Endpoint',
severity: 'pass',
diagnosis: 'V2 endpoint is accessible',
durationMs: 150,
},
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegistryHealthCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(RegistryHealthCardComponent);
component = fixture.componentInstance;
component.registry = mockRegistry;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display registry name', () => {
const nameElement = fixture.nativeElement.querySelector('.registry-name');
expect(nameElement.textContent).toContain('Test Registry');
});
it('should display registry type label', () => {
const typeElement = fixture.nativeElement.querySelector('.registry-type');
expect(typeElement.textContent).toContain('Harbor');
});
it('should display health status', () => {
const badgeElement = fixture.nativeElement.querySelector('.health-badge');
expect(badgeElement.textContent.trim()).toBe('Healthy');
});
it('should display registry URL', () => {
const urlElement = fixture.nativeElement.querySelector('.url-value');
expect(urlElement.textContent).toContain('registry.example.com');
});
it('should count supported capabilities', () => {
expect(component.supportedCount).toBe(1);
});
it('should count partial capabilities', () => {
expect(component.partialCount).toBe(1);
});
it('should count unsupported capabilities', () => {
expect(component.unsupportedCount).toBe(1);
});
it('should apply healthy CSS class', () => {
const card = fixture.nativeElement.querySelector('.registry-card');
expect(card.classList).toContain('health-healthy');
});
it('should emit select event on click', () => {
const selectSpy = jest.spyOn(component.select, 'emit');
const card = fixture.nativeElement.querySelector('.registry-card');
card.click();
expect(selectSpy).toHaveBeenCalledWith(mockRegistry);
});
it('should display last checked time', () => {
const lastChecked = fixture.nativeElement.querySelector('.last-checked');
expect(lastChecked).toBeTruthy();
});
describe('health status variations', () => {
it('should show degraded status for warning severity', () => {
component.registry = { ...mockRegistry, healthStatus: 'degraded', overallSeverity: 'warn' };
fixture.detectChanges();
expect(component.healthClass).toBe('health-degraded');
expect(component.healthLabel).toBe('Degraded');
});
it('should show unhealthy status for fail severity', () => {
component.registry = { ...mockRegistry, healthStatus: 'unhealthy', overallSeverity: 'fail' };
fixture.detectChanges();
expect(component.healthClass).toBe('health-unhealthy');
expect(component.healthLabel).toBe('Unhealthy');
});
it('should show unknown status', () => {
component.registry = { ...mockRegistry, healthStatus: 'unknown', overallSeverity: 'skip' };
fixture.detectChanges();
expect(component.healthClass).toBe('health-unknown');
expect(component.healthLabel).toBe('Unknown');
});
});
describe('registry type labels', () => {
it('should display Zot for zot type', () => {
component.registry = { ...mockRegistry, type: 'zot' };
fixture.detectChanges();
expect(component.registryTypeLabel).toBe('Zot');
});
it('should display JFrog Artifactory for jfrog type', () => {
component.registry = { ...mockRegistry, type: 'jfrog' };
fixture.detectChanges();
expect(component.registryTypeLabel).toBe('JFrog Artifactory');
});
it('should display Generic OCI for generic-oci type', () => {
component.registry = { ...mockRegistry, type: 'generic-oci' };
fixture.detectChanges();
expect(component.registryTypeLabel).toBe('Generic OCI');
});
});
describe('failed checks indicator', () => {
it('should show failed checks count when present', () => {
component.registry = {
...mockRegistry,
checkResults: [
{ checkId: 'test1', name: 'Test', severity: 'fail', diagnosis: 'Failed', durationMs: 100 },
{ checkId: 'test2', name: 'Test', severity: 'fail', diagnosis: 'Failed', durationMs: 100 },
],
};
fixture.detectChanges();
expect(component.failedChecks).toBe(2);
});
it('should not show failed badge when no failures', () => {
component.registry = {
...mockRegistry,
checkResults: [
{ checkId: 'test1', name: 'Test', severity: 'pass', diagnosis: 'Passed', durationMs: 100 },
],
};
fixture.detectChanges();
const failedBadge = fixture.nativeElement.querySelector('.failed-badge');
expect(failedBadge).toBeFalsy();
});
});
describe('formatTime', () => {
it('should format valid ISO date string', () => {
const result = component.formatTime('2026-01-27T10:30:00Z');
expect(result).toBeTruthy();
expect(typeof result).toBe('string');
});
it('should return original string for invalid date', () => {
const result = component.formatTime('invalid-date');
expect(result).toBe('invalid-date');
});
});
});

View File

@@ -0,0 +1,335 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import {
getHealthStatusDisplay,
RegistryHealthStatus,
RegistryInstance,
RegistryType,
} from '../../models/registry.models';
/**
* Registry health card component.
* Displays a summary card with traffic light indicator for a single registry.
*/
@Component({
standalone: true,
selector: 'st-registry-health-card',
imports: [CommonModule],
template: `
<article class="registry-card" [class]="healthClass" (click)="onSelect()">
<div class="card-header">
<div class="status-indicator" [class]="healthClass" [innerHTML]="statusIcon"></div>
<div class="registry-info">
<h3 class="registry-name">{{ registry.name }}</h3>
<span class="registry-type">{{ registryTypeLabel }}</span>
</div>
<div class="health-badge" [class]="healthClass">
{{ healthLabel }}
</div>
</div>
<div class="card-body">
<div class="registry-url">
<span class="url-label">URL:</span>
<code class="url-value">{{ registry.url }}</code>
</div>
<div class="capability-summary">
<span class="capability-count supported">
<span class="count-icon">&#10004;</span>
{{ supportedCount }}
</span>
<span class="capability-count partial">
<span class="count-icon">&#9898;</span>
{{ partialCount }}
</span>
<span class="capability-count unsupported">
<span class="count-icon">&#10008;</span>
{{ unsupportedCount }}
</span>
</div>
@if (registry.checkResults.length > 0) {
<div class="check-summary">
<span class="check-count">{{ registry.checkResults.length }} checks run</span>
@if (failedChecks > 0) {
<span class="failed-badge">{{ failedChecks }} failed</span>
}
</div>
}
</div>
@if (registry.lastCheckedAt) {
<div class="card-footer">
<span class="last-checked">Last checked: {{ formatTime(registry.lastCheckedAt) }}</span>
</div>
}
</article>
`,
styles: [
`
.registry-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
cursor: pointer;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: var(--color-brand-primary);
box-shadow: 0 2px 8px var(--color-shadow-light);
}
&.health-healthy {
border-left: 4px solid var(--color-status-success);
}
&.health-degraded {
border-left: 4px solid var(--color-status-warning);
}
&.health-unhealthy {
border-left: 4px solid var(--color-status-error);
}
&.health-unknown {
border-left: 4px solid var(--color-text-muted);
}
}
.card-header {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.status-indicator {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-lg);
&.health-healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.health-degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.health-unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.health-unknown {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
}
.registry-info {
flex: 1;
.registry-name {
margin: 0;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.registry-type {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.health-badge {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
&.health-healthy {
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.health-degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.health-unhealthy {
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.health-unknown {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
}
.card-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.registry-url {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-sm);
.url-label {
color: var(--color-text-secondary);
}
.url-value {
font-family: var(--font-family-mono);
color: var(--color-text-primary);
background: var(--color-surface-secondary);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
word-break: break-all;
}
}
.capability-summary {
display: flex;
gap: var(--space-3);
margin-top: var(--space-1);
}
.capability-count {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
&.supported {
color: var(--color-status-success);
}
&.partial {
color: var(--color-status-warning);
}
&.unsupported {
color: var(--color-status-error);
}
.count-icon {
font-size: var(--font-size-xs);
}
}
.check-summary {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-1);
font-size: var(--font-size-sm);
.check-count {
color: var(--color-text-secondary);
}
.failed-badge {
background: var(--color-status-error-bg);
color: var(--color-status-error);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
}
.card-footer {
margin-top: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border-primary);
.last-checked {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
`,
],
})
export class RegistryHealthCardComponent {
@Input({ required: true }) registry!: RegistryInstance;
@Output() select = new EventEmitter<RegistryInstance>();
get healthStatus(): RegistryHealthStatus {
return this.registry.healthStatus;
}
get healthClass(): string {
return `health-${this.healthStatus}`;
}
get statusIcon(): string {
return getHealthStatusDisplay(this.healthStatus).icon;
}
get healthLabel(): string {
return getHealthStatusDisplay(this.healthStatus).label;
}
get registryTypeLabel(): string {
const labels: Record<RegistryType, string> = {
'generic-oci': 'Generic OCI',
zot: 'Zot',
distribution: 'Distribution',
harbor: 'Harbor',
quay: 'Quay',
jfrog: 'JFrog Artifactory',
custom: 'Custom',
};
return labels[this.registry.type] || this.registry.type;
}
get supportedCount(): number {
return this.registry.capabilities.filter((c) => c.status === 'supported').length;
}
get partialCount(): number {
return this.registry.capabilities.filter((c) => c.status === 'partial').length;
}
get unsupportedCount(): number {
return this.registry.capabilities.filter((c) => c.status === 'unsupported').length;
}
get failedChecks(): number {
return this.registry.checkResults.filter((r) => r.severity === 'fail').length;
}
onSelect(): void {
this.select.emit(this.registry);
}
formatTime(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleString();
} catch {
return isoString;
}
}
}

View File

@@ -1,5 +1,6 @@
// Models
export * from './models/doctor.models';
export * from './models/registry.models';
// Services
export * from './services/doctor.client';
@@ -14,5 +15,8 @@ export * from './components/remediation-panel/remediation-panel.component';
export * from './components/evidence-viewer/evidence-viewer.component';
export * from './components/export-dialog/export-dialog.component';
// Registry Components
export * from './components/registry';
// Routes
export * from './doctor.routes';

View File

@@ -0,0 +1,167 @@
/**
* Registry-specific Doctor check models.
* Used by registry health and capability components.
*/
import { DoctorSeverity, Evidence } from './doctor.models';
/**
* Registry capability status for the compatibility matrix.
*/
export type CapabilityStatus = 'supported' | 'unsupported' | 'partial' | 'unknown';
/**
* Registry health status based on Doctor check results.
*/
export type RegistryHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
/**
* Registry type identifiers.
*/
export type RegistryType = 'generic-oci' | 'zot' | 'distribution' | 'harbor' | 'quay' | 'jfrog' | 'custom';
/**
* Registry capability definition.
*/
export interface RegistryCapability {
id: string;
name: string;
description: string;
status: CapabilityStatus;
checkId?: string;
evidence?: Evidence;
}
/**
* Standard OCI capabilities to check across registries.
*/
export const OCI_CAPABILITIES = [
'v2-endpoint',
'push-manifest',
'pull-manifest',
'chunked-upload',
'cross-repo-mount',
'manifest-delete',
'referrers-api',
'tag-listing',
'content-discovery',
] as const;
export type OciCapabilityId = (typeof OCI_CAPABILITIES)[number];
/**
* Registry instance with health and capability information.
*/
export interface RegistryInstance {
id: string;
name: string;
url: string;
type: RegistryType;
healthStatus: RegistryHealthStatus;
overallSeverity: DoctorSeverity;
capabilities: RegistryCapability[];
lastCheckedAt?: string;
checkResults: RegistryCheckSummary[];
}
/**
* Summary of a registry check result.
*/
export interface RegistryCheckSummary {
checkId: string;
name: string;
severity: DoctorSeverity;
diagnosis: string;
durationMs: number;
evidence?: Evidence;
}
/**
* Aggregated registry health summary for dashboard display.
*/
export interface RegistryHealthSummary {
totalRegistries: number;
healthyCount: number;
degradedCount: number;
unhealthyCount: number;
unknownCount: number;
lastUpdated?: string;
}
/**
* Capability matrix row for cross-registry comparison.
*/
export interface CapabilityMatrixRow {
capabilityId: OciCapabilityId;
capabilityName: string;
description: string;
registryStatuses: Map<string, CapabilityStatus>;
}
/**
* Maps severity to health status.
*/
export function severityToHealthStatus(severity: DoctorSeverity): RegistryHealthStatus {
switch (severity) {
case 'pass':
return 'healthy';
case 'info':
case 'warn':
return 'degraded';
case 'fail':
return 'unhealthy';
default:
return 'unknown';
}
}
/**
* Maps capability status to display properties.
*/
export function getCapabilityStatusDisplay(status: CapabilityStatus): {
icon: string;
label: string;
cssClass: string;
} {
switch (status) {
case 'supported':
return { icon: '&#10004;', label: 'Supported', cssClass: 'status-supported' };
case 'unsupported':
return { icon: '&#10008;', label: 'Not Supported', cssClass: 'status-unsupported' };
case 'partial':
return { icon: '&#9898;', label: 'Partial', cssClass: 'status-partial' };
default:
return { icon: '&#63;', label: 'Unknown', cssClass: 'status-unknown' };
}
}
/**
* Maps health status to display properties.
*/
export function getHealthStatusDisplay(status: RegistryHealthStatus): {
icon: string;
label: string;
cssClass: string;
} {
switch (status) {
case 'healthy':
return { icon: '&#10004;', label: 'Healthy', cssClass: 'health-healthy' };
case 'degraded':
return { icon: '&#9888;', label: 'Degraded', cssClass: 'health-degraded' };
case 'unhealthy':
return { icon: '&#10008;', label: 'Unhealthy', cssClass: 'health-unhealthy' };
default:
return { icon: '&#63;', label: 'Unknown', cssClass: 'health-unknown' };
}
}
/**
* Registry check IDs used by the Doctor Integration plugin.
*/
export const REGISTRY_CHECK_IDS = {
V2_ENDPOINT: 'integration.registry.v2-endpoint',
AUTH_CONFIG: 'integration.registry.auth-config',
PUSH_PULL: 'integration.registry.push-pull',
REFERRERS_API: 'integration.registry.referrers-api',
TLS_CERT: 'integration.registry.tls-cert',
} as const;

View File

@@ -0,0 +1,450 @@
/**
* Evidence Ribbon Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of, delay } from 'rxjs';
import { EvidenceRibbonComponent, EvidencePillClickEvent } from './evidence-ribbon.component';
import { EvidenceRibbonService } from '../../services/evidence-ribbon.service';
import {
EvidenceRibbonState,
DsseEvidenceStatus,
RekorEvidenceStatus,
SbomEvidenceStatus,
} from '../../models/evidence-ribbon.models';
// Test host component
@Component({
standalone: true,
imports: [EvidenceRibbonComponent],
template: `
<stella-evidence-ribbon
[artifactDigest]="artifactDigest"
[showVex]="showVex"
[showPolicy]="showPolicy"
(pillClick)="onPillClick($event)"
/>
`,
})
class TestHostComponent {
artifactDigest = 'sha256:abc123def456';
showVex = false;
showPolicy = false;
lastPillClick: EvidencePillClickEvent | null = null;
onPillClick(event: EvidencePillClickEvent): void {
this.lastPillClick = event;
}
}
describe('EvidenceRibbonComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let mockService: jasmine.SpyObj<EvidenceRibbonService>;
const createMockState = (overrides: Partial<EvidenceRibbonState> = {}): EvidenceRibbonState => ({
artifactDigest: 'sha256:abc123def456',
dsse: {
status: 'success',
signatureValid: true,
signerIdentity: 'build@example.com',
keyId: 'keyid:abc123',
algorithm: 'ecdsa-p256',
trusted: true,
verifiedAt: '2026-01-27T10:00:00Z',
},
rekor: {
status: 'success',
included: true,
logIndex: 12345678,
logId: 'c0d23d6a',
uuid: 'uuid-12345',
tileId: 'tile-2026-01-27',
integratedTime: '2026-01-27T10:00:00Z',
proofValid: true,
},
sbom: {
status: 'success',
format: 'CycloneDX',
formatVersion: '1.5',
coveragePercent: 98,
componentCount: 150,
directDependencies: 25,
transitiveDependencies: 125,
generatedAt: '2026-01-27T09:00:00Z',
},
loading: false,
lastUpdated: '2026-01-27T10:00:00Z',
...overrides,
});
beforeEach(async () => {
mockService = jasmine.createSpyObj('EvidenceRibbonService', [
'loadEvidenceStatus',
'clear',
], {
loading: signal(false),
dsseStatus: signal<DsseEvidenceStatus | null>(null),
rekorStatus: signal<RekorEvidenceStatus | null>(null),
sbomStatus: signal<SbomEvidenceStatus | null>(null),
vexStatus: signal(null),
policyStatus: signal(null),
});
await TestBed.configureTestingModule({
imports: [TestHostComponent, EvidenceRibbonComponent],
providers: [
{ provide: EvidenceRibbonService, useValue: mockService },
],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
});
describe('initialization', () => {
it('should create the component', () => {
const state = createMockState();
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
const ribbon = fixture.debugElement.query(By.directive(EvidenceRibbonComponent));
expect(ribbon).toBeTruthy();
});
it('should call loadEvidenceStatus on init', () => {
const state = createMockState();
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
expect(mockService.loadEvidenceStatus).toHaveBeenCalledWith('sha256:abc123def456');
});
it('should call clear on destroy', () => {
const state = createMockState();
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
fixture.destroy();
expect(mockService.clear).toHaveBeenCalled();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
(mockService.loading as any).set(true);
mockService.loadEvidenceStatus.and.returnValue(of(createMockState()).pipe(delay(1000)));
fixture.detectChanges();
const spinner = fixture.debugElement.query(By.css('.evidence-ribbon__spinner'));
expect(spinner).toBeTruthy();
});
it('should hide loading spinner when loaded', fakeAsync(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
tick();
const spinner = fixture.debugElement.query(By.css('.evidence-ribbon__spinner'));
expect(spinner).toBeFalsy();
}));
});
describe('pill rendering', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
});
it('should render DSSE pill with success state', fakeAsync(() => {
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
expect(dssePill).toBeTruthy();
expect(dssePill.nativeElement.classList).toContain('evidence-pill--success');
expect(dssePill.nativeElement.textContent).toContain('DSSE');
}));
it('should render Rekor pill with tile ID', fakeAsync(() => {
fixture.detectChanges();
tick();
const rekorPill = fixture.debugElement.query(By.css('.evidence-pill--rekor'));
expect(rekorPill).toBeTruthy();
expect(rekorPill.nativeElement.textContent).toContain('Rekor');
expect(rekorPill.nativeElement.textContent).toContain('tile-2026-01-27');
}));
it('should render SBOM pill with format and coverage', fakeAsync(() => {
fixture.detectChanges();
tick();
const sbomPill = fixture.debugElement.query(By.css('.evidence-pill--sbom'));
expect(sbomPill).toBeTruthy();
expect(sbomPill.nativeElement.textContent).toContain('CycloneDX');
expect(sbomPill.nativeElement.textContent).toContain('98%');
}));
});
describe('pill status variants', () => {
it('should render warning state correctly', fakeAsync(() => {
const state = createMockState({
dsse: {
status: 'warning',
signatureValid: true,
trusted: false,
},
});
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
expect(dssePill.nativeElement.classList).toContain('evidence-pill--warning');
}));
it('should render error state correctly', fakeAsync(() => {
const state = createMockState({
dsse: {
status: 'error',
signatureValid: false,
trusted: false,
errorMessage: 'Invalid signature',
},
});
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
expect(dssePill.nativeElement.classList).toContain('evidence-pill--error');
}));
it('should render unknown state correctly', fakeAsync(() => {
const state = createMockState({
rekor: {
status: 'unknown',
included: false,
},
});
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
fixture.detectChanges();
tick();
const rekorPill = fixture.debugElement.query(By.css('.evidence-pill--rekor'));
expect(rekorPill.nativeElement.classList).toContain('evidence-pill--unknown');
}));
});
describe('pill click events', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
});
it('should emit pillClick event when DSSE pill is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
dssePill.nativeElement.click();
expect(host.lastPillClick).toEqual({
type: 'dsse',
status: 'success',
});
}));
it('should emit pillClick event when Rekor pill is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const rekorPill = fixture.debugElement.query(By.css('.evidence-pill--rekor'));
rekorPill.nativeElement.click();
expect(host.lastPillClick).toEqual({
type: 'rekor',
status: 'success',
});
}));
it('should emit pillClick event when SBOM pill is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const sbomPill = fixture.debugElement.query(By.css('.evidence-pill--sbom'));
sbomPill.nativeElement.click();
expect(host.lastPillClick).toEqual({
type: 'sbom',
status: 'success',
});
}));
});
describe('accessibility', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
mockService.loadEvidenceStatus.and.returnValue(of(state));
});
it('should have aria-label on ribbon', fakeAsync(() => {
fixture.detectChanges();
tick();
const ribbon = fixture.debugElement.query(By.css('.evidence-ribbon'));
expect(ribbon.nativeElement.getAttribute('aria-label')).toContain('sha256:abc123def456');
}));
it('should have aria-label on each pill', fakeAsync(() => {
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
expect(dssePill.nativeElement.getAttribute('aria-label')).toContain('verified');
const rekorPill = fixture.debugElement.query(By.css('.evidence-pill--rekor'));
expect(rekorPill.nativeElement.getAttribute('aria-label')).toContain('Rekor');
const sbomPill = fixture.debugElement.query(By.css('.evidence-pill--sbom'));
expect(sbomPill.nativeElement.getAttribute('aria-label')).toContain('SBOM');
}));
it('should have title attributes for tooltips', fakeAsync(() => {
fixture.detectChanges();
tick();
const dssePill = fixture.debugElement.query(By.css('.evidence-pill--dsse'));
expect(dssePill.nativeElement.getAttribute('title')).toBeTruthy();
expect(dssePill.nativeElement.getAttribute('title')).toContain('build@example.com');
}));
it('should indicate loading state with aria-busy', fakeAsync(() => {
(mockService.loading as any).set(true);
mockService.loadEvidenceStatus.and.returnValue(of(createMockState()).pipe(delay(1000)));
fixture.detectChanges();
const ribbon = fixture.debugElement.query(By.css('.evidence-ribbon'));
expect(ribbon.nativeElement.getAttribute('aria-busy')).toBe('true');
}));
});
describe('optional pills', () => {
it('should not show VEX pill when showVex is false', fakeAsync(() => {
const state = createMockState({
vex: {
status: 'success',
statementCount: 5,
notAffectedCount: 3,
conflictCount: 0,
confidence: 'high',
},
});
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
(mockService.vexStatus as any).set(state.vex);
mockService.loadEvidenceStatus.and.returnValue(of(state));
host.showVex = false;
fixture.detectChanges();
tick();
const vexPill = fixture.debugElement.query(By.css('.evidence-pill--vex'));
expect(vexPill).toBeFalsy();
}));
it('should show VEX pill when showVex is true', fakeAsync(() => {
const state = createMockState({
vex: {
status: 'success',
statementCount: 5,
notAffectedCount: 3,
conflictCount: 0,
confidence: 'high',
},
});
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(state.dsse);
(mockService.rekorStatus as any).set(state.rekor);
(mockService.sbomStatus as any).set(state.sbom);
(mockService.vexStatus as any).set(state.vex);
mockService.loadEvidenceStatus.and.returnValue(of(state));
host.showVex = true;
fixture.detectChanges();
tick();
const vexPill = fixture.debugElement.query(By.css('.evidence-pill--vex'));
expect(vexPill).toBeTruthy();
expect(vexPill.nativeElement.textContent).toContain('VEX');
expect(vexPill.nativeElement.textContent).toContain('5');
}));
});
describe('empty state', () => {
it('should show empty message when no evidence is available', fakeAsync(() => {
(mockService.loading as any).set(false);
(mockService.dsseStatus as any).set(null);
(mockService.rekorStatus as any).set(null);
(mockService.sbomStatus as any).set(null);
mockService.loadEvidenceStatus.and.returnValue(of(createMockState({
dsse: undefined,
rekor: undefined,
sbom: undefined,
})));
fixture.detectChanges();
tick();
const empty = fixture.debugElement.query(By.css('.evidence-ribbon__empty'));
expect(empty).toBeTruthy();
expect(empty.nativeElement.textContent).toContain('No evidence available');
}));
});
});

View File

@@ -0,0 +1,593 @@
/**
* Evidence Ribbon Component
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*
* Horizontal ribbon displaying compact evidence status pills for DSSE, Rekor, and SBOM.
* Clicking a pill opens the evidence drawer with detailed information.
*/
import {
Component,
input,
output,
computed,
signal,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import {
EvidenceRibbonState,
EvidencePillType,
EvidencePillStatus,
DsseEvidenceStatus,
RekorEvidenceStatus,
SbomEvidenceStatus,
VexEvidenceStatus,
PolicyEvidenceStatus,
getPillStatusIcon,
formatCoverage,
} from '../../models/evidence-ribbon.models';
import { EvidenceRibbonService } from '../../services/evidence-ribbon.service';
/**
* Event emitted when a pill is clicked.
*/
export interface EvidencePillClickEvent {
readonly type: EvidencePillType;
readonly status: EvidencePillStatus;
}
/**
* Evidence Ribbon Component.
*
* Displays a horizontal row of evidence status pills showing:
* - DSSE attestation verification status
* - Rekor transparency log inclusion
* - SBOM format and coverage percentage
* - Optional: VEX statement summary
* - Optional: Policy evaluation status
*
* @example
* ```html
* <stella-evidence-ribbon
* [artifactDigest]="artifact.digest"
* [showVex]="true"
* [showPolicy]="true"
* (pillClick)="openEvidenceDrawer($event)"
* />
* ```
*/
@Component({
selector: 'stella-evidence-ribbon',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="evidence-ribbon"
role="status"
[attr.aria-label]="'Evidence status for artifact ' + artifactDigest()"
[attr.aria-busy]="loading()"
>
@if (loading()) {
<div class="evidence-ribbon__loading">
<span class="evidence-ribbon__spinner" aria-hidden="true"></span>
<span class="visually-hidden">Loading evidence status...</span>
</div>
} @else {
<!-- DSSE Pill -->
@if (dsseStatus(); as dsse) {
<button
type="button"
class="evidence-pill"
[class]="getPillClasses('dsse', dsse.status)"
(click)="onPillClick('dsse', dsse.status)"
(keydown.enter)="onPillClick('dsse', dsse.status)"
[attr.aria-label]="getDsseAriaLabel(dsse)"
[attr.title]="getDsseTooltip(dsse)"
>
<span class="evidence-pill__icon" aria-hidden="true">
{{ getPillStatusIcon(dsse.status) }}
</span>
<span class="evidence-pill__label">DSSE</span>
</button>
}
<!-- Rekor Pill -->
@if (rekorStatus(); as rekor) {
<button
type="button"
class="evidence-pill"
[class]="getPillClasses('rekor', rekor.status)"
(click)="onPillClick('rekor', rekor.status)"
(keydown.enter)="onPillClick('rekor', rekor.status)"
[attr.aria-label]="getRekorAriaLabel(rekor)"
[attr.title]="getRekorTooltip(rekor)"
>
<span class="evidence-pill__icon" aria-hidden="true">
{{ getPillStatusIcon(rekor.status) }}
</span>
<span class="evidence-pill__label">
Rekor{{ rekor.tileId ? ': ' + rekor.tileId : '' }}
</span>
</button>
}
<!-- SBOM Pill -->
@if (sbomStatus(); as sbom) {
<button
type="button"
class="evidence-pill"
[class]="getPillClasses('sbom', sbom.status)"
(click)="onPillClick('sbom', sbom.status)"
(keydown.enter)="onPillClick('sbom', sbom.status)"
[attr.aria-label]="getSbomAriaLabel(sbom)"
[attr.title]="getSbomTooltip(sbom)"
>
<span class="evidence-pill__icon" aria-hidden="true">
{{ getPillStatusIcon(sbom.status) }}
</span>
<span class="evidence-pill__label">
{{ sbom.format }}{{ sbom.coveragePercent !== undefined ? ' ' + formatCoverage(sbom.coveragePercent) : '' }}
</span>
</button>
}
<!-- VEX Pill (optional) -->
@if (showVex() && vexStatus(); as vex) {
<button
type="button"
class="evidence-pill"
[class]="getPillClasses('vex', vex.status)"
(click)="onPillClick('vex', vex.status)"
(keydown.enter)="onPillClick('vex', vex.status)"
[attr.aria-label]="getVexAriaLabel(vex)"
[attr.title]="getVexTooltip(vex)"
>
<span class="evidence-pill__icon" aria-hidden="true">
{{ getPillStatusIcon(vex.status) }}
</span>
<span class="evidence-pill__label">
VEX{{ vex.statementCount > 0 ? ' (' + vex.statementCount + ')' : '' }}
</span>
</button>
}
<!-- Policy Pill (optional) -->
@if (showPolicy() && policyStatus(); as policy) {
<button
type="button"
class="evidence-pill"
[class]="getPillClasses('policy', policy.status)"
(click)="onPillClick('policy', policy.status)"
(keydown.enter)="onPillClick('policy', policy.status)"
[attr.aria-label]="getPolicyAriaLabel(policy)"
[attr.title]="getPolicyTooltip(policy)"
>
<span class="evidence-pill__icon" aria-hidden="true">
{{ getPillStatusIcon(policy.status) }}
</span>
<span class="evidence-pill__label">
Policy: {{ policy.verdict | uppercase }}
</span>
</button>
}
<!-- No evidence available -->
@if (!hasAnyEvidence()) {
<span class="evidence-ribbon__empty">
No evidence available
</span>
}
}
</div>
`,
styles: [`
.evidence-ribbon {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--st-ribbon-bg, #f8f9fa);
border-radius: 0.375rem;
border: 1px solid var(--st-ribbon-border, #e9ecef);
overflow-x: auto;
scrollbar-width: thin;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--st-scrollbar-thumb, #dee2e6);
border-radius: 2px;
}
}
.evidence-ribbon__loading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--st-text-secondary, #6c757d);
font-size: 0.8125rem;
}
.evidence-ribbon__spinner {
width: 14px;
height: 14px;
border: 2px solid var(--st-spinner-bg, #e9ecef);
border-top-color: var(--st-spinner-color, #6c757d);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.evidence-ribbon__empty {
color: var(--st-text-secondary, #6c757d);
font-size: 0.8125rem;
font-style: italic;
}
// Evidence Pill Base
.evidence-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
&:focus {
outline: 2px solid var(--st-focus-color, #0d6efd);
outline-offset: 2px;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
}
.evidence-pill__icon {
font-size: 0.875rem;
line-height: 1;
}
.evidence-pill__label {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
// Status Variants
.evidence-pill--success {
background-color: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
border-color: var(--st-success-border, #badbcc);
&:hover {
background-color: var(--st-success-bg-hover, #c3e0d5);
}
}
.evidence-pill--warning {
background-color: var(--st-warning-bg, #fff3cd);
color: var(--st-warning-text, #664d03);
border-color: var(--st-warning-border, #ffecb5);
&:hover {
background-color: var(--st-warning-bg-hover, #ffe9a0);
}
}
.evidence-pill--error {
background-color: var(--st-error-bg, #f8d7da);
color: var(--st-error-text, #842029);
border-color: var(--st-error-border, #f5c2c7);
&:hover {
background-color: var(--st-error-bg-hover, #f1c4c8);
}
}
.evidence-pill--unknown {
background-color: var(--st-unknown-bg, #e9ecef);
color: var(--st-unknown-text, #495057);
border-color: var(--st-unknown-border, #dee2e6);
&:hover {
background-color: var(--st-unknown-bg-hover, #dde0e3);
}
}
.evidence-pill--pending {
background-color: var(--st-pending-bg, #cfe2ff);
color: var(--st-pending-text, #084298);
border-color: var(--st-pending-border, #b6d4fe);
&:hover {
background-color: var(--st-pending-bg-hover, #b8d4fe);
}
}
// Type-specific accents
.evidence-pill--dsse.evidence-pill--success {
border-left: 3px solid var(--st-dsse-accent, #198754);
}
.evidence-pill--rekor.evidence-pill--success {
border-left: 3px solid var(--st-rekor-accent, #6610f2);
}
.evidence-pill--sbom.evidence-pill--success {
border-left: 3px solid var(--st-sbom-accent, #0d6efd);
}
.evidence-pill--vex.evidence-pill--success {
border-left: 3px solid var(--st-vex-accent, #fd7e14);
}
.evidence-pill--policy.evidence-pill--success {
border-left: 3px solid var(--st-policy-accent, #20c997);
}
// Accessibility
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Dark mode
:host-context(.dark-theme) {
.evidence-ribbon {
--st-ribbon-bg: #212529;
--st-ribbon-border: #495057;
--st-text-secondary: #adb5bd;
--st-scrollbar-thumb: #495057;
}
.evidence-pill--success {
--st-success-bg: rgba(25, 135, 84, 0.2);
--st-success-text: #75b798;
--st-success-border: rgba(25, 135, 84, 0.4);
--st-success-bg-hover: rgba(25, 135, 84, 0.3);
}
.evidence-pill--warning {
--st-warning-bg: rgba(255, 193, 7, 0.2);
--st-warning-text: #ffda6a;
--st-warning-border: rgba(255, 193, 7, 0.4);
--st-warning-bg-hover: rgba(255, 193, 7, 0.3);
}
.evidence-pill--error {
--st-error-bg: rgba(220, 53, 69, 0.2);
--st-error-text: #ea868f;
--st-error-border: rgba(220, 53, 69, 0.4);
--st-error-bg-hover: rgba(220, 53, 69, 0.3);
}
.evidence-pill--unknown {
--st-unknown-bg: rgba(108, 117, 125, 0.2);
--st-unknown-text: #adb5bd;
--st-unknown-border: rgba(108, 117, 125, 0.4);
--st-unknown-bg-hover: rgba(108, 117, 125, 0.3);
}
.evidence-pill--pending {
--st-pending-bg: rgba(13, 110, 253, 0.2);
--st-pending-text: #6ea8fe;
--st-pending-border: rgba(13, 110, 253, 0.4);
--st-pending-bg-hover: rgba(13, 110, 253, 0.3);
}
}
// High contrast mode
@media (prefers-contrast: high) {
.evidence-pill {
border-width: 2px;
}
.evidence-pill--success {
background-color: #198754;
color: white;
}
.evidence-pill--warning {
background-color: #ffc107;
color: black;
}
.evidence-pill--error {
background-color: #dc3545;
color: white;
}
}
`],
})
export class EvidenceRibbonComponent implements OnInit, OnDestroy {
private readonly evidenceService = inject(EvidenceRibbonService);
/** Artifact digest to display evidence for. */
artifactDigest = input.required<string>();
/** Whether to show VEX pill. */
showVex = input<boolean>(false);
/** Whether to show Policy pill. */
showPolicy = input<boolean>(false);
/** Emitted when a pill is clicked. */
pillClick = output<EvidencePillClickEvent>();
// Delegate to service signals
readonly loading = this.evidenceService.loading;
readonly dsseStatus = this.evidenceService.dsseStatus;
readonly rekorStatus = this.evidenceService.rekorStatus;
readonly sbomStatus = this.evidenceService.sbomStatus;
readonly vexStatus = this.evidenceService.vexStatus;
readonly policyStatus = this.evidenceService.policyStatus;
// Check if any evidence exists
readonly hasAnyEvidence = computed(() => {
return (
this.dsseStatus() !== null ||
this.rekorStatus() !== null ||
this.sbomStatus() !== null ||
(this.showVex() && this.vexStatus() !== null) ||
(this.showPolicy() && this.policyStatus() !== null)
);
});
ngOnInit(): void {
this.evidenceService.loadEvidenceStatus(this.artifactDigest()).subscribe();
}
ngOnDestroy(): void {
this.evidenceService.clear();
}
// =========================================================================
// Event Handlers
// =========================================================================
onPillClick(type: EvidencePillType, status: EvidencePillStatus): void {
this.pillClick.emit({ type, status });
}
// =========================================================================
// Class Builders
// =========================================================================
getPillClasses(type: EvidencePillType, status: EvidencePillStatus): string {
return `evidence-pill--${type} evidence-pill--${status}`;
}
getPillStatusIcon(status: EvidencePillStatus): string {
return getPillStatusIcon(status);
}
formatCoverage(percent: number): string {
return formatCoverage(percent);
}
// =========================================================================
// ARIA Labels
// =========================================================================
getDsseAriaLabel(dsse: DsseEvidenceStatus): string {
if (dsse.signatureValid) {
return `DSSE signature verified${dsse.signerIdentity ? ' by ' + dsse.signerIdentity : ''}`;
}
return `DSSE signature ${dsse.errorMessage ?? 'not verified'}`;
}
getRekorAriaLabel(rekor: RekorEvidenceStatus): string {
if (rekor.included) {
return `Included in Rekor transparency log${rekor.tileId ? ', ' + rekor.tileId : ''}`;
}
return `Not included in Rekor transparency log`;
}
getSbomAriaLabel(sbom: SbomEvidenceStatus): string {
const format = sbom.format ?? 'Unknown';
const coverage = sbom.coveragePercent !== undefined ? `, ${sbom.coveragePercent}% coverage` : '';
return `SBOM format ${format}${coverage}`;
}
getVexAriaLabel(vex: VexEvidenceStatus): string {
const count = vex.statementCount;
const conflicts = vex.conflictCount > 0 ? `, ${vex.conflictCount} conflicts` : '';
return `${count} VEX statement${count !== 1 ? 's' : ''}${conflicts}`;
}
getPolicyAriaLabel(policy: PolicyEvidenceStatus): string {
return `Policy verdict: ${policy.verdict}${policy.packName ? ' from ' + policy.packName : ''}`;
}
// =========================================================================
// Tooltips
// =========================================================================
getDsseTooltip(dsse: DsseEvidenceStatus): string {
const lines: string[] = [];
lines.push(dsse.signatureValid ? 'Signature verified' : 'Signature not verified');
if (dsse.signerIdentity) lines.push(`Signer: ${dsse.signerIdentity}`);
if (dsse.keyId) lines.push(`Key: ${dsse.keyId.slice(0, 16)}...`);
if (dsse.trusted) lines.push('Trusted root');
if (dsse.errorMessage) lines.push(`Error: ${dsse.errorMessage}`);
return lines.join('\n');
}
getRekorTooltip(rekor: RekorEvidenceStatus): string {
const lines: string[] = [];
lines.push(rekor.included ? 'Logged in transparency log' : 'Not logged');
if (rekor.logIndex) lines.push(`Log index: ${rekor.logIndex}`);
if (rekor.integratedTime) lines.push(`Logged: ${new Date(rekor.integratedTime).toLocaleString()}`);
if (rekor.proofValid !== undefined) {
lines.push(rekor.proofValid ? 'Inclusion proof valid' : 'Inclusion proof invalid');
}
if (rekor.errorMessage) lines.push(`Error: ${rekor.errorMessage}`);
return lines.join('\n');
}
getSbomTooltip(sbom: SbomEvidenceStatus): string {
const lines: string[] = [];
lines.push(`Format: ${sbom.format}${sbom.formatVersion ? ' ' + sbom.formatVersion : ''}`);
if (sbom.coveragePercent !== undefined) lines.push(`Coverage: ${sbom.coveragePercent}%`);
if (sbom.componentCount) lines.push(`Components: ${sbom.componentCount}`);
if (sbom.directDependencies !== undefined) {
lines.push(`Direct: ${sbom.directDependencies}, Transitive: ${sbom.transitiveDependencies ?? 0}`);
}
if (sbom.generatedAt) lines.push(`Generated: ${new Date(sbom.generatedAt).toLocaleString()}`);
if (sbom.errorMessage) lines.push(`Error: ${sbom.errorMessage}`);
return lines.join('\n');
}
getVexTooltip(vex: VexEvidenceStatus): string {
const lines: string[] = [];
lines.push(`${vex.statementCount} VEX statement(s)`);
if (vex.notAffectedCount) lines.push(`Not affected: ${vex.notAffectedCount}`);
if (vex.conflictCount > 0) lines.push(`Conflicts: ${vex.conflictCount}`);
if (vex.confidence) lines.push(`Confidence: ${vex.confidence}`);
return lines.join('\n');
}
getPolicyTooltip(policy: PolicyEvidenceStatus): string {
const lines: string[] = [];
lines.push(`Verdict: ${policy.verdict.toUpperCase()}`);
if (policy.packName) lines.push(`Pack: ${policy.packName}`);
if (policy.version) lines.push(`Version: ${policy.version}`);
if (policy.evaluatedAt) lines.push(`Evaluated: ${new Date(policy.evaluatedAt).toLocaleString()}`);
return lines.join('\n');
}
}

View File

@@ -0,0 +1,17 @@
/**
* Evidence Ribbon Feature Exports
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*/
// Models
export * from './models/evidence-ribbon.models';
// Services
export { EvidenceRibbonService } from './services/evidence-ribbon.service';
// Components
export {
EvidenceRibbonComponent,
EvidencePillClickEvent,
} from './components/evidence-ribbon/evidence-ribbon.component';

View File

@@ -0,0 +1,259 @@
/**
* Evidence Ribbon Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*
* Models for the compact evidence status ribbon showing DSSE, Rekor, and SBOM coverage.
*/
// ============================================================================
// Pill Status Types
// ============================================================================
/**
* Evidence pill status indicating verification state.
*/
export type EvidencePillStatus = 'success' | 'warning' | 'error' | 'unknown' | 'pending';
/**
* Evidence pill type identifier.
*/
export type EvidencePillType = 'dsse' | 'rekor' | 'sbom' | 'vex' | 'policy';
// ============================================================================
// DSSE Evidence
// ============================================================================
/**
* DSSE (Dead Simple Signing Envelope) attestation status.
*/
export interface DsseEvidenceStatus {
/** Verification status. */
readonly status: EvidencePillStatus;
/** Whether signature is valid. */
readonly signatureValid: boolean;
/** Signer identity (email, URI). */
readonly signerIdentity?: string;
/** Key ID fingerprint. */
readonly keyId?: string;
/** Signing algorithm. */
readonly algorithm?: string;
/** Whether signer is from trusted root. */
readonly trusted: boolean;
/** Verification timestamp. */
readonly verifiedAt?: string;
/** Error message if verification failed. */
readonly errorMessage?: string;
}
// ============================================================================
// Rekor Evidence
// ============================================================================
/**
* Rekor transparency log inclusion status.
*/
export interface RekorEvidenceStatus {
/** Inclusion status. */
readonly status: EvidencePillStatus;
/** Whether included in transparency log. */
readonly included: boolean;
/** Rekor log index. */
readonly logIndex?: number;
/** Rekor log ID (tree ID). */
readonly logId?: string;
/** Entry UUID. */
readonly uuid?: string;
/** Tile ID for display (e.g., "tile-2026-01-27"). */
readonly tileId?: string;
/** Integration timestamp. */
readonly integratedTime?: string;
/** Whether inclusion proof is valid. */
readonly proofValid?: boolean;
/** Error message if verification failed. */
readonly errorMessage?: string;
}
// ============================================================================
// SBOM Evidence
// ============================================================================
/**
* SBOM document format.
*/
export type SbomFormat = 'CycloneDX' | 'SPDX' | 'Unknown';
/**
* SBOM coverage and quality status.
*/
export interface SbomEvidenceStatus {
/** Overall status. */
readonly status: EvidencePillStatus;
/** SBOM format (CycloneDX, SPDX). */
readonly format: SbomFormat;
/** Format version (e.g., "1.5", "2.3"). */
readonly formatVersion?: string;
/** Coverage percentage (0-100). */
readonly coveragePercent?: number;
/** Total component count. */
readonly componentCount?: number;
/** Direct dependency count. */
readonly directDependencies?: number;
/** Transitive dependency count. */
readonly transitiveDependencies?: number;
/** SBOM document hash. */
readonly documentHash?: string;
/** Generation timestamp. */
readonly generatedAt?: string;
/** Error message if unavailable. */
readonly errorMessage?: string;
}
// ============================================================================
// VEX Evidence (optional)
// ============================================================================
/**
* VEX statement presence status.
*/
export interface VexEvidenceStatus {
/** Overall status. */
readonly status: EvidencePillStatus;
/** Number of VEX statements. */
readonly statementCount: number;
/** Number of "not affected" statements. */
readonly notAffectedCount?: number;
/** Number of conflicts between sources. */
readonly conflictCount?: number;
/** Confidence level. */
readonly confidence?: 'low' | 'medium' | 'high';
}
// ============================================================================
// Policy Evidence (optional)
// ============================================================================
/**
* Policy evaluation status.
*/
export interface PolicyEvidenceStatus {
/** Overall status. */
readonly status: EvidencePillStatus;
/** Policy verdict. */
readonly verdict: 'pass' | 'warn' | 'block' | 'skip' | 'pending';
/** Policy pack name. */
readonly packName?: string;
/** Policy version. */
readonly version?: string;
/** Evaluation timestamp. */
readonly evaluatedAt?: string;
}
// ============================================================================
// Combined Evidence State
// ============================================================================
/**
* Combined evidence status for an artifact.
*/
export interface EvidenceRibbonState {
/** Artifact digest being displayed. */
readonly artifactDigest: string;
/** DSSE attestation status. */
readonly dsse?: DsseEvidenceStatus;
/** Rekor inclusion status. */
readonly rekor?: RekorEvidenceStatus;
/** SBOM coverage status. */
readonly sbom?: SbomEvidenceStatus;
/** VEX statement status (optional). */
readonly vex?: VexEvidenceStatus;
/** Policy evaluation status (optional). */
readonly policy?: PolicyEvidenceStatus;
/** Whether data is still loading. */
readonly loading: boolean;
/** Last refresh timestamp. */
readonly lastUpdated?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Gets the display label for a pill status.
*/
export function getPillStatusLabel(status: EvidencePillStatus): string {
switch (status) {
case 'success':
return 'Verified';
case 'warning':
return 'Warning';
case 'error':
return 'Failed';
case 'unknown':
return 'Unknown';
case 'pending':
return 'Pending';
default:
return 'Unknown';
}
}
/**
* Gets the icon for a pill status.
*/
export function getPillStatusIcon(status: EvidencePillStatus): string {
switch (status) {
case 'success':
return '✓';
case 'warning':
return '⚠';
case 'error':
return '✗';
case 'unknown':
return '?';
case 'pending':
return '⋯';
default:
return '?';
}
}
/**
* Formats a Rekor tile ID from timestamp.
*/
export function formatRekorTileId(integratedTime?: string): string {
if (!integratedTime) return 'unknown';
try {
const date = new Date(integratedTime);
return `tile-${date.toISOString().split('T')[0]}`;
} catch {
return 'tile-unknown';
}
}
/**
* Formats coverage percentage for display.
*/
export function formatCoverage(percent?: number): string {
if (percent === undefined || percent === null) return '—';
return `${Math.round(percent)}%`;
}
/**
* Determines overall ribbon status from component statuses.
*/
export function getOverallRibbonStatus(state: EvidenceRibbonState): EvidencePillStatus {
const statuses = [
state.dsse?.status,
state.rekor?.status,
state.sbom?.status,
].filter(Boolean) as EvidencePillStatus[];
if (statuses.length === 0) return 'unknown';
if (statuses.some((s) => s === 'error')) return 'error';
if (statuses.some((s) => s === 'warning')) return 'warning';
if (statuses.some((s) => s === 'pending')) return 'pending';
if (statuses.every((s) => s === 'success')) return 'success';
return 'unknown';
}

View File

@@ -0,0 +1,371 @@
/**
* Evidence Ribbon Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*
* Service for fetching and aggregating evidence status for the ribbon display.
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, forkJoin, of, catchError, map, tap, finalize } from 'rxjs';
import {
EvidenceRibbonState,
DsseEvidenceStatus,
RekorEvidenceStatus,
SbomEvidenceStatus,
VexEvidenceStatus,
PolicyEvidenceStatus,
EvidencePillStatus,
formatRekorTileId,
} from '../models/evidence-ribbon.models';
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
import { generateTraceId } from '../../../core/api/trace.util';
/**
* Evidence Ribbon Service.
* Fetches and aggregates evidence status from multiple sources.
*/
@Injectable({ providedIn: 'root' })
export class EvidenceRibbonService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantActivationService, { optional: true });
// API endpoints
private readonly attestorBaseUrl = '/api/v1/attestor';
private readonly sbomBaseUrl = '/api/v1/sbom';
private readonly concelierBaseUrl = '/api/v1/concelier';
private readonly policyBaseUrl = '/api/v1/policy';
// Internal state
private readonly _state = signal<EvidenceRibbonState | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
// Public readonly signals
readonly state = this._state.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
// Computed statuses
readonly dsseStatus = computed(() => this._state()?.dsse ?? null);
readonly rekorStatus = computed(() => this._state()?.rekor ?? null);
readonly sbomStatus = computed(() => this._state()?.sbom ?? null);
readonly vexStatus = computed(() => this._state()?.vex ?? null);
readonly policyStatus = computed(() => this._state()?.policy ?? null);
/**
* Load evidence status for an artifact digest.
*/
loadEvidenceStatus(artifactDigest: string): Observable<EvidenceRibbonState> {
this._loading.set(true);
this._error.set(null);
// Fetch all evidence sources in parallel
return forkJoin({
dsse: this.fetchDsseStatus(artifactDigest),
rekor: this.fetchRekorStatus(artifactDigest),
sbom: this.fetchSbomStatus(artifactDigest),
vex: this.fetchVexStatus(artifactDigest),
policy: this.fetchPolicyStatus(artifactDigest),
}).pipe(
map((results) => {
const state: EvidenceRibbonState = {
artifactDigest,
dsse: results.dsse,
rekor: results.rekor,
sbom: results.sbom,
vex: results.vex,
policy: results.policy,
loading: false,
lastUpdated: new Date().toISOString(),
};
return state;
}),
tap((state) => this._state.set(state)),
catchError((err) => {
this._error.set(err.message ?? 'Failed to load evidence status');
return of(this.createEmptyState(artifactDigest));
}),
finalize(() => this._loading.set(false))
);
}
/**
* Refresh evidence status for current artifact.
*/
refresh(): Observable<EvidenceRibbonState> | null {
const currentDigest = this._state()?.artifactDigest;
if (!currentDigest) return null;
return this.loadEvidenceStatus(currentDigest);
}
/**
* Clear current state.
*/
clear(): void {
this._state.set(null);
this._error.set(null);
}
// =========================================================================
// Private Fetch Methods
// =========================================================================
private fetchDsseStatus(digest: string): Observable<DsseEvidenceStatus | undefined> {
const url = `${this.attestorBaseUrl}/subjects/${encodeURIComponent(digest)}/status`;
const headers = this.buildHeaders();
return this.http.get<DsseApiResponse>(url, { headers }).pipe(
map((response) => this.mapDsseResponse(response)),
catchError(() => of(this.createUnknownDsseStatus()))
);
}
private fetchRekorStatus(digest: string): Observable<RekorEvidenceStatus | undefined> {
const url = `${this.attestorBaseUrl}/subjects/${encodeURIComponent(digest)}/rekor`;
const headers = this.buildHeaders();
return this.http.get<RekorApiResponse>(url, { headers }).pipe(
map((response) => this.mapRekorResponse(response)),
catchError(() => of(this.createUnknownRekorStatus()))
);
}
private fetchSbomStatus(digest: string): Observable<SbomEvidenceStatus | undefined> {
const url = `${this.sbomBaseUrl}/artifacts/${encodeURIComponent(digest)}/status`;
const headers = this.buildHeaders();
return this.http.get<SbomApiResponse>(url, { headers }).pipe(
map((response) => this.mapSbomResponse(response)),
catchError(() => of(this.createUnknownSbomStatus()))
);
}
private fetchVexStatus(digest: string): Observable<VexEvidenceStatus | undefined> {
const url = `${this.concelierBaseUrl}/artifacts/${encodeURIComponent(digest)}/vex/summary`;
const headers = this.buildHeaders();
return this.http.get<VexApiResponse>(url, { headers }).pipe(
map((response) => this.mapVexResponse(response)),
catchError(() => of(undefined)) // VEX is optional
);
}
private fetchPolicyStatus(digest: string): Observable<PolicyEvidenceStatus | undefined> {
const url = `${this.policyBaseUrl}/artifacts/${encodeURIComponent(digest)}/evaluation/latest`;
const headers = this.buildHeaders();
return this.http.get<PolicyApiResponse>(url, { headers }).pipe(
map((response) => this.mapPolicyResponse(response)),
catchError(() => of(undefined)) // Policy is optional
);
}
// =========================================================================
// Response Mappers
// =========================================================================
private mapDsseResponse(response: DsseApiResponse): DsseEvidenceStatus {
const status: EvidencePillStatus = response.verified
? 'success'
: response.error
? 'error'
: 'unknown';
return {
status,
signatureValid: response.verified,
signerIdentity: response.signer?.identity,
keyId: response.signer?.keyId,
algorithm: response.signer?.algorithm,
trusted: response.signer?.trusted ?? false,
verifiedAt: response.verifiedAt,
errorMessage: response.error,
};
}
private mapRekorResponse(response: RekorApiResponse): RekorEvidenceStatus {
const status: EvidencePillStatus = response.included
? response.proofValid
? 'success'
: 'warning'
: 'unknown';
return {
status,
included: response.included,
logIndex: response.logIndex,
logId: response.logId,
uuid: response.uuid,
tileId: formatRekorTileId(response.integratedTime),
integratedTime: response.integratedTime,
proofValid: response.proofValid,
errorMessage: response.error,
};
}
private mapSbomResponse(response: SbomApiResponse): SbomEvidenceStatus {
let status: EvidencePillStatus = 'unknown';
if (response.available) {
const coverage = response.coveragePercent ?? 0;
status = coverage >= 80 ? 'success' : coverage >= 50 ? 'warning' : 'error';
}
return {
status,
format: response.format ?? 'Unknown',
formatVersion: response.formatVersion,
coveragePercent: response.coveragePercent,
componentCount: response.componentCount,
directDependencies: response.directDependencies,
transitiveDependencies: response.transitiveDependencies,
documentHash: response.documentHash,
generatedAt: response.generatedAt,
errorMessage: response.error,
};
}
private mapVexResponse(response: VexApiResponse): VexEvidenceStatus {
let status: EvidencePillStatus = 'unknown';
if (response.statementCount > 0) {
status =
response.conflictCount > 0
? 'warning'
: response.confidence === 'high'
? 'success'
: 'success';
}
return {
status,
statementCount: response.statementCount,
notAffectedCount: response.notAffectedCount,
conflictCount: response.conflictCount,
confidence: response.confidence,
};
}
private mapPolicyResponse(response: PolicyApiResponse): PolicyEvidenceStatus {
const status: EvidencePillStatus =
response.verdict === 'pass'
? 'success'
: response.verdict === 'warn'
? 'warning'
: response.verdict === 'block'
? 'error'
: 'unknown';
return {
status,
verdict: response.verdict,
packName: response.packName,
version: response.version,
evaluatedAt: response.evaluatedAt,
};
}
// =========================================================================
// Fallback Creators
// =========================================================================
private createUnknownDsseStatus(): DsseEvidenceStatus {
return {
status: 'unknown',
signatureValid: false,
trusted: false,
errorMessage: 'Unable to verify attestation',
};
}
private createUnknownRekorStatus(): RekorEvidenceStatus {
return {
status: 'unknown',
included: false,
errorMessage: 'Unable to verify transparency log',
};
}
private createUnknownSbomStatus(): SbomEvidenceStatus {
return {
status: 'unknown',
format: 'Unknown',
errorMessage: 'SBOM not available',
};
}
private createEmptyState(digest: string): EvidenceRibbonState {
return {
artifactDigest: digest,
loading: false,
lastUpdated: new Date().toISOString(),
};
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'X-Trace-Id': generateTraceId(),
};
const tenantId = this.tenantService?.activeTenantId();
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return headers;
}
}
// ============================================================================
// API Response Types
// ============================================================================
interface DsseApiResponse {
verified: boolean;
signer?: {
identity?: string;
keyId?: string;
algorithm?: string;
trusted?: boolean;
};
verifiedAt?: string;
error?: string;
}
interface RekorApiResponse {
included: boolean;
logIndex?: number;
logId?: string;
uuid?: string;
integratedTime?: string;
proofValid?: boolean;
error?: string;
}
interface SbomApiResponse {
available: boolean;
format?: 'CycloneDX' | 'SPDX' | 'Unknown';
formatVersion?: string;
coveragePercent?: number;
componentCount?: number;
directDependencies?: number;
transitiveDependencies?: number;
documentHash?: string;
generatedAt?: string;
error?: string;
}
interface VexApiResponse {
statementCount: number;
notAffectedCount?: number;
conflictCount: number;
confidence?: 'low' | 'medium' | 'high';
}
interface PolicyApiResponse {
verdict: 'pass' | 'warn' | 'block' | 'skip' | 'pending';
packName?: string;
version?: string;
evaluatedAt?: string;
}

View File

@@ -0,0 +1,552 @@
/**
* SBOM Diff View Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of, delay, throwError } from 'rxjs';
import { provideRouter } from '@angular/router';
import { SbomDiffViewComponent, ComponentSelectEvent } from './sbom-diff-view.component';
import { SbomDiffService } from '../../services/sbom-diff.service';
import {
SbomDiffResult,
SbomDiffSummary,
SbomComponent,
SbomComponentChange,
} from '../../models/sbom-diff.models';
// Test host component
@Component({
standalone: true,
imports: [SbomDiffViewComponent],
template: `
<stella-sbom-diff-view
[versionA]="versionA"
[versionB]="versionB"
(componentSelect)="onComponentSelect($event)"
/>
`,
})
class TestHostComponent {
versionA = 'v1.0.0';
versionB = 'v1.1.0';
lastComponentSelect: ComponentSelectEvent | null = null;
onComponentSelect(event: ComponentSelectEvent): void {
this.lastComponentSelect = event;
}
}
describe('SbomDiffViewComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let mockService: jasmine.SpyObj<SbomDiffService>;
const createMockDiffResult = (overrides: Partial<SbomDiffResult> = {}): SbomDiffResult => ({
versionA: 'v1.0.0',
versionB: 'v1.1.0',
metadataA: {
versionId: 'v1.0.0',
format: 'CycloneDX',
formatVersion: '1.5',
createdAt: '2026-01-26T10:00:00Z',
},
metadataB: {
versionId: 'v1.1.0',
format: 'CycloneDX',
formatVersion: '1.5',
createdAt: '2026-01-27T10:00:00Z',
},
summary: {
addedCount: 2,
removedCount: 1,
changedCount: 3,
unchangedCount: 50,
totalA: 54,
totalB: 55,
licenseChanges: 1,
versionUpgrades: 2,
versionDowngrades: 1,
},
added: [
{
purl: 'pkg:npm/new-package@1.0.0',
name: 'new-package',
version: '1.0.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: true,
},
{
purl: 'pkg:npm/another-new@2.0.0',
name: 'another-new',
version: '2.0.0',
ecosystem: 'npm',
licenses: ['Apache-2.0'],
isDirect: false,
},
],
removed: [
{
purl: 'pkg:npm/old-package@0.9.0',
name: 'old-package',
version: '0.9.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: true,
},
],
changed: [
{
purl: 'pkg:npm/lodash',
name: 'lodash',
ecosystem: 'npm',
versionA: '4.17.20',
versionB: '4.17.21',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
licenseRiskChanged: false,
depTypeChanged: false,
wasDirectA: true,
isDirectB: true,
},
{
purl: 'pkg:npm/axios',
name: 'axios',
ecosystem: 'npm',
versionA: '0.21.0',
versionB: '1.0.0',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
licenseRiskChanged: false,
depTypeChanged: false,
wasDirectA: true,
isDirectB: true,
},
{
purl: 'pkg:npm/some-lib',
name: 'some-lib',
ecosystem: 'npm',
versionA: '2.0.0',
versionB: '2.1.0',
licensesA: ['MIT'],
licensesB: ['Apache-2.0'],
licenseChanged: true,
licenseRiskChanged: false,
depTypeChanged: false,
wasDirectA: false,
isDirectB: false,
},
],
computedAt: '2026-01-27T10:00:00Z',
...overrides,
});
const createMockSummary = (): SbomDiffSummary => ({
addedCount: 2,
removedCount: 1,
changedCount: 3,
unchangedCount: 50,
totalA: 54,
totalB: 55,
licenseChanges: 1,
versionUpgrades: 2,
versionDowngrades: 1,
});
beforeEach(async () => {
mockService = jasmine.createSpyObj('SbomDiffService', [
'loadDiff',
'setFilter',
'clearFilter',
'clear',
], {
loading: signal(false),
error: signal<string | null>(null),
filteredResult: signal<SbomDiffResult | null>(null),
filteredSummary: signal<SbomDiffSummary | null>(null),
availableEcosystems: signal<string[]>([]),
});
await TestBed.configureTestingModule({
imports: [TestHostComponent, SbomDiffViewComponent],
providers: [
{ provide: SbomDiffService, useValue: mockService },
provideRouter([]),
],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
});
describe('initialization', () => {
it('should create the component', () => {
const result = createMockDiffResult();
mockService.loadDiff.and.returnValue(of(result));
fixture.detectChanges();
const diffView = fixture.debugElement.query(By.directive(SbomDiffViewComponent));
expect(diffView).toBeTruthy();
});
it('should call loadDiff on init with version parameters', () => {
const result = createMockDiffResult();
mockService.loadDiff.and.returnValue(of(result));
fixture.detectChanges();
expect(mockService.loadDiff).toHaveBeenCalledWith('v1.0.0', 'v1.1.0');
});
it('should call clear on destroy', () => {
const result = createMockDiffResult();
mockService.loadDiff.and.returnValue(of(result));
fixture.detectChanges();
fixture.destroy();
expect(mockService.clear).toHaveBeenCalled();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
(mockService.loading as any).set(true);
mockService.loadDiff.and.returnValue(of(createMockDiffResult()).pipe(delay(1000)));
fixture.detectChanges();
const spinner = fixture.debugElement.query(By.css('.sbom-diff__spinner'));
expect(spinner).toBeTruthy();
});
it('should hide loading spinner when loaded', fakeAsync(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
mockService.loadDiff.and.returnValue(of(result));
fixture.detectChanges();
tick();
const spinner = fixture.debugElement.query(By.css('.sbom-diff__spinner'));
expect(spinner).toBeFalsy();
}));
});
describe('error state', () => {
it('should show error message when error occurs', fakeAsync(() => {
(mockService.loading as any).set(false);
(mockService.error as any).set('Failed to load SBOM diff');
mockService.loadDiff.and.returnValue(throwError(() => new Error('Failed')));
fixture.detectChanges();
tick();
const error = fixture.debugElement.query(By.css('.sbom-diff__error'));
expect(error).toBeTruthy();
expect(error.nativeElement.textContent).toContain('Failed to load SBOM diff');
}));
it('should have retry button in error state', fakeAsync(() => {
(mockService.loading as any).set(false);
(mockService.error as any).set('Failed to load SBOM diff');
mockService.loadDiff.and.returnValue(throwError(() => new Error('Failed')));
fixture.detectChanges();
tick();
const retryBtn = fixture.debugElement.query(By.css('.sbom-diff__retry'));
expect(retryBtn).toBeTruthy();
}));
});
describe('summary cards', () => {
beforeEach(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
(mockService.availableEcosystems as any).set(['npm']);
mockService.loadDiff.and.returnValue(of(result));
});
it('should render summary cards with correct counts', fakeAsync(() => {
fixture.detectChanges();
tick();
const addedCard = fixture.debugElement.query(By.css('.summary-card--added'));
const removedCard = fixture.debugElement.query(By.css('.summary-card--removed'));
const changedCard = fixture.debugElement.query(By.css('.summary-card--changed'));
const infoCard = fixture.debugElement.query(By.css('.summary-card--info'));
expect(addedCard.nativeElement.textContent).toContain('2');
expect(removedCard.nativeElement.textContent).toContain('1');
expect(changedCard.nativeElement.textContent).toContain('3');
expect(infoCard.nativeElement.textContent).toContain('50');
}));
it('should toggle filter when summary card is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const addedCard = fixture.debugElement.query(By.css('.summary-card--added'));
addedCard.nativeElement.click();
expect(mockService.setFilter).toHaveBeenCalled();
}));
});
describe('diff sections', () => {
beforeEach(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
(mockService.availableEcosystems as any).set(['npm']);
mockService.loadDiff.and.returnValue(of(result));
});
it('should render added components section', fakeAsync(() => {
fixture.detectChanges();
tick();
const addedSection = fixture.debugElement.query(By.css('.diff-section--added'));
expect(addedSection).toBeTruthy();
expect(addedSection.nativeElement.textContent).toContain('Added');
expect(addedSection.nativeElement.textContent).toContain('2');
}));
it('should render removed components section', fakeAsync(() => {
fixture.detectChanges();
tick();
const removedSection = fixture.debugElement.query(By.css('.diff-section--removed'));
expect(removedSection).toBeTruthy();
expect(removedSection.nativeElement.textContent).toContain('Removed');
expect(removedSection.nativeElement.textContent).toContain('1');
}));
it('should render changed components section', fakeAsync(() => {
fixture.detectChanges();
tick();
const changedSection = fixture.debugElement.query(By.css('.diff-section--changed'));
expect(changedSection).toBeTruthy();
expect(changedSection.nativeElement.textContent).toContain('Changed');
expect(changedSection.nativeElement.textContent).toContain('3');
}));
it('should render component items with name and version', fakeAsync(() => {
fixture.detectChanges();
tick();
const addedItems = fixture.debugElement.queryAll(By.css('.component-item--added'));
expect(addedItems.length).toBe(2);
expect(addedItems[0].nativeElement.textContent).toContain('another-new');
expect(addedItems[0].nativeElement.textContent).toContain('2.0.0');
}));
it('should show version change for changed components', fakeAsync(() => {
fixture.detectChanges();
tick();
const changedItems = fixture.debugElement.queryAll(By.css('.component-item--changed'));
expect(changedItems.length).toBe(3);
const versionChange = changedItems[0].query(By.css('.component-item__version-change'));
expect(versionChange).toBeTruthy();
}));
it('should show license changed badge when license changed', fakeAsync(() => {
fixture.detectChanges();
tick();
const changedItems = fixture.debugElement.queryAll(By.css('.component-item--changed'));
const licenseChangedBadges = fixture.debugElement.queryAll(
By.css('.component-item__badge--warning')
);
expect(licenseChangedBadges.length).toBeGreaterThan(0);
}));
});
describe('component selection', () => {
beforeEach(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
(mockService.availableEcosystems as any).set(['npm']);
mockService.loadDiff.and.returnValue(of(result));
});
it('should emit componentSelect when added component is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const addedItem = fixture.debugElement.query(
By.css('.component-item--added .component-item__button')
);
addedItem.nativeElement.click();
expect(host.lastComponentSelect).toBeTruthy();
expect(host.lastComponentSelect?.changeType).toBe('added');
}));
it('should emit componentSelect when removed component is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const removedItem = fixture.debugElement.query(
By.css('.component-item--removed .component-item__button')
);
removedItem.nativeElement.click();
expect(host.lastComponentSelect).toBeTruthy();
expect(host.lastComponentSelect?.changeType).toBe('removed');
}));
it('should emit componentSelect when changed component is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const changedItem = fixture.debugElement.query(
By.css('.component-item--changed .component-item__button')
);
changedItem.nativeElement.click();
expect(host.lastComponentSelect).toBeTruthy();
expect(host.lastComponentSelect?.changeType).toBe('changed');
}));
});
describe('filtering', () => {
beforeEach(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
(mockService.availableEcosystems as any).set(['npm', 'pypi']);
mockService.loadDiff.and.returnValue(of(result));
});
it('should render ecosystem filter chips', fakeAsync(() => {
fixture.detectChanges();
tick();
const filterChips = fixture.debugElement.queryAll(By.css('.filter-chip'));
expect(filterChips.length).toBeGreaterThan(0);
}));
it('should call setFilter when ecosystem chip is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const filterChip = fixture.debugElement.query(By.css('.filter-chip'));
filterChip.nativeElement.click();
expect(mockService.setFilter).toHaveBeenCalled();
}));
it('should render search input', fakeAsync(() => {
fixture.detectChanges();
tick();
const searchInput = fixture.debugElement.query(By.css('.filter-group__input'));
expect(searchInput).toBeTruthy();
}));
it('should call setFilter when search query changes', fakeAsync(() => {
fixture.detectChanges();
tick();
const searchInput = fixture.debugElement.query(By.css('.filter-group__input'));
searchInput.nativeElement.value = 'lodash';
searchInput.nativeElement.dispatchEvent(new Event('input'));
searchInput.triggerEventHandler('ngModelChange', 'lodash');
tick();
expect(mockService.setFilter).toHaveBeenCalled();
}));
it('should call clearFilter when clear filters button is clicked', fakeAsync(() => {
// Simulate active filters by setting up the component state
const diffComponent = fixture.debugElement.query(By.directive(SbomDiffViewComponent));
const instance = diffComponent.componentInstance;
// Manually trigger filter state
instance.onSearchChange('test');
fixture.detectChanges();
tick();
const clearBtn = fixture.debugElement.query(By.css('.sbom-diff__clear-filters'));
if (clearBtn) {
clearBtn.nativeElement.click();
expect(mockService.clearFilter).toHaveBeenCalled();
}
}));
});
describe('empty state', () => {
it('should show empty state when no differences found', fakeAsync(() => {
const emptyResult = createMockDiffResult({
added: [],
removed: [],
changed: [],
summary: {
addedCount: 0,
removedCount: 0,
changedCount: 0,
unchangedCount: 50,
totalA: 50,
totalB: 50,
licenseChanges: 0,
versionUpgrades: 0,
versionDowngrades: 0,
},
});
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(emptyResult);
(mockService.filteredSummary as any).set(emptyResult.summary);
(mockService.availableEcosystems as any).set([]);
mockService.loadDiff.and.returnValue(of(emptyResult));
fixture.detectChanges();
tick();
const empty = fixture.debugElement.query(By.css('.sbom-diff__empty'));
expect(empty).toBeTruthy();
expect(empty.nativeElement.textContent).toContain('No differences found');
}));
});
describe('deterministic ordering', () => {
it('should sort components alphabetically by name', fakeAsync(() => {
const result = createMockDiffResult();
(mockService.loading as any).set(false);
(mockService.filteredResult as any).set(result);
(mockService.filteredSummary as any).set(result.summary);
(mockService.availableEcosystems as any).set(['npm']);
mockService.loadDiff.and.returnValue(of(result));
fixture.detectChanges();
tick();
const addedItems = fixture.debugElement.queryAll(By.css('.component-item--added'));
// 'another-new' comes before 'new-package' alphabetically
expect(addedItems[0].nativeElement.textContent).toContain('another-new');
expect(addedItems[1].nativeElement.textContent).toContain('new-package');
}));
});
});

View File

@@ -0,0 +1,20 @@
/**
* SBOM Diff Feature Exports
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*/
// Models
export * from './models/sbom-diff.models';
// Services
export { SbomDiffService, SbomVersionInfo } from './services/sbom-diff.service';
// Components
export {
SbomDiffViewComponent,
ComponentSelectEvent,
} from './components/sbom-diff-view/sbom-diff-view.component';
// Routes
export { SBOM_DIFF_ROUTES } from './sbom-diff.routes';

View File

@@ -0,0 +1,405 @@
/**
* SBOM Diff Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*
* Models for SBOM comparison and diff visualization.
*/
// ============================================================================
// Component Diff Types
// ============================================================================
/**
* Type of change detected in a component.
*/
export type SbomDiffChangeType = 'added' | 'removed' | 'changed' | 'unchanged';
/**
* Component ecosystem type.
*/
export type ComponentEcosystem =
| 'npm'
| 'pypi'
| 'maven'
| 'nuget'
| 'cargo'
| 'go'
| 'gem'
| 'composer'
| 'apk'
| 'deb'
| 'rpm'
| 'oci'
| 'unknown';
/**
* License risk classification.
*/
export type LicenseRisk = 'permissive' | 'copyleft' | 'restricted' | 'unknown';
// ============================================================================
// Component Models
// ============================================================================
/**
* A component in an SBOM.
*/
export interface SbomComponent {
/** Package URL (PURL). */
readonly purl: string;
/** Component name. */
readonly name: string;
/** Component version. */
readonly version: string;
/** Ecosystem (npm, pypi, maven, etc.). */
readonly ecosystem: ComponentEcosystem;
/** License identifier(s). */
readonly licenses: readonly string[];
/** License risk classification. */
readonly licenseRisk?: LicenseRisk;
/** Whether this is a direct dependency. */
readonly isDirect: boolean;
/** Component hash/digest. */
readonly digest?: string;
/** Component description. */
readonly description?: string;
/** Supplier/publisher. */
readonly supplier?: string;
}
/**
* A changed component showing both versions.
*/
export interface SbomComponentChange {
/** Package URL (base, without version). */
readonly purl: string;
/** Component name. */
readonly name: string;
/** Ecosystem. */
readonly ecosystem: ComponentEcosystem;
/** Previous version (in version A). */
readonly versionA: string;
/** New version (in version B). */
readonly versionB: string;
/** Previous licenses. */
readonly licensesA: readonly string[];
/** New licenses. */
readonly licensesB: readonly string[];
/** Whether license changed. */
readonly licenseChanged: boolean;
/** License risk changed. */
readonly licenseRiskChanged: boolean;
/** Whether direct/transitive status changed. */
readonly depTypeChanged: boolean;
/** Was direct in A. */
readonly wasDirectA: boolean;
/** Is direct in B. */
readonly isDirectB: boolean;
}
// ============================================================================
// Diff Result Models
// ============================================================================
/**
* Summary statistics for an SBOM diff.
*/
export interface SbomDiffSummary {
/** Total components added. */
readonly addedCount: number;
/** Total components removed. */
readonly removedCount: number;
/** Total components changed (version or license). */
readonly changedCount: number;
/** Total unchanged components. */
readonly unchangedCount: number;
/** Total components in version A. */
readonly totalA: number;
/** Total components in version B. */
readonly totalB: number;
/** Number of license changes. */
readonly licenseChanges: number;
/** Number of version upgrades. */
readonly versionUpgrades: number;
/** Number of version downgrades. */
readonly versionDowngrades: number;
}
/**
* Full SBOM diff result.
*/
export interface SbomDiffResult {
/** Version A identifier. */
readonly versionA: string;
/** Version B identifier. */
readonly versionB: string;
/** Version A SBOM metadata. */
readonly metadataA: SbomMetadata;
/** Version B SBOM metadata. */
readonly metadataB: SbomMetadata;
/** Diff summary statistics. */
readonly summary: SbomDiffSummary;
/** Components added in version B. */
readonly added: readonly SbomComponent[];
/** Components removed from version A. */
readonly removed: readonly SbomComponent[];
/** Components changed between versions. */
readonly changed: readonly SbomComponentChange[];
/** Unchanged components (optional, may be omitted for large SBOMs). */
readonly unchanged?: readonly SbomComponent[];
/** Diff computed at timestamp. */
readonly computedAt: string;
}
/**
* SBOM document metadata.
*/
export interface SbomMetadata {
/** SBOM version/snapshot ID. */
readonly versionId: string;
/** SBOM format (CycloneDX, SPDX). */
readonly format: string;
/** Format version. */
readonly formatVersion?: string;
/** Document hash. */
readonly documentHash?: string;
/** Creation timestamp. */
readonly createdAt: string;
/** Tool that generated the SBOM. */
readonly toolName?: string;
/** Tool version. */
readonly toolVersion?: string;
/** Artifact digest this SBOM describes. */
readonly artifactDigest?: string;
}
// ============================================================================
// Filter and Sort Options
// ============================================================================
/**
* Filter options for diff view.
*/
export interface SbomDiffFilter {
/** Show only specific change types. */
readonly changeTypes?: readonly SbomDiffChangeType[];
/** Filter by ecosystem. */
readonly ecosystems?: readonly ComponentEcosystem[];
/** Filter by license risk. */
readonly licenseRisks?: readonly LicenseRisk[];
/** Show only direct dependencies. */
readonly directOnly?: boolean;
/** Text search query. */
readonly searchQuery?: string;
}
/**
* Sort options for diff view.
*/
export type SbomDiffSortField = 'name' | 'ecosystem' | 'version' | 'license' | 'changeType';
export type SbomDiffSortDirection = 'asc' | 'desc';
export interface SbomDiffSort {
readonly field: SbomDiffSortField;
readonly direction: SbomDiffSortDirection;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Gets the display color class for a change type.
*/
export function getChangeTypeClass(type: SbomDiffChangeType): string {
switch (type) {
case 'added':
return 'diff-added';
case 'removed':
return 'diff-removed';
case 'changed':
return 'diff-changed';
case 'unchanged':
return 'diff-unchanged';
}
}
/**
* Gets the display label for a change type.
*/
export function getChangeTypeLabel(type: SbomDiffChangeType): string {
switch (type) {
case 'added':
return 'Added';
case 'removed':
return 'Removed';
case 'changed':
return 'Changed';
case 'unchanged':
return 'Unchanged';
}
}
/**
* Gets the icon for a change type.
*/
export function getChangeTypeIcon(type: SbomDiffChangeType): string {
switch (type) {
case 'added':
return '+';
case 'removed':
return '-';
case 'changed':
return '~';
case 'unchanged':
return '=';
}
}
/**
* Gets the display label for an ecosystem.
*/
export function getEcosystemLabel(ecosystem: ComponentEcosystem): string {
const labels: Record<ComponentEcosystem, string> = {
npm: 'npm',
pypi: 'PyPI',
maven: 'Maven',
nuget: 'NuGet',
cargo: 'Cargo',
go: 'Go',
gem: 'RubyGems',
composer: 'Composer',
apk: 'Alpine',
deb: 'Debian',
rpm: 'RPM',
oci: 'OCI',
unknown: 'Unknown',
};
return labels[ecosystem] ?? ecosystem;
}
/**
* Gets the license risk label.
*/
export function getLicenseRiskLabel(risk: LicenseRisk): string {
switch (risk) {
case 'permissive':
return 'Permissive';
case 'copyleft':
return 'Copyleft';
case 'restricted':
return 'Restricted';
case 'unknown':
return 'Unknown';
}
}
/**
* Parses ecosystem from a PURL.
*/
export function parseEcosystemFromPurl(purl: string): ComponentEcosystem {
const match = purl.match(/^pkg:([^/]+)\//);
if (!match) return 'unknown';
const type = match[1].toLowerCase();
const ecosystemMap: Record<string, ComponentEcosystem> = {
npm: 'npm',
pypi: 'pypi',
maven: 'maven',
nuget: 'nuget',
cargo: 'cargo',
golang: 'go',
gem: 'gem',
composer: 'composer',
apk: 'apk',
deb: 'deb',
rpm: 'rpm',
oci: 'oci',
docker: 'oci',
};
return ecosystemMap[type] ?? 'unknown';
}
/**
* Compares two versions to determine if it's an upgrade, downgrade, or lateral.
* Returns: 1 for upgrade, -1 for downgrade, 0 for same/lateral.
*/
export function compareVersions(versionA: string, versionB: string): number {
// Simple semver-like comparison
const partsA = versionA.split('.').map((p) => parseInt(p, 10) || 0);
const partsB = versionB.split('.').map((p) => parseInt(p, 10) || 0);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const a = partsA[i] ?? 0;
const b = partsB[i] ?? 0;
if (b > a) return 1; // Upgrade
if (b < a) return -1; // Downgrade
}
return 0; // Same
}
/**
* Deterministically sorts components by name then version.
*/
export function sortComponentsDeterministic<T extends { name: string; version?: string }>(
components: readonly T[]
): T[] {
return [...components].sort((a, b) => {
const nameCompare = a.name.localeCompare(b.name);
if (nameCompare !== 0) return nameCompare;
return (a.version ?? '').localeCompare(b.version ?? '');
});
}
/**
* Filters components by the given filter options.
*/
export function filterDiffResults(
result: SbomDiffResult,
filter: SbomDiffFilter
): { added: SbomComponent[]; removed: SbomComponent[]; changed: SbomComponentChange[] } {
const matchesFilter = (
component: { name: string; ecosystem: ComponentEcosystem; licenseRisk?: LicenseRisk; isDirect?: boolean },
searchQuery?: string
): boolean => {
if (filter.ecosystems?.length && !filter.ecosystems.includes(component.ecosystem)) {
return false;
}
if (filter.licenseRisks?.length && component.licenseRisk && !filter.licenseRisks.includes(component.licenseRisk)) {
return false;
}
if (filter.directOnly && !component.isDirect) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
if (!component.name.toLowerCase().includes(query)) {
return false;
}
}
return true;
};
let added = result.added;
let removed = result.removed;
let changed = result.changed;
// Filter by change type
if (filter.changeTypes?.length) {
if (!filter.changeTypes.includes('added')) added = [];
if (!filter.changeTypes.includes('removed')) removed = [];
if (!filter.changeTypes.includes('changed')) changed = [];
}
// Apply component-level filters
return {
added: added.filter((c) => matchesFilter(c, filter.searchQuery)),
removed: removed.filter((c) => matchesFilter(c, filter.searchQuery)),
changed: changed.filter((c) =>
matchesFilter({ name: c.name, ecosystem: c.ecosystem, isDirect: c.isDirectB }, filter.searchQuery)
),
};
}

View File

@@ -0,0 +1,18 @@
/**
* SBOM Diff Routes
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*/
import { Routes } from '@angular/router';
export const SBOM_DIFF_ROUTES: Routes = [
{
path: ':versionA/:versionB',
loadComponent: () =>
import('./components/sbom-diff-view/sbom-diff-view.component').then(
(m) => m.SbomDiffViewComponent
),
title: 'SBOM Comparison',
},
];

View File

@@ -0,0 +1,384 @@
/**
* SBOM Diff Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*
* Service for fetching and processing SBOM diff data.
* Consumes the existing GET /sbom/ledger/diff API.
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, map, tap, catchError, finalize } from 'rxjs';
import {
SbomDiffResult,
SbomDiffSummary,
SbomComponent,
SbomComponentChange,
SbomMetadata,
SbomDiffFilter,
SbomDiffSort,
ComponentEcosystem,
LicenseRisk,
sortComponentsDeterministic,
filterDiffResults,
parseEcosystemFromPurl,
compareVersions,
} from '../models/sbom-diff.models';
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
import { generateTraceId } from '../../../core/api/trace.util';
/**
* SBOM Diff Service.
* Fetches and processes SBOM comparison data from the backend.
*/
@Injectable({ providedIn: 'root' })
export class SbomDiffService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantActivationService, { optional: true });
private readonly baseUrl = '/api/v1/sbom/ledger';
// Internal state
private readonly _diffResult = signal<SbomDiffResult | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _filter = signal<SbomDiffFilter>({});
private readonly _sort = signal<SbomDiffSort>({ field: 'name', direction: 'asc' });
// Public readonly signals
readonly diffResult = this._diffResult.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly filter = this._filter.asReadonly();
readonly sort = this._sort.asReadonly();
// Computed: filtered and sorted results
readonly filteredResult = computed(() => {
const result = this._diffResult();
const filter = this._filter();
if (!result) return null;
const filtered = filterDiffResults(result, filter);
const sort = this._sort();
return {
...result,
added: this.sortComponents(filtered.added, sort),
removed: this.sortComponents(filtered.removed, sort),
changed: this.sortChanges(filtered.changed, sort),
};
});
// Computed: summary of filtered results
readonly filteredSummary = computed(() => {
const filtered = this.filteredResult();
if (!filtered) return null;
return {
addedCount: filtered.added.length,
removedCount: filtered.removed.length,
changedCount: filtered.changed.length,
unchangedCount: filtered.summary.unchangedCount,
totalA: filtered.summary.totalA,
totalB: filtered.summary.totalB,
licenseChanges: filtered.changed.filter((c) => c.licenseChanged).length,
versionUpgrades: filtered.changed.filter((c) => compareVersions(c.versionA, c.versionB) > 0).length,
versionDowngrades: filtered.changed.filter((c) => compareVersions(c.versionA, c.versionB) < 0).length,
} as SbomDiffSummary;
});
// Computed: available ecosystems for filter
readonly availableEcosystems = computed(() => {
const result = this._diffResult();
if (!result) return [];
const ecosystems = new Set<ComponentEcosystem>();
result.added.forEach((c) => ecosystems.add(c.ecosystem));
result.removed.forEach((c) => ecosystems.add(c.ecosystem));
result.changed.forEach((c) => ecosystems.add(c.ecosystem));
return Array.from(ecosystems).sort();
});
/**
* Load diff between two SBOM versions.
*/
loadDiff(versionA: string, versionB: string): Observable<SbomDiffResult> {
this._loading.set(true);
this._error.set(null);
const url = `${this.baseUrl}/diff`;
const params = new HttpParams()
.set('versionA', versionA)
.set('versionB', versionB);
const headers = this.buildHeaders();
return this.http.get<SbomDiffApiResponse>(url, { params, headers }).pipe(
map((response) => this.mapApiResponse(response, versionA, versionB)),
tap((result) => this._diffResult.set(result)),
catchError((err) => {
const message = err.error?.message ?? err.message ?? 'Failed to load SBOM diff';
this._error.set(message);
throw err;
}),
finalize(() => this._loading.set(false))
);
}
/**
* Update filter options.
*/
setFilter(filter: Partial<SbomDiffFilter>): void {
this._filter.update((current) => ({ ...current, ...filter }));
}
/**
* Clear all filters.
*/
clearFilter(): void {
this._filter.set({});
}
/**
* Update sort options.
*/
setSort(sort: SbomDiffSort): void {
this._sort.set(sort);
}
/**
* Toggle sort direction for a field.
*/
toggleSort(field: SbomDiffSort['field']): void {
const current = this._sort();
if (current.field === field) {
this._sort.set({ field, direction: current.direction === 'asc' ? 'desc' : 'asc' });
} else {
this._sort.set({ field, direction: 'asc' });
}
}
/**
* Clear current diff result.
*/
clear(): void {
this._diffResult.set(null);
this._error.set(null);
this._filter.set({});
}
/**
* Get SBOM versions for an artifact (for version picker).
*/
getSbomVersions(artifactDigest: string): Observable<SbomVersionInfo[]> {
const url = `${this.baseUrl.replace('/ledger', '')}/versions`;
const params = new HttpParams().set('artifact', artifactDigest);
const headers = this.buildHeaders();
return this.http.get<{ items: SbomVersionInfo[] }>(url, { params, headers }).pipe(
map((response) => response.items),
catchError(() => of([]))
);
}
// =========================================================================
// Private Methods
// =========================================================================
private mapApiResponse(
response: SbomDiffApiResponse,
versionA: string,
versionB: string
): SbomDiffResult {
const added = this.mapComponents(response.added ?? []);
const removed = this.mapComponents(response.removed ?? []);
const changed = this.mapChanges(response.changed ?? []);
const summary: SbomDiffSummary = {
addedCount: added.length,
removedCount: removed.length,
changedCount: changed.length,
unchangedCount: response.unchangedCount ?? 0,
totalA: response.totalA ?? removed.length + changed.length + (response.unchangedCount ?? 0),
totalB: response.totalB ?? added.length + changed.length + (response.unchangedCount ?? 0),
licenseChanges: changed.filter((c) => c.licenseChanged).length,
versionUpgrades: changed.filter((c) => compareVersions(c.versionA, c.versionB) > 0).length,
versionDowngrades: changed.filter((c) => compareVersions(c.versionA, c.versionB) < 0).length,
};
return {
versionA,
versionB,
metadataA: response.metadataA ?? this.createEmptyMetadata(versionA),
metadataB: response.metadataB ?? this.createEmptyMetadata(versionB),
summary,
added: sortComponentsDeterministic(added),
removed: sortComponentsDeterministic(removed),
changed: sortComponentsDeterministic(changed),
unchanged: response.unchanged ? this.mapComponents(response.unchanged) : undefined,
computedAt: response.computedAt ?? new Date().toISOString(),
};
}
private mapComponents(apiComponents: ApiComponent[]): SbomComponent[] {
return apiComponents.map((c) => ({
purl: c.purl,
name: c.name,
version: c.version,
ecosystem: c.ecosystem ?? parseEcosystemFromPurl(c.purl),
licenses: c.licenses ?? [],
licenseRisk: c.licenseRisk,
isDirect: c.isDirect ?? false,
digest: c.digest,
description: c.description,
supplier: c.supplier,
}));
}
private mapChanges(apiChanges: ApiComponentChange[]): SbomComponentChange[] {
return apiChanges.map((c) => ({
purl: c.purl,
name: c.name,
ecosystem: c.ecosystem ?? parseEcosystemFromPurl(c.purl),
versionA: c.versionA,
versionB: c.versionB,
licensesA: c.licensesA ?? [],
licensesB: c.licensesB ?? [],
licenseChanged: !this.arraysEqual(c.licensesA ?? [], c.licensesB ?? []),
licenseRiskChanged: c.licenseRiskA !== c.licenseRiskB,
depTypeChanged: c.wasDirectA !== c.isDirectB,
wasDirectA: c.wasDirectA ?? false,
isDirectB: c.isDirectB ?? false,
}));
}
private createEmptyMetadata(versionId: string): SbomMetadata {
return {
versionId,
format: 'Unknown',
createdAt: new Date().toISOString(),
};
}
private sortComponents(components: SbomComponent[], sort: SbomDiffSort): SbomComponent[] {
const sorted = [...components];
const dir = sort.direction === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
switch (sort.field) {
case 'name':
return a.name.localeCompare(b.name) * dir;
case 'ecosystem':
return a.ecosystem.localeCompare(b.ecosystem) * dir;
case 'version':
return a.version.localeCompare(b.version) * dir;
case 'license':
return (a.licenses[0] ?? '').localeCompare(b.licenses[0] ?? '') * dir;
default:
return a.name.localeCompare(b.name) * dir;
}
});
return sorted;
}
private sortChanges(changes: SbomComponentChange[], sort: SbomDiffSort): SbomComponentChange[] {
const sorted = [...changes];
const dir = sort.direction === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
switch (sort.field) {
case 'name':
return a.name.localeCompare(b.name) * dir;
case 'ecosystem':
return a.ecosystem.localeCompare(b.ecosystem) * dir;
case 'version':
return a.versionB.localeCompare(b.versionB) * dir;
case 'license':
return (a.licensesB[0] ?? '').localeCompare(b.licensesB[0] ?? '') * dir;
default:
return a.name.localeCompare(b.name) * dir;
}
});
return sorted;
}
private arraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((v, i) => v === sortedB[i]);
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'X-Trace-Id': generateTraceId(),
};
const tenantId = this.tenantService?.activeTenantId();
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return headers;
}
}
// ============================================================================
// API Response Types
// ============================================================================
interface SbomDiffApiResponse {
added?: ApiComponent[];
removed?: ApiComponent[];
changed?: ApiComponentChange[];
unchanged?: ApiComponent[];
unchangedCount?: number;
totalA?: number;
totalB?: number;
metadataA?: SbomMetadata;
metadataB?: SbomMetadata;
computedAt?: string;
}
interface ApiComponent {
purl: string;
name: string;
version: string;
ecosystem?: ComponentEcosystem;
licenses?: string[];
licenseRisk?: LicenseRisk;
isDirect?: boolean;
digest?: string;
description?: string;
supplier?: string;
}
interface ApiComponentChange {
purl: string;
name: string;
ecosystem?: ComponentEcosystem;
versionA: string;
versionB: string;
licensesA?: string[];
licensesB?: string[];
licenseRiskA?: LicenseRisk;
licenseRiskB?: LicenseRisk;
wasDirectA?: boolean;
isDirectB?: boolean;
}
/**
* SBOM version info for version picker.
*/
export interface SbomVersionInfo {
readonly versionId: string;
readonly format: string;
readonly formatVersion?: string;
readonly createdAt: string;
readonly componentCount?: number;
readonly artifactDigest?: string;
}

View File

@@ -0,0 +1,590 @@
/**
* VEX Timeline Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of, delay, throwError } from 'rxjs';
import { VexTimelineComponent, VerifyDsseEvent } from './vex-timeline.component';
import { VexTimelineService } from '../../services/vex-timeline.service';
import {
VexTimelineState,
VexTimelineRow,
VexObservation,
VexConflict,
VexConsensus,
VexSource,
VexSourceType,
} from '../../models/vex-timeline.models';
// Test host component
@Component({
standalone: true,
imports: [VexTimelineComponent],
template: `
<stella-vex-timeline
[advisoryId]="advisoryId"
[product]="product"
(verifyDsse)="onVerifyDsse($event)"
/>
`,
})
class TestHostComponent {
advisoryId = 'CVE-2024-12345';
product = 'pkg:npm/lodash@4.17.21';
lastVerifyEvent: VerifyDsseEvent | null = null;
onVerifyDsse(event: VerifyDsseEvent): void {
this.lastVerifyEvent = event;
}
}
describe('VexTimelineComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let mockService: jasmine.SpyObj<VexTimelineService>;
const createMockSource = (id: string, type: VexSourceType, name: string): VexSource => ({
id,
type,
name,
trustLevel: 80,
});
const createMockObservation = (
id: string,
source: VexSource,
status: VexObservation['status'],
signed = true
): VexObservation => ({
id,
source,
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
status,
confidence: 'high',
timestamp: '2026-01-27T10:00:00Z',
signed,
dsseRef: signed ? `dsse:${id}` : undefined,
isLatest: true,
});
const createMockState = (overrides: Partial<VexTimelineState> = {}): VexTimelineState => {
const nvdSource = createMockSource('nvd-1', 'nvd', 'NVD');
const vendorSource = createMockSource('vendor-1', 'vendor', 'Lodash Maintainers');
const nvdObs = createMockObservation('obs-1', nvdSource, 'affected');
const vendorObs = createMockObservation('obs-2', vendorSource, 'not_affected', true);
return {
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
rows: [
{
source: nvdSource,
observations: [nvdObs],
latestObservation: nvdObs,
transitions: [],
hasConflicts: true,
},
{
source: vendorSource,
observations: [vendorObs],
latestObservation: vendorObs,
transitions: [],
hasConflicts: true,
},
],
allObservations: [nvdObs, vendorObs],
conflicts: [
{
id: 'conflict-1',
type: 'status-mismatch',
sources: [nvdSource, vendorSource],
observations: [nvdObs, vendorObs],
description: 'NVD reports affected while vendor reports not affected',
detectedAt: '2026-01-27T10:00:00Z',
resolved: false,
},
],
consensus: {
status: 'not_affected',
confidence: 'high',
agreeSources: [vendorSource],
disagreeSources: [nvdSource],
rationale: 'Vendor statement with higher trust level takes precedence',
computedAt: '2026-01-27T10:00:00Z',
},
startDate: '2026-01-26T10:00:00Z',
endDate: '2026-01-27T10:00:00Z',
loading: false,
lastUpdated: '2026-01-27T10:00:00Z',
...overrides,
};
};
beforeEach(async () => {
mockService = jasmine.createSpyObj('VexTimelineService', [
'loadTimeline',
'setFilter',
'clearFilter',
'clear',
], {
loading: signal(false),
error: signal<string | null>(null),
state: signal<VexTimelineState | null>(null),
filteredRows: signal<VexTimelineRow[]>([]),
conflictCount: signal(0),
availableSourceTypes: signal<VexSourceType[]>([]),
});
await TestBed.configureTestingModule({
imports: [TestHostComponent, VexTimelineComponent],
providers: [
{ provide: VexTimelineService, useValue: mockService },
],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
});
describe('initialization', () => {
it('should create the component', () => {
const state = createMockState();
mockService.loadTimeline.and.returnValue(of(state));
fixture.detectChanges();
const timeline = fixture.debugElement.query(By.directive(VexTimelineComponent));
expect(timeline).toBeTruthy();
});
it('should call loadTimeline on init with advisory and product', () => {
const state = createMockState();
mockService.loadTimeline.and.returnValue(of(state));
fixture.detectChanges();
expect(mockService.loadTimeline).toHaveBeenCalledWith(
'CVE-2024-12345',
'pkg:npm/lodash@4.17.21'
);
});
it('should call clear on destroy', () => {
const state = createMockState();
mockService.loadTimeline.and.returnValue(of(state));
fixture.detectChanges();
fixture.destroy();
expect(mockService.clear).toHaveBeenCalled();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
(mockService.loading as any).set(true);
mockService.loadTimeline.and.returnValue(of(createMockState()).pipe(delay(1000)));
fixture.detectChanges();
const spinner = fixture.debugElement.query(By.css('.vex-timeline__spinner'));
expect(spinner).toBeTruthy();
});
it('should hide loading spinner when loaded', fakeAsync(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
fixture.detectChanges();
tick();
const spinner = fixture.debugElement.query(By.css('.vex-timeline__spinner'));
expect(spinner).toBeFalsy();
}));
});
describe('error state', () => {
it('should show error message when error occurs', fakeAsync(() => {
(mockService.loading as any).set(false);
(mockService.error as any).set('Failed to load VEX timeline');
mockService.loadTimeline.and.returnValue(throwError(() => new Error('Failed')));
fixture.detectChanges();
tick();
const error = fixture.debugElement.query(By.css('.vex-timeline__error'));
expect(error).toBeTruthy();
expect(error.nativeElement.textContent).toContain('Failed to load VEX timeline');
}));
it('should have retry button in error state', fakeAsync(() => {
(mockService.loading as any).set(false);
(mockService.error as any).set('Failed to load VEX timeline');
mockService.loadTimeline.and.returnValue(throwError(() => new Error('Failed')));
fixture.detectChanges();
tick();
const retryBtn = fixture.debugElement.query(By.css('.vex-timeline__retry'));
expect(retryBtn).toBeTruthy();
}));
});
describe('consensus banner', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should render consensus banner with status', fakeAsync(() => {
fixture.detectChanges();
tick();
const banner = fixture.debugElement.query(By.css('.consensus-banner'));
expect(banner).toBeTruthy();
expect(banner.nativeElement.textContent).toContain('Not Affected');
}));
it('should show confidence level in consensus banner', fakeAsync(() => {
fixture.detectChanges();
tick();
const banner = fixture.debugElement.query(By.css('.consensus-banner'));
expect(banner.nativeElement.textContent).toContain('High');
}));
it('should show rationale in consensus banner', fakeAsync(() => {
fixture.detectChanges();
tick();
const rationale = fixture.debugElement.query(By.css('.consensus-banner__rationale'));
expect(rationale).toBeTruthy();
expect(rationale.nativeElement.textContent).toContain('Vendor statement');
}));
it('should show disagree count when sources disagree', fakeAsync(() => {
fixture.detectChanges();
tick();
const disagree = fixture.debugElement.query(By.css('.consensus-banner__disagree'));
expect(disagree).toBeTruthy();
expect(disagree.nativeElement.textContent).toContain('1 source(s) disagree');
}));
});
describe('conflict alert', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should render conflict alert when conflicts exist', fakeAsync(() => {
fixture.detectChanges();
tick();
const alert = fixture.debugElement.query(By.css('.conflict-alert'));
expect(alert).toBeTruthy();
expect(alert.nativeElement.textContent).toContain('1 unresolved conflict');
}));
it('should have toggle button for conflicts filter', fakeAsync(() => {
fixture.detectChanges();
tick();
const toggleBtn = fixture.debugElement.query(By.css('.conflict-alert__toggle'));
expect(toggleBtn).toBeTruthy();
}));
});
describe('timeline rows', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should render timeline rows for each source', fakeAsync(() => {
fixture.detectChanges();
tick();
const rows = fixture.debugElement.queryAll(By.css('.timeline-row'));
expect(rows.length).toBe(2);
}));
it('should show source name and type', fakeAsync(() => {
fixture.detectChanges();
tick();
const row = fixture.debugElement.query(By.css('.timeline-row'));
expect(row.nativeElement.textContent).toContain('NVD');
}));
it('should show conflict badge on rows with conflicts', fakeAsync(() => {
fixture.detectChanges();
tick();
const conflictBadges = fixture.debugElement.queryAll(By.css('.timeline-row__conflict-badge'));
expect(conflictBadges.length).toBeGreaterThan(0);
}));
it('should show latest status for each row', fakeAsync(() => {
fixture.detectChanges();
tick();
const statusBadges = fixture.debugElement.queryAll(By.css('.status-badge'));
expect(statusBadges.length).toBeGreaterThan(0);
}));
});
describe('row expansion', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should have expand button on each row', fakeAsync(() => {
fixture.detectChanges();
tick();
const expandBtns = fixture.debugElement.queryAll(By.css('.timeline-row__expand'));
expect(expandBtns.length).toBe(2);
}));
it('should show observations when row is expanded', fakeAsync(() => {
fixture.detectChanges();
tick();
// Click expand button
const expandBtn = fixture.debugElement.query(By.css('.timeline-row__expand'));
expandBtn.nativeElement.click();
fixture.detectChanges();
const observations = fixture.debugElement.query(By.css('.timeline-row__observations'));
expect(observations).toBeTruthy();
}));
it('should show observation cards with details', fakeAsync(() => {
fixture.detectChanges();
tick();
// Click expand button
const expandBtn = fixture.debugElement.query(By.css('.timeline-row__expand'));
expandBtn.nativeElement.click();
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(By.css('.observation-card'));
expect(cards.length).toBeGreaterThan(0);
}));
});
describe('DSSE verification', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should show Verify DSSE button for signed observations', fakeAsync(() => {
fixture.detectChanges();
tick();
const verifyBtn = fixture.debugElement.query(By.css('.timeline-row__verify-btn'));
expect(verifyBtn).toBeTruthy();
}));
it('should emit verifyDsse event when button is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const verifyBtn = fixture.debugElement.query(By.css('.timeline-row__verify-btn'));
verifyBtn.nativeElement.click();
expect(host.lastVerifyEvent).toBeTruthy();
expect(host.lastVerifyEvent?.observationId).toBeTruthy();
}));
it('should include dsseRef in verify event', fakeAsync(() => {
fixture.detectChanges();
tick();
const verifyBtn = fixture.debugElement.query(By.css('.timeline-row__verify-btn'));
verifyBtn.nativeElement.click();
expect(host.lastVerifyEvent?.dsseRef).toBeTruthy();
}));
});
describe('filtering', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should render source type filter chips', fakeAsync(() => {
fixture.detectChanges();
tick();
const filterChips = fixture.debugElement.queryAll(By.css('.filter-chip'));
expect(filterChips.length).toBeGreaterThan(0);
}));
it('should call setFilter when filter chip is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const filterChip = fixture.debugElement.query(By.css('.filter-chip'));
filterChip.nativeElement.click();
expect(mockService.setFilter).toHaveBeenCalled();
}));
it('should toggle conflicts filter when toggle is clicked', fakeAsync(() => {
fixture.detectChanges();
tick();
const toggleBtn = fixture.debugElement.query(By.css('.conflict-alert__toggle'));
toggleBtn.nativeElement.click();
expect(mockService.setFilter).toHaveBeenCalled();
}));
});
describe('conflicts section', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should render conflicts section when conflicts exist', fakeAsync(() => {
fixture.detectChanges();
tick();
const conflictsSection = fixture.debugElement.query(By.css('.conflicts-section'));
expect(conflictsSection).toBeTruthy();
}));
it('should render conflict cards', fakeAsync(() => {
fixture.detectChanges();
tick();
const conflictCards = fixture.debugElement.queryAll(By.css('.conflict-card'));
expect(conflictCards.length).toBe(1);
}));
it('should show conflict type and description', fakeAsync(() => {
fixture.detectChanges();
tick();
const conflictCard = fixture.debugElement.query(By.css('.conflict-card'));
expect(conflictCard.nativeElement.textContent).toContain('Status Mismatch');
expect(conflictCard.nativeElement.textContent).toContain('NVD reports affected');
}));
it('should show sources involved in conflict', fakeAsync(() => {
fixture.detectChanges();
tick();
const sources = fixture.debugElement.query(By.css('.conflict-card__sources'));
expect(sources).toBeTruthy();
expect(sources.nativeElement.textContent).toContain('NVD');
expect(sources.nativeElement.textContent).toContain('Lodash Maintainers');
}));
});
describe('empty state', () => {
it('should show empty state when no observations exist', fakeAsync(() => {
const emptyState = createMockState({
rows: [],
allObservations: [],
conflicts: [],
consensus: null,
});
(mockService.loading as any).set(false);
(mockService.state as any).set(emptyState);
(mockService.filteredRows as any).set([]);
(mockService.conflictCount as any).set(0);
(mockService.availableSourceTypes as any).set([]);
mockService.loadTimeline.and.returnValue(of(emptyState));
fixture.detectChanges();
tick();
const empty = fixture.debugElement.query(By.css('.vex-timeline__empty'));
expect(empty).toBeTruthy();
expect(empty.nativeElement.textContent).toContain('No VEX statements found');
}));
});
describe('status badges', () => {
beforeEach(() => {
const state = createMockState();
(mockService.loading as any).set(false);
(mockService.state as any).set(state);
(mockService.filteredRows as any).set(state.rows);
(mockService.conflictCount as any).set(1);
(mockService.availableSourceTypes as any).set(['nvd', 'vendor']);
mockService.loadTimeline.and.returnValue(of(state));
});
it('should apply correct class for not_affected status', fakeAsync(() => {
fixture.detectChanges();
tick();
const notAffectedBadge = fixture.debugElement.query(By.css('.status--not-affected'));
expect(notAffectedBadge).toBeTruthy();
}));
it('should apply correct class for affected status', fakeAsync(() => {
fixture.detectChanges();
tick();
const affectedBadge = fixture.debugElement.query(By.css('.status--affected'));
expect(affectedBadge).toBeTruthy();
}));
});
});

View File

@@ -0,0 +1,20 @@
/**
* VEX Timeline Feature Exports
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*/
// Models
export * from './models/vex-timeline.models';
// Services
export { VexTimelineService } from './services/vex-timeline.service';
// Components
export {
VexTimelineComponent,
VerifyDsseEvent,
} from './components/vex-timeline/vex-timeline.component';
// Routes
export { VEX_TIMELINE_ROUTES } from './vex-timeline.routes';

View File

@@ -0,0 +1,453 @@
/**
* VEX Timeline Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*
* Models for VEX statement temporal visualization showing confidence evolution.
*/
// ============================================================================
// VEX Status Types
// ============================================================================
/**
* VEX status values (OpenVEX compatible).
*/
export type VexStatus =
| 'not_affected'
| 'affected'
| 'fixed'
| 'under_investigation'
| 'unknown';
/**
* VEX justification for not_affected status.
*/
export type VexJustification =
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'vulnerable_code_not_in_execute_path'
| 'inline_mitigations_already_exist'
| 'none';
/**
* Confidence level for VEX observations.
*/
export type VexConfidence = 'low' | 'medium' | 'high';
// ============================================================================
// Source Types
// ============================================================================
/**
* VEX observation source type.
*/
export type VexSourceType =
| 'nvd'
| 'vendor'
| 'distro'
| 'internal'
| 'osv'
| 'github'
| 'manual'
| 'unknown';
/**
* VEX observation source information.
*/
export interface VexSource {
/** Source identifier. */
readonly id: string;
/** Source type. */
readonly type: VexSourceType;
/** Source display name. */
readonly name: string;
/** Source URL (if available). */
readonly url?: string;
/** Trust level (0-100). */
readonly trustLevel?: number;
}
// ============================================================================
// Observation Types
// ============================================================================
/**
* A single VEX observation from a source.
*/
export interface VexObservation {
/** Observation ID. */
readonly id: string;
/** Source of this observation. */
readonly source: VexSource;
/** Advisory/vulnerability ID. */
readonly advisoryId: string;
/** Product/component being assessed. */
readonly product: string;
/** VEX status. */
readonly status: VexStatus;
/** Status justification (for not_affected). */
readonly justification?: VexJustification;
/** Additional notes/rationale. */
readonly notes?: string;
/** Confidence level. */
readonly confidence: VexConfidence;
/** Affected version range (if applicable). */
readonly affectedRange?: string;
/** Fixed version (if status is fixed). */
readonly fixedVersion?: string;
/** Observation timestamp. */
readonly timestamp: string;
/** Whether this observation has a DSSE signature. */
readonly signed: boolean;
/** DSSE envelope reference (if signed). */
readonly dsseRef?: string;
/** Whether this is the latest observation from this source. */
readonly isLatest: boolean;
}
/**
* A status transition detected in the timeline.
*/
export interface VexStatusTransition {
/** Transition ID. */
readonly id: string;
/** Source ID. */
readonly sourceId: string;
/** Previous status. */
readonly fromStatus: VexStatus;
/** New status. */
readonly toStatus: VexStatus;
/** Previous confidence. */
readonly fromConfidence: VexConfidence;
/** New confidence. */
readonly toConfidence: VexConfidence;
/** Transition timestamp. */
readonly timestamp: string;
/** Reason for transition (if provided). */
readonly reason?: string;
}
// ============================================================================
// Conflict Types
// ============================================================================
/**
* Conflict type between sources.
*/
export type VexConflictType =
| 'status-mismatch'
| 'severity-mismatch'
| 'affected-range-divergence'
| 'confidence-divergence';
/**
* A conflict between VEX sources.
*/
export interface VexConflict {
/** Conflict ID. */
readonly id: string;
/** Conflict type. */
readonly type: VexConflictType;
/** Sources involved in conflict. */
readonly sources: readonly VexSource[];
/** Observations in conflict. */
readonly observations: readonly VexObservation[];
/** Conflict description. */
readonly description: string;
/** Detected timestamp. */
readonly detectedAt: string;
/** Whether conflict is resolved. */
readonly resolved: boolean;
/** Resolution notes (if resolved). */
readonly resolution?: string;
}
// ============================================================================
// Timeline Types
// ============================================================================
/**
* A row in the timeline representing a source's observations over time.
*/
export interface VexTimelineRow {
/** Source information. */
readonly source: VexSource;
/** Observations from this source (time-ordered). */
readonly observations: readonly VexObservation[];
/** Latest observation from this source. */
readonly latestObservation: VexObservation | null;
/** Status transitions detected. */
readonly transitions: readonly VexStatusTransition[];
/** Whether this source has conflicts with others. */
readonly hasConflicts: boolean;
}
/**
* The winning consensus status and rationale.
*/
export interface VexConsensus {
/** Consensus status. */
readonly status: VexStatus;
/** Consensus confidence. */
readonly confidence: VexConfidence;
/** Sources that agree with consensus. */
readonly agreeSources: readonly VexSource[];
/** Sources that disagree with consensus. */
readonly disagreeSources: readonly VexSource[];
/** Rationale for consensus decision. */
readonly rationale: string;
/** Computed timestamp. */
readonly computedAt: string;
}
/**
* Full VEX timeline state.
*/
export interface VexTimelineState {
/** Advisory/vulnerability ID. */
readonly advisoryId: string;
/** Product/component being assessed. */
readonly product: string;
/** Timeline rows (one per source). */
readonly rows: readonly VexTimelineRow[];
/** All observations across sources. */
readonly allObservations: readonly VexObservation[];
/** Detected conflicts. */
readonly conflicts: readonly VexConflict[];
/** Consensus status (if computed). */
readonly consensus: VexConsensus | null;
/** Timeline start date. */
readonly startDate: string;
/** Timeline end date. */
readonly endDate: string;
/** Whether data is loading. */
readonly loading: boolean;
/** Last updated timestamp. */
readonly lastUpdated: string;
}
// ============================================================================
// Filter Types
// ============================================================================
/**
* Filter options for VEX timeline.
*/
export interface VexTimelineFilter {
/** Filter by source types. */
readonly sourceTypes?: readonly VexSourceType[];
/** Filter by confidence levels. */
readonly confidenceLevels?: readonly VexConfidence[];
/** Show only sources with conflicts. */
readonly conflictsOnly?: boolean;
/** Date range start. */
readonly fromDate?: string;
/** Date range end. */
readonly toDate?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Gets the display label for a VEX status.
*/
export function getStatusLabel(status: VexStatus): string {
const labels: Record<VexStatus, string> = {
not_affected: 'Not Affected',
affected: 'Affected',
fixed: 'Fixed',
under_investigation: 'Under Investigation',
unknown: 'Unknown',
};
return labels[status] ?? status;
}
/**
* Gets the display color class for a VEX status.
*/
export function getStatusClass(status: VexStatus): string {
const classes: Record<VexStatus, string> = {
not_affected: 'status--not-affected',
affected: 'status--affected',
fixed: 'status--fixed',
under_investigation: 'status--investigating',
unknown: 'status--unknown',
};
return classes[status] ?? 'status--unknown';
}
/**
* Gets the icon for a VEX status.
*/
export function getStatusIcon(status: VexStatus): string {
const icons: Record<VexStatus, string> = {
not_affected: '✓',
affected: '!',
fixed: '✓',
under_investigation: '?',
unknown: '?',
};
return icons[status] ?? '?';
}
/**
* Gets the display label for a confidence level.
*/
export function getConfidenceLabel(confidence: VexConfidence): string {
const labels: Record<VexConfidence, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
};
return labels[confidence] ?? confidence;
}
/**
* Gets the display label for a source type.
*/
export function getSourceTypeLabel(type: VexSourceType): string {
const labels: Record<VexSourceType, string> = {
nvd: 'NVD',
vendor: 'Vendor',
distro: 'Distribution',
internal: 'Internal',
osv: 'OSV',
github: 'GitHub',
manual: 'Manual',
unknown: 'Unknown',
};
return labels[type] ?? type;
}
/**
* Gets the display label for a justification.
*/
export function getJustificationLabel(justification: VexJustification): string {
const labels: Record<VexJustification, string> = {
component_not_present: 'Component not present',
vulnerable_code_not_present: 'Vulnerable code not present',
vulnerable_code_cannot_be_controlled_by_adversary: 'Code cannot be controlled by adversary',
vulnerable_code_not_in_execute_path: 'Not in execute path',
inline_mitigations_already_exist: 'Mitigations exist',
none: 'No justification provided',
};
return labels[justification] ?? justification;
}
/**
* Gets the display label for a conflict type.
*/
export function getConflictTypeLabel(type: VexConflictType): string {
const labels: Record<VexConflictType, string> = {
'status-mismatch': 'Status Mismatch',
'severity-mismatch': 'Severity Mismatch',
'affected-range-divergence': 'Affected Range Divergence',
'confidence-divergence': 'Confidence Divergence',
};
return labels[type] ?? type;
}
/**
* Sorts observations by timestamp (newest first).
*/
export function sortObservationsByTime(
observations: readonly VexObservation[],
direction: 'asc' | 'desc' = 'desc'
): VexObservation[] {
return [...observations].sort((a, b) => {
const diff = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
return direction === 'desc' ? diff : -diff;
});
}
/**
* Groups observations by source.
*/
export function groupObservationsBySource(
observations: readonly VexObservation[]
): Map<string, VexObservation[]> {
const grouped = new Map<string, VexObservation[]>();
for (const obs of observations) {
const sourceId = obs.source.id;
if (!grouped.has(sourceId)) {
grouped.set(sourceId, []);
}
grouped.get(sourceId)!.push(obs);
}
// Sort each group by time
for (const [, group] of grouped) {
group.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
}
return grouped;
}
/**
* Detects transitions in a sequence of observations.
*/
export function detectTransitions(observations: readonly VexObservation[]): VexStatusTransition[] {
if (observations.length < 2) return [];
const transitions: VexStatusTransition[] = [];
const sorted = sortObservationsByTime(observations, 'asc');
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1];
const curr = sorted[i];
if (prev.status !== curr.status || prev.confidence !== curr.confidence) {
transitions.push({
id: `transition-${prev.id}-${curr.id}`,
sourceId: curr.source.id,
fromStatus: prev.status,
toStatus: curr.status,
fromConfidence: prev.confidence,
toConfidence: curr.confidence,
timestamp: curr.timestamp,
reason: curr.notes,
});
}
}
return transitions;
}
/**
* Formats a timestamp for display.
*/
export function formatTimestamp(timestamp: string): string {
try {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return timestamp;
}
}
/**
* Formats a timestamp with time for tooltip.
*/
export function formatTimestampFull(timestamp: string): string {
try {
const date = new Date(timestamp);
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return timestamp;
}
}

View File

@@ -0,0 +1,365 @@
/**
* VEX Timeline Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*
* Service for fetching and processing VEX timeline data from Concelier.
*/
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, map, tap, catchError, finalize } from 'rxjs';
import {
VexTimelineState,
VexTimelineRow,
VexObservation,
VexConflict,
VexConsensus,
VexSource,
VexStatus,
VexConfidence,
VexTimelineFilter,
VexSourceType,
groupObservationsBySource,
detectTransitions,
sortObservationsByTime,
} from '../models/vex-timeline.models';
import { TenantActivationService } from '../../../core/auth/tenant-activation.service';
import { generateTraceId } from '../../../core/api/trace.util';
/**
* VEX Timeline Service.
* Fetches and processes VEX observation timeline from Concelier.
*/
@Injectable({ providedIn: 'root' })
export class VexTimelineService {
private readonly http = inject(HttpClient);
private readonly tenantService = inject(TenantActivationService, { optional: true });
private readonly baseUrl = '/api/v1/concelier';
// Internal state
private readonly _state = signal<VexTimelineState | null>(null);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _filter = signal<VexTimelineFilter>({});
// Public readonly signals
readonly state = this._state.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly filter = this._filter.asReadonly();
// Computed: filtered rows
readonly filteredRows = computed(() => {
const state = this._state();
const filter = this._filter();
if (!state) return [];
return state.rows.filter((row) => {
// Filter by source type
if (filter.sourceTypes?.length && !filter.sourceTypes.includes(row.source.type)) {
return false;
}
// Filter by confidence
if (filter.confidenceLevels?.length) {
const latestConf = row.latestObservation?.confidence;
if (latestConf && !filter.confidenceLevels.includes(latestConf)) {
return false;
}
}
// Filter by conflicts
if (filter.conflictsOnly && !row.hasConflicts) {
return false;
}
return true;
});
});
// Computed: conflict count
readonly conflictCount = computed(() => {
const state = this._state();
return state?.conflicts.filter((c) => !c.resolved).length ?? 0;
});
// Computed: unique source types
readonly availableSourceTypes = computed(() => {
const state = this._state();
if (!state) return [];
const types = new Set(state.rows.map((r) => r.source.type));
return Array.from(types);
});
/**
* Load VEX timeline for an advisory and product.
*/
loadTimeline(advisoryId: string, product: string): Observable<VexTimelineState> {
this._loading.set(true);
this._error.set(null);
const url = `${this.baseUrl}/advisories/${encodeURIComponent(advisoryId)}/timeline`;
const params = new HttpParams().set('product', product);
const headers = this.buildHeaders();
return this.http.get<VexTimelineApiResponse>(url, { params, headers }).pipe(
map((response) => this.mapApiResponse(response, advisoryId, product)),
tap((state) => this._state.set(state)),
catchError((err) => {
const message = err.error?.message ?? err.message ?? 'Failed to load VEX timeline';
this._error.set(message);
return of(this.createEmptyState(advisoryId, product));
}),
finalize(() => this._loading.set(false))
);
}
/**
* Verify DSSE signature for an observation.
*/
verifyObservation(observationId: string): Observable<VerifyResult> {
const url = `${this.baseUrl}/observations/${encodeURIComponent(observationId)}/verify`;
const headers = this.buildHeaders();
return this.http.post<VerifyResult>(url, {}, { headers }).pipe(
catchError((err) => {
return of({
valid: false,
error: err.error?.message ?? 'Verification failed',
});
})
);
}
/**
* Update filter options.
*/
setFilter(filter: Partial<VexTimelineFilter>): void {
this._filter.update((current) => ({ ...current, ...filter }));
}
/**
* Clear all filters.
*/
clearFilter(): void {
this._filter.set({});
}
/**
* Clear current state.
*/
clear(): void {
this._state.set(null);
this._error.set(null);
this._filter.set({});
}
// =========================================================================
// Private Methods
// =========================================================================
private mapApiResponse(
response: VexTimelineApiResponse,
advisoryId: string,
product: string
): VexTimelineState {
const observations = this.mapObservations(response.observations ?? []);
const conflicts = this.mapConflicts(response.conflicts ?? []);
const consensus = response.consensus ? this.mapConsensus(response.consensus) : null;
// Group observations by source and build rows
const grouped = groupObservationsBySource(observations);
const rows: VexTimelineRow[] = [];
for (const [sourceId, sourceObs] of grouped) {
const source = sourceObs[0].source;
const sorted = sortObservationsByTime(sourceObs, 'asc');
const latestObs = sorted[sorted.length - 1] ?? null;
const transitions = detectTransitions(sorted);
const hasConflicts = conflicts.some((c) =>
c.sources.some((s) => s.id === sourceId)
);
rows.push({
source,
observations: sorted,
latestObservation: latestObs,
transitions,
hasConflicts,
});
}
// Sort rows by source trust level (highest first), then by name
rows.sort((a, b) => {
const trustA = a.source.trustLevel ?? 50;
const trustB = b.source.trustLevel ?? 50;
if (trustA !== trustB) return trustB - trustA;
return a.source.name.localeCompare(b.source.name);
});
// Compute date range
const allTimestamps = observations.map((o) => new Date(o.timestamp).getTime());
const startDate = allTimestamps.length > 0
? new Date(Math.min(...allTimestamps)).toISOString()
: new Date().toISOString();
const endDate = allTimestamps.length > 0
? new Date(Math.max(...allTimestamps)).toISOString()
: new Date().toISOString();
return {
advisoryId,
product,
rows,
allObservations: observations,
conflicts,
consensus,
startDate,
endDate,
loading: false,
lastUpdated: new Date().toISOString(),
};
}
private mapObservations(apiObs: ApiObservation[]): VexObservation[] {
return apiObs.map((o) => ({
id: o.id,
source: this.mapSource(o.source),
advisoryId: o.advisoryId,
product: o.product,
status: o.status as VexStatus,
justification: o.justification,
notes: o.notes,
confidence: (o.confidence ?? 'medium') as VexConfidence,
affectedRange: o.affectedRange,
fixedVersion: o.fixedVersion,
timestamp: o.timestamp,
signed: o.signed ?? false,
dsseRef: o.dsseRef,
isLatest: o.isLatest ?? false,
}));
}
private mapSource(apiSource: ApiSource): VexSource {
return {
id: apiSource.id,
type: (apiSource.type ?? 'unknown') as VexSourceType,
name: apiSource.name,
url: apiSource.url,
trustLevel: apiSource.trustLevel,
};
}
private mapConflicts(apiConflicts: ApiConflict[]): VexConflict[] {
return apiConflicts.map((c) => ({
id: c.id,
type: c.type as VexConflict['type'],
sources: c.sources.map((s) => this.mapSource(s)),
observations: this.mapObservations(c.observations ?? []),
description: c.description,
detectedAt: c.detectedAt,
resolved: c.resolved ?? false,
resolution: c.resolution,
}));
}
private mapConsensus(apiConsensus: ApiConsensus): VexConsensus {
return {
status: apiConsensus.status as VexStatus,
confidence: (apiConsensus.confidence ?? 'medium') as VexConfidence,
agreeSources: apiConsensus.agreeSources?.map((s) => this.mapSource(s)) ?? [],
disagreeSources: apiConsensus.disagreeSources?.map((s) => this.mapSource(s)) ?? [],
rationale: apiConsensus.rationale ?? 'No rationale provided',
computedAt: apiConsensus.computedAt ?? new Date().toISOString(),
};
}
private createEmptyState(advisoryId: string, product: string): VexTimelineState {
return {
advisoryId,
product,
rows: [],
allObservations: [],
conflicts: [],
consensus: null,
startDate: new Date().toISOString(),
endDate: new Date().toISOString(),
loading: false,
lastUpdated: new Date().toISOString(),
};
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'X-Trace-Id': generateTraceId(),
};
const tenantId = this.tenantService?.activeTenantId();
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return headers;
}
}
// ============================================================================
// API Response Types
// ============================================================================
interface VexTimelineApiResponse {
observations?: ApiObservation[];
conflicts?: ApiConflict[];
consensus?: ApiConsensus;
}
interface ApiObservation {
id: string;
source: ApiSource;
advisoryId: string;
product: string;
status: string;
justification?: string;
notes?: string;
confidence?: string;
affectedRange?: string;
fixedVersion?: string;
timestamp: string;
signed?: boolean;
dsseRef?: string;
isLatest?: boolean;
}
interface ApiSource {
id: string;
type?: string;
name: string;
url?: string;
trustLevel?: number;
}
interface ApiConflict {
id: string;
type: string;
sources: ApiSource[];
observations?: ApiObservation[];
description: string;
detectedAt: string;
resolved?: boolean;
resolution?: string;
}
interface ApiConsensus {
status: string;
confidence?: string;
agreeSources?: ApiSource[];
disagreeSources?: ApiSource[];
rationale?: string;
computedAt?: string;
}
interface VerifyResult {
valid: boolean;
signerIdentity?: string;
timestamp?: string;
error?: string;
}

View File

@@ -0,0 +1,18 @@
/**
* VEX Timeline Routes
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*/
import { Routes } from '@angular/router';
export const VEX_TIMELINE_ROUTES: Routes = [
{
path: ':advisoryId/:product',
loadComponent: () =>
import('./components/vex-timeline/vex-timeline.component').then(
(m) => m.VexTimelineComponent
),
title: 'VEX Timeline',
},
];

View File

@@ -0,0 +1,18 @@
/**
* Auditor Workspace Routes
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*/
import { Routes } from '@angular/router';
export const AUDITOR_WORKSPACE_ROUTES: Routes = [
{
path: ':artifactDigest',
loadComponent: () =>
import('./components/auditor-workspace/auditor-workspace.component').then(
(m) => m.AuditorWorkspaceComponent
),
title: 'Auditor Workspace',
},
];

View File

@@ -0,0 +1,525 @@
/**
* Auditor Workspace Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter, ActivatedRoute } from '@angular/router';
import { of, BehaviorSubject } from 'rxjs';
import { signal } from '@angular/core';
import { AuditorWorkspaceComponent } from './auditor-workspace.component';
import {
AUDITOR_WORKSPACE_SERVICE,
IAuditorWorkspaceService,
} from '../../services/auditor-workspace.service';
import {
ReviewRibbonSummary,
QuietTriageItem,
ExportStatus,
ExportResult,
AuditActionResult,
} from '../../models/auditor-workspace.models';
describe('AuditorWorkspaceComponent', () => {
let component: AuditorWorkspaceComponent;
let fixture: ComponentFixture<AuditorWorkspaceComponent>;
let mockService: jasmine.SpyObj<IAuditorWorkspaceService>;
const mockSummary: ReviewRibbonSummary = {
policyVerdict: 'pass',
policyPackName: 'production-security',
policyVersion: '2.1.0',
attestationStatus: 'verified',
coverageScore: 94,
openExceptionsCount: 2,
evaluatedAt: '2024-01-25T10:00:00Z',
};
const mockQuietTriageItems: QuietTriageItem[] = [
{
id: 'qt-1',
findingId: 'finding-1',
cveId: 'CVE-2024-99999',
title: 'Potential memory leak in parser',
severity: 'low',
confidence: 'low',
componentName: 'parser-lib',
componentVersion: '1.2.3',
addedAt: '2024-01-20T10:00:00Z',
reason: 'Low confidence from automated scan',
},
{
id: 'qt-2',
findingId: 'finding-2',
title: 'Deprecated API usage',
severity: 'info',
confidence: 'medium',
componentName: 'legacy-util',
componentVersion: '0.9.0',
addedAt: '2024-01-22T14:00:00Z',
reason: 'Needs manual review for migration path',
},
];
const mockExportResult: ExportResult = {
success: true,
downloadUrl: '/api/export/downloads/sha256:abc123/audit-pack.zip',
filename: 'audit-pack-sha256-abc123.zip',
checksum: 'sha256:abcdef1234567890',
checksumAlgorithm: 'SHA-256',
sizeBytes: 2457600,
completedAt: '2024-01-25T10:30:00Z',
};
beforeEach(async () => {
mockService = {
loading: signal(false),
error: signal<string | null>(null),
reviewSummary: signal<ReviewRibbonSummary | null>(null),
quietTriageItems: signal<QuietTriageItem[]>([]),
exportStatus: signal<ExportStatus>('idle'),
exportResult: signal<ExportResult | null>(null),
loadWorkspace: jasmine.createSpy('loadWorkspace').and.returnValue(of(undefined)),
exportAuditPack: jasmine.createSpy('exportAuditPack').and.returnValue(of(mockExportResult)),
performAuditAction: jasmine.createSpy('performAuditAction').and.returnValue(
of({
success: true,
actionType: 'recheck',
itemId: 'qt-1',
signedEntryId: 'audit-entry-123',
timestamp: new Date().toISOString(),
} as AuditActionResult)
),
clear: jasmine.createSpy('clear'),
} as jasmine.SpyObj<IAuditorWorkspaceService>;
await TestBed.configureTestingModule({
imports: [AuditorWorkspaceComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
params: of({ artifactDigest: 'sha256:abc123def456' }),
},
},
{
provide: AUDITOR_WORKSPACE_SERVICE,
useValue: mockService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(AuditorWorkspaceComponent);
component = fixture.componentInstance;
});
describe('Initialization', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load workspace on init', () => {
fixture.detectChanges();
expect(mockService.loadWorkspace).toHaveBeenCalledWith('sha256:abc123def456');
});
it('should clear service on destroy', () => {
fixture.detectChanges();
fixture.destroy();
expect(mockService.clear).toHaveBeenCalled();
});
});
describe('Layout Assembly', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.quietTriageItems = signal(mockQuietTriageItems);
fixture.detectChanges();
});
it('should render review ribbon section', () => {
const compiled = fixture.nativeElement as HTMLElement;
const ribbon = compiled.querySelector('.review-ribbon');
expect(ribbon).toBeTruthy();
});
it('should display policy verdict in review ribbon', () => {
const compiled = fixture.nativeElement as HTMLElement;
const verdict = compiled.querySelector('.verdict-badge');
expect(verdict).toBeTruthy();
expect(verdict?.textContent?.toLowerCase()).toContain('pass');
});
it('should display attestation status', () => {
const compiled = fixture.nativeElement as HTMLElement;
const status = compiled.querySelector('.attestation-status');
expect(status).toBeTruthy();
expect(status?.textContent?.toLowerCase()).toContain('verified');
});
it('should display coverage score', () => {
const compiled = fixture.nativeElement as HTMLElement;
const coverage = compiled.querySelector('.coverage-score');
expect(coverage).toBeTruthy();
expect(coverage?.textContent).toContain('94');
});
it('should display open exceptions count', () => {
const compiled = fixture.nativeElement as HTMLElement;
const exceptions = compiled.querySelector('.exceptions-count');
expect(exceptions).toBeTruthy();
expect(exceptions?.textContent).toContain('2');
});
it('should render export audit-pack section', () => {
const compiled = fixture.nativeElement as HTMLElement;
const exportSection = compiled.querySelector('.export-section');
expect(exportSection).toBeTruthy();
});
it('should render quiet-triage lane', () => {
const compiled = fixture.nativeElement as HTMLElement;
const triageLane = compiled.querySelector('.quiet-triage-lane');
expect(triageLane).toBeTruthy();
});
it('should display quiet-triage items', () => {
const compiled = fixture.nativeElement as HTMLElement;
const items = compiled.querySelectorAll('.triage-item');
expect(items.length).toBe(2);
});
});
describe('Export Options Dialog', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
fixture.detectChanges();
});
it('should have export options checkboxes', () => {
const compiled = fixture.nativeElement as HTMLElement;
const pqcCheckbox = compiled.querySelector('input[id="include-pqc"]');
const rawDocsCheckbox = compiled.querySelector('input[id="include-raw-docs"]');
const redactPiiCheckbox = compiled.querySelector('input[id="redact-pii"]');
expect(pqcCheckbox).toBeTruthy();
expect(rawDocsCheckbox).toBeTruthy();
expect(redactPiiCheckbox).toBeTruthy();
});
it('should toggle export options', () => {
component.toggleExportOption('includePqc');
expect(component.exportOptions().includePqc).toBe(true);
component.toggleExportOption('includePqc');
expect(component.exportOptions().includePqc).toBe(false);
});
});
describe('Export Flow', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
fixture.detectChanges();
});
it('should trigger export when button clicked', () => {
component.triggerExport();
expect(mockService.exportAuditPack).toHaveBeenCalledWith(
'sha256:abc123def456',
jasmine.objectContaining({
includePqc: jasmine.any(Boolean),
includeRawDocs: jasmine.any(Boolean),
redactPii: jasmine.any(Boolean),
})
);
});
it('should display progress indicator during export', fakeAsync(() => {
mockService.exportStatus = signal<ExportStatus>('preparing');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const progressIndicator = compiled.querySelector('.export-progress');
expect(progressIndicator).toBeTruthy();
}));
it('should display checksum on export completion', fakeAsync(() => {
mockService.exportStatus = signal<ExportStatus>('complete');
mockService.exportResult = signal<ExportResult | null>(mockExportResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const checksum = compiled.querySelector('.checksum-display');
expect(checksum).toBeTruthy();
expect(checksum?.textContent).toContain('sha256:abcdef1234567890');
}));
it('should display download link on success', fakeAsync(() => {
mockService.exportStatus = signal<ExportStatus>('complete');
mockService.exportResult = signal<ExportResult | null>(mockExportResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const downloadLink = compiled.querySelector('.download-link');
expect(downloadLink).toBeTruthy();
}));
it('should display file size on completion', fakeAsync(() => {
mockService.exportStatus = signal<ExportStatus>('complete');
mockService.exportResult = signal<ExportResult | null>(mockExportResult);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const fileSize = compiled.querySelector('.file-size');
expect(fileSize).toBeTruthy();
expect(fileSize?.textContent).toContain('2.3 MB');
}));
it('should display error message on export failure', fakeAsync(() => {
mockService.exportStatus = signal<ExportStatus>('error');
mockService.exportResult = signal<ExportResult | null>({
success: false,
errorMessage: 'Export failed: timeout',
completedAt: new Date().toISOString(),
});
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const error = compiled.querySelector('.export-error');
expect(error).toBeTruthy();
expect(error?.textContent).toContain('Export failed');
}));
});
describe('Quiet-Triage Actions', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.quietTriageItems = signal(mockQuietTriageItems);
fixture.detectChanges();
});
it('should have recheck button for each item', () => {
const compiled = fixture.nativeElement as HTMLElement;
const recheckButtons = compiled.querySelectorAll('.action-recheck');
expect(recheckButtons.length).toBe(2);
});
it('should have promote button for each item', () => {
const compiled = fixture.nativeElement as HTMLElement;
const promoteButtons = compiled.querySelectorAll('.action-promote');
expect(promoteButtons.length).toBe(2);
});
it('should have exception button for each item', () => {
const compiled = fixture.nativeElement as HTMLElement;
const exceptionButtons = compiled.querySelectorAll('.action-exception');
expect(exceptionButtons.length).toBe(2);
});
it('should call performAuditAction on recheck click', () => {
component.performAction('qt-1', 'recheck');
expect(mockService.performAuditAction).toHaveBeenCalledWith('qt-1', 'recheck');
});
it('should call performAuditAction on promote click', () => {
component.performAction('qt-1', 'promote');
expect(mockService.performAuditAction).toHaveBeenCalledWith('qt-1', 'promote');
});
it('should call performAuditAction on exception click', () => {
component.performAction('qt-1', 'exception');
expect(mockService.performAuditAction).toHaveBeenCalledWith('qt-1', 'exception');
});
it('should emit signed audit entry on action success', fakeAsync(() => {
const emittedActions: AuditActionResult[] = [];
component.auditActionEmitted.subscribe((action) => emittedActions.push(action));
component.performAction('qt-1', 'recheck');
tick();
expect(emittedActions.length).toBe(1);
expect(emittedActions[0].success).toBe(true);
expect(emittedActions[0].signedEntryId).toBe('audit-entry-123');
}));
});
describe('Quiet-Triage Display', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.quietTriageItems = signal(mockQuietTriageItems);
fixture.detectChanges();
});
it('should display item title', () => {
const compiled = fixture.nativeElement as HTMLElement;
const titles = compiled.querySelectorAll('.triage-item-title');
expect(titles[0]?.textContent).toContain('Potential memory leak');
});
it('should display CVE ID when present', () => {
const compiled = fixture.nativeElement as HTMLElement;
const cveIds = compiled.querySelectorAll('.triage-item-cve');
expect(cveIds[0]?.textContent).toContain('CVE-2024-99999');
});
it('should display severity badge', () => {
const compiled = fixture.nativeElement as HTMLElement;
const severityBadges = compiled.querySelectorAll('.severity-badge');
expect(severityBadges.length).toBeGreaterThan(0);
});
it('should display confidence badge', () => {
const compiled = fixture.nativeElement as HTMLElement;
const confidenceBadges = compiled.querySelectorAll('.confidence-badge');
expect(confidenceBadges.length).toBeGreaterThan(0);
});
it('should display component info', () => {
const compiled = fixture.nativeElement as HTMLElement;
const componentInfo = compiled.querySelectorAll('.triage-item-component');
expect(componentInfo[0]?.textContent).toContain('parser-lib');
expect(componentInfo[0]?.textContent).toContain('1.2.3');
});
it('should display reason when present', () => {
const compiled = fixture.nativeElement as HTMLElement;
const reasons = compiled.querySelectorAll('.triage-item-reason');
expect(reasons[0]?.textContent).toContain('Low confidence from automated scan');
});
});
describe('Collapsible Quiet-Triage Lane', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.quietTriageItems = signal(mockQuietTriageItems);
fixture.detectChanges();
});
it('should have expand/collapse toggle', () => {
const compiled = fixture.nativeElement as HTMLElement;
const toggle = compiled.querySelector('.triage-toggle');
expect(toggle).toBeTruthy();
});
it('should toggle lane expanded state', () => {
expect(component.quietTriageExpanded()).toBe(true);
component.toggleQuietTriage();
expect(component.quietTriageExpanded()).toBe(false);
});
it('should display item count in header', () => {
const compiled = fixture.nativeElement as HTMLElement;
const header = compiled.querySelector('.triage-header');
expect(header?.textContent).toContain('2');
});
});
describe('Loading and Error States', () => {
it('should display loading indicator when loading', () => {
mockService.loading = signal(true);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const loading = compiled.querySelector('.loading-indicator');
expect(loading).toBeTruthy();
});
it('should display error message when error occurs', () => {
mockService.error = signal('Failed to load workspace');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const error = compiled.querySelector('.error-message');
expect(error).toBeTruthy();
expect(error?.textContent).toContain('Failed to load workspace');
});
});
describe('Verify Offline Tooltip', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.exportStatus = signal<ExportStatus>('complete');
mockService.exportResult = signal<ExportResult | null>(mockExportResult);
fixture.detectChanges();
});
it('should display verify offline tooltip on export completion', () => {
const compiled = fixture.nativeElement as HTMLElement;
const tooltip = compiled.querySelector('.verify-offline-hint');
expect(tooltip).toBeTruthy();
});
});
describe('Accessibility', () => {
beforeEach(() => {
mockService.reviewSummary = signal(mockSummary);
mockService.quietTriageItems = signal(mockQuietTriageItems);
fixture.detectChanges();
});
it('should have aria-label on export button', () => {
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.export-button');
expect(button?.getAttribute('aria-label')).toBeTruthy();
});
it('should have aria-labels on action buttons', () => {
const compiled = fixture.nativeElement as HTMLElement;
const actionButtons = compiled.querySelectorAll('.triage-action-btn');
actionButtons.forEach((btn) => {
expect(btn.getAttribute('aria-label')).toBeTruthy();
});
});
it('should have proper heading hierarchy', () => {
const compiled = fixture.nativeElement as HTMLElement;
const h1 = compiled.querySelector('h1');
const h2s = compiled.querySelectorAll('h2');
expect(h1).toBeTruthy();
expect(h2s.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,992 @@
/**
* Auditor Workspace Component
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*
* Auditor-focused workspace with Review Ribbon, Export Audit-Pack,
* and Quiet-Triage lane with signed audit actions.
*/
import {
Component,
input,
output,
signal,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { EvidenceRibbonComponent } from '../../../../evidence-ribbon/components/evidence-ribbon/evidence-ribbon.component';
import {
ReviewRibbonSummary,
QuietTriageItem,
ExportOptions,
ExportResult,
AuditActionType,
AuditActionResult,
getVerdictClass,
getVerdictLabel,
getVerdictIcon,
getAttestationLabel,
getConfidenceClass,
formatFileSize,
} from '../../models/auditor-workspace.models';
import { AuditorWorkspaceService } from '../../services/auditor-workspace.service';
/**
* Event emitted when an audit action is performed.
*/
export interface AuditActionEvent {
readonly action: AuditActionType;
readonly item: QuietTriageItem;
readonly result?: AuditActionResult;
}
/**
* Auditor Workspace Component.
*
* Assembles:
* - Review ribbon with policy/attestation/coverage summary
* - Export Audit-Pack CTA with options dialog
* - Quiet-Triage lane with signed action buttons
*
* @example
* ```html
* <stella-auditor-workspace
* [artifactDigest]="artifact.digest"
* (auditAction)="handleAuditAction($event)"
* />
* ```
*/
@Component({
selector: 'stella-auditor-workspace',
standalone: true,
imports: [CommonModule, FormsModule, EvidenceRibbonComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-workspace">
<!-- Evidence Ribbon -->
<section class="audit-workspace__ribbon">
<stella-evidence-ribbon
[artifactDigest]="artifactDigest()"
[showVex]="true"
[showPolicy]="true"
(pillClick)="onPillClick($event)"
/>
</section>
<!-- Review Ribbon Summary -->
@if (reviewSummary(); as summary) {
<section class="review-ribbon">
<div class="review-ribbon__item">
<span
class="review-ribbon__verdict"
[class]="getVerdictClass(summary.policyVerdict)"
>
<span class="review-ribbon__verdict-icon" aria-hidden="true">
{{ getVerdictIcon(summary.policyVerdict) }}
</span>
{{ getVerdictLabel(summary.policyVerdict) }}
</span>
<span class="review-ribbon__label">Policy</span>
@if (summary.policyPackName) {
<span class="review-ribbon__detail">{{ summary.policyPackName }}</span>
}
</div>
<div class="review-ribbon__item">
<span
class="review-ribbon__status"
[class.review-ribbon__status--verified]="summary.attestationStatus === 'verified'"
[class.review-ribbon__status--unverified]="summary.attestationStatus !== 'verified'"
>
{{ getAttestationLabel(summary.attestationStatus) }}
</span>
<span class="review-ribbon__label">Attestation</span>
</div>
<div class="review-ribbon__item">
<span class="review-ribbon__score">{{ summary.coverageScore }}%</span>
<span class="review-ribbon__label">Coverage</span>
</div>
<div class="review-ribbon__item">
<span
class="review-ribbon__exceptions"
[class.review-ribbon__exceptions--has]="summary.openExceptionsCount > 0"
>
{{ summary.openExceptionsCount }}
</span>
<span class="review-ribbon__label">Open Exceptions</span>
</div>
</section>
}
<!-- Main Content -->
<div class="audit-workspace__content">
<!-- Export Panel -->
<section class="audit-workspace__export">
<div class="export-panel">
<div class="export-panel__header">
<h3 class="export-panel__title">Export Audit-Pack</h3>
<p class="export-panel__desc">
Generate OCI-referrer bundle with attestations and evidence
</p>
</div>
<!-- Export Options -->
<div class="export-options">
<label class="export-option">
<input
type="checkbox"
[checked]="exportOptions().includePqc"
(change)="updateExportOption('includePqc', $event)"
/>
<span class="export-option__label">Include PQC signatures</span>
<span class="export-option__hint">Post-quantum cryptography if available</span>
</label>
<label class="export-option">
<input
type="checkbox"
[checked]="exportOptions().includeRawDocs"
(change)="updateExportOption('includeRawDocs', $event)"
/>
<span class="export-option__label">Include raw documents</span>
<span class="export-option__hint">SBOM, VEX, and policy files</span>
</label>
<label class="export-option">
<input
type="checkbox"
[checked]="exportOptions().redactPii"
(change)="updateExportOption('redactPii', $event)"
/>
<span class="export-option__label">Redact PII</span>
<span class="export-option__hint">Remove personal identifiable information</span>
</label>
</div>
@if (exportStatus() === 'idle') {
<button
type="button"
class="export-panel__cta"
(click)="startExport()"
>
<span class="export-panel__cta-icon" aria-hidden="true">📦</span>
Generate Audit-Pack
</button>
}
@if (exportStatus() === 'preparing' || exportStatus() === 'exporting') {
<div class="export-panel__progress">
<span class="export-panel__spinner"></span>
<span>
{{ exportStatus() === 'preparing' ? 'Preparing bundle...' : 'Exporting...' }}
</span>
</div>
}
@if (exportResult(); as result) {
<div
class="export-panel__result"
[class.export-panel__result--success]="result.success"
[class.export-panel__result--error]="!result.success"
>
@if (result.success) {
<div class="export-result__header">
<span class="export-result__icon" aria-hidden="true">✓</span>
<span>Export Complete</span>
</div>
@if (result.filename) {
<div class="export-result__filename">{{ result.filename }}</div>
}
@if (result.sizeBytes) {
<div class="export-result__size">{{ formatFileSize(result.sizeBytes) }}</div>
}
@if (result.checksum) {
<div class="export-result__checksum">
<span class="checksum-label">{{ result.checksumAlgorithm }}:</span>
<code class="checksum-value">{{ result.checksum }}</code>
<button
type="button"
class="checksum-copy"
(click)="copyChecksum(result.checksum)"
title="Copy checksum"
>
📋
</button>
</div>
}
<div class="export-result__actions">
<a
[href]="result.downloadUrl"
class="export-result__download"
download
>
Download
</a>
<span
class="export-result__verify-hint"
title="stella verify audit-pack.zip"
>
Verify offline: <code>stella verify audit-pack.zip</code>
</span>
</div>
} @else {
<div class="export-result__header">
<span class="export-result__icon" aria-hidden="true">✗</span>
<span>Export Failed</span>
</div>
<p class="export-result__error">{{ result.errorMessage }}</p>
<button
type="button"
class="export-result__retry"
(click)="startExport()"
>
Retry
</button>
}
</div>
}
</div>
</section>
<!-- Quiet-Triage Lane -->
<section class="audit-workspace__triage">
<div class="triage-lane">
<div class="triage-lane__header">
<h3 class="triage-lane__title">Quiet-Triage</h3>
<span class="triage-lane__count">{{ quietTriageItems().length }}</span>
<span class="triage-lane__hint">Low-confidence items requiring manual review</span>
</div>
@if (loading()) {
<div class="triage-lane__loading">
<span class="triage-lane__spinner"></span>
Loading...
</div>
} @else if (quietTriageItems().length === 0) {
<div class="triage-lane__empty">
No items in quiet-triage queue.
</div>
} @else {
<ul class="triage-list" role="list">
@for (item of quietTriageItems(); track item.id) {
<li class="triage-item">
<div class="triage-item__header">
<span
class="triage-item__severity severity--{{ item.severity }}"
>
{{ item.severity | uppercase }}
</span>
@if (item.cveId) {
<span class="triage-item__cve">{{ item.cveId }}</span>
}
<span
class="triage-item__confidence"
[class]="getConfidenceClass(item.confidence)"
>
{{ item.confidence }} confidence
</span>
</div>
<div class="triage-item__title">{{ item.title }}</div>
<div class="triage-item__component">
{{ item.componentName }}@{{ item.componentVersion }}
</div>
@if (item.reason) {
<div class="triage-item__reason">{{ item.reason }}</div>
}
<!-- Signed Action Buttons -->
<div class="triage-item__actions">
<button
type="button"
class="action-btn action-btn--recheck"
title="Re-evaluate with latest data"
[disabled]="actionInProgress() === item.id"
(click)="onAuditAction(item, 'recheck')"
>
@if (actionInProgress() === item.id) {
<span class="action-btn__spinner"></span>
}
Recheck now
</button>
<button
type="button"
class="action-btn action-btn--promote"
title="Move to active findings"
[disabled]="actionInProgress() === item.id"
(click)="onAuditAction(item, 'promote')"
>
Promote to Active
</button>
<button
type="button"
class="action-btn action-btn--exception"
title="Create time-boxed exception"
[disabled]="actionInProgress() === item.id"
(click)="onAuditAction(item, 'exception')"
>
Accept Exception
</button>
</div>
</li>
}
</ul>
}
</div>
</section>
</div>
</div>
`,
styles: [`
.audit-workspace {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--st-bg, #f8f9fa);
min-height: 100vh;
}
// Ribbon Sections
.audit-workspace__ribbon {
background: var(--st-card-bg, #ffffff);
border-radius: 0.5rem;
border: 1px solid var(--st-border, #e9ecef);
}
// Review Ribbon
.review-ribbon {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 1rem 1.5rem;
background: var(--st-card-bg, #ffffff);
border: 1px solid var(--st-border, #e9ecef);
border-radius: 0.5rem;
}
.review-ribbon__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.review-ribbon__verdict {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-weight: 600;
font-size: 0.875rem;
&.verdict--pass {
background: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
}
&.verdict--fail {
background: var(--st-error-bg, #f8d7da);
color: var(--st-error-text, #842029);
}
&.verdict--warn {
background: var(--st-warning-bg, #fff3cd);
color: var(--st-warning-text, #664d03);
}
}
.review-ribbon__status {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
&--verified {
background: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
}
&--unverified {
background: var(--st-warning-bg, #fff3cd);
color: var(--st-warning-text, #664d03);
}
}
.review-ribbon__score {
font-size: 1.25rem;
font-weight: 700;
color: var(--st-text-primary, #212529);
}
.review-ribbon__exceptions {
font-size: 1.25rem;
font-weight: 700;
color: var(--st-text-primary, #212529);
&--has {
color: var(--st-warning-text, #664d03);
}
}
.review-ribbon__label {
font-size: 0.75rem;
color: var(--st-text-secondary, #6c757d);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.review-ribbon__detail {
font-size: 0.8125rem;
color: var(--st-text-tertiary, #adb5bd);
}
// Content Layout
.audit-workspace__content {
display: grid;
grid-template-columns: 1fr 400px;
gap: 1rem;
@media (max-width: 1000px) {
grid-template-columns: 1fr;
}
}
// Export Panel
.export-panel {
background: var(--st-card-bg, #ffffff);
border: 1px solid var(--st-border, #e9ecef);
border-radius: 0.5rem;
padding: 1.5rem;
}
.export-panel__header {
margin-bottom: 1rem;
}
.export-panel__title {
margin: 0 0 0.25rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--st-text-primary, #212529);
}
.export-panel__desc {
margin: 0;
font-size: 0.875rem;
color: var(--st-text-secondary, #6c757d);
}
.export-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
background: var(--st-options-bg, #f8f9fa);
border-radius: 0.375rem;
}
.export-option {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.5rem;
align-items: start;
cursor: pointer;
input {
margin-top: 0.25rem;
}
}
.export-option__label {
font-size: 0.875rem;
font-weight: 500;
color: var(--st-text-primary, #212529);
}
.export-option__hint {
grid-column: 2;
font-size: 0.75rem;
color: var(--st-text-tertiary, #adb5bd);
}
.export-panel__cta {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
background: var(--st-primary, #0d6efd);
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--st-primary-hover, #0b5ed7);
}
}
.export-panel__progress {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--st-text-secondary, #6c757d);
}
.export-panel__spinner {
width: 16px;
height: 16px;
border: 2px solid #e9ecef;
border-top-color: #0d6efd;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.export-panel__result {
padding: 1rem;
border-radius: 0.375rem;
&--success {
background: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
}
&--error {
background: var(--st-error-bg, #f8d7da);
color: var(--st-error-text, #842029);
}
}
.export-result__header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.export-result__filename {
font-family: monospace;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
.export-result__size {
font-size: 0.8125rem;
opacity: 0.8;
margin-bottom: 0.5rem;
}
.export-result__checksum {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
}
.checksum-label {
font-weight: 500;
}
.checksum-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
}
.checksum-copy {
padding: 0.125rem 0.25rem;
border: none;
background: transparent;
cursor: pointer;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.export-result__actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.export-result__download {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: 1px solid currentColor;
border-radius: 0.25rem;
background: transparent;
color: inherit;
text-decoration: none;
font-weight: 500;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
.export-result__verify-hint {
font-size: 0.75rem;
opacity: 0.8;
code {
background: rgba(0, 0, 0, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
}
.export-result__error {
margin: 0.5rem 0;
font-size: 0.875rem;
}
.export-result__retry {
padding: 0.375rem 0.75rem;
border: 1px solid currentColor;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
// Triage Lane
.triage-lane {
background: var(--st-card-bg, #ffffff);
border: 1px solid var(--st-border, #e9ecef);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
max-height: calc(100vh - 350px);
}
.triage-lane__header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid var(--st-border, #e9ecef);
}
.triage-lane__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--st-text-primary, #212529);
}
.triage-lane__count {
padding: 0.125rem 0.5rem;
background: var(--st-badge-bg, #e9ecef);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.triage-lane__hint {
flex: 1 0 100%;
font-size: 0.75rem;
color: var(--st-text-tertiary, #adb5bd);
}
.triage-lane__loading,
.triage-lane__empty {
padding: 2rem;
text-align: center;
color: var(--st-text-secondary, #6c757d);
}
.triage-lane__spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #e9ecef;
border-top-color: #6c757d;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
}
.triage-list {
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
flex: 1;
}
.triage-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--st-border, #e9ecef);
&:last-child {
border-bottom: none;
}
}
.triage-item__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.triage-item__severity {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
&.severity--critical { background: #dc3545; color: white; }
&.severity--high { background: #fd7e14; color: white; }
&.severity--medium { background: #ffc107; color: #212529; }
&.severity--low { background: #0dcaf0; color: #212529; }
&.severity--info { background: #6c757d; color: white; }
}
.triage-item__cve {
font-family: monospace;
font-size: 0.75rem;
color: var(--st-text-secondary, #6c757d);
}
.triage-item__confidence {
margin-left: auto;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
&.confidence--high {
background: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
}
&.confidence--medium {
background: var(--st-warning-bg, #fff3cd);
color: var(--st-warning-text, #664d03);
}
&.confidence--low {
background: var(--st-error-bg, #f8d7da);
color: var(--st-error-text, #842029);
}
}
.triage-item__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--st-text-primary, #212529);
margin-bottom: 0.25rem;
}
.triage-item__component {
font-size: 0.75rem;
font-family: monospace;
color: var(--st-text-secondary, #6c757d);
margin-bottom: 0.25rem;
}
.triage-item__reason {
font-size: 0.75rem;
color: var(--st-text-tertiary, #adb5bd);
font-style: italic;
margin-bottom: 0.5rem;
}
.triage-item__actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--st-border, #ced4da);
border-radius: 0.25rem;
background: transparent;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--st-hover-bg, #e9ecef);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--recheck {
color: var(--st-info-text, #0d6efd);
border-color: var(--st-info-text, #0d6efd);
}
&--promote {
color: var(--st-warning-text, #664d03);
border-color: var(--st-warning-border, #ffc107);
}
&--exception {
color: var(--st-success-text, #0f5132);
border-color: var(--st-success-border, #198754);
}
}
.action-btn__spinner {
width: 10px;
height: 10px;
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// Dark mode
:host-context(.dark-theme) {
.audit-workspace {
--st-bg: #1a1d21;
--st-card-bg: #212529;
--st-border: #495057;
--st-text-primary: #f8f9fa;
--st-text-secondary: #adb5bd;
--st-text-tertiary: #6c757d;
--st-options-bg: #343a40;
--st-hover-bg: #2d3238;
--st-badge-bg: #495057;
}
}
`],
})
export class AuditorWorkspaceComponent implements OnInit, OnDestroy {
private readonly workspaceService = inject(AuditorWorkspaceService);
/** Artifact digest to display workspace for. */
artifactDigest = input.required<string>();
/** Emitted when an audit action is performed. */
auditAction = output<AuditActionEvent>();
// Delegate to service
readonly loading = this.workspaceService.loading;
readonly error = this.workspaceService.error;
readonly reviewSummary = this.workspaceService.reviewSummary;
readonly quietTriageItems = this.workspaceService.quietTriageItems;
readonly exportStatus = this.workspaceService.exportStatus;
readonly exportResult = this.workspaceService.exportResult;
// Local state
readonly exportOptions = signal<ExportOptions>({
includePqc: false,
includeRawDocs: true,
redactPii: false,
});
readonly actionInProgress = signal<string | null>(null);
ngOnInit(): void {
this.workspaceService.loadWorkspace(this.artifactDigest()).subscribe();
}
ngOnDestroy(): void {
this.workspaceService.clear();
}
// =========================================================================
// Event Handlers
// =========================================================================
onPillClick(event: any): void {
console.log('Pill clicked:', event);
}
updateExportOption(key: keyof ExportOptions, event: Event): void {
const checkbox = event.target as HTMLInputElement;
this.exportOptions.update((opts) => ({
...opts,
[key]: checkbox.checked,
}));
}
startExport(): void {
this.workspaceService
.exportAuditPack(this.artifactDigest(), this.exportOptions())
.subscribe();
}
copyChecksum(checksum: string): void {
navigator.clipboard.writeText(checksum).then(() => {
// Could show toast notification
console.log('Checksum copied');
});
}
onAuditAction(item: QuietTriageItem, action: AuditActionType): void {
this.actionInProgress.set(item.id);
this.workspaceService.performAuditAction(item.id, action).subscribe({
next: (result) => {
this.auditAction.emit({ action, item, result });
this.actionInProgress.set(null);
},
error: () => {
this.actionInProgress.set(null);
},
});
}
// =========================================================================
// Helpers
// =========================================================================
getVerdictClass = getVerdictClass;
getVerdictLabel = getVerdictLabel;
getVerdictIcon = getVerdictIcon;
getAttestationLabel = getAttestationLabel;
getConfidenceClass = getConfidenceClass;
formatFileSize = formatFileSize;
}

View File

@@ -0,0 +1,22 @@
/**
* Auditor Workspace Feature - Public API
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*/
// Models
export * from './models/auditor-workspace.models';
// Services
export {
IAuditorWorkspaceService,
AUDITOR_WORKSPACE_SERVICE,
AuditorWorkspaceService,
MockAuditorWorkspaceService,
} from './services/auditor-workspace.service';
// Components
export { AuditorWorkspaceComponent } from './components/auditor-workspace/auditor-workspace.component';
// Routes
export { AUDITOR_WORKSPACE_ROUTES } from './auditor-workspace.routes';

View File

@@ -0,0 +1,166 @@
/**
* Auditor Workspace Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*/
/**
* Policy verdict.
*/
export type PolicyVerdict = 'pass' | 'fail' | 'warn' | 'unknown';
/**
* Attestation status.
*/
export type AttestationStatus = 'verified' | 'unverified' | 'expired' | 'unknown';
/**
* Review ribbon summary.
*/
export interface ReviewRibbonSummary {
readonly policyVerdict: PolicyVerdict;
readonly policyPackName?: string;
readonly policyVersion?: string;
readonly attestationStatus: AttestationStatus;
readonly coverageScore: number; // 0-100
readonly openExceptionsCount: number;
readonly evaluatedAt?: string;
}
/**
* Export options for Audit-Pack.
*/
export interface ExportOptions {
includePqc: boolean;
includeRawDocs: boolean;
redactPii: boolean;
}
/**
* Export progress state.
*/
export type ExportStatus = 'idle' | 'preparing' | 'exporting' | 'complete' | 'error';
/**
* Export result.
*/
export interface ExportResult {
readonly success: boolean;
readonly downloadUrl?: string;
readonly filename?: string;
readonly checksum?: string;
readonly checksumAlgorithm?: string;
readonly sizeBytes?: number;
readonly errorMessage?: string;
readonly completedAt: string;
}
/**
* Quiet-Triage item.
*/
export interface QuietTriageItem {
readonly id: string;
readonly findingId: string;
readonly cveId?: string;
readonly title: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
readonly confidence: 'high' | 'medium' | 'low';
readonly componentName: string;
readonly componentVersion: string;
readonly addedAt: string;
readonly reason?: string;
}
/**
* Audit action type.
*/
export type AuditActionType = 'recheck' | 'promote' | 'exception';
/**
* Audit action result.
*/
export interface AuditActionResult {
readonly success: boolean;
readonly actionType: AuditActionType;
readonly itemId: string;
readonly signedEntryId?: string;
readonly errorMessage?: string;
readonly timestamp: string;
}
/**
* Auditor workspace state.
*/
export interface AuditorWorkspaceState {
readonly artifactDigest: string;
readonly artifactName?: string;
readonly reviewSummary?: ReviewRibbonSummary;
readonly quietTriageItems: QuietTriageItem[];
readonly loading: boolean;
readonly error?: string;
readonly exportStatus: ExportStatus;
readonly exportResult?: ExportResult;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get verdict CSS class.
*/
export function getVerdictClass(verdict: PolicyVerdict): string {
return `verdict--${verdict}`;
}
/**
* Get verdict label.
*/
export function getVerdictLabel(verdict: PolicyVerdict): string {
switch (verdict) {
case 'pass': return 'Pass';
case 'fail': return 'Fail';
case 'warn': return 'Warning';
default: return 'Unknown';
}
}
/**
* Get verdict icon.
*/
export function getVerdictIcon(verdict: PolicyVerdict): string {
switch (verdict) {
case 'pass': return '✓';
case 'fail': return '✗';
case 'warn': return '⚠';
default: return '?';
}
}
/**
* Get attestation status label.
*/
export function getAttestationLabel(status: AttestationStatus): string {
switch (status) {
case 'verified': return 'Verified';
case 'unverified': return 'Unverified';
case 'expired': return 'Expired';
default: return 'Unknown';
}
}
/**
* Get confidence CSS class.
*/
export function getConfidenceClass(confidence: 'high' | 'medium' | 'low'): string {
return `confidence--${confidence}`;
}
/**
* Format file size.
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,260 @@
/**
* Auditor Workspace Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-05 - Auditor Workspace Layout
*/
import { Injectable, InjectionToken, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay, tap, catchError, finalize } from 'rxjs';
import {
ReviewRibbonSummary,
QuietTriageItem,
ExportOptions,
ExportResult,
ExportStatus,
AuditActionType,
AuditActionResult,
} from '../models/auditor-workspace.models';
/**
* Auditor Workspace Service Interface.
*/
export interface IAuditorWorkspaceService {
readonly loading: ReturnType<typeof signal<boolean>>;
readonly error: ReturnType<typeof signal<string | null>>;
readonly reviewSummary: ReturnType<typeof signal<ReviewRibbonSummary | null>>;
readonly quietTriageItems: ReturnType<typeof signal<QuietTriageItem[]>>;
readonly exportStatus: ReturnType<typeof signal<ExportStatus>>;
readonly exportResult: ReturnType<typeof signal<ExportResult | null>>;
loadWorkspace(artifactDigest: string): Observable<void>;
exportAuditPack(artifactDigest: string, options: ExportOptions): Observable<ExportResult>;
performAuditAction(itemId: string, action: AuditActionType): Observable<AuditActionResult>;
clear(): void;
}
export const AUDITOR_WORKSPACE_SERVICE = new InjectionToken<IAuditorWorkspaceService>(
'AuditorWorkspaceService'
);
/**
* HTTP implementation of Auditor Workspace Service.
*/
@Injectable({ providedIn: 'root' })
export class AuditorWorkspaceService implements IAuditorWorkspaceService {
private readonly http = inject(HttpClient);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly reviewSummary = signal<ReviewRibbonSummary | null>(null);
readonly quietTriageItems = signal<QuietTriageItem[]>([]);
readonly exportStatus = signal<ExportStatus>('idle');
readonly exportResult = signal<ExportResult | null>(null);
loadWorkspace(artifactDigest: string): Observable<void> {
this.loading.set(true);
this.error.set(null);
return this.http.get<{
summary: ReviewRibbonSummary;
quietTriageItems: QuietTriageItem[];
}>(`/api/v1/artifacts/${encodeURIComponent(artifactDigest)}/audit-workspace`).pipe(
tap((response) => {
this.reviewSummary.set(response.summary);
this.quietTriageItems.set(response.quietTriageItems);
}),
catchError((err) => {
this.error.set(err.message || 'Failed to load workspace');
return of(undefined as void);
}),
finalize(() => {
this.loading.set(false);
})
) as Observable<void>;
}
exportAuditPack(artifactDigest: string, options: ExportOptions): Observable<ExportResult> {
this.exportStatus.set('preparing');
this.exportResult.set(null);
return this.http.post<ExportResult>(
'/api/export/runs',
{
artifactDigest,
profile: 'audit-bundle',
options,
}
).pipe(
tap((result) => {
this.exportStatus.set(result.success ? 'complete' : 'error');
this.exportResult.set(result);
}),
catchError((err) => {
const errorResult: ExportResult = {
success: false,
errorMessage: err.message || 'Export failed',
completedAt: new Date().toISOString(),
};
this.exportStatus.set('error');
this.exportResult.set(errorResult);
return of(errorResult);
})
);
}
performAuditAction(itemId: string, action: AuditActionType): Observable<AuditActionResult> {
return this.http.post<AuditActionResult>(
'/api/v1/audit/entries',
{ itemId, action }
).pipe(
tap((result) => {
if (result.success && (action === 'promote' || action === 'recheck')) {
// Remove item from quiet triage
this.quietTriageItems.update((items) =>
items.filter((item) => item.id !== itemId)
);
}
}),
catchError((err) => {
return of({
success: false,
actionType: action,
itemId,
errorMessage: err.message || 'Action failed',
timestamp: new Date().toISOString(),
});
})
);
}
clear(): void {
this.loading.set(false);
this.error.set(null);
this.reviewSummary.set(null);
this.quietTriageItems.set([]);
this.exportStatus.set('idle');
this.exportResult.set(null);
}
}
/**
* Mock implementation for development/testing.
*/
@Injectable()
export class MockAuditorWorkspaceService implements IAuditorWorkspaceService {
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly reviewSummary = signal<ReviewRibbonSummary | null>(null);
readonly quietTriageItems = signal<QuietTriageItem[]>([]);
readonly exportStatus = signal<ExportStatus>('idle');
readonly exportResult = signal<ExportResult | null>(null);
loadWorkspace(artifactDigest: string): Observable<void> {
this.loading.set(true);
this.error.set(null);
const mockSummary: ReviewRibbonSummary = {
policyVerdict: 'pass',
policyPackName: 'production-security',
policyVersion: '2.1.0',
attestationStatus: 'verified',
coverageScore: 94,
openExceptionsCount: 2,
evaluatedAt: new Date().toISOString(),
};
const mockItems: QuietTriageItem[] = [
{
id: 'qt-1',
findingId: 'finding-1',
cveId: 'CVE-2024-99999',
title: 'Potential memory leak in parser',
severity: 'low',
confidence: 'low',
componentName: 'parser-lib',
componentVersion: '1.2.3',
addedAt: '2024-01-20T10:00:00Z',
reason: 'Low confidence from automated scan',
},
{
id: 'qt-2',
findingId: 'finding-2',
title: 'Deprecated API usage',
severity: 'info',
confidence: 'medium',
componentName: 'legacy-util',
componentVersion: '0.9.0',
addedAt: '2024-01-22T14:00:00Z',
reason: 'Needs manual review for migration path',
},
];
return of(undefined).pipe(
delay(500),
tap(() => {
this.reviewSummary.set(mockSummary);
this.quietTriageItems.set(mockItems);
}),
finalize(() => {
this.loading.set(false);
})
) as Observable<void>;
}
exportAuditPack(artifactDigest: string, options: ExportOptions): Observable<ExportResult> {
this.exportStatus.set('preparing');
this.exportResult.set(null);
return new Observable<ExportResult>((observer) => {
// Simulate progress
setTimeout(() => this.exportStatus.set('exporting'), 500);
setTimeout(() => {
const result: ExportResult = {
success: true,
downloadUrl: `/api/export/downloads/${artifactDigest}/audit-pack.zip`,
filename: `audit-pack-${artifactDigest.slice(7, 19)}.zip`,
checksum: 'sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
checksumAlgorithm: 'SHA-256',
sizeBytes: 2457600,
completedAt: new Date().toISOString(),
};
this.exportStatus.set('complete');
this.exportResult.set(result);
observer.next(result);
observer.complete();
}, 2000);
});
}
performAuditAction(itemId: string, action: AuditActionType): Observable<AuditActionResult> {
return of({
success: true,
actionType: action,
itemId,
signedEntryId: `audit-entry-${Date.now()}`,
timestamp: new Date().toISOString(),
}).pipe(
delay(300),
tap((result) => {
if (result.success && action !== 'exception') {
this.quietTriageItems.update((items) =>
items.filter((item) => item.id !== itemId)
);
}
})
);
}
clear(): void {
this.loading.set(false);
this.error.set(null);
this.reviewSummary.set(null);
this.quietTriageItems.set([]);
this.exportStatus.set('idle');
this.exportResult.set(null);
}
}

View File

@@ -0,0 +1,435 @@
/**
* Developer Workspace Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of, delay, Subject } from 'rxjs';
import { DeveloperWorkspaceComponent, FindingSelectEvent, ActionEvent } from './developer-workspace.component';
import { DeveloperWorkspaceService } from '../../services/developer-workspace.service';
import { EvidenceRibbonService } from '../../../../evidence-ribbon/services/evidence-ribbon.service';
import {
Finding,
FindingSort,
VerifyStep,
VerifyResult,
} from '../../models/developer-workspace.models';
// Test host component
@Component({
standalone: true,
imports: [DeveloperWorkspaceComponent],
template: `
<stella-developer-workspace
[artifactDigest]="artifactDigest"
(findingSelect)="onFindingSelect($event)"
(action)="onAction($event)"
/>
`,
})
class TestHostComponent {
artifactDigest = 'sha256:abc123def456';
lastFindingSelect: FindingSelectEvent | null = null;
lastAction: ActionEvent | null = null;
onFindingSelect(event: FindingSelectEvent): void {
this.lastFindingSelect = event;
}
onAction(event: ActionEvent): void {
this.lastAction = event;
}
}
describe('DeveloperWorkspaceComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let mockWorkspaceService: jasmine.SpyObj<DeveloperWorkspaceService>;
let mockEvidenceService: jasmine.SpyObj<EvidenceRibbonService>;
const mockFindings: Finding[] = [
{
id: 'finding-1',
cveId: 'CVE-2024-12345',
title: 'Critical RCE in lodash',
severity: 'critical',
exploitability: 9.2,
reachability: 'reachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/lodash@4.17.20',
componentName: 'lodash',
componentVersion: '4.17.20',
fixedVersion: '4.17.21',
publishedAt: '2024-01-15T10:00:00Z',
},
{
id: 'finding-2',
cveId: 'CVE-2024-23456',
title: 'SQL Injection in pg-promise',
severity: 'high',
exploitability: 7.5,
reachability: 'unreachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/pg-promise@10.11.0',
componentName: 'pg-promise',
componentVersion: '10.11.0',
publishedAt: '2024-02-20T14:00:00Z',
},
];
beforeEach(async () => {
mockWorkspaceService = jasmine.createSpyObj('DeveloperWorkspaceService', [
'loadFindings',
'setSort',
'startVerification',
'downloadReceipt',
'clear',
], {
loading: signal(false),
error: signal<string | null>(null),
findings: signal<Finding[]>([]),
sortedFindings: signal<Finding[]>(mockFindings),
currentSort: signal<FindingSort>({ field: 'exploitability', direction: 'desc' }),
verifyInProgress: signal(false),
verifySteps: signal<VerifyStep[]>([]),
verifyResult: signal<VerifyResult | null>(null),
});
mockEvidenceService = jasmine.createSpyObj('EvidenceRibbonService', [
'loadEvidenceStatus',
'clear',
], {
loading: signal(false),
dsseStatus: signal(null),
rekorStatus: signal(null),
sbomStatus: signal(null),
vexStatus: signal(null),
policyStatus: signal(null),
});
mockWorkspaceService.loadFindings.and.returnValue(of(mockFindings));
mockEvidenceService.loadEvidenceStatus.and.returnValue(of({} as any));
await TestBed.configureTestingModule({
imports: [TestHostComponent, DeveloperWorkspaceComponent],
providers: [
{ provide: DeveloperWorkspaceService, useValue: mockWorkspaceService },
{ provide: EvidenceRibbonService, useValue: mockEvidenceService },
],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
});
describe('initialization', () => {
it('should create the component', () => {
fixture.detectChanges();
const workspace = fixture.debugElement.query(By.directive(DeveloperWorkspaceComponent));
expect(workspace).toBeTruthy();
});
it('should load findings on init', () => {
fixture.detectChanges();
expect(mockWorkspaceService.loadFindings).toHaveBeenCalledWith('sha256:abc123def456');
});
it('should call clear on destroy', () => {
fixture.detectChanges();
fixture.destroy();
expect(mockWorkspaceService.clear).toHaveBeenCalled();
});
});
describe('layout assembly', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should render Evidence Ribbon', () => {
const ribbon = fixture.debugElement.query(By.css('stella-evidence-ribbon'));
expect(ribbon).toBeTruthy();
});
it('should render Quick-Verify panel', () => {
const verifyPanel = fixture.debugElement.query(By.css('.verify-panel'));
expect(verifyPanel).toBeTruthy();
expect(verifyPanel.nativeElement.textContent).toContain('Quick-Verify');
});
it('should render Findings rail', () => {
const findingsRail = fixture.debugElement.query(By.css('.findings-rail'));
expect(findingsRail).toBeTruthy();
expect(findingsRail.nativeElement.textContent).toContain('Findings');
});
it('should show findings count badge', () => {
const count = fixture.debugElement.query(By.css('.findings-rail__count'));
expect(count.nativeElement.textContent.trim()).toBe('2');
});
});
describe('findings rail', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should render findings list', () => {
const items = fixture.debugElement.queryAll(By.css('.finding-item'));
expect(items.length).toBe(2);
});
it('should display finding details', () => {
const firstItem = fixture.debugElement.query(By.css('.finding-item'));
const text = firstItem.nativeElement.textContent;
expect(text).toContain('Critical');
expect(text).toContain('CVE-2024-12345');
expect(text).toContain('Critical RCE in lodash');
expect(text).toContain('lodash@4.17.20');
});
it('should show fixed version when available', () => {
const fix = fixture.debugElement.query(By.css('.finding-item__fix'));
expect(fix).toBeTruthy();
expect(fix.nativeElement.textContent).toContain('4.17.21');
});
it('should show reachability indicator', () => {
const indicators = fixture.debugElement.queryAll(By.css('.indicator'));
expect(indicators.length).toBeGreaterThan(0);
const reachabilityText = indicators[0].nativeElement.textContent;
expect(reachabilityText).toContain('Reachable');
});
it('should emit findingSelect when finding is clicked', () => {
const button = fixture.debugElement.query(By.css('.finding-item__button'));
button.nativeElement.click();
expect(host.lastFindingSelect).toBeTruthy();
expect(host.lastFindingSelect?.finding.id).toBe('finding-1');
});
});
describe('action stubs', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should render GitHub action button', () => {
const ghBtn = fixture.debugElement.query(By.css('.action-btn--github'));
expect(ghBtn).toBeTruthy();
expect(ghBtn.nativeElement.textContent.trim()).toBe('GH');
});
it('should render Jira action button', () => {
const jiraBtn = fixture.debugElement.query(By.css('.action-btn--jira'));
expect(jiraBtn).toBeTruthy();
expect(jiraBtn.nativeElement.textContent.trim()).toBe('Jira');
});
it('should emit action event for GitHub', () => {
const ghBtn = fixture.debugElement.query(By.css('.action-btn--github'));
ghBtn.nativeElement.click();
expect(host.lastAction).toBeTruthy();
expect(host.lastAction?.action).toBe('github');
expect(host.lastAction?.finding.id).toBe('finding-1');
});
it('should emit action event for Jira', () => {
const jiraBtn = fixture.debugElement.query(By.css('.action-btn--jira'));
jiraBtn.nativeElement.click();
expect(host.lastAction).toBeTruthy();
expect(host.lastAction?.action).toBe('jira');
});
});
describe('sort controls', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should render sort select', () => {
const select = fixture.debugElement.query(By.css('.sort-select'));
expect(select).toBeTruthy();
});
it('should call setSort when sort field changes', () => {
const select = fixture.debugElement.query(By.css('.sort-select'));
select.nativeElement.value = 'severity';
select.nativeElement.dispatchEvent(new Event('change'));
expect(mockWorkspaceService.setSort).toHaveBeenCalledWith({
field: 'severity',
direction: 'desc',
});
});
it('should toggle sort direction when button clicked', () => {
const dirBtn = fixture.debugElement.query(By.css('.sort-direction'));
dirBtn.nativeElement.click();
expect(mockWorkspaceService.setSort).toHaveBeenCalledWith({
field: 'exploitability',
direction: 'asc',
});
});
});
describe('Quick-Verify flow', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should show Start Verification button initially', () => {
const cta = fixture.debugElement.query(By.css('.verify-panel__cta'));
expect(cta).toBeTruthy();
expect(cta.nativeElement.textContent).toContain('Start Verification');
});
it('should call startVerification when CTA clicked', () => {
mockWorkspaceService.startVerification.and.returnValue(of({
success: true,
steps: [],
completedAt: new Date().toISOString(),
}));
const cta = fixture.debugElement.query(By.css('.verify-panel__cta'));
cta.nativeElement.click();
expect(mockWorkspaceService.startVerification).toHaveBeenCalledWith('sha256:abc123def456');
});
it('should show progress steps when verification in progress', fakeAsync(() => {
const steps: VerifyStep[] = [
{ id: 'hash', label: 'Hash Check', status: 'success', durationMs: 200 },
{ id: 'dsse', label: 'DSSE Verify', status: 'running' },
{ id: 'rekor', label: 'Rekor Inclusion', status: 'pending' },
];
(mockWorkspaceService.verifyInProgress as any).set(true);
(mockWorkspaceService.verifySteps as any).set(steps);
fixture.detectChanges();
tick();
const stepElements = fixture.debugElement.queryAll(By.css('.verify-step'));
expect(stepElements.length).toBe(3);
const runningStep = fixture.debugElement.query(By.css('.verify-step--running'));
expect(runningStep.nativeElement.textContent).toContain('DSSE Verify');
}));
it('should show success result with download button', fakeAsync(() => {
const result: VerifyResult = {
success: true,
steps: [],
receiptUrl: '/api/receipts/test/receipt.json',
completedAt: new Date().toISOString(),
};
(mockWorkspaceService.verifyResult as any).set(result);
fixture.detectChanges();
tick();
const resultPanel = fixture.debugElement.query(By.css('.verify-panel__result--success'));
expect(resultPanel).toBeTruthy();
expect(resultPanel.nativeElement.textContent).toContain('Verification Successful');
const downloadBtn = fixture.debugElement.query(By.css('.verify-result__download'));
expect(downloadBtn).toBeTruthy();
expect(downloadBtn.nativeElement.textContent).toContain('receipt.json');
}));
it('should show error result with retry button', fakeAsync(() => {
const result: VerifyResult = {
success: false,
steps: [],
errorMessage: 'DSSE verification failed',
completedAt: new Date().toISOString(),
};
(mockWorkspaceService.verifyResult as any).set(result);
fixture.detectChanges();
tick();
const resultPanel = fixture.debugElement.query(By.css('.verify-panel__result--error'));
expect(resultPanel).toBeTruthy();
expect(resultPanel.nativeElement.textContent).toContain('Verification Failed');
expect(resultPanel.nativeElement.textContent).toContain('DSSE verification failed');
const retryBtn = fixture.debugElement.query(By.css('.verify-result__retry'));
expect(retryBtn).toBeTruthy();
expect(retryBtn.nativeElement.textContent).toContain('Retry');
}));
it('should call downloadReceipt when download button clicked', fakeAsync(() => {
const result: VerifyResult = {
success: true,
steps: [],
receiptUrl: '/api/receipts/test/receipt.json',
completedAt: new Date().toISOString(),
};
(mockWorkspaceService.verifyResult as any).set(result);
fixture.detectChanges();
tick();
const downloadBtn = fixture.debugElement.query(By.css('.verify-result__download'));
downloadBtn.nativeElement.click();
expect(mockWorkspaceService.downloadReceipt).toHaveBeenCalled();
}));
});
describe('loading state', () => {
it('should show loading indicator when loading findings', fakeAsync(() => {
(mockWorkspaceService.loading as any).set(true);
(mockWorkspaceService.sortedFindings as any).set([]);
fixture.detectChanges();
tick();
const loading = fixture.debugElement.query(By.css('.findings-rail__loading'));
expect(loading).toBeTruthy();
expect(loading.nativeElement.textContent).toContain('Loading findings');
}));
});
describe('error state', () => {
it('should show error message when error occurs', fakeAsync(() => {
(mockWorkspaceService.error as any).set('Failed to load findings');
(mockWorkspaceService.sortedFindings as any).set([]);
fixture.detectChanges();
tick();
const error = fixture.debugElement.query(By.css('.findings-rail__error'));
expect(error).toBeTruthy();
expect(error.nativeElement.textContent).toContain('Failed to load findings');
}));
});
describe('empty state', () => {
it('should show empty message when no findings', fakeAsync(() => {
(mockWorkspaceService.sortedFindings as any).set([]);
fixture.detectChanges();
tick();
const empty = fixture.debugElement.query(By.css('.findings-rail__empty'));
expect(empty).toBeTruthy();
expect(empty.nativeElement.textContent).toContain('No findings');
}));
});
});

View File

@@ -0,0 +1,814 @@
/**
* Developer Workspace Component
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*
* Developer-focused workspace assembling Evidence Ribbon, Quick-Verify,
* Findings Rail, and action stubs.
*/
import {
Component,
input,
output,
computed,
signal,
inject,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { EvidenceRibbonComponent } from '../../../../evidence-ribbon/components/evidence-ribbon/evidence-ribbon.component';
import {
Finding,
FindingSort,
FindingSortField,
VerifyStep,
VerifyResult,
getSeverityClass,
getSeverityLabel,
getReachabilityIcon,
getReachabilityLabel,
getRuntimeIcon,
getVerifyStepIcon,
} from '../../models/developer-workspace.models';
import { DeveloperWorkspaceService } from '../../services/developer-workspace.service';
/**
* Event emitted when a finding is selected.
*/
export interface FindingSelectEvent {
readonly finding: Finding;
}
/**
* Event emitted when an action is triggered.
*/
export interface ActionEvent {
readonly action: 'github' | 'jira';
readonly finding: Finding;
}
/**
* Developer Workspace Component.
*
* Assembles:
* - Evidence Ribbon at top
* - Quick-Verify CTA with streaming progress
* - Findings rail with sort controls
* - Action stubs for issue creation
*
* @example
* ```html
* <stella-developer-workspace
* [artifactDigest]="artifact.digest"
* (findingSelect)="showFindingDetails($event)"
* (action)="handleAction($event)"
* />
* ```
*/
@Component({
selector: 'stella-developer-workspace',
standalone: true,
imports: [CommonModule, EvidenceRibbonComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="dev-workspace">
<!-- Evidence Ribbon -->
<section class="dev-workspace__ribbon">
<stella-evidence-ribbon
[artifactDigest]="artifactDigest()"
[showVex]="true"
[showPolicy]="false"
(pillClick)="onPillClick($event)"
/>
</section>
<!-- Main Content -->
<div class="dev-workspace__content">
<!-- Quick-Verify Panel -->
<section class="dev-workspace__verify">
<div class="verify-panel">
<div class="verify-panel__header">
<h3 class="verify-panel__title">Quick-Verify</h3>
<p class="verify-panel__desc">
Verify attestation chain: hash → DSSE → Rekor
</p>
</div>
@if (!verifyInProgress() && !verifyResult()) {
<button
type="button"
class="verify-panel__cta"
(click)="startVerify()"
>
<span class="verify-panel__cta-icon" aria-hidden="true">▶</span>
Start Verification
</button>
}
@if (verifyInProgress()) {
<div class="verify-panel__progress">
@for (step of verifySteps(); track step.id) {
<div
class="verify-step"
[class.verify-step--running]="step.status === 'running'"
[class.verify-step--success]="step.status === 'success'"
[class.verify-step--error]="step.status === 'error'"
>
<span class="verify-step__icon" aria-hidden="true">
{{ getVerifyStepIcon(step.status) }}
</span>
<span class="verify-step__label">{{ step.label }}</span>
@if (step.status === 'running') {
<span class="verify-step__spinner"></span>
}
@if (step.durationMs) {
<span class="verify-step__time">{{ step.durationMs }}ms</span>
}
</div>
}
</div>
}
@if (verifyResult(); as result) {
<div
class="verify-panel__result"
[class.verify-panel__result--success]="result.success"
[class.verify-panel__result--error]="!result.success"
>
<div class="verify-result__header">
<span class="verify-result__icon" aria-hidden="true">
{{ result.success ? '✓' : '✗' }}
</span>
<span class="verify-result__text">
{{ result.success ? 'Verification Successful' : 'Verification Failed' }}
</span>
</div>
@if (result.errorMessage) {
<p class="verify-result__error">{{ result.errorMessage }}</p>
}
<div class="verify-result__actions">
@if (result.success && result.receiptUrl) {
<button
type="button"
class="verify-result__download"
(click)="downloadReceipt()"
>
Download receipt.json
</button>
}
<button
type="button"
class="verify-result__retry"
(click)="startVerify()"
>
{{ result.success ? 'Verify Again' : 'Retry' }}
</button>
</div>
</div>
}
</div>
</section>
<!-- Findings Rail -->
<section class="dev-workspace__findings">
<div class="findings-rail">
<div class="findings-rail__header">
<h3 class="findings-rail__title">Findings</h3>
<span class="findings-rail__count">{{ sortedFindings().length }}</span>
</div>
<!-- Sort Controls -->
<div class="findings-rail__sort">
<label class="sort-label">Sort by:</label>
<select
class="sort-select"
[value]="currentSort().field"
(change)="onSortChange($event)"
>
<option value="exploitability">Exploitability</option>
<option value="severity">Severity</option>
<option value="reachability">Reachability</option>
<option value="runtime">Runtime</option>
<option value="published">Published</option>
</select>
<button
type="button"
class="sort-direction"
[title]="currentSort().direction === 'desc' ? 'Descending' : 'Ascending'"
(click)="toggleSortDirection()"
>
{{ currentSort().direction === 'desc' ? '↓' : '↑' }}
</button>
</div>
<!-- Findings List -->
@if (loading()) {
<div class="findings-rail__loading">
<span class="findings-rail__spinner"></span>
Loading findings...
</div>
} @else if (error()) {
<div class="findings-rail__error">
{{ error() }}
</div>
} @else if (sortedFindings().length === 0) {
<div class="findings-rail__empty">
No findings for this artifact.
</div>
} @else {
<ul class="findings-list" role="list">
@for (finding of sortedFindings(); track finding.id) {
<li class="finding-item">
<button
type="button"
class="finding-item__button"
(click)="onFindingSelect(finding)"
>
<div class="finding-item__header">
<span
class="finding-item__severity"
[class]="getSeverityClass(finding.severity)"
>
{{ getSeverityLabel(finding.severity) }}
</span>
<span class="finding-item__cve">{{ finding.cveId ?? 'N/A' }}</span>
<span class="finding-item__epss" title="Exploitability score">
EPSS: {{ finding.exploitability.toFixed(1) }}
</span>
</div>
<div class="finding-item__title">{{ finding.title }}</div>
<div class="finding-item__component">
{{ finding.componentName }}@{{ finding.componentVersion }}
@if (finding.fixedVersion) {
<span class="finding-item__fix">→ {{ finding.fixedVersion }}</span>
}
</div>
<div class="finding-item__indicators">
<span
class="indicator"
[class.indicator--positive]="finding.reachability === 'unreachable'"
[class.indicator--negative]="finding.reachability === 'reachable'"
[title]="getReachabilityLabel(finding.reachability)"
>
{{ getReachabilityIcon(finding.reachability) }} {{ getReachabilityLabel(finding.reachability) }}
</span>
<span
class="indicator"
[class.indicator--negative]="finding.runtimePresence === 'present'"
[class.indicator--positive]="finding.runtimePresence === 'absent'"
title="Runtime presence"
>
{{ getRuntimeIcon(finding.runtimePresence) }} Runtime
</span>
</div>
</button>
<!-- Action Buttons -->
<div class="finding-item__actions">
<button
type="button"
class="action-btn action-btn--github"
title="Create GitHub Issue"
(click)="onAction('github', finding)"
>
GH
</button>
<button
type="button"
class="action-btn action-btn--jira"
title="Create Jira Ticket"
(click)="onAction('jira', finding)"
>
Jira
</button>
</div>
</li>
}
</ul>
}
</div>
</section>
</div>
</div>
`,
styles: [`
.dev-workspace {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: var(--st-bg, #f8f9fa);
min-height: 100vh;
}
// Ribbon Section
.dev-workspace__ribbon {
background: var(--st-card-bg, #ffffff);
border-radius: 0.5rem;
border: 1px solid var(--st-border, #e9ecef);
}
// Content Layout
.dev-workspace__content {
display: grid;
grid-template-columns: 1fr 350px;
gap: 1rem;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
// Verify Panel
.verify-panel {
background: var(--st-card-bg, #ffffff);
border: 1px solid var(--st-border, #e9ecef);
border-radius: 0.5rem;
padding: 1.5rem;
}
.verify-panel__header {
margin-bottom: 1rem;
}
.verify-panel__title {
margin: 0 0 0.25rem 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--st-text-primary, #212529);
}
.verify-panel__desc {
margin: 0;
font-size: 0.875rem;
color: var(--st-text-secondary, #6c757d);
}
.verify-panel__cta {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
background: var(--st-primary, #0d6efd);
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--st-primary-hover, #0b5ed7);
}
}
.verify-panel__progress {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.verify-step {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: var(--st-step-bg, #f8f9fa);
font-size: 0.875rem;
color: var(--st-text-secondary, #6c757d);
&--running {
background: var(--st-step-running-bg, #cfe2ff);
color: var(--st-step-running-text, #084298);
}
&--success {
background: var(--st-step-success-bg, #d1e7dd);
color: var(--st-step-success-text, #0f5132);
}
&--error {
background: var(--st-step-error-bg, #f8d7da);
color: var(--st-step-error-text, #842029);
}
}
.verify-step__icon {
width: 16px;
text-align: center;
}
.verify-step__label {
flex: 1;
}
.verify-step__spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.verify-step__time {
font-size: 0.75rem;
opacity: 0.7;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.verify-panel__result {
padding: 1rem;
border-radius: 0.375rem;
&--success {
background: var(--st-success-bg, #d1e7dd);
color: var(--st-success-text, #0f5132);
}
&--error {
background: var(--st-error-bg, #f8d7da);
color: var(--st-error-text, #842029);
}
}
.verify-result__header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.verify-result__error {
margin: 0.5rem 0;
font-size: 0.875rem;
}
.verify-result__actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.verify-result__download,
.verify-result__retry {
padding: 0.375rem 0.75rem;
border: 1px solid currentColor;
border-radius: 0.25rem;
background: transparent;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
// Findings Rail
.findings-rail {
background: var(--st-card-bg, #ffffff);
border: 1px solid var(--st-border, #e9ecef);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
max-height: calc(100vh - 200px);
}
.findings-rail__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--st-border, #e9ecef);
}
.findings-rail__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--st-text-primary, #212529);
}
.findings-rail__count {
padding: 0.125rem 0.5rem;
background: var(--st-badge-bg, #e9ecef);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.findings-rail__sort {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--st-border, #e9ecef);
background: var(--st-sort-bg, #f8f9fa);
}
.sort-label {
font-size: 0.8125rem;
color: var(--st-text-secondary, #6c757d);
}
.sort-select {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--st-border, #ced4da);
border-radius: 0.25rem;
font-size: 0.8125rem;
background: white;
}
.sort-direction {
padding: 0.25rem 0.5rem;
border: 1px solid var(--st-border, #ced4da);
border-radius: 0.25rem;
background: white;
cursor: pointer;
&:hover {
background: var(--st-hover-bg, #e9ecef);
}
}
.findings-rail__loading,
.findings-rail__error,
.findings-rail__empty {
padding: 2rem;
text-align: center;
color: var(--st-text-secondary, #6c757d);
}
.findings-rail__spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #e9ecef;
border-top-color: #6c757d;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 0.5rem;
}
.findings-list {
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
flex: 1;
}
.finding-item {
border-bottom: 1px solid var(--st-border, #e9ecef);
&:last-child {
border-bottom: none;
}
}
.finding-item__button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--st-hover-bg, #f8f9fa);
}
}
.finding-item__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.finding-item__severity {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
&.severity--critical {
background: var(--st-critical-bg, #dc3545);
color: white;
}
&.severity--high {
background: var(--st-high-bg, #fd7e14);
color: white;
}
&.severity--medium {
background: var(--st-medium-bg, #ffc107);
color: #212529;
}
&.severity--low {
background: var(--st-low-bg, #0dcaf0);
color: #212529;
}
&.severity--info {
background: var(--st-info-bg, #6c757d);
color: white;
}
}
.finding-item__cve {
font-family: monospace;
font-size: 0.75rem;
color: var(--st-text-secondary, #6c757d);
}
.finding-item__epss {
margin-left: auto;
font-size: 0.6875rem;
color: var(--st-text-tertiary, #adb5bd);
}
.finding-item__title {
font-size: 0.875rem;
font-weight: 500;
color: var(--st-text-primary, #212529);
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finding-item__component {
font-size: 0.75rem;
font-family: monospace;
color: var(--st-text-secondary, #6c757d);
margin-bottom: 0.375rem;
}
.finding-item__fix {
color: var(--st-success-text, #0f5132);
}
.finding-item__indicators {
display: flex;
gap: 0.75rem;
font-size: 0.6875rem;
}
.indicator {
color: var(--st-text-tertiary, #adb5bd);
&--positive {
color: var(--st-success-text, #0f5132);
}
&--negative {
color: var(--st-error-text, #842029);
}
}
.finding-item__actions {
display: flex;
gap: 0.25rem;
padding: 0.25rem 1rem 0.5rem;
}
.action-btn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--st-border, #ced4da);
border-radius: 0.25rem;
background: transparent;
font-size: 0.6875rem;
cursor: pointer;
&:hover {
background: var(--st-hover-bg, #e9ecef);
}
&--github {
color: #24292e;
}
&--jira {
color: #0052cc;
}
}
// Dark mode
:host-context(.dark-theme) {
.dev-workspace {
--st-bg: #1a1d21;
--st-card-bg: #212529;
--st-border: #495057;
--st-text-primary: #f8f9fa;
--st-text-secondary: #adb5bd;
--st-text-tertiary: #6c757d;
--st-hover-bg: #2d3238;
--st-sort-bg: #343a40;
--st-badge-bg: #495057;
}
}
`],
})
export class DeveloperWorkspaceComponent implements OnInit, OnDestroy {
private readonly workspaceService = inject(DeveloperWorkspaceService);
/** Artifact digest to display workspace for. */
artifactDigest = input.required<string>();
/** Emitted when a finding is selected. */
findingSelect = output<FindingSelectEvent>();
/** Emitted when an action is triggered. */
action = output<ActionEvent>();
// Delegate to service
readonly loading = this.workspaceService.loading;
readonly error = this.workspaceService.error;
readonly sortedFindings = this.workspaceService.sortedFindings;
readonly currentSort = this.workspaceService.currentSort;
readonly verifyInProgress = this.workspaceService.verifyInProgress;
readonly verifySteps = this.workspaceService.verifySteps;
readonly verifyResult = this.workspaceService.verifyResult;
ngOnInit(): void {
this.workspaceService.loadFindings(this.artifactDigest()).subscribe();
}
ngOnDestroy(): void {
this.workspaceService.clear();
}
// =========================================================================
// Event Handlers
// =========================================================================
onPillClick(event: any): void {
// Could open evidence drawer
console.log('Pill clicked:', event);
}
startVerify(): void {
this.workspaceService.startVerification(this.artifactDigest()).subscribe();
}
downloadReceipt(): void {
this.workspaceService.downloadReceipt();
}
onSortChange(event: Event): void {
const select = event.target as HTMLSelectElement;
const field = select.value as FindingSortField;
this.workspaceService.setSort({
field,
direction: this.currentSort().direction,
});
}
toggleSortDirection(): void {
const current = this.currentSort();
this.workspaceService.setSort({
field: current.field,
direction: current.direction === 'desc' ? 'asc' : 'desc',
});
}
onFindingSelect(finding: Finding): void {
this.findingSelect.emit({ finding });
}
onAction(actionType: 'github' | 'jira', finding: Finding): void {
this.action.emit({ action: actionType, finding });
}
// =========================================================================
// Helpers
// =========================================================================
getSeverityClass = getSeverityClass;
getSeverityLabel = getSeverityLabel;
getReachabilityIcon = getReachabilityIcon;
getReachabilityLabel = getReachabilityLabel;
getRuntimeIcon = getRuntimeIcon;
getVerifyStepIcon = getVerifyStepIcon;
}

View File

@@ -0,0 +1,20 @@
/**
* Developer Workspace Routes
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*/
import { Routes } from '@angular/router';
export const DEVELOPER_WORKSPACE_ROUTES: Routes = [
{
path: ':artifactDigest',
loadComponent: () =>
import('./components/developer-workspace/developer-workspace.component').then(
(m) => m.DeveloperWorkspaceComponent
),
data: {
title: 'Developer Workspace',
},
},
];

View File

@@ -0,0 +1,10 @@
/**
* Developer Workspace Feature - Public API
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*/
export * from './models/developer-workspace.models';
export * from './services/developer-workspace.service';
export * from './components/developer-workspace/developer-workspace.component';
export * from './developer-workspace.routes';

View File

@@ -0,0 +1,217 @@
/**
* Developer Workspace Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*/
/**
* Finding severity levels.
*/
export type FindingSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
/**
* Reachability status for a finding.
*/
export type ReachabilityStatus = 'reachable' | 'unreachable' | 'unknown' | 'analyzing';
/**
* Runtime presence status.
*/
export type RuntimePresence = 'present' | 'absent' | 'unknown';
/**
* Finding in the findings rail.
*/
export interface Finding {
readonly id: string;
readonly cveId?: string;
readonly title: string;
readonly severity: FindingSeverity;
readonly exploitability: number; // 0-10 EPSS-like score
readonly reachability: ReachabilityStatus;
readonly runtimePresence: RuntimePresence;
readonly componentPurl: string;
readonly componentName: string;
readonly componentVersion: string;
readonly fixedVersion?: string;
readonly publishedAt?: string;
}
/**
* Sort options for findings rail.
*/
export type FindingSortField = 'exploitability' | 'severity' | 'reachability' | 'runtime' | 'published';
/**
* Sort direction.
*/
export type SortDirection = 'asc' | 'desc';
/**
* Sort configuration.
*/
export interface FindingSort {
field: FindingSortField;
direction: SortDirection;
}
/**
* Quick-Verify step status.
*/
export type VerifyStepStatus = 'pending' | 'running' | 'success' | 'error' | 'skipped';
/**
* Quick-Verify verification step.
*/
export interface VerifyStep {
readonly id: string;
readonly label: string;
readonly status: VerifyStepStatus;
readonly message?: string;
readonly durationMs?: number;
}
/**
* Quick-Verify result.
*/
export interface VerifyResult {
readonly success: boolean;
readonly steps: VerifyStep[];
readonly receiptUrl?: string;
readonly errorMessage?: string;
readonly completedAt: string;
}
/**
* Developer workspace state.
*/
export interface DeveloperWorkspaceState {
readonly artifactDigest: string;
readonly artifactName?: string;
readonly findings: Finding[];
readonly findingsLoading: boolean;
readonly findingsError?: string;
readonly verifyInProgress: boolean;
readonly verifySteps: VerifyStep[];
readonly verifyResult?: VerifyResult;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get severity CSS class.
*/
export function getSeverityClass(severity: FindingSeverity): string {
return `severity--${severity}`;
}
/**
* Get severity label.
*/
export function getSeverityLabel(severity: FindingSeverity): string {
return severity.charAt(0).toUpperCase() + severity.slice(1);
}
/**
* Get reachability icon.
*/
export function getReachabilityIcon(status: ReachabilityStatus): string {
switch (status) {
case 'reachable': return '●';
case 'unreachable': return '○';
case 'analyzing': return '◐';
default: return '?';
}
}
/**
* Get reachability label.
*/
export function getReachabilityLabel(status: ReachabilityStatus): string {
switch (status) {
case 'reachable': return 'Reachable';
case 'unreachable': return 'Unreachable';
case 'analyzing': return 'Analyzing';
default: return 'Unknown';
}
}
/**
* Get runtime presence icon.
*/
export function getRuntimeIcon(status: RuntimePresence): string {
switch (status) {
case 'present': return '✓';
case 'absent': return '✗';
default: return '?';
}
}
/**
* Get verify step icon.
*/
export function getVerifyStepIcon(status: VerifyStepStatus): string {
switch (status) {
case 'success': return '✓';
case 'error': return '✗';
case 'running': return '◐';
case 'skipped': return '○';
default: return '○';
}
}
/**
* Sort findings by field.
*/
export function sortFindings(findings: Finding[], sort: FindingSort): Finding[] {
const multiplier = sort.direction === 'asc' ? 1 : -1;
return [...findings].sort((a, b) => {
switch (sort.field) {
case 'exploitability':
return (b.exploitability - a.exploitability) * multiplier;
case 'severity':
return (severityOrder(b.severity) - severityOrder(a.severity)) * multiplier;
case 'reachability':
return (reachabilityOrder(b.reachability) - reachabilityOrder(a.reachability)) * multiplier;
case 'runtime':
return (runtimeOrder(b.runtimePresence) - runtimeOrder(a.runtimePresence)) * multiplier;
case 'published':
return ((a.publishedAt ?? '').localeCompare(b.publishedAt ?? '')) * multiplier;
default:
return 0;
}
});
}
function severityOrder(severity: FindingSeverity): number {
switch (severity) {
case 'critical': return 5;
case 'high': return 4;
case 'medium': return 3;
case 'low': return 2;
case 'info': return 1;
default: return 0;
}
}
function reachabilityOrder(status: ReachabilityStatus): number {
switch (status) {
case 'reachable': return 3;
case 'analyzing': return 2;
case 'unknown': return 1;
case 'unreachable': return 0;
default: return 0;
}
}
function runtimeOrder(status: RuntimePresence): number {
switch (status) {
case 'present': return 2;
case 'unknown': return 1;
case 'absent': return 0;
default: return 0;
}
}

View File

@@ -0,0 +1,329 @@
/**
* Developer Workspace Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-04 - Developer Workspace Layout
*/
import { Injectable, InjectionToken, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay, tap, catchError, finalize, interval, take, map, switchMap } from 'rxjs';
import {
Finding,
FindingSort,
VerifyStep,
VerifyResult,
sortFindings,
} from '../models/developer-workspace.models';
/**
* Developer Workspace Service Interface.
*/
export interface IDeveloperWorkspaceService {
readonly loading: ReturnType<typeof signal<boolean>>;
readonly error: ReturnType<typeof signal<string | null>>;
readonly findings: ReturnType<typeof signal<Finding[]>>;
readonly sortedFindings: ReturnType<typeof computed<Finding[]>>;
readonly currentSort: ReturnType<typeof signal<FindingSort>>;
readonly verifyInProgress: ReturnType<typeof signal<boolean>>;
readonly verifySteps: ReturnType<typeof signal<VerifyStep[]>>;
readonly verifyResult: ReturnType<typeof signal<VerifyResult | null>>;
loadFindings(artifactDigest: string): Observable<Finding[]>;
setSort(sort: FindingSort): void;
startVerification(artifactDigest: string): Observable<VerifyResult>;
downloadReceipt(): void;
clear(): void;
}
export const DEVELOPER_WORKSPACE_SERVICE = new InjectionToken<IDeveloperWorkspaceService>(
'DeveloperWorkspaceService'
);
/**
* HTTP implementation of Developer Workspace Service.
*/
@Injectable({ providedIn: 'root' })
export class DeveloperWorkspaceService implements IDeveloperWorkspaceService {
private readonly http = inject(HttpClient);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly findings = signal<Finding[]>([]);
readonly currentSort = signal<FindingSort>({ field: 'exploitability', direction: 'desc' });
readonly verifyInProgress = signal(false);
readonly verifySteps = signal<VerifyStep[]>([]);
readonly verifyResult = signal<VerifyResult | null>(null);
private lastReceiptUrl: string | null = null;
readonly sortedFindings = computed(() => {
return sortFindings(this.findings(), this.currentSort());
});
loadFindings(artifactDigest: string): Observable<Finding[]> {
this.loading.set(true);
this.error.set(null);
return this.http.get<Finding[]>(`/api/v1/artifacts/${encodeURIComponent(artifactDigest)}/findings`).pipe(
tap((findings) => {
this.findings.set(findings);
}),
catchError((err) => {
this.error.set(err.message || 'Failed to load findings');
return of([]);
}),
finalize(() => {
this.loading.set(false);
})
);
}
setSort(sort: FindingSort): void {
this.currentSort.set(sort);
}
startVerification(artifactDigest: string): Observable<VerifyResult> {
this.verifyInProgress.set(true);
this.verifyResult.set(null);
// Initialize steps
const initialSteps: VerifyStep[] = [
{ id: 'hash', label: 'Hash Check', status: 'pending' },
{ id: 'dsse', label: 'DSSE Verify', status: 'pending' },
{ id: 'rekor', label: 'Rekor Inclusion', status: 'pending' },
{ id: 'complete', label: 'Complete', status: 'pending' },
];
this.verifySteps.set(initialSteps);
// Stream verification progress
return this.http.post<{ jobId: string }>(
'/api/v1/rekor/verify',
{ artifactDigest }
).pipe(
switchMap((response) => this.pollVerificationStatus(response.jobId)),
tap((result) => {
this.verifyResult.set(result);
this.lastReceiptUrl = result.receiptUrl ?? null;
}),
catchError((err) => {
const errorResult: VerifyResult = {
success: false,
steps: this.verifySteps(),
errorMessage: err.message || 'Verification failed',
completedAt: new Date().toISOString(),
};
this.verifyResult.set(errorResult);
return of(errorResult);
}),
finalize(() => {
this.verifyInProgress.set(false);
})
);
}
private pollVerificationStatus(jobId: string): Observable<VerifyResult> {
// Poll for status updates
return interval(500).pipe(
take(20), // Max 10 seconds
switchMap(() => this.http.get<{ status: string; steps: VerifyStep[]; result?: VerifyResult }>(
`/api/v1/rekor/verify/${jobId}/status`
)),
tap((response) => {
this.verifySteps.set(response.steps);
}),
map((response) => {
if (response.result) {
return response.result;
}
throw new Error('pending');
}),
catchError((err) => {
if (err.message === 'pending') {
throw err; // Continue polling
}
throw err;
})
);
}
downloadReceipt(): void {
if (this.lastReceiptUrl) {
window.open(this.lastReceiptUrl, '_blank');
}
}
clear(): void {
this.loading.set(false);
this.error.set(null);
this.findings.set([]);
this.verifyInProgress.set(false);
this.verifySteps.set([]);
this.verifyResult.set(null);
this.lastReceiptUrl = null;
}
}
/**
* Mock implementation for development/testing.
*/
@Injectable()
export class MockDeveloperWorkspaceService implements IDeveloperWorkspaceService {
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly findings = signal<Finding[]>([]);
readonly currentSort = signal<FindingSort>({ field: 'exploitability', direction: 'desc' });
readonly verifyInProgress = signal(false);
readonly verifySteps = signal<VerifyStep[]>([]);
readonly verifyResult = signal<VerifyResult | null>(null);
readonly sortedFindings = computed(() => {
return sortFindings(this.findings(), this.currentSort());
});
loadFindings(artifactDigest: string): Observable<Finding[]> {
this.loading.set(true);
this.error.set(null);
const mockFindings: Finding[] = [
{
id: 'finding-1',
cveId: 'CVE-2024-12345',
title: 'Remote Code Execution in lodash',
severity: 'critical',
exploitability: 9.2,
reachability: 'reachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/lodash@4.17.20',
componentName: 'lodash',
componentVersion: '4.17.20',
fixedVersion: '4.17.21',
publishedAt: '2024-01-15T10:00:00Z',
},
{
id: 'finding-2',
cveId: 'CVE-2024-23456',
title: 'SQL Injection in pg-promise',
severity: 'high',
exploitability: 7.5,
reachability: 'unreachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/pg-promise@10.11.0',
componentName: 'pg-promise',
componentVersion: '10.11.0',
fixedVersion: '10.12.0',
publishedAt: '2024-02-20T14:00:00Z',
},
{
id: 'finding-3',
cveId: 'CVE-2024-34567',
title: 'Prototype Pollution in minimist',
severity: 'medium',
exploitability: 5.0,
reachability: 'analyzing',
runtimePresence: 'unknown',
componentPurl: 'pkg:npm/minimist@1.2.5',
componentName: 'minimist',
componentVersion: '1.2.5',
fixedVersion: '1.2.6',
publishedAt: '2024-03-10T08:00:00Z',
},
{
id: 'finding-4',
cveId: 'CVE-2024-45678',
title: 'Information Disclosure in express',
severity: 'low',
exploitability: 2.5,
reachability: 'reachable',
runtimePresence: 'present',
componentPurl: 'pkg:npm/express@4.18.1',
componentName: 'express',
componentVersion: '4.18.1',
fixedVersion: '4.18.2',
publishedAt: '2024-04-05T12:00:00Z',
},
];
return of(mockFindings).pipe(
delay(500),
tap((findings) => {
this.findings.set(findings);
}),
finalize(() => {
this.loading.set(false);
})
);
}
setSort(sort: FindingSort): void {
this.currentSort.set(sort);
}
startVerification(artifactDigest: string): Observable<VerifyResult> {
this.verifyInProgress.set(true);
this.verifyResult.set(null);
const steps: VerifyStep[] = [
{ id: 'hash', label: 'Hash Check', status: 'pending' },
{ id: 'dsse', label: 'DSSE Verify', status: 'pending' },
{ id: 'rekor', label: 'Rekor Inclusion', status: 'pending' },
{ id: 'complete', label: 'Complete', status: 'pending' },
];
this.verifySteps.set(steps);
// Simulate progressive verification
return new Observable<VerifyResult>((observer) => {
let stepIndex = 0;
const updateStep = () => {
if (stepIndex < steps.length) {
const updatedSteps = [...this.verifySteps()];
if (stepIndex > 0) {
updatedSteps[stepIndex - 1] = { ...updatedSteps[stepIndex - 1], status: 'success', durationMs: 200 + Math.random() * 300 };
}
updatedSteps[stepIndex] = { ...updatedSteps[stepIndex], status: 'running' };
this.verifySteps.set(updatedSteps);
stepIndex++;
setTimeout(updateStep, 400 + Math.random() * 200);
} else {
// Complete
const finalSteps = this.verifySteps().map(s => ({
...s,
status: 'success' as const,
durationMs: s.durationMs ?? 250,
}));
this.verifySteps.set(finalSteps);
const result: VerifyResult = {
success: true,
steps: finalSteps,
receiptUrl: `/api/v1/receipts/${artifactDigest}/receipt.json`,
completedAt: new Date().toISOString(),
};
this.verifyResult.set(result);
this.verifyInProgress.set(false);
observer.next(result);
observer.complete();
}
};
setTimeout(updateStep, 300);
});
}
downloadReceipt(): void {
const result = this.verifyResult();
if (result?.receiptUrl) {
console.log('Mock: Download receipt from', result.receiptUrl);
}
}
clear(): void {
this.loading.set(false);
this.error.set(null);
this.findings.set([]);
this.verifyInProgress.set(false);
this.verifySteps.set([]);
this.verifyResult.set(null);
}
}

View File

@@ -0,0 +1,378 @@
/**
* Workspace Nav Dropdown Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal, computed } from '@angular/core';
import { WorkspaceNavDropdownComponent } from './workspace-nav-dropdown.component';
import {
WORKSPACE_PREFERENCES_SERVICE,
IWorkspacePreferencesService,
} from '../../services/workspace-preferences.service';
import { WorkspaceType, WorkspaceFeatureFlags } from '../../models/workspace-preferences.models';
describe('WorkspaceNavDropdownComponent', () => {
let component: WorkspaceNavDropdownComponent;
let fixture: ComponentFixture<WorkspaceNavDropdownComponent>;
let mockPreferencesService: jasmine.SpyObj<IWorkspacePreferencesService>;
const preferredWorkspaceSignal = signal<WorkspaceType>('developer');
const featureFlagsSignal = signal<WorkspaceFeatureFlags>({
developerEnabled: true,
auditorEnabled: true,
});
beforeEach(async () => {
mockPreferencesService = {
preferredWorkspace: preferredWorkspaceSignal,
featureFlags: featureFlagsSignal,
isDeveloperEnabled: computed(() => featureFlagsSignal().developerEnabled),
isAuditorEnabled: computed(() => featureFlagsSignal().auditorEnabled),
availableWorkspaces: computed(() => {
const flags = featureFlagsSignal();
const workspaces: WorkspaceType[] = [];
if (flags.developerEnabled) workspaces.push('developer');
if (flags.auditorEnabled) workspaces.push('auditor');
return workspaces;
}),
loadPreference: jasmine.createSpy('loadPreference'),
setPreferredWorkspace: jasmine.createSpy('setPreferredWorkspace').and.callFake((type: WorkspaceType) => {
preferredWorkspaceSignal.set(type);
}),
loadFeatureFlags: jasmine.createSpy('loadFeatureFlags'),
isWorkspaceEnabled: jasmine.createSpy('isWorkspaceEnabled'),
} as jasmine.SpyObj<IWorkspacePreferencesService>;
await TestBed.configureTestingModule({
imports: [WorkspaceNavDropdownComponent],
providers: [
provideRouter([]),
{ provide: WORKSPACE_PREFERENCES_SERVICE, useValue: mockPreferencesService },
],
}).compileComponents();
fixture = TestBed.createComponent(WorkspaceNavDropdownComponent);
component = fixture.componentInstance;
});
beforeEach(() => {
// Reset signals
preferredWorkspaceSignal.set('developer');
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: true });
});
describe('Initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should start with dropdown closed', () => {
fixture.detectChanges();
expect(component.isOpen()).toBe(false);
});
it('should have trigger button', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const trigger = compiled.querySelector('.dropdown-trigger');
expect(trigger).toBeTruthy();
expect(trigger?.textContent).toContain('Workspaces');
});
});
describe('Dropdown Toggle', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should open dropdown on trigger click', () => {
const compiled = fixture.nativeElement as HTMLElement;
const trigger = compiled.querySelector('.dropdown-trigger') as HTMLButtonElement;
trigger.click();
fixture.detectChanges();
expect(component.isOpen()).toBe(true);
});
it('should close dropdown on second click', () => {
component.toggleDropdown();
fixture.detectChanges();
component.toggleDropdown();
fixture.detectChanges();
expect(component.isOpen()).toBe(false);
});
it('should show menu when open', () => {
component.toggleDropdown();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const menu = compiled.querySelector('.dropdown-menu');
expect(menu).toBeTruthy();
});
it('should hide menu when closed', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const menu = compiled.querySelector('.dropdown-menu');
expect(menu).toBeFalsy();
});
});
describe('Menu Items', () => {
beforeEach(() => {
component.toggleDropdown();
fixture.detectChanges();
});
it('should display all enabled workspaces', () => {
const compiled = fixture.nativeElement as HTMLElement;
const items = compiled.querySelectorAll('.dropdown-item');
expect(items.length).toBe(2);
});
it('should show workspace labels', () => {
const compiled = fixture.nativeElement as HTMLElement;
const labels = compiled.querySelectorAll('.item-label');
expect(labels[0].textContent).toContain('Developer');
expect(labels[1].textContent).toContain('Auditor');
});
it('should show workspace descriptions', () => {
const compiled = fixture.nativeElement as HTMLElement;
const descriptions = compiled.querySelectorAll('.item-description');
expect(descriptions.length).toBe(2);
descriptions.forEach((desc) => {
expect(desc.textContent?.length).toBeGreaterThan(0);
});
});
it('should highlight preferred workspace', () => {
const compiled = fixture.nativeElement as HTMLElement;
const preferred = compiled.querySelector('.dropdown-item.preferred');
expect(preferred).toBeTruthy();
expect(preferred?.textContent).toContain('Developer');
});
it('should show checkmark on preferred workspace', () => {
const compiled = fixture.nativeElement as HTMLElement;
const badge = compiled.querySelector('.preferred-badge');
expect(badge).toBeTruthy();
});
it('should only show enabled workspaces', () => {
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: false });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const items = compiled.querySelectorAll('.dropdown-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Developer');
});
it('should show empty message when no workspaces available', () => {
featureFlagsSignal.set({ developerEnabled: false, auditorEnabled: false });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const empty = compiled.querySelector('.dropdown-empty');
expect(empty).toBeTruthy();
expect(empty?.textContent).toContain('No workspaces available');
});
});
describe('Workspace Selection', () => {
beforeEach(() => {
component.toggleDropdown();
fixture.detectChanges();
});
it('should update preference on selection', () => {
component.selectWorkspace('auditor');
expect(mockPreferencesService.setPreferredWorkspace).toHaveBeenCalledWith('auditor');
});
it('should close dropdown on selection', () => {
component.selectWorkspace('auditor');
expect(component.isOpen()).toBe(false);
});
it('should update preferred badge on selection', fakeAsync(() => {
component.selectWorkspace('auditor');
component.toggleDropdown();
fixture.detectChanges();
tick();
const compiled = fixture.nativeElement as HTMLElement;
const preferred = compiled.querySelector('.dropdown-item.preferred');
expect(preferred?.textContent).toContain('Auditor');
}));
});
describe('Workspace Links', () => {
beforeEach(() => {
component.toggleDropdown();
fixture.detectChanges();
});
it('should generate correct links without artifact digest', () => {
const link = component.getWorkspaceLink({
type: 'developer',
label: 'Developer View',
icon: 'code',
routePrefix: '/workspace/dev',
description: '',
});
expect(link).toBe('/workspace/dev');
});
it('should generate correct links with artifact digest', () => {
fixture.componentRef.setInput('artifactDigest', 'sha256:abc123');
fixture.detectChanges();
const link = component.getWorkspaceLink({
type: 'developer',
label: 'Developer View',
icon: 'code',
routePrefix: '/workspace/dev',
description: '',
});
expect(link).toBe('/workspace/dev/sha256%3Aabc123');
});
it('should have routerLink on menu items', () => {
const compiled = fixture.nativeElement as HTMLElement;
const items = compiled.querySelectorAll('.dropdown-item');
items.forEach((item) => {
expect(item.hasAttribute('ng-reflect-router-link') || item.getAttribute('href')).toBeTruthy();
});
});
});
describe('Close on Outside Click', () => {
beforeEach(() => {
component.toggleDropdown();
fixture.detectChanges();
});
it('should close on escape key', () => {
const event = new KeyboardEvent('keydown', { key: 'Escape' });
document.dispatchEvent(event);
expect(component.isOpen()).toBe(false);
});
it('should close on outside click', () => {
const event = new MouseEvent('click');
Object.defineProperty(event, 'target', { value: document.body });
document.dispatchEvent(event);
expect(component.isOpen()).toBe(false);
});
it('should stay open on inside click', () => {
const compiled = fixture.nativeElement as HTMLElement;
const menu = compiled.querySelector('.dropdown-menu');
const event = new MouseEvent('click', { bubbles: true });
menu?.dispatchEvent(event);
// Should still be open since click was inside
expect(component.isOpen()).toBe(true);
});
});
describe('Accessibility', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have aria-expanded on trigger', () => {
const compiled = fixture.nativeElement as HTMLElement;
const trigger = compiled.querySelector('.dropdown-trigger');
expect(trigger?.getAttribute('aria-expanded')).toBe('false');
component.toggleDropdown();
fixture.detectChanges();
expect(trigger?.getAttribute('aria-expanded')).toBe('true');
});
it('should have aria-haspopup on trigger', () => {
const compiled = fixture.nativeElement as HTMLElement;
const trigger = compiled.querySelector('.dropdown-trigger');
expect(trigger?.getAttribute('aria-haspopup')).toBe('true');
});
it('should have aria-label on trigger', () => {
const compiled = fixture.nativeElement as HTMLElement;
const trigger = compiled.querySelector('.dropdown-trigger');
expect(trigger?.getAttribute('aria-label')).toBeTruthy();
});
it('should have role="menu" on dropdown menu', () => {
component.toggleDropdown();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const menu = compiled.querySelector('.dropdown-menu');
expect(menu?.getAttribute('role')).toBe('menu');
});
it('should have role="menuitem" on items', () => {
component.toggleDropdown();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const items = compiled.querySelectorAll('.dropdown-item');
items.forEach((item) => {
expect(item.getAttribute('role')).toBe('menuitem');
});
});
});
describe('Footer', () => {
beforeEach(() => {
component.toggleDropdown();
fixture.detectChanges();
});
it('should show preference hint in footer', () => {
const compiled = fixture.nativeElement as HTMLElement;
const footer = compiled.querySelector('.dropdown-footer');
expect(footer).toBeTruthy();
expect(footer?.textContent).toContain('preference');
});
});
});

View File

@@ -0,0 +1,348 @@
/**
* Workspace Navigation Dropdown Component
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*
* A dropdown menu for global navigation showing available workspace links.
*/
import { Component, inject, input, signal, computed, HostListener, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import {
WorkspaceType,
WorkspaceNavItem,
WORKSPACE_NAV_ITEMS,
} from '../../models/workspace-preferences.models';
import {
WORKSPACE_PREFERENCES_SERVICE,
WorkspacePreferencesService,
} from '../../services/workspace-preferences.service';
@Component({
selector: 'stella-workspace-nav-dropdown',
standalone: true,
imports: [CommonModule, RouterModule],
providers: [
{ provide: WORKSPACE_PREFERENCES_SERVICE, useExisting: WorkspacePreferencesService },
],
template: `
<div class="workspace-dropdown" [class.open]="isOpen()">
<button
class="dropdown-trigger"
[attr.aria-expanded]="isOpen()"
aria-haspopup="true"
aria-label="Open workspace menu"
(click)="toggleDropdown()"
>
<span class="trigger-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="9"></rect>
<rect x="14" y="3" width="7" height="5"></rect>
<rect x="14" y="12" width="7" height="9"></rect>
<rect x="3" y="16" width="7" height="5"></rect>
</svg>
</span>
<span class="trigger-label">Workspaces</span>
<span class="trigger-chevron" [class.rotated]="isOpen()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</span>
</button>
@if (isOpen()) {
<div class="dropdown-menu" role="menu" aria-label="Workspace options">
@for (workspace of availableWorkspaces(); track workspace.type) {
<a
class="dropdown-item"
[class.preferred]="workspace.type === preferredWorkspace()"
[routerLink]="getWorkspaceLink(workspace)"
role="menuitem"
(click)="selectWorkspace(workspace.type)"
>
<span class="item-icon">
@switch (workspace.icon) {
@case ('code') {
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
}
@case ('shield-check') {
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
<polyline points="9 12 11 14 15 10"></polyline>
</svg>
}
}
</span>
<div class="item-content">
<span class="item-label">{{ workspace.label }}</span>
<span class="item-description">{{ workspace.description }}</span>
</div>
@if (workspace.type === preferredWorkspace()) {
<span class="preferred-badge" aria-label="Preferred workspace">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</span>
}
</a>
}
@if (availableWorkspaces().length === 0) {
<div class="dropdown-empty">
<span>No workspaces available</span>
</div>
}
<div class="dropdown-divider"></div>
<div class="dropdown-footer">
<span class="footer-hint">
Your preference is saved automatically
</span>
</div>
</div>
}
</div>
`,
styles: [`
.workspace-dropdown {
position: relative;
display: inline-block;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--border-primary, #e4e4e7);
border-radius: 6px;
background: var(--surface-primary, #fff);
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #18181b);
transition: all 0.15s ease;
}
.dropdown-trigger:hover {
background: var(--surface-hover, #f4f4f5);
border-color: var(--border-hover, #d4d4d8);
}
.dropdown-trigger:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
.trigger-icon {
display: inline-flex;
color: var(--text-secondary, #71717a);
}
.trigger-chevron {
display: inline-flex;
transition: transform 0.15s ease;
color: var(--text-tertiary, #a1a1aa);
}
.trigger-chevron.rotated {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
background: var(--surface-primary, #fff);
border: 1px solid var(--border-primary, #e4e4e7);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
text-decoration: none;
color: var(--text-primary, #18181b);
cursor: pointer;
transition: background 0.1s ease;
}
.dropdown-item:hover {
background: var(--surface-hover, #f4f4f5);
}
.dropdown-item.preferred {
background: var(--surface-accent-subtle, #eff6ff);
}
.dropdown-item.preferred:hover {
background: var(--surface-accent-hover, #dbeafe);
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--surface-secondary, #f4f4f5);
color: var(--text-secondary, #71717a);
flex-shrink: 0;
}
.dropdown-item.preferred .item-icon {
background: var(--primary-100, #dbeafe);
color: var(--primary-600, #2563eb);
}
.item-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.item-label {
font-weight: 500;
font-size: 14px;
}
.item-description {
font-size: 12px;
color: var(--text-secondary, #71717a);
}
.preferred-badge {
display: flex;
align-items: center;
color: var(--primary-600, #2563eb);
flex-shrink: 0;
}
.dropdown-divider {
height: 1px;
background: var(--border-primary, #e4e4e7);
margin: 4px 0;
}
.dropdown-footer {
padding: 8px 16px;
}
.footer-hint {
font-size: 11px;
color: var(--text-tertiary, #a1a1aa);
}
.dropdown-empty {
padding: 16px;
text-align: center;
color: var(--text-secondary, #71717a);
font-size: 13px;
}
/* Dark mode support */
:host-context(.dark-mode) .dropdown-trigger {
background: var(--surface-primary-dark, #27272a);
border-color: var(--border-primary-dark, #3f3f46);
color: var(--text-primary-dark, #fafafa);
}
:host-context(.dark-mode) .dropdown-trigger:hover {
background: var(--surface-hover-dark, #3f3f46);
}
:host-context(.dark-mode) .dropdown-menu {
background: var(--surface-primary-dark, #27272a);
border-color: var(--border-primary-dark, #3f3f46);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:host-context(.dark-mode) .dropdown-item {
color: var(--text-primary-dark, #fafafa);
}
:host-context(.dark-mode) .dropdown-item:hover {
background: var(--surface-hover-dark, #3f3f46);
}
:host-context(.dark-mode) .dropdown-item.preferred {
background: var(--surface-accent-subtle-dark, #1e3a5f);
}
:host-context(.dark-mode) .item-icon {
background: var(--surface-secondary-dark, #3f3f46);
color: var(--text-secondary-dark, #a1a1aa);
}
`],
})
export class WorkspaceNavDropdownComponent {
private readonly elementRef = inject(ElementRef);
private readonly preferencesService = inject(WORKSPACE_PREFERENCES_SERVICE);
/**
* Optional artifact digest for building workspace links.
*/
readonly artifactDigest = input<string>('');
/**
* Dropdown open state.
*/
readonly isOpen = signal(false);
/**
* Current preferred workspace.
*/
readonly preferredWorkspace = computed(() => this.preferencesService.preferredWorkspace());
/**
* Available workspaces based on feature flags.
*/
readonly availableWorkspaces = computed(() => {
const available = this.preferencesService.availableWorkspaces();
return WORKSPACE_NAV_ITEMS.filter((item) => available.includes(item.type));
});
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.elementRef.nativeElement.contains(event.target)) {
this.isOpen.set(false);
}
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
this.isOpen.set(false);
}
toggleDropdown(): void {
this.isOpen.update((open) => !open);
}
selectWorkspace(type: WorkspaceType): void {
this.preferencesService.setPreferredWorkspace(type);
this.isOpen.set(false);
}
getWorkspaceLink(workspace: WorkspaceNavItem): string {
const digest = this.artifactDigest();
if (digest) {
return `${workspace.routePrefix}/${encodeURIComponent(digest)}`;
}
// Return route prefix for listing/landing page
return workspace.routePrefix;
}
}

View File

@@ -0,0 +1,256 @@
/**
* Workspace Toggle Component Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { signal, computed } from '@angular/core';
import { WorkspaceToggleComponent } from './workspace-toggle.component';
import {
WORKSPACE_PREFERENCES_SERVICE,
IWorkspacePreferencesService,
} from '../../services/workspace-preferences.service';
import { WorkspaceType, WorkspaceFeatureFlags } from '../../models/workspace-preferences.models';
describe('WorkspaceToggleComponent', () => {
let component: WorkspaceToggleComponent;
let fixture: ComponentFixture<WorkspaceToggleComponent>;
let mockPreferencesService: jasmine.SpyObj<IWorkspacePreferencesService>;
let router: Router;
const preferredWorkspaceSignal = signal<WorkspaceType>('developer');
const featureFlagsSignal = signal<WorkspaceFeatureFlags>({
developerEnabled: true,
auditorEnabled: true,
});
beforeEach(async () => {
mockPreferencesService = {
preferredWorkspace: preferredWorkspaceSignal,
featureFlags: featureFlagsSignal,
isDeveloperEnabled: computed(() => featureFlagsSignal().developerEnabled),
isAuditorEnabled: computed(() => featureFlagsSignal().auditorEnabled),
availableWorkspaces: computed(() => {
const flags = featureFlagsSignal();
const workspaces: WorkspaceType[] = [];
if (flags.developerEnabled) workspaces.push('developer');
if (flags.auditorEnabled) workspaces.push('auditor');
return workspaces;
}),
loadPreference: jasmine.createSpy('loadPreference'),
setPreferredWorkspace: jasmine.createSpy('setPreferredWorkspace').and.callFake((type: WorkspaceType) => {
preferredWorkspaceSignal.set(type);
}),
loadFeatureFlags: jasmine.createSpy('loadFeatureFlags'),
isWorkspaceEnabled: jasmine.createSpy('isWorkspaceEnabled').and.callFake((type: WorkspaceType) => {
const flags = featureFlagsSignal();
return type === 'developer' ? flags.developerEnabled : flags.auditorEnabled;
}),
} as jasmine.SpyObj<IWorkspacePreferencesService>;
await TestBed.configureTestingModule({
imports: [WorkspaceToggleComponent],
providers: [
provideRouter([]),
{ provide: WORKSPACE_PREFERENCES_SERVICE, useValue: mockPreferencesService },
],
}).compileComponents();
router = TestBed.inject(Router);
spyOn(router, 'navigateByUrl');
fixture = TestBed.createComponent(WorkspaceToggleComponent);
component = fixture.componentInstance;
});
beforeEach(() => {
// Reset signals
preferredWorkspaceSignal.set('developer');
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: true });
});
describe('Initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should load preferences on init', () => {
fixture.detectChanges();
expect(mockPreferencesService.loadPreference).toHaveBeenCalled();
});
it('should display both workspaces when both enabled', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('.toggle-button');
expect(buttons.length).toBe(2);
});
it('should display only enabled workspaces', () => {
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: false });
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('.toggle-button');
expect(buttons.length).toBe(1);
expect(buttons[0].textContent).toContain('Developer');
});
});
describe('Toggle Behavior', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should highlight current workspace', () => {
const compiled = fixture.nativeElement as HTMLElement;
const activeButton = compiled.querySelector('.toggle-button.active');
expect(activeButton).toBeTruthy();
expect(activeButton?.textContent).toContain('Developer');
});
it('should update preference when workspace selected', () => {
component.selectWorkspace('auditor');
expect(mockPreferencesService.setPreferredWorkspace).toHaveBeenCalledWith('auditor');
});
it('should emit workspaceChanged event', () => {
const emitted: WorkspaceType[] = [];
component.workspaceChanged.subscribe((type) => emitted.push(type));
component.selectWorkspace('auditor');
expect(emitted).toEqual(['auditor']);
});
it('should not select disabled workspace', () => {
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: false });
fixture.detectChanges();
component.selectWorkspace('auditor');
expect(mockPreferencesService.setPreferredWorkspace).not.toHaveBeenCalled();
});
});
describe('Navigation', () => {
beforeEach(() => {
fixture.componentRef.setInput('artifactDigest', 'sha256:abc123');
fixture.detectChanges();
});
it('should navigate when workspace selected and navigateOnSelect is true', () => {
component.selectWorkspace('auditor');
expect(router.navigateByUrl).toHaveBeenCalledWith(
'/workspace/audit/sha256%3Aabc123'
);
});
it('should not navigate when navigateOnSelect is false', () => {
fixture.componentRef.setInput('navigateOnSelect', false);
fixture.detectChanges();
component.selectWorkspace('auditor');
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('should not navigate when artifactDigest is empty', () => {
fixture.componentRef.setInput('artifactDigest', '');
fixture.detectChanges();
component.selectWorkspace('auditor');
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
describe('Accessibility', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have role="tablist" on container', () => {
const compiled = fixture.nativeElement as HTMLElement;
const toggle = compiled.querySelector('.workspace-toggle');
expect(toggle?.getAttribute('role')).toBe('tablist');
});
it('should have role="tab" on buttons', () => {
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('.toggle-button');
buttons.forEach((button) => {
expect(button.getAttribute('role')).toBe('tab');
});
});
it('should have aria-selected on active button', () => {
const compiled = fixture.nativeElement as HTMLElement;
const activeButton = compiled.querySelector('.toggle-button.active');
expect(activeButton?.getAttribute('aria-selected')).toBe('true');
});
it('should have aria-label on buttons', () => {
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('.toggle-button');
buttons.forEach((button) => {
expect(button.getAttribute('aria-label')).toBeTruthy();
});
});
it('should disable button for disabled workspace', () => {
featureFlagsSignal.set({ developerEnabled: true, auditorEnabled: false });
fixture.detectChanges();
// Re-check with fresh compile - only developer should be shown
const compiled = fixture.nativeElement as HTMLElement;
const buttons = compiled.querySelectorAll('.toggle-button');
expect(buttons.length).toBe(1);
});
});
describe('Visual States', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should show code icon for developer workspace', () => {
const compiled = fixture.nativeElement as HTMLElement;
const developerIcon = compiled.querySelector('[data-icon="code"]');
expect(developerIcon).toBeTruthy();
});
it('should show shield icon for auditor workspace', () => {
const compiled = fixture.nativeElement as HTMLElement;
const auditorIcon = compiled.querySelector('[data-icon="shield-check"]');
expect(auditorIcon).toBeTruthy();
});
it('should update active state on preference change', fakeAsync(() => {
preferredWorkspaceSignal.set('auditor');
fixture.detectChanges();
tick();
const compiled = fixture.nativeElement as HTMLElement;
const activeButton = compiled.querySelector('.toggle-button.active');
expect(activeButton?.textContent).toContain('Auditor');
}));
});
});

View File

@@ -0,0 +1,209 @@
/**
* Workspace Toggle Component
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*
* A toggle component for switching between Developer and Auditor workspaces.
* Displayed in artifact detail headers to allow users to switch views.
*/
import { Component, inject, input, output, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import {
WorkspaceType,
WorkspaceNavItem,
WORKSPACE_NAV_ITEMS,
buildWorkspaceRoute,
} from '../../models/workspace-preferences.models';
import {
WORKSPACE_PREFERENCES_SERVICE,
WorkspacePreferencesService,
} from '../../services/workspace-preferences.service';
@Component({
selector: 'stella-workspace-toggle',
standalone: true,
imports: [CommonModule],
providers: [
{ provide: WORKSPACE_PREFERENCES_SERVICE, useExisting: WorkspacePreferencesService },
],
template: `
<div class="workspace-toggle" role="tablist" aria-label="Workspace view selector">
@for (workspace of availableWorkspaces(); track workspace.type) {
<button
class="toggle-button"
[class.active]="workspace.type === currentWorkspace()"
[attr.aria-selected]="workspace.type === currentWorkspace()"
[attr.aria-label]="workspace.label"
role="tab"
(click)="selectWorkspace(workspace.type)"
[disabled]="!isEnabled(workspace.type)"
>
<span class="toggle-icon" [attr.data-icon]="workspace.icon">
@switch (workspace.icon) {
@case ('code') {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
</svg>
}
@case ('shield-check') {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
<polyline points="9 12 11 14 15 10"></polyline>
</svg>
}
}
</span>
<span class="toggle-label">{{ workspace.label }}</span>
</button>
}
</div>
`,
styles: [`
.workspace-toggle {
display: inline-flex;
gap: 0;
background: var(--surface-secondary, #f4f4f5);
border-radius: 8px;
padding: 4px;
}
.toggle-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary, #71717a);
transition: all 0.15s ease;
}
.toggle-button:hover:not(:disabled) {
color: var(--text-primary, #18181b);
background: var(--surface-hover, rgba(0, 0, 0, 0.04));
}
.toggle-button.active {
background: var(--surface-primary, #fff);
color: var(--text-primary, #18181b);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.toggle-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-button:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
.toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.toggle-icon svg {
width: 16px;
height: 16px;
}
.toggle-label {
white-space: nowrap;
}
/* Dark mode support */
:host-context(.dark-mode) .workspace-toggle {
background: var(--surface-secondary-dark, #27272a);
}
:host-context(.dark-mode) .toggle-button {
color: var(--text-secondary-dark, #a1a1aa);
}
:host-context(.dark-mode) .toggle-button:hover:not(:disabled) {
color: var(--text-primary-dark, #fafafa);
background: var(--surface-hover-dark, rgba(255, 255, 255, 0.06));
}
:host-context(.dark-mode) .toggle-button.active {
background: var(--surface-primary-dark, #3f3f46);
color: var(--text-primary-dark, #fafafa);
}
/* Compact variant */
:host(.compact) .toggle-button {
padding: 6px 12px;
font-size: 13px;
}
:host(.compact) .toggle-icon svg {
width: 14px;
height: 14px;
}
`],
})
export class WorkspaceToggleComponent implements OnInit {
private readonly router = inject(Router);
private readonly preferencesService = inject(WORKSPACE_PREFERENCES_SERVICE);
/**
* Current artifact digest for navigation.
*/
readonly artifactDigest = input<string>('');
/**
* Whether to navigate on selection (default: true).
*/
readonly navigateOnSelect = input<boolean>(true);
/**
* Emitted when workspace selection changes.
*/
readonly workspaceChanged = output<WorkspaceType>();
/**
* Current selected workspace.
*/
readonly currentWorkspace = computed(() => this.preferencesService.preferredWorkspace());
/**
* Available workspaces based on feature flags.
*/
readonly availableWorkspaces = computed(() => {
const available = this.preferencesService.availableWorkspaces();
return WORKSPACE_NAV_ITEMS.filter((item) => available.includes(item.type));
});
ngOnInit(): void {
this.preferencesService.loadPreference();
}
selectWorkspace(type: WorkspaceType): void {
if (!this.isEnabled(type)) {
return;
}
this.preferencesService.setPreferredWorkspace(type);
this.workspaceChanged.emit(type);
if (this.navigateOnSelect() && this.artifactDigest()) {
const route = buildWorkspaceRoute(type, this.artifactDigest());
this.router.navigateByUrl(route);
}
}
isEnabled(type: WorkspaceType): boolean {
return this.preferencesService.isWorkspaceEnabled(type);
}
}

View File

@@ -0,0 +1,20 @@
/**
* Workspace Shared Feature - Public API
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
// Models
export * from './models/workspace-preferences.models';
// Services
export {
IWorkspacePreferencesService,
WORKSPACE_PREFERENCES_SERVICE,
WorkspacePreferencesService,
MockWorkspacePreferencesService,
} from './services/workspace-preferences.service';
// Components
export { WorkspaceToggleComponent } from './components/workspace-toggle/workspace-toggle.component';
export { WorkspaceNavDropdownComponent } from './components/workspace-nav-dropdown/workspace-nav-dropdown.component';

View File

@@ -0,0 +1,88 @@
/**
* Workspace Preferences Models
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
/**
* Workspace types available in the system.
*/
export type WorkspaceType = 'developer' | 'auditor';
/**
* User workspace preference.
*/
export interface WorkspacePreference {
readonly preferredWorkspace: WorkspaceType;
readonly lastUpdated: string;
}
/**
* Workspace feature flags.
*/
export interface WorkspaceFeatureFlags {
readonly developerEnabled: boolean;
readonly auditorEnabled: boolean;
}
/**
* Workspace navigation item.
*/
export interface WorkspaceNavItem {
readonly type: WorkspaceType;
readonly label: string;
readonly icon: string;
readonly routePrefix: string;
readonly description: string;
}
/**
* Default workspace navigation items.
*/
export const WORKSPACE_NAV_ITEMS: readonly WorkspaceNavItem[] = [
{
type: 'developer',
label: 'Developer View',
icon: 'code',
routePrefix: '/workspace/dev',
description: 'Verify evidence, explore findings, create issues',
},
{
type: 'auditor',
label: 'Auditor View',
icon: 'shield-check',
routePrefix: '/workspace/audit',
description: 'Review policy, export audit packs, triage findings',
},
] as const;
/**
* Local storage key for workspace preferences.
*/
export const WORKSPACE_PREFERENCE_KEY = 'stella.workspace.preference';
/**
* Default workspace preference.
*/
export const DEFAULT_WORKSPACE_PREFERENCE: WorkspacePreference = {
preferredWorkspace: 'developer',
lastUpdated: new Date().toISOString(),
};
/**
* Get workspace navigation item by type.
*/
export function getWorkspaceNavItem(type: WorkspaceType): WorkspaceNavItem | undefined {
return WORKSPACE_NAV_ITEMS.find((item) => item.type === type);
}
/**
* Build workspace route for an artifact.
*/
export function buildWorkspaceRoute(type: WorkspaceType, artifactDigest: string): string {
const navItem = getWorkspaceNavItem(type);
if (!navItem) {
return `/workspace/dev/${encodeURIComponent(artifactDigest)}`;
}
return `${navItem.routePrefix}/${encodeURIComponent(artifactDigest)}`;
}

View File

@@ -0,0 +1,256 @@
/**
* Workspace Preferences Service Tests
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import {
WorkspacePreferencesService,
MockWorkspacePreferencesService,
} from './workspace-preferences.service';
import {
WorkspacePreference,
WorkspaceFeatureFlags,
WORKSPACE_PREFERENCE_KEY,
} from '../models/workspace-preferences.models';
describe('WorkspacePreferencesService', () => {
let service: WorkspacePreferencesService;
let httpMock: HttpTestingController;
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [WorkspacePreferencesService],
});
service = TestBed.inject(WorkspacePreferencesService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
describe('loadPreference', () => {
it('should load preference from localStorage', () => {
const stored: WorkspacePreference = {
preferredWorkspace: 'auditor',
lastUpdated: '2024-01-25T10:00:00Z',
};
localStorage.setItem(WORKSPACE_PREFERENCE_KEY, JSON.stringify(stored));
service.loadPreference();
expect(service.preferredWorkspace()).toBe('auditor');
});
it('should use default when localStorage is empty', () => {
service.loadPreference();
expect(service.preferredWorkspace()).toBe('developer');
});
it('should use default when localStorage has invalid JSON', () => {
localStorage.setItem(WORKSPACE_PREFERENCE_KEY, 'invalid-json');
service.loadPreference();
expect(service.preferredWorkspace()).toBe('developer');
});
it('should not load disabled workspace preference', () => {
const stored: WorkspacePreference = {
preferredWorkspace: 'auditor',
lastUpdated: '2024-01-25T10:00:00Z',
};
localStorage.setItem(WORKSPACE_PREFERENCE_KEY, JSON.stringify(stored));
// Disable auditor workspace
service.featureFlags.set({
developerEnabled: true,
auditorEnabled: false,
});
service.loadPreference();
// Should keep default since auditor is disabled
expect(service.preferredWorkspace()).toBe('developer');
});
});
describe('setPreferredWorkspace', () => {
it('should update signal and localStorage', () => {
service.setPreferredWorkspace('auditor');
expect(service.preferredWorkspace()).toBe('auditor');
const stored = localStorage.getItem(WORKSPACE_PREFERENCE_KEY);
expect(stored).toBeTruthy();
const parsed: WorkspacePreference = JSON.parse(stored!);
expect(parsed.preferredWorkspace).toBe('auditor');
});
it('should not set disabled workspace', () => {
service.featureFlags.set({
developerEnabled: true,
auditorEnabled: false,
});
service.setPreferredWorkspace('auditor');
expect(service.preferredWorkspace()).toBe('developer');
});
it('should update lastUpdated timestamp', () => {
const before = new Date().toISOString();
service.setPreferredWorkspace('auditor');
const stored = localStorage.getItem(WORKSPACE_PREFERENCE_KEY);
const parsed: WorkspacePreference = JSON.parse(stored!);
expect(parsed.lastUpdated >= before).toBe(true);
});
});
describe('loadFeatureFlags', () => {
it('should fetch flags from API and update signal', fakeAsync(() => {
const mockFlags: WorkspaceFeatureFlags = {
developerEnabled: true,
auditorEnabled: false,
};
service.loadFeatureFlags().subscribe((flags) => {
expect(flags).toEqual(mockFlags);
});
const req = httpMock.expectOne('/api/v1/feature-flags/workspaces');
req.flush(mockFlags);
tick();
expect(service.featureFlags()).toEqual(mockFlags);
}));
it('should use defaults on API error', fakeAsync(() => {
service.loadFeatureFlags().subscribe((flags) => {
expect(flags.developerEnabled).toBe(true);
expect(flags.auditorEnabled).toBe(true);
});
const req = httpMock.expectOne('/api/v1/feature-flags/workspaces');
req.error(new ErrorEvent('Network error'));
tick();
expect(service.featureFlags().developerEnabled).toBe(true);
expect(service.featureFlags().auditorEnabled).toBe(true);
}));
it('should switch preference if current workspace becomes disabled', fakeAsync(() => {
// Start with auditor preference
service.preferredWorkspace.set('auditor');
const mockFlags: WorkspaceFeatureFlags = {
developerEnabled: true,
auditorEnabled: false,
};
service.loadFeatureFlags().subscribe();
const req = httpMock.expectOne('/api/v1/feature-flags/workspaces');
req.flush(mockFlags);
tick();
// Should have switched to developer
expect(service.preferredWorkspace()).toBe('developer');
}));
});
describe('isWorkspaceEnabled', () => {
it('should return true for enabled workspace', () => {
service.featureFlags.set({
developerEnabled: true,
auditorEnabled: true,
});
expect(service.isWorkspaceEnabled('developer')).toBe(true);
expect(service.isWorkspaceEnabled('auditor')).toBe(true);
});
it('should return false for disabled workspace', () => {
service.featureFlags.set({
developerEnabled: false,
auditorEnabled: false,
});
expect(service.isWorkspaceEnabled('developer')).toBe(false);
expect(service.isWorkspaceEnabled('auditor')).toBe(false);
});
it('should return false for unknown workspace type', () => {
expect(service.isWorkspaceEnabled('unknown' as any)).toBe(false);
});
});
describe('computed signals', () => {
it('isDeveloperEnabled should reflect feature flag', () => {
service.featureFlags.set({ developerEnabled: true, auditorEnabled: false });
expect(service.isDeveloperEnabled()).toBe(true);
service.featureFlags.set({ developerEnabled: false, auditorEnabled: true });
expect(service.isDeveloperEnabled()).toBe(false);
});
it('isAuditorEnabled should reflect feature flag', () => {
service.featureFlags.set({ developerEnabled: false, auditorEnabled: true });
expect(service.isAuditorEnabled()).toBe(true);
service.featureFlags.set({ developerEnabled: true, auditorEnabled: false });
expect(service.isAuditorEnabled()).toBe(false);
});
it('availableWorkspaces should list only enabled workspaces', () => {
service.featureFlags.set({ developerEnabled: true, auditorEnabled: true });
expect(service.availableWorkspaces()).toEqual(['developer', 'auditor']);
service.featureFlags.set({ developerEnabled: true, auditorEnabled: false });
expect(service.availableWorkspaces()).toEqual(['developer']);
service.featureFlags.set({ developerEnabled: false, auditorEnabled: true });
expect(service.availableWorkspaces()).toEqual(['auditor']);
service.featureFlags.set({ developerEnabled: false, auditorEnabled: false });
expect(service.availableWorkspaces()).toEqual([]);
});
});
});
describe('MockWorkspacePreferencesService', () => {
let service: MockWorkspacePreferencesService;
beforeEach(() => {
service = new MockWorkspacePreferencesService();
});
it('should allow setting feature flags for testing', () => {
service.setFeatureFlags({ auditorEnabled: false });
expect(service.isAuditorEnabled()).toBe(false);
expect(service.isDeveloperEnabled()).toBe(true);
});
it('should prevent setting disabled workspace as preference', () => {
service.setFeatureFlags({ auditorEnabled: false });
service.setPreferredWorkspace('auditor');
expect(service.preferredWorkspace()).toBe('developer');
});
});

View File

@@ -0,0 +1,194 @@
/**
* Workspace Preferences Service
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-06 - Workspace Navigation & Feature Flags
*/
import { Injectable, InjectionToken, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, catchError, tap } from 'rxjs';
import {
WorkspaceType,
WorkspacePreference,
WorkspaceFeatureFlags,
WORKSPACE_PREFERENCE_KEY,
DEFAULT_WORKSPACE_PREFERENCE,
} from '../models/workspace-preferences.models';
/**
* Workspace Preferences Service Interface.
*/
export interface IWorkspacePreferencesService {
readonly preferredWorkspace: ReturnType<typeof signal<WorkspaceType>>;
readonly featureFlags: ReturnType<typeof signal<WorkspaceFeatureFlags>>;
readonly isDeveloperEnabled: ReturnType<typeof computed<boolean>>;
readonly isAuditorEnabled: ReturnType<typeof computed<boolean>>;
readonly availableWorkspaces: ReturnType<typeof computed<WorkspaceType[]>>;
loadPreference(): void;
setPreferredWorkspace(workspace: WorkspaceType): void;
loadFeatureFlags(): Observable<WorkspaceFeatureFlags>;
isWorkspaceEnabled(type: WorkspaceType): boolean;
}
export const WORKSPACE_PREFERENCES_SERVICE = new InjectionToken<IWorkspacePreferencesService>(
'WorkspacePreferencesService'
);
/**
* HTTP implementation of Workspace Preferences Service.
*/
@Injectable({ providedIn: 'root' })
export class WorkspacePreferencesService implements IWorkspacePreferencesService {
private readonly http = inject(HttpClient);
readonly preferredWorkspace = signal<WorkspaceType>('developer');
readonly featureFlags = signal<WorkspaceFeatureFlags>({
developerEnabled: true,
auditorEnabled: true,
});
readonly isDeveloperEnabled = computed(() => this.featureFlags().developerEnabled);
readonly isAuditorEnabled = computed(() => this.featureFlags().auditorEnabled);
readonly availableWorkspaces = computed(() => {
const flags = this.featureFlags();
const workspaces: WorkspaceType[] = [];
if (flags.developerEnabled) {
workspaces.push('developer');
}
if (flags.auditorEnabled) {
workspaces.push('auditor');
}
return workspaces;
});
loadPreference(): void {
try {
const stored = localStorage.getItem(WORKSPACE_PREFERENCE_KEY);
if (stored) {
const preference: WorkspacePreference = JSON.parse(stored);
if (this.isWorkspaceEnabled(preference.preferredWorkspace)) {
this.preferredWorkspace.set(preference.preferredWorkspace);
}
}
} catch {
// Use default on parse error
this.preferredWorkspace.set(DEFAULT_WORKSPACE_PREFERENCE.preferredWorkspace);
}
}
setPreferredWorkspace(workspace: WorkspaceType): void {
if (!this.isWorkspaceEnabled(workspace)) {
return;
}
this.preferredWorkspace.set(workspace);
const preference: WorkspacePreference = {
preferredWorkspace: workspace,
lastUpdated: new Date().toISOString(),
};
try {
localStorage.setItem(WORKSPACE_PREFERENCE_KEY, JSON.stringify(preference));
} catch {
// localStorage may be full or unavailable
}
}
loadFeatureFlags(): Observable<WorkspaceFeatureFlags> {
return this.http.get<WorkspaceFeatureFlags>('/api/v1/feature-flags/workspaces').pipe(
tap((flags) => {
this.featureFlags.set(flags);
// If current preference is disabled, switch to an enabled workspace
if (!this.isWorkspaceEnabled(this.preferredWorkspace())) {
const available = this.availableWorkspaces();
if (available.length > 0) {
this.setPreferredWorkspace(available[0]);
}
}
}),
catchError(() => {
// Default: both enabled
const defaultFlags: WorkspaceFeatureFlags = {
developerEnabled: true,
auditorEnabled: true,
};
this.featureFlags.set(defaultFlags);
return of(defaultFlags);
})
);
}
isWorkspaceEnabled(type: WorkspaceType): boolean {
const flags = this.featureFlags();
switch (type) {
case 'developer':
return flags.developerEnabled;
case 'auditor':
return flags.auditorEnabled;
default:
return false;
}
}
}
/**
* Mock implementation for development/testing.
*/
@Injectable()
export class MockWorkspacePreferencesService implements IWorkspacePreferencesService {
readonly preferredWorkspace = signal<WorkspaceType>('developer');
readonly featureFlags = signal<WorkspaceFeatureFlags>({
developerEnabled: true,
auditorEnabled: true,
});
readonly isDeveloperEnabled = computed(() => this.featureFlags().developerEnabled);
readonly isAuditorEnabled = computed(() => this.featureFlags().auditorEnabled);
readonly availableWorkspaces = computed(() => {
const flags = this.featureFlags();
const workspaces: WorkspaceType[] = [];
if (flags.developerEnabled) {
workspaces.push('developer');
}
if (flags.auditorEnabled) {
workspaces.push('auditor');
}
return workspaces;
});
loadPreference(): void {
// Mock: use default
}
setPreferredWorkspace(workspace: WorkspaceType): void {
if (this.isWorkspaceEnabled(workspace)) {
this.preferredWorkspace.set(workspace);
}
}
loadFeatureFlags(): Observable<WorkspaceFeatureFlags> {
return of(this.featureFlags());
}
isWorkspaceEnabled(type: WorkspaceType): boolean {
const flags = this.featureFlags();
switch (type) {
case 'developer':
return flags.developerEnabled;
case 'auditor':
return flags.auditorEnabled;
default:
return false;
}
}
// Test helpers
setFeatureFlags(flags: Partial<WorkspaceFeatureFlags>): void {
this.featureFlags.update((current) => ({ ...current, ...flags }));
}
}

View File

@@ -0,0 +1,657 @@
/**
* Evidence Ribbon Component Stories
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-01 - Evidence Ribbon Component
*
* Storybook stories demonstrating all pill states for the Evidence Ribbon.
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata, applicationConfig } from '@storybook/angular';
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { EvidenceRibbonComponent } from '../../app/features/evidence-ribbon/components/evidence-ribbon/evidence-ribbon.component';
import { EvidenceRibbonService } from '../../app/features/evidence-ribbon/services/evidence-ribbon.service';
import type {
DsseEvidenceStatus,
RekorEvidenceStatus,
SbomEvidenceStatus,
VexEvidenceStatus,
PolicyEvidenceStatus,
} from '../../app/features/evidence-ribbon/models/evidence-ribbon.models';
// Mock service factory
const createMockService = (config: {
loading?: boolean;
dsse?: DsseEvidenceStatus | null;
rekor?: RekorEvidenceStatus | null;
sbom?: SbomEvidenceStatus | null;
vex?: VexEvidenceStatus | null;
policy?: PolicyEvidenceStatus | null;
}) => ({
loading: signal(config.loading ?? false),
dsseStatus: signal(config.dsse ?? null),
rekorStatus: signal(config.rekor ?? null),
sbomStatus: signal(config.sbom ?? null),
vexStatus: signal(config.vex ?? null),
policyStatus: signal(config.policy ?? null),
loadEvidenceStatus: () => of({}),
clear: () => {},
});
// Sample data
const successDsse: DsseEvidenceStatus = {
status: 'success',
signatureValid: true,
signerIdentity: 'build@acme-corp.com',
keyId: 'keyid:abc123def456789',
algorithm: 'ecdsa-p256',
trusted: true,
verifiedAt: '2026-01-27T10:00:00Z',
};
const successRekor: RekorEvidenceStatus = {
status: 'success',
included: true,
logIndex: 12345678,
logId: 'c0d23d6ad230f9a',
uuid: 'uuid-12345-67890',
tileId: 'tile-2026-01-27',
integratedTime: '2026-01-27T10:00:00Z',
proofValid: true,
};
const successSbom: SbomEvidenceStatus = {
status: 'success',
format: 'CycloneDX',
formatVersion: '1.5',
coveragePercent: 98,
componentCount: 150,
directDependencies: 25,
transitiveDependencies: 125,
generatedAt: '2026-01-27T09:00:00Z',
};
const successVex: VexEvidenceStatus = {
status: 'success',
statementCount: 5,
notAffectedCount: 3,
conflictCount: 0,
confidence: 'high',
};
const successPolicy: PolicyEvidenceStatus = {
status: 'success',
verdict: 'pass',
packName: 'production-security',
version: '1.2.0',
evaluatedAt: '2026-01-27T10:05:00Z',
};
const meta: Meta<EvidenceRibbonComponent> = {
title: 'Sprint-0127/Evidence Ribbon',
component: EvidenceRibbonComponent,
decorators: [
moduleMetadata({
imports: [EvidenceRibbonComponent],
}),
],
argTypes: {
pillClick: { action: 'pillClick' },
},
parameters: {
layout: 'padded',
docs: {
description: {
component: `
Evidence Ribbon displays compact status pills for attestation evidence:
- **DSSE**: Digital Signature Envelope status (verified/unverified)
- **Rekor**: Transparency log inclusion status
- **SBOM**: Software Bill of Materials format and coverage
- **VEX**: Vulnerability Exploitability eXchange statements (optional)
- **Policy**: Policy evaluation verdict (optional)
Each pill shows a colored status indicator with extended metadata on hover.
`,
},
},
},
render: (args) => ({
props: {
...args,
artifactDigest: 'sha256:abc123def456789012345678901234567890',
},
template: `
<div style="max-width: 800px; padding: 16px; background: #fff;">
<stella-evidence-ribbon
[artifactDigest]="artifactDigest"
[showVex]="showVex"
[showPolicy]="showPolicy"
(pillClick)="pillClick($event)"
/>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<EvidenceRibbonComponent>;
// =============================================================================
// All Success States
// =============================================================================
export const AllSuccess: Story = {
name: 'All Success',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: successSbom,
vex: successVex,
policy: successPolicy,
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: true,
},
parameters: {
docs: {
description: {
story: 'All evidence types verified successfully with full metadata.',
},
},
},
};
// =============================================================================
// Core Pills Only (Default View)
// =============================================================================
export const CorePillsOnly: Story = {
name: 'Core Pills Only (DSSE, Rekor, SBOM)',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: successSbom,
}),
},
],
}),
],
args: {
showVex: false,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'Default developer view showing only core evidence pills.',
},
},
},
};
// =============================================================================
// Warning States
// =============================================================================
export const WarningStates: Story = {
name: 'Warning States',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: {
status: 'warning',
signatureValid: true,
trusted: false,
signerIdentity: 'unknown@untrusted.com',
},
rekor: {
status: 'warning',
included: true,
proofValid: false,
logIndex: 12345678,
},
sbom: {
status: 'warning',
format: 'SPDX',
formatVersion: '2.3',
coveragePercent: 65,
componentCount: 80,
},
vex: {
status: 'warning',
statementCount: 3,
notAffectedCount: 1,
conflictCount: 2,
confidence: 'medium',
},
policy: {
status: 'warning',
verdict: 'warn',
packName: 'staging-policy',
},
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: true,
},
parameters: {
docs: {
description: {
story: 'Warning states indicating issues that need attention: untrusted signer, proof issues, low coverage, conflicts.',
},
},
},
};
// =============================================================================
// Error States
// =============================================================================
export const ErrorStates: Story = {
name: 'Error States',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: {
status: 'error',
signatureValid: false,
trusted: false,
errorMessage: 'Signature verification failed',
},
rekor: {
status: 'error',
included: false,
errorMessage: 'Entry not found in transparency log',
},
sbom: {
status: 'error',
format: 'Unknown',
errorMessage: 'SBOM parsing failed',
},
vex: {
status: 'error',
statementCount: 0,
notAffectedCount: 0,
conflictCount: 0,
},
policy: {
status: 'error',
verdict: 'fail',
packName: 'security-baseline',
},
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: true,
},
parameters: {
docs: {
description: {
story: 'Error states indicating verification failures or missing evidence.',
},
},
},
};
// =============================================================================
// Unknown States
// =============================================================================
export const UnknownStates: Story = {
name: 'Unknown/Pending States',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: {
status: 'unknown',
signatureValid: false,
trusted: false,
},
rekor: {
status: 'unknown',
included: false,
},
sbom: {
status: 'unknown',
format: 'Unknown',
},
}),
},
],
}),
],
args: {
showVex: false,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'Unknown states when evidence has not been evaluated yet.',
},
},
},
};
// =============================================================================
// Mixed States
// =============================================================================
export const MixedStates: Story = {
name: 'Mixed States',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: {
status: 'warning',
included: true,
proofValid: false,
tileId: 'tile-2026-01-26',
},
sbom: {
status: 'error',
format: 'CycloneDX',
coveragePercent: 45,
errorMessage: 'Coverage below threshold',
},
vex: {
status: 'success',
statementCount: 2,
notAffectedCount: 2,
conflictCount: 0,
confidence: 'high',
},
policy: {
status: 'warning',
verdict: 'warn',
packName: 'compliance-check',
},
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: true,
},
parameters: {
docs: {
description: {
story: 'Realistic scenario with mixed evidence states.',
},
},
},
};
// =============================================================================
// Loading State
// =============================================================================
export const Loading: Story = {
name: 'Loading',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
loading: true,
}),
},
],
}),
],
args: {
showVex: false,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'Loading spinner while evidence is being fetched.',
},
},
},
};
// =============================================================================
// Empty State
// =============================================================================
export const NoEvidence: Story = {
name: 'No Evidence Available',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({}),
},
],
}),
],
args: {
showVex: false,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'Empty state when no evidence is available for the artifact.',
},
},
},
};
// =============================================================================
// SBOM Format Variants
// =============================================================================
export const SpdxFormat: Story = {
name: 'SPDX Format',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: {
status: 'success',
format: 'SPDX',
formatVersion: '2.3',
coveragePercent: 92,
componentCount: 200,
directDependencies: 30,
transitiveDependencies: 170,
},
}),
},
],
}),
],
args: {
showVex: false,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'SBOM pill showing SPDX format with coverage percentage.',
},
},
},
};
// =============================================================================
// VEX Conflict Scenario
// =============================================================================
export const VexWithConflicts: Story = {
name: 'VEX with Conflicts',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: successSbom,
vex: {
status: 'warning',
statementCount: 8,
notAffectedCount: 3,
conflictCount: 3,
confidence: 'low',
},
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: false,
},
parameters: {
docs: {
description: {
story: 'VEX pill showing conflict count when sources disagree.',
},
},
},
};
// =============================================================================
// Policy Verdict Variants
// =============================================================================
export const PolicyFail: Story = {
name: 'Policy Fail',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: successSbom,
policy: {
status: 'error',
verdict: 'fail',
packName: 'production-security',
version: '2.0.0',
evaluatedAt: '2026-01-27T10:00:00Z',
},
}),
},
],
}),
],
args: {
showVex: false,
showPolicy: true,
},
parameters: {
docs: {
description: {
story: 'Policy pill showing a failed evaluation verdict.',
},
},
},
};
// =============================================================================
// Dark Theme
// =============================================================================
export const DarkTheme: Story = {
name: 'Dark Theme',
decorators: [
moduleMetadata({
providers: [
{
provide: EvidenceRibbonService,
useValue: createMockService({
dsse: successDsse,
rekor: successRekor,
sbom: successSbom,
vex: successVex,
policy: successPolicy,
}),
},
],
}),
],
args: {
showVex: true,
showPolicy: true,
},
render: (args) => ({
props: {
...args,
artifactDigest: 'sha256:abc123def456789012345678901234567890',
},
template: `
<div class="dark-theme" style="max-width: 800px; padding: 16px; background: #1a1d21;">
<stella-evidence-ribbon
[artifactDigest]="artifactDigest"
[showVex]="showVex"
[showPolicy]="showPolicy"
(pillClick)="pillClick($event)"
/>
</div>
`,
}),
parameters: {
backgrounds: { default: 'dark' },
docs: {
description: {
story: 'Evidence ribbon in dark theme mode.',
},
},
},
};

View File

@@ -0,0 +1,820 @@
/**
* SBOM Diff View Component Stories
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-02 - SBOM A/B Diff View
*
* Storybook stories demonstrating SBOM diff visualization with sample data.
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { signal, computed } from '@angular/core';
import { of, delay } from 'rxjs';
import { SbomDiffViewComponent } from '../../app/features/sbom-diff/components/sbom-diff-view/sbom-diff-view.component';
import { SbomDiffService } from '../../app/features/sbom-diff/services/sbom-diff.service';
import type {
SbomDiffResult,
SbomComponent,
SbomComponentChange,
SbomDiffSummary,
ComponentEcosystem,
} from '../../app/features/sbom-diff/models/sbom-diff.models';
// Sample components
const sampleAdded: SbomComponent[] = [
{
purl: 'pkg:npm/axios@1.6.0',
name: 'axios',
version: '1.6.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: true,
hash: 'sha256:abc123',
},
{
purl: 'pkg:npm/@types/node@20.10.0',
name: '@types/node',
version: '20.10.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: false,
hash: 'sha256:def456',
},
{
purl: 'pkg:pypi/pydantic@2.5.0',
name: 'pydantic',
version: '2.5.0',
ecosystem: 'pypi',
licenses: ['MIT'],
isDirect: true,
hash: 'sha256:ghi789',
},
];
const sampleRemoved: SbomComponent[] = [
{
purl: 'pkg:npm/request@2.88.2',
name: 'request',
version: '2.88.2',
ecosystem: 'npm',
licenses: ['Apache-2.0'],
isDirect: true,
hash: 'sha256:old123',
},
{
purl: 'pkg:npm/moment@2.29.4',
name: 'moment',
version: '2.29.4',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: false,
hash: 'sha256:old456',
},
];
const sampleChanged: SbomComponentChange[] = [
{
purl: 'pkg:npm/lodash@4.17.21',
name: 'lodash',
versionA: '4.17.20',
versionB: '4.17.21',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
{
purl: 'pkg:npm/express@4.19.0',
name: 'express',
versionA: '4.18.2',
versionB: '4.19.0',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
{
purl: 'pkg:maven/org.apache.commons/commons-lang3@3.14.0',
name: 'commons-lang3',
versionA: '3.12.0',
versionB: '3.14.0',
ecosystem: 'maven',
licensesA: ['Apache-2.0'],
licensesB: ['Apache-2.0'],
licenseChanged: false,
isDirectA: false,
isDirectB: true,
depTypeChanged: true,
},
{
purl: 'pkg:npm/webpack@5.89.0',
name: 'webpack',
versionA: '5.88.0',
versionB: '5.89.0',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['ISC'],
licenseChanged: true,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
];
// Mock service factory
const createMockService = (config: {
loading?: boolean;
error?: string | null;
result?: SbomDiffResult | null;
summary?: SbomDiffSummary | null;
ecosystems?: ComponentEcosystem[];
}) => {
const resultSignal = signal(config.result ?? null);
const summarySignal = signal(config.summary ?? null);
return {
loading: signal(config.loading ?? false),
error: signal(config.error ?? null),
result: resultSignal,
filteredResult: resultSignal,
filteredSummary: summarySignal,
availableEcosystems: signal(config.ecosystems ?? []),
loadDiff: () => of(config.result),
setFilter: () => {},
clearFilter: () => {},
clear: () => {},
};
};
const meta: Meta<SbomDiffViewComponent> = {
title: 'Sprint-0127/SBOM Diff View',
component: SbomDiffViewComponent,
decorators: [
moduleMetadata({
imports: [SbomDiffViewComponent],
}),
],
argTypes: {
componentSelect: { action: 'componentSelect' },
},
parameters: {
layout: 'padded',
docs: {
description: {
component: `
SBOM Diff View displays side-by-side comparison of two SBOM versions showing:
- **Added components**: New dependencies introduced (green)
- **Removed components**: Dependencies no longer present (red)
- **Changed components**: Version upgrades/downgrades and license changes (amber)
- **Summary cards**: Quick counts for each change type
- **Filter chips**: Filter by ecosystem or change type
Supports deterministic ordering (alphabetical by PURL) for reliable comparisons.
`,
},
},
},
render: (args) => ({
props: {
...args,
versionA: 'v1.0.0',
versionB: 'v1.1.0',
},
template: `
<div style="max-width: 1200px; padding: 16px; background: #f5f5f5;">
<stella-sbom-diff-view
[versionA]="versionA"
[versionB]="versionB"
(componentSelect)="componentSelect($event)"
/>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<SbomDiffViewComponent>;
// =============================================================================
// Full Diff with All Change Types
// =============================================================================
export const FullDiff: Story = {
name: 'Full Diff (Added, Removed, Changed)',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: sampleAdded,
removed: sampleRemoved,
changed: sampleChanged,
unchanged: [],
},
summary: {
addedCount: sampleAdded.length,
removedCount: sampleRemoved.length,
changedCount: sampleChanged.length,
unchangedCount: 142,
totalA: 150,
totalB: 151,
},
ecosystems: ['npm', 'pypi', 'maven'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Complete diff showing all change types with filtering options.',
},
},
},
};
// =============================================================================
// Only Added Components
// =============================================================================
export const OnlyAdded: Story = {
name: 'Only Added Components',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: [
...sampleAdded,
{
purl: 'pkg:npm/zod@3.22.0',
name: 'zod',
version: '3.22.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: true,
hash: 'sha256:zod123',
},
{
purl: 'pkg:npm/drizzle-orm@0.29.0',
name: 'drizzle-orm',
version: '0.29.0',
ecosystem: 'npm',
licenses: ['Apache-2.0'],
isDirect: true,
hash: 'sha256:drizzle123',
},
],
removed: [],
changed: [],
unchanged: [],
},
summary: {
addedCount: 5,
removedCount: 0,
changedCount: 0,
unchangedCount: 150,
totalA: 150,
totalB: 155,
},
ecosystems: ['npm', 'pypi'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Scenario where only new components were added.',
},
},
},
};
// =============================================================================
// Only Removed Components
// =============================================================================
export const OnlyRemoved: Story = {
name: 'Only Removed Components',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: [],
removed: [
...sampleRemoved,
{
purl: 'pkg:npm/underscore@1.13.6',
name: 'underscore',
version: '1.13.6',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: false,
hash: 'sha256:under123',
},
],
changed: [],
unchanged: [],
},
summary: {
addedCount: 0,
removedCount: 3,
changedCount: 0,
unchangedCount: 147,
totalA: 150,
totalB: 147,
},
ecosystems: ['npm'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Scenario where components were only removed (dependency cleanup).',
},
},
},
};
// =============================================================================
// Version Upgrades Only
// =============================================================================
export const VersionUpgrades: Story = {
name: 'Version Upgrades Only',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: [],
removed: [],
changed: [
{
purl: 'pkg:npm/react@18.3.0',
name: 'react',
versionA: '18.2.0',
versionB: '18.3.0',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
{
purl: 'pkg:npm/react-dom@18.3.0',
name: 'react-dom',
versionA: '18.2.0',
versionB: '18.3.0',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
{
purl: 'pkg:npm/typescript@5.3.0',
name: 'typescript',
versionA: '5.2.0',
versionB: '5.3.0',
ecosystem: 'npm',
licensesA: ['Apache-2.0'],
licensesB: ['Apache-2.0'],
licenseChanged: false,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
],
unchanged: [],
},
summary: {
addedCount: 0,
removedCount: 0,
changedCount: 3,
unchangedCount: 147,
totalA: 150,
totalB: 150,
},
ecosystems: ['npm'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Scenario showing only version upgrades (routine dependency updates).',
},
},
},
};
// =============================================================================
// License Changes
// =============================================================================
export const LicenseChanges: Story = {
name: 'License Changes Detected',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: [],
removed: [],
changed: [
{
purl: 'pkg:npm/problematic-pkg@2.0.0',
name: 'problematic-pkg',
versionA: '1.5.0',
versionB: '2.0.0',
ecosystem: 'npm',
licensesA: ['MIT'],
licensesB: ['GPL-3.0'],
licenseChanged: true,
isDirectA: true,
isDirectB: true,
depTypeChanged: false,
},
{
purl: 'pkg:npm/another-pkg@3.0.0',
name: 'another-pkg',
versionA: '2.9.0',
versionB: '3.0.0',
ecosystem: 'npm',
licensesA: ['Apache-2.0'],
licensesB: ['AGPL-3.0'],
licenseChanged: true,
isDirectA: false,
isDirectB: false,
depTypeChanged: false,
},
],
unchanged: [],
},
summary: {
addedCount: 0,
removedCount: 0,
changedCount: 2,
unchangedCount: 148,
totalA: 150,
totalB: 150,
},
ecosystems: ['npm'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Scenario highlighting license changes that may require legal review.',
},
},
},
};
// =============================================================================
// Multi-Ecosystem
// =============================================================================
export const MultiEcosystem: Story = {
name: 'Multi-Ecosystem (npm, pypi, maven, go)',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: [
{
purl: 'pkg:npm/new-npm-pkg@1.0.0',
name: 'new-npm-pkg',
version: '1.0.0',
ecosystem: 'npm',
licenses: ['MIT'],
isDirect: true,
hash: 'sha256:npm1',
},
{
purl: 'pkg:pypi/new-pypi-pkg@1.0.0',
name: 'new-pypi-pkg',
version: '1.0.0',
ecosystem: 'pypi',
licenses: ['Apache-2.0'],
isDirect: true,
hash: 'sha256:pypi1',
},
{
purl: 'pkg:maven/com.example/new-maven-pkg@1.0.0',
name: 'new-maven-pkg',
version: '1.0.0',
ecosystem: 'maven',
licenses: ['Apache-2.0'],
isDirect: true,
hash: 'sha256:maven1',
},
{
purl: 'pkg:golang/github.com/example/new-go-pkg@v1.0.0',
name: 'new-go-pkg',
version: 'v1.0.0',
ecosystem: 'go',
licenses: ['BSD-3-Clause'],
isDirect: true,
hash: 'sha256:go1',
},
],
removed: [],
changed: [],
unchanged: [],
},
summary: {
addedCount: 4,
removedCount: 0,
changedCount: 0,
unchangedCount: 150,
totalA: 150,
totalB: 154,
},
ecosystems: ['npm', 'pypi', 'maven', 'go'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Diff spanning multiple package ecosystems with ecosystem filter chips.',
},
},
},
};
// =============================================================================
// No Differences
// =============================================================================
export const NoDifferences: Story = {
name: 'No Differences',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.0.1',
added: [],
removed: [],
changed: [],
unchanged: [],
},
summary: {
addedCount: 0,
removedCount: 0,
changedCount: 0,
unchangedCount: 150,
totalA: 150,
totalB: 150,
},
ecosystems: [],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Empty state when two SBOM versions are identical.',
},
},
},
};
// =============================================================================
// Loading State
// =============================================================================
export const Loading: Story = {
name: 'Loading',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
loading: true,
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Loading spinner while SBOM diff is being computed.',
},
},
},
};
// =============================================================================
// Error State
// =============================================================================
export const Error: Story = {
name: 'Error State',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
error: 'Failed to load SBOM diff: Version v1.0.0 not found',
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Error state when SBOM diff fails to load.',
},
},
},
};
// =============================================================================
// Large Diff
// =============================================================================
const generateComponents = (prefix: string, count: number, ecosystem: ComponentEcosystem): SbomComponent[] =>
Array.from({ length: count }, (_, i) => ({
purl: `pkg:${ecosystem}/${prefix}-${i}@1.0.${i}`,
name: `${prefix}-${i}`,
version: `1.0.${i}`,
ecosystem,
licenses: ['MIT'],
isDirect: i % 3 === 0,
hash: `sha256:${prefix}${i}`,
}));
export const LargeDiff: Story = {
name: 'Large Diff (50+ changes)',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v2.0.0',
added: generateComponents('new-pkg', 25, 'npm'),
removed: generateComponents('old-pkg', 15, 'npm'),
changed: Array.from({ length: 20 }, (_, i) => ({
purl: `pkg:npm/updated-pkg-${i}@2.0.${i}`,
name: `updated-pkg-${i}`,
versionA: `1.0.${i}`,
versionB: `2.0.${i}`,
ecosystem: 'npm' as ComponentEcosystem,
licensesA: ['MIT'],
licensesB: ['MIT'],
licenseChanged: false,
isDirectA: i % 2 === 0,
isDirectB: i % 2 === 0,
depTypeChanged: false,
})),
unchanged: [],
},
summary: {
addedCount: 25,
removedCount: 15,
changedCount: 20,
unchangedCount: 500,
totalA: 535,
totalB: 545,
},
ecosystems: ['npm'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Large diff demonstrating scroll behavior with many components.',
},
},
},
};
// =============================================================================
// Dark Theme
// =============================================================================
export const DarkTheme: Story = {
name: 'Dark Theme',
decorators: [
moduleMetadata({
providers: [
{
provide: SbomDiffService,
useValue: createMockService({
result: {
versionA: 'v1.0.0',
versionB: 'v1.1.0',
added: sampleAdded,
removed: sampleRemoved,
changed: sampleChanged,
unchanged: [],
},
summary: {
addedCount: sampleAdded.length,
removedCount: sampleRemoved.length,
changedCount: sampleChanged.length,
unchangedCount: 142,
totalA: 150,
totalB: 151,
},
ecosystems: ['npm', 'pypi', 'maven'],
}),
},
],
}),
],
render: (args) => ({
props: {
...args,
versionA: 'v1.0.0',
versionB: 'v1.1.0',
},
template: `
<div class="dark-theme" style="max-width: 1200px; padding: 16px; background: #1a1d21;">
<stella-sbom-diff-view
[versionA]="versionA"
[versionB]="versionB"
(componentSelect)="componentSelect($event)"
/>
</div>
`,
}),
parameters: {
backgrounds: { default: 'dark' },
docs: {
description: {
story: 'SBOM diff view in dark theme mode.',
},
},
},
};

View File

@@ -0,0 +1,966 @@
/**
* VEX Timeline Component Stories
* Sprint: SPRINT_0127_0001_FE_sbom_vex_persona_views
* Task: FE-PERSONA-03 - VEX Merge Timeline
*
* Storybook stories demonstrating VEX timeline with multi-source conflict scenarios.
*/
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { signal } from '@angular/core';
import { of } from 'rxjs';
import { VexTimelineComponent } from '../../app/features/vex-timeline/components/vex-timeline/vex-timeline.component';
import { VexTimelineService } from '../../app/features/vex-timeline/services/vex-timeline.service';
import type {
VexTimelineState,
VexTimelineRow,
VexObservation,
VexSource,
VexConflict,
VexConsensus,
VexSourceType,
} from '../../app/features/vex-timeline/models/vex-timeline.models';
// Sample sources
const nvdSource: VexSource = {
id: 'nvd',
name: 'NVD (NIST)',
type: 'authority',
trustLevel: 90,
url: 'https://nvd.nist.gov',
};
const vendorSource: VexSource = {
id: 'vendor-acme',
name: 'ACME Corp',
type: 'vendor',
trustLevel: 85,
url: 'https://security.acme.com',
};
const internalSource: VexSource = {
id: 'internal',
name: 'Internal Security Team',
type: 'internal',
trustLevel: 95,
};
const communitySource: VexSource = {
id: 'osv',
name: 'OSV Database',
type: 'community',
trustLevel: 75,
url: 'https://osv.dev',
};
const cveSource: VexSource = {
id: 'cve-org',
name: 'CVE.org',
type: 'authority',
trustLevel: 88,
url: 'https://cve.org',
};
// Helper to create observations
const createObservation = (
id: string,
sourceId: string,
status: 'affected' | 'not_affected' | 'fixed' | 'investigating' | 'unknown',
confidence: 'high' | 'medium' | 'low',
timestamp: string,
options: Partial<VexObservation> = {}
): VexObservation => ({
id,
sourceId,
status,
confidence,
timestamp,
signed: false,
isLatest: false,
...options,
});
// Mock service factory
const createMockService = (config: {
loading?: boolean;
error?: string | null;
state?: VexTimelineState | null;
filteredRows?: VexTimelineRow[];
conflictCount?: number;
sourceTypes?: VexSourceType[];
}) => ({
loading: signal(config.loading ?? false),
error: signal(config.error ?? null),
state: signal(config.state ?? null),
filteredRows: signal(config.filteredRows ?? []),
conflictCount: signal(config.conflictCount ?? 0),
availableSourceTypes: signal(config.sourceTypes ?? []),
loadTimeline: () => of(config.state),
setFilter: () => {},
clearFilter: () => {},
clear: () => {},
});
const meta: Meta<VexTimelineComponent> = {
title: 'Sprint-0127/VEX Timeline',
component: VexTimelineComponent,
decorators: [
moduleMetadata({
imports: [VexTimelineComponent],
}),
],
argTypes: {
verifyDsse: { action: 'verifyDsse' },
},
parameters: {
layout: 'padded',
docs: {
description: {
component: `
VEX Timeline displays temporal visualization of vulnerability status evolution:
- **Timeline rows**: Each row represents one observation source
- **Status transitions**: Visual markers when status changed over time
- **Confidence scores**: Badge per observation showing low/medium/high
- **Conflict indicators**: Red markers where sources disagree
- **Consensus banner**: Shows the winning status with rationale
- **DSSE verification**: Inline button to verify signed statements
Supports filtering by source type and showing only conflicting sources.
`,
},
},
},
render: (args) => ({
props: {
...args,
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
},
template: `
<div style="max-width: 1000px; padding: 16px; background: #f5f5f5;">
<stella-vex-timeline
[advisoryId]="advisoryId"
[product]="product"
(verifyDsse)="verifyDsse($event)"
/>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<VexTimelineComponent>;
// =============================================================================
// Consensus: Not Affected (Happy Path)
// =============================================================================
export const ConsensusNotAffected: Story = {
name: 'Consensus: Not Affected',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
rows: [],
conflicts: [],
consensus: {
status: 'not_affected',
confidence: 'high',
rationale: 'All sources agree this version is not affected. The vulnerable code path is not reachable.',
contributingSources: [nvdSource.id, vendorSource.id, internalSource.id],
disagreeSources: [],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('obs-1', nvdSource.id, 'not_affected', 'high', '2026-01-25T10:00:00Z', {
justification: 'vulnerable_code_not_present',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-1', nvdSource.id, 'not_affected', 'high', '2026-01-25T10:00:00Z', {
justification: 'vulnerable_code_not_present',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: vendorSource,
observations: [
createObservation('obs-2', vendorSource.id, 'not_affected', 'high', '2026-01-26T08:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'Confirmed after internal analysis.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-2', vendorSource.id, 'not_affected', 'high', '2026-01-26T08:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'Confirmed after internal analysis.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: internalSource,
observations: [
createObservation('obs-3', internalSource.id, 'not_affected', 'high', '2026-01-27T09:00:00Z', {
justification: 'vulnerable_code_cannot_be_controlled_by_adversary',
notes: 'Verified via static analysis and runtime testing.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-3', internalSource.id, 'not_affected', 'high', '2026-01-27T09:00:00Z', {
justification: 'vulnerable_code_cannot_be_controlled_by_adversary',
notes: 'Verified via static analysis and runtime testing.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
],
conflictCount: 0,
sourceTypes: ['authority', 'vendor', 'internal'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Happy path: All sources agree the product is not affected by the vulnerability.',
},
},
},
};
// =============================================================================
// Multi-Source Conflict
// =============================================================================
export const MultiSourceConflict: Story = {
name: 'Multi-Source Conflict',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-67890',
product: 'pkg:npm/express@4.18.2',
rows: [],
conflicts: [
{
id: 'conflict-1',
type: 'status_disagreement',
description: 'NVD reports affected while vendor claims not_affected',
sources: [nvdSource, vendorSource],
resolved: false,
},
{
id: 'conflict-2',
type: 'confidence_mismatch',
description: 'Community source has low confidence conflicting with high-confidence internal assessment',
sources: [communitySource, internalSource],
resolved: false,
},
],
consensus: {
status: 'investigating',
confidence: 'medium',
rationale: 'Conflicting assessments require further investigation. Internal team is validating.',
contributingSources: [internalSource.id],
disagreeSources: [nvdSource.id, vendorSource.id],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('obs-nvd-1', nvdSource.id, 'affected', 'high', '2026-01-20T10:00:00Z', {
affectedRange: '>=4.0.0 <4.19.0',
notes: 'Remote code execution vulnerability in query parser.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-nvd-1', nvdSource.id, 'affected', 'high', '2026-01-20T10:00:00Z', {
affectedRange: '>=4.0.0 <4.19.0',
notes: 'Remote code execution vulnerability in query parser.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: true,
},
{
source: vendorSource,
observations: [
createObservation('obs-vendor-1', vendorSource.id, 'not_affected', 'high', '2026-01-22T14:00:00Z', {
justification: 'vulnerable_code_not_in_execute_path',
notes: 'Our application does not use the affected query parser feature.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-vendor-1', vendorSource.id, 'not_affected', 'high', '2026-01-22T14:00:00Z', {
justification: 'vulnerable_code_not_in_execute_path',
notes: 'Our application does not use the affected query parser feature.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: true,
},
{
source: communitySource,
observations: [
createObservation('obs-osv-1', communitySource.id, 'affected', 'low', '2026-01-21T08:00:00Z', {
affectedRange: '>=4.0.0',
notes: 'Unconfirmed report from community.',
signed: false,
isLatest: true,
}),
],
latestObservation: createObservation('obs-osv-1', communitySource.id, 'affected', 'low', '2026-01-21T08:00:00Z', {
affectedRange: '>=4.0.0',
notes: 'Unconfirmed report from community.',
signed: false,
isLatest: true,
}),
transitions: [],
hasConflicts: true,
},
{
source: internalSource,
observations: [
createObservation('obs-int-1', internalSource.id, 'investigating', 'medium', '2026-01-27T09:00:00Z', {
notes: 'Currently validating vendor claim with runtime analysis.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('obs-int-1', internalSource.id, 'investigating', 'medium', '2026-01-27T09:00:00Z', {
notes: 'Currently validating vendor claim with runtime analysis.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
],
conflictCount: 2,
sourceTypes: ['authority', 'vendor', 'community', 'internal'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Conflict scenario: Multiple sources disagree on vulnerability status, requiring investigation.',
},
},
},
};
// =============================================================================
// Status Evolution Over Time
// =============================================================================
export const StatusEvolution: Story = {
name: 'Status Evolution Over Time',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-11111',
product: 'pkg:npm/axios@1.5.0',
rows: [],
conflicts: [],
consensus: {
status: 'fixed',
confidence: 'high',
rationale: 'Vulnerability patched in version 1.5.1. All sources confirm.',
contributingSources: [nvdSource.id, vendorSource.id],
disagreeSources: [],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('nvd-1', nvdSource.id, 'unknown', 'low', '2026-01-10T10:00:00Z', {
notes: 'Initial report received, under review.',
}),
createObservation('nvd-2', nvdSource.id, 'affected', 'high', '2026-01-15T10:00:00Z', {
affectedRange: '>=1.0.0 <1.5.1',
notes: 'Confirmed SSRF vulnerability in proxy handling.',
signed: true,
}),
createObservation('nvd-3', nvdSource.id, 'fixed', 'high', '2026-01-20T10:00:00Z', {
fixedVersion: '1.5.1',
notes: 'Patch released and verified.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('nvd-3', nvdSource.id, 'fixed', 'high', '2026-01-20T10:00:00Z', {
fixedVersion: '1.5.1',
notes: 'Patch released and verified.',
signed: true,
isLatest: true,
}),
transitions: [
{ fromStatus: 'unknown', toStatus: 'affected', timestamp: '2026-01-15T10:00:00Z' },
{ fromStatus: 'affected', toStatus: 'fixed', timestamp: '2026-01-20T10:00:00Z' },
],
hasConflicts: false,
},
{
source: vendorSource,
observations: [
createObservation('vendor-1', vendorSource.id, 'investigating', 'medium', '2026-01-12T08:00:00Z', {
notes: 'Security team investigating reported issue.',
}),
createObservation('vendor-2', vendorSource.id, 'affected', 'high', '2026-01-14T16:00:00Z', {
affectedRange: '>=1.0.0 <1.5.1',
notes: 'Confirmed. Working on patch.',
signed: true,
}),
createObservation('vendor-3', vendorSource.id, 'fixed', 'high', '2026-01-19T12:00:00Z', {
fixedVersion: '1.5.1',
notes: 'Patch v1.5.1 released to npm.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('vendor-3', vendorSource.id, 'fixed', 'high', '2026-01-19T12:00:00Z', {
fixedVersion: '1.5.1',
notes: 'Patch v1.5.1 released to npm.',
signed: true,
isLatest: true,
}),
transitions: [
{ fromStatus: 'investigating', toStatus: 'affected', timestamp: '2026-01-14T16:00:00Z' },
{ fromStatus: 'affected', toStatus: 'fixed', timestamp: '2026-01-19T12:00:00Z' },
],
hasConflicts: false,
},
],
conflictCount: 0,
sourceTypes: ['authority', 'vendor'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Shows status evolution from unknown → affected → fixed across multiple sources over time.',
},
},
},
};
// =============================================================================
// Resolved Conflict
// =============================================================================
export const ResolvedConflict: Story = {
name: 'Resolved Conflict',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-22222',
product: 'pkg:pypi/requests@2.31.0',
rows: [],
conflicts: [
{
id: 'conflict-resolved',
type: 'status_disagreement',
description: 'Initial disagreement between NVD and vendor on affected versions.',
sources: [nvdSource, vendorSource],
resolved: true,
resolution: 'Vendor provided additional context. NVD updated their assessment.',
},
],
consensus: {
status: 'not_affected',
confidence: 'high',
rationale: 'Version 2.31.0 is not affected. Vulnerability only impacts versions prior to 2.25.0.',
contributingSources: [nvdSource.id, vendorSource.id],
disagreeSources: [],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('nvd-old', nvdSource.id, 'affected', 'medium', '2026-01-15T10:00:00Z', {
affectedRange: '<2.32.0',
notes: 'Initial assessment based on advisory.',
}),
createObservation('nvd-new', nvdSource.id, 'not_affected', 'high', '2026-01-25T10:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'Updated: Affected versions are <2.25.0 only.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('nvd-new', nvdSource.id, 'not_affected', 'high', '2026-01-25T10:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'Updated: Affected versions are <2.25.0 only.',
signed: true,
isLatest: true,
}),
transitions: [
{ fromStatus: 'affected', toStatus: 'not_affected', timestamp: '2026-01-25T10:00:00Z' },
],
hasConflicts: false,
},
{
source: vendorSource,
observations: [
createObservation('vendor-1', vendorSource.id, 'not_affected', 'high', '2026-01-16T08:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'v2.31.0 was never affected. Fixed in 2.25.0.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('vendor-1', vendorSource.id, 'not_affected', 'high', '2026-01-16T08:00:00Z', {
justification: 'vulnerable_code_not_present',
notes: 'v2.31.0 was never affected. Fixed in 2.25.0.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
],
conflictCount: 0,
sourceTypes: ['authority', 'vendor'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Shows a conflict that was resolved after additional vendor context was provided.',
},
},
},
};
// =============================================================================
// Single Source
// =============================================================================
export const SingleSource: Story = {
name: 'Single Source Only',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-33333',
product: 'pkg:npm/internal-lib@1.0.0',
rows: [],
conflicts: [],
consensus: {
status: 'not_affected',
confidence: 'medium',
rationale: 'Internal assessment only. No external sources available.',
contributingSources: [internalSource.id],
disagreeSources: [],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: internalSource,
observations: [
createObservation('int-1', internalSource.id, 'not_affected', 'medium', '2026-01-27T09:00:00Z', {
justification: 'inline_mitigations_already_exist',
notes: 'Internal library with custom mitigations in place.',
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('int-1', internalSource.id, 'not_affected', 'medium', '2026-01-27T09:00:00Z', {
justification: 'inline_mitigations_already_exist',
notes: 'Internal library with custom mitigations in place.',
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
],
conflictCount: 0,
sourceTypes: ['internal'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Scenario with only internal assessment available (no external sources).',
},
},
},
};
// =============================================================================
// Loading State
// =============================================================================
export const Loading: Story = {
name: 'Loading',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
loading: true,
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Loading spinner while VEX timeline is being fetched.',
},
},
},
};
// =============================================================================
// Error State
// =============================================================================
export const Error: Story = {
name: 'Error State',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
error: 'Failed to load VEX timeline: Advisory CVE-2024-99999 not found',
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Error state when VEX timeline fails to load.',
},
},
},
};
// =============================================================================
// No VEX Data
// =============================================================================
export const NoVexData: Story = {
name: 'No VEX Data',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-44444',
product: 'pkg:npm/obscure-pkg@1.0.0',
rows: [],
conflicts: [],
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [],
conflictCount: 0,
sourceTypes: [],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Empty state when no VEX statements exist for the advisory/product pair.',
},
},
},
};
// =============================================================================
// Five Source Types
// =============================================================================
export const AllSourceTypes: Story = {
name: 'All Source Types',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-55555',
product: 'pkg:npm/popular-lib@5.0.0',
rows: [],
conflicts: [],
consensus: {
status: 'not_affected',
confidence: 'high',
rationale: 'Consensus from multiple high-trust sources.',
contributingSources: [nvdSource.id, cveSource.id, vendorSource.id, internalSource.id, communitySource.id],
disagreeSources: [],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('nvd', nvdSource.id, 'not_affected', 'high', '2026-01-20T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('nvd', nvdSource.id, 'not_affected', 'high', '2026-01-20T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: cveSource,
observations: [
createObservation('cve', cveSource.id, 'not_affected', 'high', '2026-01-21T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('cve', cveSource.id, 'not_affected', 'high', '2026-01-21T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: vendorSource,
observations: [
createObservation('vendor', vendorSource.id, 'not_affected', 'high', '2026-01-22T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('vendor', vendorSource.id, 'not_affected', 'high', '2026-01-22T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: internalSource,
observations: [
createObservation('internal', internalSource.id, 'not_affected', 'high', '2026-01-23T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('internal', internalSource.id, 'not_affected', 'high', '2026-01-23T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
{
source: communitySource,
observations: [
createObservation('community', communitySource.id, 'not_affected', 'medium', '2026-01-24T10:00:00Z', {
signed: false,
isLatest: true,
}),
],
latestObservation: createObservation('community', communitySource.id, 'not_affected', 'medium', '2026-01-24T10:00:00Z', {
signed: false,
isLatest: true,
}),
transitions: [],
hasConflicts: false,
},
],
conflictCount: 0,
sourceTypes: ['authority', 'vendor', 'internal', 'community'],
}),
},
],
}),
],
parameters: {
docs: {
description: {
story: 'Timeline with all source types showing filter chip functionality.',
},
},
},
};
// =============================================================================
// Dark Theme
// =============================================================================
export const DarkTheme: Story = {
name: 'Dark Theme',
decorators: [
moduleMetadata({
providers: [
{
provide: VexTimelineService,
useValue: createMockService({
state: {
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
rows: [],
conflicts: [
{
id: 'conflict-1',
type: 'status_disagreement',
description: 'Sources disagree on affected status',
sources: [nvdSource, vendorSource],
resolved: false,
},
],
consensus: {
status: 'investigating',
confidence: 'medium',
rationale: 'Under investigation due to conflicting reports.',
contributingSources: [internalSource.id],
disagreeSources: [nvdSource.id, vendorSource.id],
determinedAt: '2026-01-27T10:00:00Z',
},
lastUpdated: '2026-01-27T10:00:00Z',
},
filteredRows: [
{
source: nvdSource,
observations: [
createObservation('nvd', nvdSource.id, 'affected', 'high', '2026-01-20T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('nvd', nvdSource.id, 'affected', 'high', '2026-01-20T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: true,
},
{
source: vendorSource,
observations: [
createObservation('vendor', vendorSource.id, 'not_affected', 'high', '2026-01-22T10:00:00Z', {
signed: true,
isLatest: true,
}),
],
latestObservation: createObservation('vendor', vendorSource.id, 'not_affected', 'high', '2026-01-22T10:00:00Z', {
signed: true,
isLatest: true,
}),
transitions: [],
hasConflicts: true,
},
],
conflictCount: 1,
sourceTypes: ['authority', 'vendor'],
}),
},
],
}),
],
render: (args) => ({
props: {
...args,
advisoryId: 'CVE-2024-12345',
product: 'pkg:npm/lodash@4.17.21',
},
template: `
<div class="dark-theme" style="max-width: 1000px; padding: 16px; background: #1a1d21;">
<stella-vex-timeline
[advisoryId]="advisoryId"
[product]="product"
(verifyDsse)="verifyDsse($event)"
/>
</div>
`,
}),
parameters: {
backgrounds: { default: 'dark' },
docs: {
description: {
story: 'VEX timeline in dark theme mode.',
},
},
},
};

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -1,4 +0,0 @@
{
"url": "http://127.0.0.1:4400/console/profile",
"violations": []
}

View File

@@ -1,92 +0,0 @@
{
"url": "http://127.0.0.1:4400/console/status",
"violations": [
{
"id": "color-contrast",
"impact": "serious",
"tags": [
"cat.color",
"wcag2aa",
"wcag143",
"TTv5",
"TT13.c",
"EN-301-549",
"EN-9.1.4.3",
"ACT"
],
"description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds",
"help": "Elements must meet minimum color contrast ratio thresholds",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=playwright",
"nodes": [
{
"any": [
{
"id": "color-contrast",
"data": {
"fgColor": "#f05d5d",
"bgColor": "#f8fafc",
"contrastRatio": 3.12,
"fontSize": "12.0pt (16px)",
"fontWeight": "normal",
"messageKey": null,
"expectedContrastRatio": "4.5:1"
},
"relatedNodes": [
{
"html": "<app-root _nghost-ng-c1556698195=\"\" ng-version=\"17.3.12\">",
"target": [
"app-root"
]
}
],
"impact": "serious",
"message": "Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
],
"all": [],
"none": [],
"impact": "serious",
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"error\">Unable to load console status</div>",
"target": [
".error"
],
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
},
{
"any": [
{
"id": "color-contrast",
"data": {
"fgColor": "#69707a",
"bgColor": "#0b0f14",
"contrastRatio": 3.84,
"fontSize": "12.0pt (16px)",
"fontWeight": "normal",
"messageKey": null,
"expectedContrastRatio": "4.5:1"
},
"relatedNodes": [
{
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"events\"><!--bindings={\n \"ng-reflect-ng-for-of\": \"\"\n}--><p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p><!--bindings={\n \"ng-reflect-ng-if\": \"true\"\n}--></div>",
"target": [
".events"
]
}
],
"impact": "serious",
"message": "Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
],
"all": [],
"none": [],
"impact": "serious",
"html": "<p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p>",
"target": [
".empty"
],
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
]
}
]
}

View File

@@ -1,4 +0,0 @@
{
"url": "http://127.0.0.1:4400/graph",
"violations": []
}

View File

@@ -1,4 +0,0 @@
{
"url": "http://127.0.0.1:4400/triage/artifacts/asset-web-prod",
"violations": []
}

View File

@@ -0,0 +1,762 @@
// -----------------------------------------------------------------------------
// doctor-registry.spec.ts
// Sprint: SPRINT_0127_001_0002_oci_registry_compatibility
// Tasks: REG-UI-01
// Description: E2E tests for Doctor Registry UI components
// -----------------------------------------------------------------------------
import { expect, test, type Page } from '@playwright/test';
/**
* E2E Tests for Doctor Registry UI Components
* Task REG-UI-01: Registry health card, capability matrix, check details
*/
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope: 'openid profile email ui.read doctor:read',
audience: 'https://doctor.local',
},
apiBaseUrls: {
authority: 'https://authority.local',
doctor: 'https://doctor.local',
},
quickstartMode: true,
};
// Mock Doctor report with registry check results
const mockDoctorReport = {
runId: 'run-registry-001',
status: 'completed',
startedAt: '2026-01-27T10:00:00Z',
completedAt: '2026-01-27T10:01:30Z',
durationMs: 90000,
summary: {
passed: 4,
info: 1,
warnings: 2,
failed: 1,
skipped: 0,
total: 8,
},
overallSeverity: 'fail',
results: [
// Harbor Registry - healthy
{
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'V2 endpoint accessible and responding correctly',
evidence: {
description: 'Registry V2 API probe results',
data: {
registry_url: 'https://harbor.example.com',
registry_name: 'Harbor Production',
status_code: '200',
response_time_ms: '45',
server_header: 'Harbor',
},
},
durationMs: 150,
executedAt: '2026-01-27T10:00:05Z',
},
{
checkId: 'integration.registry.auth-config',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'Authentication configured correctly',
evidence: {
description: 'Authentication validation results',
data: {
registry_url: 'https://harbor.example.com',
auth_method: 'bearer',
token_valid: 'true',
},
},
durationMs: 85,
executedAt: '2026-01-27T10:00:10Z',
},
{
checkId: 'integration.registry.referrers-api',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'OCI 1.1 Referrers API fully supported',
evidence: {
description: 'Referrers API probe results',
data: {
registry_url: 'https://harbor.example.com',
referrers_supported: 'true',
api_version: 'OCI 1.1',
},
},
durationMs: 200,
executedAt: '2026-01-27T10:00:15Z',
},
// Generic OCI Registry - degraded (no referrers API)
{
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'pass',
diagnosis: 'V2 endpoint accessible',
evidence: {
description: 'Registry V2 API probe results',
data: {
registry_url: 'https://registry.example.com',
registry_name: 'Generic OCI Registry',
status_code: '200',
response_time_ms: '120',
},
},
durationMs: 180,
executedAt: '2026-01-27T10:00:30Z',
},
{
checkId: 'integration.registry.referrers-api',
pluginId: 'integration.registry',
category: 'integration',
severity: 'warn',
diagnosis: 'Referrers API not supported, using tag-based fallback',
evidence: {
description: 'Referrers API probe results',
data: {
registry_url: 'https://registry.example.com',
referrers_supported: 'false',
fallback_required: 'true',
http_status: '404',
},
},
likelyCauses: [
'Registry does not support OCI Distribution Spec 1.1',
'Referrers API endpoint not implemented',
],
remediation: {
requiresBackup: false,
steps: [
{
order: 1,
description: 'Upgrade registry to a version supporting OCI 1.1',
command: 'helm upgrade registry oci-registry --version 2.0.0',
commandType: 'shell',
},
],
},
durationMs: 250,
executedAt: '2026-01-27T10:00:45Z',
},
// Broken Registry - unhealthy
{
checkId: 'integration.registry.v2-endpoint',
pluginId: 'integration.registry',
category: 'integration',
severity: 'fail',
diagnosis: 'V2 endpoint unreachable - connection refused',
evidence: {
description: 'Registry V2 API probe results',
data: {
registry_url: 'https://broken.example.com',
registry_name: 'Broken Registry',
error: 'Connection refused',
error_code: 'ECONNREFUSED',
},
},
likelyCauses: [
'Registry service is not running',
'Firewall blocking connection',
'Incorrect registry URL',
],
remediation: {
requiresBackup: false,
steps: [
{
order: 1,
description: 'Verify registry service is running',
command: 'docker ps | grep registry',
commandType: 'shell',
},
{
order: 2,
description: 'Check firewall rules',
command: 'iptables -L -n | grep 5000',
commandType: 'shell',
},
],
},
durationMs: 3000,
executedAt: '2026-01-27T10:01:00Z',
},
// Capability check - info severity
{
checkId: 'integration.registry.capabilities',
pluginId: 'integration.registry',
category: 'integration',
severity: 'info',
diagnosis: 'Registry capability matrix generated',
evidence: {
description: 'OCI capability probe results',
data: {
registry_url: 'https://harbor.example.com',
supports_chunked_upload: 'true',
supports_cross_repo_mount: 'true',
supports_manifest_delete: 'true',
supports_blob_delete: 'true',
capability_score: '6/7',
},
},
durationMs: 500,
executedAt: '2026-01-27T10:01:15Z',
},
// TLS certificate warning
{
checkId: 'integration.registry.tls-cert',
pluginId: 'integration.registry',
category: 'integration',
severity: 'warn',
diagnosis: 'TLS certificate expires in 14 days',
evidence: {
description: 'TLS certificate validation results',
data: {
registry_url: 'https://registry.example.com',
expires_at: '2026-02-10T00:00:00Z',
days_remaining: '14',
issuer: "Let's Encrypt",
},
},
likelyCauses: ['Certificate renewal not configured', 'Certbot job failed'],
remediation: {
requiresBackup: false,
steps: [
{
order: 1,
description: 'Renew certificate',
command: 'certbot renew --quiet',
commandType: 'shell',
},
],
},
durationMs: 100,
executedAt: '2026-01-27T10:01:20Z',
},
],
};
const mockPlugins = {
plugins: [
{
pluginId: 'integration.registry',
displayName: 'Registry Integration',
category: 'integration',
version: '1.0.0',
checkCount: 5,
},
],
total: 1,
};
const mockChecks = {
checks: [
{
checkId: 'integration.registry.v2-endpoint',
name: 'V2 Endpoint Check',
description: 'Verify OCI registry V2 API endpoint accessibility',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'fail',
tags: ['registry', 'oci', 'connectivity'],
estimatedDurationMs: 5000,
},
{
checkId: 'integration.registry.auth-config',
name: 'Authentication Config',
description: 'Validate registry authentication configuration',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'fail',
tags: ['registry', 'oci', 'auth'],
estimatedDurationMs: 3000,
},
{
checkId: 'integration.registry.referrers-api',
name: 'Referrers API Support',
description: 'Detect OCI 1.1 Referrers API support',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'warn',
tags: ['registry', 'oci', 'referrers', 'oci-1.1'],
estimatedDurationMs: 5000,
},
{
checkId: 'integration.registry.capabilities',
name: 'Capability Probe',
description: 'Probe registry OCI capabilities',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'info',
tags: ['registry', 'oci', 'capabilities'],
estimatedDurationMs: 10000,
},
{
checkId: 'integration.registry.tls-cert',
name: 'TLS Certificate',
description: 'Validate TLS certificate validity',
pluginId: 'integration.registry',
category: 'integration',
defaultSeverity: 'warn',
tags: ['registry', 'tls', 'security'],
estimatedDurationMs: 2000,
},
],
total: 5,
};
test.describe('REG-UI-01: Doctor Registry Health Card', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
await setupDoctorMocks(page);
});
test('registry health panel displays after doctor run', async ({ page }) => {
await page.goto('/doctor');
// Wait for doctor page to load
await expect(page.getByRole('heading', { name: /doctor/i })).toBeVisible({ timeout: 10000 });
// Registry health section should be visible after results load
const registrySection = page.locator('text=/registry.*health|configured.*registries/i');
if ((await registrySection.count()) > 0) {
await expect(registrySection.first()).toBeVisible({ timeout: 10000 });
}
});
test('registry cards show health indicators', async ({ page }) => {
await page.goto('/doctor');
// Look for health status indicators (healthy/degraded/unhealthy)
const healthIndicators = page.locator(
'text=/healthy|degraded|unhealthy|pass|warn|fail/i'
);
if ((await healthIndicators.count()) > 0) {
await expect(healthIndicators.first()).toBeVisible({ timeout: 10000 });
}
});
test('registry cards display registry names', async ({ page }) => {
await page.goto('/doctor');
// Check for registry names from mock data
const harborRegistry = page.getByText(/harbor.*production|harbor\.example\.com/i);
if ((await harborRegistry.count()) > 0) {
await expect(harborRegistry.first()).toBeVisible({ timeout: 10000 });
}
});
test('clicking registry card shows details', async ({ page }) => {
await page.goto('/doctor');
// Find and click a registry card
const registryCard = page.locator('[class*="registry-card"], [class*="health-card"]').first();
if (await registryCard.isVisible({ timeout: 5000 }).catch(() => false)) {
await registryCard.click();
// Details panel should appear
const detailsPanel = page.locator(
'[class*="details"], [class*="check-details"], [class*="registry-details"]'
);
if ((await detailsPanel.count()) > 0) {
await expect(detailsPanel.first()).toBeVisible({ timeout: 5000 });
}
}
});
});
test.describe('REG-UI-01: Doctor Registry Capability Matrix', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
await setupDoctorMocks(page);
});
test('capability matrix displays after doctor run', async ({ page }) => {
await page.goto('/doctor');
// Look for capability matrix
const capabilityMatrix = page.locator(
'text=/capability.*matrix|oci.*capabilities/i, [class*="capability-matrix"]'
);
if ((await capabilityMatrix.count()) > 0) {
await expect(capabilityMatrix.first()).toBeVisible({ timeout: 10000 });
}
});
test('capability matrix shows OCI features', async ({ page }) => {
await page.goto('/doctor');
// Check for OCI capability names
const ociFeatures = [
/v2.*endpoint|v2.*api/i,
/referrers.*api|referrers/i,
/chunked.*upload/i,
/manifest.*delete/i,
];
for (const feature of ociFeatures) {
const featureElement = page.locator(`text=${feature.source}`);
if ((await featureElement.count()) > 0) {
// At least one OCI feature should be visible
break;
}
}
});
test('capability matrix shows supported/unsupported indicators', async ({ page }) => {
await page.goto('/doctor');
// Look for checkmark/x indicators or supported/unsupported text
const indicators = page.locator(
'text=/supported|unsupported|partial|✓|✗|yes|no/i'
);
if ((await indicators.count()) > 0) {
await expect(indicators.first()).toBeVisible({ timeout: 10000 });
}
});
test('capability rows are expandable', async ({ page }) => {
await page.goto('/doctor');
// Find expandable capability row
const expandableRow = page.locator(
'[class*="capability-row"], [class*="expandable"], tr[class*="capability"]'
).first();
if (await expandableRow.isVisible({ timeout: 5000 }).catch(() => false)) {
await expandableRow.click();
// Description should appear
const description = page.locator('[class*="description"], [class*="expanded"]');
if ((await description.count()) > 0) {
await expect(description.first()).toBeVisible({ timeout: 3000 });
}
}
});
});
test.describe('REG-UI-01: Doctor Registry Check Details', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
await setupDoctorMocks(page);
});
test('check results display for registry checks', async ({ page }) => {
await page.goto('/doctor');
// Look for check results
const checkResults = page.locator(
'[class*="check-result"], [class*="check-item"], text=/integration\.registry/i'
);
if ((await checkResults.count()) > 0) {
await expect(checkResults.first()).toBeVisible({ timeout: 10000 });
}
});
test('check results show severity indicators', async ({ page }) => {
await page.goto('/doctor');
// Look for severity badges/icons
const severityIndicators = page.locator(
'[class*="severity"], [class*="pass"], [class*="warn"], [class*="fail"]'
);
if ((await severityIndicators.count()) > 0) {
await expect(severityIndicators.first()).toBeVisible({ timeout: 10000 });
}
});
test('expanding check shows evidence', async ({ page }) => {
await page.goto('/doctor');
// Find and click a check result
const checkResult = page.locator(
'[class*="check-result"], [class*="check-item"]'
).first();
if (await checkResult.isVisible({ timeout: 5000 }).catch(() => false)) {
await checkResult.click();
// Evidence section should appear
const evidence = page.locator(
'[class*="evidence"], text=/evidence|registry_url|status_code/i'
);
if ((await evidence.count()) > 0) {
await expect(evidence.first()).toBeVisible({ timeout: 5000 });
}
}
});
test('failed checks show remediation steps', async ({ page }) => {
await page.goto('/doctor');
// Look for failed check
const failedCheck = page.locator('[class*="fail"], [class*="severity-fail"]').first();
if (await failedCheck.isVisible({ timeout: 5000 }).catch(() => false)) {
await failedCheck.click();
// Remediation should be visible
const remediation = page.locator(
'text=/remediation|steps|command|verify|check/i'
);
if ((await remediation.count()) > 0) {
await expect(remediation.first()).toBeVisible({ timeout: 5000 });
}
}
});
test('evidence displays key-value pairs', async ({ page }) => {
await page.goto('/doctor');
// Find and expand a check
const checkResult = page.locator('[class*="check-result"], [class*="check-item"]').first();
if (await checkResult.isVisible({ timeout: 5000 }).catch(() => false)) {
await checkResult.click();
// Evidence data should show key-value pairs
const evidenceKeys = ['registry_url', 'status_code', 'response_time'];
for (const key of evidenceKeys) {
const keyElement = page.locator(`text=/${key}/i`);
if ((await keyElement.count()) > 0) {
// At least one evidence key should be present
break;
}
}
}
});
});
test.describe('REG-UI-01: Doctor Registry Integration', () => {
test.beforeEach(async ({ page }) => {
await setupBasicMocks(page);
await setupAuthenticatedSession(page);
await setupDoctorMocks(page);
});
test('running doctor shows registry checks in progress', async ({ page }) => {
// Mock SSE for progress updates
await page.route('**/api/doctor/runs/*/progress*', (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: {"eventType":"check-started","checkId":"integration.registry.v2-endpoint","completed":0,"total":5}\n\n`,
})
);
await page.goto('/doctor');
// Click run button if visible
const runButton = page.getByRole('button', { name: /run|check|quick|normal|full/i });
if (await runButton.first().isVisible({ timeout: 5000 }).catch(() => false)) {
// Don't actually run - just verify button exists
await expect(runButton.first()).toBeEnabled();
}
});
test('registry filter shows only registry checks', async ({ page }) => {
await page.goto('/doctor');
// Look for category filter
const categoryFilter = page.locator(
'select[id*="category"], [class*="filter"] select, [class*="category-filter"]'
);
if (await categoryFilter.isVisible({ timeout: 5000 }).catch(() => false)) {
// Select integration category
await categoryFilter.selectOption({ label: /integration/i });
// Should filter to registry checks
const registryChecks = page.locator('text=/integration\.registry/i');
if ((await registryChecks.count()) > 0) {
await expect(registryChecks.first()).toBeVisible({ timeout: 5000 });
}
}
});
test('severity filter highlights failed registry checks', async ({ page }) => {
await page.goto('/doctor');
// Look for severity filter
const failFilter = page.locator(
'input[type="checkbox"][id*="fail"], label:has-text("fail") input, [class*="severity-fail"] input'
);
if (await failFilter.first().isVisible({ timeout: 5000 }).catch(() => false)) {
await failFilter.first().check();
// Should show only failed checks
const failedChecks = page.locator('[class*="severity-fail"], [class*="fail"]');
if ((await failedChecks.count()) > 0) {
await expect(failedChecks.first()).toBeVisible({ timeout: 5000 });
}
}
});
test('health summary shows correct counts', async ({ page }) => {
await page.goto('/doctor');
// Look for health summary counts
const summarySection = page.locator('[class*="summary"], [class*="health-summary"]');
if (await summarySection.isVisible({ timeout: 5000 }).catch(() => false)) {
// Should show counts from mock data
// 1 healthy (Harbor), 1 degraded (Generic OCI), 1 unhealthy (Broken)
const healthyCount = page.locator('text=/healthy.*[0-9]|[0-9].*healthy/i');
const unhealthyCount = page.locator('text=/unhealthy.*[0-9]|[0-9].*unhealthy/i');
if ((await healthyCount.count()) > 0) {
await expect(healthyCount.first()).toBeVisible();
}
if ((await unhealthyCount.count()) > 0) {
await expect(unhealthyCount.first()).toBeVisible();
}
}
});
});
// Helper functions
async function setupBasicMocks(page: Page) {
page.on('console', (message) => {
if (message.type() === 'error') {
console.log('[browser:error]', message.text());
}
});
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
// Block actual auth requests
await page.route('https://authority.local/**', (route) => {
if (route.request().url().includes('authorize')) {
return route.abort();
}
return route.fulfill({ status: 400, body: 'blocked' });
});
}
async function setupAuthenticatedSession(page: Page) {
const mockToken = {
access_token: 'mock-doctor-access-token',
id_token: 'mock-id-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email doctor:read',
};
await page.addInitScript((tokenData) => {
(window as any).__stellaopsTestSession = {
isAuthenticated: true,
accessToken: tokenData.access_token,
idToken: tokenData.id_token,
expiresAt: Date.now() + tokenData.expires_in * 1000,
};
const originalFetch = window.fetch;
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const headers = new Headers(init?.headers);
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${tokenData.access_token}`);
}
return originalFetch(input, { ...init, headers });
};
}, mockToken);
}
async function setupDoctorMocks(page: Page) {
// Mock Doctor plugins list
await page.route('**/api/doctor/plugins*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockPlugins),
})
);
// Mock Doctor checks list
await page.route('**/api/doctor/checks*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockChecks),
})
);
// Mock start run
await page.route('**/api/doctor/runs', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ runId: mockDoctorReport.runId }),
});
}
return route.continue();
});
// Mock get run result
await page.route('**/api/doctor/runs/*', (route) => {
if (route.request().method() === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockDoctorReport),
});
}
return route.continue();
});
// Mock latest report endpoint
await page.route('**/api/doctor/reports/latest*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockDoctorReport),
})
);
// Mock dashboard data if Doctor is on dashboard
await page.route('**/api/dashboard*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
doctor: {
lastRun: mockDoctorReport.completedAt,
summary: mockDoctorReport.summary,
},
}),
})
);
}