fix(web): remediate orphan revival regressions

This commit is contained in:
master
2026-03-08 20:23:37 +02:00
parent d6b2e354f0
commit f40043ed50
23 changed files with 823 additions and 683 deletions

View File

@@ -0,0 +1,111 @@
# Sprint 20260308-024 - FE Orphan Revival Regression Remediation
## Topic & Scope
- Repair the concrete regressions found in review across the orphan-revival implementation batch before any more revival work proceeds.
- Fix the frontend build break, restore canonical evidence-thread navigation, restore lost audit and trust filtering capabilities, and remove fabricated finding evidence from mounted shells.
- Keep the remediation bounded to the shipped frontend and its verification/docs; do not reopen unrelated orphan candidates in this sprint.
- Working directory: `src/Web/StellaOps.Web`.
- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/components/policy/`, `src/Web/StellaOps.Web/src/app/shared/directives/`, `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/`, `src/Web/StellaOps.Web/src/app/routes/evidence.routes.ts`, `src/Web/StellaOps.Web/src/app/features/evidence-thread/`, `src/Web/StellaOps.Web/src/app/features/audit-log/`, `src/Web/StellaOps.Web/src/app/features/trust-admin/`, `src/Web/StellaOps.Web/src/app/features/findings/`, and `src/Web/StellaOps.Web/src/app/features/release-orchestrator/`.
- Expected evidence: green Angular build, focused frontend tests for each repaired area, one checked-feature note, and sprint execution-log updates.
## Dependencies & Concurrency
- Hard dependency inside the orphan revival batch: none.
- External prerequisite already satisfied: the affected orphan-revival commits are already landed on `main` and are the baseline for this remediation.
- Safe parallelism:
- Do not staff this sprint in parallel with new orphan-revival implementation work on the same files.
- This sprint owns remediation of the reviewed regressions across sprints `015`, `019`, `020`, and `021`.
## Documentation Prerequisites
- `docs/modules/ui/orphan-revival-batch/README.md`
- `docs/modules/ui/implementation_plan.md`
- `src/Web/StellaOps.Web/AGENTS.md`
- `src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts`
- `src/Web/StellaOps.Web/src/app/features/evidence-thread/evidence-thread.routes.ts`
- `src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts`
- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts`
## Delivery Tracker
### FE-ORM-001 - Clear build blockers in revived shared policy and glossary code
Status: DONE
Dependency: none
Owners: Developer (FE)
Task description:
- Repair the broken shared policy-widget imports and strict TypeScript issues introduced by the orphan revival batch so the Angular application builds again.
- Keep the fix minimal and bounded to the revived components and directive; do not redesign the widgets in this task.
Completion criteria:
- [x] `npm run build` succeeds in `src/Web/StellaOps.Web`.
- [x] Shared policy widget imports resolve correctly.
- [x] The glossary directive no longer violates strict-null or typing rules.
### FE-ORM-002 - Restore canonical evidence-thread navigation behavior
Status: DONE
Dependency: FE-ORM-001
Owners: Developer (FE)
Task description:
- Repair the evidence-thread list/detail navigation so the reconnected `/evidence/threads/*` URLs are used consistently by row-click, back navigation, and any route-focused regression coverage.
Completion criteria:
- [x] Evidence-thread list rows navigate to `/evidence/threads/:artifactDigest`.
- [x] Evidence-thread detail back navigation returns to `/evidence/threads`.
- [x] Focused route tests cover the repaired canonical URLs.
### FE-ORM-003 - Restore full mounted audit and trust filter semantics
Status: DONE
Dependency: FE-ORM-001
Owners: Developer (FE), UX
Task description:
- Restore the filter capabilities lost during the shared filter-bar migration on mounted audit pages, including actor filtering, custom date support, and any multi-select semantics that the pages previously exposed.
- The end result may extend the shared filter bar or use bounded page-local controls, but the mounted pages must regain their previously available filtering behavior.
Completion criteria:
- [x] Audit log page supports actor filtering, custom date ranges, and multi-value module/action/severity filters again.
- [x] Trust audit log page supports start and end date filtering again.
- [x] Focused tests assert the restored filter-state behavior and request-shape wiring.
### FE-ORM-004 - Remove fabricated finding evidence from revived mounted consumers
Status: DONE
Dependency: FE-ORM-001
Owners: Developer (FE), Product Manager
Task description:
- Eliminate fabricated timestamps, synthetic scores, and invented PURLs from the revived findings and release-security consumers.
- If the shared finding-list contract cannot be satisfied truthfully for a mounted host, back that host out to its prior truthful presentation instead of synthesizing data.
Completion criteria:
- [x] Mounted findings and release-security surfaces no longer fabricate `last_seen`, `risk_score`, or package identity fields.
- [x] Any remaining revived shared finding-list adoption is backed by truthful source data.
- [x] Focused tests assert truthful rendering or the bounded rollback decision for the affected hosts.
### FE-ORM-005 - Verify, document, and sync the remediation
Status: DONE
Dependency: FE-ORM-002
Owners: Test Automation, Documentation author
Task description:
- Run the focused build and test matrix for the repaired areas, record the outcomes, and sync the UI plan/docs with the remediation result.
Completion criteria:
- [x] Focused frontend tests cover policy build blockers, evidence-thread routing, audit/trust filtering, and findings/release-security behavior.
- [x] Checked-feature note exists under `docs/features/checked/web/`.
- [x] UI plan/task docs reflect the remediation status and any bounded rollback decisions.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-08 | Sprint created to remediate the concrete regressions found during review of orphan-revival sprints 015, 019, 020, and 021. | Project Manager |
| 2026-03-08 | Repaired build blockers in revived policy widgets and glossary tooltip directive so the Angular production build compiles again. | Developer (FE) |
| 2026-03-08 | Repaired evidence-thread row and back navigation to use canonical `/evidence/threads/*` URLs. | Developer (FE) |
| 2026-03-08 | Restored full mounted audit and trust filter semantics by rolling the audit-log and trust-audit pages back to truthful page-local controls where the shared filter bar could not preserve behavior. | Developer (FE) |
| 2026-03-08 | Removed fabricated finding evidence from mounted findings and release-security hosts by rolling those consumers back to their truthful bespoke list/table rendering. | Developer (FE) |
| 2026-03-08 | Verified remediation with `npm run build` and focused Angular tests covering policy hosts, evidence-thread routing, audit/trust filtering, and truthful findings/release rendering. | Test Automation |
## Decisions & Risks
- Decision: this sprint prioritizes truthful mounted behavior over preserving every individual orphan adoption. If a revived component forces fabricated data, the mounted host may be rolled back to its prior truthful UI.
- Decision: `audit-log-table` and `trust-audit-log` keep page-local filter controls for now; the shared `FilterBarComponent` remains adopted only where it does not remove operator capabilities.
- Decision: the shared `FindingListComponent` is no longer used on mounted findings and release-security hosts until a truthful data contract exists for those surfaces.
- Risk: older checked-feature notes for orphan revival sprints can drift from current shipped behavior after bounded rollbacks.
- Mitigation: record the remediation in a dedicated checked-feature note and sync the UI plan plus orphan-revival batch docs with the rollback decisions.
## Next Checkpoints
- 2026-03-08: build blockers fixed and verified.
- 2026-03-09: routing, filter, and findings regressions resolved with focused test evidence.

View File

@@ -12,6 +12,8 @@ SPRINT_20260308_021_FE_unreachable_evidence_thread_and_persona_workspaces_routes
## Description
Reconnected the disconnected evidence-thread and persona-workspace route families under the canonical `/evidence` route shell. Evidence threads and auditor/developer workspaces are now reachable through Evidence-owned URLs, acting as evidence lenses rather than a parallel product shell.
Sprint `SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` completed the cutover by fixing evidence-thread list-row and back-button navigation to use the same canonical `/evidence/threads/*` URLs instead of the dead legacy `/evidence-thread` frontdoor.
## Canonical URL Contract
- `/evidence/threads` - Evidence thread list
- `/evidence/threads/:artifactDigest` - Evidence thread detail

View File

