Fix findings detail truthfulness and export affordances
This commit is contained in:
@@ -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`.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user