test fixes and new product advisories work
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">🔍</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) ? '▼' : '▶' }}</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">✔</span>
|
||||
<span>Supported</span>
|
||||
</div>
|
||||
<div class="legend-item status-partial">
|
||||
<span class="legend-icon">⚪</span>
|
||||
<span>Partial</span>
|
||||
</div>
|
||||
<div class="legend-item status-unsupported">
|
||||
<span class="legend-icon">✘</span>
|
||||
<span>Not Supported</span>
|
||||
</div>
|
||||
<div class="legend-item status-unknown">
|
||||
<span class="legend-icon">?</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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
✕
|
||||
</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) ? '▲' : '▼' }}</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 '✔';
|
||||
case 'info':
|
||||
return 'ℹ';
|
||||
case 'warn':
|
||||
return '⚠';
|
||||
case 'fail':
|
||||
return '✘';
|
||||
case 'skip':
|
||||
return '→';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilityIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'supported':
|
||||
return '✔';
|
||||
case 'partial':
|
||||
return '⚪';
|
||||
case 'unsupported':
|
||||
return '✘';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">🔍</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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">✔</span>
|
||||
{{ supportedCount }}
|
||||
</span>
|
||||
<span class="capability-count partial">
|
||||
<span class="count-icon">⚪</span>
|
||||
{{ partialCount }}
|
||||
</span>
|
||||
<span class="capability-count unsupported">
|
||||
<span class="count-icon">✘</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: '✔', label: 'Supported', cssClass: 'status-supported' };
|
||||
case 'unsupported':
|
||||
return { icon: '✘', label: 'Not Supported', cssClass: 'status-unsupported' };
|
||||
case 'partial':
|
||||
return { icon: '⚪', label: 'Partial', cssClass: 'status-partial' };
|
||||
default:
|
||||
return { icon: '?', 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: '✔', label: 'Healthy', cssClass: 'health-healthy' };
|
||||
case 'degraded':
|
||||
return { icon: '⚠', label: 'Degraded', cssClass: 'health-degraded' };
|
||||
case 'unhealthy':
|
||||
return { icon: '✘', label: 'Unhealthy', cssClass: 'health-unhealthy' };
|
||||
default:
|
||||
return { icon: '?', 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;
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
20
src/Web/StellaOps.Web/src/app/features/sbom-diff/index.ts
Normal file
20
src/Web/StellaOps.Web/src/app/features/sbom-diff/index.ts
Normal 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';
|
||||
@@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
20
src/Web/StellaOps.Web/src/app/features/vex-timeline/index.ts
Normal file
20
src/Web/StellaOps.Web/src/app/features/vex-timeline/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/console/profile",
|
||||
"violations": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/graph",
|
||||
"violations": []
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:4400/triage/artifacts/asset-web-prod",
|
||||
"violations": []
|
||||
}
|
||||
762
src/Web/StellaOps.Web/tests/e2e/doctor-registry.spec.ts
Normal file
762
src/Web/StellaOps.Web/tests/e2e/doctor-registry.spec.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user