Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

@@ -52,3 +52,4 @@
| UI-TTFS-0340-001 | DONE (2025-12-18) | FirstSignalCard UI component + client/store/tests + TTFS telemetry client/sampling + i18n micro-copy (SPRINT_0340_0001_0001_first_signal_card_ui.md). |
| WEB-TTFS-0341-001 | DONE (2025-12-18) | Extend FirstSignal client models with `lastKnownOutcome` (SPRINT_0341_0001_0001_ttfs_enhancements.md). |
| TRI-MASTER-0009 | DONE (2025-12-17) | Added Playwright E2E coverage for triage workflow (tabs, VEX modal, decision drawer, evidence pills). |
| UI-EXC-3900-0003-0002-T7 | DONE (2025-12-22) | Exception wizard updated with recheck policy and evidence requirement steps plus unit coverage. |

View File

@@ -2,7 +2,13 @@
* Exception management models for the Exception Center.
*/
export type ExceptionStatus = 'draft' | 'pending' | 'approved' | 'active' | 'expired' | 'revoked';
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
@@ -186,20 +192,19 @@ export interface ExceptionTransition {
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'active', action: 'Activate', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'active', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'pending', to: 'revoked', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending', label: 'Pending Approval', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'active', label: 'Active', color: '#10b981' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending_review', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending_review', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'pending_review', to: 'rejected', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending_review', label: 'Pending Review', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'rejected', label: 'Rejected', color: '#f472b6' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];

View File

@@ -0,0 +1,45 @@
/**
* API client for Triage Inbox endpoints.
* Sprint: SPRINT_3900_0003_0001_exploit_path_inbox_proof_bundles
*/
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
ExploitPath,
InboxFilter,
TriageInboxResponse,
} from './triage-inbox.models';
export const TRIAGE_INBOX_API = 'TRIAGE_INBOX_API';
export interface TriageInboxApi {
getInbox(artifactDigest: string, filter?: InboxFilter): Observable<TriageInboxResponse>;
getPath(pathId: string): Observable<ExploitPath>;
}
@Injectable({ providedIn: 'root' })
export class TriageInboxClient implements TriageInboxApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.scannerApiUrl}/v1/triage`;
/**
* Retrieves triage inbox with grouped exploit paths.
*/
getInbox(artifactDigest: string, filter?: InboxFilter): Observable<TriageInboxResponse> {
let params = new HttpParams().set('artifactDigest', artifactDigest);
if (filter) {
params = params.set('filter', filter);
}
return this.http.get<TriageInboxResponse>(`${this.baseUrl}/inbox`, { params });
}
/**
* Retrieves a single exploit path by ID.
*/
getPath(pathId: string): Observable<ExploitPath> {
return this.http.get<ExploitPath>(`${this.baseUrl}/paths/${pathId}`);
}
}

View File

@@ -0,0 +1,108 @@
/**
* Models for Triage Inbox API (exploit path grouping).
* Sprint: SPRINT_3900_0003_0001_exploit_path_inbox_proof_bundles
*/
export interface ExploitPath {
pathId: string;
artifactDigest: string;
package: PackageRef;
symbol: VulnerableSymbol;
entryPoint: EntryPoint;
cveIds: string[];
reachability: ReachabilityStatus;
riskScore: PathRiskScore;
evidence: PathEvidence;
activeExceptions: ExceptionRef[];
isQuiet: boolean;
firstSeenAt: string;
lastUpdatedAt: string;
}
export interface PackageRef {
purl: string;
name: string;
version: string;
ecosystem?: string;
}
export interface VulnerableSymbol {
fullyQualifiedName: string;
sourceFile?: string;
lineNumber?: number;
language?: string;
}
export interface EntryPoint {
name: string;
type: string;
path?: string;
}
export interface PathRiskScore {
aggregatedCvss: number;
maxEpss: number;
criticalCount: number;
highCount: number;
mediumCount: number;
lowCount: number;
}
export interface PathEvidence {
latticeState: ReachabilityLatticeState;
vexStatus: VexStatus;
confidence: number;
items: EvidenceItem[];
}
export interface EvidenceItem {
type: string;
source: string;
description: string;
weight: number;
}
export interface ExceptionRef {
exceptionId: string;
reason: string;
expiresAt: string;
}
export type ReachabilityStatus =
| 'Unknown'
| 'StaticallyReachable'
| 'RuntimeConfirmed'
| 'Unreachable'
| 'Contested';
export type ReachabilityLatticeState =
| 'Unknown'
| 'StaticallyReachable'
| 'RuntimeObserved'
| 'Unreachable'
| 'Contested';
export type VexStatus =
| 'Unknown'
| 'NotAffected'
| 'Affected'
| 'Fixed'
| 'UnderInvestigation';
export interface TriageInboxResponse {
artifactDigest: string;
totalPaths: number;
filteredPaths: number;
filter?: string;
paths: ExploitPath[];
generatedAt: string;
}
export type InboxFilter =
| 'actionable'
| 'noisy'
| 'reachable'
| 'runtime'
| 'critical'
| 'high'
| null;

View File

@@ -214,3 +214,19 @@ export const requirePolicyAuditGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.POLICY_READ, StellaOpsScopes.POLICY_AUDIT],
'/console/profile'
);
/**
* Guard requiring exception:read scope for Exception Center access.
*/
export const requireExceptionViewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.EXCEPTION_READ],
'/console/profile'
);
/**
* Guard requiring exception:read + exception:approve scopes for approvals.
*/
export const requireExceptionApproverGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.EXCEPTION_READ, StellaOpsScopes.EXCEPTION_APPROVE],
'/console/profile'
);

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { VexConflict } from '../../features/vex-studio/vex-conflict-studio.component';
import { OverrideRequest } from '../../features/vex-studio/override-dialog/override-dialog.component';
export interface ConflictQuery {
productId?: string;
vulnId?: string;
}
@Injectable({
providedIn: 'root'
})
export class VexConflictService {
private readonly apiBaseUrl = '/api/v1/vex';
async getConflicts(query: ConflictQuery): Promise<VexConflict[]> {
const params = new URLSearchParams();
if (query.productId) params.append('productId', query.productId);
if (query.vulnId) params.append('vulnId', query.vulnId);
const url = `${this.apiBaseUrl}/conflicts?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch VEX conflicts: ${response.statusText}`);
}
return response.json();
}
async applyOverride(conflictId: string, override: OverrideRequest): Promise<void> {
const url = `${this.apiBaseUrl}/conflicts/${conflictId}/override`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(override)
});
if (!response.ok) {
throw new Error(`Failed to apply override: ${response.statusText}`);
}
}
async removeOverride(conflictId: string): Promise<void> {
const url = `${this.apiBaseUrl}/conflicts/${conflictId}/override`;
const response = await fetch(url, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to remove override: ${response.statusText}`);
}
}
}

View File

@@ -0,0 +1,108 @@
import { TestBed } from '@angular/core/testing';
import { ViewModeService } from './view-mode.service';
describe('ViewModeService', () => {
let service: ViewModeService;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({});
service = TestBed.inject(ViewModeService);
});
afterEach(() => {
localStorage.clear();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should default to operator mode', () => {
expect(service.mode()).toBe('operator');
});
it('should toggle between modes', () => {
expect(service.mode()).toBe('operator');
service.toggle();
expect(service.mode()).toBe('auditor');
service.toggle();
expect(service.mode()).toBe('operator');
});
it('should set mode directly', () => {
service.setMode('auditor');
expect(service.mode()).toBe('auditor');
service.setMode('operator');
expect(service.mode()).toBe('operator');
});
it('should persist to localStorage', () => {
service.setMode('auditor');
TestBed.flushEffects();
expect(localStorage.getItem('stella-view-mode')).toBe('auditor');
});
it('should load from localStorage on init', () => {
localStorage.setItem('stella-view-mode', 'auditor');
const newService = TestBed.inject(ViewModeService);
expect(newService.mode()).toBe('auditor');
});
it('should return operator config when in operator mode', () => {
service.setMode('operator');
expect(service.config().showSignatures).toBe(false);
expect(service.config().compactFindings).toBe(true);
expect(service.config().showProvenance).toBe(false);
expect(service.config().autoExpandEvidence).toBe(false);
});
it('should return auditor config when in auditor mode', () => {
service.setMode('auditor');
expect(service.config().showSignatures).toBe(true);
expect(service.config().compactFindings).toBe(false);
expect(service.config().showProvenance).toBe(true);
expect(service.config().autoExpandEvidence).toBe(true);
});
it('should have correct isOperator computed signal', () => {
service.setMode('operator');
expect(service.isOperator()).toBe(true);
expect(service.isAuditor()).toBe(false);
});
it('should have correct isAuditor computed signal', () => {
service.setMode('auditor');
expect(service.isAuditor()).toBe(true);
expect(service.isOperator()).toBe(false);
});
it('should have convenience computed properties', () => {
service.setMode('auditor');
expect(service.showSignatures()).toBe(true);
expect(service.showProvenance()).toBe(true);
expect(service.showEvidenceDetails()).toBe(true);
expect(service.showSnapshots()).toBe(true);
expect(service.compactFindings()).toBe(false);
});
it('should check if feature should be shown', () => {
service.setMode('auditor');
expect(service.shouldShow('showSignatures')).toBe(true);
expect(service.shouldShow('compactFindings')).toBe(false);
service.setMode('operator');
expect(service.shouldShow('showSignatures')).toBe(false);
expect(service.shouldShow('compactFindings')).toBe(true);
});
});

View File

@@ -0,0 +1,84 @@
import { Injectable, signal, computed, effect } from '@angular/core';
export type ViewMode = 'operator' | 'auditor';
export interface ViewModeConfig {
showSignatures: boolean;
showProvenance: boolean;
showEvidenceDetails: boolean;
showSnapshots: boolean;
showMergeTraces: boolean;
showPolicyDetails: boolean;
compactFindings: boolean;
autoExpandEvidence: boolean;
}
const OPERATOR_CONFIG: ViewModeConfig = {
showSignatures: false,
showProvenance: false,
showEvidenceDetails: false,
showSnapshots: false,
showMergeTraces: false,
showPolicyDetails: false,
compactFindings: true,
autoExpandEvidence: false
};
const AUDITOR_CONFIG: ViewModeConfig = {
showSignatures: true,
showProvenance: true,
showEvidenceDetails: true,
showSnapshots: true,
showMergeTraces: true,
showPolicyDetails: true,
compactFindings: false,
autoExpandEvidence: true
};
const STORAGE_KEY = 'stella-view-mode';
@Injectable({ providedIn: 'root' })
export class ViewModeService {
private readonly _mode = signal<ViewMode>(this.loadFromStorage());
readonly mode = this._mode.asReadonly();
readonly config = computed<ViewModeConfig>(() => {
return this._mode() === 'operator' ? OPERATOR_CONFIG : AUDITOR_CONFIG;
});
readonly isOperator = computed(() => this._mode() === 'operator');
readonly isAuditor = computed(() => this._mode() === 'auditor');
readonly showSignatures = computed(() => this.config().showSignatures);
readonly showProvenance = computed(() => this.config().showProvenance);
readonly showEvidenceDetails = computed(() => this.config().showEvidenceDetails);
readonly showSnapshots = computed(() => this.config().showSnapshots);
readonly compactFindings = computed(() => this.config().compactFindings);
constructor() {
effect(() => {
const mode = this._mode();
localStorage.setItem(STORAGE_KEY, mode);
});
}
toggle(): void {
this._mode.set(this._mode() === 'operator' ? 'auditor' : 'operator');
}
setMode(mode: ViewMode): void {
this._mode.set(mode);
}
shouldShow(feature: keyof ViewModeConfig): boolean {
return this.config()[feature] as boolean;
}
private loadFromStorage(): ViewMode {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'operator' || stored === 'auditor') {
return stored;
}
return 'operator';
}
}

View File

@@ -0,0 +1,73 @@
<div class="approval-queue">
<header class="queue-header">
<div>
<h2 class="queue-title">Exception Approval Queue</h2>
<p class="queue-subtitle">Review pending exception requests.</p>
</div>
<div class="queue-actions">
<a class="btn-secondary" routerLink="/exceptions">Back to Exception Center</a>
<button class="btn-secondary" (click)="loadQueue()">Refresh</button>
</div>
</header>
@if (error()) {
<div class="alert">{{ error() }}</div>
}
@if (loading()) {
<div class="state-panel">Loading pending exceptions...</div>
} @else if (exceptions().length === 0) {
<div class="state-panel">No pending exceptions awaiting approval.</div>
}
@if (exceptions().length > 0) {
<div class="queue-controls">
<div class="control-group">
<label class="field-label">Rejection comment (required)</label>
<input
class="field-input"
[value]="rejectionComment()"
(input)="rejectionComment.set($any($event.target).value)"
placeholder="Provide rejection reason"
/>
</div>
<div class="control-actions">
<button class="btn-primary" (click)="approveSelected()" [disabled]="selectedExceptions().length === 0">
Approve selected
</button>
<button class="btn-danger" (click)="rejectSelected()" [disabled]="selectedExceptions().length === 0">
Reject selected
</button>
</div>
</div>
<div class="queue-table">
<div class="queue-row queue-header-row">
<span>Select</span>
<span>Exception</span>
<span>Requester</span>
<span>Scope</span>
<span>Rationale</span>
<span>Requested</span>
</div>
@for (exception of exceptions(); track exception.exceptionId) {
<div class="queue-row">
<input
type="checkbox"
[checked]="selectedIds().has(exception.exceptionId)"
(change)="toggleSelection(exception.exceptionId)"
/>
<div>
<div class="exception-name">{{ exception.displayName ?? exception.name }}</div>
<div class="exception-meta">{{ exception.exceptionId }}</div>
</div>
<div class="exception-meta">{{ exception.createdBy }}</div>
<div class="exception-meta">{{ summarizeScope(exception) }}</div>
<div class="exception-meta">{{ summarizeJustification(exception) }}</div>
<div class="exception-meta">{{ formatRelativeTime(exception.createdAt) }}</div>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,155 @@
.approval-queue {
display: flex;
flex-direction: column;
gap: 1rem;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.75rem;
}
.queue-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.queue-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.queue-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
padding: 1rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-bg-card, white);
}
.control-group {
flex: 1 1 280px;
}
.control-actions {
display: flex;
gap: 0.5rem;
}
.queue-table {
display: grid;
gap: 0.5rem;
}
.queue-row {
display: grid;
grid-template-columns: 80px 2fr 1fr 1.4fr 1.6fr 1fr;
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-bg-card, white);
font-size: 0.8125rem;
&.queue-header-row {
background: var(--color-bg-subtle, #f9fafb);
font-weight: 600;
color: var(--color-text-muted, #6b7280);
}
}
.exception-name {
font-weight: 600;
color: var(--color-text, #1f2937);
}
.exception-meta {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
background: #fee2e2;
color: #b91c1c;
font-size: 0.875rem;
}
.state-panel {
padding: 1.5rem;
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: 6px;
text-align: center;
color: var(--color-text-muted, #6b7280);
}
.field-label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
margin-bottom: 0.25rem;
}
.field-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
}
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 0.5rem 1rem;
border-radius: 4px;
border: none;
font-size: 0.8125rem;
cursor: pointer;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
}
.btn-danger {
background: #dc2626;
color: white;
}
@media (max-width: 1100px) {
.queue-row {
grid-template-columns: 60px 2fr 1fr;
grid-auto-rows: minmax(24px, auto);
}
.queue-header-row {
display: none;
}
}

View File

@@ -0,0 +1,190 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { ExceptionApprovalQueueComponent } from './exception-approval-queue.component';
import { EXCEPTION_API, ExceptionApi } from '../../core/api/exception.client';
import { Exception } from '../../core/api/exception.contract.models';
describe('ExceptionApprovalQueueComponent', () => {
let fixture: ComponentFixture<ExceptionApprovalQueueComponent>;
let component: ExceptionApprovalQueueComponent;
let mockExceptionApi: jasmine.SpyObj<ExceptionApi>;
const mockPendingException: Exception = {
exceptionId: 'exc-pending-001',
name: 'pending-exception',
displayName: 'Pending Exception',
description: 'Needs approval',
type: 'vulnerability',
severity: 'high',
status: 'pending_review',
scope: {
type: 'global',
vulnIds: ['CVE-2024-1234'],
componentPurls: ['pkg:npm/lodash@4.17.21'],
},
justification: {
text: 'This vulnerability is mitigated by network controls.',
},
timebox: {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
labels: {},
createdBy: 'user@test.com',
createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), // 3 hours ago
};
beforeEach(async () => {
mockExceptionApi = jasmine.createSpyObj('ExceptionApi', [
'listExceptions',
'transitionStatus',
]);
mockExceptionApi.listExceptions.and.returnValue(
of({ items: [mockPendingException], total: 1 })
);
mockExceptionApi.transitionStatus.and.returnValue(of(mockPendingException));
await TestBed.configureTestingModule({
imports: [ExceptionApprovalQueueComponent],
providers: [{ provide: EXCEPTION_API, useValue: mockExceptionApi }],
}).compileComponents();
fixture = TestBed.createComponent(ExceptionApprovalQueueComponent);
component = fixture.componentInstance;
});
it('filters to proposed status by default', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(mockExceptionApi.listExceptions).toHaveBeenCalledWith({
status: 'pending_review',
limit: 200,
});
expect(component.exceptions().length).toBe(1);
});
it('handles error during load', async () => {
mockExceptionApi.listExceptions.and.returnValue(
throwError(() => new Error('API Error'))
);
fixture.detectChanges();
await fixture.whenStable();
expect(component.error()).toBe('API Error');
expect(component.exceptions().length).toBe(0);
});
it('toggles exception selection', () => {
component.exceptions.set([mockPendingException]);
expect(component.selectedIds().has('exc-pending-001')).toBeFalse();
component.toggleSelection('exc-pending-001');
expect(component.selectedIds().has('exc-pending-001')).toBeTrue();
component.toggleSelection('exc-pending-001');
expect(component.selectedIds().has('exc-pending-001')).toBeFalse();
});
it('approves selected exceptions', async () => {
component.exceptions.set([mockPendingException]);
component.toggleSelection('exc-pending-001');
await component.approveSelected();
expect(mockExceptionApi.transitionStatus).toHaveBeenCalledWith({
exceptionId: 'exc-pending-001',
newStatus: 'approved',
});
expect(mockExceptionApi.listExceptions).toHaveBeenCalledTimes(2); // init + refresh
});
it('rejects selected exceptions with comment', async () => {
component.exceptions.set([mockPendingException]);
component.toggleSelection('exc-pending-001');
component.rejectionComment.set('Does not meet security policy');
await component.rejectSelected();
expect(mockExceptionApi.transitionStatus).toHaveBeenCalledWith({
exceptionId: 'exc-pending-001',
newStatus: 'rejected',
comment: 'Does not meet security policy',
});
expect(component.rejectionComment()).toBe(''); // cleared after rejection
});
it('requires comment for rejection', async () => {
component.exceptions.set([mockPendingException]);
component.toggleSelection('exc-pending-001');
component.rejectionComment.set('');
await component.rejectSelected();
expect(mockExceptionApi.transitionStatus).not.toHaveBeenCalled();
expect(component.error()).toBe('Rejection requires a comment.');
});
it('does nothing when no exceptions selected', async () => {
await component.approveSelected();
expect(mockExceptionApi.transitionStatus).not.toHaveBeenCalled();
});
it('formats relative time correctly', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
expect(component.formatRelativeTime(threeHoursAgo)).toContain('h ago');
expect(component.formatRelativeTime(twoDaysAgo)).toContain('d ago');
});
it('summarizes scope correctly', () => {
const summary = component.summarizeScope(mockPendingException);
expect(summary).toContain('1 CVE(s)');
expect(summary).toContain('1 component(s)');
});
it('summarizes justification with truncation', () => {
const shortText = 'Short justification';
const longText = 'A'.repeat(100);
const shortException = { ...mockPendingException, justification: { text: shortText } };
const longException = { ...mockPendingException, justification: { text: longText } };
expect(component.summarizeJustification(shortException)).toBe(shortText);
expect(component.summarizeJustification(longException)).toContain('...');
expect(component.summarizeJustification(longException).length).toBeLessThan(longText.length);
});
it('computes selected exceptions from selected IDs', () => {
const exception2: Exception = {
...mockPendingException,
exceptionId: 'exc-pending-002',
};
component.exceptions.set([mockPendingException, exception2]);
component.toggleSelection('exc-pending-001');
component.toggleSelection('exc-pending-002');
const selected = component.selectedExceptions();
expect(selected.length).toBe(2);
expect(selected[0].exceptionId).toBe('exc-pending-001');
expect(selected[1].exceptionId).toBe('exc-pending-002');
});
it('clears selection after reload', async () => {
component.exceptions.set([mockPendingException]);
component.toggleSelection('exc-pending-001');
expect(component.selectedIds().size).toBe(1);
await component.loadQueue();
expect(component.selectedIds().size).toBe(0);
});
});

