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:
master
2026-03-08 19:25:24 +02:00
parent 1660a9138e
commit 900b291560
6 changed files with 773 additions and 24 deletions

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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(),
};
}
}