From 6aa8bb5095ed26319af8c41e8df860b3cd4c77a0 Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 18:38:30 +0200 Subject: [PATCH] Fix findings detail truthfulness and export affordances --- ..._findings_compare_baseline_availability.md | 52 ++++ .../findings-container.component.spec.ts | 44 ++- .../compare-view/compare-view.component.html | 27 +- .../compare-view/compare-view.component.ts | 253 +++++++++++++++--- .../findings-container.component.spec.ts | 65 ++++- .../container/findings-container.component.ts | 180 +++++++++---- .../findings/findings-list.component.html | 13 - .../findings/findings-list.component.ts | 5 - .../compare/compare-view.component.spec.ts | 72 ++++- .../compare/role-based-views.behavior.spec.ts | 2 + ...indings-list.audit-export.behavior.spec.ts | 83 ++++++ 11 files changed, 678 insertions(+), 118 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_018_FE_findings_compare_baseline_availability.md create mode 100644 src/Web/StellaOps.Web/src/tests/findings/findings-list.audit-export.behavior.spec.ts diff --git a/docs/implplan/SPRINT_20260307_018_FE_findings_compare_baseline_availability.md b/docs/implplan/SPRINT_20260307_018_FE_findings_compare_baseline_availability.md new file mode 100644 index 000000000..50b68a73d --- /dev/null +++ b/docs/implplan/SPRINT_20260307_018_FE_findings_compare_baseline_availability.md @@ -0,0 +1,52 @@ +# Sprint 20260307_018 - Findings Compare Baseline Availability + +## Topic & Scope +- Repair the live `/security/findings` diff surface so it does not present an empty compare shell as if comparison data exists. +- Wire the embedded findings compare view to the current scan context instead of relying only on route params from standalone compare routes. +- Replace misleading zero-change and active-export states with truthful comparison availability states when no baseline exists. +- Remove the unsupported detail-view audit export affordance that currently posts to a nonexistent frontend-only route. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: focused Angular specs, live Playwright findings-route verification, rebuilt/synced web bundle. + +## Dependencies & Concurrency +- Depends on the current live stack at `https://stella-ops.local`. +- Safe to run in parallel with unrelated UI/settings/search work as long as edits stay within compare/findings components and this sprint file. + +## Documentation Prerequisites +- `AGENTS.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` + +## Delivery Tracker + +### FE-018-01 - Restore truthful findings diff behavior +Status: DOING +Dependency: none +Owners: Developer, QA +Task description: +- Investigate the live authenticated findings diff route with Playwright and trace why the compare surface renders empty panes and misleading change/export affordances. +- Implement a durable fix in the embedded compare/finding components so the current scan context is wired correctly, baseline availability is surfaced honestly, and inert export behavior is removed. +- Replace detail-mode placeholder findings data and unsupported audit export controls with truthful live-data and live-contract behavior. + +Completion criteria: +- [ ] `/security/findings` uses the active/current scan context inside the embedded compare surface. +- [ ] When no baseline is available, the UI shows an explicit unavailable state instead of fake zero-change content. +- [ ] Export affordances are disabled or otherwise truthful when comparison data is unavailable. +- [ ] Detail mode does not expose any inert audit export control without a live backend contract. +- [ ] Focused Angular tests cover the embedded-current-scan path and the no-baseline state. +- [ ] Live Playwright verification on `https://stella-ops.local` confirms the corrected behavior. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-07 | Sprint created and set to DOING after real-auth Playwright reproduction showed `/security/findings` only calling `/api/compare/baselines/active-scan`, then rendering empty compare panes with active export despite no baseline being available. | Codex | +| 2026-03-07 | Replaced detail-mode placeholder findings with live `api/v2/security/findings` data, removed the unsupported `Export Audit Pack` control that posted to nonexistent `/api/v1/audit-pack/export`, and queued a live Playwright recheck for detail/diff parity. | Codex | + +## Decisions & Risks +- The live compare API returns `selectedDigest: null` with a selection reason for `active-scan`; the UI must handle this as a first-class state instead of implying a successful comparison. +- The embedded findings route cannot rely only on standalone compare route params; it must pass or derive current scan context explicitly. +- Findings detail mode previously exposed an audit export workflow backed only by a stale frontend-only path. Until a real scan/finding-scoped export contract exists, the findings surface must not advertise that action. + +## Next Checkpoints +- Focused Angular regression specs green. +- Live Playwright recheck on `/security/findings?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d`. diff --git a/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts index afaa5b964..0aaaddf7c 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts @@ -2,10 +2,13 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; +import { By } from '@angular/platform-browser'; import { BehaviorSubject, of } from 'rxjs'; +import { CompareViewComponent } from '../../features/compare/components/compare-view/compare-view.component'; import { FindingsContainerComponent } from '../../features/findings/container/findings-container.component'; import { CompareService } from '../../features/compare/services/compare.service'; +import { SECURITY_FINDINGS_API } from '../api/security-findings.client'; import { ViewPreferenceService, FindingsViewMode } from '../services/view-preference.service'; import { MockScoringApi, SCORING_API } from '../services/scoring.service'; @@ -40,6 +43,21 @@ describe('FindingsContainerComponent', () => { alternatives: [], autoSelectEnabled: true, }), + getBaselineRationale: () => + of({ + selectedDigest: 'baseline-123', + selectionReason: 'Last Green Build', + alternatives: [], + autoSelectEnabled: true, + }), + getTarget: (id: string) => + of({ + id, + digest: id, + imageRef: `registry.local/${id}`, + scanDate: '2026-03-07T00:00:00Z', + label: id === 'test-scan-123' ? 'Test scan' : 'Baseline test scan', + }), computeDelta: () => of({ categories: [ @@ -53,6 +71,24 @@ describe('FindingsContainerComponent', () => { provide: SCORING_API, useClass: MockScoringApi, }, + { + provide: SECURITY_FINDINGS_API, + useValue: { + listFindings: () => + of([ + { + 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', + }, + ]), + }, + }, { provide: ActivatedRoute, useValue: { @@ -82,7 +118,7 @@ describe('FindingsContainerComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('backend-api'); + expect(fixture.nativeElement.textContent).toContain('CVE-2026-8001'); expect(fixture.nativeElement.textContent).not.toContain('Comparing:'); }); @@ -101,4 +137,10 @@ describe('FindingsContainerComponent', () => { expect(fixture.nativeElement.textContent).toContain('Comparing:'); expect(fixture.nativeElement.textContent).not.toContain('backend-api'); }); + + it('passes the resolved scan id into the embedded compare view', () => { + const compareView = fixture.debugElement.query(By.directive(CompareViewComponent)); + expect(compareView).not.toBeNull(); + expect(compareView.componentInstance.currentId()).toBe('test-scan-123'); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.html b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.html index 266aad0c4..1b0a48db0 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.html +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.html @@ -3,16 +3,21 @@
Comparing: - {{ currentTarget()?.label }} + {{ currentTargetLabel() }} - @for (preset of baselinePresets; track preset) { - - {{ preset.label }} + @if (!availableBaselines().length) { + + No baselines available + + } + @for (baseline of availableBaselines(); track baseline.digest) { + + {{ baseline.label }} } @@ -36,7 +41,7 @@ - @@ -44,9 +49,9 @@ - @if (baselineRationale() && roleView().showBaselineRationale) { + @if (baselineNarrative() && roleView().showBaselineRationale) { } @@ -125,7 +130,7 @@ @if (filteredItems().length === 0) {
-

No changes in this category

+

{{ changesEmptyMessage() }}

}
@@ -168,7 +173,7 @@ } @else {
-

Select an item to view evidence

+

{{ evidenceEmptyMessage() }}

} diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.ts b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.ts index 5abe411d5..98a2d7b55 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view/compare-view.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ChangeDetectionStrategy, signal, computed, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, computed, inject, input, effect } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatSelectModule } from '@angular/material/select'; import { MatButtonModule } from '@angular/material/button'; @@ -9,7 +9,18 @@ import { MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute } from '@angular/router'; -import { CompareService, CompareTarget, DeltaCategory, DeltaItem, EvidencePane } from '../../services/compare.service'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { + BaselineRationale, + BaselineRecommendation, + CompareService, + CompareTarget, + DeltaCategory, + DeltaItem, + EvidencePane, +} from '../../services/compare.service'; import { CompareExportService } from '../../services/compare-export.service'; import { UserPreferencesService, ViewRole } from '../../services/user-preferences.service'; import { ActionablesPanelComponent } from '../actionables-panel/actionables-panel.component'; @@ -59,12 +70,22 @@ const ROLE_VIEW_CONFIG: Record = { styleUrls: ['./compare-view.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CompareViewComponent implements OnInit { +export class CompareViewComponent { + readonly currentId = input(null); + readonly baselineId = input(null); + private readonly route = inject(ActivatedRoute); private readonly compareService = inject(CompareService); private readonly exportService = inject(CompareExportService); private readonly userPreferences = inject(UserPreferencesService); private readonly sanitizer = inject(DomSanitizer); + private readonly routeParamMap = toSignal(this.route.paramMap, { + initialValue: this.route.snapshot.paramMap, + }); + private readonly routeQueryParamMap = toSignal(this.route.queryParamMap, { + initialValue: this.route.snapshot.queryParamMap, + }); + private loadToken = 0; private readonly iconSvgMap: Record = { add_circle: '', @@ -99,24 +120,96 @@ export class CompareViewComponent implements OnInit { // State currentTarget = signal(null); baselineTarget = signal(null); + baselineRecommendation = signal(null); categories = signal([]); selectedCategory = signal(null); items = signal([]); selectedItem = signal(null); evidence = signal(null); viewMode = signal<'side-by-side' | 'unified'>(this.userPreferences.viewMode()); - baselineRationale = signal(null); + readonly resolvedCurrentId = computed(() => { + const explicitCurrent = this.currentId()?.trim(); + const routeCurrent = + this.routeParamMap().get('currentId')?.trim() ?? + this.routeParamMap().get('current')?.trim(); + const scanId = this.routeQueryParamMap().get('scanId')?.trim(); + return explicitCurrent || routeCurrent || scanId || null; + }); + readonly resolvedBaselineId = computed(() => { + const explicitBaseline = this.baselineId()?.trim(); + const routeBaseline = this.routeQueryParamMap().get('baseline')?.trim(); + return explicitBaseline || routeBaseline || null; + }); // Computed filteredItems = computed(() => { + if (!this.comparisonReady()) { + return []; + } + const cat = this.selectedCategory(); if (!cat) return this.items(); return this.items().filter(i => i.category === cat); }); currentRole = computed(() => this.userPreferences.role()); roleView = computed(() => ROLE_VIEW_CONFIG[this.currentRole()]); + comparisonReady = computed(() => !!this.currentTarget() && !!this.baselineTarget()); + currentTargetLabel = computed(() => { + const current = this.currentTarget(); + if (current?.label?.trim()) { + return current.label; + } + + return this.fallbackTargetLabel(this.resolvedCurrentId(), 'Current scan'); + }); + baselineNarrative = computed(() => this.baselineRecommendation()?.selectionReason?.trim() || null); + availableBaselines = computed(() => { + const rationale = this.baselineRecommendation(); + if (!rationale) { + return []; + } + + const alternatives = Array.isArray(rationale.alternatives) ? [...rationale.alternatives] : []; + const selectedDigest = rationale.selectedDigest?.trim(); + if (selectedDigest && !alternatives.some(option => option.digest === selectedDigest)) { + alternatives.unshift({ + digest: selectedDigest, + label: 'Recommended baseline', + reason: rationale.selectionReason, + scanDate: '', + isPrimary: true, + confidenceScore: 1, + }); + } + + return alternatives; + }); + selectedBaselineId = computed(() => this.baselineTarget()?.id ?? this.baselineRecommendation()?.selectedDigest ?? null); + hasBaselineChoices = computed(() => this.availableBaselines().length > 0); + baselineSelectPlaceholder = computed(() => this.hasBaselineChoices() ? 'Select baseline' : 'No baselines available'); + changesEmptyMessage = computed(() => { + if (!this.comparisonReady()) { + return this.baselineNarrative() ?? 'No baseline is available for this scan yet.'; + } + + return this.selectedCategory() + ? 'No changes in this category' + : 'No changes were detected for this comparison'; + }); + evidenceEmptyMessage = computed(() => { + if (!this.comparisonReady()) { + return 'Comparison evidence becomes available after a baseline is selected.'; + } + + return 'Select an item to view evidence'; + }); + canExport = computed(() => this.comparisonReady()); deltaSummary = computed(() => { + if (!this.comparisonReady()) { + return null; + } + const cats = this.categories(); return { totalAdded: cats.reduce((sum, c) => sum + c.added, 0), @@ -124,51 +217,77 @@ export class CompareViewComponent implements OnInit { totalChanged: cats.reduce((sum, c) => sum + c.changed, 0) }; }); + private readonly syncInputsEffect = effect(() => { + const currentId = this.resolvedCurrentId(); + const baselineId = this.resolvedBaselineId(); + const token = ++this.loadToken; - // Baseline presets - baselinePresets = [ - { id: 'last-green', label: 'Last Green Build' }, - { id: 'previous-release', label: 'Previous Release' }, - { id: 'main-branch', label: 'Main Branch' }, - { id: 'custom', label: 'Custom...' } - ]; + this.resetComparisonState(); + if (!currentId) { + return; + } + + this.loadTarget(currentId, 'current', token); + this.loadBaselineRecommendation(currentId, token, !baselineId); + + if (baselineId) { + this.loadTarget(baselineId, 'baseline', token); + } + }); ngOnInit(): void { - // Load from route params - const currentId = - this.route.snapshot.paramMap.get('currentId') ?? - this.route.snapshot.paramMap.get('current'); - const baselineId = this.route.snapshot.queryParamMap.get('baseline'); - - if (currentId) { - this.loadTarget(currentId, 'current'); - } - if (baselineId) { - this.loadTarget(baselineId, 'baseline'); - } + // Intentionally empty: the compare surface is synchronized through signals/effects. } - loadTarget(id: string, type: 'current' | 'baseline'): void { - this.compareService.getTarget(id).subscribe(target => { + loadTarget(id: string, type: 'current' | 'baseline', token = this.loadToken): void { + const trimmedId = id.trim(); + if (!trimmedId) { + return; + } + + if (type === 'current' && trimmedId === 'active-scan') { + this.currentTarget.set(this.createSyntheticTarget(trimmedId, 'Active scan')); + this.loadDelta(token); + return; + } + + if (type === 'baseline') { + this.clearComparisonResults(); + const rationale = this.baselineRecommendation(); + if (rationale) { + this.baselineRecommendation.set({ ...rationale, selectedDigest: trimmedId }); + } + } + + this.compareService.getTarget(trimmedId).pipe( + catchError(() => of(null)) + ).subscribe(target => { + if (token !== this.loadToken || !target) { + return; + } + if (type === 'current') { this.currentTarget.set(target); } else { this.baselineTarget.set(target); - // Load baseline rationale - this.compareService.getBaselineRationale(id).subscribe(rationale => { - this.baselineRationale.set(rationale.selectionReason); - }); } - this.loadDelta(); + + this.loadDelta(token); }); } - loadDelta(): void { + loadDelta(token = this.loadToken): void { const current = this.currentTarget(); const baseline = this.baselineTarget(); if (!current || !baseline) return; - this.compareService.computeDelta(current.id, baseline.id).subscribe(delta => { + this.compareService.computeDelta(current.id, baseline.id).pipe( + catchError(() => of({ categories: [], items: [] })) + ).subscribe(delta => { + if (token !== this.loadToken) { + return; + } + this.categories.set(delta.categories); this.items.set(delta.items); this.applyRoleDefaults(this.currentRole()); @@ -236,6 +355,10 @@ export class CompareViewComponent implements OnInit { } exportReport(): void { + if (!this.canExport()) { + return; + } + const current = this.currentTarget(); const baseline = this.baselineTarget(); if (!current || !baseline) return; @@ -257,4 +380,70 @@ export class CompareViewComponent implements OnInit { const hasDefaultCategory = this.categories().some(category => category.id === defaultCategory); this.selectedCategory.set(hasDefaultCategory ? defaultCategory : null); } + + private clearComparisonResults(): void { + this.categories.set([]); + this.items.set([]); + this.selectedCategory.set(null); + this.selectedItem.set(null); + this.evidence.set(null); + } + + private resetComparisonState(): void { + this.currentTarget.set(null); + this.baselineTarget.set(null); + this.baselineRecommendation.set(null); + this.clearComparisonResults(); + } + + private loadBaselineRecommendation(currentId: string, token: number, allowAutoSelection: boolean): void { + this.compareService.getBaselineRationale(currentId).pipe( + catchError(() => + of(this.normalizeRationale(null)) + ) + ).subscribe(rationale => { + if (token !== this.loadToken) { + return; + } + + const normalized = this.normalizeRationale(rationale); + this.baselineRecommendation.set(normalized); + + if (allowAutoSelection && !this.baselineTarget()) { + const recommendedDigest = normalized.selectedDigest.trim(); + if (recommendedDigest) { + this.loadTarget(recommendedDigest, 'baseline', token); + } + } + }); + } + + private normalizeRationale(rationale: BaselineRationale | null | undefined): BaselineRationale { + return { + selectedDigest: typeof rationale?.selectedDigest === 'string' ? rationale.selectedDigest : '', + selectionReason: rationale?.selectionReason?.trim() || 'No baseline is available for this scan yet.', + alternatives: Array.isArray(rationale?.alternatives) ? rationale.alternatives : [], + autoSelectEnabled: rationale?.autoSelectEnabled ?? true, + }; + } + + private fallbackTargetLabel(id: string | null, fallback: string): string { + if (!id) { + return fallback; + } + + return id === 'active-scan' + ? 'Active scan' + : id; + } + + private createSyntheticTarget(id: string, label: string): CompareTarget { + return { + id, + digest: id, + imageRef: label, + scanDate: '', + label, + }; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.spec.ts index 3407bfaa2..7deb129ff 100644 --- a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.spec.ts @@ -8,18 +8,20 @@ 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 { of, BehaviorSubject, throwError } 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', () => { let component: FindingsContainerComponent; let fixture: ComponentFixture; let mockViewPrefService: jasmine.SpyObj; let mockCompareService: jasmine.SpyObj; + let mockFindingsApi: jasmine.SpyObj<{ listFindings: () => any }>; let queryParamMap$: BehaviorSubject; let paramMap$: BehaviorSubject; @@ -34,6 +36,8 @@ describe('FindingsContainerComponent', () => { mockCompareService = jasmine.createSpyObj('CompareService', [ 'getBaselineRecommendations', + 'getBaselineRationale', + 'getTarget', 'computeDelta' ]); mockCompareService.getBaselineRecommendations.and.returnValue(of({ @@ -42,6 +46,19 @@ describe('FindingsContainerComponent', () => { alternatives: [], autoSelectEnabled: true })); + mockCompareService.getBaselineRationale.and.returnValue(of({ + selectedDigest: 'baseline-123', + selectionReason: 'Last Green Build', + alternatives: [], + autoSelectEnabled: true + })); + mockCompareService.getTarget.and.callFake((id: string) => of({ + id, + digest: id, + imageRef: `registry.local/${id}`, + scanDate: '2026-03-07T00:00:00Z', + label: id === 'test-scan-123' ? 'Test scan' : 'Baseline test scan' + })); mockCompareService.computeDelta.and.returnValue(of({ categories: [ { id: 'added', name: 'Added', icon: 'add', added: 5, removed: 0, changed: 0 }, @@ -50,6 +67,19 @@ describe('FindingsContainerComponent', () => { ], items: [] })); + mockFindingsApi = jasmine.createSpyObj('SecurityFindingsApi', ['listFindings']); + mockFindingsApi.listFindings.and.returnValue(of([ + { + 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', + }, + ])); await TestBed.configureTestingModule({ imports: [ @@ -61,6 +91,7 @@ describe('FindingsContainerComponent', () => { provideRouter([]), { provide: ViewPreferenceService, useValue: mockViewPrefService }, { provide: CompareService, useValue: mockCompareService }, + { provide: SECURITY_FINDINGS_API, useValue: mockFindingsApi }, { provide: ActivatedRoute, useValue: { @@ -102,6 +133,7 @@ describe('FindingsContainerComponent', () => { provideRouter([]), { provide: ViewPreferenceService, useValue: mockViewPrefService }, { provide: CompareService, useValue: mockCompareService }, + { provide: SECURITY_FINDINGS_API, useValue: mockFindingsApi }, { provide: ActivatedRoute, useValue: { @@ -134,6 +166,7 @@ describe('FindingsContainerComponent', () => { provideRouter([]), { provide: ViewPreferenceService, useValue: mockViewPrefService }, { provide: CompareService, useValue: mockCompareService }, + { provide: SECURITY_FINDINGS_API, useValue: mockFindingsApi }, { provide: ActivatedRoute, useValue: { @@ -177,4 +210,34 @@ describe('FindingsContainerComponent', () => { const toggle = fixture.nativeElement.querySelector('stella-findings-view-toggle'); expect(toggle).toBeTruthy(); }); + + it('loads detail findings from the security findings client instead of placeholder rows', async () => { + await fixture.whenStable(); + + expect(mockFindingsApi.listFindings).toHaveBeenCalledWith({ limit: 200, sort: 'severity' }); + expect(component.findings()).toEqual([ + jasmine.objectContaining({ + id: 'finding-1', + advisoryId: 'CVE-2026-8001', + packageName: 'backend-api', + packageVersion: '2.5.0', + severity: 'critical', + status: 'open', + }), + ]); + }); + + it('shows an explicit detail error instead of placeholder findings when the live list fails', async () => { + mockFindingsApi.listFindings.and.returnValue(throwError(() => new Error('findings API unavailable'))); + queryParamMap$.next(convertToParamMap({ view: 'detail' })); + + const freshFixture = TestBed.createComponent(FindingsContainerComponent); + freshFixture.detectChanges(); + await freshFixture.whenStable(); + freshFixture.detectChanges(); + + expect(freshFixture.nativeElement.textContent).toContain('Findings data unavailable'); + expect(freshFixture.nativeElement.textContent).toContain('findings API unavailable'); + expect(freshFixture.nativeElement.textContent).not.toContain('backend-api'); + }); }); 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 75b6e2b1d..b91bbca65 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 @@ -13,44 +13,71 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { toSignal } from '@angular/core/rxjs-interop'; import { map, switchMap, catchError } from 'rxjs/operators'; -import { of } from 'rxjs'; +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 { CompareService, DeltaResult } from '../../compare/services/compare.service'; +import { CompareService } from '../../compare/services/compare.service'; +import { + SECURITY_FINDINGS_API, + type SecurityFindingsApi, + type FindingDto as SecurityFindingDto, +} from '../../../core/api/security-findings.client'; -function buildDetailViewFindings(scanId: string): Finding[] { - return [ - { - id: `CVE-2026-8001@pkg:oci/backend-api@2.5.0-hard-fail-anchored-${scanId}`, - advisoryId: 'CVE-2026-8001', - packageName: 'backend-api', - packageVersion: '2.5.0', - severity: 'critical', - status: 'open', - publishedAt: '2026-02-10T09:30:00Z', - }, - { - id: `CVE-2026-8002@pkg:oci/worker-api@1.8.3-anchored-${scanId}`, - advisoryId: 'CVE-2026-8002', - packageName: 'worker-api', - packageVersion: '1.8.3', - severity: 'high', - status: 'in_progress', - publishedAt: '2026-02-08T09:30:00Z', - }, - { - id: `CVE-2026-8003@pkg:oci/frontend-ui@4.0.1-${scanId}`, - advisoryId: 'CVE-2026-8003', - packageName: 'frontend-ui', - packageVersion: '4.0.1', - severity: 'medium', - status: 'open', - publishedAt: '2026-02-05T09:30:00Z', - }, - ]; +type DetailFindingSource = SecurityFindingDto & Record; + +function readString(value: unknown, fallback = ''): string { + return typeof value === 'string' && value.trim() ? value.trim() : fallback; +} + +function mapSeverity(value: unknown): Finding['severity'] { + const normalized = readString(value, 'unknown').toLowerCase(); + switch (normalized) { + case 'critical': + case 'high': + case 'medium': + case 'low': + return normalized; + default: + return 'unknown'; + } +} + +function mapStatus(source: DetailFindingSource): Finding['status'] { + const delta = readString(source.delta).toLowerCase(); + const vexStatus = readString(source.vexStatus).toLowerCase(); + + if (delta === 'resolved' || vexStatus === 'fixed') { + return 'fixed'; + } + + if (vexStatus === 'not_affected' || vexStatus === 'excepted' || vexStatus === 'false_positive') { + return 'excepted'; + } + + if (delta === 'carried' || delta === 'new' || vexStatus === 'affected' || vexStatus === 'under_investigation') { + return 'open'; + } + + return 'open'; +} + +function mapFinding(source: DetailFindingSource): Finding { + const advisoryId = readString(source['advisoryId'], readString(source['cveId'], readString(source.id, 'unknown-finding'))); + const packageName = readString(source['package'], readString(source['packageName'], 'unknown-package')); + const packageVersion = readString(source.version, readString(source['componentName'], 'unknown-version')); + + return { + id: readString(source.id, advisoryId), + advisoryId, + packageName, + packageVersion, + severity: mapSeverity(source.severity), + status: mapStatus(source), + publishedAt: readString(source.firstSeen, readString(source['updatedAt'])), + }; } /** @@ -113,12 +140,19 @@ function buildDetailViewFindings(scanId: string): Finding[] { } @else { @switch (viewMode()) { @case ('diff') { - + } @case ('detail') { - + @if (detailError(); as message) { + + } @else { + + } } } } @@ -189,6 +223,25 @@ function buildDetailViewFindings(scanId: string): Finding[] { gap: 16px; color: var(--mat-app-text-color); } + + .detail-state { + margin: 24px; + padding: 16px 18px; + border-radius: var(--radius-md); + border: 1px solid var(--color-status-error-border, rgba(239, 83, 80, 0.35)); + background: var(--color-status-error-bg, rgba(239, 83, 80, 0.08)); + color: var(--color-status-error-text); + } + + .detail-state h3 { + margin: 0 0 8px; + font-size: var(--font-size-md); + } + + .detail-state p { + margin: 0; + line-height: 1.5; + } `], changeDetection: ChangeDetectionStrategy.OnPush }) @@ -196,6 +249,7 @@ export class FindingsContainerComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly viewPref = inject(ViewPreferenceService); private readonly compareService = inject(CompareService); + private readonly findingsApi = inject(SECURITY_FINDINGS_API); // View mode: explicit URL override > reactive user preference readonly viewMode = computed(() => this.urlViewMode() ?? this.viewPref.viewMode()); @@ -206,6 +260,9 @@ export class FindingsContainerComponent implements OnInit { // Findings for detail view readonly findings = signal([]); + // Detail view data failure state + readonly detailError = signal(null); + // Delta summary for diff view readonly deltaSummary = signal<{ added: number; removed: number; changed: number } | null>(null); @@ -239,26 +296,41 @@ export class FindingsContainerComponent implements OnInit { const scanId = this.scanId(); if (!scanId) return; - this.findings.set(buildDetailViewFindings(scanId)); this.loading.set(true); + this.detailError.set(null); + this.findings.set([]); + + forkJoin({ + delta: this.compareService.getBaselineRecommendations(scanId).pipe( + switchMap((rationale) => { + if (rationale.selectedDigest) { + return this.compareService.computeDelta(scanId, rationale.selectedDigest); + } + + return of(null); + }), + catchError(() => of(null)) + ), + findings: this.findingsApi.listFindings({ limit: 200, sort: 'severity' }).pipe( + map((items) => items.map((item) => mapFinding(item as DetailFindingSource))), + catchError((error) => { + this.detailError.set(this.describeDetailError(error)); + return of([] as Finding[]); + }) + ), + }).subscribe(({ delta, findings }) => { + this.findings.set(findings); - // Load delta summary for diff view header - this.compareService.getBaselineRecommendations(scanId).pipe( - switchMap(rationale => { - if (rationale.selectedDigest) { - return this.compareService.computeDelta(scanId, rationale.selectedDigest); - } - return of(null); - }), - catchError(() => of(null)) - ).subscribe(delta => { if (delta) { this.deltaSummary.set({ - added: delta.categories.reduce((sum, c) => sum + c.added, 0), - removed: delta.categories.reduce((sum, c) => sum + c.removed, 0), - changed: delta.categories.reduce((sum, c) => sum + c.changed, 0) + added: delta.categories.reduce((sum, category) => sum + category.added, 0), + removed: delta.categories.reduce((sum, category) => sum + category.removed, 0), + changed: delta.categories.reduce((sum, category) => sum + category.changed, 0), }); + } else { + this.deltaSummary.set(null); } + this.loading.set(false); }); } @@ -271,4 +343,12 @@ export class FindingsContainerComponent implements OnInit { private readViewMode(raw: string | null): FindingsViewMode | null { return raw === 'diff' || raw === 'detail' ? raw : null; } + + private describeDetailError(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return 'The scoped findings list could not be loaded for the current tenant and environment context.'; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.html b/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.html index 529ae2934..f070caa32 100644 --- a/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.html +++ b/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.html @@ -6,13 +6,6 @@
{{ displayFindings().length }} of {{ scoredFindings().length }}
-
- @if (scanId()) { - - } -
@@ -82,12 +75,6 @@ - @if (scanId()) { - - } } diff --git a/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.ts b/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.ts index 6b202d71c..bfab8e272 100644 --- a/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/findings/findings-list.component.ts @@ -26,7 +26,6 @@ import { ScoreBreakdownPopoverComponent, ScoreHistoryChartComponent, } from '../../shared/components/score'; -import { ExportAuditPackButtonComponent } from '../../shared/components/audit-pack'; import { VexTrustChipComponent, VexTrustPopoverComponent, TrustChipPopoverEvent } from '../../shared/components'; import { ReasonCapsuleComponent } from '../triage/components/reason-capsule/reason-capsule.component'; import { TranslatePipe } from '../../core/i18n'; @@ -111,7 +110,6 @@ export interface FindingsFilter { ScoreBadgeComponent, ScoreBreakdownPopoverComponent, ScoreHistoryChartComponent, - ExportAuditPackButtonComponent, VexTrustChipComponent, VexTrustPopoverComponent, ReasonCapsuleComponent, @@ -127,9 +125,6 @@ export class FindingsListComponent { /** Input findings to display */ readonly findings = input([]); - /** Scan ID for export functionality */ - readonly scanId = input(''); - /** Whether to auto-load scores */ readonly autoLoadScores = input(true); diff --git a/src/Web/StellaOps.Web/src/tests/compare/compare-view.component.spec.ts b/src/Web/StellaOps.Web/src/tests/compare/compare-view.component.spec.ts index 6c52ef812..b31534532 100644 --- a/src/Web/StellaOps.Web/src/tests/compare/compare-view.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/compare/compare-view.component.spec.ts @@ -79,9 +79,22 @@ describe('CompareViewComponent (compare)', () => { 'computeDelta', 'getItemEvidence', ]) as jasmine.SpyObj; - compareSpy.getTarget.and.callFake((id: string) => - of(id === 'cur-1' ? currentTarget : baselineTarget) - ); + compareSpy.getTarget.and.callFake((id: string) => { + if (id === 'cur-1') { + return of(currentTarget); + } + + if (id === 'active-scan') { + return of({ + ...currentTarget, + id: 'active-scan', + digest: 'active-scan', + label: 'Active scan', + }); + } + + return of(baselineTarget); + }); compareSpy.getBaselineRationale.and.returnValue(of(rationale)); compareSpy.computeDelta.and.returnValue(of(delta)); compareSpy.getItemEvidence.and.returnValue( @@ -109,6 +122,8 @@ describe('CompareViewComponent (compare)', () => { { provide: ActivatedRoute, useValue: { + paramMap: of(convertToParamMap({ currentId: 'cur-1' })), + queryParamMap: of(convertToParamMap({ baseline: 'base-1' })), snapshot: { paramMap: convertToParamMap({ currentId: 'cur-1' }), queryParamMap: convertToParamMap({ baseline: 'base-1' }), @@ -143,8 +158,8 @@ describe('CompareViewComponent (compare)', () => { }); it('renders delta summary chips for compare verdict view', () => { - expect(component.deltaSummary().totalAdded).toBe(1); - expect(component.deltaSummary().totalChanged).toBe(1); + expect(component.deltaSummary()?.totalAdded).toBe(1); + expect(component.deltaSummary()?.totalChanged).toBe(1); const addedChip = fixture.nativeElement.querySelector( '.summary-chip.added' @@ -170,4 +185,51 @@ describe('CompareViewComponent (compare)', () => { component.exportReport(); expect(exportSpy.exportJson).toHaveBeenCalled(); }); + + it('auto-loads the active scan input and recommended baseline for embedded findings usage', () => { + compareSpy.getTarget.calls.reset(); + compareSpy.getBaselineRationale.calls.reset(); + compareSpy.computeDelta.calls.reset(); + + fixture.componentRef.setInput('currentId', 'active-scan'); + fixture.componentRef.setInput('baselineId', null); + fixture.detectChanges(); + + expect(compareSpy.getBaselineRationale).toHaveBeenCalledWith('active-scan'); + expect(compareSpy.getTarget).toHaveBeenCalledWith('base-1'); + expect(component.currentTarget()?.id).toBe('active-scan'); + expect(component.baselineTarget()?.id).toBe('base-1'); + expect(component.canExport()).toBeTrue(); + }); + + it('shows an unavailable state and disables export when no baseline exists for the scan', () => { + compareSpy.getTarget.calls.reset(); + compareSpy.getBaselineRationale.calls.reset(); + compareSpy.computeDelta.calls.reset(); + compareSpy.getBaselineRationale.and.returnValue( + of({ + selectedDigest: '', + selectionReason: 'No baseline recommendations available for this scan', + alternatives: [], + autoSelectEnabled: true, + }) + ); + + fixture.componentRef.setInput('currentId', 'active-scan'); + fixture.componentRef.setInput('baselineId', null); + fixture.detectChanges(); + + expect(compareSpy.getBaselineRationale).toHaveBeenCalledWith('active-scan'); + expect(compareSpy.computeDelta).not.toHaveBeenCalled(); + expect(component.deltaSummary()).toBeNull(); + expect(component.canExport()).toBeFalse(); + expect(fixture.nativeElement.textContent).toContain('No baseline recommendations available for this scan'); + + const exportButton = Array.from( + fixture.nativeElement.querySelectorAll('button') + ) + .find((button) => button.textContent?.includes('Export')) as HTMLButtonElement | undefined; + expect(exportButton).toBeDefined(); + expect(exportButton?.disabled).toBeTrue(); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/compare/role-based-views.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/compare/role-based-views.behavior.spec.ts index 8a3b5687a..52fba6805 100644 --- a/src/Web/StellaOps.Web/src/tests/compare/role-based-views.behavior.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/compare/role-based-views.behavior.spec.ts @@ -94,6 +94,8 @@ describe('role-based-views behavior', () => { { provide: ActivatedRoute, useValue: { + paramMap: of(convertToParamMap({ currentId: 'cur-1' })), + queryParamMap: of(convertToParamMap({ baseline: 'base-1' })), snapshot: { paramMap: convertToParamMap({ currentId: 'cur-1' }), queryParamMap: convertToParamMap({ baseline: 'base-1' }), diff --git a/src/Web/StellaOps.Web/src/tests/findings/findings-list.audit-export.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/findings/findings-list.audit-export.behavior.spec.ts new file mode 100644 index 000000000..5d3f76f70 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/findings/findings-list.audit-export.behavior.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { AuditReasonsClient } from '../../app/core/api/audit-reasons.client'; +import { SCORING_API, ScoringApi } from '../../app/core/services/scoring.service'; +import { Finding, FindingsListComponent } from '../../app/features/findings/findings-list.component'; + +describe('findings-list audit export behavior', () => { + let fixture: ComponentFixture; + + const findings: Finding[] = [ + { + id: 'finding-001', + advisoryId: 'CVE-2026-7001', + packageName: 'backend-api', + packageVersion: '2.5.0', + severity: 'high', + status: 'open', + publishedAt: '2026-03-01T00:00:00Z', + }, + ]; + + beforeEach(async () => { + const scoringApi: ScoringApi = { + calculateScore: jasmine.createSpy('calculateScore').and.returnValue(of(undefined as never)), + getScore: jasmine.createSpy('getScore').and.returnValue(of(undefined as never)), + calculateScores: jasmine.createSpy('calculateScores').and.returnValue( + of({ + results: [], + summary: { + total: 0, + byBucket: { + ActNow: 0, + ScheduleNext: 0, + Investigate: 0, + Watchlist: 0, + }, + averageScore: 0, + calculationTimeMs: 0, + }, + policyDigest: 'sha256:test-policy', + calculatedAt: '2026-03-07T00:00:00Z', + }), + ), + getScoreHistory: jasmine.createSpy('getScoreHistory').and.returnValue(of(undefined as never)), + getScoringPolicy: jasmine.createSpy('getScoringPolicy').and.returnValue(of(undefined as never)), + getScoringPolicyVersion: jasmine.createSpy('getScoringPolicyVersion').and.returnValue(of(undefined as never)), + }; + + await TestBed.configureTestingModule({ + imports: [FindingsListComponent], + providers: [ + { + provide: AuditReasonsClient, + useValue: { + getReason: jasmine.createSpy('getReason').and.returnValue( + of({ + verdictId: 'verdict-001', + policyName: 'default-release-gate', + ruleId: 'RULE-101', + graphRevisionId: 'graph-r001', + inputsDigest: 'sha256:1111', + evaluatedAt: '2026-02-08T10:00:00Z', + reasonLines: ['line-1'], + evidenceRefs: [], + }), + ), + }, + }, + { provide: SCORING_API, useValue: scoringApi }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FindingsListComponent); + fixture.componentRef.setInput('autoLoadScores', false); + fixture.componentRef.setInput('findings', findings); + fixture.detectChanges(); + }); + + it('does not render the unsupported audit export action in findings detail mode', () => { + expect(fixture.nativeElement.textContent).not.toContain('Export Audit Pack'); + }); +});