View File

@@ -0,0 +1,166 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
} from '../../core/api/exception.client';
import { Exception } from '../../core/api/exception.contract.models';
@Component({
selector: 'app-exception-approval-queue',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './exception-approval-queue.component.html',
styleUrls: ['./exception-approval-queue.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionApprovalQueueComponent implements OnInit {
private readonly exceptionApi = inject<ExceptionApi>(EXCEPTION_API);
readonly exceptions = signal<Exception[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rejectionComment = signal('');
readonly selectedIds = signal<Set<string>>(new Set());
readonly selectedExceptions = computed(() => {
const ids = this.selectedIds();
return this.exceptions().filter((exc) => ids.has(exc.exceptionId));
});
ngOnInit(): void {
this.loadQueue();
}
async loadQueue(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const response = await firstValueFrom(
this.exceptionApi.listExceptions({ status: 'pending_review', limit: 200 })
);
this.exceptions.set([...response.items]);
this.selectedIds.set(new Set());
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
toggleSelection(exceptionId: string): void {
this.selectedIds.update((current) => {
const next = new Set(current);
if (next.has(exceptionId)) {
next.delete(exceptionId);
} else {
next.add(exceptionId);
}
return next;
});
}
async approveSelected(): Promise<void> {
const selected = this.selectedExceptions();
if (selected.length === 0) return;
this.loading.set(true);
this.error.set(null);
try {
await Promise.all(
selected.map((exc) =>
firstValueFrom(
this.exceptionApi.transitionStatus({
exceptionId: exc.exceptionId,
newStatus: 'approved',
})
)
)
);
await this.loadQueue();
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
async rejectSelected(): Promise<void> {
const selected = this.selectedExceptions();
if (selected.length === 0) return;
const comment = this.rejectionComment().trim();
if (!comment) {
this.error.set('Rejection requires a comment.');
return;
}
this.loading.set(true);
this.error.set(null);
try {
await Promise.all(
selected.map((exc) =>
firstValueFrom(
this.exceptionApi.transitionStatus({
exceptionId: exc.exceptionId,
newStatus: 'rejected',
comment,
})
)
)
);
this.rejectionComment.set('');
await this.loadQueue();
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const hours = Math.floor(diffMs / (1000 * 60 * 60));
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
summarizeScope(exception: Exception): string {
const scope = exception.scope;
const parts: string[] = [];
if (scope.vulnIds?.length) parts.push(`${scope.vulnIds.length} CVE(s)`);
if (scope.componentPurls?.length) parts.push(`${scope.componentPurls.length} component(s)`);
if (scope.assetIds?.length) parts.push(`${scope.assetIds.length} asset(s)`);
if (scope.tenantId) parts.push(`Tenant: ${scope.tenantId}`);
return parts.length > 0 ? parts.join(' · ') : 'Global';
}
summarizeJustification(exception: Exception): string {
const text = exception.justification.text ?? '';
if (text.length <= 80) return text;
return `${text.slice(0, 80)}...`;
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -193,22 +193,22 @@ export class ExceptionCenterComponent {
);
}
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending':
return '[?]';
case 'approved':
return '[+]';
case 'active':
return '[*]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending_review':
return '[?]';
case 'approved':
return '[+]';
case 'rejected':
return '[~]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
}
}

View File

@@ -0,0 +1,71 @@
<div class="exception-dashboard">
<header class="dashboard-header">
<div>
<h2 class="dashboard-title">Exception Center</h2>
<p class="dashboard-subtitle">Manage policy exceptions with auditable workflows.</p>
</div>
<div class="dashboard-actions">
<a class="btn-secondary" routerLink="/exceptions/approvals">Approval Queue</a>
<button class="btn-secondary" (click)="refresh()">Refresh</button>
<button class="btn-primary" (click)="openWizard()">+ New Exception</button>
</div>
</header>
@if (error()) {
<div class="alert alert-error">{{ error() }}</div>
}
@if (eventsError()) {
<div class="alert alert-warning">{{ eventsError() }}</div>
}
@if (loading()) {
<div class="state-panel">Loading exceptions...</div>
} @else if (viewExceptions().length === 0) {
<div class="state-panel">
<p>No exceptions found yet.</p>
<button class="btn-primary" (click)="openWizard()">Create the first exception</button>
</div>
}
<div class="dashboard-body" [class.has-detail]="!!selectedException()">
<section class="center-pane">
<app-exception-center
[exceptions]="viewExceptions()"
[userRole]="userRole()"
(create)="openWizard()"
(select)="selectException($event)"
(transition)="handleTransition($event)"
(viewAudit)="selectException($event)"
></app-exception-center>
</section>
@if (selectedException()) {
<aside class="detail-pane">
<app-exception-detail
[exception]="selectedException()"
[userRole]="userRole()"
(close)="closeDetail()"
(update)="handleDetailUpdate($event)"
(transition)="handleDetailTransition($event)"
></app-exception-detail>
</aside>
} @else {
<aside class="detail-pane empty">
<div class="state-panel">
<p>Select an exception to view details.</p>
</div>
</aside>
}
</div>
@if (showWizard()) {
<div class="wizard-overlay">
<div class="wizard-panel">
<app-exception-wizard
(cancel)="closeWizard()"
(create)="onWizardCreate($event)"
></app-exception-wizard>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,146 @@
.exception-dashboard {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.dashboard-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.dashboard-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
}
.dashboard-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
border: none;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
&.alert-error {
background: #fee2e2;
color: #b91c1c;
}
&.alert-warning {
background: #fef3c7;
color: #92400e;
}
}
.state-panel {
padding: 1.5rem;
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: 6px;
text-align: center;
color: var(--color-text-muted, #6b7280);
}
.dashboard-body {
display: grid;
grid-template-columns: 2.2fr 1fr;
gap: 1rem;
align-items: start;
&.has-detail {
grid-template-columns: 2fr 1.2fr;
}
}
.center-pane {
min-width: 0;
}
.detail-pane {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1rem;
&.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
}
.wizard-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.wizard-panel {
width: min(900px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--color-bg-card, white);
border-radius: 10px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25);
}
@media (max-width: 1024px) {
.dashboard-body {
grid-template-columns: 1fr;
}
.detail-pane {
order: 2;
}
}

View File

@@ -0,0 +1,215 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of, throwError, Subject } from 'rxjs';
import { ExceptionDashboardComponent } from './exception-dashboard.component';
import { EXCEPTION_API, ExceptionApi } from '../../core/api/exception.client';
import {
EXCEPTION_EVENTS_API,
ExceptionEventsApi,
} from '../../core/api/exception-events.client';
import { Exception } from '../../core/api/exception.contract.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { StellaOpsScopes } from '../../core/auth/scopes';
describe('ExceptionDashboardComponent', () => {
let fixture: ComponentFixture<ExceptionDashboardComponent>;
let component: ExceptionDashboardComponent;
let mockExceptionApi: jasmine.SpyObj<ExceptionApi>;
let mockEventsApi: jasmine.SpyObj<ExceptionEventsApi>;
let mockAuthStore: jasmine.SpyObj<AuthSessionStore>;
let mockRouter: jasmine.SpyObj<Router>;
let eventsSubject: Subject<void>;
const mockException: Exception = {
exceptionId: 'exc-001',
name: 'test-exception',
displayName: 'Test Exception',
description: 'Test description',
type: 'vulnerability',
severity: 'high',
status: 'active',
scope: {
type: 'global',
vulnIds: ['CVE-2024-1234'],
},
justification: {
text: 'Test justification',
},
timebox: {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
labels: {},
createdBy: 'user@test.com',
createdAt: new Date().toISOString(),
};
beforeEach(async () => {
eventsSubject = new Subject<void>();
mockExceptionApi = jasmine.createSpyObj('ExceptionApi', [
'listExceptions',
'createException',
'updateException',
'transitionStatus',
]);
mockEventsApi = jasmine.createSpyObj('ExceptionEventsApi', ['streamEvents']);
mockAuthStore = jasmine.createSpyObj('AuthSessionStore', [], {
session: jasmine.createSpy().and.returnValue({
scopes: [StellaOpsScopes.EXCEPTION_MANAGE],
}),
});
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
mockExceptionApi.listExceptions.and.returnValue(
of({ items: [mockException], total: 1 })
);
mockEventsApi.streamEvents.and.returnValue(eventsSubject.asObservable());
await TestBed.configureTestingModule({
imports: [ExceptionDashboardComponent],
providers: [
{ provide: EXCEPTION_API, useValue: mockExceptionApi },
{ provide: EXCEPTION_EVENTS_API, useValue: mockEventsApi },
{ provide: AuthSessionStore, useValue: mockAuthStore },
{ provide: Router, useValue: mockRouter },
],
}).compileComponents();
fixture = TestBed.createComponent(ExceptionDashboardComponent);
component = fixture.componentInstance;
});
it('loads exceptions on init', async () => {
expect(component.loading()).toBeFalse();
expect(component.exceptions().length).toBe(0);
fixture.detectChanges();
await fixture.whenStable();
expect(mockExceptionApi.listExceptions).toHaveBeenCalledWith({ limit: 200 });
expect(component.exceptions().length).toBe(1);
expect(component.exceptions()[0]).toEqual(mockException);
expect(component.loading()).toBeFalse();
});
it('handles error states', async () => {
mockExceptionApi.listExceptions.and.returnValue(
throwError(() => new Error('API Error'))
);
fixture.detectChanges();
await fixture.whenStable();
expect(component.error()).toBe('API Error');
expect(component.exceptions().length).toBe(0);
expect(component.loading()).toBeFalse();
});
it('creates exception via wizard', async () => {
const draft = {
title: 'New Exception',
justification: 'Test reason',
type: 'vulnerability' as const,
severity: 'high' as const,
expiresInDays: 30,
scope: {
cves: ['CVE-2024-5678'],
},
tags: ['security'],
};
mockExceptionApi.createException.and.returnValue(of(mockException));
component.showWizard.set(true);
await component.onWizardCreate(draft);
expect(mockExceptionApi.createException).toHaveBeenCalled();
expect(component.showWizard()).toBeFalse();
expect(mockExceptionApi.listExceptions).toHaveBeenCalledTimes(2); // init + refresh
});
it('subscribes to events on init', () => {
fixture.detectChanges();
expect(mockEventsApi.streamEvents).toHaveBeenCalled();
});
it('refreshes on event notification', async () => {
fixture.detectChanges();
await fixture.whenStable();
mockExceptionApi.listExceptions.calls.reset();
eventsSubject.next();
await fixture.whenStable();
expect(mockExceptionApi.listExceptions).toHaveBeenCalledWith({ limit: 200 });
});
it('determines user role from session scopes', () => {
fixture.detectChanges();
expect(component.userRole()).toBe('user');
// Admin role
(mockAuthStore.session as jasmine.Spy).and.returnValue({
scopes: [StellaOpsScopes.ADMIN],
});
fixture.detectChanges();
expect(component.userRole()).toBe('admin');
// Approver role
(mockAuthStore.session as jasmine.Spy).and.returnValue({
scopes: [StellaOpsScopes.EXCEPTION_APPROVE],
});
fixture.detectChanges();
expect(component.userRole()).toBe('approver');
});
it('applies status transition', async () => {
mockExceptionApi.transitionStatus.and.returnValue(of(mockException));
component.exceptions.set([mockException]);
await component.handleTransition({
exception: component.viewExceptions()[0],
to: 'approved',
});
expect(mockExceptionApi.transitionStatus).toHaveBeenCalledWith({
exceptionId: 'exc-001',
newStatus: 'approved',
comment: undefined,
});
});
it('opens and closes wizard', () => {
expect(component.showWizard()).toBeFalse();
component.openWizard();
expect(component.showWizard()).toBeTrue();
component.closeWizard();
expect(component.showWizard()).toBeFalse();
});
it('selects exception and navigates', () => {
component.exceptions.set([mockException]);
const viewException = component.viewExceptions()[0];
component.selectException(viewException);
expect(component.selectedExceptionId()).toBe('exc-001');
expect(mockRouter.navigate).toHaveBeenCalledWith(['/exceptions', 'exc-001']);
});
it('cleans up subscriptions on destroy', () => {
fixture.detectChanges();
const unsubscribeSpy = spyOn(component['eventsSubscription']!, 'unsubscribe');
component.ngOnDestroy();
expect(unsubscribeSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,357 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { Subscription, firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
} from '../../core/api/exception.client';
import {
EXCEPTION_EVENTS_API,
ExceptionEventsApi,
} from '../../core/api/exception-events.client';
import {
Exception as ContractException,
ExceptionApproval,
ExceptionAuditEntry as ContractAuditEntry,
ExceptionStatusTransition,
} from '../../core/api/exception.contract.models';
import {
Exception,
ExceptionApproval as ViewApproval,
ExceptionAuditEntry,
ExceptionScope,
ExceptionStatus,
} from '../../core/api/exception.models';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { StellaOpsScopes } from '../../core/auth/scopes';
import { ExceptionCenterComponent } from './exception-center.component';
import { ExceptionDetailComponent } from './exception-detail.component';
import { ExceptionDraft, ExceptionWizardComponent } from './exception-wizard.component';
type UserRole = 'user' | 'approver' | 'admin';
@Component({
selector: 'app-exception-dashboard',
standalone: true,
imports: [
CommonModule,
RouterLink,
ExceptionCenterComponent,
ExceptionDetailComponent,
ExceptionWizardComponent,
],
templateUrl: './exception-dashboard.component.html',
styleUrls: ['./exception-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionDashboardComponent implements OnInit, OnDestroy {
private readonly exceptionApi = inject<ExceptionApi>(EXCEPTION_API);
private readonly eventsApi = inject<ExceptionEventsApi>(EXCEPTION_EVENTS_API);
private readonly authSession = inject(AuthSessionStore);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private eventsSubscription?: Subscription;
private routeSubscription?: Subscription;
readonly exceptions = signal<ContractException[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly eventsError = signal<string | null>(null);
readonly showWizard = signal(false);
readonly selectedExceptionId = signal<string | null>(null);
readonly viewExceptions = computed(() =>
this.exceptions().map((exception) => this.mapToViewException(exception))
);
readonly selectedException = computed(() =>
this.exceptions().find((exc) => exc.exceptionId === this.selectedExceptionId()) ?? null
);
readonly userRole = computed<UserRole>(() => {
const scopes = this.authSession.session()?.scopes ?? [];
if (scopes.includes(StellaOpsScopes.ADMIN)) return 'admin';
if (scopes.includes(StellaOpsScopes.EXCEPTION_APPROVE)) return 'approver';
return 'user';
});
ngOnInit(): void {
this.refresh();
this.routeSubscription = this.route.paramMap.subscribe((params) => {
const exceptionId = params.get('exceptionId');
this.selectedExceptionId.set(exceptionId);
});
this.subscribeToEvents();
}
ngOnDestroy(): void {
this.eventsSubscription?.unsubscribe();
this.routeSubscription?.unsubscribe();
}
async refresh(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const response = await firstValueFrom(this.exceptionApi.listExceptions({ limit: 200 }));
this.exceptions.set([...response.items]);
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
openWizard(): void {
this.showWizard.set(true);
}
closeWizard(): void {
this.showWizard.set(false);
}
async onWizardCreate(draft: ExceptionDraft): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
await firstValueFrom(this.exceptionApi.createException(this.mapDraftToRequest(draft)));
this.showWizard.set(false);
await this.refresh();
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
selectException(exception: Exception): void {
this.selectedExceptionId.set(exception.id);
this.router.navigate(['/exceptions', exception.id]);
}
closeDetail(): void {
this.selectedExceptionId.set(null);
this.router.navigate(['/exceptions']);
}
async handleTransition(payload: { exception: Exception; to: ExceptionStatus }): Promise<void> {
const { exception, to } = payload;
const comment = to === 'rejected' ? this.promptForComment('Provide rejection comment') : undefined;
if (to === 'rejected' && !comment) {
return;
}
await this.applyTransition({
exceptionId: exception.id,
newStatus: to,
comment,
});
}
async handleDetailUpdate(payload: { exceptionId: string; updates: Partial<ContractException> }): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
await firstValueFrom(this.exceptionApi.updateException(payload.exceptionId, payload.updates));
await this.refresh();
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
async handleDetailTransition(transition: ExceptionStatusTransition): Promise<void> {
await this.applyTransition(transition);
}
private async applyTransition(transition: ExceptionStatusTransition): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
await firstValueFrom(this.exceptionApi.transitionStatus(transition));
await this.refresh();
} catch (err) {
this.error.set(this.toErrorMessage(err));
} finally {
this.loading.set(false);
}
}
private subscribeToEvents(): void {
this.eventsSubscription = this.eventsApi.streamEvents().subscribe({
next: () => void this.refresh(),
error: (err) => this.eventsError.set(this.toErrorMessage(err)),
});
}
private mapDraftToRequest(draft: ExceptionDraft): Partial<ContractException> {
const now = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + draft.expiresInDays);
return {
name: this.normalizeName(draft.title),
displayName: draft.title,
description: draft.justification,
type: draft.type ?? undefined,
severity: draft.severity,
scope: {
type: 'global',
cves: draft.scope.cves ?? undefined,
packages: draft.scope.packages ?? undefined,
images: draft.scope.images ?? undefined,
licenses: draft.scope.licenses ?? undefined,
policyRules: draft.scope.policyRules ?? undefined,
environments: draft.scope.environments ?? undefined,
},
justification: {
text: draft.justification,
},
timebox: {
startDate: now.toISOString(),
endDate: endDate.toISOString(),
autoRenew: false,
},
labels: draft.tags.reduce<Record<string, string>>((acc, tag) => {
acc[tag] = 'true';
return acc;
}, {}),
};
}
private normalizeName(title: string): string {
const normalized = title
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'exception';
}
private mapToViewException(exception: ContractException): Exception {
const status = exception.status as ExceptionStatus;
const timebox = this.buildTimebox(exception.timebox.endDate, exception.timebox.startDate);
const approvals = (exception.approvals ?? []).map((approval) =>
this.mapApproval(approval)
);
return {
id: exception.exceptionId,
title: exception.displayName ?? exception.name,
justification: exception.justification.text,
type: exception.type ?? 'policy',
status,
severity: exception.severity,
scope: this.mapScope(exception.scope),
timebox,
workflow: {
state: status,
requestedBy: exception.createdBy,
requestedAt: exception.createdAt,
approvedBy: approvals.at(-1)?.approver,
approvedAt: approvals.at(-1)?.at,
revokedBy: undefined,
revokedAt: undefined,
revocationReason: undefined,
requiredApprovers: [],
approvals,
},
auditLog: (exception.auditTrail ?? []).map((entry) => this.mapAudit(entry)),
findings: [],
tags: Object.keys(exception.labels ?? {}).sort(),
createdAt: exception.createdAt,
updatedAt: exception.updatedAt ?? exception.createdAt,
};
}
private mapScope(scope: ContractException['scope']): ExceptionScope {
return {
images: scope.images ?? undefined,
cves: scope.cves ?? scope.vulnIds ?? undefined,
packages: scope.packages ?? undefined,
licenses: scope.licenses ?? undefined,
policyRules: scope.policyRules ?? undefined,
tenantId: scope.tenantId,
environments: scope.environments ?? undefined,
};
}
private mapApproval(approval: ExceptionApproval): ViewApproval {
return {
approver: approval.approvedBy,
decision: 'approved',
at: approval.approvedAt,
comment: approval.comment,
};
}
private mapAudit(entry: ContractAuditEntry): ExceptionAuditEntry {
const action = this.mapAuditAction(entry.action);
return {
id: entry.auditId,
action,
actor: entry.actor,
at: entry.timestamp,
details: entry.metadata ? JSON.stringify(entry.metadata) : undefined,
previousValues: entry.previousStatus ? { status: entry.previousStatus } : undefined,
newValues: entry.newStatus ? { status: entry.newStatus } : undefined,
};
}
private mapAuditAction(action: string): ExceptionAuditEntry['action'] {
const normalized = action.toLowerCase();
if (normalized.includes('approve')) return 'approved';
if (normalized.includes('reject')) return 'rejected';
if (normalized.includes('revoke')) return 'revoked';
if (normalized.includes('expire')) return 'expired';
if (normalized.includes('submit')) return 'submitted';
if (normalized.includes('create')) return 'created';
return 'edited';
}
private buildTimebox(endDate: string, startDate: string): Exception['timebox'] {
const end = new Date(endDate);
const start = new Date(startDate);
const now = new Date();
const diffMs = end.getTime() - now.getTime();
const remainingDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
const warnDays = 7;
return {
startsAt: start.toISOString(),
expiresAt: end.toISOString(),
remainingDays,
isExpired: end <= now,
warnDays,
isWarning: remainingDays <= warnDays && end > now,
};
}
private promptForComment(message: string): string | undefined {
const result = window.prompt(message);
const trimmed = result?.trim();
return trimmed ? trimmed : undefined;
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -0,0 +1,181 @@
@if (exception() as exc) {
<div class="detail-container">
<header class="detail-header">
<div>
<h3 class="detail-title">{{ exc.displayName ?? exc.name }}</h3>
<p class="detail-subtitle">{{ exc.exceptionId }}</p>
</div>
<button class="btn-link" (click)="closePanel()">Close</button>
</header>
@if (error()) {
<div class="alert">{{ error() }}</div>
}
<section class="detail-section">
<div class="detail-grid">
<div>
<span class="detail-label">Status</span>
<span class="detail-value">{{ exc.status | titlecase }}</span>
</div>
<div>
<span class="detail-label">Severity</span>
<span class="detail-value">{{ exc.severity | titlecase }}</span>
</div>
<div>
<span class="detail-label">Created</span>
<span class="detail-value">{{ formatDate(exc.createdAt) }}</span>
</div>
<div>
<span class="detail-label">Expires</span>
<span class="detail-value">{{ formatDate(exc.timebox.endDate) }}</span>
</div>
</div>
</section>
<section class="detail-section">
<h4 class="section-title">Scope</h4>
<div class="scope-summary">
@if (relatedScopeSummary().length === 0) {
<span class="detail-value">Global scope</span>
} @else {
@for (item of relatedScopeSummary(); track item) {
<span class="scope-chip">{{ item }}</span>
}
}
</div>
</section>
<section class="detail-section">
<h4 class="section-title">Justification</h4>
<textarea
class="text-area"
[value]="editJustification()"
(input)="editJustification.set($any($event.target).value)"
[disabled]="!canEdit()"
></textarea>
</section>
<section class="detail-section">
<h4 class="section-title">Rationale</h4>
<textarea
class="text-area"
[value]="editDescription()"
(input)="editDescription.set($any($event.target).value)"
[disabled]="!canEdit()"
placeholder="Add additional rationale or context..."
></textarea>
</section>
<section class="detail-section">
<h4 class="section-title">Metadata</h4>
<div class="labels-list">
@for (entry of labelEntries(); track $index) {
<div class="label-row">
<input
class="field-input"
placeholder="Key"
[value]="entry.key"
(input)="updateLabel($index, $any($event.target).value, entry.value)"
[disabled]="!canEdit()"
/>
<input
class="field-input"
placeholder="Value"
[value]="entry.value"
(input)="updateLabel($index, entry.key, $any($event.target).value)"
[disabled]="!canEdit()"
/>
<button
class="btn-link danger"
(click)="removeLabel($index)"
[disabled]="!canEdit()"
>
Remove
</button>
</div>
}
<button class="btn-secondary" (click)="addLabel()" [disabled]="!canEdit()">Add label</button>
</div>
</section>
<section class="detail-section">
<h4 class="section-title">Evidence</h4>
@if (evidenceLinks().length === 0) {
<span class="detail-value">No evidence references recorded.</span>
} @else {
<ul class="evidence-list">
@for (link of evidenceLinks(); track link.key) {
<li>
<span class="detail-label">{{ link.key }}:</span>
<a [href]="link.value" target="_blank" rel="noreferrer">{{ link.value }}</a>
</li>
}
</ul>
}
</section>
<section class="detail-section">
<h4 class="section-title">Extend expiry</h4>
<div class="extend-row">
<input
type="number"
class="field-input"
[value]="extendDays()"
(input)="extendDays.set(+$any($event.target).value)"
[disabled]="!canEdit()"
/>
<button class="btn-secondary" (click)="extendExpiry()" [disabled]="!canEdit()">Extend</button>
</div>
</section>
<section class="detail-section">
<h4 class="section-title">Transitions</h4>
<div class="transition-row">
@for (transition of availableTransitions(); track transition.to) {
<button
class="btn-secondary"
(click)="requestTransition(transition)"
>
{{ transition.action }}
</button>
}
</div>
<div class="transition-comment">
<label class="detail-label">Comment (required for rejection)</label>
<input
class="field-input"
[value]="transitionComment()"
(input)="transitionComment.set($any($event.target).value)"
placeholder="Add comment"
/>
</div>
</section>
<section class="detail-section">
<h4 class="section-title">Audit trail</h4>
@if ((exc.auditTrail ?? []).length === 0) {
<span class="detail-value">No audit entries available.</span>
} @else {
<ul class="audit-list">
@for (entry of exc.auditTrail ?? []; track entry.auditId) {
<li>
<span class="detail-label">{{ entry.action }}</span>
<span class="detail-value">{{ formatDate(entry.timestamp) }} by {{ entry.actor }}</span>
</li>
}
</ul>
}
</section>
<footer class="detail-footer">
<button
class="btn-primary"
(click)="saveChanges()"
[disabled]="!canEdit() || !hasChanges()"
>
Save changes
</button>
</footer>
</div>
}

View File

@@ -0,0 +1,185 @@
.detail-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.75rem;
}
.detail-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.detail-subtitle {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.detail-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #6b7280);
margin: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.detail-label {
display: block;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.detail-value {
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.scope-summary {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.scope-chip {
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-subtle, #f3f4f6);
font-size: 0.75rem;
color: var(--color-text, #374151);
}
.text-area {
min-height: 90px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.875rem;
resize: vertical;
&:disabled {
background: #f9fafb;
}
}
.labels-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.label-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
align-items: center;
}
.field-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
&:disabled {
background: #f9fafb;
}
}
.evidence-list,
.audit-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.extend-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.transition-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.transition-comment {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-footer {
display: flex;
justify-content: flex-end;
}
.btn-primary,
.btn-secondary,
.btn-link {
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
border: none;
background: none;
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
}
.btn-link {
color: var(--color-primary, #2563eb);
&.danger {
color: var(--color-error, #dc2626);
}
}
.alert {
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: #fee2e2;
color: #b91c1c;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,208 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExceptionDetailComponent } from './exception-detail.component';
import { Exception } from '../../core/api/exception.contract.models';
import { EXCEPTION_TRANSITIONS } from '../../core/api/exception.models';
describe('ExceptionDetailComponent', () => {
let fixture: ComponentFixture<ExceptionDetailComponent>;
let component: ExceptionDetailComponent;
const mockException: Exception = {
exceptionId: 'exc-001',
name: 'test-exception',
displayName: 'Test Exception',
description: 'Test description',
type: 'vulnerability',
severity: 'high',
status: 'pending_review',
scope: {
type: 'global',
vulnIds: ['CVE-2024-1234'],
componentPurls: ['pkg:npm/lodash@4.17.21'],
assetIds: ['asset-001'],
tenantId: 'tenant-123',
},
justification: {
text: 'Test justification',
},
timebox: {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
labels: {
'evidence.ref': 'https://example.com/evidence',
tag: 'security',
},
createdBy: 'user@test.com',
createdAt: new Date().toISOString(),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExceptionDetailComponent],
}).compileComponents();
fixture = TestBed.createComponent(ExceptionDetailComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('exception', mockException);
fixture.componentRef.setInput('userRole', 'approver');
fixture.detectChanges();
});
it('displays exception data', () => {
expect(component.exception()).toEqual(mockException);
expect(component.editDescription()).toBe('Test description');
expect(component.editJustification()).toBe('Test justification');
expect(component.labelEntries().length).toBeGreaterThan(0);
});
it('shows editable state for draft and pending_review statuses', () => {
fixture.componentRef.setInput('exception', {
...mockException,
status: 'draft',
});
fixture.detectChanges();
expect(component.canEdit()).toBeTrue();
fixture.componentRef.setInput('exception', {
...mockException,
status: 'pending_review',
});
fixture.detectChanges();
expect(component.canEdit()).toBeTrue();
fixture.componentRef.setInput('exception', {
...mockException,
status: 'approved',
});
fixture.detectChanges();
expect(component.canEdit()).toBeFalse();
});
it('detects changes in description and justification', () => {
expect(component.hasChanges()).toBeFalse();
component.editDescription.set('Updated description');
expect(component.hasChanges()).toBeTrue();
component.editDescription.set('Test description');
component.editJustification.set('Updated justification');
expect(component.hasChanges()).toBeTrue();
});
it('handles status transitions', () => {
const transitions = component.availableTransitions();
expect(transitions.length).toBeGreaterThan(0);
const approveTransition = transitions.find((t) => t.to === 'approved');
expect(approveTransition).toBeDefined();
expect(approveTransition?.allowedRoles).toContain('approver');
});
it('requires comment for rejection', () => {
const rejectTransition = EXCEPTION_TRANSITIONS.find(
(t) => t.from === 'pending_review' && t.to === 'rejected'
)!;
component.requestTransition(rejectTransition);
expect(component.error()).toBe('Rejection requires a comment.');
});
it('emits transition when valid', () => {
spyOn(component.transition, 'emit');
const approveTransition = EXCEPTION_TRANSITIONS.find(
(t) => t.from === 'pending_review' && t.to === 'approved'
)!;
component.requestTransition(approveTransition);
expect(component.transition.emit).toHaveBeenCalledWith({
exceptionId: 'exc-001',
newStatus: 'approved',
comment: undefined,
});
});
it('emits update when changes saved', () => {
spyOn(component.update, 'emit');
component.editDescription.set('Updated description');
component.saveChanges();
expect(component.update.emit).toHaveBeenCalledWith({
exceptionId: 'exc-001',
updates: jasmine.objectContaining({
description: 'Updated description',
}),
});
});
it('adds and removes labels', () => {
const initialCount = component.labelEntries().length;
component.addLabel();
expect(component.labelEntries().length).toBe(initialCount + 1);
component.removeLabel(0);
expect(component.labelEntries().length).toBe(initialCount);
});
it('updates label entry', () => {
component.addLabel();
const lastIndex = component.labelEntries().length - 1;
component.updateLabel(lastIndex, 'newKey', 'newValue');
const updated = component.labelEntries()[lastIndex];
expect(updated.key).toBe('newKey');
expect(updated.value).toBe('newValue');
});
it('extends expiry date', () => {
spyOn(component.update, 'emit');
component.extendDays.set(14);
component.extendExpiry();
expect(component.update.emit).toHaveBeenCalledWith({
exceptionId: 'exc-001',
updates: jasmine.objectContaining({
timebox: jasmine.objectContaining({
endDate: jasmine.any(String),
}),
}),
});
});
it('shows evidence links', () => {
const evidenceLinks = component.evidenceLinks();
expect(evidenceLinks.length).toBeGreaterThan(0);
expect(evidenceLinks[0].key).toBe('evidence.ref');
expect(evidenceLinks[0].value).toContain('https://');
});
it('summarizes related scope', () => {
const summary = component.relatedScopeSummary();
expect(summary).toContain('1 CVE(s)');
expect(summary).toContain('1 component(s)');
expect(summary).toContain('1 asset(s)');
expect(summary).toContain('Tenant: tenant-123');
});
it('formats dates correctly', () => {
const formatted = component.formatDate(mockException.createdAt);
expect(formatted).not.toBe('-');
expect(formatted).toContain(',');
});
it('emits close event', () => {
spyOn(component.close, 'emit');
component.closePanel();
expect(component.close.emit).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,203 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
Exception,
ExceptionStatusTransition,
} from '../../core/api/exception.contract.models';
import { ExceptionTransition, EXCEPTION_TRANSITIONS } from '../../core/api/exception.models';
interface LabelEntry {
key: string;
value: string;
}
@Component({
selector: 'app-exception-detail',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './exception-detail.component.html',
styleUrls: ['./exception-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionDetailComponent {
readonly exception = input<Exception | null>(null);
readonly userRole = input<string>('user');
readonly close = output<void>();
readonly update = output<{ exceptionId: string; updates: Partial<Exception> }>();
readonly transition = output<ExceptionStatusTransition>();
readonly editDescription = signal('');
readonly editJustification = signal('');
readonly labelEntries = signal<LabelEntry[]>([]);
readonly transitionComment = signal('');
readonly extendDays = signal(7);
readonly error = signal<string | null>(null);
readonly canEdit = computed(() => {
const status = this.exception()?.status;
return status === 'draft' || status === 'pending_review';
});
readonly hasChanges = computed(() => {
const exception = this.exception();
if (!exception) return false;
if (this.editDescription() !== (exception.description ?? '')) return true;
if (this.editJustification() !== exception.justification.text) return true;
return !this.labelsEqual(exception.labels ?? {}, this.buildLabelsMap());
});
readonly availableTransitions = computed<ExceptionTransition[]>(() => {
const status = this.exception()?.status;
if (!status) return [];
return EXCEPTION_TRANSITIONS.filter(
(transition) =>
transition.from === status && transition.allowedRoles.includes(this.userRole())
);
});
readonly evidenceLinks = computed(() => {
const labels = this.exception()?.labels ?? {};
return Object.entries(labels)
.filter(([, value]) => value.startsWith('http') || value.startsWith('sha256:'))
.map(([key, value]) => ({ key, value }));
});
readonly relatedScopeSummary = computed(() => {
const scope = this.exception()?.scope;
if (!scope) return [];
const summary: string[] = [];
if (scope.vulnIds?.length) summary.push(`${scope.vulnIds.length} CVE(s)`);
if (scope.componentPurls?.length) summary.push(`${scope.componentPurls.length} component(s)`);
if (scope.assetIds?.length) summary.push(`${scope.assetIds.length} asset(s)`);
if (scope.tenantId) summary.push(`Tenant: ${scope.tenantId}`);
return summary;
});
constructor() {
effect(() => {
const exception = this.exception();
if (!exception) return;
this.editDescription.set(exception.description ?? '');
this.editJustification.set(exception.justification.text);
this.labelEntries.set(this.mapLabels(exception.labels ?? {}));
this.transitionComment.set('');
this.error.set(null);
});
}
addLabel(): void {
this.labelEntries.update((entries) => [...entries, { key: '', value: '' }]);
}
removeLabel(index: number): void {
this.labelEntries.update((entries) => entries.filter((_, i) => i !== index));
}
updateLabel(index: number, key: string, value: string): void {
this.labelEntries.update((entries) =>
entries.map((entry, i) => (i === index ? { key, value } : entry))
);
}
saveChanges(): void {
const exception = this.exception();
if (!exception) return;
const updates: Partial<Exception> = {
description: this.editDescription().trim() || undefined,
justification: {
...exception.justification,
text: this.editJustification().trim(),
},
labels: this.buildLabelsMap(),
};
this.update.emit({ exceptionId: exception.exceptionId, updates });
}
extendExpiry(): void {
const exception = this.exception();
if (!exception) return;
const days = Math.max(1, this.extendDays());
const endDate = new Date(exception.timebox.endDate);
endDate.setDate(endDate.getDate() + days);
const updates: Partial<Exception> = {
timebox: {
...exception.timebox,
endDate: endDate.toISOString(),
},
};
this.update.emit({ exceptionId: exception.exceptionId, updates });
}
requestTransition(transition: ExceptionTransition): void {
const exception = this.exception();
if (!exception) return;
if (transition.to === 'rejected' && !this.transitionComment().trim()) {
this.error.set('Rejection requires a comment.');
return;
}
this.error.set(null);
this.transition.emit({
exceptionId: exception.exceptionId,
newStatus: transition.to,
comment: this.transitionComment().trim() || undefined,
});
this.transitionComment.set('');
}
closePanel(): void {
this.close.emit();
}
formatDate(value: string | undefined): string {
if (!value) return '-';
return new Date(value).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private mapLabels(labels: Record<string, string>): LabelEntry[] {
return Object.entries(labels)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ({ key, value }));
}
private buildLabelsMap(): Record<string, string> {
const entries = this.labelEntries();
return entries.reduce<Record<string, string>>((acc, entry) => {
const key = entry.key.trim();
if (!key) return acc;
acc[key] = entry.value.trim();
return acc;
}, {});
}
private labelsEqual(a: Record<string, string>, b: Record<string, string>): boolean {
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key, idx) => key === bKeys[idx] && a[key] === b[key]);
}
}

View File

@@ -1,21 +1,22 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
@@ -61,8 +62,9 @@ const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] =
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly router = inject(Router);
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@@ -190,9 +192,10 @@ export class ExceptionDraftInlineComponent implements OnInit {
},
};
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
this.router.navigate(['/exceptions', created.exceptionId]);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);

View File

@@ -230,9 +230,9 @@
</div>
}
<!-- Step 4: Timebox -->
@if (currentStep() === 'timebox') {
<div class="step-panel">
<!-- Step 4: Timebox -->
@if (currentStep() === 'timebox') {
<div class="step-panel">
<h3 class="step-title">Set exception duration</h3>
<p class="step-desc">
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
@@ -283,13 +283,220 @@
</div>
}
</div>
</div>
}
<!-- Step 5: Review -->
@if (currentStep() === 'review') {
<div class="step-panel">
<h3 class="step-title">Review and submit</h3>
</div>
}
<!-- Step 5: Recheck Policy -->
@if (currentStep() === 'recheck-policy') {
<div class="step-panel">
<h3 class="step-title">Configure recheck policy</h3>
<p class="step-desc">
Define the conditions that automatically re-evaluate this exception. Leave disabled if not needed.
</p>
@if (!recheckPolicy()) {
<div class="empty-panel">
<p class="empty-text">No recheck policy is configured for this exception.</p>
<button class="btn-secondary" (click)="enableRecheckPolicy()">Enable Recheck Policy</button>
</div>
} @else {
<div class="recheck-form">
<div class="form-field">
<label class="field-label">Policy name</label>
<input
type="text"
class="field-input"
[value]="recheckPolicy()?.name"
(input)="updateRecheckPolicy('name', $any($event.target).value)"
/>
</div>
<div class="form-field">
<label class="field-label">Default action</label>
<select
class="field-select"
[value]="recheckPolicy()?.defaultAction"
(change)="updateRecheckPolicy('defaultAction', $any($event.target).value)"
>
@for (action of actionOptions; track action.value) {
<option [value]="action.value">{{ action.label }}</option>
}
</select>
</div>
<div class="conditions-header">
<h4 class="section-title">Conditions</h4>
<button class="btn-secondary" (click)="addRecheckCondition()">+ Add Condition</button>
</div>
@if (recheckConditions().length === 0) {
<div class="empty-inline">Add at least one condition to enable recheck enforcement.</div>
}
<div class="condition-list">
@for (condition of recheckConditions(); track condition.id) {
<div class="condition-card">
<div class="condition-grid">
<div class="form-field">
<label class="field-label">Condition</label>
<select
class="field-select"
[value]="condition.type"
(change)="updateRecheckCondition(condition.id, { type: $any($event.target).value, threshold: null })"
>
@for (option of conditionTypeOptions; track option.type) {
<option [value]="option.type">{{ option.label }}</option>
}
</select>
</div>
@if (requiresThreshold(condition.type)) {
<div class="form-field">
<label class="field-label">Threshold</label>
<input
type="number"
class="field-input"
[placeholder]="conditionTypeOptions.find(o => o.type === condition.type)?.thresholdHint || ''"
[value]="condition.threshold ?? ''"
(input)="updateRecheckCondition(condition.id, { threshold: $any($event.target).value === '' ? null : +$any($event.target).value })"
/>
</div>
}
<div class="form-field">
<label class="field-label">Action</label>
<select
class="field-select"
[value]="condition.action"
(change)="updateRecheckCondition(condition.id, { action: $any($event.target).value })"
>
@for (action of actionOptions; track action.value) {
<option [value]="action.value">{{ action.label }}</option>
}
</select>
</div>
</div>
<div class="form-field">
<label class="field-label">Environment scope</label>
<div class="env-chips">
@for (env of environmentOptions; track env) {
<button
class="env-chip"
[class.selected]="condition.environmentScope.includes(env)"
(click)="updateRecheckCondition(condition.id, {
environmentScope: condition.environmentScope.includes(env)
? condition.environmentScope.filter(e => e !== env)
: [...condition.environmentScope, env]
})"
>
{{ env | titlecase }}
</button>
}
</div>
<span class="field-hint">Leave empty to apply in all environments.</span>
</div>
<div class="condition-actions">
<button class="btn-link danger" (click)="removeRecheckCondition(condition.id)">Remove</button>
</div>
</div>
}
</div>
<button class="btn-link" (click)="disableRecheckPolicy()">Disable recheck policy</button>
</div>
}
</div>
}
<!-- Step 6: Evidence Requirements -->
@if (currentStep() === 'evidence') {
<div class="step-panel">
<h3 class="step-title">Evidence requirements</h3>
<p class="step-desc">
Submit evidence to support the exception. Mandatory evidence must be provided before submission.
</p>
@if (missingEvidence().length > 0) {
<div class="missing-banner">
<span class="warning-icon">[!]</span>
{{ missingEvidence().length }} mandatory evidence item(s) missing.
</div>
}
<div class="evidence-grid">
@for (entry of evidenceEntries(); track entry.hook.hookId) {
<div class="evidence-card">
<div class="evidence-header">
<div>
<div class="evidence-title">
{{ getEvidenceLabel(entry.hook.type) }}
@if (entry.hook.isMandatory) {
<span class="tag required">Required</span>
} @else {
<span class="tag optional">Optional</span>
}
</div>
<div class="evidence-desc">{{ entry.hook.description }}</div>
</div>
<span class="status-badge" [class]="'status-' + entry.status.toLowerCase()">
{{ entry.status }}
</span>
</div>
<div class="evidence-meta">
@if (entry.hook.maxAge) {
<span class="meta-chip">Max age: {{ entry.hook.maxAge }}</span>
}
@if (entry.hook.minTrustScore) {
<span class="meta-chip">Min trust: {{ entry.hook.minTrustScore }}</span>
}
</div>
<div class="evidence-body">
<div class="form-field">
<label class="field-label">Reference link</label>
<input
type="text"
class="field-input"
placeholder="https://... or launchdarkly://..."
[value]="entry.submission?.reference || ''"
(input)="updateEvidenceSubmission(entry.hook.hookId, { reference: $any($event.target).value })"
/>
</div>
<div class="form-field">
<label class="field-label">Notes or evidence summary</label>
<textarea
class="field-textarea"
[value]="entry.submission?.content || ''"
(input)="updateEvidenceSubmission(entry.hook.hookId, { content: $any($event.target).value })"
></textarea>
</div>
<div class="form-field">
<label class="field-label">Attach file (optional)</label>
<input
type="file"
class="field-input"
(change)="onEvidenceFileSelected(entry.hook.hookId, $event)"
/>
@if (entry.submission?.fileName) {
<span class="field-hint">Attached: {{ entry.submission?.fileName }}</span>
}
</div>
</div>
</div>
}
</div>
</div>
}
<!-- Step 7: Review -->
@if (currentStep() === 'review') {
<div class="step-panel">
<h3 class="step-title">Review and submit</h3>
<p class="step-desc">Please review your exception request before submitting.</p>
<div class="review-summary">
@@ -369,20 +576,57 @@
}
</div>
<div class="review-section">
<h4 class="section-title">Timebox</h4>
<div class="review-row">
<span class="review-label">Duration:</span>
<span class="review-value">{{ draft().expiresInDays }} days</span>
</div>
<div class="review-row">
<span class="review-label">Expires:</span>
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
</div>
</div>
</div>
</div>
}
<div class="review-section">
<h4 class="section-title">Timebox</h4>
<div class="review-row">
<span class="review-label">Duration:</span>
<span class="review-value">{{ draft().expiresInDays }} days</span>
</div>
<div class="review-row">
<span class="review-label">Expires:</span>
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
</div>
</div>
<div class="review-section">
<h4 class="section-title">Recheck Policy</h4>
@if (!recheckPolicy()) {
<div class="review-row">
<span class="review-label">Status:</span>
<span class="review-value">Not configured</span>
</div>
} @else {
<div class="review-row">
<span class="review-label">Policy:</span>
<span class="review-value">{{ recheckPolicy()?.name }}</span>
</div>
@for (condition of recheckConditions(); track condition.id) {
<div class="review-row">
<span class="review-label">Condition:</span>
<span class="review-value">
{{ getConditionLabel(condition.type) }}
@if (condition.threshold !== null) {
({{ condition.threshold }})
}
- {{ condition.action }}
</span>
</div>
}
}
</div>
<div class="review-section">
<h4 class="section-title">Evidence</h4>
@for (entry of evidenceEntries(); track entry.hook.hookId) {
<div class="review-row">
<span class="review-label">{{ getEvidenceLabel(entry.hook.type) }}:</span>
<span class="review-value">{{ entry.status }}</span>
</div>
}
</div>
</div>
</div>
}
</div>
<!-- Footer Actions -->

View File

@@ -276,18 +276,31 @@
gap: 0.375rem;
}
.field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
.field-input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
}
}
.field-select {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
background: var(--color-bg-card, white);
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
.severity-options {
display: flex;
gap: 0.5rem;
@@ -376,16 +389,26 @@
gap: 0.375rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-text, #374151);
}
color: var(--color-text, #374151);
}
.tag.required {
background: #fee2e2;
color: #b91c1c;
}
.tag.optional {
background: #e0f2fe;
color: #0369a1;
}
.tag-remove {
background: none;
@@ -489,11 +512,11 @@
color: var(--color-text, #374151);
}
.timebox-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
.timebox-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-warning-bg, #fef3c7);
border-radius: 4px;
font-size: 0.875rem;
@@ -502,9 +525,205 @@
.warning-icon {
font-family: monospace;
font-weight: 700;
}
font-weight: 700;
}
// Recheck Policy
.recheck-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.conditions-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.condition-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.condition-card {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1rem;
background: var(--color-bg-card, white);
}
.condition-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.condition-actions {
margin-top: 0.5rem;
display: flex;
justify-content: flex-end;
}
.empty-panel {
padding: 1rem;
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-bg-subtle, #f9fafb);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.empty-text {
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
}
.empty-inline {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
// Evidence
.missing-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #fef3c7;
border-radius: 6px;
color: #92400e;
font-size: 0.875rem;
}
.evidence-grid {
display: grid;
gap: 1rem;
}
.evidence-card {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1rem;
background: var(--color-bg-card, white);
}
.evidence-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.75rem;
}
.evidence-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text, #374151);
display: flex;
align-items: center;
gap: 0.5rem;
}
.evidence-desc {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.evidence-meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.meta-chip {
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-subtle, #f3f4f6);
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
&.status-missing {
background: #fee2e2;
color: #b91c1c;
}
&.status-pending {
background: #fef3c7;
color: #92400e;
}
&.status-valid {
background: #dcfce7;
color: #166534;
}
&.status-invalid {
background: #fef2f2;
color: #dc2626;
}
&.status-expired {
background: #f1f5f9;
color: #475569;
}
&.status-insufficienttrust {
background: #e0e7ff;
color: #4338ca;
}
}
.evidence-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
// Buttons
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
cursor: pointer;
font-size: 0.8125rem;
padding: 0;
&:hover {
text-decoration: underline;
}
&.danger {
color: var(--color-error, #dc2626);
}
}
// Review
.review-summary {
display: flex;

View File

@@ -0,0 +1,45 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExceptionWizardComponent } from './exception-wizard.component';
describe('ExceptionWizardComponent', () => {
let fixture: ComponentFixture<ExceptionWizardComponent>;
let component: ExceptionWizardComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ExceptionWizardComponent],
}).compileComponents();
fixture = TestBed.createComponent(ExceptionWizardComponent);
component = fixture.componentInstance;
});
it('blocks recheck step until conditions are valid', () => {
component.currentStep.set('recheck-policy');
component.enableRecheckPolicy();
component.addRecheckCondition();
expect(component.canGoNext()).toBeFalse();
const conditionId = component.recheckConditions()[0].id;
component.updateRecheckCondition(conditionId, { threshold: 0.5 });
expect(component.canGoNext()).toBeTrue();
});
it('requires mandatory evidence before continuing', () => {
component.currentStep.set('evidence');
expect(component.canGoNext()).toBeFalse();
const requiredHooks = component.evidenceHooks().filter((hook) => hook.isMandatory);
for (const hook of requiredHooks) {
component.updateEvidenceSubmission(hook.hookId, {
reference: `https://evidence.local/${hook.hookId}`,
});
}
expect(component.canGoNext()).toBeTrue();
});
});

View File

@@ -13,7 +13,77 @@ import {
ExceptionScope,
} from '../../core/api/exception.models';
type WizardStep = 'type' | 'scope' | 'justification' | 'timebox' | 'review';
type WizardStep =
| 'type'
| 'scope'
| 'justification'
| 'timebox'
| 'recheck-policy'
| 'evidence'
| 'review';
type RecheckConditionType =
| 'ReachGraphChange'
| 'EPSSAbove'
| 'CVSSAbove'
| 'UnknownsAbove'
| 'NewCVEInPackage'
| 'KEVFlagged'
| 'ExpiryWithin'
| 'VEXStatusChange'
| 'PackageVersionChange';
type RecheckAction = 'Warn' | 'RequireReapproval' | 'Revoke' | 'Block';
type EvidenceType =
| 'FeatureFlagDisabled'
| 'BackportMerged'
| 'CompensatingControl'
| 'SecurityReview'
| 'RuntimeMitigation'
| 'WAFRuleDeployed'
| 'CustomAttestation';
type EvidenceValidationStatus =
| 'Missing'
| 'Pending'
| 'Valid'
| 'Invalid'
| 'Expired'
| 'InsufficientTrust';
interface RecheckConditionForm {
id: string;
type: RecheckConditionType;
threshold: number | null;
environmentScope: string[];
action: RecheckAction;
}
interface RecheckPolicyDraft {
name: string;
isActive: boolean;
defaultAction: RecheckAction;
conditions: RecheckConditionForm[];
}
interface EvidenceHookRequirement {
hookId: string;
type: EvidenceType;
description: string;
isMandatory: boolean;
maxAge?: string;
minTrustScore?: number;
}
interface EvidenceSubmission {
hookId: string;
type: EvidenceType;
reference: string;
content: string;
fileName?: string;
validationStatus: EvidenceValidationStatus;
}
export interface JustificationTemplate {
id: string;
@@ -29,15 +99,17 @@ export interface TimeboxPreset {
description: string;
}
export interface ExceptionDraft {
type: ExceptionType | null;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
justification: string;
scope: Partial<ExceptionScope>;
expiresInDays: number;
tags: string[];
}
export interface ExceptionDraft {
type: ExceptionType | null;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
justification: string;
scope: Partial<ExceptionScope>;
expiresInDays: number;
tags: string[];
recheckPolicy: RecheckPolicyDraft | null;
evidenceSubmissions: EvidenceSubmission[];
}
@Component({
selector: 'app-exception-wizard',
@@ -47,7 +119,7 @@ export interface ExceptionDraft {
styleUrls: ['./exception-wizard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionWizardComponent {
export class ExceptionWizardComponent {
/** Pre-selected type (e.g., from vulnerability view) */
readonly preselectedType = input<ExceptionType>();
@@ -66,40 +138,51 @@ export class ExceptionWizardComponent {
/** Emits when exception is created */
readonly create = output<ExceptionDraft>();
readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review'];
readonly currentStep = signal<WizardStep>('type');
readonly draft = signal<ExceptionDraft>({
type: null,
severity: 'medium',
title: '',
justification: '',
scope: {},
expiresInDays: 30,
tags: [],
});
readonly steps: WizardStep[] = [
'type',
'scope',
'justification',
'timebox',
'recheck-policy',
'evidence',
'review',
];
readonly currentStep = signal<WizardStep>('type');
readonly draft = signal<ExceptionDraft>({
type: null,
severity: 'medium',
title: '',
justification: '',
scope: {},
expiresInDays: 30,
tags: [],
recheckPolicy: null,
evidenceSubmissions: [],
});
readonly scopePreview = signal<string[]>([]);
readonly selectedTemplate = signal<string | null>(null);
readonly newTag = signal('');
readonly selectedTemplate = signal<string | null>(null);
readonly newTag = signal('');
private conditionCounter = 0;
readonly timeboxPresets: TimeboxPreset[] = [
readonly timeboxPresets: TimeboxPreset[] = [
{ label: '7 days', days: 7, description: 'Short-term exception for urgent fixes' },
{ label: '14 days', days: 14, description: 'Sprint-length exception' },
{ label: '30 days', days: 30, description: 'Standard exception duration' },
{ label: '60 days', days: 60, description: 'Extended exception for complex remediation' },
{ label: '90 days', days: 90, description: 'Maximum allowed duration' },
];
readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [
{ type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' },
{ type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' },
];
readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [
{ type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' },
{ type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' },
{ type: 'policy', label: 'Policy', icon: 'P', description: 'Exception for policy rule violations' },
{ type: 'entropy', label: 'Entropy', icon: 'E', description: 'Exception for high entropy findings' },
{ type: 'determinism', label: 'Determinism', icon: 'D', description: 'Exception for determinism check failures' },
];
readonly defaultTemplates: JustificationTemplate[] = [
readonly defaultTemplates: JustificationTemplate[] = [
{
id: 'false-positive',
name: 'False Positive',
@@ -128,37 +211,149 @@ export class ExceptionWizardComponent {
template: 'This exception is required for the following business reason:\n\n[Explain business requirement]\n\nImpact if not granted:\n- [Impact 1]\n- [Impact 2]\n\nApproved by: [Business Owner]',
type: ['license', 'policy'],
},
];
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
];
readonly defaultEvidenceHooks: EvidenceHookRequirement[] = [
{
hookId: 'feature-flag-disabled',
type: 'FeatureFlagDisabled',
description: 'Feature flag must be disabled in target environment',
isMandatory: true,
maxAge: 'PT24H',
minTrustScore: 0.8,
},
{
hookId: 'backport-merged',
type: 'BackportMerged',
description: 'Security backport must be merged',
isMandatory: true,
minTrustScore: 0.7,
},
{
hookId: 'compensating-control',
type: 'CompensatingControl',
description: 'Compensating control attestation',
isMandatory: false,
minTrustScore: 0.6,
},
{
hookId: 'security-review',
type: 'SecurityReview',
description: 'Security review completed',
isMandatory: false,
maxAge: 'P30D',
minTrustScore: 0.7,
},
];
readonly evidenceHooks = input<EvidenceHookRequirement[]>(this.defaultEvidenceHooks);
readonly environmentOptions = ['development', 'staging', 'production'];
readonly conditionTypeOptions: {
type: RecheckConditionType;
label: string;
requiresThreshold: boolean;
thresholdHint?: string;
}[] = [
{ type: 'ReachGraphChange', label: 'Reach Graph Change', requiresThreshold: false },
{ type: 'EPSSAbove', label: 'EPSS Above', requiresThreshold: true, thresholdHint: '0.0 - 1.0' },
{ type: 'CVSSAbove', label: 'CVSS Above', requiresThreshold: true, thresholdHint: '0.0 - 10.0' },
{ type: 'UnknownsAbove', label: 'Unknowns Above', requiresThreshold: true, thresholdHint: 'Count' },
{ type: 'NewCVEInPackage', label: 'New CVE In Package', requiresThreshold: false },
{ type: 'KEVFlagged', label: 'KEV Flagged', requiresThreshold: false },
{ type: 'ExpiryWithin', label: 'Expiry Within', requiresThreshold: true, thresholdHint: 'Days' },
{ type: 'VEXStatusChange', label: 'VEX Status Change', requiresThreshold: false },
{ type: 'PackageVersionChange', label: 'Package Version Change', requiresThreshold: false },
];
readonly actionOptions: { value: RecheckAction; label: string }[] = [
{ value: 'Warn', label: 'Warn' },
{ value: 'RequireReapproval', label: 'Require Reapproval' },
{ value: 'Revoke', label: 'Revoke' },
{ value: 'Block', label: 'Block' },
];
readonly evidenceTypeOptions: { value: EvidenceType; label: string }[] = [
{ value: 'FeatureFlagDisabled', label: 'Feature Flag Disabled' },
{ value: 'BackportMerged', label: 'Backport Merged' },
{ value: 'CompensatingControl', label: 'Compensating Control' },
{ value: 'SecurityReview', label: 'Security Review' },
{ value: 'RuntimeMitigation', label: 'Runtime Mitigation' },
{ value: 'WAFRuleDeployed', label: 'WAF Rule Deployed' },
{ value: 'CustomAttestation', label: 'Custom Attestation' },
];
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
readonly canGoNext = computed(() => {
const step = this.currentStep();
const d = this.draft();
switch (step) {
case 'type':
return d.type !== null;
case 'scope':
return this.hasValidScope();
case 'justification':
return d.title.trim().length > 0 && d.justification.trim().length > 20;
case 'timebox':
return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays();
case 'review':
return true;
default:
return false;
}
});
switch (step) {
case 'type':
return d.type !== null;
case 'scope':
return this.hasValidScope();
case 'justification':
return d.title.trim().length > 0 && d.justification.trim().length > 20;
case 'timebox':
return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays();
case 'recheck-policy':
return this.isRecheckPolicyValid();
case 'evidence':
return this.isEvidenceSatisfied();
case 'review':
return true;
default:
return false;
}
});
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
readonly applicableTemplates = computed(() => {
const type = this.draft().type;
if (!type) return [];
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
});
readonly applicableTemplates = computed(() => {
const type = this.draft().type;
if (!type) return [];
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
});
readonly recheckPolicy = computed(() => this.draft().recheckPolicy);
readonly recheckConditions = computed(() => this.draft().recheckPolicy?.conditions ?? []);
readonly isRecheckPolicyValid = computed(() => {
const policy = this.draft().recheckPolicy;
if (!policy) return true;
if (policy.conditions.length === 0) return false;
return policy.conditions.every((condition) => {
if (!this.requiresThreshold(condition.type)) return true;
return typeof condition.threshold === 'number' && condition.threshold > 0;
});
});
readonly evidenceEntries = computed(() => {
const submissions = this.draft().evidenceSubmissions;
return this.evidenceHooks().map((hook) => {
const submission = submissions.find((s) => s.hookId === hook.hookId) ?? null;
const status = this.resolveEvidenceStatus(hook, submission);
return {
hook,
submission,
status,
};
});
});
readonly missingEvidence = computed(() => {
return this.evidenceEntries()
.filter((entry) => entry.hook.isMandatory && entry.status === 'Missing')
.map((entry) => entry.hook);
});
readonly isEvidenceSatisfied = computed(() => {
return this.missingEvidence().length === 0;
});
readonly expirationDate = computed(() => {
const days = this.draft().expiresInDays;
@@ -200,9 +395,9 @@ export class ExceptionWizardComponent {
this.draft.update((d) => ({ ...d, [key]: value }));
}
updateScope<K extends keyof ExceptionScope>(key: K, value: ExceptionScope[K]): void {
this.draft.update((d) => ({
...d,
updateScope<K extends keyof ExceptionScope>(key: K, value: ExceptionScope[K]): void {
this.draft.update((d) => ({
...d,
scope: { ...d.scope, [key]: value },
}));
this.updateScopePreview();
@@ -219,11 +414,128 @@ export class ExceptionWizardComponent {
if (scope.policyRules?.length) preview.push(`${scope.policyRules.length} rule(s)`);
this.scopePreview.set(preview);
}
selectType(type: ExceptionType): void {
this.updateDraft('type', type);
}
}
enableRecheckPolicy(): void {
if (this.draft().recheckPolicy) {
return;
}
this.draft.update((d) => ({
...d,
recheckPolicy: {
name: 'Default Recheck Policy',
isActive: true,
defaultAction: 'Warn',
conditions: [],
},
}));
}
disableRecheckPolicy(): void {
this.draft.update((d) => ({
...d,
recheckPolicy: null,
}));
}
updateRecheckPolicy<K extends keyof RecheckPolicyDraft>(key: K, value: RecheckPolicyDraft[K]): void {
const policy = this.draft().recheckPolicy;
if (!policy) return;
this.draft.update((d) => ({
...d,
recheckPolicy: {
...policy,
[key]: value,
},
}));
}
addRecheckCondition(): void {
if (!this.draft().recheckPolicy) {
this.enableRecheckPolicy();
}
const policy = this.draft().recheckPolicy;
if (!policy) return;
const condition: RecheckConditionForm = {
id: `cond-${++this.conditionCounter}`,
type: 'EPSSAbove',
threshold: null,
environmentScope: [],
action: policy.defaultAction,
};
this.updateRecheckPolicy('conditions', [...policy.conditions, condition]);
}
updateRecheckCondition(
conditionId: string,
updates: Partial<Omit<RecheckConditionForm, 'id'>>
): void {
const policy = this.draft().recheckPolicy;
if (!policy) return;
const updated = policy.conditions.map((condition) =>
condition.id === conditionId ? { ...condition, ...updates } : condition
);
this.updateRecheckPolicy('conditions', updated);
}
removeRecheckCondition(conditionId: string): void {
const policy = this.draft().recheckPolicy;
if (!policy) return;
this.updateRecheckPolicy(
'conditions',
policy.conditions.filter((condition) => condition.id !== conditionId)
);
}
updateEvidenceSubmission(hookId: string, updates: Partial<EvidenceSubmission>): void {
const hooks = this.evidenceHooks();
const hook = hooks.find((h) => h.hookId === hookId);
if (!hook) return;
this.draft.update((d) => {
const existing = d.evidenceSubmissions.find((s) => s.hookId === hookId);
const next: EvidenceSubmission = {
hookId,
type: hook.type,
reference: '',
content: '',
validationStatus: 'Missing',
...existing,
...updates,
};
next.validationStatus = this.resolveEvidenceStatus(hook, next);
const nextSubmissions = existing
? d.evidenceSubmissions.map((s) => (s.hookId === hookId ? next : s))
: [...d.evidenceSubmissions, next];
return {
...d,
evidenceSubmissions: nextSubmissions,
};
});
}
onEvidenceFileSelected(hookId: string, event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.updateEvidenceSubmission(hookId, {
fileName: file.name,
});
}
selectType(type: ExceptionType): void {
this.updateDraft('type', type);
}
selectTemplate(templateId: string): void {
const template = this.applicableTemplates().find((t) => t.id === templateId);
@@ -276,11 +588,11 @@ export class ExceptionWizardComponent {
this.cancel.emit();
}
onSubmit(): void {
if (this.canGoNext()) {
this.create.emit(this.draft());
}
}
onSubmit(): void {
if (this.canGoNext()) {
this.create.emit(this.draft());
}
}
formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
@@ -290,7 +602,37 @@ export class ExceptionWizardComponent {
});
}
onTagInput(event: Event): void {
this.newTag.set((event.target as HTMLInputElement).value);
}
}
onTagInput(event: Event): void {
this.newTag.set((event.target as HTMLInputElement).value);
}
resolveEvidenceStatus(
hook: EvidenceHookRequirement,
submission: EvidenceSubmission | null
): EvidenceValidationStatus {
if (!submission) return 'Missing';
const hasReference = submission.reference.trim().length > 0;
const hasContent = submission.content.trim().length > 0;
const hasFile = !!submission.fileName;
if (!hasReference && !hasContent && !hasFile) return 'Missing';
if (submission.validationStatus && submission.validationStatus !== 'Missing') {
return submission.validationStatus;
}
return hook.isMandatory ? 'Pending' : 'Pending';
}
requiresThreshold(type: RecheckConditionType): boolean {
return this.conditionTypeOptions.some((option) => option.type === type && option.requiresThreshold);
}
getConditionLabel(type: RecheckConditionType): string {
return this.conditionTypeOptions.find((option) => option.type === type)?.label ?? type;
}
getEvidenceLabel(type: EvidenceType): string {
return this.evidenceTypeOptions.find((option) => option.value === type)?.label ?? type;
}
}

View File

@@ -0,0 +1,98 @@
<div class="triage-inbox">
<!-- Header -->
<div class="inbox-header">
<h1>Triage Inbox</h1>
<div class="inbox-stats">
<span>{{ totalPaths() }} total paths</span>
<span *ngIf="filter()">· {{ filteredCount() }} filtered</span>
</div>
<select
class="filter-select"
[ngModel]="filter()"
(ngModelChange)="onFilterChange($event)"
>
<option [ngValue]="null">All paths</option>
<option *ngFor="let opt of filterOptions" [ngValue]="opt">
{{ opt || 'All' }}
</option>
</select>
</div>
<!-- 3-Pane Layout -->
<div class="inbox-content">
<!-- Left: Path List -->
<div class="path-list">
<div *ngIf="loading()" class="loading">Loading...</div>
<div *ngIf="error()" class="error">{{ error() }}</div>
<div
*ngFor="let path of paths()"
class="path-item"
[class.selected]="selectedPath()?.pathId === path.pathId"
(click)="selectPath(path)"
>
<div class="path-package">{{ path.package.name }}</div>
<div class="path-symbol">{{ path.symbol.fullyQualifiedName }}</div>
<div class="path-meta">
<span [class]="getReachStatusClass(path.reachability)">
{{ getReachStatusLabel(path.reachability) }}
</span>
<span class="cve-count">{{ path.cveIds.length }} CVEs</span>
</div>
</div>
</div>
<!-- Center: Path Detail -->
<div class="path-detail" *ngIf="selectedPath() as path">
<h2>{{ path.package.name }} @ {{ path.package.version }}</h2>
<div class="detail-section">
<h3>Vulnerable Symbol</h3>
<code>{{ path.symbol.fullyQualifiedName }}</code>
<p *ngIf="path.symbol.sourceFile">
{{ path.symbol.sourceFile }}:{{ path.symbol.lineNumber }}
</p>
</div>
<div class="detail-section">
<h3>Entry Point</h3>
<div>{{ path.entryPoint.name }} ({{ path.entryPoint.type }})</div>
</div>
<div class="detail-section">
<h3>CVEs</h3>
<div class="cve-list">
<span *ngFor="let cve of path.cveIds" class="cve-badge">{{ cve }}</span>
</div>
</div>
<div class="detail-section">
<h3>Risk Score</h3>
<div class="risk-grid">
<div class="risk-item critical">
Critical: {{ path.riskScore.criticalCount }}
</div>
<div class="risk-item high">
High: {{ path.riskScore.highCount }}
</div>
<div class="risk-item medium">
Medium: {{ path.riskScore.mediumCount }}
</div>
<div class="risk-item low">
Low: {{ path.riskScore.lowCount }}
</div>
</div>
</div>
</div>
<!-- Right: Evidence / Graph -->
<div class="path-evidence" *ngIf="selectedPath() as path">
<h3>Evidence</h3>
<div class="evidence-list">
<div
*ngFor="let item of path.evidence.items"
class="evidence-item"
>
<strong>{{ item.type }}</strong>
<p>{{ item.description }}</p>
<small>Source: {{ item.source }} ({{ item.weight | number:'1.2-2' }})</small>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,191 @@
.triage-inbox {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
}
.inbox-header {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5e7eb;
h1 {
margin: 0;
font-size: 1.5rem;
}
.inbox-stats {
flex: 1;
font-size: 0.875rem;
color: #64748b;
}
.filter-select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
}
.inbox-content {
display: grid;
grid-template-columns: 300px 1fr 350px;
gap: 1rem;
flex: 1;
overflow: hidden;
}
.path-list {
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 0.5rem;
}
.path-item {
padding: 0.75rem;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f9fafb;
}
&.selected {
background: #eff6ff;
border-left: 3px solid #3b82f6;
}
.path-package {
font-weight: 600;
color: #1e293b;
}
.path-symbol {
font-size: 0.75rem;
color: #64748b;
font-family: monospace;
}
.path-meta {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.75rem;
}
}
.path-detail,
.path-evidence {
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
}
h3 {
margin: 1rem 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
color: #64748b;
}
}
.detail-section {
margin-bottom: 1.5rem;
}
.cve-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.cve-badge {
padding: 0.25rem 0.5rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.risk-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.risk-item {
padding: 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
&.critical {
background: #fef2f2;
color: #991b1b;
}
&.high {
background: #fff7ed;
color: #9a3412;
}
&.medium {
background: #fefce8;
color: #854d0e;
}
&.low {
background: #f0fdf4;
color: #166534;
}
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.evidence-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.375rem;
strong {
display: block;
margin-bottom: 0.25rem;
color: #1e293b;
}
p {
margin: 0.25rem 0;
font-size: 0.875rem;
color: #475569;
}
small {
font-size: 0.75rem;
color: #64748b;
}
}
.loading,
.error {
padding: 1rem;
text-align: center;
}
.error {
color: #dc2626;
}

View File

@@ -0,0 +1,126 @@
import { Component, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatRadioModule } from '@angular/material/radio';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { VexConflict } from '../vex-conflict-studio.component';
export interface OverrideRequest {
preferredStatementId: string;
reason: string;
}
@Component({
selector: 'stella-override-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
MatRadioModule,
MatFormFieldModule,
MatInputModule,
MatIconModule
],
template: `
<h2 mat-dialog-title>Set Manual Override</h2>
<mat-dialog-content>
<p>Select which VEX statement should be used instead of the automatic merge result.</p>
<mat-radio-group [(ngModel)]="selectedStatementId" class="statement-options">
<mat-radio-button
*ngFor="let stmt of data.conflict.statements"
[value]="stmt.id"
>
<span class="statement-option">
<strong>{{ stmt.source }}</strong>: {{ stmt.status }}
<span class="timestamp">({{ stmt.timestamp | date:'short' }})</span>
</span>
</mat-radio-button>
</mat-radio-group>
<mat-form-field class="reason-field" appearance="outline">
<mat-label>Reason for override</mat-label>
<textarea
matInput
[(ngModel)]="reason"
rows="3"
placeholder="Explain why you're overriding the automatic merge..."
required
></textarea>
<mat-hint>This will be recorded in the audit log</mat-hint>
</mat-form-field>
<div class="warning-box">
<mat-icon>warning</mat-icon>
<span>Manual overrides bypass the trust-based merge logic. Use with caution.</span>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button
mat-flat-button
color="primary"
[disabled]="!selectedStatementId || !reason"
(click)="confirm()"
>
Apply Override
</button>
</mat-dialog-actions>
`,
styles: [`
.statement-options {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0;
}
.statement-option {
.timestamp {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.875rem;
}
}
.reason-field {
width: 100%;
margin-top: 16px;
}
.warning-box {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--md-sys-color-error-container);
border-radius: 8px;
margin-top: 16px;
mat-icon {
color: var(--md-sys-color-error);
}
}
`]
})
export class OverrideDialogComponent {
selectedStatementId: string = '';
reason: string = '';
constructor(
@Inject(MAT_DIALOG_DATA) public data: { conflict: VexConflict },
private dialogRef: MatDialogRef<OverrideDialogComponent>
) {}
confirm(): void {
this.dialogRef.close({
preferredStatementId: this.selectedStatementId,
reason: this.reason
} as OverrideRequest);
}
}

View File

@@ -0,0 +1,180 @@
<div class="vex-conflict-studio">
<header class="studio-header">
<h2>VEX Conflict Studio</h2>
<div class="filters">
<mat-select
placeholder="Filter by status"
(selectionChange)="filterStatus.set($event.value)"
>
<mat-option [value]="null">All</mat-option>
<mat-option value="affected">Affected</mat-option>
<mat-option value="not_affected">Not Affected</mat-option>
<mat-option value="fixed">Fixed</mat-option>
<mat-option value="under_investigation">Under Investigation</mat-option>
</mat-select>
<mat-select
[value]="sortBy()"
(selectionChange)="sortBy.set($event.value)"
>
<mat-option value="timestamp">Sort by Time</mat-option>
<mat-option value="source">Sort by Source</mat-option>
</mat-select>
</div>
</header>
<div class="studio-content">
<!-- Conflict List -->
<aside class="conflict-list">
<mat-card
*ngFor="let conflict of filteredConflicts()"
[class.selected]="selectedConflict()?.id === conflict.id"
(click)="selectConflict(conflict)"
>
<mat-card-header>
<mat-icon mat-card-avatar [class]="getStatusClass(conflict.mergeResult.winningStatement.status)">
{{ getStatusIcon(conflict.mergeResult.winningStatement.status) }}
</mat-icon>
<mat-card-title>{{ conflict.vulnId }}</mat-card-title>
<mat-card-subtitle>{{ conflict.statements.length }} statements</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<mat-chip *ngIf="conflict.hasManualOverride" class="override-chip">
Override Active
</mat-chip>
</mat-card-content>
</mat-card>
<div class="empty-state" *ngIf="filteredConflicts().length === 0">
<mat-icon>check_circle</mat-icon>
<p>No VEX conflicts found</p>
</div>
</aside>
<!-- Conflict Detail -->
<main class="conflict-detail" *ngIf="selectedConflict() as conflict">
<h3>{{ conflict.vulnId }} - {{ conflict.productId }}</h3>
<!-- Side-by-side statements -->
<div class="statements-comparison">
<div
class="statement-card"
*ngFor="let stmt of conflict.statements"
[class.winner]="stmt.id === conflict.mergeResult.winningStatement.id"
>
<div class="statement-header">
<mat-icon [class]="getStatusClass(stmt.status)">
{{ getStatusIcon(stmt.status) }}
</mat-icon>
<span class="status">{{ stmt.status | uppercase }}</span>
<mat-chip *ngIf="stmt.id === conflict.mergeResult.winningStatement.id">
Winner
</mat-chip>
</div>
<div class="statement-source">
<strong>Source:</strong> {{ stmt.source }}
</div>
<div class="statement-issuer" *ngIf="stmt.issuer">
<strong>Issuer:</strong> {{ stmt.issuer }}
</div>
<div class="statement-time">
<strong>Timestamp:</strong> {{ stmt.timestamp | date:'medium' }}
</div>
<!-- Signature info -->
<div class="statement-signature" *ngIf="stmt.signature">
<mat-icon [class.valid]="stmt.signature.valid" [class.invalid]="!stmt.signature.valid">
{{ stmt.signature.valid ? 'verified' : 'dangerous' }}
</mat-icon>
<span>
Signed by {{ stmt.signature.signedBy }}
{{ stmt.signature.valid ? '' : '(Invalid)' }}
</span>
</div>
<div class="statement-justification" *ngIf="stmt.justification">
<strong>Justification:</strong>
<p>{{ stmt.justification }}</p>
</div>
</div>
</div>
<mat-divider></mat-divider>
<!-- Merge explanation -->
<div class="merge-explanation">
<h4>Merge Decision</h4>
<div class="merge-trace">
<div class="trace-row">
<span class="label">Resolution:</span>
<mat-chip [class]="'reason-' + conflict.mergeResult.reason">
{{ getReasonLabel(conflict.mergeResult.reason) }}
</mat-chip>
</div>
<div class="trace-row">
<span class="label">{{ conflict.mergeResult.trace.leftSource }}:</span>
<span>{{ conflict.mergeResult.trace.leftStatus }}</span>
<span class="trust">Trust: {{ getTrustPercent(conflict.mergeResult.trace.leftTrust) }}</span>
</div>
<div class="trace-row">
<span class="label">{{ conflict.mergeResult.trace.rightSource }}:</span>
<span>{{ conflict.mergeResult.trace.rightStatus }}</span>
<span class="trust">Trust: {{ getTrustPercent(conflict.mergeResult.trace.rightTrust) }}</span>
</div>
<div class="trace-explanation">
{{ conflict.mergeResult.trace.explanation }}
</div>
</div>
<!-- K4 Lattice Visualization -->
<div class="lattice-viz">
<h5>K4 Lattice Position</h5>
<stella-lattice-diagram
[leftValue]="conflict.mergeResult.trace.leftStatus"
[rightValue]="conflict.mergeResult.trace.rightStatus"
[result]="conflict.mergeResult.trace.resultStatus"
></stella-lattice-diagram>
</div>
</div>
<mat-divider></mat-divider>
<!-- Override section -->
<div class="override-section">
<h4>Manual Override</h4>
<div *ngIf="conflict.hasManualOverride" class="active-override">
<p>
Override active: Using
<strong>{{ conflict.overrideStatement?.source }}</strong>
({{ conflict.overrideStatement?.status }})
</p>
<button mat-stroked-button color="warn" (click)="removeOverride(conflict)">
<mat-icon>undo</mat-icon>
Remove Override
</button>
</div>
<div *ngIf="!conflict.hasManualOverride">
<p>No override active. The automatic merge decision is being used.</p>
<button mat-stroked-button (click)="openOverrideDialog(conflict)">
<mat-icon>edit</mat-icon>
Set Override
</button>
</div>
</div>
</main>
<div class="no-selection" *ngIf="!selectedConflict()">
<mat-icon>touch_app</mat-icon>
<p>Select a conflict to view details</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,235 @@
.vex-conflict-studio {
display: flex;
flex-direction: column;
height: 100%;
}
.studio-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: var(--md-sys-color-surface-container);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
h2 {
margin: 0;
}
.filters {
display: flex;
gap: 16px;
}
}
.studio-content {
display: flex;
flex: 1;
overflow: hidden;
}
.conflict-list {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
padding: 16px;
border-right: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface);
mat-card {
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
box-shadow: var(--md-sys-elevation-2);
}
&.selected {
border: 2px solid var(--md-sys-color-primary);
}
}
.override-chip {
background: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
}
}
.conflict-detail {
flex: 1;
overflow-y: auto;
padding: 24px;
h3 {
margin: 0 0 24px;
}
}
.statements-comparison {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.statement-card {
padding: 16px;
background: var(--md-sys-color-surface-variant);
border-radius: 8px;
border: 2px solid transparent;
&.winner {
border-color: var(--md-sys-color-primary);
background: var(--md-sys-color-primary-container);
}
.statement-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
.status {
font-weight: 600;
}
}
.statement-source,
.statement-issuer,
.statement-time {
margin-bottom: 8px;
font-size: 0.875rem;
}
.statement-signature {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--md-sys-color-outline-variant);
mat-icon.valid {
color: var(--md-sys-color-tertiary);
}
mat-icon.invalid {
color: var(--md-sys-color-error);
}
}
.statement-justification {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--md-sys-color-outline-variant);
p {
margin: 4px 0 0;
font-size: 0.875rem;
font-style: italic;
}
}
}
.status-affected {
color: var(--md-sys-color-error);
}
.status-not-affected {
color: var(--md-sys-color-tertiary);
}
.status-fixed {
color: var(--md-sys-color-primary);
}
.status-under-investigation {
color: var(--md-sys-color-secondary);
}
.merge-explanation {
margin: 24px 0;
h4 {
margin: 0 0 16px;
}
}
.merge-trace {
background: var(--md-sys-color-surface-variant);
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
.trace-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.label {
font-weight: 500;
min-width: 120px;
}
.trust {
margin-left: auto;
color: var(--md-sys-color-on-surface-variant);
}
}
.trace-explanation {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--md-sys-color-outline-variant);
font-style: italic;
}
}
.reason-trust_weight {
background: var(--md-sys-color-primary-container);
}
.reason-freshness {
background: var(--md-sys-color-tertiary-container);
}
.reason-lattice_position {
background: var(--md-sys-color-secondary-container);
}
.reason-tie {
background: var(--md-sys-color-surface-variant);
}
.lattice-viz {
margin-top: 24px;
h5 {
margin: 0 0 16px;
}
}
.override-section {
margin-top: 24px;
.active-override {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background: var(--md-sys-color-error-container);
border-radius: 8px;
}
}
.no-selection,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--md-sys-color-on-surface-variant);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
margin-bottom: 16px;
}
}

View File

@@ -0,0 +1,201 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { VexConflictStudioComponent, VexConflict, VexStatement } from './vex-conflict-studio.component';
import { VexConflictService } from '../../core/services/vex-conflict.service';
describe('VexConflictStudioComponent', () => {
let component: VexConflictStudioComponent;
let fixture: ComponentFixture<VexConflictStudioComponent>;
let mockVexService: jasmine.SpyObj<VexConflictService>;
const mockStatement1: VexStatement = {
id: 'stmt-1',
vulnId: 'CVE-2024-1234',
productId: 'pkg:npm/express@4.17.1',
status: 'affected',
source: 'redhat-csaf',
issuer: 'Red Hat',
timestamp: new Date('2024-01-01'),
signature: {
signedBy: 'redhat@example.com',
signedAt: new Date('2024-01-01'),
valid: true
},
justification: 'Vulnerable code is present'
};
const mockStatement2: VexStatement = {
id: 'stmt-2',
vulnId: 'CVE-2024-1234',
productId: 'pkg:npm/express@4.17.1',
status: 'not_affected',
source: 'cisco-csaf',
issuer: 'Cisco',
timestamp: new Date('2024-01-02'),
signature: {
signedBy: 'cisco@example.com',
signedAt: new Date('2024-01-02'),
valid: true
},
justification: 'Vulnerable code not present'
};
const mockConflict: VexConflict = {
id: 'conflict-1',
vulnId: 'CVE-2024-1234',
productId: 'pkg:npm/express@4.17.1',
statements: [mockStatement1, mockStatement2],
mergeResult: {
winningStatement: mockStatement1,
reason: 'trust_weight',
trace: {
leftSource: 'redhat-csaf',
rightSource: 'cisco-csaf',
leftStatus: 'affected',
rightStatus: 'not_affected',
leftTrust: 0.95,
rightTrust: 0.85,
resultStatus: 'affected',
explanation: 'Red Hat has higher trust score'
}
},
hasManualOverride: false
};
beforeEach(async () => {
mockVexService = jasmine.createSpyObj('VexConflictService', [
'getConflicts',
'applyOverride',
'removeOverride'
]);
await TestBed.configureTestingModule({
imports: [VexConflictStudioComponent, NoopAnimationsModule],
providers: [
{ provide: VexConflictService, useValue: mockVexService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: {
get: () => null
},
queryParamMap: {
get: () => null
}
}
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(VexConflictStudioComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load conflicts on init', async () => {
mockVexService.getConflicts.and.returnValue(Promise.resolve([mockConflict]));
await component.ngOnInit();
expect(component.conflicts().length).toBe(1);
expect(component.conflicts()[0].id).toBe('conflict-1');
});
it('should select a conflict', () => {
component.selectConflict(mockConflict);
expect(component.selectedConflict()).toBe(mockConflict);
});
it('should get correct status icon', () => {
expect(component.getStatusIcon('affected')).toBe('error');
expect(component.getStatusIcon('not_affected')).toBe('check_circle');
expect(component.getStatusIcon('fixed')).toBe('build_circle');
expect(component.getStatusIcon('under_investigation')).toBe('help');
});
it('should get correct status class', () => {
expect(component.getStatusClass('affected')).toBe('status-affected');
expect(component.getStatusClass('not_affected')).toBe('status-not-affected');
});
it('should format trust percentage', () => {
expect(component.getTrustPercent(0.95)).toBe('95%');
expect(component.getTrustPercent(0.50)).toBe('50%');
});
it('should get correct reason label', () => {
expect(component.getReasonLabel('trust_weight')).toBe('Higher Trust');
expect(component.getReasonLabel('freshness')).toBe('More Recent');
expect(component.getReasonLabel('lattice_position')).toBe('K4 Lattice');
expect(component.getReasonLabel('tie')).toBe('Tie (First Used)');
});
it('should filter conflicts by status', () => {
component.conflicts.set([mockConflict]);
component.filterStatus.set('affected');
const filtered = component.filteredConflicts();
expect(filtered.length).toBe(1);
});
it('should filter out conflicts not matching status', () => {
component.conflicts.set([mockConflict]);
component.filterStatus.set('fixed');
const filtered = component.filteredConflicts();
expect(filtered.length).toBe(0);
});
it('should sort conflicts by timestamp', () => {
const conflict2: VexConflict = {
...mockConflict,
id: 'conflict-2',
statements: [{
...mockStatement1,
timestamp: new Date('2024-02-01')
}]
};
component.conflicts.set([mockConflict, conflict2]);
component.sortBy.set('timestamp');
const sorted = component.filteredConflicts();
expect(sorted[0].id).toBe('conflict-2');
expect(sorted[1].id).toBe('conflict-1');
});
it('should apply override', async () => {
mockVexService.applyOverride.and.returnValue(Promise.resolve());
mockVexService.getConflicts.and.returnValue(Promise.resolve([mockConflict]));
await component.applyOverride(mockConflict, {
preferredStatementId: 'stmt-2',
reason: 'Test override'
});
expect(mockVexService.applyOverride).toHaveBeenCalledWith(
'conflict-1',
jasmine.objectContaining({ preferredStatementId: 'stmt-2' })
);
});
it('should remove override', async () => {
mockVexService.removeOverride.and.returnValue(Promise.resolve());
mockVexService.getConflicts.and.returnValue(Promise.resolve([mockConflict]));
await component.removeOverride(mockConflict);
expect(mockVexService.removeOverride).toHaveBeenCalledWith('conflict-1');
});
});

View File

@@ -0,0 +1,179 @@
import { Component, OnInit, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatDividerModule } from '@angular/material/divider';
import { MatSelectModule } from '@angular/material/select';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { ActivatedRoute } from '@angular/router';
import { LatticeDiagramComponent } from '../../shared/components/lattice-diagram/lattice-diagram.component';
import { OverrideDialogComponent, OverrideRequest } from './override-dialog/override-dialog.component';
import { VexConflictService } from '../../core/services/vex-conflict.service';
export interface VexStatement {
id: string;
vulnId: string;
productId: string;
status: 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
source: string;
issuer?: string;
timestamp: Date;
signature?: {
signedBy: string;
signedAt: Date;
valid: boolean;
};
justification?: string;
actionStatement?: string;
}
export interface VexConflict {
id: string;
vulnId: string;
productId: string;
statements: VexStatement[];
mergeResult: {
winningStatement: VexStatement;
reason: 'trust_weight' | 'freshness' | 'lattice_position' | 'tie';
trace: MergeTrace;
};
hasManualOverride: boolean;
overrideStatement?: VexStatement;
}
export interface MergeTrace {
leftSource: string;
rightSource: string;
leftStatus: string;
rightStatus: string;
leftTrust: number;
rightTrust: number;
resultStatus: string;
explanation: string;
}
@Component({
selector: 'stella-vex-conflict-studio',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatDividerModule,
MatSelectModule,
MatDialogModule,
LatticeDiagramComponent
],
templateUrl: './vex-conflict-studio.component.html',
styleUrls: ['./vex-conflict-studio.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VexConflictStudioComponent implements OnInit {
conflicts = signal<VexConflict[]>([]);
selectedConflict = signal<VexConflict | null>(null);
filterStatus = signal<string | null>(null);
sortBy = signal<'timestamp' | 'severity' | 'source'>('timestamp');
filteredConflicts = computed(() => {
let result = this.conflicts();
const status = this.filterStatus();
if (status) {
result = result.filter(c =>
c.statements.some(s => s.status === status)
);
}
const sort = this.sortBy();
return result.sort((a, b) => {
switch (sort) {
case 'timestamp':
return new Date(b.statements[0].timestamp).getTime() -
new Date(a.statements[0].timestamp).getTime();
case 'source':
return a.statements[0].source.localeCompare(b.statements[0].source);
default:
return 0;
}
});
});
constructor(
private route: ActivatedRoute,
private dialog: MatDialog,
private vexService: VexConflictService
) {}
async ngOnInit(): Promise<void> {
const productId = this.route.snapshot.paramMap.get('productId');
const vulnId = this.route.snapshot.queryParamMap.get('vulnId');
await this.loadConflicts(productId, vulnId);
}
async loadConflicts(productId?: string | null, vulnId?: string | null): Promise<void> {
const conflicts = await this.vexService.getConflicts({
productId: productId ?? undefined,
vulnId: vulnId ?? undefined
});
this.conflicts.set(conflicts);
}
selectConflict(conflict: VexConflict): void {
this.selectedConflict.set(conflict);
}
getStatusIcon(status: string): string {
switch (status) {
case 'affected': return 'error';
case 'not_affected': return 'check_circle';
case 'fixed': return 'build_circle';
case 'under_investigation': return 'help';
default: return 'help_outline';
}
}
getStatusClass(status: string): string {
return `status-${status.replace('_', '-')}`;
}
getTrustPercent(trust: number): string {
return `${(trust * 100).toFixed(0)}%`;
}
getReasonLabel(reason: string): string {
switch (reason) {
case 'trust_weight': return 'Higher Trust';
case 'freshness': return 'More Recent';
case 'lattice_position': return 'K4 Lattice';
case 'tie': return 'Tie (First Used)';
default: return reason;
}
}
async openOverrideDialog(conflict: VexConflict): Promise<void> {
const dialogRef = this.dialog.open(OverrideDialogComponent, {
width: '600px',
data: { conflict }
});
const result = await dialogRef.afterClosed().toPromise();
if (result) {
await this.applyOverride(conflict, result);
}
}
async applyOverride(conflict: VexConflict, override: OverrideRequest): Promise<void> {
await this.vexService.applyOverride(conflict.id, override);
await this.loadConflicts();
}
async removeOverride(conflict: VexConflict): Promise<void> {
await this.vexService.removeOverride(conflict.id);
await this.loadConflicts();
}
}

View File

@@ -0,0 +1,94 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { VexStatement } from '../../../features/vex-studio/vex-conflict-studio.component';
interface EvidenceRequirement {
label: string;
key: string;
description?: string;
}
@Component({
selector: 'stella-evidence-checklist',
standalone: true,
imports: [CommonModule, MatIconModule, MatListModule],
template: `
<div class="evidence-checklist">
<h5>Required Evidence for "{{ status }}"</h5>
<mat-list>
<mat-list-item *ngFor="let item of getRequiredEvidence(status)">
<mat-icon matListItemIcon [class.met]="item.met" [class.unmet]="!item.met">
{{ item.met ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
<span matListItemTitle>{{ item.label }}</span>
<span matListItemLine *ngIf="item.description">{{ item.description }}</span>
</mat-list-item>
</mat-list>
</div>
`,
styles: [`
.evidence-checklist {
margin-top: 16px;
padding: 16px;
background: var(--md-sys-color-surface-variant);
border-radius: 8px;
}
h5 {
margin: 0 0 12px;
}
mat-icon.met {
color: var(--md-sys-color-tertiary);
}
mat-icon.unmet {
color: var(--md-sys-color-outline);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceChecklistComponent {
@Input() status!: string;
@Input() statement?: VexStatement;
private readonly requirements: Record<string, EvidenceRequirement[]> = {
'not_affected': [
{ label: 'Justification provided', key: 'justification' },
{ label: 'Impact statement', key: 'impactStatement' },
{ label: 'Signed by trusted issuer', key: 'signature' }
],
'affected': [
{ label: 'Action statement', key: 'actionStatement' },
{ label: 'Severity assessment', key: 'severity' }
],
'fixed': [
{ label: 'Fixed version specified', key: 'fixedVersion' },
{ label: 'Fix commit reference', key: 'fixCommit' }
],
'under_investigation': [
{ label: 'Investigation timeline', key: 'timeline' }
]
};
getRequiredEvidence(status: string): { label: string; met: boolean; description?: string }[] {
const reqs = this.requirements[status] ?? [];
return reqs.map(req => ({
label: req.label,
met: this.checkRequirement(req),
description: req.description
}));
}
private checkRequirement(req: EvidenceRequirement): boolean {
if (!this.statement) return false;
switch (req.key) {
case 'justification': return !!this.statement.justification;
case 'signature': return !!this.statement.signature?.valid;
case 'actionStatement': return !!this.statement.actionStatement;
default: return false;
}
}
}

View File

@@ -1,404 +1,547 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
computed,
signal,
} from '@angular/core';
export interface ExceptionBadgeData {
readonly exceptionId: string;
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly name: string;
readonly endDate: string;
readonly justificationSummary?: string;
readonly approvedBy?: string;
}
@Component({
selector: 'app-exception-badge',
standalone: true,
imports: [CommonModule],
template: `
<div
class="exception-badge"
[class]="badgeClass()"
[class.exception-badge--expanded]="expanded()"
(click)="toggleExpanded()"
(keydown.enter)="toggleExpanded()"
(keydown.space)="toggleExpanded(); $event.preventDefault()"
tabindex="0"
role="button"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="ariaLabel()"
>
<!-- Collapsed View -->
<div class="exception-badge__summary">
<span class="exception-badge__icon">✓</span>
<span class="exception-badge__label">Excepted</span>
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
{{ countdownText() }}
</span>
</div>
<!-- Expanded View -->
<div class="exception-badge__details" *ngIf="expanded()">
<div class="exception-badge__header">
<span class="exception-badge__name">{{ data.name }}</span>
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
{{ statusLabel() }}
</span>
</div>
<div class="exception-badge__info">
<div class="exception-badge__row">
<span class="exception-badge__row-label">Severity:</span>
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
{{ data.severity }}
</span>
</div>
<div class="exception-badge__row">
<span class="exception-badge__row-label">Expires:</span>
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
{{ formatDate(data.endDate) }}
</span>
</div>
<div class="exception-badge__row" *ngIf="data.approvedBy">
<span class="exception-badge__row-label">Approved by:</span>
<span>{{ data.approvedBy }}</span>
</div>
</div>
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
<span class="exception-badge__justification-label">Justification:</span>
<p>{{ data.justificationSummary }}</p>
</div>
<div class="exception-badge__actions">
<button
type="button"
class="exception-badge__action"
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
>
View Details
</button>
<button
type="button"
class="exception-badge__action exception-badge__action--secondary"
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
>
Explain
</button>
</div>
</div>
</div>
`,
styles: [`
.exception-badge {
display: inline-flex;
flex-direction: column;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8125rem;
&:hover {
background: #ede9fe;
border-color: #a78bfa;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
&--expanded {
min-width: 280px;
}
&--expired {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.exception-badge__summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__label {
color: #6d28d9;
font-weight: 500;
}
.exception-badge__countdown {
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.exception-badge__details {
padding: 0.75rem;
border-top: 1px solid #c4b5fd;
}
.exception-badge__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.exception-badge__name {
font-weight: 600;
color: #1e293b;
}
.exception-badge__status {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
&--approved {
background: #dcfce7;
color: #166534;
}
&--pending_review {
background: #fef3c7;
color: #92400e;
}
&--draft {
background: #f1f5f9;
color: #475569;
}
&--expired {
background: #f1f5f9;
color: #6b7280;
}
&--revoked {
background: #fce7f3;
color: #9d174d;
}
}
.exception-badge__info {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.exception-badge__row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.exception-badge__row-label {
color: #64748b;
min-width: 80px;
}
.exception-badge__severity {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.exception-badge__expiry {
color: #1e293b;
&--soon {
color: #dc2626;
font-weight: 500;
}
}
.exception-badge__justification {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
}
.exception-badge__justification-label {
display: block;
font-size: 0.6875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.exception-badge__justification p {
margin: 0;
font-size: 0.75rem;
color: #475569;
line-height: 1.4;
}
.exception-badge__actions {
display: flex;
gap: 0.5rem;
}
.exception-badge__action {
flex: 1;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #7c3aed;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #6d28d9;
}
&--secondary {
background: white;
color: #7c3aed;
border: 1px solid #c4b5fd;
&:hover {
background: #f3e8ff;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
@Input({ required: true }) data!: ExceptionBadgeData;
@Input() compact = false;
@Output() readonly viewDetails = new EventEmitter<string>();
@Output() readonly explain = new EventEmitter<string>();
readonly expanded = signal(false);
private countdownInterval?: ReturnType<typeof setInterval>;
private readonly now = signal(new Date());
readonly countdownText = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const diffMs = endDate.getTime() - current.getTime();
if (diffMs <= 0) return 'Expired';
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${minutes}m`;
});
readonly isExpiringSoon = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
});
readonly badgeClass = computed(() => {
const classes = ['exception-badge'];
if (this.data.status === 'expired') classes.push('exception-badge--expired');
return classes.join(' ');
});
readonly statusLabel = computed(() => {
const labels: Record<string, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
return labels[this.data.status] || this.data.status;
});
readonly ariaLabel = computed(() => {
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
});
ngOnInit(): void {
if (this.isExpiringSoon()) {
this.countdownInterval = setInterval(() => {
this.now.set(new Date());
}, 60000); // Update every minute
}
}
ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
toggleExpanded(): void {
if (!this.compact) {
this.expanded.set(!this.expanded());
}
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
computed,
inject,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { EXCEPTION_API, ExceptionApi } from '../../core/api/exception.client';
import { Exception as ContractException } from '../../core/api/exception.contract.models';
export interface ExceptionBadgeData {
readonly exceptionId: string;
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly name: string;
readonly endDate: string;
readonly justificationSummary?: string;
readonly approvedBy?: string;
}
export interface ExceptionBadgeContext {
readonly vulnId?: string;
readonly componentPurl?: string;
readonly assetId?: string;
readonly tenantId?: string;
}
@Component({
selector: 'app-exception-badge',
standalone: true,
imports: [CommonModule],
template: `
@if (resolvedData()) {
<div
class="exception-badge"
[class]="badgeClass()"
[class.exception-badge--expanded]="expanded()"
(click)="toggleExpanded()"
(keydown.enter)="toggleExpanded()"
(keydown.space)="toggleExpanded(); $event.preventDefault()"
tabindex="0"
role="button"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="ariaLabel()"
>
<!-- Collapsed View -->
<div class="exception-badge__summary">
<span class="exception-badge__icon">バ"</span>
<span class="exception-badge__label">Excepted</span>
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
{{ countdownText() }}
</span>
</div>
<!-- Expanded View -->
<div class="exception-badge__details" *ngIf="expanded()">
<div class="exception-badge__header">
<span class="exception-badge__name">{{ resolvedData()?.name }}</span>
<span class="exception-badge__status exception-badge__status--{{ resolvedData()?.status }}">
{{ statusLabel() }}
</span>
</div>
<div class="exception-badge__info">
<div class="exception-badge__row">
<span class="exception-badge__row-label">Severity:</span>
<span class="exception-badge__severity exception-badge__severity--{{ resolvedData()?.severity }}">
{{ resolvedData()?.severity }}
</span>
</div>
<div class="exception-badge__row">
<span class="exception-badge__row-label">Expires:</span>
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
{{ formatDate(resolvedData()?.endDate ?? '') }}
</span>
</div>
<div class="exception-badge__row" *ngIf="resolvedData()?.approvedBy">
<span class="exception-badge__row-label">Approved by:</span>
<span>{{ resolvedData()?.approvedBy }}</span>
</div>
</div>
<div class="exception-badge__justification" *ngIf="resolvedData()?.justificationSummary">
<span class="exception-badge__justification-label">Justification:</span>
<p>{{ resolvedData()?.justificationSummary }}</p>
</div>
<div class="exception-badge__actions">
<button
type="button"
class="exception-badge__action"
(click)="handleViewDetails($event)"
>
View Details
</button>
<button
type="button"
class="exception-badge__action exception-badge__action--secondary"
(click)="handleExplain($event)"
>
Explain
</button>
</div>
</div>
</div>
}
`,
styles: [`
.exception-badge {
display: inline-flex;
flex-direction: column;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8125rem;
&:hover {
background: #ede9fe;
border-color: #a78bfa;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
&--expanded {
min-width: 280px;
}
&--expired {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.exception-badge__summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__label {
color: #6d28d9;
font-weight: 500;
}
.exception-badge__countdown {
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.exception-badge__details {
padding: 0.75rem;
border-top: 1px solid #c4b5fd;
}
.exception-badge__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.exception-badge__name {
font-weight: 600;
color: #1e293b;
}
.exception-badge__status {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
&--approved {
background: #dcfce7;
color: #166534;
}
&--pending_review {
background: #fef3c7;
color: #92400e;
}
&--draft {
background: #f1f5f9;
color: #475569;
}
&--expired {
background: #f1f5f9;
color: #6b7280;
}
&--revoked {
background: #fce7f3;
color: #9d174d;
}
}
.exception-badge__info {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.exception-badge__row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.exception-badge__row-label {
color: #64748b;
min-width: 80px;
}
.exception-badge__severity {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.exception-badge__expiry {
color: #1e293b;
&--soon {
color: #dc2626;
font-weight: 500;
}
}
.exception-badge__justification {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
}
.exception-badge__justification-label {
display: block;
font-size: 0.6875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.exception-badge__justification p {
margin: 0;
font-size: 0.75rem;
color: #475569;
line-height: 1.4;
}
.exception-badge__actions {
display: flex;
gap: 0.5rem;
}
.exception-badge__action {
flex: 1;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #7c3aed;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #6d28d9;
}
&--secondary {
background: white;
color: #7c3aed;
border: 1px solid #c4b5fd;
&:hover {
background: #f3e8ff;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionBadgeComponent implements OnInit, OnDestroy, OnChanges {
private static readonly badgeCache = new Map<string, ExceptionBadgeData | null>();
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly router = inject(Router);
@Input() data?: ExceptionBadgeData;
@Input() context?: ExceptionBadgeContext;
@Input() compact = false;
@Output() readonly viewDetails = new EventEmitter<string>();
@Output() readonly explain = new EventEmitter<string>();
readonly resolvedData = signal<ExceptionBadgeData | null>(null);
readonly loading = signal(false);
readonly expanded = signal(false);
private countdownInterval?: ReturnType<typeof setInterval>;
private readonly now = signal(new Date());
readonly countdownText = computed(() => {
const data = this.resolvedData();
if (!data) return '';
const endDate = new Date(data.endDate);
const current = this.now();
const diffMs = endDate.getTime() - current.getTime();
if (diffMs <= 0) return 'Expired';
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${minutes}m`;
});
readonly isExpiringSoon = computed(() => {
const data = this.resolvedData();
if (!data) return false;
const endDate = new Date(data.endDate);
const current = this.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
});
readonly badgeClass = computed(() => {
const classes = ['exception-badge'];
if (this.resolvedData()?.status === 'expired') classes.push('exception-badge--expired');
return classes.join(' ');
});
readonly statusLabel = computed(() => {
const labels: Record<string, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
const status = this.resolvedData()?.status ?? 'draft';
return labels[status] || status;
});
readonly ariaLabel = computed(() => {
const data = this.resolvedData();
if (!data) return 'Exception badge';
return `Exception: ${data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
});
ngOnInit(): void {
this.resolveBadge();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['data'] || changes['context']) {
this.resolveBadge();
}
}
ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
toggleExpanded(): void {
if (!this.compact) {
this.expanded.set(!this.expanded());
}
}
handleViewDetails(event: Event): void {
event.stopPropagation();
const data = this.resolvedData();
if (!data) return;
this.viewDetails.emit(data.exceptionId);
this.router.navigate(['/exceptions', data.exceptionId]);
}
handleExplain(event: Event): void {
event.stopPropagation();
const data = this.resolvedData();
if (!data) return;
this.explain.emit(data.exceptionId);
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
private resolveBadge(): void {
if (this.data) {
this.resolvedData.set(this.data);
this.ensureCountdown();
return;
}
if (!this.context) {
this.resolvedData.set(null);
return;
}
void this.loadFromContext(this.context);
}
private ensureCountdown(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
if (this.isExpiringSoon()) {
this.countdownInterval = setInterval(() => {
this.now.set(new Date());
}, 60000);
}
}
private async loadFromContext(context: ExceptionBadgeContext): Promise<void> {
const cacheKey = this.getCacheKey(context);
if (ExceptionBadgeComponent.badgeCache.has(cacheKey)) {
this.resolvedData.set(ExceptionBadgeComponent.badgeCache.get(cacheKey) ?? null);
this.ensureCountdown();
return;
}
this.loading.set(true);
try {
const response = await firstValueFrom(this.api.listExceptions({ limit: 200, tenantId: context.tenantId }));
const match = response.items.find((exception) => this.matchesContext(exception, context)) ?? null;
const badgeData = match ? this.mapToBadgeData(match) : null;
ExceptionBadgeComponent.badgeCache.set(cacheKey, badgeData);
this.resolvedData.set(badgeData);
this.ensureCountdown();
} catch {
this.resolvedData.set(null);
} finally {
this.loading.set(false);
}
}
private getCacheKey(context: ExceptionBadgeContext): string {
return [
context.tenantId ?? '',
context.vulnId ?? '',
context.componentPurl ?? '',
context.assetId ?? '',
].join('|');
}
private matchesContext(exception: ContractException, context: ExceptionBadgeContext): boolean {
const scope = exception.scope;
if (context.tenantId && scope.tenantId && scope.tenantId !== context.tenantId) {
return false;
}
const vulnMatch =
!!context.vulnId &&
(scope.vulnIds?.includes(context.vulnId) || scope.cves?.includes(context.vulnId));
const purlMatch =
!!context.componentPurl && scope.componentPurls?.includes(context.componentPurl);
const assetMatch = !!context.assetId && scope.assetIds?.includes(context.assetId);
return vulnMatch || purlMatch || assetMatch;
}
private mapToBadgeData(exception: ContractException): ExceptionBadgeData {
return {
exceptionId: exception.exceptionId,
status: exception.status,
severity: exception.severity,
name: exception.displayName ?? exception.name,
endDate: exception.timebox.endDate,
justificationSummary: this.summarize(exception.justification.text),
approvedBy: exception.approvals?.at(0)?.approvedBy,
};
}
private summarize(text: string): string {
if (text.length <= 90) return text;
return `${text.slice(0, 90)}...`;
}
}

View File

@@ -0,0 +1,82 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LatticeDiagramComponent } from './lattice-diagram.component';
describe('LatticeDiagramComponent', () => {
let component: LatticeDiagramComponent;
let fixture: ComponentFixture<LatticeDiagramComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LatticeDiagramComponent]
}).compileComponents();
fixture = TestBed.createComponent(LatticeDiagramComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should highlight left value node', () => {
component.leftValue = 'affected';
component.rightValue = 'not_affected';
component.result = 'affected';
expect(component.getNodeClass('affected')).toContain('active-left');
expect(component.getNodeClass('affected')).toContain('active-result');
});
it('should highlight right value node', () => {
component.leftValue = 'affected';
component.rightValue = 'not_affected';
component.result = 'affected';
expect(component.getNodeClass('not_affected')).toContain('active-right');
});
it('should highlight result node', () => {
component.leftValue = 'fixed';
component.rightValue = 'not_affected';
component.result = 'affected';
expect(component.getNodeClass('affected')).toContain('active-result');
});
it('should generate correct join path', () => {
component.leftValue = 'affected';
component.rightValue = 'not_affected';
component.result = 'affected';
const path = component.getJoinPath();
expect(path).toContain('M 100 20');
expect(path).toContain('L 160 80');
});
it('should return empty path when values are missing', () => {
component.leftValue = undefined;
component.rightValue = 'not_affected';
component.result = 'affected';
const path = component.getJoinPath();
expect(path).toBe('');
});
it('should handle all lattice positions', () => {
const positions = ['affected', 'fixed', 'not_affected', 'under_investigation'];
positions.forEach(pos => {
component.leftValue = pos;
component.rightValue = pos;
component.result = pos;
const nodeClass = component.getNodeClass(pos);
expect(nodeClass).toContain('active-left');
expect(nodeClass).toContain('active-right');
expect(nodeClass).toContain('active-result');
});
});
});

View File

@@ -0,0 +1,189 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'stella-lattice-diagram',
standalone: true,
imports: [CommonModule],
template: `
<div class="lattice-diagram">
<svg viewBox="0 0 200 160" class="lattice-svg">
<!-- K4 Lattice structure -->
<!-- Top: Both/Affected -->
<circle cx="100" cy="20" r="12" [class]="getNodeClass('affected')"/>
<text x="100" y="24" text-anchor="middle" class="node-label">Both</text>
<!-- Middle: True/Fixed and False/NotAffected -->
<circle cx="40" cy="80" r="12" [class]="getNodeClass('fixed')"/>
<text x="40" y="84" text-anchor="middle" class="node-label">True</text>
<circle cx="160" cy="80" r="12" [class]="getNodeClass('not_affected')"/>
<text x="160" y="84" text-anchor="middle" class="node-label">False</text>
<!-- Bottom: Neither/UnderInvestigation -->
<circle cx="100" cy="140" r="12" [class]="getNodeClass('under_investigation')"/>
<text x="100" y="144" text-anchor="middle" class="node-label">Neither</text>
<!-- Edges -->
<line x1="100" y1="32" x2="40" y2="68" class="lattice-edge"/>
<line x1="100" y1="32" x2="160" y2="68" class="lattice-edge"/>
<line x1="40" y1="92" x2="100" y2="128" class="lattice-edge"/>
<line x1="160" y1="92" x2="100" y2="128" class="lattice-edge"/>
<!-- Highlight path -->
<path
*ngIf="leftValue && rightValue"
[attr.d]="getJoinPath()"
class="join-path"
fill="none"
stroke-width="3"
/>
</svg>
<div class="lattice-legend">
<div class="legend-item">
<span class="dot left"></span>
<span>{{ leftValue }} (Left)</span>
</div>
<div class="legend-item">
<span class="dot right"></span>
<span>{{ rightValue }} (Right)</span>
</div>
<div class="legend-item">
<span class="dot result"></span>
<span>{{ result }} (Result)</span>
</div>
</div>
<div class="lattice-explanation">
<p>
The K4 lattice determines merge outcomes:
<strong>Affected</strong> (Both) is highest,
<strong>Under Investigation</strong> (Neither) is lowest.
</p>
</div>
</div>
`,
styles: [`
.lattice-diagram {
padding: 16px;
}
.lattice-svg {
width: 100%;
max-width: 300px;
height: auto;
margin: 0 auto;
display: block;
}
circle {
fill: var(--md-sys-color-surface-variant);
stroke: var(--md-sys-color-outline);
stroke-width: 2;
transition: all 0.3s;
&.active-left {
fill: var(--md-sys-color-tertiary-container);
stroke: var(--md-sys-color-tertiary);
stroke-width: 3;
}
&.active-right {
fill: var(--md-sys-color-secondary-container);
stroke: var(--md-sys-color-secondary);
stroke-width: 3;
}
&.active-result {
fill: var(--md-sys-color-primary-container);
stroke: var(--md-sys-color-primary);
stroke-width: 4;
}
}
.node-label {
font-size: 10px;
fill: var(--md-sys-color-on-surface);
}
.lattice-edge {
stroke: var(--md-sys-color-outline-variant);
stroke-width: 1;
}
.join-path {
stroke: var(--md-sys-color-primary);
stroke-dasharray: 5,5;
animation: dash 1s linear infinite;
}
@keyframes dash {
to { stroke-dashoffset: -10; }
}
.lattice-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
&.left { background: var(--md-sys-color-tertiary); }
&.right { background: var(--md-sys-color-secondary); }
&.result { background: var(--md-sys-color-primary); }
}
}
}
.lattice-explanation {
margin-top: 16px;
font-size: 0.75rem;
color: var(--md-sys-color-on-surface-variant);
text-align: center;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LatticeDiagramComponent {
@Input() leftValue?: string;
@Input() rightValue?: string;
@Input() result?: string;
private readonly positions: Record<string, { x: number; y: number }> = {
'affected': { x: 100, y: 20 },
'fixed': { x: 40, y: 80 },
'not_affected': { x: 160, y: 80 },
'under_investigation': { x: 100, y: 140 }
};
getNodeClass(status: string): string {
const classes: string[] = [];
if (this.leftValue === status) classes.push('active-left');
if (this.rightValue === status) classes.push('active-right');
if (this.result === status) classes.push('active-result');
return classes.join(' ');
}
getJoinPath(): string {
if (!this.leftValue || !this.rightValue || !this.result) return '';
const left = this.positions[this.leftValue];
const right = this.positions[this.rightValue];
const res = this.positions[this.result];
if (!left || !right || !res) return '';
return `M ${left.x} ${left.y} L ${res.x} ${res.y} L ${right.x} ${right.y}`;
}
}

View File

@@ -0,0 +1,10 @@
<div class="view-mode-toggle" [matTooltip]="tooltipText()">
<mat-icon class="mode-icon">{{ isAuditor() ? 'verified_user' : 'speed' }}</mat-icon>
<mat-slide-toggle
[checked]="isAuditor()"
(change)="onToggle()"
color="primary"
>
</mat-slide-toggle>
<span class="mode-label">{{ modeLabel() }}</span>
</div>

View File

@@ -0,0 +1,20 @@
.view-mode-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
background: var(--md-sys-color-surface-variant);
border-radius: 20px;
.mode-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.mode-label {
font-size: 0.875rem;
font-weight: 500;
min-width: 60px;
}
}

View File

@@ -0,0 +1,85 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ViewModeToggleComponent } from './view-mode-toggle.component';
import { ViewModeService } from '../../../core/services/view-mode.service';
describe('ViewModeToggleComponent', () => {
let component: ViewModeToggleComponent;
let fixture: ComponentFixture<ViewModeToggleComponent>;
let service: ViewModeService;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [
ViewModeToggleComponent,
NoopAnimationsModule
]
}).compileComponents();
fixture = TestBed.createComponent(ViewModeToggleComponent);
component = fixture.componentInstance;
service = TestBed.inject(ViewModeService);
fixture.detectChanges();
});
afterEach(() => {
localStorage.clear();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should show operator label by default', () => {
expect(component.modeLabel()).toBe('Operator');
});
it('should show auditor label when in auditor mode', () => {
service.setMode('auditor');
fixture.detectChanges();
expect(component.modeLabel()).toBe('Auditor');
});
it('should show speed icon in operator mode', () => {
service.setMode('operator');
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('.mode-icon');
expect(icon.textContent?.trim()).toBe('speed');
});
it('should show verified_user icon in auditor mode', () => {
service.setMode('auditor');
fixture.detectChanges();
const icon = fixture.nativeElement.querySelector('.mode-icon');
expect(icon.textContent?.trim()).toBe('verified_user');
});
it('should toggle mode when slide toggle is clicked', () => {
expect(service.mode()).toBe('operator');
component.onToggle();
expect(service.mode()).toBe('auditor');
});
it('should display correct tooltip for operator mode', () => {
service.setMode('operator');
const tooltip = component.tooltipText();
expect(tooltip).toContain('Streamlined action-focused view');
expect(tooltip).toContain('Switch to Auditor');
});
it('should display correct tooltip for auditor mode', () => {
service.setMode('auditor');
const tooltip = component.tooltipText();
expect(tooltip).toContain('Full provenance and evidence details');
expect(tooltip).toContain('Switch to Operator');
});
});

View File

@@ -0,0 +1,34 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ViewModeService } from '../../../core/services/view-mode.service';
@Component({
selector: 'stella-view-mode-toggle',
standalone: true,
imports: [CommonModule, MatSlideToggleModule, MatIconModule, MatTooltipModule],
templateUrl: './view-mode-toggle.component.html',
styleUrls: ['./view-mode-toggle.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ViewModeToggleComponent {
constructor(private viewModeService: ViewModeService) {}
isAuditor = this.viewModeService.isAuditor;
modeLabel() {
return this.viewModeService.isAuditor() ? 'Auditor' : 'Operator';
}
tooltipText() {
return this.viewModeService.isAuditor()
? 'Full provenance and evidence details. Switch to Operator for streamlined view.'
: 'Streamlined action-focused view. Switch to Auditor for full details.';
}
onToggle(): void {
this.viewModeService.toggle();
}
}

View File

@@ -0,0 +1,59 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AuditorOnlyDirective } from './auditor-only.directive';
import { ViewModeService } from '../../core/services/view-mode.service';
@Component({
template: '<div *stellaAuditorOnly>Auditor content</div>',
standalone: true,
imports: [AuditorOnlyDirective]
})
class TestComponent {}
describe('AuditorOnlyDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let service: ViewModeService;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [TestComponent, AuditorOnlyDirective]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
service = TestBed.inject(ViewModeService);
});
afterEach(() => {
localStorage.clear();
});
it('should hide content in operator mode', () => {
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Auditor content');
});
it('should show content in auditor mode', () => {
service.setMode('auditor');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Auditor content');
});
it('should react to mode changes', () => {
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Auditor content');
service.setMode('auditor');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Auditor content');
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Auditor content');
});
});

View File

@@ -0,0 +1,30 @@
import { Directive, TemplateRef, ViewContainerRef, effect } from '@angular/core';
import { ViewModeService } from '../../core/services/view-mode.service';
/**
* Structural directive that shows content only in auditor mode.
*
* @example
* <div *stellaAuditorOnly>
* Full provenance details visible only to auditors...
* </div>
*/
@Directive({
selector: '[stellaAuditorOnly]',
standalone: true
})
export class AuditorOnlyDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private viewModeService: ViewModeService
) {
effect(() => {
if (this.viewModeService.isAuditor()) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
});
}
}

View File

@@ -0,0 +1,59 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OperatorOnlyDirective } from './operator-only.directive';
import { ViewModeService } from '../../core/services/view-mode.service';
@Component({
template: '<div *stellaOperatorOnly>Operator content</div>',
standalone: true,
imports: [OperatorOnlyDirective]
})
class TestComponent {}
describe('OperatorOnlyDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let service: ViewModeService;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [TestComponent, OperatorOnlyDirective]
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
service = TestBed.inject(ViewModeService);
});
afterEach(() => {
localStorage.clear();
});
it('should show content in operator mode', () => {
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Operator content');
});
it('should hide content in auditor mode', () => {
service.setMode('auditor');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Operator content');
});
it('should react to mode changes', () => {
service.setMode('auditor');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Operator content');
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Operator content');
service.setMode('auditor');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('Operator content');
});
});

View File

@@ -0,0 +1,30 @@
import { Directive, TemplateRef, ViewContainerRef, effect } from '@angular/core';
import { ViewModeService } from '../../core/services/view-mode.service';
/**
* Structural directive that shows content only in operator mode.
*
* @example
* <div *stellaOperatorOnly>
* Quick action buttons visible only to operators...
* </div>
*/
@Directive({
selector: '[stellaOperatorOnly]',
standalone: true
})
export class OperatorOnlyDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private viewModeService: ViewModeService
) {
effect(() => {
if (this.viewModeService.isOperator()) {
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
this.viewContainer.clear();
}
});
}
}

View File

@@ -36,6 +36,24 @@ export const policyAuditSession: StubAuthSession = {
scopes: [...baseScopes, 'policy:audit'],
};
export const exceptionUserSession: StubAuthSession = {
subjectId: 'user-exception-requester',
tenant: 'tenant-default',
scopes: [...baseScopes, 'exceptions:read', 'exceptions:manage'],
};
export const exceptionApproverSession: StubAuthSession = {
subjectId: 'user-exception-approver',
tenant: 'tenant-default',
scopes: [...baseScopes, 'exceptions:read', 'exceptions:approve'],
};
export const exceptionAdminSession: StubAuthSession = {
subjectId: 'user-exception-admin',
tenant: 'tenant-default',
scopes: [...baseScopes, 'exceptions:read', 'exceptions:manage', 'exceptions:approve', 'admin'],
};
export const allPolicySessions = [
policyAuthorSession,
policyReviewerSession,
@@ -43,3 +61,9 @@ export const allPolicySessions = [
policyOperatorSession,
policyAuditSession,
];
export const allExceptionSessions = [
exceptionUserSession,
exceptionApproverSession,
exceptionAdminSession,
];

View File

@@ -0,0 +1,461 @@
import { expect, test } from '@playwright/test';
import {
exceptionUserSession,
exceptionApproverSession,
exceptionAdminSession,
} from '../../src/app/testing/auth-fixtures';
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 exceptions:read exceptions:manage exceptions:approve admin',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
const mockException = {
exceptionId: 'exc-test-001',
name: 'test-exception',
displayName: 'Test Exception E2E',
description: 'E2E test exception',
type: 'vulnerability',
severity: 'high',
status: 'pending_review',
scope: {
type: 'global',
vulnIds: ['CVE-2024-9999'],
},
justification: {
text: 'This is a test exception for E2E testing',
},
timebox: {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
labels: {},
createdBy: 'user-exception-requester',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
function setupMockRoutes(page) {
// Mock config
page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
// Mock exception list API
page.route('**/api/v1/exceptions?*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [mockException], total: 1 }),
})
);
// Mock exception detail API
page.route(`**/api/v1/exceptions/${mockException.exceptionId}`, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockException),
})
);
// Mock exception create API
page.route('**/api/v1/exceptions', async (route) => {
if (route.request().method() === 'POST') {
const newException = {
...mockException,
exceptionId: 'exc-new-001',
status: 'draft',
};
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(newException),
});
} else {
route.continue();
}
});
// Mock exception transition API
page.route('**/api/v1/exceptions/*/transition', (route) => {
const approvedException = {
...mockException,
status: 'approved',
};
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(approvedException),
});
});
// Mock exception update API
page.route('**/api/v1/exceptions/*', async (route) => {
if (route.request().method() === 'PATCH' || route.request().method() === 'PUT') {
const updatedException = {
...mockException,
description: 'Updated description',
};
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(updatedException),
});
} else {
route.continue();
}
});
// Mock SSE events
page.route('**/api/v1/exceptions/events', (route) =>
route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: '',
})
);
// Block authority
page.route('https://authority.local/**', (route) => route.abort());
}
test.describe('Exception Lifecycle - User Flow', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionUserSession);
await setupMockRoutes(page);
});
test('create exception flow', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Open wizard
const createButton = page.getByRole('button', { name: /create exception/i });
await expect(createButton).toBeVisible();
await createButton.click();
// Wizard should be visible
await expect(page.getByRole('dialog', { name: /exception wizard/i })).toBeVisible();
// Fill in basic info
await page.getByLabel('Title').fill('Test Exception');
await page.getByLabel('Justification').fill('This is a test justification');
// Select severity
await page.getByLabel('Severity').selectOption('high');
// Fill scope (CVEs)
await page.getByLabel('CVE IDs').fill('CVE-2024-9999');
// Set expiry
await page.getByLabel('Expires in days').fill('30');
// Submit
const submitButton = page.getByRole('button', { name: /submit|create/i });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Wizard should close
await expect(page.getByRole('dialog', { name: /exception wizard/i })).toBeHidden();
// Exception should appear in list
await expect(page.getByText('Test Exception E2E')).toBeVisible();
});
test('displays exception list', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Exception should be visible in list
await expect(page.getByText('Test Exception E2E')).toBeVisible();
await expect(page.getByText('CVE-2024-9999')).toBeVisible();
});
test('opens exception detail panel', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Click on exception to view detail
await page.getByText('Test Exception E2E').click();
// Detail panel should open
await expect(page.getByText('This is a test exception for E2E testing')).toBeVisible();
await expect(page.getByText('CVE-2024-9999')).toBeVisible();
});
});
test.describe('Exception Lifecycle - Approval Flow', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionApproverSession);
await setupMockRoutes(page);
});
test('approval queue shows pending exceptions', async ({ page }) => {
await page.goto('/exceptions/approvals');
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
timeout: 10000,
});
// Pending exception should be visible
await expect(page.getByText('Test Exception E2E')).toBeVisible();
await expect(page.getByText(/pending/i)).toBeVisible();
});
test('approve exception', async ({ page }) => {
await page.goto('/exceptions/approvals');
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
timeout: 10000,
});
// Select exception
const checkbox = page.getByRole('checkbox', { name: /select exception/i }).first();
await checkbox.check();
// Approve button should be enabled
const approveButton = page.getByRole('button', { name: /approve/i });
await expect(approveButton).toBeEnabled();
await approveButton.click();
// Confirmation or success message
await expect(page.getByText(/approved/i)).toBeVisible({ timeout: 5000 });
});
test('reject exception requires comment', async ({ page }) => {
await page.goto('/exceptions/approvals');
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
timeout: 10000,
});
// Select exception
const checkbox = page.getByRole('checkbox', { name: /select exception/i }).first();
await checkbox.check();
// Try to reject without comment
const rejectButton = page.getByRole('button', { name: /reject/i });
await rejectButton.click();
// Error message should appear
await expect(page.getByText(/comment.*required/i)).toBeVisible();
// Add comment
await page.getByLabel(/rejection comment/i).fill('Does not meet policy requirements');
// Reject should now work
await rejectButton.click();
// Success or confirmation
await expect(page.getByText(/rejected/i)).toBeVisible({ timeout: 5000 });
});
});
test.describe('Exception Lifecycle - Admin Flow', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionAdminSession);
await setupMockRoutes(page);
});
test('edit exception details', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Open exception detail
await page.getByText('Test Exception E2E').click();
// Edit description
const descriptionField = page.getByLabel(/description/i);
await descriptionField.fill('Updated description for E2E test');
// Save changes
const saveButton = page.getByRole('button', { name: /save/i });
await expect(saveButton).toBeEnabled();
await saveButton.click();
// Success message or confirmation
await expect(page.getByText(/saved|updated/i)).toBeVisible({ timeout: 5000 });
});
test('extend exception expiry', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Open exception detail
await page.getByText('Test Exception E2E').click();
// Find extend button
const extendButton = page.getByRole('button', { name: /extend/i });
await expect(extendButton).toBeVisible();
// Set extension days
await page.getByLabel(/extend.*days/i).fill('14');
await extendButton.click();
// Confirmation
await expect(page.getByText(/extended/i)).toBeVisible({ timeout: 5000 });
});
test('exception transition workflow', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Open exception detail
await page.getByText('Test Exception E2E').click();
// Transition button should be available
const transitionButton = page.getByRole('button', { name: /approve|activate/i }).first();
await expect(transitionButton).toBeVisible();
await transitionButton.click();
// Confirmation or success
await expect(page.getByText(/approved|activated/i)).toBeVisible({ timeout: 5000 });
});
});
test.describe('Exception Lifecycle - Role-Based Access', () => {
test('user without approve scope cannot see approval queue', async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionUserSession);
await setupMockRoutes(page);
await page.goto('/exceptions/approvals');
// Should redirect or show access denied
await expect(
page.getByText(/access denied|not authorized|forbidden/i)
).toBeVisible({ timeout: 10000 });
});
test('approver can access approval queue', async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionApproverSession);
await setupMockRoutes(page);
await page.goto('/exceptions/approvals');
// Should show approval queue
await expect(page.getByRole('heading', { name: /approval queue/i })).toBeVisible({
timeout: 10000,
});
});
});
test.describe('Exception Export', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors
}
(window as any).__stellaopsTestSession = session;
}, exceptionAdminSession);
await setupMockRoutes(page);
// Mock export API
page.route('**/api/v1/exports/exceptions*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reportId: 'report-001',
downloadUrl: '/downloads/exception-report.json',
}),
})
);
});
test('export exception report', async ({ page }) => {
await page.goto('/exceptions');
await expect(page.getByRole('heading', { name: /exception/i })).toBeVisible({
timeout: 10000,
});
// Find export button
const exportButton = page.getByRole('button', { name: /export/i });
await expect(exportButton).toBeVisible();
await exportButton.click();
// Export dialog or confirmation
await expect(page.getByText(/export|download/i)).toBeVisible();
// Verify download initiated (check for link or success message)
const downloadLink = page.getByRole('link', { name: /download/i });
await expect(downloadLink).toBeVisible({ timeout: 10000 });
});
});