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,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 `<table>` 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 `<table>` |
## 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)

View File

@@ -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.

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