Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
108
src/Web/StellaOps.Web/src/app/core/api/triage-inbox.models.ts
Normal file
108
src/Web/StellaOps.Web/src/app/core/api/triage-inbox.models.ts
Normal 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;
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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 '[-]';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
461
src/Web/StellaOps.Web/tests/e2e/exception-lifecycle.spec.ts
Normal file
461
src/Web/StellaOps.Web/tests/e2e/exception-lifecycle.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user