From 900b2915601096874b3a8367d5a036212232a1a2 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 8 Mar 2026 19:25:24 +0200 Subject: [PATCH] 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 --- .../web/orphan-finding-list-consolidation.md | 53 ++++ ...20_FE_orphan_finding_list_consolidation.md | 96 ++++++ ...er-finding-list-adoption.component.spec.ts | 208 +++++++++++++ .../container/findings-container.component.ts | 70 ++++- ...il-finding-list-adoption.component.spec.ts | 277 ++++++++++++++++++ .../release-detail.component.ts | 93 ++++-- 6 files changed, 773 insertions(+), 24 deletions(-) create mode 100644 docs/features/checked/web/orphan-finding-list-consolidation.md create mode 100644 docs/implplan/SPRINT_20260308_020_FE_orphan_finding_list_consolidation.md create mode 100644 src/Web/StellaOps.Web/src/app/features/findings/container/findings-container-finding-list-adoption.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail-finding-list-adoption.component.spec.ts diff --git a/docs/features/checked/web/orphan-finding-list-consolidation.md b/docs/features/checked/web/orphan-finding-list-consolidation.md new file mode 100644 index 000000000..113d68836 --- /dev/null +++ b/docs/features/checked/web/orphan-finding-list-consolidation.md @@ -0,0 +1,53 @@ +# Orphan Finding List Consolidation + +Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation + +## Summary + +Revived the dormant shared `FindingListComponent` and `FindingRowComponent` by adopting them on two mounted surfaces that previously used bespoke finding list rendering: + +1. **FindingsContainerComponent** (`features/findings/container/`): Replaced the bespoke `FindingsListComponent` (`app-findings-list`) with the shared `FindingListComponent` (`stella-finding-list`). Added a `findingEvidenceItems` computed signal that adapts `Finding[]` to `FindingEvidenceResponse[]` with severity-to-risk-score mapping and VEX status bridging. + +2. **ReleaseDetailComponent** (`features/release-orchestrator/releases/release-detail/`): Replaced the bespoke inline HTML `` on the security-inputs tab with the shared `FindingListComponent`. Added a `securityFindingEvidenceItems` computed signal that adapts `SecurityFindingProjection[]` to `FindingEvidenceResponse[]` with reachability path mapping and VEX status forwarding. + +## Mounted hosts + +| Widget | Host component | Route context | Adoption type | +|---|---|---|---| +| FindingListComponent + FindingRowComponent | FindingsContainerComponent | `/security/findings` (detail view) | Replaces bespoke `app-findings-list` | +| FindingListComponent + FindingRowComponent | ReleaseDetailComponent | `/releases/:releaseId` (security-inputs tab) | Replaces bespoke inline `
` | + +## Exclusions + +| Surface | Reason | +|---|---| +| `FindingsDetailPageComponent` (`features/triage/components/findings-detail-page/`) | Card-based layout with triage lane toggle, gated buckets, and gating reason filter. Interaction model is materially different from the shared tabular list. | +| `TriageWorkspaceComponent` (`features/triage/`) | Uses `FindingCardModel` (Vulnerability + AffectedComponent) with deeply integrated keyboard navigation, VEX decision modals, AI recommendations, reachability drawers, and bulk VEX. Interaction model is materially different. | +| `VulnerabilityExplorerComponent` (`features/vulnerabilities/`) | Reserved for sprint 013. Explicitly excluded per sprint scope. | + +## Data contracts + +- `FindingEvidenceResponse` (from `triage-evidence.models.ts`): The shared list's primary input type. +- `Finding` (from `findings-list.component.ts`): Bespoke model used by FindingsContainerComponent; adapted via `mapFindingToEvidence()`. +- `SecurityFindingProjection` (inline in `release-detail.component.ts`): Bespoke model used by ReleaseDetailComponent; adapted via `mapSecurityFindingToEvidence()`. + +## Adapter strategy + +Both host components use computed signals that transform their existing data models into `FindingEvidenceResponse[]`: + +- Severity strings map to numeric risk scores (critical=90, high=70, medium=45, low=20). +- Finding status maps to VEX evidence where semantically appropriate (fixed -> `{status: 'fixed'}`, excepted -> `{status: 'not_affected'}`). +- Reachability booleans map to `reachable_path` arrays. +- Component metadata is projected into `ComponentRef` with generic PURL construction. + +## Test coverage + +- `findings-container-finding-list-adoption.component.spec.ts`: Verifies findingEvidenceItems derivation, field mapping (finding_id, cve, component, risk_score, VEX status), template rendering of shared list element, and absence of bespoke element. +- `release-detail-finding-list-adoption.component.spec.ts`: Verifies securityFindingEvidenceItems derivation, field mapping (finding_id, cve, component, risk_score, reachable_path, VEX status), and empty-findings handling. + +## Files changed + +- `src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts` +- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts` +- `src/Web/StellaOps.Web/src/app/features/findings/container/findings-container-finding-list-adoption.component.spec.ts` (new) +- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail-finding-list-adoption.component.spec.ts` (new) diff --git a/docs/implplan/SPRINT_20260308_020_FE_orphan_finding_list_consolidation.md b/docs/implplan/SPRINT_20260308_020_FE_orphan_finding_list_consolidation.md new file mode 100644 index 000000000..e46e5a406 --- /dev/null +++ b/docs/implplan/SPRINT_20260308_020_FE_orphan_finding_list_consolidation.md @@ -0,0 +1,96 @@ +# Sprint 20260308-020 - FE Orphan Finding List Consolidation + +## Topic & Scope +- Revive `FindingListComponent` and `FindingRowComponent` by adopting them on mounted findings, triage, and release-review surfaces that still maintain separate bespoke lists. +- Use this sprint to consolidate the shared finding-list family, not to redesign every findings workflow. +- Explicit non-goals: do not touch vulnerability-explorer consumers reserved for sprint `013`, and do not absorb unrelated filter-toolbar work reserved for sprint `015`. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/components/finding-list.component.ts`, `src/Web/StellaOps.Web/src/app/shared/components/finding-row.component.ts`, `src/Web/StellaOps.Web/src/app/features/findings/`, `src/Web/StellaOps.Web/src/app/features/triage/`, and `src/Web/StellaOps.Web/src/app/features/release-orchestrator/`. +- Expected evidence: focused Angular tests, one checked-feature note, and sprint execution-log updates. + +## Dependencies & Concurrency +- Hard dependency inside the orphan revival batch: none. +- External prerequisite already satisfied: findings, triage, and release-review shells are already mounted. +- Safe parallelism: + - Can run in parallel with sprint `013` because vulnerability-explorer consumers are excluded. + - Can run in parallel with route-reconnection sprints because this sprint does not own router parent files. + - This sprint exclusively owns `finding-list` and `finding-row` while staffed. + +## Documentation Prerequisites +- `docs/modules/ui/orphan-revival-batch/README.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `src/Web/StellaOps.Web/src/app/shared/components/finding-list.component.ts` +- `src/Web/StellaOps.Web/src/app/shared/components/finding-row.component.ts` +- `src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.ts` +- `src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts` + +## Delivery Tracker + +### FE-OFL-001 - Freeze mounted list-host matrix +Status: DONE +Dependency: none +Owners: Developer (FE), Project Manager +Task description: +- Freeze the mounted findings, triage, and release-review hosts that will adopt the shared finding-list family. +- Record any list host that should stay bespoke because its interaction model is materially different. + +Completion criteria: +- [x] Mounted host matrix is recorded in the execution log. +- [x] Hosts reserved for other sprints are explicitly excluded. +- [x] Compatibility notes for each adopted host are recorded. + +### FE-OFL-002 - Adopt shared finding list on canonical findings surfaces +Status: DONE +Dependency: FE-OFL-001 +Owners: Developer (FE) +Task description: +- Replace bespoke list rendering on the selected canonical findings surfaces with the shared `FindingListComponent` and `FindingRowComponent` where the interaction model aligns. + +Completion criteria: +- [x] Selected findings surfaces render the shared list family. +- [x] Sorting, expansion, and core status affordances remain usable. +- [x] Any required host-level adapters stay bounded to the findings family. + +### FE-OFL-003 - Adopt shared finding list on triage and release-review surfaces +Status: DONE +Dependency: FE-OFL-001 +Owners: Developer (FE) +Task description: +- Extend the shared finding-list family to the frozen triage and release-review hosts where that consolidation improves consistency without flattening domain-specific actions. + +Completion criteria: +- [x] Selected triage and release-review hosts render the shared list family. +- [x] Domain-specific actions remain available in the adopted hosts. +- [x] Hosts that do not fit the shared list are explicitly excluded with reasons. + +### FE-OFL-004 - Verify and document finding-list revival +Status: DONE +Dependency: FE-OFL-002 +Owners: Test Automation, Documentation author +Task description: +- Add focused Angular coverage for the shared finding-list adoption and document the shipped consolidation slice. + +Completion criteria: +- [x] Angular tests cover the shared finding-list family in mounted consumers. +- [x] Checked-feature note exists under `docs/features/checked/web/`. +- [x] UI plan/task docs reflect the finding-list consolidation. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created from the orphan-revival batch to revive the dormant shared finding-list family across mounted findings, triage, and release-review surfaces. | Project Manager | +| 2026-03-08 | FE-OFL-001: Frozen mounted host matrix. Shared FindingListComponent/FindingRowComponent accept FindingEvidenceResponse[] from triage-evidence.models.ts. ADOPT: (1) FindingsContainerComponent at features/findings/container/ — currently uses bespoke FindingsListComponent (app-findings-list) with Finding[] interface; will map Finding to FindingEvidenceResponse via host adapter. (2) ReleaseDetailComponent at features/release-orchestrator/releases/release-detail/ — currently uses inline table with SecurityFindingProjection; will map SecurityFindingProjection to FindingEvidenceResponse via host adapter. EXCLUDED (bespoke stays): (3) FindingsDetailPageComponent at features/triage/components/findings-detail-page/ — card-based layout with triage lane toggle, gated buckets, gating reason filter; interaction model is materially different from shared tabular list. (4) TriageWorkspaceComponent at features/triage/ — uses FindingCardModel (Vulnerability + AffectedComponent) with deeply integrated keyboard navigation, VEX decision modals, AI recommendations, reachability drawers, bulk VEX; interaction model is materially different. (5) VulnerabilityExplorerComponent at features/vulnerabilities/ — reserved for sprint 013, explicitly excluded. | Developer (FE) | +| 2026-03-08 | FE-OFL-002: Replaced bespoke FindingsListComponent (app-findings-list) usage in FindingsContainerComponent with shared FindingListComponent (stella-finding-list). Added findingEvidenceItems computed signal that maps Finding[] to FindingEvidenceResponse[] via mapFindingToEvidence() adapter. Severity maps to risk_score (critical=90, high=70, medium=45, low=20). Status maps to VEX status where applicable (fixed -> fixed, excepted -> not_affected). Sorting and expansion provided by shared component. | Developer (FE) | +| 2026-03-08 | FE-OFL-003: Adopted shared FindingListComponent on ReleaseDetailComponent security-inputs tab. Replaced bespoke inline HTML table with stella-finding-list. Added securityFindingEvidenceItems computed signal that maps SecurityFindingProjection[] to FindingEvidenceResponse[] via mapSecurityFindingToEvidence() adapter. Reachability is mapped to reachable_path. VEX status is forwarded where not under_investigation. Added onSecurityFindingSelected handler that navigates to triage workspace with release context. Triage surfaces (FindingsDetailPageComponent, TriageWorkspaceComponent) excluded per host matrix — interaction models are materially different. | Developer (FE) | +| 2026-03-08 | FE-OFL-004: Added focused Angular tests for both adoption hosts. Created findings-container-finding-list-adoption.component.spec.ts (11 tests) and release-detail-finding-list-adoption.component.spec.ts (10 tests). Created checked-feature note at docs/features/checked/web/orphan-finding-list-consolidation.md. All four tasks DONE. | Developer (FE) | + +## Decisions & Risks +- Decision: this sprint consolidates the shared finding-list family across mounted shells instead of restoring any dead findings prototype wholesale. +- Risk: some hosts may rely on bespoke actions or lane semantics that do not fit the shared list without awkward adapters. +- Mitigation: freeze the host matrix first and explicitly record any host that should remain purpose-built. +- Decision: FindingsDetailPageComponent and TriageWorkspaceComponent stay bespoke because their card-based layouts, lane filtering, gating reason display, keyboard triage shortcuts, and VEX decision modals are materially different from the shared tabular finding-list family. Adopting the shared list would require flattening these domain-specific interaction models. +- Decision: FindingsContainerComponent and ReleaseDetailComponent adopt the shared list because their rendering is simple tabular display that aligns with the shared component's table-based layout. Host-level adapters will bridge their data contracts (Finding/SecurityFindingProjection) to FindingEvidenceResponse. + +## Next Checkpoints +- 2026-03-09: mounted host matrix frozen. +- 2026-03-11: consolidation criteria agreed. diff --git a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container-finding-list-adoption.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container-finding-list-adoption.component.spec.ts new file mode 100644 index 000000000..2e733e71b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container-finding-list-adoption.component.spec.ts @@ -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; + let queryParamMap$: BehaviorSubject; + let paramMap$: BehaviorSubject; + + 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('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(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts index b91bbca65..717a2a816 100644 --- a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts @@ -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: `
@@ -149,9 +153,11 @@ function mapFinding(source: DetailFindingSource): Finding {

{{ message }}

} @else { - + } } } @@ -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([]); + // Adapter: map Finding[] to FindingEvidenceResponse[] for the shared FindingListComponent + // Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-002) + readonly findingEvidenceItems = computed(() => { + return this.findings().map((f) => this.mapFindingToEvidence(f)); + }); + // Detail view data failure state readonly detailError = signal(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 = { + 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; } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail-finding-list-adoption.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail-finding-list-adoption.component.spec.ts new file mode 100644 index 000000000..4c48fbea6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail-finding-list-adoption.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts index c453bcf71..650cb2cc3 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts @@ -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 { items: T[]; total: number; limit: number; offset: number; } interface PlatformItemResponse { 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: `
@@ -153,9 +158,10 @@ interface ReloadOptions { {{ getEvidencePostureLabel(release()!.evidencePosture) }}
- - - + + + +
@@ -263,17 +269,13 @@ interface ReloadOptions { @case ('security-inputs') {

Release-Scoped Security

-
- - - @for (item of findings(); track item.findingId) { - - - - - } @empty { } - -
CVEComponentSeverityReachableVEXExceptionBlocks Promotion
{{ item.cveId }}{{ item.componentName }}{{ item.severity }}{{ item.reachable ? 'yes' : 'no' }} ({{ item.reachabilityScore }}){{ item.vexStatus }}{{ item.exceptionStatus }}{{ item.effectiveDisposition === 'action_required' ? 'yes' : 'no' }}
No findings.
+

} @@ -282,9 +284,12 @@ interface ReloadOptions {

Pack Summary

Versions {{ versions().length }} · Findings {{ findings().length }} · Dispositions {{ dispositions().length }}

-

Proof Chain and Replay

-

Evidence posture {{ getEvidencePostureLabel(release()!.evidencePosture) }} · replay mismatch {{ release()!.replayMismatch ? 'yes' : 'no' }}

-

+
+

Proof Chain and Replay

+

Evidence posture {{ getEvidencePostureLabel(release()!.evidencePosture) }} · replay mismatch {{ release()!.replayMismatch ? 'yes' : 'no' }}

+

+
+

} @@ -415,6 +420,12 @@ export class ReleaseDetailComponent { readonly diffRows = signal([]); 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(() => { + return this.findings().map((f) => this.mapSecurityFindingToEvidence(f)); + }); + readonly selectedTimelineId = signal(null); readonly selectedTargets = signal>(new Set()); @@ -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 = { + 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(), + }; + } }