@@ -4,9 +4,13 @@ Sprint: `SPRINT_20260308_015_FE_orphan_filter_bar_unification.md`
Feature IDs: FE-OFB-001 through FE-OFB-004
Status: Shipped
## Post-remediation note
Sprint `SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` partially rolled back this adoption on mounted audit hosts. `audit-log-table` and `trust-audit-log` no longer use the shared filter bar because that migration removed actor, multi-select, and date-filter behavior. The shared bar remains mounted only on the adopters where it preserves real page semantics.
## What shipped
The dormant shared `FilterBarComponent` (`src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts`) has been adopted across all seven eligible mounted list pages. Each page's bespoke filter toolbar markup has been replaced by a single `<app-filter-bar>` element that renders search input, single-select filter dropdowns, active-filter chips, and a clear-all button through the shared component's existing API.
The dormant shared `FilterBarComponent` (`src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts`) was adopted across seven mounted list pages in the original sprint. After remediation, five mounted pages still use the shared bar; the two audit-family pages listed below reverted to page-local controls to preserve truthful operator workflows.
## Shared contract (unchanged)
@@ -26,7 +30,7 @@ The `FilterBarComponent` API was sufficient without extension:
| Page | File | Filter options exposed |
| --- | --- | --- |
| Audit Log Table | `features/audit-log/audit-log-table.component.ts` | module, action, severity, dateRange |
| Audit Log Table | `features/audit-log/audit-log-table.component.ts` | rolled back in sprint `024`; page-local filters restored |
| Secret Findings List | `features/secret-detection/secret-findings-list.component.ts` | severity, status, category |
| Console Admin Audit Log | `features/console-admin/audit/audit-log.component.ts` | eventType (16 event types) |
@@ -36,7 +40,7 @@ The `FilterBarComponent` API was sufficient without extension:
| --- | --- | --- |
| Release List | `features/release-orchestrator/releases/release-list/release-list.component.ts` | type, stage, gate, risk, blocked, needsApproval, hotfixLane, replayMismatch |
| Evidence Pack List | `features/evidence-pack/evidence-pack-list.component.ts` | (search only, no dropdowns) |
| Trust Audit Log | `features/trust-admin/trust-audit-log.component.ts` | resourceType, severity |
| Trust Audit Log | `features/trust-admin/trust-audit-log.component.ts` | rolled back in sprint `024`; start/end date filters restored via page-local controls |
| Certificate Inventory | `features/trust-admin/certificate-inventory.component.ts` | status, type |
## Exclusions
@@ -46,10 +50,9 @@ The `FilterBarComponent` API was sufficient without extension:
## Design decisions
- Multi-select dropdowns on the audit-log-table were simplified to single-select to match the shared bar's existing contract. The underlying filter arrays still work but now hold at most one value.
- Date-range inputs on the audit-log-table were mapped to preset dropdown options (24h, 7d, 30d, 90d).
- Date inputs on trust-admin pages were removed from the filter bar; dates remain in component state for API queries but are not exposed through the shared bar.
- Audit-log and trust-audit pages were reverted to bespoke controls because the shared bar could not preserve their real semantics.
- Query-state persistence on the release-list via `buildQueryParams()` and route subscription remains intact.
- Future shared filter-bar expansion must prove parity before replacing mounted audit-family pages again.
## Test coverage

View File

@@ -2,6 +2,10 @@
Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
## Post-remediation note
Sprint `SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` rolled back both mounted adopters described below. The shared `FindingListComponent` is no longer mounted on `FindingsContainerComponent` or the `ReleaseDetailComponent` security tab because those hosts required fabricated timestamps, severity-derived risk scores, and invented package identities to satisfy the shared contract.
## Summary
Revived the dormant shared `FindingListComponent` and `FindingRowComponent` by adopting them on two mounted surfaces that previously used bespoke finding list rendering:

View File

@@ -0,0 +1,34 @@
# Orphan Revival Regression Remediation
Sprint: `SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md`
Status: Shipped
## What changed
This remediation sprint closed the concrete regressions introduced by the orphan-revival batch and kept mounted behavior truthful where the revived shared components did not fit the live hosts.
- Revived shared policy widgets build again: broken `policy-interop.models` imports were corrected in the shared policy components, and the glossary tooltip directive was fixed for strict-null and typing compliance.
- Evidence-thread navigation now stays inside the canonical Evidence shell. Row click and back navigation both use `/evidence/threads` instead of dead `/evidence-thread` paths.
- Audit and trust filtering capabilities were restored on mounted pages. `audit-log-table` and `trust-audit-log` were intentionally rolled back to page-local controls so actor filters, multi-select filters, and start/end date filters remain usable.
- Fabricated finding evidence was removed from mounted consumers. `FindingsContainerComponent` and the `ReleaseDetailComponent` security tab no longer synthesize `last_seen`, `risk_score`, or fake package identities just to satisfy the shared `FindingListComponent` contract.
## Bounded rollback decisions
- `FilterBarComponent` remains valid, but not for every list page. Audit-log and trust-audit hosts keep their bespoke filter controls until the shared bar can support their real semantics without loss.
- `FindingListComponent` remains available for future truthful adopters, but it is no longer mounted on findings and release-security hosts that would require invented data.
## Verification
- `npm run build`
- Result: passed
- Notes: only the existing Angular bundle-budget warnings remained
- `npm test -- --watch=false --include src/tests/orphan_revival/orphan-revival-regression-remediation.spec.ts --include src/tests/evidence/evidence-thread-browser.component.spec.ts --include src/tests/releases/release-detail.live-refresh.spec.ts --include src/app/routes/evidence.routes.spec.ts`
- Result: passed
- Coverage: policy host rendering, evidence-thread canonical routes, audit/trust filter semantics, truthful findings rendering, release-security rendering, and evidence route declarations
## Current shipped state
- Canonical evidence-thread routes are usable and internally consistent.
- Shared policy widgets stay mounted on the active policy surfaces without breaking the app build.
- Audit and trust pages retain their full filtering workflows.
- Findings and release-security views favor truthful bespoke rendering over forced shared-component reuse.

View File

@@ -1,9 +1,9 @@
# UI Task Board
## Active Sprint Links
- None currently. Completed UI delivery and verification sprints are archived under `docs-archived/implplan/`.
- [DONE] `docs/implplan/SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption.md` - CopyToClipboard, InlineCode, TruncatePipe adoption on console-admin, offline-kit, and triage replay-command surfaces.
- [DONE] `docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md` - FilterBarComponent adoption on 7 mounted list pages (audit-log-table, secret-findings-list, console-admin audit-log, release-list, evidence-pack-list, trust-audit-log, certificate-inventory).
- [DONE] `docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md` - Initial FilterBarComponent adoption batch; audit-log-table and trust-audit-log were later rolled back in sprint `024` to restore lost semantics.
- [DONE] `docs-archived/implplan/SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` - Fixed reviewed orphan-revival regressions: build blockers cleared, canonical evidence-thread navigation restored, audit/trust filter capabilities restored, and fabricated finding evidence removed from mounted hosts.
## Queued Sprint Links
- `docs/modules/ui/orphan-revival-batch/README.md` - review index for the orphan shared-component and disconnected-route revival batch.

View File

@@ -6,13 +6,16 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
## Active work
- Track current sprints under `docs/implplan/SPRINT_*.md` for this module.
- Update this file when new scoped work is approved.
- No active UI remediation sprint is open right now.
## Near-term deliverables
- No active UI deliverables are currently staged in `docs/implplan`.
- The next queued batch is `docs/modules/ui/orphan-revival-batch/README.md`, which stages independent review-ready sprints for orphan shared-component adoption and disconnected-route integration.
- The queued orphan batch currently spans `SPRINT_20260308_013` through `SPRINT_20260308_023` and is intentionally not marked active until product review approves staffing.
- Sprint `014` (CopyToClipboard, InlineCode, TruncatePipe adoption) is DONE. See `docs/features/checked/web/orphan-copy-inline-truncate-adoption.md`.
- Sprint `015` (FilterBarComponent adoption on 7 mounted list pages) is DONE. See `docs/features/checked/web/filter-bar-unification.md`.
- Sprint `015` (FilterBarComponent adoption) shipped, then was partially rolled back on audit-family pages to restore lost filter semantics. See `docs/features/checked/web/filter-bar-unification.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
- Sprint `020` (FindingListComponent consolidation) shipped, then was rolled back on mounted findings and release-security hosts because the shared contract required fabricated data. See `docs/features/checked/web/orphan-finding-list-consolidation.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
- Sprint `021` (Evidence thread and persona workspace reconnection) shipped, and its internal thread navigation was completed in the remediation sprint. See `docs/features/checked/web/evidence-thread-persona-workspaces-routes.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
## Latest evidence
- `docs/modules/ui/component-preservation-map/README.md` - root index for the first-pass preservation map.
@@ -54,7 +57,8 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/modules/ui/contextual-actions-patterns/README.md` - shared placement contract for stray actions, pages, drawers, and tabs.
- `docs/modules/ui/unified-audit-surfaces/README.md` - shipped canonical audit owner, alias contract, and secondary entry-point rules for cross-module audit browsing.
- `docs/modules/ui/orphan-revival-batch/README.md` - queued execution batch for reviving selected orphan shared components and reconnecting disconnected route families without reopening duplicate top-level products.
- `docs/features/checked/web/filter-bar-unification.md` - shipped verification note for the shared FilterBarComponent adoption on 7 mounted list pages (audit, security, release, evidence, trust families).
- `docs/features/checked/web/filter-bar-unification.md` - shipped verification note for the shared FilterBarComponent adoption, now superseded on audit-family pages by the remediation rollback that restored missing filter semantics.
- `docs/features/checked/web/orphan-revival-regression-remediation-ui.md` - shipped verification note for the orphan-revival regression remediation, including the audit/trust filter rollback, truthful findings/release rollback, policy build fixes, and canonical evidence-thread navigation repair.
## Dependencies
- `docs/modules/ui/architecture.md`

