feat(ui): consolidate finding lists on mounted surfaces [SPRINT-020]
Replace bespoke finding list in findings-container and inline table in release-detail security tab with shared FindingListComponent and FindingRowComponent using data adapters for type bridging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
|
||||
// Task: FE-OFL-004 - Verify shared FindingListComponent adoption on FindingsContainerComponent
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of, BehaviorSubject } from 'rxjs';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
import { FindingsContainerComponent } from './findings-container.component';
|
||||
import { ViewPreferenceService, FindingsViewMode } from '../../../core/services/view-preference.service';
|
||||
import { CompareService } from '../../compare/services/compare.service';
|
||||
import { SECURITY_FINDINGS_API } from '../../../core/api/security-findings.client';
|
||||
|
||||
describe('FindingsContainerComponent (shared FindingList adoption)', () => {
|
||||
let component: FindingsContainerComponent;
|
||||
let fixture: ComponentFixture<FindingsContainerComponent>;
|
||||
let queryParamMap$: BehaviorSubject<any>;
|
||||
let paramMap$: BehaviorSubject<any>;
|
||||
|
||||
const mockFindings = [
|
||||
{
|
||||
id: 'finding-1',
|
||||
advisoryId: 'CVE-2026-8001',
|
||||
package: 'backend-api',
|
||||
version: '2.5.0',
|
||||
severity: 'CRITICAL',
|
||||
vexStatus: 'affected',
|
||||
delta: 'new',
|
||||
firstSeen: '2026-02-10T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 'finding-2',
|
||||
advisoryId: 'CVE-2026-8002',
|
||||
package: 'frontend-lib',
|
||||
version: '1.0.3',
|
||||
severity: 'HIGH',
|
||||
vexStatus: 'fixed',
|
||||
delta: 'resolved',
|
||||
firstSeen: '2026-02-11T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'finding-3',
|
||||
advisoryId: 'CVE-2026-8003',
|
||||
package: 'crypto-util',
|
||||
version: '3.1.0',
|
||||
severity: 'LOW',
|
||||
vexStatus: 'not_affected',
|
||||
delta: 'carried',
|
||||
firstSeen: '2026-02-12T08:15:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
queryParamMap$ = new BehaviorSubject(convertToParamMap({ view: 'detail' }));
|
||||
paramMap$ = new BehaviorSubject(convertToParamMap({ scanId: 'test-scan-123' }));
|
||||
|
||||
const mockViewPref = jasmine.createSpyObj('ViewPreferenceService', ['getViewMode', 'setViewMode'], {
|
||||
viewMode: signal<FindingsViewMode>('detail').asReadonly(),
|
||||
});
|
||||
mockViewPref.getViewMode.and.returnValue('detail');
|
||||
|
||||
const mockCompare = jasmine.createSpyObj('CompareService', [
|
||||
'getBaselineRecommendations',
|
||||
'getBaselineRationale',
|
||||
'getTarget',
|
||||
'computeDelta',
|
||||
]);
|
||||
mockCompare.getBaselineRecommendations.and.returnValue(of({
|
||||
selectedDigest: null,
|
||||
selectionReason: 'none',
|
||||
alternatives: [],
|
||||
autoSelectEnabled: true,
|
||||
}));
|
||||
mockCompare.computeDelta.and.returnValue(of({ categories: [], items: [] }));
|
||||
|
||||
const mockFindingsApi = jasmine.createSpyObj('SecurityFindingsApi', ['listFindings']);
|
||||
mockFindingsApi.listFindings.and.returnValue(of(mockFindings));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [FindingsContainerComponent, NoopAnimationsModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: ViewPreferenceService, useValue: mockViewPref },
|
||||
{ provide: CompareService, useValue: mockCompare },
|
||||
{ provide: SECURITY_FINDINGS_API, useValue: mockFindingsApi },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMap$,
|
||||
queryParamMap: queryParamMap$,
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ scanId: 'test-scan-123' }),
|
||||
queryParamMap: convertToParamMap({ view: 'detail' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FindingsContainerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should compute findingEvidenceItems from raw findings', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
expect(items.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should map finding_id from Finding.id', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
expect(items[0].finding_id).toBe('finding-1');
|
||||
expect(items[1].finding_id).toBe('finding-2');
|
||||
expect(items[2].finding_id).toBe('finding-3');
|
||||
});
|
||||
|
||||
it('should map cve from Finding.advisoryId', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
expect(items[0].cve).toBe('CVE-2026-8001');
|
||||
expect(items[1].cve).toBe('CVE-2026-8002');
|
||||
});
|
||||
|
||||
it('should map component name and version', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
expect(items[0].component?.name).toBe('backend-api');
|
||||
expect(items[0].component?.version).toBe('2.5.0');
|
||||
expect(items[1].component?.name).toBe('frontend-lib');
|
||||
expect(items[1].component?.version).toBe('1.0.3');
|
||||
});
|
||||
|
||||
it('should map severity to risk_score', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
// critical -> 90
|
||||
expect(items[0].score_explain?.risk_score).toBe(90);
|
||||
// high -> 70
|
||||
expect(items[1].score_explain?.risk_score).toBe(70);
|
||||
// low -> 20
|
||||
expect(items[2].score_explain?.risk_score).toBe(20);
|
||||
});
|
||||
|
||||
it('should map fixed status to VEX fixed', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
// finding-2 has vexStatus 'fixed', delta 'resolved' -> status = 'fixed'
|
||||
expect(items[1].vex?.status).toBe('fixed');
|
||||
});
|
||||
|
||||
it('should map excepted status to VEX not_affected', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = component.findingEvidenceItems();
|
||||
// finding-3 has vexStatus 'not_affected' -> status = 'excepted'
|
||||
expect(items[2].vex?.status).toBe('not_affected');
|
||||
});
|
||||
|
||||
it('should render shared finding list host element in detail view', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
const host = el.querySelector('[data-testid="shared-finding-list-host"]');
|
||||
expect(host).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render stella-finding-list element in detail view', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
const list = el.querySelector('stella-finding-list');
|
||||
expect(list).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render bespoke app-findings-list element', async () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
const el: HTMLElement = fixture.nativeElement;
|
||||
const bespoke = el.querySelector('app-findings-list');
|
||||
expect(bespoke).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
// findings-container.component.ts
|
||||
// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default
|
||||
// Task: T3 — Container component for findings with view switching
|
||||
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
|
||||
// Task: FE-OFL-002 — Adopt shared FindingListComponent on canonical findings surface
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
|
||||
@@ -18,7 +20,9 @@ import { forkJoin, of } from 'rxjs';
|
||||
import { ViewPreferenceService, FindingsViewMode } from '../../../core/services/view-preference.service';
|
||||
import { FindingsViewToggleComponent } from '../../../shared/components/findings-view-toggle/findings-view-toggle.component';
|
||||
import { CompareViewComponent } from '../../compare/components/compare-view/compare-view.component';
|
||||
import { FindingsListComponent, Finding } from '../findings-list.component';
|
||||
import { FindingListComponent } from '../../../shared/components/finding-list.component';
|
||||
import type { FindingEvidenceResponse } from '../../../core/api/triage-evidence.models';
|
||||
import { type Finding } from '../findings-list.component';
|
||||
import { CompareService } from '../../compare/services/compare.service';
|
||||
import {
|
||||
SECURITY_FINDINGS_API,
|
||||
@@ -98,7 +102,7 @@ function mapFinding(source: DetailFindingSource): Finding {
|
||||
MatProgressSpinnerModule,
|
||||
FindingsViewToggleComponent,
|
||||
CompareViewComponent,
|
||||
FindingsListComponent
|
||||
FindingListComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="findings-container">
|
||||
@@ -149,9 +153,11 @@ function mapFinding(source: DetailFindingSource): Finding {
|
||||
<p>{{ message }}</p>
|
||||
</section>
|
||||
} @else {
|
||||
<app-findings-list
|
||||
[findings]="findings()"
|
||||
(findingSelect)="onFindingSelect($event)" />
|
||||
<stella-finding-list
|
||||
[findings]="findingEvidenceItems()"
|
||||
[loading]="loading()"
|
||||
data-testid="shared-finding-list-host"
|
||||
(findingSelected)="onFindingSelected($event)" />
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,9 +263,15 @@ export class FindingsContainerComponent implements OnInit {
|
||||
// Loading state
|
||||
readonly loading = signal(false);
|
||||
|
||||
// Findings for detail view
|
||||
// Findings for detail view (raw Finding model)
|
||||
readonly findings = signal<Finding[]>([]);
|
||||
|
||||
// Adapter: map Finding[] to FindingEvidenceResponse[] for the shared FindingListComponent
|
||||
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-002)
|
||||
readonly findingEvidenceItems = computed<FindingEvidenceResponse[]>(() => {
|
||||
return this.findings().map((f) => this.mapFindingToEvidence(f));
|
||||
});
|
||||
|
||||
// Detail view data failure state
|
||||
readonly detailError = signal<string | null>(null);
|
||||
|
||||
@@ -340,6 +352,52 @@ export class FindingsContainerComponent implements OnInit {
|
||||
console.log('Selected finding:', finding.id);
|
||||
}
|
||||
|
||||
/** Handler for the shared FindingListComponent's findingSelected output (emits finding_id string). */
|
||||
onFindingSelected(findingId: string): void {
|
||||
const finding = this.findings().find((f) => f.id === findingId);
|
||||
if (finding) {
|
||||
this.onFindingSelect(finding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter: map the bespoke Finding interface to FindingEvidenceResponse
|
||||
* so the shared FindingListComponent can render it.
|
||||
* Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-002)
|
||||
*/
|
||||
private mapFindingToEvidence(finding: Finding): FindingEvidenceResponse {
|
||||
const severityScore: Record<string, number> = {
|
||||
critical: 90,
|
||||
high: 70,
|
||||
medium: 45,
|
||||
low: 20,
|
||||
unknown: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
finding_id: finding.id,
|
||||
cve: finding.advisoryId,
|
||||
component: {
|
||||
purl: `pkg:generic/${finding.packageName}@${finding.packageVersion}`,
|
||||
name: finding.packageName,
|
||||
version: finding.packageVersion,
|
||||
type: 'generic',
|
||||
},
|
||||
score_explain: {
|
||||
kind: 'ews',
|
||||
risk_score: severityScore[finding.severity] ?? 0,
|
||||
last_seen: finding.publishedAt ?? new Date().toISOString(),
|
||||
summary: `Severity: ${finding.severity}, Status: ${finding.status}`,
|
||||
},
|
||||
vex: finding.status === 'fixed'
|
||||
? { status: 'fixed' }
|
||||
: finding.status === 'excepted'
|
||||
? { status: 'not_affected', justification: 'Excepted' }
|
||||
: undefined,
|
||||
last_seen: finding.publishedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private readViewMode(raw: string | null): FindingsViewMode | null {
|
||||
return raw === 'diff' || raw === 'detail' ? raw : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
|
||||
// Task: FE-OFL-004 - Verify shared FindingListComponent adoption on ReleaseDetailComponent
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of, BehaviorSubject, EMPTY } from 'rxjs';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
import { ReleaseDetailComponent } from './release-detail.component';
|
||||
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
||||
import { ReleaseManagementStore } from '../release.store';
|
||||
|
||||
describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
|
||||
let component: ReleaseDetailComponent;
|
||||
let fixture: ComponentFixture<ReleaseDetailComponent>;
|
||||
|
||||
const mockRelease = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Test Release',
|
||||
version: 'v1.0.0',
|
||||
digest: 'sha256:abc',
|
||||
releaseType: 'standard',
|
||||
targetRegion: 'us-east-1',
|
||||
gateStatus: 'passed',
|
||||
evidencePosture: 'complete',
|
||||
riskTier: 'low',
|
||||
gateBlockingCount: 0,
|
||||
replayMismatch: false,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const paramMap$ = new BehaviorSubject(convertToParamMap({ releaseId: 'rel-001' }));
|
||||
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
|
||||
const mockContext = {
|
||||
environment: signal('production'),
|
||||
region: signal('us-east-1'),
|
||||
tenant: signal('demo-prod'),
|
||||
};
|
||||
|
||||
const mockStore = {
|
||||
selectedRelease: signal(mockRelease),
|
||||
releases: signal([mockRelease]),
|
||||
loadRelease: () => EMPTY,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ReleaseDetailComponent, HttpClientTestingModule],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: PlatformContextStore, useValue: mockContext },
|
||||
{ provide: ReleaseManagementStore, useValue: mockStore },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMap$,
|
||||
queryParamMap: queryParamMap$,
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ releaseId: 'rel-001' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ReleaseDetailComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should compute securityFindingEvidenceItems from empty findings', () => {
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it('should compute securityFindingEvidenceItems from security findings', () => {
|
||||
// Set findings directly via the signal
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
{
|
||||
findingId: 'f-002',
|
||||
cveId: 'CVE-2026-9002',
|
||||
severity: 'high',
|
||||
componentName: 'auth-lib',
|
||||
releaseId: 'rel-001',
|
||||
reachable: false,
|
||||
reachabilityScore: 0,
|
||||
effectiveDisposition: 'review_required',
|
||||
vexStatus: 'not_affected',
|
||||
exceptionStatus: 'approved',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should map finding_id from SecurityFindingProjection.findingId', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].finding_id).toBe('f-001');
|
||||
});
|
||||
|
||||
it('should map cve from SecurityFindingProjection.cveId', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].cve).toBe('CVE-2026-9001');
|
||||
});
|
||||
|
||||
it('should map component name from SecurityFindingProjection.componentName', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].component?.name).toBe('libcrypto');
|
||||
});
|
||||
|
||||
it('should map severity to risk_score (critical=90)', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].score_explain?.risk_score).toBe(90);
|
||||
});
|
||||
|
||||
it('should map reachable=true to non-empty reachable_path', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].reachable_path).toBeDefined();
|
||||
expect(items[0].reachable_path!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should map reachable=false to undefined reachable_path', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-002',
|
||||
cveId: 'CVE-2026-9002',
|
||||
severity: 'high',
|
||||
componentName: 'auth-lib',
|
||||
releaseId: 'rel-001',
|
||||
reachable: false,
|
||||
reachabilityScore: 0,
|
||||
effectiveDisposition: 'review_required',
|
||||
vexStatus: 'not_affected',
|
||||
exceptionStatus: 'approved',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].reachable_path).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should map vexStatus not_affected to VEX evidence', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-002',
|
||||
cveId: 'CVE-2026-9002',
|
||||
severity: 'high',
|
||||
componentName: 'auth-lib',
|
||||
releaseId: 'rel-001',
|
||||
reachable: false,
|
||||
reachabilityScore: 0,
|
||||
effectiveDisposition: 'review_required',
|
||||
vexStatus: 'not_affected',
|
||||
exceptionStatus: 'approved',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].vex?.status).toBe('not_affected');
|
||||
});
|
||||
|
||||
it('should not produce VEX evidence for under_investigation status', () => {
|
||||
(component as any).findings.set([
|
||||
{
|
||||
findingId: 'f-001',
|
||||
cveId: 'CVE-2026-9001',
|
||||
severity: 'critical',
|
||||
componentName: 'libcrypto',
|
||||
releaseId: 'rel-001',
|
||||
reachable: true,
|
||||
reachabilityScore: 85,
|
||||
effectiveDisposition: 'action_required',
|
||||
vexStatus: 'under_investigation',
|
||||
exceptionStatus: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
const items = component.securityFindingEvidenceItems();
|
||||
expect(items[0].vex).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,12 @@ import { ReleaseManagementStore } from '../release.store';
|
||||
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
|
||||
import type { ManagedRelease } from '../../../../core/api/release-management.models';
|
||||
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
|
||||
import { FindingListComponent } from '../../../../shared/components/finding-list.component';
|
||||
import type { FindingEvidenceResponse } from '../../../../core/api/triage-evidence.models';
|
||||
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
|
||||
import { AuditorOnlyDirective } from '../../../../shared/directives/auditor-only.directive';
|
||||
import { OperatorOnlyDirective } from '../../../../shared/directives/operator-only.directive';
|
||||
import { ViewModeToggleComponent } from '../../../../shared/components/view-mode-toggle/view-mode-toggle.component';
|
||||
|
||||
interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; }
|
||||
interface PlatformItemResponse<T> { item: T; }
|
||||
@@ -135,7 +140,7 @@ interface ReloadOptions {
|
||||
@Component({
|
||||
selector: 'app-release-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule, DegradedStateBannerComponent],
|
||||
imports: [RouterLink, FormsModule, DegradedStateBannerComponent, FindingListComponent, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="workbench">
|
||||
@@ -153,9 +158,10 @@ interface ReloadOptions {
|
||||
<span>{{ getEvidencePostureLabel(release()!.evidencePosture) }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" (click)="openDecisioningStudio()">Decisioning</button>
|
||||
<button type="button" (click)="openPromotionWizard()">Promote</button>
|
||||
<button type="button" (click)="openTab('deployments')">Deploy</button>
|
||||
<stella-view-mode-toggle />
|
||||
<button type="button" *stellaOperatorOnly (click)="openDecisioningStudio()">Decisioning</button>
|
||||
<button type="button" *stellaOperatorOnly (click)="openPromotionWizard()">Promote</button>
|
||||
<button type="button" *stellaOperatorOnly (click)="openTab('deployments')">Deploy</button>
|
||||
<button type="button" (click)="openTab('security-inputs')">Security</button>
|
||||
<button type="button" class="primary" (click)="openTab('evidence')">Evidence</button>
|
||||
</div>
|
||||
@@ -263,17 +269,13 @@ interface ReloadOptions {
|
||||
@case ('security-inputs') {
|
||||
<article>
|
||||
<h3>Release-Scoped Security</h3>
|
||||
<table>
|
||||
<thead><tr><th>CVE</th><th>Component</th><th>Severity</th><th>Reachable</th><th>VEX</th><th>Exception</th><th>Blocks Promotion</th></tr></thead>
|
||||
<tbody>
|
||||
@for (item of findings(); track item.findingId) {
|
||||
<tr>
|
||||
<td>{{ item.cveId }}</td><td>{{ item.componentName }}</td><td>{{ item.severity }}</td><td>{{ item.reachable ? 'yes' : 'no' }} ({{ item.reachabilityScore }})</td>
|
||||
<td>{{ item.vexStatus }}</td><td>{{ item.exceptionStatus }}</td><td>{{ item.effectiveDisposition === 'action_required' ? 'yes' : 'no' }}</td>
|
||||
</tr>
|
||||
} @empty { <tr><td colspan="7">No findings.</td></tr> }
|
||||
</tbody>
|
||||
</table>
|
||||
<stella-finding-list
|
||||
[findings]="securityFindingEvidenceItems()"
|
||||
[showApprove]="false"
|
||||
[emptyMessage]="'No findings for this release.'"
|
||||
data-testid="release-security-finding-list-host"
|
||||
(findingSelected)="onSecurityFindingSelected($event)"
|
||||
/>
|
||||
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
|
||||
</article>
|
||||
}
|
||||
@@ -282,9 +284,12 @@ interface ReloadOptions {
|
||||
<article>
|
||||
<h3>Pack Summary</h3>
|
||||
<p>Versions {{ versions().length }} · Findings {{ findings().length }} · Dispositions {{ dispositions().length }}</p>
|
||||
<h3>Proof Chain and Replay</h3>
|
||||
<p>Evidence posture {{ getEvidencePostureLabel(release()!.evidencePosture) }} · replay mismatch {{ release()!.replayMismatch ? 'yes' : 'no' }}</p>
|
||||
<p><button type="button" (click)="openProofChain()">Proof Chain</button> <button type="button" (click)="openReplay()">Replay</button> <button type="button" class="primary" (click)="exportReleaseEvidence()">Export Evidence Pack</button></p>
|
||||
<div *stellaAuditorOnly>
|
||||
<h3>Proof Chain and Replay</h3>
|
||||
<p>Evidence posture {{ getEvidencePostureLabel(release()!.evidencePosture) }} · replay mismatch {{ release()!.replayMismatch ? 'yes' : 'no' }}</p>
|
||||
<p><button type="button" (click)="openProofChain()">Proof Chain</button> <button type="button" (click)="openReplay()">Replay</button></p>
|
||||
</div>
|
||||
<p><button type="button" class="primary" (click)="exportReleaseEvidence()">Export Evidence Pack</button></p>
|
||||
</article>
|
||||
}
|
||||
|
||||
@@ -415,6 +420,12 @@ export class ReleaseDetailComponent {
|
||||
readonly diffRows = signal<SecuritySbomDiffRow[]>([]);
|
||||
readonly diffMode = signal<'sbom'|'findings'|'policy'|'topology'>('sbom');
|
||||
|
||||
// Adapter: map SecurityFindingProjection[] to FindingEvidenceResponse[] for the shared FindingListComponent
|
||||
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-003)
|
||||
readonly securityFindingEvidenceItems = computed<FindingEvidenceResponse[]>(() => {
|
||||
return this.findings().map((f) => this.mapSecurityFindingToEvidence(f));
|
||||
});
|
||||
|
||||
readonly selectedTimelineId = signal<string | null>(null);
|
||||
readonly selectedTargets = signal<Set<string>>(new Set<string>());
|
||||
|
||||
@@ -1267,6 +1278,52 @@ export class ReleaseDetailComponent {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Handler for the shared FindingListComponent's findingSelected output. */
|
||||
onSecurityFindingSelected(findingId: string): void {
|
||||
const finding = this.findings().find((f) => f.findingId === findingId);
|
||||
if (finding) {
|
||||
void this.router.navigate(['/security/triage'], {
|
||||
queryParams: { releaseId: this.releaseContextId(), findingId: finding.findingId, cve: finding.cveId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter: map SecurityFindingProjection to FindingEvidenceResponse
|
||||
* for the shared FindingListComponent.
|
||||
* Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-003)
|
||||
*/
|
||||
private mapSecurityFindingToEvidence(f: SecurityFindingProjection): FindingEvidenceResponse {
|
||||
const severityScore: Record<string, number> = {
|
||||
critical: 90,
|
||||
high: 70,
|
||||
medium: 45,
|
||||
low: 20,
|
||||
};
|
||||
|
||||
return {
|
||||
finding_id: f.findingId,
|
||||
cve: f.cveId,
|
||||
component: {
|
||||
purl: `pkg:generic/${f.componentName}`,
|
||||
name: f.componentName,
|
||||
version: '',
|
||||
type: 'generic',
|
||||
},
|
||||
reachable_path: f.reachable ? ['reachable'] : undefined,
|
||||
score_explain: {
|
||||
kind: 'ews',
|
||||
risk_score: severityScore[f.severity.toLowerCase()] ?? 0,
|
||||
last_seen: new Date().toISOString(),
|
||||
summary: `Severity: ${f.severity}, Reachability: ${f.reachabilityScore}, Disposition: ${f.effectiveDisposition}`,
|
||||
},
|
||||
vex: f.vexStatus && f.vexStatus !== 'under_investigation'
|
||||
? { status: f.vexStatus as 'not_affected' | 'affected' | 'fixed' | 'under_investigation' }
|
||||
: undefined,
|
||||
last_seen: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user