Fix findings detail truthfulness and export affordances

This commit is contained in:
master
2026-03-07 18:38:30 +02:00
parent e295768662
commit 6aa8bb5095
11 changed files with 678 additions and 118 deletions

View File

@@ -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&regions=us-east&environments=stage&timeWindow=7d`.

View File

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

View File

@@ -3,16 +3,21 @@
<mat-toolbar class="compare-toolbar">
<div class="target-selector">
<span class="label">Comparing:</span>
<span class="target current">{{ currentTarget()?.label }}</span>
<span class="target current">{{ currentTargetLabel() }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
<mat-select
[value]="baselineTarget()?.id"
[value]="selectedBaselineId()"
(selectionChange)="loadTarget($event.value, 'baseline')"
placeholder="Select baseline"
[placeholder]="baselineSelectPlaceholder()"
>
@for (preset of baselinePresets; track preset) {
<mat-option [value]="preset.id">
{{ preset.label }}
@if (!availableBaselines().length) {
<mat-option value="" disabled>
No baselines available
</mat-option>
}
@for (baseline of availableBaselines(); track baseline.digest) {
<mat-option [value]="baseline.digest">
{{ baseline.label }}
</mat-option>
}
</mat-select>
@@ -36,7 +41,7 @@
<button mat-icon-button (click)="toggleViewMode()" matTooltip="Toggle view mode">
<span [innerHTML]="viewModeIconSvg()"></span>
</button>
<button mat-stroked-button (click)="exportReport()">
<button mat-stroked-button (click)="exportReport()" [disabled]="!canExport()">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export
</button>
@@ -44,9 +49,9 @@
</mat-toolbar>
<!-- Baseline Rationale -->
@if (baselineRationale() && roleView().showBaselineRationale) {
@if (baselineNarrative() && roleView().showBaselineRationale) {
<stella-baseline-rationale
[rationale]="baselineRationale()!"
[rationale]="baselineNarrative()!"
/>
}
@@ -125,7 +130,7 @@
@if (filteredItems().length === 0) {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<p>No changes in this category</p>
<p>{{ changesEmptyMessage() }}</p>
</div>
}
</div>
@@ -168,7 +173,7 @@
} @else {
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 13"/></svg>
<p>Select an item to view evidence</p>
<p>{{ evidenceEmptyMessage() }}</p>
</div>
}

View File

@@ -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<ViewRole, RoleViewConfig> = {
styleUrls: ['./compare-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CompareViewComponent implements OnInit {
export class CompareViewComponent {
readonly currentId = input<string | null>(null);
readonly baselineId = input<string | null>(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<string, string> = {
add_circle: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
@@ -99,24 +120,96 @@ export class CompareViewComponent implements OnInit {
// State
currentTarget = signal<CompareTarget | null>(null);
baselineTarget = signal<CompareTarget | null>(null);
baselineRecommendation = signal<BaselineRationale | null>(null);
categories = signal<DeltaCategory[]>([]);
selectedCategory = signal<string | null>(null);
items = signal<DeltaItem[]>([]);
selectedItem = signal<DeltaItem | null>(null);
evidence = signal<EvidencePane | null>(null);
viewMode = signal<'side-by-side' | 'unified'>(this.userPreferences.viewMode());
baselineRationale = signal<string | null>(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<BaselineRecommendation[]>(() => {
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,
};
}
}

View File

@@ -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<FindingsContainerComponent>;
let mockViewPrefService: jasmine.SpyObj<ViewPreferenceService>;
let mockCompareService: jasmine.SpyObj<CompareService>;
let mockFindingsApi: jasmine.SpyObj<{ listFindings: () => any }>;
let queryParamMap$: BehaviorSubject<any>;
let paramMap$: BehaviorSubject<any>;
@@ -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');
});
});

View File

@@ -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<string, unknown>;
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') {
<stella-compare-view />
<stella-compare-view [currentId]="scanId()" />
}
@case ('detail') {
<app-findings-list
[findings]="findings()"
(findingSelect)="onFindingSelect($event)" />
@if (detailError(); as message) {
<section class="detail-state detail-state--error" role="alert">
<h3>Findings data unavailable</h3>
<p>{{ message }}</p>
</section>
} @else {
<app-findings-list
[findings]="findings()"
(findingSelect)="onFindingSelect($event)" />
}
}
}
}
@@ -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<SecurityFindingsApi>(SECURITY_FINDINGS_API);
// View mode: explicit URL override > reactive user preference
readonly viewMode = computed<FindingsViewMode>(() => this.urlViewMode() ?? this.viewPref.viewMode());
@@ -206,6 +260,9 @@ export class FindingsContainerComponent implements OnInit {
// Findings for detail view
readonly findings = signal<Finding[]>([]);
// Detail view data failure state
readonly detailError = signal<string | null>(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.';
}
}

View File

@@ -6,13 +6,6 @@
<div class="findings-count">
{{ displayFindings().length }} of {{ scoredFindings().length }}
</div>
<div class="header-actions">
@if (scanId()) {
<stella-export-audit-pack-button
[scanId]="scanId()"
[attr.aria-label]="'ui.findings.export_all' | translate" />
}
</div>
</div>
<!-- Bucket summary -->
@@ -82,12 +75,6 @@
<button type="button" class="action-btn primary">
{{ 'ui.findings.bulk_triage' | translate }}
</button>
@if (scanId()) {
<stella-export-audit-pack-button
[scanId]="scanId()"
[findingIds]="selectedIdsArray()"
[attr.aria-label]="'ui.findings.export_selected' | translate" />
}
</div>
}

View File

@@ -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<Finding[]>([]);
/** Scan ID for export functionality */
readonly scanId = input<string>('');
/** Whether to auto-load scores */
readonly autoLoadScores = input(true);

View File

@@ -79,9 +79,22 @@ describe('CompareViewComponent (compare)', () => {
'computeDelta',
'getItemEvidence',
]) as jasmine.SpyObj<CompareService>;
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<HTMLButtonElement>(
fixture.nativeElement.querySelectorAll('button')
)
.find((button) => button.textContent?.includes('Export')) as HTMLButtonElement | undefined;
expect(exportButton).toBeDefined();
expect(exportButton?.disabled).toBeTrue();
});
});

View File

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

View File

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