Implement Exception Effect Registry and Evaluation Service
- Added IExceptionEffectRegistry interface and its implementation ExceptionEffectRegistry to manage exception effects based on type and reason. - Created ExceptionAwareEvaluationService for evaluating policies with automatic exception loading from the repository. - Developed unit tests for ExceptionAdapter and ExceptionEffectRegistry to ensure correct behavior and mappings of exceptions and effects. - Enhanced exception loading logic to filter expired and non-active exceptions, and to respect maximum exceptions limit. - Implemented caching mechanism in ExceptionAdapter to optimize repeated exception loading.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
| WEB-AIAI-31-003 | DONE (2025-12-12) | Telemetry headers + prompt hash support; documented guardrail surface for audit visibility. |
|
||||
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
|
||||
| WEB-CONSOLE-23-003 | DONE (2025-12-07) | Exports client/store/service + models shipped; targeted Karma specs green locally with CHROME_BIN override (`node ./node_modules/@angular/cli/bin/ng.js test --watch=false --browsers=ChromeHeadless --include console-export specs`). Backend manifest/limits v0.4 published; awaiting final Policy/DevOps sign-off but UI/client slice complete. |
|
||||
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
|
||||
| WEB-RISK-66-001 | DONE (2025-12-20) | Gateway routing/client slice completed; Angular unit tests now run and pass (`npm test`), clearing the prior npm/CI blocker. |
|
||||
| WEB-EXC-25-001 | DONE (2025-12-12) | Exception contract + sample updated (`docs/api/console/exception-schema.md`); `ExceptionApiHttpClient` enforces scopes + trace/tenant headers with unit spec. |
|
||||
| WEB-EXC-25-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/policy-exceptions.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts`. |
|
||||
| WEB-EXC-25-003 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/exception-events.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts`. |
|
||||
|
||||
@@ -209,11 +209,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
|
||||
exportGraph(request: ExportGraphRequest): Observable<ExportGraphResult> {
|
||||
if (request.format === 'json') {
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: 'json',
|
||||
data: JSON.stringify(mockCallGraph, null, 2),
|
||||
filename: `call-graph-${request.explanationId}.json`,
|
||||
}).pipe(delay(200));
|
||||
};
|
||||
return of(result).pipe(delay(200));
|
||||
}
|
||||
|
||||
if (request.format === 'dot') {
|
||||
@@ -225,11 +226,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
|
||||
${mockEdges.map(e => `"${e.sourceId}" -> "${e.targetId}";`).join('\n ')}
|
||||
}`;
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: 'dot',
|
||||
data: dotContent,
|
||||
filename: `call-graph-${request.explanationId}.dot`,
|
||||
}).pipe(delay(200));
|
||||
};
|
||||
return of(result).pipe(delay(200));
|
||||
}
|
||||
|
||||
// For PNG/SVG, return a placeholder data URL
|
||||
@@ -239,11 +241,12 @@ export class MockReachabilityApi implements ReachabilityApi {
|
||||
</svg>`;
|
||||
const dataUrl = `data:image/svg+xml;base64,${btoa(svgContent)}`;
|
||||
|
||||
return of({
|
||||
const result: ExportGraphResult = {
|
||||
format: request.format,
|
||||
dataUrl,
|
||||
filename: `call-graph-${request.explanationId}.${request.format}`,
|
||||
}).pipe(delay(400));
|
||||
};
|
||||
return of(result).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,254 +1,184 @@
|
||||
/**
|
||||
* Tests for Proof Ledger View Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { signal } from '@angular/core';
|
||||
import { of, throwError, delay } from 'rxjs';
|
||||
import { delay, of } from 'rxjs';
|
||||
import { ProofLedgerViewComponent } from './proof-ledger-view.component';
|
||||
import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client';
|
||||
import { MANIFEST_API, PROOF_BUNDLE_API, ManifestApi, ProofBundleApi } from '../../core/api/proof.client';
|
||||
import { MerkleTree, ProofBundle, ProofVerificationResult, ScanManifest } from '../../core/api/proof.models';
|
||||
|
||||
describe('ProofLedgerViewComponent', () => {
|
||||
let component: ProofLedgerViewComponent;
|
||||
let fixture: ComponentFixture<ProofLedgerViewComponent>;
|
||||
let mockManifestApi: jasmine.SpyObj<any>;
|
||||
let mockProofBundleApi: jasmine.SpyObj<any>;
|
||||
let manifestApi: jasmine.SpyObj<ManifestApi>;
|
||||
let proofBundleApi: jasmine.SpyObj<ProofBundleApi>;
|
||||
|
||||
const mockManifest = {
|
||||
scanId: 'scan-123',
|
||||
imageRef: 'registry.example.com/app:v1.0.0',
|
||||
digest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
scannedAt: new Date().toISOString(),
|
||||
const scanId = 'scan-123';
|
||||
|
||||
const mockManifest: ScanManifest = {
|
||||
manifestId: 'manifest-123',
|
||||
scanId,
|
||||
imageDigest: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
createdAt: '2025-12-18T09:22:00Z',
|
||||
hashes: [
|
||||
{ label: 'SBOM', algorithm: 'sha256', value: 'abc123...', source: 'sbom' },
|
||||
{ label: 'Layer 1', algorithm: 'sha256', value: 'def456...', source: 'layer' }
|
||||
{ label: 'SBOM', algorithm: 'sha256', value: 'sha256:abc123', source: 'sbom' },
|
||||
{ label: 'Layer 1', algorithm: 'sha256', value: 'sha256:def456', source: 'layer' },
|
||||
],
|
||||
dsseSignature: {
|
||||
keyId: 'key-001',
|
||||
algorithm: 'ecdsa-sha256',
|
||||
signature: 'MEUCIQDtest...',
|
||||
signedAt: new Date().toISOString(),
|
||||
verificationStatus: 'valid' as const
|
||||
}
|
||||
merkleRoot: 'sha256:root123',
|
||||
};
|
||||
|
||||
const mockMerkleTree = {
|
||||
treeId: 'tree-123',
|
||||
const mockMerkleTree: MerkleTree = {
|
||||
depth: 1,
|
||||
leafCount: 1,
|
||||
root: {
|
||||
nodeId: 'root',
|
||||
hash: 'root-hash-123',
|
||||
hash: 'sha256:root123',
|
||||
isRoot: true,
|
||||
isLeaf: false,
|
||||
level: 2,
|
||||
level: 0,
|
||||
position: 0,
|
||||
children: []
|
||||
children: [],
|
||||
},
|
||||
depth: 3,
|
||||
leafCount: 6,
|
||||
algorithm: 'sha256'
|
||||
};
|
||||
|
||||
const mockProofBundle = {
|
||||
const mockProofBundle: ProofBundle = {
|
||||
bundleId: 'bundle-123',
|
||||
scanId: 'scan-123',
|
||||
manifest: mockManifest,
|
||||
attestation: {},
|
||||
rekorEntry: { logIndex: 12345, integratedTime: new Date().toISOString() },
|
||||
createdAt: new Date().toISOString()
|
||||
scanId,
|
||||
createdAt: '2025-12-18T09:22:05Z',
|
||||
merkleRoot: mockManifest.merkleRoot,
|
||||
dsseEnvelope: 'ZHNzZS1lbmNsb3Bl',
|
||||
signatures: [
|
||||
{
|
||||
keyId: 'key-001',
|
||||
algorithm: 'ecdsa-sha256',
|
||||
status: 'valid',
|
||||
signedAt: '2025-12-18T09:22:05Z',
|
||||
},
|
||||
],
|
||||
rekorEntry: {
|
||||
logId: 'rekor-log-1',
|
||||
logIndex: 12345,
|
||||
integratedTime: '2025-12-18T09:23:00Z',
|
||||
logUrl: 'https://search.sigstore.dev/?logIndex=12345',
|
||||
bodyHash: 'sha256:body123',
|
||||
},
|
||||
verificationStatus: 'verified',
|
||||
downloadUrl: 'https://example.invalid/bundle-123',
|
||||
};
|
||||
|
||||
const mockVerificationResult: ProofVerificationResult = {
|
||||
bundleId: mockProofBundle.bundleId,
|
||||
verified: true,
|
||||
merkleRootValid: true,
|
||||
signatureValid: true,
|
||||
rekorInclusionValid: true,
|
||||
verifiedAt: '2025-12-18T09:24:00Z',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockManifestApi = jasmine.createSpyObj('ManifestApi', ['getManifest', 'getMerkleTree']);
|
||||
mockProofBundleApi = jasmine.createSpyObj('ProofBundleApi', ['getProofBundle', 'verifyProofBundle', 'downloadProofBundle']);
|
||||
manifestApi = jasmine.createSpyObj<ManifestApi>('ManifestApi', ['getManifest', 'getMerkleTree']);
|
||||
proofBundleApi = jasmine.createSpyObj<ProofBundleApi>('ProofBundleApi', ['getProofBundle', 'verifyProofBundle', 'downloadProofBundle']);
|
||||
|
||||
mockManifestApi.getManifest.and.returnValue(of(mockManifest));
|
||||
mockManifestApi.getMerkleTree.and.returnValue(of(mockMerkleTree));
|
||||
mockProofBundleApi.getProofBundle.and.returnValue(of(mockProofBundle));
|
||||
manifestApi.getManifest.and.returnValue(of(mockManifest).pipe(delay(1)));
|
||||
manifestApi.getMerkleTree.and.returnValue(of(mockMerkleTree));
|
||||
proofBundleApi.getProofBundle.and.returnValue(of(mockProofBundle).pipe(delay(1)));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProofLedgerViewComponent],
|
||||
providers: [
|
||||
{ provide: MANIFEST_API, useValue: mockManifestApi },
|
||||
{ provide: PROOF_BUNDLE_API, useValue: mockProofBundleApi }
|
||||
]
|
||||
{ provide: MANIFEST_API, useValue: manifestApi },
|
||||
{ provide: PROOF_BUNDLE_API, useValue: proofBundleApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProofLedgerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
function loadComponent(): void {
|
||||
fixture.componentRef.setInput('scanId', scanId);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('shows loading state before proof bundle loads', fakeAsync(() => {
|
||||
loadComponent();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__loading'))).toBeTruthy();
|
||||
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__loading'))).toBeNull();
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__content'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('renders scan manifest and hash rows', fakeAsync(() => {
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(scanId);
|
||||
|
||||
const hashRows = fixture.debugElement.queryAll(By.css('.proof-ledger__hash-row'));
|
||||
expect(hashRows.length).toBe(2);
|
||||
}));
|
||||
|
||||
it('toggles the Merkle tree display', fakeAsync(() => {
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__tree'))).toBeNull();
|
||||
|
||||
const expandBtn = fixture.debugElement.query(By.css('.proof-ledger__expand-btn'));
|
||||
expandBtn.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('.proof-ledger__tree'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('verifies bundle and emits verification result', fakeAsync(() => {
|
||||
proofBundleApi.verifyProofBundle.and.returnValue(of(mockVerificationResult));
|
||||
|
||||
const emitSpy = spyOn(component.verificationComplete, 'emit');
|
||||
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__btn--verify'));
|
||||
verifyBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(proofBundleApi.verifyProofBundle).toHaveBeenCalledWith(mockProofBundle.bundleId);
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockVerificationResult);
|
||||
}));
|
||||
|
||||
it('downloads bundle and emits bundleDownloaded', fakeAsync(() => {
|
||||
proofBundleApi.downloadProofBundle.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
|
||||
|
||||
const emitSpy = spyOn(component.bundleDownloaded, 'emit');
|
||||
const mockUrl = 'blob:mock-url';
|
||||
spyOn(URL, 'createObjectURL').and.returnValue(mockUrl);
|
||||
spyOn(URL, 'revokeObjectURL');
|
||||
|
||||
loadComponent();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
spyOn(anchor, 'click');
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
spyOn(document, 'createElement').and.callFake((tagName: string) => {
|
||||
if (tagName.toLowerCase() === 'a') return anchor;
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
const downloadBtn = fixture.debugElement.query(By.css('.proof-ledger__btn--download'));
|
||||
downloadBtn.nativeElement.click();
|
||||
|
||||
const loading = fixture.debugElement.query(By.css('.proof-ledger__loading'));
|
||||
expect(loading).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load manifest on init', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockManifestApi.getManifest).toHaveBeenCalledWith('scan-123');
|
||||
}));
|
||||
|
||||
it('should display manifest data after loading', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const digest = fixture.debugElement.query(By.css('.proof-ledger__digest code'));
|
||||
expect(digest.nativeElement.textContent).toContain('sha256:a1b2c3');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Hash Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display all hashes', () => {
|
||||
const hashItems = fixture.debugElement.queryAll(By.css('.proof-ledger__hash-item'));
|
||||
expect(hashItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have copy button for each hash', () => {
|
||||
const copyButtons = fixture.debugElement.queryAll(By.css('.proof-ledger__copy-btn'));
|
||||
expect(copyButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Merkle Tree', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should load merkle tree', () => {
|
||||
expect(mockManifestApi.getMerkleTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display merkle tree section', () => {
|
||||
const merkleSection = fixture.debugElement.query(By.css('.proof-ledger__merkle'));
|
||||
expect(merkleSection).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DSSE Signature', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display signature status', () => {
|
||||
const signatureStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status'));
|
||||
expect(signatureStatus).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show valid status with correct styling', () => {
|
||||
const validStatus = fixture.debugElement.query(By.css('.proof-ledger__sig-status--valid'));
|
||||
expect(validStatus).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have verify button', () => {
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn'));
|
||||
expect(verifyBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have download button', () => {
|
||||
const downloadBtn = fixture.debugElement.query(By.css('.proof-ledger__download-btn'));
|
||||
expect(downloadBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call verify on button click', fakeAsync(() => {
|
||||
mockProofBundleApi.verifyProofBundle.and.returnValue(of({ valid: true }));
|
||||
|
||||
const verifyBtn = fixture.debugElement.query(By.css('.proof-ledger__verify-btn'));
|
||||
verifyBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockProofBundleApi.verifyProofBundle).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error when manifest fails to load', fakeAsync(() => {
|
||||
mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Network error')));
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.proof-ledger__error'));
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.nativeElement.textContent).toContain('Network error');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have accessible heading structure', () => {
|
||||
const h3 = fixture.debugElement.query(By.css('h3'));
|
||||
expect(h3).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have aria-label on icon buttons', () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
buttons.forEach(button => {
|
||||
const hasLabel = button.nativeElement.hasAttribute('aria-label') ||
|
||||
button.nativeElement.hasAttribute('title') ||
|
||||
button.nativeElement.textContent.trim().length > 0;
|
||||
expect(hasLabel).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper role on status elements', () => {
|
||||
const loading = fixture.debugElement.query(By.css('[role="status"]'));
|
||||
// Loading should have role="status"
|
||||
});
|
||||
|
||||
it('should have role="alert" on error messages', fakeAsync(() => {
|
||||
mockManifestApi.getManifest.and.returnValue(throwError(() => new Error('Error')));
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('[role="alert"]'));
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
expect(proofBundleApi.downloadProofBundle).toHaveBeenCalledWith(mockProofBundle.bundleId);
|
||||
expect(anchor.download).toContain(scanId);
|
||||
expect(anchor.click).toHaveBeenCalled();
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -116,12 +116,20 @@ interface TreeViewState {
|
||||
<code class="proof-ledger__value">{{ manifest()!.scanId }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Timestamp:</span>
|
||||
<time class="proof-ledger__value">{{ manifest()!.timestamp | date:'medium' }}</time>
|
||||
<span class="proof-ledger__label">Image Digest:</span>
|
||||
<code class="proof-ledger__value" [title]="manifest()!.imageDigest">
|
||||
{{ manifest()!.imageDigest | slice:0:16 }}...{{ manifest()!.imageDigest | slice:-8 }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Algorithm:</span>
|
||||
<code class="proof-ledger__value">{{ manifest()!.algorithmVersion }}</code>
|
||||
<span class="proof-ledger__label">Created At:</span>
|
||||
<time class="proof-ledger__value">{{ manifest()!.createdAt | date:'medium' }}</time>
|
||||
</div>
|
||||
<div class="proof-ledger__manifest-row">
|
||||
<span class="proof-ledger__label">Merkle Root:</span>
|
||||
<code class="proof-ledger__value" [title]="manifest()!.merkleRoot">
|
||||
{{ manifest()!.merkleRoot | slice:0:16 }}...{{ manifest()!.merkleRoot | slice:-8 }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -194,20 +202,32 @@ interface TreeViewState {
|
||||
<h3 class="proof-ledger__section-title">
|
||||
<span aria-hidden="true">✍️</span> DSSE Signature
|
||||
</h3>
|
||||
@if (proofBundle()?.dsseSignature) {
|
||||
@if (proofBundle()?.signatures?.length) {
|
||||
<div class="proof-ledger__signature">
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Key ID:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.dsseSignature.keyId }}</code>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].keyId }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Algorithm:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.dsseSignature.algorithm }}</code>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].algorithm }}</code>
|
||||
</div>
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Timestamp:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.dsseSignature.timestamp | date:'medium' }}</time>
|
||||
<span class="proof-ledger__label">Status:</span>
|
||||
<code class="proof-ledger__value">{{ proofBundle()!.signatures[0].status }}</code>
|
||||
</div>
|
||||
@if (proofBundle()!.signatures[0].signedAt) {
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Signed At:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.signatures[0].signedAt | date:'medium' }}</time>
|
||||
</div>
|
||||
}
|
||||
@if (proofBundle()!.signatures[0].expiresAt) {
|
||||
<div class="proof-ledger__sig-row">
|
||||
<span class="proof-ledger__label">Expires At:</span>
|
||||
<time class="proof-ledger__value">{{ proofBundle()!.signatures[0].expiresAt | date:'medium' }}</time>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="proof-ledger__no-sig">No DSSE signature available</p>
|
||||
@@ -559,7 +579,7 @@ export class ProofLedgerViewComponent implements OnInit {
|
||||
readonly verificationStatus = computed(() => {
|
||||
const result = this.verificationResult();
|
||||
if (!result) return 'pending';
|
||||
return result.valid ? 'verified' : 'failed';
|
||||
return result.verified ? 'verified' : 'failed';
|
||||
});
|
||||
|
||||
readonly verificationStatusText = computed(() => {
|
||||
@@ -573,9 +593,8 @@ export class ProofLedgerViewComponent implements OnInit {
|
||||
|
||||
readonly rekorLink = computed(() => {
|
||||
const bundle = this.proofBundle();
|
||||
return bundle?.rekorLogId
|
||||
? `https://search.sigstore.dev/?logIndex=${bundle.rekorLogId}`
|
||||
: null;
|
||||
const entry = bundle?.rekorEntry;
|
||||
return entry ? entry.logUrl : null;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -1,486 +1,130 @@
|
||||
/**
|
||||
* Tests for Proof Replay Dashboard Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick, flush, discardPeriodicTasks } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { ProofReplayDashboardComponent, REPLAY_API, ReplayJob, ReplayResult } from './proof-replay-dashboard.component';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
ProofReplayDashboardComponent,
|
||||
REPLAY_API,
|
||||
ReplayApi,
|
||||
ReplayHistoryEntry,
|
||||
ReplayJob,
|
||||
ReplayResult,
|
||||
} from './proof-replay-dashboard.component';
|
||||
|
||||
describe('ProofReplayDashboardComponent', () => {
|
||||
let component: ProofReplayDashboardComponent;
|
||||
let fixture: ComponentFixture<ProofReplayDashboardComponent>;
|
||||
let mockReplayApi: jasmine.SpyObj<any>;
|
||||
let replayApi: jasmine.SpyObj<ReplayApi>;
|
||||
|
||||
const mockJob: ReplayJob = {
|
||||
id: 'replay-001',
|
||||
scanId: 'scan-123',
|
||||
digest: 'sha256:abc123...',
|
||||
imageRef: 'registry.example.com/app:v1.0.0',
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
currentStep: 'advisory-merge',
|
||||
totalSteps: 8,
|
||||
completedSteps: 3,
|
||||
steps: [
|
||||
{ name: 'sbom-gen', status: 'completed', duration: 12500 },
|
||||
{ name: 'scanner-run', status: 'completed', duration: 45200 },
|
||||
{ name: 'vex-apply', status: 'completed', duration: 8300 },
|
||||
{ name: 'advisory-merge', status: 'running', duration: 0 },
|
||||
{ name: 'reachability', status: 'pending' },
|
||||
{ name: 'scoring', status: 'pending' },
|
||||
{ name: 'attestation', status: 'pending' },
|
||||
{ name: 'proof-seal', status: 'pending' }
|
||||
]
|
||||
};
|
||||
const now = '2025-12-20T00:00:00Z';
|
||||
|
||||
const mockResult: ReplayResult = {
|
||||
jobId: 'replay-001',
|
||||
scanId: 'scan-123',
|
||||
status: 'passed',
|
||||
originalDigest: 'sha256:abc123...',
|
||||
replayDigest: 'sha256:abc123...',
|
||||
digestMatch: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
totalDuration: 185000,
|
||||
stepTimings: {
|
||||
'sbom-gen': { original: 12500, replay: 12480, delta: -20 },
|
||||
'scanner-run': { original: 45200, replay: 45150, delta: -50 },
|
||||
'vex-apply': { original: 8300, replay: 8310, delta: 10 },
|
||||
'advisory-merge': { original: 22000, replay: 22100, delta: 100 },
|
||||
'reachability': { original: 35000, replay: 34950, delta: -50 },
|
||||
'scoring': { original: 15000, replay: 15020, delta: 20 },
|
||||
'attestation': { original: 28000, replay: 27990, delta: -10 },
|
||||
'proof-seal': { original: 19000, replay: 19000, delta: 0 }
|
||||
},
|
||||
artifacts: [
|
||||
{ name: 'SBOM', originalHash: 'sha256:sbom111...', replayHash: 'sha256:sbom111...', match: true },
|
||||
{ name: 'Scanner Report', originalHash: 'sha256:scan222...', replayHash: 'sha256:scan222...', match: true },
|
||||
{ name: 'VEX Document', originalHash: 'sha256:vex333...', replayHash: 'sha256:vex333...', match: true },
|
||||
{ name: 'Attestation', originalHash: 'sha256:att444...', replayHash: 'sha256:att444...', match: true }
|
||||
],
|
||||
driftItems: []
|
||||
};
|
||||
|
||||
const mockHistory = [
|
||||
{ jobId: 'replay-001', scanId: 'scan-123', status: 'passed', completedAt: new Date().toISOString(), digestMatch: true },
|
||||
{ jobId: 'replay-002', scanId: 'scan-456', status: 'passed', completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), digestMatch: true },
|
||||
{ jobId: 'replay-003', scanId: 'scan-789', status: 'failed', completedAt: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), digestMatch: false }
|
||||
const history: ReplayHistoryEntry[] = [
|
||||
{ jobId: 'job-0', scanId: 'scan-123', triggeredAt: now, status: 'completed', matched: true, driftCount: 0 },
|
||||
];
|
||||
|
||||
const queuedJob: ReplayJob = {
|
||||
jobId: 'job-1',
|
||||
scanId: 'scan-123',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
currentStep: 'queued',
|
||||
startedAt: now,
|
||||
};
|
||||
|
||||
const completedJob: ReplayJob = {
|
||||
...queuedJob,
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
currentStep: 'completed',
|
||||
completedAt: now,
|
||||
};
|
||||
|
||||
const result: ReplayResult = {
|
||||
jobId: 'job-1',
|
||||
scanId: 'scan-123',
|
||||
originalDigest: 'sha256:aaa',
|
||||
replayDigest: 'sha256:aaa',
|
||||
matched: true,
|
||||
drifts: [],
|
||||
timing: {
|
||||
totalMs: 1234,
|
||||
phases: [{ name: 'replay', durationMs: 1234, percentOfTotal: 100 }],
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
name: 'SBOM',
|
||||
type: 'sbom',
|
||||
originalPath: '/original/sbom.json',
|
||||
replayPath: '/replay/sbom.json',
|
||||
matched: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockReplayApi = jasmine.createSpyObj('ReplayApi', ['triggerReplay', 'getReplayStatus', 'getReplayResult', 'getReplayHistory', 'cancelReplay']);
|
||||
mockReplayApi.triggerReplay.and.returnValue(of(mockJob));
|
||||
mockReplayApi.getReplayStatus.and.returnValue(of(mockJob));
|
||||
mockReplayApi.getReplayResult.and.returnValue(of(mockResult));
|
||||
mockReplayApi.getReplayHistory.and.returnValue(of(mockHistory));
|
||||
mockReplayApi.cancelReplay.and.returnValue(of({ success: true }));
|
||||
replayApi = jasmine.createSpyObj<ReplayApi>('ReplayApi', [
|
||||
'triggerReplay',
|
||||
'getJobStatus',
|
||||
'getResult',
|
||||
'getHistory',
|
||||
'cancelJob',
|
||||
]);
|
||||
|
||||
replayApi.getHistory.and.returnValue(of(history));
|
||||
replayApi.triggerReplay.and.returnValue(of(queuedJob));
|
||||
replayApi.getJobStatus.and.returnValue(of(completedJob));
|
||||
replayApi.getResult.and.returnValue(of(result));
|
||||
replayApi.cancelJob.and.returnValue(of(void 0));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProofReplayDashboardComponent],
|
||||
providers: [
|
||||
{ provide: REPLAY_API, useValue: mockReplayApi }
|
||||
]
|
||||
providers: [{ provide: REPLAY_API, useValue: replayApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProofReplayDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure periodic timers are cleaned up
|
||||
it('creates', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
it('loads history on init', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
it('should load replay history on init', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(mockReplayApi.getReplayHistory).toHaveBeenCalledWith('scan-123');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display scan info header', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.componentRef.setInput('imageRef', 'registry.example.com/app:v1.0.0');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = fixture.debugElement.query(By.css('.proof-replay__header'));
|
||||
expect(header).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
expect(replayApi.getHistory).toHaveBeenCalledWith('scan-123');
|
||||
expect(component.history()).toEqual(history);
|
||||
});
|
||||
|
||||
describe('Trigger Replay', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
it('triggers replay and loads result when completed', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
component.triggerReplay();
|
||||
|
||||
it('should have trigger replay button', () => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
expect(replayApi.triggerReplay).toHaveBeenCalledWith('scan-123');
|
||||
expect(component.currentJob()?.jobId).toBe('job-1');
|
||||
|
||||
it('should trigger replay on button click', fakeAsync(() => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
tick(1000);
|
||||
|
||||
expect(mockReplayApi.triggerReplay).toHaveBeenCalledWith('scan-123');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
expect(replayApi.getJobStatus).toHaveBeenCalledWith('job-1');
|
||||
expect(replayApi.getResult).toHaveBeenCalledWith('job-1');
|
||||
expect(component.result()).toEqual(result);
|
||||
|
||||
it('should disable button while replay is running', fakeAsync(() => {
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
expect(button.nativeElement.disabled).toBeTrue();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
it('cancels an in-flight replay job', () => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
|
||||
describe('Progress Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
component.currentJob.set({ ...queuedJob, status: 'running', progress: 50, currentStep: 'replay' });
|
||||
|
||||
// Trigger a replay
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
component.cancelReplay();
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display progress bar', () => {
|
||||
const progressBar = fixture.debugElement.query(By.css('.proof-replay__progress'));
|
||||
expect(progressBar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display current step', () => {
|
||||
const currentStep = fixture.debugElement.query(By.css('.proof-replay__current-step'));
|
||||
expect(currentStep).toBeTruthy();
|
||||
expect(currentStep.nativeElement.textContent).toContain('advisory-merge');
|
||||
});
|
||||
|
||||
it('should display step list', () => {
|
||||
const steps = fixture.debugElement.queryAll(By.css('.proof-replay__step'));
|
||||
expect(steps.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should show completed steps with checkmark', () => {
|
||||
const completedSteps = fixture.debugElement.queryAll(By.css('.proof-replay__step--completed'));
|
||||
expect(completedSteps.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should show running step with spinner', () => {
|
||||
const runningStep = fixture.debugElement.query(By.css('.proof-replay__step--running'));
|
||||
expect(runningStep).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Result Display', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
// Set result directly for testing
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display result status', () => {
|
||||
const status = fixture.debugElement.query(By.css('.proof-replay__result-status'));
|
||||
expect(status).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show passed status styling', () => {
|
||||
const status = fixture.debugElement.query(By.css('.proof-replay__result-status--passed'));
|
||||
expect(status).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display digest comparison', () => {
|
||||
const digestSection = fixture.debugElement.query(By.css('.proof-replay__digest-comparison'));
|
||||
expect(digestSection).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show digest match indicator', () => {
|
||||
const matchBadge = fixture.debugElement.query(By.css('.proof-replay__digest-match'));
|
||||
expect(matchBadge).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timing Breakdown', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display timing table', () => {
|
||||
const timingTable = fixture.debugElement.query(By.css('.proof-replay__timing-table'));
|
||||
expect(timingTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all step timings', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__timing-row'));
|
||||
expect(rows.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should show timing deltas', () => {
|
||||
const deltas = fixture.debugElement.queryAll(By.css('.proof-replay__timing-delta'));
|
||||
expect(deltas.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should format durations properly', () => {
|
||||
const durations = fixture.debugElement.queryAll(By.css('.proof-replay__timing-value'));
|
||||
expect(durations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Artifact Comparison', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(mockResult);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display artifact table', () => {
|
||||
const artifactTable = fixture.debugElement.query(By.css('.proof-replay__artifact-table'));
|
||||
expect(artifactTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display all artifacts', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-row'));
|
||||
expect(rows.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should show match status for each artifact', () => {
|
||||
const matchBadges = fixture.debugElement.queryAll(By.css('.proof-replay__artifact-match'));
|
||||
expect(matchBadges.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drift Detection', () => {
|
||||
it('should display drift warning when drift detected', fakeAsync(() => {
|
||||
const resultWithDrift: ReplayResult = {
|
||||
...mockResult,
|
||||
status: 'drift',
|
||||
digestMatch: false,
|
||||
replayDigest: 'sha256:xyz789...',
|
||||
driftItems: [
|
||||
{ artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' },
|
||||
{ artifact: 'Attestation', field: 'signature', original: 'sig-aaa', replay: 'sig-bbb', severity: 'error' }
|
||||
]
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(resultWithDrift);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const driftWarning = fixture.debugElement.query(By.css('.proof-replay__drift-warning'));
|
||||
expect(driftWarning).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should list drift items', fakeAsync(() => {
|
||||
const resultWithDrift: ReplayResult = {
|
||||
...mockResult,
|
||||
status: 'drift',
|
||||
driftItems: [
|
||||
{ artifact: 'Scanner Report', field: 'timestamp', original: '2024-01-01T00:00:00Z', replay: '2024-01-01T00:00:01Z', severity: 'warning' }
|
||||
]
|
||||
};
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
component.result.set(resultWithDrift);
|
||||
component.activeJob.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
const driftItems = fixture.debugElement.queryAll(By.css('.proof-replay__drift-item'));
|
||||
expect(driftItems.length).toBe(1);
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('History Table', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display history table', () => {
|
||||
const historyTable = fixture.debugElement.query(By.css('.proof-replay__history'));
|
||||
expect(historyTable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display history rows', () => {
|
||||
const rows = fixture.debugElement.queryAll(By.css('.proof-replay__history-row'));
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should show status badges in history', () => {
|
||||
const badges = fixture.debugElement.queryAll(By.css('.proof-replay__history-status'));
|
||||
expect(badges.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should allow selecting a history item', fakeAsync(() => {
|
||||
const firstRow = fixture.debugElement.queryAll(By.css('.proof-replay__history-row'))[0];
|
||||
firstRow.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockReplayApi.getReplayResult).toHaveBeenCalled();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Cancel Replay', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
// Trigger a replay
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should display cancel button when replay is running', () => {
|
||||
const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn'));
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should cancel replay on button click', fakeAsync(() => {
|
||||
const cancelBtn = fixture.debugElement.query(By.css('.proof-replay__cancel-btn'));
|
||||
cancelBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockReplayApi.cancelReplay).toHaveBeenCalledWith('replay-001');
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error on replay failure', fakeAsync(() => {
|
||||
mockReplayApi.triggerReplay.and.returnValue(throwError(() => new Error('Replay failed')));
|
||||
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.debugElement.query(By.css('.proof-replay__trigger-btn'));
|
||||
button.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.proof-replay__error'));
|
||||
expect(error).toBeTruthy();
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.componentRef.setInput('scanId', 'scan-123');
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
afterEach(fakeAsync(() => {
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
const headings = fixture.debugElement.queryAll(By.css('h2, h3, h4'));
|
||||
expect(headings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible buttons', () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button'));
|
||||
buttons.forEach(button => {
|
||||
const hasText = button.nativeElement.textContent.trim().length > 0;
|
||||
const hasAriaLabel = button.nativeElement.hasAttribute('aria-label');
|
||||
expect(hasText || hasAriaLabel).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper table structure', () => {
|
||||
const tables = fixture.debugElement.queryAll(By.css('table'));
|
||||
tables.forEach(table => {
|
||||
const thead = table.query(By.css('thead'));
|
||||
expect(thead).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should announce progress updates', () => {
|
||||
const liveRegion = fixture.debugElement.query(By.css('[aria-live]'));
|
||||
expect(liveRegion).toBeTruthy();
|
||||
});
|
||||
expect(replayApi.cancelJob).toHaveBeenCalledWith('job-1');
|
||||
expect(component.currentJob()?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ describe('PathViewerComponent', () => {
|
||||
sink: mockSink,
|
||||
intermediateCount: 5,
|
||||
keyNodes: [mockKeyNode],
|
||||
fullPath: ['entry-1', 'mid-1', 'mid-2', 'key-1', 'mid-3', 'sink-1']
|
||||
fullPath: ['entry-1', 'mid-1', 'mid-2', 'key-1', 'mid-3', 'sink-1'],
|
||||
length: 6,
|
||||
confidence: 0.92,
|
||||
hasGates: false
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -94,7 +97,7 @@ describe('PathViewerComponent', () => {
|
||||
|
||||
it('should emit nodeClick when node is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.nodeClick, 'emit');
|
||||
const emitSpy = spyOn(component.nodeClick, 'emit');
|
||||
|
||||
component.onNodeClick(mockKeyNode);
|
||||
|
||||
@@ -103,7 +106,7 @@ describe('PathViewerComponent', () => {
|
||||
|
||||
it('should emit expandRequest when toggling expand', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.expandRequest, 'emit');
|
||||
const emitSpy = spyOn(component.expandRequest, 'emit');
|
||||
|
||||
component.toggleExpand();
|
||||
|
||||
|
||||
@@ -1,60 +1,104 @@
|
||||
/**
|
||||
* RiskDriftCardComponent Unit Tests
|
||||
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
* Task: UI-013
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RiskDriftCardComponent } from './risk-drift-card.component';
|
||||
import { DriftResult, DriftedSink, DriftSummary } from '../../models/drift.models';
|
||||
import { CompressedPath, PathNode } from '../../models/path-viewer.models';
|
||||
|
||||
describe('RiskDriftCardComponent', () => {
|
||||
let fixture: ComponentFixture<RiskDriftCardComponent>;
|
||||
let component: RiskDriftCardComponent;
|
||||
|
||||
const mockSink1: DriftedSink = {
|
||||
sinkId: 'sink-1',
|
||||
sinkSymbol: 'SqlCommand.Execute',
|
||||
driftKind: 'became_reachable',
|
||||
riskDelta: 0.25,
|
||||
severity: 'high',
|
||||
cveId: 'CVE-2021-12345',
|
||||
pathCount: 2
|
||||
const entrypoint: PathNode = {
|
||||
nodeId: 'entry-1',
|
||||
symbol: 'Program.Main',
|
||||
isChanged: false,
|
||||
nodeType: 'entrypoint',
|
||||
};
|
||||
|
||||
const mockSink2: DriftedSink = {
|
||||
sinkId: 'sink-2',
|
||||
sinkSymbol: 'ProcessBuilder.start',
|
||||
driftKind: 'became_unreachable',
|
||||
riskDelta: -0.15,
|
||||
severity: 'critical',
|
||||
pathCount: 1
|
||||
};
|
||||
const makeSink = (nodeId: string, symbol: string): PathNode => ({
|
||||
nodeId,
|
||||
symbol,
|
||||
isChanged: false,
|
||||
nodeType: 'sink',
|
||||
});
|
||||
|
||||
const mockSink3: DriftedSink = {
|
||||
sinkId: 'sink-3',
|
||||
sinkSymbol: 'Runtime.exec',
|
||||
driftKind: 'became_reachable',
|
||||
riskDelta: 0.10,
|
||||
severity: 'medium',
|
||||
pathCount: 3
|
||||
};
|
||||
const makePath = (sink: PathNode, confidence: number): CompressedPath => ({
|
||||
entrypoint,
|
||||
sink,
|
||||
intermediateCount: 0,
|
||||
keyNodes: [],
|
||||
fullPath: [entrypoint.nodeId, sink.nodeId],
|
||||
length: 2,
|
||||
confidence,
|
||||
hasGates: false,
|
||||
});
|
||||
|
||||
const sink1 = makeSink('sink-1', 'SqlCommand.Execute');
|
||||
const sink2 = makeSink('sink-2', 'ProcessBuilder.start');
|
||||
const sink3 = makeSink('sink-3', 'Runtime.exec');
|
||||
|
||||
const mockSinks: DriftedSink[] = [
|
||||
{
|
||||
sink: sink1,
|
||||
previousBucket: 'unreachable',
|
||||
currentBucket: 'runtime',
|
||||
cveId: 'CVE-2021-12345',
|
||||
severity: 'high',
|
||||
paths: [makePath(sink1, 0.92)],
|
||||
isRiskIncrease: true,
|
||||
riskDelta: 0.25,
|
||||
newPathCount: 2,
|
||||
removedPathCount: 0,
|
||||
},
|
||||
{
|
||||
sink: sink2,
|
||||
previousBucket: 'runtime',
|
||||
currentBucket: 'unreachable',
|
||||
severity: 'critical',
|
||||
paths: [makePath(sink2, 0.77)],
|
||||
isRiskIncrease: false,
|
||||
riskDelta: -0.15,
|
||||
newPathCount: 0,
|
||||
removedPathCount: 1,
|
||||
},
|
||||
{
|
||||
sink: sink3,
|
||||
previousBucket: null,
|
||||
currentBucket: 'runtime',
|
||||
severity: 'medium',
|
||||
paths: [makePath(sink3, 0.81)],
|
||||
isRiskIncrease: true,
|
||||
riskDelta: 0.1,
|
||||
newPathCount: 3,
|
||||
removedPathCount: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary: DriftSummary = {
|
||||
totalDrifts: 3,
|
||||
newlyReachable: 2,
|
||||
newlyUnreachable: 1,
|
||||
totalSinks: 3,
|
||||
increasedReachability: 2,
|
||||
decreasedReachability: 1,
|
||||
unchangedReachability: 0,
|
||||
newSinks: 1,
|
||||
removedSinks: 0,
|
||||
riskTrend: 'increasing',
|
||||
baselineScanId: 'scan-base',
|
||||
currentScanId: 'scan-current'
|
||||
netRiskDelta: 0.2,
|
||||
bySeverity: {
|
||||
critical: 1,
|
||||
high: 1,
|
||||
medium: 1,
|
||||
low: 0,
|
||||
info: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const mockDriftResult: DriftResult = {
|
||||
id: 'drift-1',
|
||||
comparedAt: '2025-12-19T12:00:00Z',
|
||||
baseGraphId: 'graph-base',
|
||||
headGraphId: 'graph-head',
|
||||
driftedSinks: mockSinks,
|
||||
summary: mockSummary,
|
||||
driftedSinks: [mockSink1, mockSink2, mockSink3],
|
||||
attestationDigest: 'sha256:abc123',
|
||||
createdAt: '2025-12-19T12:00:00Z'
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -67,123 +111,112 @@ describe('RiskDriftCardComponent', () => {
|
||||
fixture.componentRef.setInput('drift', mockDriftResult);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
it('creates', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should compute summary from drift', () => {
|
||||
it('computes summary from drift', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.summary()).toEqual(mockSummary);
|
||||
});
|
||||
|
||||
it('should detect signed attestation', () => {
|
||||
it('detects signed attestation', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.isSigned()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unsigned drift when no attestation', () => {
|
||||
const unsignedDrift = { ...mockDriftResult, attestationDigest: undefined };
|
||||
fixture.componentRef.setInput('drift', unsignedDrift);
|
||||
it('detects unsigned drift when no attestation', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, attestationDigest: undefined });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSigned()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show upward trend icon for increasing risk', () => {
|
||||
it('shows upward trend icon for increasing risk', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.trendIcon()).toBe('↑');
|
||||
});
|
||||
|
||||
it('should show downward trend icon for decreasing risk', () => {
|
||||
const decreasingDrift = {
|
||||
...mockDriftResult,
|
||||
summary: { ...mockSummary, riskTrend: 'decreasing' as const }
|
||||
};
|
||||
fixture.componentRef.setInput('drift', decreasingDrift);
|
||||
it('shows downward trend icon for decreasing risk', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, summary: { ...mockSummary, riskTrend: 'decreasing' } });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.trendIcon()).toBe('↓');
|
||||
});
|
||||
|
||||
it('should show stable trend icon for stable risk', () => {
|
||||
const stableDrift = {
|
||||
...mockDriftResult,
|
||||
summary: { ...mockSummary, riskTrend: 'stable' as const }
|
||||
};
|
||||
fixture.componentRef.setInput('drift', stableDrift);
|
||||
it('shows stable trend icon for stable risk', () => {
|
||||
fixture.componentRef.setInput('drift', { ...mockDriftResult, summary: { ...mockSummary, riskTrend: 'stable' } });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.trendIcon()).toBe('→');
|
||||
});
|
||||
|
||||
it('should compute trend CSS class correctly', () => {
|
||||
it('computes trend CSS class correctly', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.trendClass()).toBe('risk-drift-card__trend--increasing');
|
||||
});
|
||||
|
||||
it('should show max preview sinks (default 3)', () => {
|
||||
it('shows max preview sinks (default 3)', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.previewSinks().length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should respect custom maxPreviewSinks', () => {
|
||||
it('respects custom maxPreviewSinks', () => {
|
||||
fixture.componentRef.setInput('maxPreviewSinks', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.previewSinks().length).toBe(1);
|
||||
});
|
||||
|
||||
it('should sort preview sinks by severity first', () => {
|
||||
it('sorts preview sinks by severity first', () => {
|
||||
fixture.detectChanges();
|
||||
const sinks = component.previewSinks();
|
||||
|
||||
// Critical should come before high
|
||||
const criticalIndex = sinks.findIndex(s => s.severity === 'critical');
|
||||
const highIndex = sinks.findIndex(s => s.severity === 'high');
|
||||
const criticalIndex = sinks.findIndex((s) => s.severity === 'critical');
|
||||
const highIndex = sinks.findIndex((s) => s.severity === 'high');
|
||||
|
||||
if (criticalIndex !== -1 && highIndex !== -1) {
|
||||
expect(criticalIndex).toBeLessThan(highIndex);
|
||||
}
|
||||
});
|
||||
|
||||
it('should compute additional sinks count', () => {
|
||||
it('computes additional sinks count', () => {
|
||||
fixture.detectChanges();
|
||||
// 3 total sinks, max 3 preview = 0 additional
|
||||
expect(component.additionalSinksCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should compute additional sinks when more than max', () => {
|
||||
it('computes additional sinks when more than max', () => {
|
||||
fixture.componentRef.setInput('maxPreviewSinks', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
// 3 total sinks, max 1 preview = 2 additional
|
||||
expect(component.additionalSinksCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit viewDetails when view details is clicked', () => {
|
||||
it('emits viewDetails when view details is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.viewDetails, 'emit');
|
||||
const emitSpy = spyOn(component.viewDetails, 'emit');
|
||||
|
||||
component.onViewDetails();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit sinkClick when a sink is clicked', () => {
|
||||
it('emits sinkClick when a sink is clicked', () => {
|
||||
fixture.detectChanges();
|
||||
const emitSpy = jest.spyOn(component.sinkClick, 'emit');
|
||||
const emitSpy = spyOn(component.sinkClick, 'emit');
|
||||
|
||||
component.onSinkClick(mockSink1);
|
||||
component.onSinkClick(mockSinks[0]);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockSink1);
|
||||
expect(emitSpy).toHaveBeenCalledWith(mockSinks[0]);
|
||||
});
|
||||
|
||||
it('should be non-compact by default', () => {
|
||||
it('is non-compact by default', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.compact()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show attestation by default', () => {
|
||||
it('shows attestation by default', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component.showAttestation()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -135,3 +135,4 @@ export class RiskDriftCardComponent {
|
||||
return labels[bucket] ?? bucket;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('ReachabilityExplainComponent', () => {
|
||||
|
||||
it('should select node on click', fakeAsync(() => {
|
||||
const node = fixture.debugElement.query(By.css('.reachability-explain__node-group'));
|
||||
node.nativeElement.click();
|
||||
node.nativeElement.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -68,6 +68,23 @@ export interface TimeSeriesPoint {
|
||||
readonly low: number;
|
||||
}
|
||||
|
||||
const SEVERITIES = [
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' },
|
||||
{ key: 'medium', label: 'Medium', color: '#d97706' },
|
||||
{ key: 'low', label: 'Low', color: '#059669' }
|
||||
] as const;
|
||||
|
||||
type SeverityKey = typeof SEVERITIES[number]['key'];
|
||||
|
||||
const CHART_SERIES = [
|
||||
{ key: 'riskScore', label: 'Risk Score', color: '#3b82f6' },
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' }
|
||||
] as const;
|
||||
|
||||
type ChartSeriesKey = typeof CHART_SERIES[number]['key'];
|
||||
|
||||
// ============================================================================
|
||||
// Injection Token & API
|
||||
// ============================================================================
|
||||
@@ -877,18 +894,8 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
readonly viewMode = signal<'side-by-side' | 'timeline'>('side-by-side');
|
||||
|
||||
// Static data
|
||||
readonly severities = [
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' },
|
||||
{ key: 'medium', label: 'Medium', color: '#d97706' },
|
||||
{ key: 'low', label: 'Low', color: '#059669' }
|
||||
];
|
||||
|
||||
readonly chartSeries = [
|
||||
{ key: 'riskScore', label: 'Risk Score', color: '#3b82f6' },
|
||||
{ key: 'critical', label: 'Critical', color: '#dc2626' },
|
||||
{ key: 'high', label: 'High', color: '#ea580c' }
|
||||
];
|
||||
readonly severities = SEVERITIES;
|
||||
readonly chartSeries = CHART_SERIES;
|
||||
|
||||
readonly yAxisTicks = [0, 25, 50, 75, 100];
|
||||
|
||||
@@ -954,17 +961,17 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityPercent(scores: ScoreMetrics, key: string): number {
|
||||
getSeverityPercent(scores: ScoreMetrics, key: SeverityKey): number {
|
||||
const total = scores.totalVulnerabilities || 1;
|
||||
const count = (scores as Record<string, number>)[key] || 0;
|
||||
const count = scores[key] || 0;
|
||||
return (count / total) * 100;
|
||||
}
|
||||
|
||||
getSeverityCount(scores: ScoreMetrics, key: string): number {
|
||||
return (scores as Record<string, number>)[key] || 0;
|
||||
getSeverityCount(scores: ScoreMetrics, key: SeverityKey): number {
|
||||
return scores[key] || 0;
|
||||
}
|
||||
|
||||
getSeverityDelta(key: string): number {
|
||||
getSeverityDelta(key: SeverityKey): number {
|
||||
const comp = this.comparison();
|
||||
if (!comp) return 0;
|
||||
const before = this.getSeverityCount(comp.before.scores, key);
|
||||
@@ -972,7 +979,7 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
return after - before;
|
||||
}
|
||||
|
||||
getSeverityChangeClass(key: string): string {
|
||||
getSeverityChangeClass(key: SeverityKey): string {
|
||||
const delta = this.getSeverityDelta(key);
|
||||
if (delta < 0) return 'improved';
|
||||
if (delta > 0) return 'worsened';
|
||||
@@ -1010,10 +1017,10 @@ export class ScoreComparisonComponent implements OnInit {
|
||||
return 50 + index * spacing;
|
||||
}
|
||||
|
||||
getSeriesPoints(key: string): string {
|
||||
getSeriesPoints(key: ChartSeriesKey): string {
|
||||
return this.timeSeries()
|
||||
.map((point, i) => {
|
||||
const value = (point as Record<string, number>)[key] || 0;
|
||||
const value = point[key] || 0;
|
||||
return `${this.getXPosition(i)},${this.getYPosition(value)}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
@@ -1,368 +1,182 @@
|
||||
/**
|
||||
* Tests for Unknowns Queue Component
|
||||
* Sprint: SPRINT_3500_0004_0002 - T8
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { delay, of, throwError } from 'rxjs';
|
||||
import { UnknownsQueueComponent } from './unknowns-queue.component';
|
||||
import { UNKNOWNS_API } from '../../core/api/unknowns.client';
|
||||
import { UNKNOWNS_API, UnknownsApi } from '../../core/api/unknowns.client';
|
||||
import { UnknownEntry, UnknownsListResponse, UnknownsSummary } from '../../core/api/unknowns.models';
|
||||
|
||||
describe('UnknownsQueueComponent', () => {
|
||||
let component: UnknownsQueueComponent;
|
||||
let fixture: ComponentFixture<UnknownsQueueComponent>;
|
||||
let mockUnknownsApi: jasmine.SpyObj<any>;
|
||||
let unknownsApi: jasmine.SpyObj<UnknownsApi>;
|
||||
|
||||
const mockUnknowns = {
|
||||
items: [
|
||||
{
|
||||
unknownId: 'unk-001',
|
||||
purl: 'pkg:npm/lodash@4.17.21',
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
ecosystem: 'npm',
|
||||
band: 'HOT' as const,
|
||||
firstSeen: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
occurrenceCount: 15,
|
||||
affectedScans: 8,
|
||||
status: 'pending' as const
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-002',
|
||||
purl: 'pkg:pypi/requests@2.28.0',
|
||||
name: 'requests',
|
||||
version: '2.28.0',
|
||||
ecosystem: 'pypi',
|
||||
band: 'WARM' as const,
|
||||
firstSeen: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
occurrenceCount: 5,
|
||||
affectedScans: 3,
|
||||
status: 'pending' as const
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-003',
|
||||
purl: 'pkg:maven/com.example/old-lib@1.0.0',
|
||||
name: 'old-lib',
|
||||
version: '1.0.0',
|
||||
ecosystem: 'maven',
|
||||
band: 'COLD' as const,
|
||||
firstSeen: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
lastSeen: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
occurrenceCount: 2,
|
||||
affectedScans: 1,
|
||||
status: 'pending' as const
|
||||
}
|
||||
],
|
||||
totalCount: 3,
|
||||
pageSize: 20,
|
||||
pageNumber: 1
|
||||
const scanId = 'scan-123';
|
||||
|
||||
const unknowns: readonly UnknownEntry[] = [
|
||||
{
|
||||
unknownId: 'unk-001',
|
||||
package: { name: 'lodash', version: '4.17.21', ecosystem: 'npm', purl: 'pkg:npm/lodash@4.17.21' },
|
||||
band: 'HOT',
|
||||
status: 'pending',
|
||||
rank: 1,
|
||||
occurrenceCount: 15,
|
||||
firstSeenAt: '2025-12-18T00:00:00Z',
|
||||
lastSeenAt: '2025-12-19T00:00:00Z',
|
||||
ageInDays: 2,
|
||||
relatedCves: ['CVE-2024-0001'],
|
||||
recentOccurrences: [],
|
||||
},
|
||||
{
|
||||
unknownId: 'unk-002',
|
||||
package: { name: 'requests', version: '2.28.0', ecosystem: 'pypi', purl: 'pkg:pypi/requests@2.28.0' },
|
||||
band: 'WARM',
|
||||
status: 'pending',
|
||||
rank: 2,
|
||||
occurrenceCount: 5,
|
||||
firstSeenAt: '2025-12-10T00:00:00Z',
|
||||
lastSeenAt: '2025-12-19T00:00:00Z',
|
||||
ageInDays: 10,
|
||||
recentOccurrences: [],
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse: UnknownsListResponse = {
|
||||
items: unknowns,
|
||||
total: unknowns.length,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
const summary: UnknownsSummary = {
|
||||
hotCount: 1,
|
||||
warmCount: 1,
|
||||
coldCount: 0,
|
||||
totalCount: 2,
|
||||
pendingCount: 2,
|
||||
escalatedCount: 0,
|
||||
resolvedToday: 0,
|
||||
oldestUnresolvedDays: 10,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockUnknownsApi = jasmine.createSpyObj('UnknownsApi', [
|
||||
'getUnknowns',
|
||||
'escalateUnknown',
|
||||
'resolveUnknown',
|
||||
'bulkEscalate',
|
||||
'bulkResolve'
|
||||
unknownsApi = jasmine.createSpyObj<UnknownsApi>('UnknownsApi', [
|
||||
'list',
|
||||
'get',
|
||||
'getSummary',
|
||||
'escalate',
|
||||
'resolve',
|
||||
'bulkAction',
|
||||
]);
|
||||
|
||||
mockUnknownsApi.getUnknowns.and.returnValue(of(mockUnknowns));
|
||||
unknownsApi.list.and.returnValue(of(listResponse).pipe(delay(1)));
|
||||
unknownsApi.getSummary.and.returnValue(of(summary).pipe(delay(1)));
|
||||
unknownsApi.escalate.and.returnValue(of({ ...unknowns[0], status: 'escalated', recentOccurrences: [] }));
|
||||
unknownsApi.resolve.and.returnValue(of({ ...unknowns[0], status: 'resolved', recentOccurrences: [] }));
|
||||
unknownsApi.bulkAction.and.returnValue(of({ successCount: 2, failureCount: 0 }));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [UnknownsQueueComponent],
|
||||
providers: [
|
||||
{ provide: UNKNOWNS_API, useValue: mockUnknownsApi }
|
||||
]
|
||||
providers: [{ provide: UNKNOWNS_API, useValue: unknownsApi }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnknownsQueueComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
function render(): void {
|
||||
fixture.componentRef.setInput('scanId', scanId);
|
||||
fixture.componentRef.setInput('refreshInterval', 0);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('should show loading state initially', () => {
|
||||
fixture.detectChanges();
|
||||
it('loads unknowns and summary on init', fakeAsync(() => {
|
||||
render();
|
||||
|
||||
const loading = fixture.debugElement.query(By.css('.unknowns-queue__loading'));
|
||||
expect(loading).toBeTruthy();
|
||||
});
|
||||
expect(unknownsApi.list).toHaveBeenCalledWith({ scanId });
|
||||
expect(unknownsApi.getSummary).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__loading'))).toBeTruthy();
|
||||
|
||||
it('should load unknowns on init', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalled();
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__loading'))).toBeFalsy();
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(2);
|
||||
}));
|
||||
|
||||
it('filters items by band tab', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
const hotTab = tabs.find(t => (t.nativeElement.textContent as string).includes('Hot'));
|
||||
expect(hotTab).toBeTruthy();
|
||||
|
||||
hotTab!.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hotTab!.nativeElement.getAttribute('aria-selected')).toBe('true');
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('filters items by search query', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.searchQuery.set('lodash');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.queryAll(By.css('.unknowns-queue__item')).length).toBe(1);
|
||||
}));
|
||||
|
||||
it('escalates an unknown when clicking Escalate', fakeAsync(() => {
|
||||
const escalatedSpy = spyOn(component.unknownEscalated, 'emit');
|
||||
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css('button[title="Escalate"]'));
|
||||
expect(buttons.length).toBe(2);
|
||||
|
||||
buttons[0].nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(unknownsApi.escalate).toHaveBeenCalledWith(jasmine.objectContaining({ unknownId: 'unk-001' }));
|
||||
expect(escalatedSpy).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('performs bulk resolve for selected items', fakeAsync(() => {
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
unknownsApi.list.and.returnValue(of(listResponse));
|
||||
|
||||
const selectAll = fixture.debugElement.query(By.css('#select-all'));
|
||||
selectAll.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const bulkResolve = fixture.debugElement.query(By.css('.unknowns-queue__bulk-btn--resolve'));
|
||||
bulkResolve.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(unknownsApi.bulkAction).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
unknownIds: ['unk-001', 'unk-002'],
|
||||
action: 'resolve',
|
||||
resolutionAction: 'other',
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should display unknowns after loading', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
it('shows an error message if list fails', fakeAsync(() => {
|
||||
unknownsApi.list.and.returnValue(throwError(() => new Error('boom')));
|
||||
|
||||
const rows = fixture.debugElement.queryAll(By.css('.unknowns-queue__row'));
|
||||
expect(rows.length).toBe(3);
|
||||
}));
|
||||
});
|
||||
render();
|
||||
tick(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
describe('Band Tabs', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display all band tabs', () => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
expect(tabs.length).toBe(4); // All, HOT, WARM, COLD
|
||||
});
|
||||
|
||||
it('should filter by band when tab clicked', fakeAsync(() => {
|
||||
const hotTab = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'))[1];
|
||||
hotTab.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
band: 'HOT'
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should show active state on selected tab', fakeAsync(() => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('.unknowns-queue__tab'));
|
||||
tabs[1].nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(tabs[1].nativeElement.classList.contains('unknowns-queue__tab--active')).toBeTrue();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Search and Filter', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have search input', () => {
|
||||
const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search'));
|
||||
expect(searchInput).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should filter on search input', fakeAsync(() => {
|
||||
const searchInput = fixture.debugElement.query(By.css('.unknowns-queue__search input'));
|
||||
searchInput.nativeElement.value = 'lodash';
|
||||
searchInput.nativeElement.dispatchEvent(new Event('input'));
|
||||
tick(300); // debounce
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockUnknownsApi.getUnknowns).toHaveBeenCalledWith(jasmine.objectContaining({
|
||||
search: 'lodash'
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should have ecosystem filter', () => {
|
||||
const ecosystemFilter = fixture.debugElement.query(By.css('.unknowns-queue__filter select'));
|
||||
expect(ecosystemFilter).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Selection', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have select all checkbox', () => {
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all'));
|
||||
expect(selectAll).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should select all when checkbox clicked', fakeAsync(() => {
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input'));
|
||||
selectAll.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const checkedBoxes = fixture.debugElement.queryAll(By.css('.unknowns-queue__row-checkbox:checked'));
|
||||
expect(checkedBoxes.length).toBe(3);
|
||||
}));
|
||||
|
||||
it('should enable bulk actions when items selected', fakeAsync(() => {
|
||||
const checkbox = fixture.debugElement.query(By.css('.unknowns-queue__row-checkbox'));
|
||||
checkbox.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const bulkActions = fixture.debugElement.query(By.css('.unknowns-queue__bulk-actions'));
|
||||
expect(bulkActions).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have escalate button for each row', () => {
|
||||
const escalateBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__escalate-btn'));
|
||||
expect(escalateBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should have resolve button for each row', () => {
|
||||
const resolveBtns = fixture.debugElement.queryAll(By.css('.unknowns-queue__resolve-btn'));
|
||||
expect(resolveBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should call escalate API when button clicked', fakeAsync(() => {
|
||||
mockUnknownsApi.escalateUnknown.and.returnValue(of({ success: true }));
|
||||
|
||||
const escalateBtn = fixture.debugElement.query(By.css('.unknowns-queue__escalate-btn'));
|
||||
escalateBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.escalateUnknown).toHaveBeenCalledWith('unk-001');
|
||||
}));
|
||||
|
||||
it('should call resolve API when button clicked', fakeAsync(() => {
|
||||
mockUnknownsApi.resolveUnknown.and.returnValue(of({ success: true }));
|
||||
|
||||
const resolveBtn = fixture.debugElement.query(By.css('.unknowns-queue__resolve-btn'));
|
||||
resolveBtn.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.resolveUnknown).toHaveBeenCalledWith('unk-001', jasmine.any(Object));
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Bulk Actions', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Select all items
|
||||
const selectAll = fixture.debugElement.query(By.css('.unknowns-queue__select-all input'));
|
||||
selectAll.nativeElement.click();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should perform bulk escalate', fakeAsync(() => {
|
||||
mockUnknownsApi.bulkEscalate.and.returnValue(of({ success: true }));
|
||||
|
||||
const bulkEscalate = fixture.debugElement.query(By.css('.unknowns-queue__bulk-escalate'));
|
||||
bulkEscalate.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.bulkEscalate).toHaveBeenCalledWith(['unk-001', 'unk-002', 'unk-003']);
|
||||
}));
|
||||
|
||||
it('should perform bulk resolve', fakeAsync(() => {
|
||||
mockUnknownsApi.bulkResolve.and.returnValue(of({ success: true }));
|
||||
|
||||
const bulkResolve = fixture.debugElement.query(By.css('.unknowns-queue__bulk-resolve'));
|
||||
bulkResolve.nativeElement.click();
|
||||
tick();
|
||||
|
||||
expect(mockUnknownsApi.bulkResolve).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Pagination', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should display pagination controls', () => {
|
||||
const pagination = fixture.debugElement.query(By.css('.unknowns-queue__pagination'));
|
||||
expect(pagination).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show total count', () => {
|
||||
const totalCount = fixture.debugElement.query(By.css('.unknowns-queue__total-count'));
|
||||
expect(totalCount.nativeElement.textContent).toContain('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto Refresh', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have auto-refresh toggle', () => {
|
||||
const autoRefresh = fixture.debugElement.query(By.css('.unknowns-queue__auto-refresh'));
|
||||
expect(autoRefresh).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should display error when API fails', fakeAsync(() => {
|
||||
mockUnknownsApi.getUnknowns.and.returnValue(throwError(() => new Error('API Error')));
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = fixture.debugElement.query(By.css('.unknowns-queue__error'));
|
||||
expect(error).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should have proper table structure', () => {
|
||||
const table = fixture.debugElement.query(By.css('table'));
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
const headers = fixture.debugElement.queryAll(By.css('th'));
|
||||
expect(headers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have role="tablist" on band tabs', () => {
|
||||
const tablist = fixture.debugElement.query(By.css('[role="tablist"]'));
|
||||
expect(tablist).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have role="tab" on each tab', () => {
|
||||
const tabs = fixture.debugElement.queryAll(By.css('[role="tab"]'));
|
||||
expect(tabs.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should have aria-selected on active tab', () => {
|
||||
const activeTab = fixture.debugElement.query(By.css('[aria-selected="true"]'));
|
||||
expect(activeTab).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have labels for checkboxes', () => {
|
||||
const checkboxes = fixture.debugElement.queryAll(By.css('input[type="checkbox"]'));
|
||||
checkboxes.forEach(checkbox => {
|
||||
const hasLabel = checkbox.nativeElement.hasAttribute('aria-label') ||
|
||||
checkbox.nativeElement.hasAttribute('aria-labelledby') ||
|
||||
checkbox.nativeElement.id;
|
||||
expect(hasLabel).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(fixture.debugElement.query(By.css('.unknowns-queue__error'))).toBeTruthy();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ interface SortConfig {
|
||||
</h2>
|
||||
<div class="unknowns-queue__stats" *ngIf="summary()">
|
||||
<span class="unknowns-queue__stat unknowns-queue__stat--total">
|
||||
{{ summary()!.total }} Total
|
||||
{{ summary()!.totalCount }} Total
|
||||
</span>
|
||||
<span class="unknowns-queue__stat unknowns-queue__stat--hot">
|
||||
🔴 {{ summary()!.hotCount }} Hot
|
||||
@@ -733,7 +733,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
// State
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly unknowns = signal<UnknownEntry[]>([]);
|
||||
readonly unknowns = signal<readonly UnknownEntry[]>([]);
|
||||
readonly summary = signal<UnknownsSummary | null>(null);
|
||||
readonly activeTab = signal<TabId>('all');
|
||||
readonly searchQuery = signal('');
|
||||
@@ -826,9 +826,8 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const filter: UnknownsFilter = {};
|
||||
if (this.workspaceId()) filter.workspaceId = this.workspaceId();
|
||||
if (this.scanId()) filter.scanId = this.scanId();
|
||||
const scanId = this.scanId();
|
||||
const filter: UnknownsFilter = scanId ? { scanId } : {};
|
||||
|
||||
this.unknownsApi.list(filter).subscribe({
|
||||
next: (response) => {
|
||||
@@ -862,7 +861,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
case 'hot': return s.hotCount;
|
||||
case 'warm': return s.warmCount;
|
||||
case 'cold': return s.coldCount;
|
||||
case 'all': return s.total;
|
||||
case 'all': return s.totalCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,7 +930,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
resolveUnknown(unknown: UnknownEntry): void {
|
||||
const request: ResolveUnknownRequest = {
|
||||
unknownId: unknown.unknownId,
|
||||
resolution: 'resolved',
|
||||
action: 'other',
|
||||
notes: 'Resolved from UI'
|
||||
};
|
||||
|
||||
@@ -955,7 +954,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.unknownsApi.bulkAction({
|
||||
unknownIds: ids,
|
||||
action: 'escalate',
|
||||
reason: 'Bulk escalation from UI'
|
||||
notes: 'Bulk escalation from UI'
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.loadUnknowns();
|
||||
@@ -972,7 +971,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy {
|
||||
this.unknownsApi.bulkAction({
|
||||
unknownIds: ids,
|
||||
action: 'resolve',
|
||||
resolution: 'resolved',
|
||||
resolutionAction: 'other',
|
||||
notes: 'Bulk resolved from UI'
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -406,13 +406,12 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
async openWitnessModal(vuln: Vulnerability): Promise<void> {
|
||||
this.witnessLoading.set(true);
|
||||
try {
|
||||
// Map reachability status to confidence tier
|
||||
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
|
||||
|
||||
// Get or create witness data
|
||||
const witness = await firstValueFrom(
|
||||
this.witnessClient.getWitnessForVulnerability(vuln.vulnId)
|
||||
);
|
||||
// Map reachability status to confidence tier
|
||||
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
|
||||
|
||||
// Get or create witness data
|
||||
const witnesses = await firstValueFrom(this.witnessClient.getWitnessesForVuln(vuln.vulnId));
|
||||
const witness = witnesses.at(0);
|
||||
|
||||
if (witness) {
|
||||
this.witnessModalData.set(witness);
|
||||
@@ -432,14 +431,14 @@ export class VulnerabilityExplorerComponent implements OnInit {
|
||||
confidenceScore: vuln.reachabilityScore ?? 0,
|
||||
isReachable: vuln.reachabilityStatus === 'reachable',
|
||||
callPath: [],
|
||||
gates: [],
|
||||
evidence: {
|
||||
callGraphHash: undefined,
|
||||
surfaceHash: undefined,
|
||||
sbomDigest: undefined,
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
gates: [],
|
||||
evidence: {
|
||||
callGraphHash: undefined,
|
||||
surfaceHash: undefined,
|
||||
analysisMethod: 'static',
|
||||
},
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
this.witnessModalData.set(placeholderWitness);
|
||||
this.showWitnessModal.set(true);
|
||||
}
|
||||
|
||||
@@ -548,9 +548,9 @@ export class ApprovalButtonComponent {
|
||||
});
|
||||
|
||||
/** Whether the confirmation form can be submitted. */
|
||||
readonly canSubmit = computed(() => {
|
||||
canSubmit(): boolean {
|
||||
return this.reason.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Methods
|
||||
|
||||
@@ -483,18 +483,20 @@ export class AttestationNodeComponent {
|
||||
|
||||
/** Format timestamp for display. */
|
||||
formatTimestamp(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
} catch {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('FindingListComponent', () => {
|
||||
fixture = TestBed.createComponent(FindingListComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('findings', mockFindings);
|
||||
fixture.componentRef.setInput('totalCount', mockFindings.length);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -138,8 +139,7 @@ describe('FindingListComponent', () => {
|
||||
|
||||
it('should calculate critical/high count', () => {
|
||||
const criticalHighCount = component.criticalHighCount();
|
||||
// f1 has score 85 (critical), f3 has 60 (high)
|
||||
expect(criticalHighCount).toBe(2);
|
||||
expect(criticalHighCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -101,18 +101,12 @@ export interface FindingSort {
|
||||
<span class="finding-list__spinner">⏳</span>
|
||||
<span>Loading findings...</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@else if (sortedFindings().length === 0) {
|
||||
} @else if (sortedFindings().length === 0) {
|
||||
<div class="finding-list__empty" role="status">
|
||||
<span class="finding-list__empty-icon">📋</span>
|
||||
<span class="finding-list__empty-text">{{ emptyMessage() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Findings List -->
|
||||
@else {
|
||||
} @else {
|
||||
<!-- Regular list (virtual scroll requires @angular/cdk, add if needed) -->
|
||||
<div class="finding-list__content">
|
||||
@for (finding of sortedFindings(); track trackByFinding($index, finding)) {
|
||||
@@ -281,7 +275,7 @@ export class FindingListComponent {
|
||||
/**
|
||||
* Current sort configuration.
|
||||
*/
|
||||
readonly sort = input<FindingSort | undefined>(undefined);
|
||||
readonly sort = input<FindingSort | undefined>({ field: 'score', direction: 'desc' });
|
||||
|
||||
/**
|
||||
* Total count for pagination display.
|
||||
@@ -399,7 +393,7 @@ export class FindingListComponent {
|
||||
criticalHighCount(): number {
|
||||
return this.sortedFindings().filter(f => {
|
||||
const score = f.score_explain?.risk_score ?? 0;
|
||||
return score >= 7.0;
|
||||
return score >= 70;
|
||||
}).length;
|
||||
}
|
||||
|
||||
@@ -438,7 +432,7 @@ export class FindingListComponent {
|
||||
getSortIndicator(field: FindingSortField): string {
|
||||
const currentSort = this.sort();
|
||||
if (currentSort?.field !== field) return '';
|
||||
return currentSort.direction === 'asc' ? '↑' : '↓';
|
||||
return currentSort.direction === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -453,9 +453,9 @@ export class FindingRowComponent {
|
||||
|
||||
readonly severityClass = computed(() => {
|
||||
const score = this.riskScore();
|
||||
if (score >= 9.0) return 'critical';
|
||||
if (score >= 7.0) return 'high';
|
||||
if (score >= 4.0) return 'medium';
|
||||
if (score >= 90) return 'critical';
|
||||
if (score >= 70) return 'high';
|
||||
if (score >= 40) return 'medium';
|
||||
if (score > 0) return 'low';
|
||||
return 'none';
|
||||
});
|
||||
@@ -473,14 +473,14 @@ export class FindingRowComponent {
|
||||
|
||||
readonly callPath = computed(() => this.finding()?.reachable_path ?? []);
|
||||
|
||||
readonly vexStatus = computed(() => this.finding()?.vex?.status);
|
||||
readonly vexStatus = computed(() => this.finding()?.vex?.status ?? 'under_investigation');
|
||||
|
||||
readonly vexJustification = computed(() => this.finding()?.vex?.justification);
|
||||
|
||||
readonly chainStatus = computed((): ChainStatusDisplay => {
|
||||
const refs = this.finding()?.attestation_refs;
|
||||
if (!refs || refs.length === 0) return 'empty';
|
||||
// Simplified - in real impl would check actual chain status
|
||||
if (refs.length < 3) return 'partial';
|
||||
return 'complete';
|
||||
});
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ export interface RekorReference {
|
||||
|
||||
<span class="rekor-link__content" *ngIf="!compact()">
|
||||
<span class="rekor-link__label">Rekor Log</span>
|
||||
<span class="rekor-link__index">#{{ logIndex() }}</span>
|
||||
<span class="rekor-link__index">#{{ effectiveLogIndex() }}</span>
|
||||
</span>
|
||||
|
||||
<span class="rekor-link__index-only" *ngIf="compact()">
|
||||
#{{ logIndex() }}
|
||||
#{{ effectiveLogIndex() }}
|
||||
</span>
|
||||
|
||||
<span class="rekor-link__external" aria-hidden="true">↗</span>
|
||||
|
||||
@@ -199,7 +199,9 @@ describe('WitnessModalComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should generate JSON download', () => {
|
||||
it('should generate JSON download', async () => {
|
||||
mockWitnessClient.downloadWitnessJson.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
|
||||
|
||||
// Mock URL.createObjectURL and document.createElement
|
||||
const mockUrl = 'blob:mock-url';
|
||||
spyOn(URL, 'createObjectURL').and.returnValue(mockUrl);
|
||||
@@ -212,7 +214,7 @@ describe('WitnessModalComponent', () => {
|
||||
};
|
||||
spyOn(document, 'createElement').and.returnValue(mockAnchor as unknown as HTMLAnchorElement);
|
||||
|
||||
component.downloadJson();
|
||||
await component.downloadJson();
|
||||
|
||||
expect(mockAnchor.download).toContain('witness-');
|
||||
expect(mockAnchor.download).toContain('.json');
|
||||
@@ -229,7 +231,7 @@ describe('WitnessModalComponent', () => {
|
||||
|
||||
it('should copy witness ID to clipboard', async () => {
|
||||
const writeTextSpy = jasmine.createSpy('writeText').and.returnValue(Promise.resolve(undefined));
|
||||
Object.assign(navigator, { clipboard: { writeText: writeTextSpy } });
|
||||
spyOnProperty(navigator, 'clipboard', 'get').and.returnValue({ writeText: writeTextSpy } as unknown as Clipboard);
|
||||
|
||||
await component.copyWitnessId();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Component, input, output, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { ReachabilityWitness, WitnessVerificationResult } from '../../core/api/witness.models';
|
||||
import { WitnessMockClient } from '../../core/api/witness.client';
|
||||
@@ -50,7 +51,7 @@ import { PathVisualizationComponent, PathVisualizationData } from './path-visual
|
||||
</div>
|
||||
<div class="witness-modal__package">
|
||||
{{ witness()!.packageName }}
|
||||
<span *ngIf="witness()!.packageVersion">@{{ witness()!.packageVersion }}</span>
|
||||
<span *ngIf="witness()!.packageVersion">@{{ witness()!.packageVersion }}</span>
|
||||
</div>
|
||||
<div class="witness-modal__purl" *ngIf="witness()!.purl">
|
||||
{{ witness()!.purl }}
|
||||
@@ -465,7 +466,7 @@ export class WitnessModalComponent {
|
||||
|
||||
this.isVerifying.set(true);
|
||||
try {
|
||||
const result = await this.witnessClient.verifyWitness(w.witnessId).toPromise();
|
||||
const result = await firstValueFrom(this.witnessClient.verifyWitness(w.witnessId));
|
||||
this.verificationResult.set(result ?? null);
|
||||
} catch (error) {
|
||||
this.verificationResult.set({
|
||||
@@ -486,7 +487,7 @@ export class WitnessModalComponent {
|
||||
if (!w) return;
|
||||
|
||||
try {
|
||||
const blob = await this.witnessClient.downloadWitnessJson(w.witnessId).toPromise();
|
||||
const blob = await firstValueFrom(this.witnessClient.downloadWitnessJson(w.witnessId));
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
Reference in New Issue
Block a user