View File

@@ -9,6 +9,7 @@
- The preservation-map and restoration-topic work already resolved the major product-shape questions for Policy Decisioning Studio, Reachability Witnessing, Workflow Visualization, Watchlist, Triage Explainability, and the consolidated Operations and Setup shells.
- This batch covers the remaining lower-level orphaned shared components and disconnected route files that still look worth reviving after those larger product merges landed.
- These sprints are intentionally queued for review. They are not started by default.
- Follow-up remediation sprint `024` already proved that some orphan adoptions need bounded rollback when the shared contract removes mounted behavior or forces fabricated data. Treat sprint `015` and sprint `020` as cautionary examples, not as proof that every orphan shared component should stay mounted wherever it first lands.
## Corrections To The External Scan
- `EvidenceDrawerComponent` is already mounted in `features/vulnerabilities/vulnerability-detail.component.html`; it is not a valid "finish the wiring" target in the current repo snapshot.

View File

@@ -1,7 +1,6 @@
/**
* @file audit-log-table.component.spec.ts
* @sprint SPRINT_20260308_015_FE (FE-OFB-004)
* @description Focused tests verifying FilterBarComponent adoption on the audit-log-table page.
* @description Unit tests for AuditLogTableComponent filter behavior.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -11,7 +10,7 @@ import { AuditLogTableComponent } from './audit-log-table.component';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditEventsPagedResponse } from '../../core/api/audit-log.models';
describe('AuditLogTableComponent (filter-bar adoption)', () => {
describe('AuditLogTableComponent', () => {
let component: AuditLogTableComponent;
let fixture: ComponentFixture<AuditLogTableComponent>;
@@ -44,72 +43,79 @@ describe('AuditLogTableComponent (filter-bar adoption)', () => {
expect(component).toBeTruthy();
});
it('should render the shared filter bar', () => {
const filterBar = fixture.nativeElement.querySelector('app-filter-bar');
expect(filterBar).toBeTruthy();
it('renders the audit-specific filter toolbar with actor search', () => {
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('.filters-bar')).toBeTruthy();
expect(element.textContent).toContain('Actor');
expect(element.querySelector('app-filter-bar')).toBeFalsy();
});
it('should expose four filter option groups (module, action, severity, dateRange)', () => {
expect(component.filterOptions.length).toBe(4);
expect(component.filterOptions.map(f => f.key)).toEqual(['module', 'action', 'severity', 'dateRange']);
it('builds filters with multi-select arrays and actor name', () => {
component.selectedModules = ['policy', 'scanner'];
component.selectedActions = ['approve', 'replay'];
component.selectedSeverities = ['warning', 'critical'];
component.actorFilter = 'ops@stella.local';
expect(component.buildFilters()).toEqual(
jasmine.objectContaining({
modules: ['policy', 'scanner'],
actions: ['approve', 'replay'],
severities: ['warning', 'critical'],
actorName: 'ops@stella.local',
}),
);
});
it('should populate module options from allModules array', () => {
const moduleFilter = component.filterOptions.find(f => f.key === 'module');
expect(moduleFilter).toBeTruthy();
expect(moduleFilter!.options.length).toBe(component.allModules.length);
expect(moduleFilter!.options[0].value).toBe('authority');
it('builds custom start and end dates when custom range is selected', () => {
component.dateRange = 'custom';
component.customStartDate = '2026-03-01';
component.customEndDate = '2026-03-07';
expect(component.buildFilters()).toEqual(
jasmine.objectContaining({
startDate: '2026-03-01',
endDate: '2026-03-07',
}),
);
});
it('should update selectedModules on onFilterChanged with module key', () => {
component.onFilterChanged({ key: 'module', value: 'policy', label: 'Policy' });
expect(component.selectedModules).toEqual(['policy']);
it('resets paging state when applyFilters is called', () => {
component.selectedModules = ['scanner'];
component.cursor.set('cursor-1');
(component as { cursorStack: string[] }).cursorStack = ['cursor-0'];
component.hasPrev.set(true);
component.applyFilters();
expect(component.cursor()).toBeNull();
expect(component.hasPrev()).toBeFalse();
expect(mockClient.getEvents).toHaveBeenCalled();
});
it('should clear selectedModules on onFilterRemoved with module key', () => {
component.selectedModules = ['policy'];
component.onFilterRemoved({ key: 'module', value: 'policy', label: 'Module: Policy' });
expect(component.selectedModules).toEqual([]);
});
it('clears all restored filters', () => {
component.selectedModules = ['scanner'];
component.selectedActions = ['replay'];
component.selectedSeverities = ['error'];
component.dateRange = 'custom';
component.customStartDate = '2026-03-01';
component.customEndDate = '2026-03-07';
component.searchQuery = 'digest';
component.actorFilter = 'operator';
it('should update dateRange on onFilterChanged with dateRange key', () => {
component.onFilterChanged({ key: 'dateRange', value: '30d', label: 'Last 30 days' });
expect(component.dateRange).toBe('30d');
});
it('should reset dateRange to 7d on onFilterRemoved with dateRange key', () => {
component.dateRange = '30d';
component.onFilterRemoved({ key: 'dateRange', value: '30d', label: 'Date: Last 30 days' });
expect(component.dateRange).toBe('7d');
});
it('should update searchQuery on onSearchChange', () => {
component.onSearchChange('test keyword');
expect(component.searchQuery).toBe('test keyword');
});
it('should rebuild active filter list when a filter is applied', () => {
component.onFilterChanged({ key: 'severity', value: 'critical', label: 'Critical' });
const active = component.activeFilterList();
expect(active.length).toBeGreaterThanOrEqual(1);
expect(active.find(f => f.key === 'severity')).toBeTruthy();
});
it('should clear all filters and reset active filter list', () => {
component.onFilterChanged({ key: 'module', value: 'scanner', label: 'Scanner' });
component.onFilterChanged({ key: 'severity', value: 'error', label: 'Error' });
component.clearFilters();
expect(component.selectedModules.length).toBe(0);
expect(component.selectedActions.length).toBe(0);
expect(component.selectedSeverities.length).toBe(0);
expect(component.dateRange).toBe('7d');
expect(component.searchQuery).toBe('');
expect(component.activeFilterList().length).toBe(0);
expect(component.actorFilter).toBe('');
expect(component.customStartDate).toBe('');
expect(component.customEndDate).toBe('');
});
it('should not include dateRange chip when dateRange is 7d (default)', () => {
component.onFilterChanged({ key: 'module', value: 'vex', label: 'VEX' });
const active = component.activeFilterList();
expect(active.find(f => f.key === 'dateRange')).toBeFalsy();
it('formats modules for the multi-select options', () => {
expect(component.formatModule('authority')).toBe('Authority');
expect(component.formatModule('jobengine')).toBe('JobEngine');
});
});

View File

@@ -1,16 +1,14 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002)
import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } from '../../core/api/audit-log.models';
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
@Component({
selector: 'app-audit-log-table',
imports: [CommonModule, RouterModule, FormsModule, FilterBarComponent],
imports: [CommonModule, RouterModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-table-page">
@@ -21,15 +19,66 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
<h1>Audit Events</h1>
</header>
<app-filter-bar
searchPlaceholder="Search events by keyword or actor..."
[filters]="filterOptions"
[activeFilters]="activeFilterList()"
(searchChange)="onSearchChange($event)"
(filterChange)="onFilterChanged($event)"
(filterRemove)="onFilterRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
<div class="filters-bar">
<div class="filter-row">
<div class="filter-group">
<label>Modules</label>
<select multiple [(ngModel)]="selectedModules" (change)="applyFilters()">
@for (m of allModules; track m) {
<option [value]="m">{{ formatModule(m) }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Actions</label>
<select multiple [(ngModel)]="selectedActions" (change)="applyFilters()">
@for (a of allActions; track a) {
<option [value]="a">{{ a }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Severity</label>
<select multiple [(ngModel)]="selectedSeverities" (change)="applyFilters()">
@for (s of allSeverities; track s) {
<option [value]="s">{{ s }}</option>
}
</select>
</div>
<div class="filter-group">
<label>Date Range</label>
<select [(ngModel)]="dateRange" (change)="applyFilters()">
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="custom">Custom</option>
</select>
</div>
@if (dateRange === 'custom') {
<div class="filter-group">
<label>Start</label>
<input type="date" [(ngModel)]="customStartDate" (change)="applyFilters()" />
</div>
<div class="filter-group">
<label>End</label>
<input type="date" [(ngModel)]="customEndDate" (change)="applyFilters()" />
</div>
}
</div>
<div class="filter-row">
<div class="filter-group search-group">
<label>Search</label>
<input type="text" [(ngModel)]="searchQuery" placeholder="Search events..." (keyup.enter)="applyFilters()" />
<button class="btn-sm" (click)="applyFilters()">Search</button>
</div>
<div class="filter-group">
<label>Actor</label>
<input type="text" [(ngModel)]="actorFilter" placeholder="Username or email" (keyup.enter)="applyFilters()" />
</div>
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
</div>
</div>
@if (loading()) {
<div class="loading">Loading events...</div>
@@ -194,7 +243,18 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.page-header h1 { margin: 0; }
/* Filter bar styles handled by shared FilterBarComponent */
.filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; }
.filter-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
.filter-row:last-child { margin-bottom: 0; }
.filter-group { display: flex; flex-direction: column; gap: 0.25rem; }
.filter-group label { font-size: 0.75rem; color: var(--color-text-secondary); }
.filter-group select, .filter-group input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.9rem; }
.filter-group select[multiple] { height: 80px; }
.search-group { flex: 1; min-width: 200px; flex-direction: row; align-items: flex-end; }
.search-group label { display: none; }
.search-group input { flex: 1; }
.btn-sm { padding: 0.5rem 0.75rem; cursor: pointer; background: var(--color-brand-primary); color: var(--color-text-heading); border: none; border-radius: var(--radius-sm); }
.btn-secondary { padding: 0.5rem 1rem; cursor: pointer; background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); align-self: flex-end; }
.loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.events-table th, .events-table td { padding: 0.75rem 0.5rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.85rem; }
@@ -286,37 +346,6 @@ export class AuditLogTableComponent implements OnInit {
readonly allActions: AuditAction[] = ['create', 'update', 'delete', 'promote', 'demote', 'revoke', 'issue', 'refresh', 'test', 'fail', 'complete', 'start', 'submit', 'approve', 'reject', 'sign', 'verify', 'rotate', 'enable', 'disable', 'deadletter', 'replay'];
readonly allSeverities: AuditSeverity[] = ['info', 'warning', 'error', 'critical'];
// Shared filter bar options
readonly filterOptions: FilterOption[] = [
{
key: 'module',
label: 'Module',
options: this.allModules.map(m => ({ value: m, label: this.formatModule(m) })),
},
{
key: 'action',
label: 'Action',
options: this.allActions.map(a => ({ value: a, label: a })),
},
{
key: 'severity',
label: 'Severity',
options: this.allSeverities.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) })),
},
{
key: 'dateRange',
label: 'Date Range',
options: [
{ value: '24h', label: 'Last 24 hours' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
],
},
];
readonly activeFilterList = signal<ActiveFilter[]>([]);
ngOnInit(): void {
this.loadEvents();
}
@@ -365,7 +394,6 @@ export class AuditLogTableComponent implements OnInit {
this.cursor.set(null);
this.cursorStack = [];
this.hasPrev.set(false);
this.rebuildActiveFilters();
this.loadEvents();
}
@@ -381,65 +409,6 @@ export class AuditLogTableComponent implements OnInit {
this.applyFilters();
}
onSearchChange(value: string): void {
this.searchQuery = value;
this.applyFilters();
}
onFilterChanged(filter: ActiveFilter): void {
switch (filter.key) {
case 'module':
this.selectedModules = [filter.value as AuditModule];
break;
case 'action':
this.selectedActions = [filter.value as AuditAction];
break;
case 'severity':
this.selectedSeverities = [filter.value as AuditSeverity];
break;
case 'dateRange':
this.dateRange = filter.value;
break;
}
this.applyFilters();
}
onFilterRemoved(filter: ActiveFilter): void {
switch (filter.key) {
case 'module':
this.selectedModules = [];
break;
case 'action':
this.selectedActions = [];
break;
case 'severity':
this.selectedSeverities = [];
break;
case 'dateRange':
this.dateRange = '7d';
break;
}
this.applyFilters();
}
private rebuildActiveFilters(): void {
const filters: ActiveFilter[] = [];
if (this.selectedModules.length > 0) {
filters.push({ key: 'module', value: this.selectedModules[0], label: 'Module: ' + this.formatModule(this.selectedModules[0]) });
}
if (this.selectedActions.length > 0) {
filters.push({ key: 'action', value: this.selectedActions[0], label: 'Action: ' + this.selectedActions[0] });
}
if (this.selectedSeverities.length > 0) {
filters.push({ key: 'severity', value: this.selectedSeverities[0], label: 'Severity: ' + this.selectedSeverities[0] });
}
if (this.dateRange !== '7d') {
const labels: Record<string, string> = { '24h': 'Last 24 hours', '30d': 'Last 30 days', '90d': 'Last 90 days' };
filters.push({ key: 'dateRange', value: this.dateRange, label: 'Date: ' + (labels[this.dateRange] || this.dateRange) });
}
this.activeFilterList.set(filters);
}
nextPage(): void {
if (this.cursor()) {
this.cursorStack.push(this.cursor()!);

View File

@@ -156,7 +156,7 @@ export class EvidenceThreadListComponent implements OnInit {
onRowClick(thread: EvidenceThread): void {
const encodedDigest = encodeURIComponent(thread.artifactDigest);
this.router.navigate(['/evidence-thread', encodedDigest]);
this.router.navigate(['/evidence/threads', encodedDigest]);
}
onRefresh(): void {

View File

@@ -158,7 +158,7 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy {
}
onBack(): void {
this.router.navigate(['/evidence-thread']);
this.router.navigate(['/evidence/threads']);
}
getVerdictLabel(verdict?: EvidenceVerdict): string {

View File

@@ -1,7 +1,7 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
// Task: FE-OFL-004 - Verify shared FindingListComponent adoption on FindingsContainerComponent
// Sprint: SPRINT_20260308_024_FE_orphan_revival_regression_remediation
// Task: FE-ORM-004 - Verify truthful findings-container rollback on mounted surface
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -15,7 +15,7 @@ import { ViewPreferenceService, FindingsViewMode } from '../../../core/services/
import { CompareService } from '../../compare/services/compare.service';
import { SECURITY_FINDINGS_API } from '../../../core/api/security-findings.client';
describe('FindingsContainerComponent (shared FindingList adoption)', () => {
describe('FindingsContainerComponent (truthful mounted findings view)', () => {
let component: FindingsContainerComponent;
let fixture: ComponentFixture<FindingsContainerComponent>;
let queryParamMap$: BehaviorSubject<any>;
@@ -110,99 +110,30 @@ describe('FindingsContainerComponent (shared FindingList adoption)', () => {
expect(component).toBeTruthy();
});
it('should compute findingEvidenceItems from raw findings', async () => {
it('loads live findings into the mounted findings list contract', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
expect(items.length).toBe(3);
expect(component.findings().length).toBe(3);
expect(component.findings()[0]).toEqual(
jasmine.objectContaining({
id: 'finding-1',
advisoryId: 'CVE-2026-8001',
packageName: 'backend-api',
packageVersion: '2.5.0',
severity: 'critical',
status: 'open',
publishedAt: '2026-02-10T09:30:00Z',
}),
);
});
it('should map finding_id from Finding.id', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
expect(items[0].finding_id).toBe('finding-1');
expect(items[1].finding_id).toBe('finding-2');
expect(items[2].finding_id).toBe('finding-3');
});
it('should map cve from Finding.advisoryId', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
expect(items[0].cve).toBe('CVE-2026-8001');
expect(items[1].cve).toBe('CVE-2026-8002');
});
it('should map component name and version', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
expect(items[0].component?.name).toBe('backend-api');
expect(items[0].component?.version).toBe('2.5.0');
expect(items[1].component?.name).toBe('frontend-lib');
expect(items[1].component?.version).toBe('1.0.3');
});
it('should map severity to risk_score', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
// critical -> 90
expect(items[0].score_explain?.risk_score).toBe(90);
// high -> 70
expect(items[1].score_explain?.risk_score).toBe(70);
// low -> 20
expect(items[2].score_explain?.risk_score).toBe(20);
});
it('should map fixed status to VEX fixed', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
// finding-2 has vexStatus 'fixed', delta 'resolved' -> status = 'fixed'
expect(items[1].vex?.status).toBe('fixed');
});
it('should map excepted status to VEX not_affected', async () => {
await fixture.whenStable();
fixture.detectChanges();
const items = component.findingEvidenceItems();
// finding-3 has vexStatus 'not_affected' -> status = 'excepted'
expect(items[2].vex?.status).toBe('not_affected');
});
it('should render shared finding list host element in detail view', async () => {
it('renders the truthful bespoke findings list instead of the shared evidence row list', async () => {
await fixture.whenStable();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
const host = el.querySelector('[data-testid="shared-finding-list-host"]');
expect(host).toBeTruthy();
});
it('should render stella-finding-list element in detail view', async () => {
await fixture.whenStable();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
const list = el.querySelector('stella-finding-list');
expect(list).toBeTruthy();
});
it('should not render bespoke app-findings-list element', async () => {
await fixture.whenStable();
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
const bespoke = el.querySelector('app-findings-list');
expect(bespoke).toBeFalsy();
expect(el.querySelector('app-findings-list')).toBeTruthy();
expect(el.querySelector('stella-finding-list')).toBeFalsy();
});
});

View File

@@ -2,8 +2,6 @@
// findings-container.component.ts
// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default
// Task: T3 — Container component for findings with view switching
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
// Task: FE-OFL-002 — Adopt shared FindingListComponent on canonical findings surface
// -----------------------------------------------------------------------------
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
@@ -20,9 +18,7 @@ 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 { FindingListComponent } from '../../../shared/components/finding-list.component';
import type { FindingEvidenceResponse } from '../../../core/api/triage-evidence.models';
import { type Finding } from '../findings-list.component';
import { FindingsListComponent, type Finding } from '../findings-list.component';
import { CompareService } from '../../compare/services/compare.service';
import {
SECURITY_FINDINGS_API,
@@ -102,7 +98,7 @@ function mapFinding(source: DetailFindingSource): Finding {
MatProgressSpinnerModule,
FindingsViewToggleComponent,
CompareViewComponent,
FindingListComponent,
FindingsListComponent
],
template: `
<div class="findings-container">
@@ -153,11 +149,9 @@ function mapFinding(source: DetailFindingSource): Finding {
<p>{{ message }}</p>
</section>
} @else {
<stella-finding-list
[findings]="findingEvidenceItems()"
[loading]="loading()"
data-testid="shared-finding-list-host"
(findingSelected)="onFindingSelected($event)" />
<app-findings-list
[findings]="findings()"
(findingSelect)="onFindingSelect($event)" />
}
}
}
@@ -263,15 +257,9 @@ export class FindingsContainerComponent implements OnInit {
// Loading state
readonly loading = signal(false);
// Findings for detail view (raw Finding model)
// Findings for detail view
readonly findings = signal<Finding[]>([]);
// Adapter: map Finding[] to FindingEvidenceResponse[] for the shared FindingListComponent
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-002)
readonly findingEvidenceItems = computed<FindingEvidenceResponse[]>(() => {
return this.findings().map((f) => this.mapFindingToEvidence(f));
});
// Detail view data failure state
readonly detailError = signal<string | null>(null);
@@ -352,52 +340,6 @@ export class FindingsContainerComponent implements OnInit {
console.log('Selected finding:', finding.id);
}
/** Handler for the shared FindingListComponent's findingSelected output (emits finding_id string). */
onFindingSelected(findingId: string): void {
const finding = this.findings().find((f) => f.id === findingId);
if (finding) {
this.onFindingSelect(finding);
}
}
/**
* Adapter: map the bespoke Finding interface to FindingEvidenceResponse
* so the shared FindingListComponent can render it.
* Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-002)
*/
private mapFindingToEvidence(finding: Finding): FindingEvidenceResponse {
const severityScore: Record<string, number> = {
critical: 90,
high: 70,
medium: 45,
low: 20,
unknown: 0,
};
return {
finding_id: finding.id,
cve: finding.advisoryId,
component: {
purl: `pkg:generic/${finding.packageName}@${finding.packageVersion}`,
name: finding.packageName,
version: finding.packageVersion,
type: 'generic',
},
score_explain: {
kind: 'ews',
risk_score: severityScore[finding.severity] ?? 0,
last_seen: finding.publishedAt ?? new Date().toISOString(),
summary: `Severity: ${finding.severity}, Status: ${finding.status}`,
},
vex: finding.status === 'fixed'
? { status: 'fixed' }
: finding.status === 'excepted'
? { status: 'not_affected', justification: 'Excepted' }
: undefined,
last_seen: finding.publishedAt ?? new Date().toISOString(),
};
}
private readViewMode(raw: string | null): FindingsViewMode | null {
return raw === 'diff' || raw === 'detail' ? raw : null;
}

View File

@@ -1,19 +1,19 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation
// Task: FE-OFL-004 - Verify shared FindingListComponent adoption on ReleaseDetailComponent
// Sprint: SPRINT_20260308_024_FE_orphan_revival_regression_remediation
// Task: FE-ORM-004 - Verify truthful release security rendering on mounted surface
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of, BehaviorSubject, EMPTY } from 'rxjs';
import { BehaviorSubject, EMPTY } from 'rxjs';
import { signal } from '@angular/core';
import { ReleaseDetailComponent } from './release-detail.component';
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
import { ReleaseManagementStore } from '../release.store';
describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
describe('ReleaseDetailComponent (truthful release security rendering)', () => {
let component: ReleaseDetailComponent;
let fixture: ComponentFixture<ReleaseDetailComponent>;
@@ -36,14 +36,14 @@ describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
const queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
const mockContext = {
environment: signal('production'),
region: signal('us-east-1'),
tenant: signal('demo-prod'),
selectedRegions: signal<string[]>([]),
selectedEnvironments: signal<string[]>([]),
};
const mockStore = {
selectedRelease: signal(mockRelease),
releases: signal([mockRelease]),
selectRelease: jasmine.createSpy('selectRelease'),
loadRelease: () => EMPTY,
};
@@ -69,6 +69,8 @@ describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
fixture = TestBed.createComponent(ReleaseDetailComponent);
component = fixture.componentInstance;
component.mode.set('version');
component.activeTab.set('security-inputs');
fixture.detectChanges();
});
@@ -76,46 +78,8 @@ describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
expect(component).toBeTruthy();
});
it('should compute securityFindingEvidenceItems from empty findings', () => {
const items = component.securityFindingEvidenceItems();
expect(items).toEqual([]);
});
it('should compute securityFindingEvidenceItems from security findings', () => {
// Set findings directly via the signal
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
{
findingId: 'f-002',
cveId: 'CVE-2026-9002',
severity: 'high',
componentName: 'auth-lib',
releaseId: 'rel-001',
reachable: false,
reachabilityScore: 0,
effectiveDisposition: 'review_required',
vexStatus: 'not_affected',
exceptionStatus: 'approved',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items.length).toBe(2);
});
it('should map finding_id from SecurityFindingProjection.findingId', () => {
(component as any).findings.set([
it('renders the mounted release security table with truthful fields', () => {
component.findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
@@ -130,148 +94,21 @@ describe('ReleaseDetailComponent (shared FindingList adoption)', () => {
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].finding_id).toBe('f-001');
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('stella-finding-list')).toBeFalsy();
expect(element.textContent).toContain('CVE-2026-9001');
expect(element.textContent).toContain('libcrypto');
expect(element.textContent).toContain('action_required');
expect(element.textContent).toContain('yes (85)');
});
it('should map cve from SecurityFindingProjection.cveId', () => {
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
it('shows the legacy empty-state row when the release has no findings', () => {
component.findings.set([]);
fixture.detectChanges();
const items = component.securityFindingEvidenceItems();
expect(items[0].cve).toBe('CVE-2026-9001');
});
it('should map component name from SecurityFindingProjection.componentName', () => {
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].component?.name).toBe('libcrypto');
});
it('should map severity to risk_score (critical=90)', () => {
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].score_explain?.risk_score).toBe(90);
});
it('should map reachable=true to non-empty reachable_path', () => {
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].reachable_path).toBeDefined();
expect(items[0].reachable_path!.length).toBeGreaterThan(0);
});
it('should map reachable=false to undefined reachable_path', () => {
(component as any).findings.set([
{
findingId: 'f-002',
cveId: 'CVE-2026-9002',
severity: 'high',
componentName: 'auth-lib',
releaseId: 'rel-001',
reachable: false,
reachabilityScore: 0,
effectiveDisposition: 'review_required',
vexStatus: 'not_affected',
exceptionStatus: 'approved',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].reachable_path).toBeUndefined();
});
it('should map vexStatus not_affected to VEX evidence', () => {
(component as any).findings.set([
{
findingId: 'f-002',
cveId: 'CVE-2026-9002',
severity: 'high',
componentName: 'auth-lib',
releaseId: 'rel-001',
reachable: false,
reachabilityScore: 0,
effectiveDisposition: 'review_required',
vexStatus: 'not_affected',
exceptionStatus: 'approved',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].vex?.status).toBe('not_affected');
});
it('should not produce VEX evidence for under_investigation status', () => {
(component as any).findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
const items = component.securityFindingEvidenceItems();
expect(items[0].vex).toBeUndefined();
const element: HTMLElement = fixture.nativeElement;
expect(element.textContent).toContain('No findings.');
});
});

View File

@@ -10,8 +10,6 @@ import { ReleaseManagementStore } from '../release.store';
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
import type { ManagedRelease } from '../../../../core/api/release-management.models';
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
import { FindingListComponent } from '../../../../shared/components/finding-list.component';
import type { FindingEvidenceResponse } from '../../../../core/api/triage-evidence.models';
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
import { AuditorOnlyDirective } from '../../../../shared/directives/auditor-only.directive';
import { OperatorOnlyDirective } from '../../../../shared/directives/operator-only.directive';
@@ -140,7 +138,7 @@ interface ReloadOptions {
@Component({
selector: 'app-release-detail',
standalone: true,
imports: [RouterLink, FormsModule, DegradedStateBannerComponent, FindingListComponent, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent],
imports: [RouterLink, FormsModule, DegradedStateBannerComponent, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workbench">
@@ -269,13 +267,17 @@ interface ReloadOptions {
@case ('security-inputs') {
<article>
<h3>Release-Scoped Security</h3>
<stella-finding-list
[findings]="securityFindingEvidenceItems()"
[showApprove]="false"
[emptyMessage]="'No findings for this release.'"
data-testid="release-security-finding-list-host"
(findingSelected)="onSecurityFindingSelected($event)"
/>
<table>
<thead><tr><th>CVE</th><th>Component</th><th>Severity</th><th>Reachable</th><th>VEX</th><th>Exception</th><th>Blocks Promotion</th></tr></thead>
<tbody>
@for (item of findings(); track item.findingId) {
<tr>
<td>{{ item.cveId }}</td><td>{{ item.componentName }}</td><td>{{ item.severity }}</td><td>{{ item.reachable ? 'yes' : 'no' }} ({{ item.reachabilityScore }})</td>
<td>{{ item.vexStatus }}</td><td>{{ item.exceptionStatus }}</td><td>{{ item.effectiveDisposition === 'action_required' ? 'yes' : 'no' }}</td>
</tr>
} @empty { <tr><td colspan="7">No findings.</td></tr> }
</tbody>
</table>
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
</article>
}
@@ -420,12 +422,6 @@ export class ReleaseDetailComponent {
readonly diffRows = signal<SecuritySbomDiffRow[]>([]);
readonly diffMode = signal<'sbom'|'findings'|'policy'|'topology'>('sbom');
// Adapter: map SecurityFindingProjection[] to FindingEvidenceResponse[] for the shared FindingListComponent
// Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-003)
readonly securityFindingEvidenceItems = computed<FindingEvidenceResponse[]>(() => {
return this.findings().map((f) => this.mapSecurityFindingToEvidence(f));
});
readonly selectedTimelineId = signal<string | null>(null);
readonly selectedTargets = signal<Set<string>>(new Set<string>());
@@ -1278,52 +1274,5 @@ export class ReleaseDetailComponent {
return false;
}
/** Handler for the shared FindingListComponent's findingSelected output. */
onSecurityFindingSelected(findingId: string): void {
const finding = this.findings().find((f) => f.findingId === findingId);
if (finding) {
void this.router.navigate(['/security/triage'], {
queryParams: { releaseId: this.releaseContextId(), findingId: finding.findingId, cve: finding.cveId },
});
}
}
/**
* Adapter: map SecurityFindingProjection to FindingEvidenceResponse
* for the shared FindingListComponent.
* Sprint: SPRINT_20260308_020_FE_orphan_finding_list_consolidation (FE-OFL-003)
*/
private mapSecurityFindingToEvidence(f: SecurityFindingProjection): FindingEvidenceResponse {
const severityScore: Record<string, number> = {
critical: 90,
high: 70,
medium: 45,
low: 20,
};
return {
finding_id: f.findingId,
cve: f.cveId,
component: {
purl: `pkg:generic/${f.componentName}`,
name: f.componentName,
version: '',
type: 'generic',
},
reachable_path: f.reachable ? ['reachable'] : undefined,
score_explain: {
kind: 'ews',
risk_score: severityScore[f.severity.toLowerCase()] ?? 0,
last_seen: new Date().toISOString(),
summary: `Severity: ${f.severity}, Reachability: ${f.reachabilityScore}, Disposition: ${f.effectiveDisposition}`,
},
vex: f.vexStatus && f.vexStatus !== 'under_investigation'
? { status: f.vexStatus as 'not_affected' | 'affected' | 'fixed' | 'under_investigation' }
: undefined,
last_seen: new Date().toISOString(),
};
}
}

View File

@@ -2,7 +2,6 @@
* @file trust-audit-log.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Trust audit log viewer
* Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003)
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
@@ -17,16 +16,76 @@ import {
ListAuditEventsParams,
TrustAuditFilter,
} from '../../core/api/trust.models';
import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component';
@Component({
selector: 'app-trust-audit-log',
imports: [CommonModule, FormsModule, FilterBarComponent],
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="audit-log">
<!-- Shared Filter Bar -->
<div class="audit-log__actions">
<!-- Filters -->
<div class="audit-log__filters">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search events..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); onSearch()"
/>
</div>
<div class="filter-group">
<label for="resourceType">Resource Type</label>
<select
id="resourceType"
[ngModel]="selectedResourceType()"
(ngModelChange)="selectedResourceType.set($event); onFilterChange()"
>
<option value="all">All Resources</option>
<option value="key">Keys</option>
<option value="issuer">Issuers</option>
<option value="certificate">Certificates</option>
<option value="config">Config</option>
</select>
</div>
<div class="filter-group">
<label for="severity">Severity</label>
<select
id="severity"
[ngModel]="selectedSeverity()"
(ngModelChange)="selectedSeverity.set($event); onFilterChange()"
>
<option value="all">All Severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="filter-group">
<label for="startDate">Start Date</label>
<input
id="startDate"
type="date"
[ngModel]="startDate()"
(ngModelChange)="startDate.set($event); onFilterChange()"
/>
</div>
<div class="filter-group">
<label for="endDate">End Date</label>
<input
id="endDate"
type="date"
[ngModel]="endDate()"
(ngModelChange)="endDate.set($event); onFilterChange()"
/>
</div>
<button
type="button"
class="btn-export"
@@ -35,17 +94,13 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
>
{{ exporting() ? 'Exporting...' : 'Export' }}
</button>
</div>
<app-filter-bar
searchPlaceholder="Search events..."
[filters]="trustFilterOptions"
[activeFilters]="activeTrustFilters()"
(searchChange)="onTrustSearch($event)"
(filterChange)="onTrustFilterChanged($event)"
(filterRemove)="onTrustFilterRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
}
</div>
<!-- Events List -->
<div class="audit-log__content">
@@ -174,12 +229,39 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
padding: 1.5rem;
}
/* Filter bar styles handled by shared FilterBarComponent */
.audit-log__actions {
.audit-log__filters {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.filter-group input,
.filter-group select {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: var(--color-text-primary);
padding: 0.5rem 0.75rem;
min-width: 140px;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: var(--color-status-info);
}
.btn-export {
@@ -476,14 +558,6 @@ export class TrustAuditLogComponent implements OnInit {
this.endDate() !== ''
);
// Shared filter bar integration
readonly trustFilterOptions: FilterOption[] = [
{ key: 'resourceType', label: 'Resource Type', options: [{ value: 'key', label: 'Keys' }, { value: 'issuer', label: 'Issuers' }, { value: 'certificate', label: 'Certificates' }, { value: 'config', label: 'Config' }] },
{ key: 'severity', label: 'Severity', options: [{ value: 'info', label: 'Info' }, { value: 'warning', label: 'Warning' }, { value: 'error', label: 'Error' }, { value: 'critical', label: 'Critical' }] },
];
readonly activeTrustFilters = signal<ActiveFilter[]>([]);
ngOnInit(): void {
this.loadEvents();
}
@@ -526,52 +600,14 @@ export class TrustAuditLogComponent implements OnInit {
onSearch(): void {
this.pageNumber.set(1);
this.rebuildActiveTrustFilters();
this.loadEvents();
}
onFilterChange(): void {
this.pageNumber.set(1);
this.rebuildActiveTrustFilters();
this.loadEvents();
}
onTrustSearch(value: string): void {
this.searchQuery.set(value);
this.onSearch();
}
onTrustFilterChanged(filter: ActiveFilter): void {
if (filter.key === 'resourceType') {
this.selectedResourceType.set(filter.value as any);
} else if (filter.key === 'severity') {
this.selectedSeverity.set(filter.value as any);
}
this.onFilterChange();
}
onTrustFilterRemoved(filter: ActiveFilter): void {
if (filter.key === 'resourceType') {
this.selectedResourceType.set('all');
} else if (filter.key === 'severity') {
this.selectedSeverity.set('all');
}
this.onFilterChange();
}
private rebuildActiveTrustFilters(): void {
const filters: ActiveFilter[] = [];
if (this.selectedResourceType() !== 'all') {
const opt = this.trustFilterOptions[0].options.find(o => o.value === this.selectedResourceType());
filters.push({ key: 'resourceType', value: this.selectedResourceType(), label: 'Resource: ' + (opt?.label || this.selectedResourceType()) });
}
if (this.selectedSeverity() !== 'all') {
const opt = this.trustFilterOptions[1].options.find(o => o.value === this.selectedSeverity());
filters.push({ key: 'severity', value: this.selectedSeverity() as string, label: 'Severity: ' + (opt?.label || this.selectedSeverity()) });
}
this.activeTrustFilters.set(filters);
}
onPageChange(page: number): void {
this.pageNumber.set(page);
this.loadEvents();
@@ -583,7 +619,6 @@ export class TrustAuditLogComponent implements OnInit {
this.selectedSeverity.set('all');
this.startDate.set('');
this.endDate.set('');
this.activeTrustFilters.set([]);
this.pageNumber.set(1);
this.loadEvents();
}

View File

@@ -6,7 +6,7 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RemediationHintComponent } from './remediation-hint.component';
import { PolicyEvaluateResponse, GateEvaluation } from '../../core/api/policy-interop.models';
import { PolicyEvaluateResponse } from '../../../core/api/policy-interop.models';
@Component({
selector: 'stella-policy-evaluate-panel',

View File

@@ -11,7 +11,7 @@ import {
PolicyGateDefinition,
PolicyRuleDefinition,
PolicyGateTypes,
} from '../../core/api/policy-interop.models';
} from '../../../core/api/policy-interop.models';
interface GateEditState {
gate: PolicyGateDefinition;
@@ -269,7 +269,7 @@ export class PolicyPackEditorComponent {
if (value) {
this._document.set(value);
this._gateStates.set(
(value.spec.gates || []).map((g) => ({ gate: { ...g }, expanded: false }))
(value.spec.gates || []).map((gate: PolicyGateDefinition) => ({ gate: { ...gate }, expanded: false }))
);
}
}

View File

@@ -5,7 +5,7 @@
import { Component, Input } from '@angular/core';
import { RemediationHint } from '../../core/api/policy-interop.models';
import { RemediationHint } from '../../../core/api/policy-interop.models';
@Component({
selector: 'stella-remediation-hint',

View File

@@ -147,10 +147,11 @@ export class GlossaryTooltipDirective implements OnInit, OnDestroy {
private showTooltip(entry: GlossaryEntry, anchor: HTMLElement): void {
this.hideTooltip();
this.tooltipElement = this.renderer.createElement('div');
this.renderer.addClass(this.tooltipElement, 'glossary-tooltip');
this.renderer.setAttribute(this.tooltipElement, 'role', 'tooltip');
this.renderer.setAttribute(this.tooltipElement, 'id', `glossary-tooltip-${entry.term.toLowerCase()}`);
const tooltip = this.renderer.createElement('div') as HTMLElement;
this.tooltipElement = tooltip;
this.renderer.addClass(tooltip, 'glossary-tooltip');
this.renderer.setAttribute(tooltip, 'role', 'tooltip');
this.renderer.setAttribute(tooltip, 'id', `glossary-tooltip-${entry.term.toLowerCase()}`);
const content = `
<div class="glossary-tooltip__header">
@@ -164,8 +165,8 @@ export class GlossaryTooltipDirective implements OnInit, OnDestroy {
</div>
`;
this.tooltipElement.innerHTML = content;
this.renderer.appendChild(document.body, this.tooltipElement);
this.renderer.setProperty(tooltip, 'innerHTML', content);
this.renderer.appendChild(document.body, tooltip);
// Position tooltip
this.positionTooltip(anchor);

View File

@@ -1,7 +1,6 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
@@ -67,7 +66,7 @@ describe('Evidence thread browser', () => {
it('navigates to encoded thread digest when a row is opened', () => {
component.onRowClick(thread);
expect(router.navigate).toHaveBeenCalledWith([
'/evidence-thread',
'/evidence/threads',
encodeURIComponent('sha256:artifact-1'),
]);
});
@@ -76,6 +75,7 @@ describe('Evidence thread browser', () => {
describe('EvidenceThreadViewComponent', () => {
let fixture: ComponentFixture<EvidenceThreadViewComponent>;
let component: EvidenceThreadViewComponent;
let router: Router;
let routeParams$: BehaviorSubject<Record<string, string>>;
const graph: EvidenceThreadGraph = {
@@ -154,6 +154,9 @@ describe('Evidence thread browser', () => {
],
}).compileComponents();
router = TestBed.inject(Router);
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
fixture = TestBed.createComponent(EvidenceThreadViewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -164,40 +167,10 @@ describe('Evidence thread browser', () => {
expect(component.thread()?.thread.id).toBe('thread-1');
});
it('shows snackbar when clipboard API is unavailable', () => {
const openSpy = spyOn((component as unknown as { snackBar: MatSnackBar }).snackBar, 'open');
routeParams$.next({ artifactDigest: '' });
fixture.detectChanges();
it('navigates back to the canonical evidence threads list', () => {
component.onBack();
component.copyDigest();
expect(openSpy).toHaveBeenCalledWith('Clipboard not available', 'Dismiss', {
duration: 3000,
});
});
it('shows snackbar when clipboard write rejects', async () => {
const openSpy = spyOn((component as unknown as { snackBar: MatSnackBar }).snackBar, 'open');
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: (_value: string) => Promise.resolve() },
});
}
const writeText = spyOn(navigator.clipboard, 'writeText').and.returnValue(
Promise.reject(new Error('clipboard denied'))
);
component.copyDigest();
await Promise.resolve();
await Promise.resolve();
expect(writeText).toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith('Failed to copy digest', 'Dismiss', {
duration: 3000,
});
expect(router.navigate).toHaveBeenCalledWith(['/evidence/threads']);
});
});
});

View File

@@ -0,0 +1,338 @@
import { signal } from '@angular/core';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { AuditLogTableComponent } from '../../app/features/audit-log/audit-log-table.component';
import { FindingsContainerComponent } from '../../app/features/findings/container/findings-container.component';
import { PolicyDecisioningGatesPageComponent } from '../../app/features/policy-decisioning/policy-decisioning-gates-page.component';
import { ReleaseDetailComponent } from '../../app/features/release-orchestrator/releases/release-detail/release-detail.component';
import { TrustAuditLogComponent } from '../../app/features/trust-admin/trust-audit-log.component';
import { AuditLogClient } from '../../app/core/api/audit-log.client';
import { SECURITY_FINDINGS_API } from '../../app/core/api/security-findings.client';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
import { CompareService } from '../../app/features/compare/services/compare.service';
import { MockScoringApi, SCORING_API } from '../../app/core/services/scoring.service';
import { ViewPreferenceService, FindingsViewMode } from '../../app/core/services/view-preference.service';
import { TRUST_API } from '../../app/core/api/trust.client';
import { PolicyPackEditorComponent } from '../../app/shared/components/policy/policy-pack-editor.component';
describe('Orphan revival regression remediation', () => {
describe('policy hosts', () => {
it('renders the policy evaluation panel on the mounted gates page', async () => {
await TestBed.configureTestingModule({
imports: [PolicyDecisioningGatesPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
queryParams: of({}),
queryParamMap: of(convertToParamMap({})),
snapshot: {
root: {
queryParams: {},
queryParamMap: convertToParamMap({}),
},
queryParams: {},
queryParamMap: convertToParamMap({}),
paramMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(PolicyDecisioningGatesPageComponent);
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('stella-policy-evaluate-panel')).toBeTruthy();
expect(element.textContent).toContain('Decision:');
expect(element.textContent).toContain('WARN');
expect(element.textContent).toContain('Gate Results');
});
it('renders the shared policy pack editor with a valid pack document', async () => {
await TestBed.configureTestingModule({
imports: [PolicyPackEditorComponent],
}).compileComponents();
const fixture = TestBed.createComponent(PolicyPackEditorComponent);
fixture.componentInstance.policyPack = {
apiVersion: 'policy.stella/v2',
kind: 'PolicyPack',
metadata: {
name: 'Release Safety',
version: '3.1.0',
digest: 'sha256:pack',
},
spec: {
settings: {
default_action: 'block',
deterministic_mode: true,
},
gates: [
{
id: 'cvss-threshold',
type: 'CvssThresholdGate',
enabled: true,
config: { threshold: 8 },
},
],
},
};
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.textContent).toContain('Release Safety');
expect(element.textContent).toContain('Gates');
});
});
describe('audit filtering', () => {
let fixture: ComponentFixture<AuditLogTableComponent>;
let component: AuditLogTableComponent;
const auditClient = {
getEvents: jasmine.createSpy('getEvents').and.returnValue(
of({ items: [], cursor: null, hasMore: false }),
),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AuditLogTableComponent],
providers: [provideRouter([]), { provide: AuditLogClient, useValue: auditClient }],
}).compileComponents();
fixture = TestBed.createComponent(AuditLogTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
auditClient.getEvents.calls.reset();
});
it('restores actor filtering and custom date ranges', () => {
component.actorFilter = 'auditor@stella.local';
component.dateRange = 'custom';
component.customStartDate = '2026-03-01';
component.customEndDate = '2026-03-08';
expect(component.buildFilters()).toEqual(
jasmine.objectContaining({
actorName: 'auditor@stella.local',
startDate: '2026-03-01',
endDate: '2026-03-08',
}),
);
});
it('preserves multi-select module, action, and severity filters', () => {
component.selectedModules = ['policy', 'scanner'];
component.selectedActions = ['approve', 'replay'];
component.selectedSeverities = ['warning', 'critical'];
expect(component.buildFilters()).toEqual(
jasmine.objectContaining({
modules: ['policy', 'scanner'],
actions: ['approve', 'replay'],
severities: ['warning', 'critical'],
}),
);
});
});
describe('trust audit filtering', () => {
let fixture: ComponentFixture<TrustAuditLogComponent>;
let component: TrustAuditLogComponent;
const trustApi = {
listAuditEvents: jasmine.createSpy('listAuditEvents').and.returnValue(
of({ items: [], totalCount: 0, pageNumber: 1, pageSize: 20 }),
),
exportAuditLog: jasmine.createSpy('exportAuditLog').and.returnValue(
of(new Blob(['[]'], { type: 'application/json' })),
),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TrustAuditLogComponent],
providers: [{ provide: TRUST_API, useValue: trustApi }],
}).compileComponents();
fixture = TestBed.createComponent(TrustAuditLogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
trustApi.listAuditEvents.calls.reset();
});
it('keeps start and end date filters in the request model', () => {
component.startDate.set('2026-03-01');
component.endDate.set('2026-03-08');
component.onFilterChange();
expect(trustApi.listAuditEvents).toHaveBeenCalledWith(
jasmine.objectContaining({
filter: jasmine.objectContaining({
startDate: '2026-03-01',
endDate: '2026-03-08',
}),
}),
);
});
});
describe('truthful findings and release security rendering', () => {
it('renders the mounted findings container with the bespoke findings list', async () => {
const queryParamMap$ = new BehaviorSubject(convertToParamMap({ view: 'detail' }));
const paramMap$ = new BehaviorSubject(convertToParamMap({ scanId: 'scan-1' }));
const viewPreference = jasmine.createSpyObj(
'ViewPreferenceService',
['getViewMode', 'setViewMode'],
{ viewMode: signal<FindingsViewMode>('detail').asReadonly() },
);
viewPreference.getViewMode.and.returnValue('detail');
const compareService = jasmine.createSpyObj('CompareService', [
'getBaselineRecommendations',
'computeDelta',
]);
compareService.getBaselineRecommendations.and.returnValue(
of({ selectedDigest: null, selectionReason: 'none', alternatives: [], autoSelectEnabled: true }),
);
compareService.computeDelta.and.returnValue(of({ categories: [], items: [] }));
const findingsApi = jasmine.createSpyObj('SecurityFindingsApi', ['listFindings']);
findingsApi.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: [FindingsContainerComponent, NoopAnimationsModule, HttpClientTestingModule],
providers: [
provideRouter([]),
{ provide: ViewPreferenceService, useValue: viewPreference },
{ provide: CompareService, useValue: compareService },
{ provide: SCORING_API, useClass: MockScoringApi },
{ provide: SECURITY_FINDINGS_API, useValue: findingsApi },
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMap$,
queryParamMap: queryParamMap$,
snapshot: {
paramMap: convertToParamMap({ scanId: 'scan-1' }),
queryParamMap: convertToParamMap({ view: 'detail' }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(FindingsContainerComponent);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('app-findings-list')).toBeTruthy();
expect(element.querySelector('stella-finding-list')).toBeFalsy();
expect(fixture.componentInstance.findings()[0].publishedAt).toBe('2026-02-10T09:30:00Z');
});
it('renders the release security table instead of a synthetic shared finding list', async () => {
const mockRelease = {
releaseId: 'rel-001',
name: 'Test Release',
version: 'v1.0.0',
digest: 'sha256:abc',
releaseType: 'standard',
targetRegion: 'us-east-1',
gateStatus: 'passed',
evidencePosture: 'complete',
riskTier: 'low',
gateBlockingCount: 0,
replayMismatch: false,
};
await TestBed.configureTestingModule({
imports: [ReleaseDetailComponent, HttpClientTestingModule],
providers: [
provideRouter([]),
{
provide: PlatformContextStore,
useValue: {
initialize: jasmine.createSpy('initialize'),
contextVersion: signal(0).asReadonly(),
selectedRegions: signal<string[]>([]),
selectedEnvironments: signal<string[]>([]),
},
},
{
provide: ReleaseManagementStore,
useValue: {
selectedRelease: signal(mockRelease),
releases: signal([mockRelease]),
selectRelease: jasmine.createSpy('selectRelease'),
loadRelease: () => EMPTY,
},
},
{
provide: ActivatedRoute,
useValue: {
data: of({ semanticObject: 'version' }),
paramMap: of(convertToParamMap({ releaseId: 'rel-001' })),
queryParamMap: of(convertToParamMap({})),
snapshot: {
paramMap: convertToParamMap({ releaseId: 'rel-001' }),
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReleaseDetailComponent);
const component = fixture.componentInstance;
component.mode.set('version');
component.activeTab.set('security-inputs');
component.findings.set([
{
findingId: 'f-001',
cveId: 'CVE-2026-9001',
severity: 'critical',
componentName: 'libcrypto',
releaseId: 'rel-001',
reachable: true,
reachabilityScore: 85,
effectiveDisposition: 'action_required',
vexStatus: 'under_investigation',
exceptionStatus: 'none',
},
]);
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
expect(element.querySelector('stella-finding-list')).toBeFalsy();
expect(element.textContent).toContain('CVE-2026-9001');
expect(element.textContent).toContain('libcrypto');
expect(element.textContent).toContain('yes (85)');
});
});
});