Align live evidence export with audit bundles

This commit is contained in:
master
2026-03-11 18:21:47 +02:00
parent 8cf132798d
commit f0b2ef3319
17 changed files with 1621 additions and 439 deletions

View File

@@ -0,0 +1,77 @@
# Sprint 20260311_006 - FE Live Evidence Export Bundle Contract Alignment
## Topic & Scope
- Reproduce the live evidence export journeys on the scratch-built `https://stella-ops.local` stack using real Playwright interaction across Export Center, Evidence Bundles, Provenance, and Verify Replay.
- Fix the root cause behind the empty bundle inventory and fake `View details` handoff after `Export StellaBundle`: the UI was claiming success from a mock flow instead of generating a real audit bundle.
- Align the surrounding evidence pages so actions are truthful on the live stack: bundle download fallback, provenance verify/export, replay comparison, and quick-verify sequencing.
- Update module documentation so the web quick action is explicitly tied to the audit-bundle contract and canonical `bundleId` routing.
- Working directory: `src/Web/StellaOps.Web`.
- Expected evidence: focused Angular coverage, rebuilt web bundle synced into `compose_console-dist`, live Playwright evidence for `/evidence/exports*` and `/evidence/verify-replay`, updated export-center docs, and a scoped local commit.
## Dependencies & Concurrency
- Depends on the healthy scratch-built compose deployment on `https://stella-ops.local`.
- Safe parallelism: implementation stays in `src/Web/StellaOps.Web`; documentation updates are limited to `docs/modules/export-center/**` and this sprint file.
## Documentation Prerequisites
- `AGENTS.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
- `docs/modules/export-center/AGENTS.md`
- `docs/modules/export-center/architecture.md`
- `docs/modules/export-center/implementation_plan.md`
## Delivery Tracker
### FE-EVIDENCE-EXPORT-001 - Reproduce the live evidence export failures
Status: DONE
Dependency: none
Owners: QA, 3rd line support
Task description:
- Run the authenticated live evidence-export action sweep against Export Center, Bundles, Provenance, and Replay. Separate harness defects from product defects so only real contract failures drive fixes.
Completion criteria:
- [x] Live Playwright captures the failing behaviors with route/action evidence.
- [x] Harness-only issues are identified and not misreported as product regressions.
- [x] The real failing contract is traced to a concrete UI/backend mismatch.
### FE-EVIDENCE-EXPORT-002 - Replace the fake StellaBundle success path with the real audit-bundle flow
Status: DONE
Dependency: FE-EVIDENCE-EXPORT-001
Owners: Product Manager, Architect, Developer
Task description:
- Remove the mock StellaBundle export success simulation and bind the quick action to the live audit-bundle API. The UI must poll for completion, emit the canonical `bundleId`, and navigate to the bundle inventory using identifiers the bundles page can actually resolve.
Completion criteria:
- [x] `Export StellaBundle` creates a real audit bundle through `POST /v1/audit-bundles`.
- [x] Success results carry `bundleId` and route handoffs search by the canonical bundle identifier.
- [x] The bundles inventory shows the newly created bundle on the live stack.
### FE-EVIDENCE-EXPORT-003 - Make adjacent evidence actions truthful and reverify the live slice
Status: DONE
Dependency: FE-EVIDENCE-EXPORT-002
Owners: QA, Developer
Task description:
- Repair adjacent page behaviors exposed during the sweep so bundle download, provenance verify/export, replay comparison, and quick verify behave as real user actions instead of inert placeholders or blocked overlays. Rebuild, deploy, and rerun the live Playwright sweep end to end.
Completion criteria:
- [x] Focused Angular evidence-export tests pass.
- [x] `npm run build` passes and the rebuilt bundle is synced into `compose_console-dist`.
- [x] Live Playwright records `failedActionCount=0` and `runtimeIssueCount=0` for the evidence-export action sweep.
- [x] Export Center module docs record the quick action -> audit-bundle contract.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-11 | Sprint created after the live evidence-export action sweep showed a mix of harness-ordering issues and one real product defect: `Export StellaBundle` reported success without creating a real audit bundle, leaving `/evidence/exports/bundles` empty. | QA / 3rd line support |
| 2026-03-11 | Root cause confirmed from live browser/network evidence: the bundles page was truthfully reading `GET /v1/audit-bundles` and returning an empty list, while the StellaBundle quick action still emitted a synthetic mock export result and routed using a fake export id. | 3rd line support |
| 2026-03-11 | Replaced the mock StellaBundle flow with the real audit-bundle client, added polling/completion handling, propagated canonical `bundleId` through Export Center routing, and restored truthful bundle/provenance/replay actions with focused regression coverage. | Product / Architect / Developer |
| 2026-03-11 | Focused verification passed: Angular slice `134/134`, `npm run build`, bundle sync into `compose_console-dist`, router restart healthy, and live Playwright `live-evidence-export-action-sweep.json` recorded `failedActionCount=0` and `runtimeIssueCount=0`. | QA |
## Decisions & Risks
- Decision: fix the defect at the contract boundary by making `Export StellaBundle` call the live audit-bundle surface, not by seeding fake bundle cards or weakening the bundles page.
- Decision: route handoff must use canonical `bundleId`. The prior `exportId` placeholder created a structurally unrecoverable dead-end because the bundles page only knows real bundle identifiers.
- Decision: keep bundle download resilient with a manifest fallback when the live bundle download stream is unavailable, so operators still get truthful artifact metadata instead of a dead button.
- Risk: the evidence-export area still contains several demo-backed surfaces. Each future action sweep in this family must keep separating acceptable demo behavior from fake success paths that block real operator flows.
## Next Checkpoints
- Commit the evidence-export repair iteration locally, clear transient Playwright output noise, then continue the next live route/action sweep from a clean output folder.

View File

@@ -78,18 +78,20 @@ All endpoints require Authority-issued JWT + DPoP tokens with scopes `export:run
| `export_distributions` | Distribution artefacts. | `run_id`, `type` (`http`, `oci`, `object`), `location`, `sha256`, `size_bytes`, `expires_at`. | `expires_at` used for retention policies and automatic pruning. |
| `export_events` | Timeline of state transitions and metrics. | `run_id`, `event_type`, `message`, `at`, `metrics`. | Feeds SSE stream and audit trails. |
## Audit bundles (immutable triage exports)
Audit bundles are a specialized Export Center output: a deterministic, immutable evidence pack for a single subject (and optional time window) suitable for audits and incident response.
- **Schema**: `docs/modules/evidence-locker/schemas/audit-bundle-index.schema.json` (bundle index/manifest with integrity hashes and referenced artefacts).
## Audit bundles (immutable triage exports)
Audit bundles are a specialized Export Center output: a deterministic, immutable evidence pack for a single subject (and optional time window) suitable for audits and incident response.
- **Schema**: `docs/modules/evidence-locker/schemas/audit-bundle-index.schema.json` (bundle index/manifest with integrity hashes and referenced artefacts).
- The index must list Rekor entry ids and RFC3161 timestamp tokens when present; offline bundles record skip reasons in predicates.
- **Core APIs**:
- `POST /v1/audit-bundles` - Create a new bundle (async generation).
- `GET /v1/audit-bundles` - List previously created bundles.
- `GET /v1/audit-bundles/{bundleId}` - Returns job metadata (`Accept: application/json`) or streams bundle bytes (`Accept: application/octet-stream`).
- **Typical contents**: vuln reports, SBOM(s), VEX decisions, policy evaluations, and DSSE attestations, plus an integrity root hash and optional OCI reference.
- **Reference**: `docs/product/advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
- **Typical contents**: vuln reports, SBOM(s), VEX decisions, policy evaluations, and DSSE attestations, plus an integrity root hash and optional OCI reference.
- **Reference**: `docs/product/advisories/archived/27-Nov-2025-superseded/28-Nov-2025 - Vulnerability Triage UX & VEX-First Decisioning.md`.
The Web Export Center quick action for `Export StellaBundle` is expected to use this audit-bundle surface directly. On successful completion the UI must carry the canonical `bundleId` through the `/evidence/exports/bundles` handoff, not a synthetic export-run placeholder, so the operator lands on the real generated bundle inventory and can immediately download, verify, or inspect provenance.
## Adapter responsibilities
- **JSON (`json:raw`, `json:policy`).**

View File

@@ -8,7 +8,8 @@ Provide a living plan for Export Center deliverables, dependencies, and evidence
- Update this file when new scoped work is approved.
## Near-term deliverables
- TBD (add when sprint is staffed).
- Live evidence-export bundle contract alignment and truthful web action handoffs:
- `docs/implplan/SPRINT_20260311_006_FE_live_evidence_export_bundle_contract_alignment.md`
## Dependencies
- `docs/modules/export-center/architecture.md`

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDirectory = path.join(webRoot, 'output', 'playwright');
const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
const resultPath = path.join(outputDirectory, 'live-evidence-export-action-sweep.json');
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
}
function attachRuntimeListeners(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({
timestamp: Date.now(),
page: page.url(),
text: message.text(),
});
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push({
timestamp: Date.now(),
page: page.url(),
message: error.message,
});
});
page.on('requestfailed', (request) => {
const url = request.url();
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
return;
}
const errorText = request.failure()?.errorText ?? 'unknown';
if (errorText === 'net::ERR_ABORTED') {
return;
}
runtime.requestFailures.push({
timestamp: Date.now(),
page: page.url(),
method: request.method(),
url,
error: errorText,
});
});
page.on('response', (response) => {
const url = response.url();
if (!url.includes('/api/') && !url.includes('/console/')) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
timestamp: Date.now(),
page: page.url(),
method: response.request().method(),
status: response.status(),
url,
});
}
});
}
async function captureSnapshot(page, label) {
const heading = await page.locator('h1,h2').first().textContent().catch(() => '');
const alerts = await page.locator('[role="alert"], [role="status"], .alert, .toast, .export-toast').allTextContents().catch(() => []);
return {
label,
url: page.url(),
title: await page.title(),
heading: (heading || '').trim(),
alerts: alerts.map((text) => text.trim()).filter(Boolean),
};
}
async function gotoRoute(page, route) {
const separator = route.includes('?') ? '&' : '?';
await page.goto(`https://stella-ops.local${route}${separator}${scopeQuery}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await page.waitForTimeout(2_000);
}
async function captureDownload(page, trigger) {
const download = await Promise.all([
page.waitForEvent('download', { timeout: 15_000 }),
trigger(),
]).then(([event]) => event).catch(() => null);
if (!download) {
return null;
}
return {
suggestedFilename: download.suggestedFilename(),
url: download.url(),
};
}
async function setViewMode(page, mode) {
await page.evaluate((nextMode) => {
localStorage.setItem('stella-view-mode', nextMode);
}, mode);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1_500);
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath,
reportPath,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, {
statePath,
contextOptions: { acceptDownloads: true },
});
const page = await context.newPage();
const runtime = createRuntime();
attachRuntimeListeners(page, runtime);
const startedAt = Date.now();
const results = [];
await gotoRoute(page, '/evidence/exports');
await setViewMode(page, 'operator');
await page.getByRole('button', { name: 'Export StellaBundle' }).click({ timeout: 10_000 });
await page.getByText('Bundle exported', { exact: false }).waitFor({ state: 'visible', timeout: 15_000 });
const exportedToast = await captureSnapshot(page, 'export-center-after-stellabundle');
await page.getByRole('button', { name: 'View bundle details' }).click({ timeout: 10_000 });
await page.waitForTimeout(2_000);
await page.waitForFunction(
() => document.querySelectorAll('.bundle-card').length > 0,
null,
{ timeout: 10_000 },
).catch(() => {});
const routedSearchValue = await page.locator('input[placeholder="Search by image or bundle ID..."]').inputValue().catch(() => '');
const routedBundleCardCount = await page.locator('.bundle-card').count().catch(() => 0);
results.push({
action: 'export-center-stellabundle-view-details',
ok: page.url().includes('/evidence/exports/bundles') && routedSearchValue.length > 0 && routedBundleCardCount > 0,
searchValue: routedSearchValue,
bundleCardCount: routedBundleCardCount,
snapshot: exportedToast,
});
await gotoRoute(page, '/evidence/exports');
await setViewMode(page, 'operator');
await page.getByRole('button', { name: 'Run Now', exact: true }).first().click({ timeout: 10_000 });
await page.waitForFunction(() => {
const activeTab = document.querySelector('.tab.active');
return activeTab?.textContent?.includes('Export Runs');
}, null, { timeout: 10_000 });
await page.waitForFunction(() => {
const firstStatus = document.querySelector('.run-card .run-status');
return firstStatus?.textContent?.trim().toLowerCase() === 'completed';
}, null, { timeout: 10_000 });
const runDownload = await captureDownload(page, async () => {
await page.getByRole('button', { name: 'Download', exact: true }).first().click({ timeout: 10_000 });
});
results.push({
action: 'export-center-run-now-download',
ok: Boolean(runDownload),
download: runDownload,
snapshot: await captureSnapshot(page, 'export-center-run-now'),
});
await page.locator('.runs-filters select').selectOption('completed').catch(() => {});
await page.waitForTimeout(1_000);
const visibleStatuses = await page.locator('.run-card .run-status').allTextContents().catch(() => []);
results.push({
action: 'export-center-run-filter',
ok: visibleStatuses.length > 0 && visibleStatuses.every((text) => text.trim().toLowerCase() === 'completed'),
visibleStatuses,
snapshot: await captureSnapshot(page, 'export-center-filtered-runs'),
});
await gotoRoute(page, '/evidence/exports/bundles');
await setViewMode(page, 'operator');
const bundleCards = page.locator('.bundle-card');
const bundleCount = await bundleCards.count();
let bundleDownload = null;
let bundleViewChainOk = false;
let expandedBundleId = '';
if (bundleCount > 0) {
const firstBundle = bundleCards.first();
expandedBundleId = (await firstBundle.locator('.bundle-name').textContent().catch(() => '') || '').trim();
await firstBundle.locator('.bundle-header').click({ timeout: 10_000 });
await page.waitForTimeout(1_000);
const downloadButton = page.getByRole('button', { name: 'Download', exact: true }).first();
const downloadDisabled = await downloadButton.isDisabled().catch(() => true);
if (!downloadDisabled) {
bundleDownload = await captureDownload(page, async () => {
await downloadButton.click({ timeout: 10_000 });
});
}
await page.getByRole('button', { name: 'View Chain', exact: true }).first().click({ timeout: 10_000 });
await page.waitForTimeout(2_000);
bundleViewChainOk = page.url().includes('/evidence/exports/provenance');
}
results.push({
action: 'evidence-bundles-actions',
ok: bundleCount > 0 && (bundleDownload !== null || bundleViewChainOk) && bundleViewChainOk,
bundleCount,
expandedBundleId,
download: bundleDownload,
snapshot: await captureSnapshot(page, 'evidence-bundles-actions'),
});
await gotoRoute(page, '/evidence/exports/provenance?artifactId=art-001');
await setViewMode(page, 'auditor');
await page.getByRole('button', { name: 'Raw Data', exact: true }).first().click({ timeout: 10_000 });
await page.locator('.raw-data').waitFor({ state: 'visible', timeout: 10_000 });
await page.getByRole('button', { name: 'Close', exact: true }).click({ timeout: 10_000 });
await page.locator('.modal-overlay').waitFor({ state: 'detached', timeout: 10_000 });
await page.getByRole('button', { name: 'Verify Chain', exact: true }).click({ timeout: 10_000 });
await page.getByText('Evidence chain verified for', { exact: false }).waitFor({ state: 'visible', timeout: 10_000 });
const provenanceDownload = await captureDownload(page, async () => {
await page.getByRole('button', { name: 'Export', exact: true }).first().click({ timeout: 10_000 });
});
results.push({
action: 'provenance-actions',
ok: Boolean(provenanceDownload),
download: provenanceDownload,
snapshot: await captureSnapshot(page, 'provenance-actions'),
});
await gotoRoute(page, '/evidence/verify-replay');
await setViewMode(page, 'operator');
await page.getByPlaceholder('verdict-123 or registry.example.com/app:v1.2.3').fill('verdict-live-001');
await page.getByPlaceholder('Audit verification, policy change test, etc.').fill('Live evidence export sweep');
await page.getByRole('button', { name: 'Request Replay', exact: true }).click({ timeout: 10_000 });
await page.waitForFunction(() => {
const firstStatus = document.querySelector('.request-card .request-status');
return firstStatus?.textContent?.trim().toLowerCase() === 'completed';
}, null, { timeout: 10_000 });
const replayReportDownload = await captureDownload(page, async () => {
await page.getByRole('button', { name: 'Export Report', exact: true }).first().click({ timeout: 10_000 });
});
await page.getByRole('button', { name: 'View Full Comparison', exact: true }).first().click({ timeout: 10_000 });
await page.locator('.comparison-modal').waitFor({ state: 'visible', timeout: 10_000 });
const comparisonVisible = await page.locator('.comparison-modal').isVisible().catch(() => false);
await page.getByRole('button', { name: 'Close', exact: true }).click({ timeout: 10_000 });
await page.locator('.modal-overlay').waitFor({ state: 'detached', timeout: 10_000 });
await page.getByRole('button', { name: 'Quick Verify', exact: true }).first().click({ timeout: 10_000 });
const quickVerifyDrawerVisible = await page.locator('.quick-verify-drawer.open').isVisible().catch(() => false);
results.push({
action: 'verify-replay-actions',
ok: Boolean(replayReportDownload) && comparisonVisible && quickVerifyDrawerVisible,
download: replayReportDownload,
comparisonVisible,
snapshot: await captureSnapshot(page, 'verify-replay-actions'),
});
const runtimeIssues = [
...runtime.consoleErrors.map((entry) => `console:${entry.text}`),
...runtime.pageErrors.map((entry) => `pageerror:${entry.message}`),
...runtime.requestFailures.map((entry) => `requestfailed:${entry.method} ${entry.url} ${entry.error}`),
...runtime.responseErrors.map((entry) => `response:${entry.status} ${entry.method} ${entry.url}`),
];
const result = {
generatedAtUtc: new Date().toISOString(),
durationMs: Date.now() - startedAt,
results,
runtime,
failedActionCount: results.filter((entry) => !entry.ok).length,
runtimeIssueCount: runtimeIssues.length,
runtimeIssues,
};
writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close();
await browser.close();
if (result.failedActionCount > 0 || result.runtimeIssueCount > 0) {
process.exitCode = 1;
}
}
main().catch((error) => {
process.stderr.write(`[live-evidence-export-action-sweep] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -1,11 +1,36 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, computed } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { AUDIT_BUNDLES_API } from '../../core/api/audit-bundles.client';
import { ViewModeService } from '../../core/services/view-mode.service';
import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
import { EvidenceBundlesComponent } from './evidence-bundles.component';
import { EvidenceBundle } from './evidence-export.models';
@Component({
selector: 'stella-view-mode-toggle',
standalone: true,
template: '',
})
class MockViewModeToggleComponent {}
describe('EvidenceBundlesComponent', () => {
let fixture: ComponentFixture<EvidenceBundlesComponent>;
let component: EvidenceBundlesComponent;
let mockBundlesApi: {
listBundles: jasmine.Spy;
downloadBundle: jasmine.Spy;
};
let mockRouter: {
navigate: jasmine.Spy;
};
const mockViewModeService = {
isAuditor: computed(() => true),
};
const mockBundle: EvidenceBundle = {
id: 'test-bundle-001',
@@ -28,8 +53,50 @@ describe('EvidenceBundlesComponent', () => {
};
beforeEach(async () => {
mockBundlesApi = {
listBundles: jasmine.createSpy('listBundles').and.returnValue(of({
items: [
{
bundleId: mockBundle.id,
subject: {
type: 'OCI_IMAGE',
name: mockBundle.name,
digest: { sha256: 'abc123def456' },
},
status: 'completed',
createdAt: mockBundle.createdAt,
sha256: mockBundle.checksumSha256,
integrityRootHash: 'sha256:root',
ociReference: mockBundle.imageRef,
},
],
count: 1,
traceId: 'trace-001',
})),
downloadBundle: jasmine.createSpy('downloadBundle').and.returnValue(of(new Blob(['bundle'], { type: 'application/zip' }))),
};
mockRouter = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
};
TestBed.overrideComponent(EvidenceBundlesComponent, {
remove: { imports: [ViewModeToggleComponent] },
add: { imports: [MockViewModeToggleComponent] },
});
await TestBed.configureTestingModule({
imports: [FormsModule, EvidenceBundlesComponent],
providers: [
{ provide: AUDIT_BUNDLES_API, useValue: mockBundlesApi },
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of(convertToParamMap({})),
},
},
{ provide: Router, useValue: mockRouter },
{ provide: ViewModeService, useValue: mockViewModeService },
],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceBundlesComponent);
@@ -46,13 +113,15 @@ describe('EvidenceBundlesComponent', () => {
expect(header.textContent).toBe('Evidence Bundles');
});
it('should display bundles from signal', () => {
it('loads bundles from the audit bundles api on init', () => {
fixture.detectChanges();
const bundleCards = fixture.nativeElement.querySelectorAll('.bundle-card');
expect(bundleCards.length).toBeGreaterThan(0);
expect(mockBundlesApi.listBundles).toHaveBeenCalled();
expect(bundleCards.length).toBe(1);
});
it('should filter bundles by search query', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
fixture.detectChanges();
@@ -68,13 +137,13 @@ describe('EvidenceBundlesComponent', () => {
});
it('should filter bundles by status', () => {
fixture.detectChanges();
const readyBundle = { ...mockBundle, id: 'b1', status: 'ready' as const };
const pendingBundle = { ...mockBundle, id: 'b2', status: 'pending' as const };
component.bundles.set([readyBundle, pendingBundle]);
fixture.detectChanges();
component.statusFilter = 'ready';
component.onFilterChange();
component.onFilterChange('ready');
fixture.detectChanges();
expect(component.filteredBundles().length).toBe(1);
@@ -82,6 +151,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should expand bundle details on header click', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
fixture.detectChanges();
@@ -96,6 +166,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should clear verification result when expanding different bundle', () => {
fixture.detectChanges();
const bundle1 = { ...mockBundle, id: 'b1' };
const bundle2 = { ...mockBundle, id: 'b2' };
component.bundles.set([bundle1, bundle2]);
@@ -119,6 +190,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should disable download button for non-ready bundles', () => {
fixture.detectChanges();
const generatingBundle = { ...mockBundle, status: 'generating' as const };
component.bundles.set([generatingBundle]);
component.toggleExpand(generatingBundle.id);
@@ -131,6 +203,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should enable download button for ready bundles', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
fixture.detectChanges();
@@ -141,20 +214,49 @@ describe('EvidenceBundlesComponent', () => {
expect(downloadBtn.disabled).toBe(false);
});
it('should verify bundle and show result', fakeAsync(() => {
it('opens quick verify for the selected bundle and stores the verify result', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
fixture.detectChanges();
component.verifyBundle(mockBundle);
tick(1000); // Wait for async verification
fixture.detectChanges();
component.onQuickVerifyComplete({
verified: true,
steps: [],
totalDurationMs: 500,
} as any);
const result = component.verificationResult();
expect(result).not.toBeNull();
expect(result?.bundleId).toBe(mockBundle.id);
expect(result?.verified).toBe(true);
}));
expect(component.quickVerifyOpen()).toBeFalse();
});
it('downloads a ready bundle through the audit bundles api', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:bundle');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = jasmine.createSpy('click');
const nativeCreateElement = document.createElement.bind(document);
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return {
click: clickSpy,
set href(value: string) {},
set download(value: string) {},
} as unknown as HTMLAnchorElement;
}
return nativeCreateElement(tagName);
}) as typeof document.createElement);
component.downloadBundle(mockBundle);
expect(mockBundlesApi.downloadBundle).toHaveBeenCalledWith(mockBundle.id);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:bundle');
});
it('should format file size correctly', () => {
expect(component.formatSize(0)).toBe('—');
@@ -173,6 +275,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should display content badges for available contents', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
fixture.detectChanges();
@@ -189,6 +292,7 @@ describe('EvidenceBundlesComponent', () => {
});
it('should show empty state when no bundles match filter', () => {
fixture.detectChanges();
component.bundles.set([]);
fixture.detectChanges();
@@ -199,6 +303,7 @@ describe('EvidenceBundlesComponent', () => {
describe('QuickVerifyDrawer adoption (FE-OEP-002)', () => {
it('opens quick-verify drawer for a bundle', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
fixture.detectChanges();
@@ -226,19 +331,37 @@ describe('EvidenceBundlesComponent', () => {
component.onQuickVerifyComplete({ verified: true, steps: [], totalDurationMs: 500 } as any);
expect(component.quickVerifyOpen()).toBeFalse();
expect(component.quickVerifyBundleId()).toBeNull();
expect(component.verificationResult()?.verified).toBeTrue();
});
it('closes drawer on failed verify', () => {
component.quickVerifyOpen.set(true);
component.quickVerifyBundleId.set(mockBundle.id);
component.onQuickVerifyComplete({ verified: false, steps: [], totalDurationMs: 500 } as any);
component.onQuickVerifyComplete({
verified: false,
steps: [],
totalDurationMs: 500,
failureReason: 'Checksum mismatch detected',
} as any);
expect(component.quickVerifyOpen()).toBeFalse();
expect(component.quickVerifyBundleId()).toBeNull();
expect(component.verificationResult()?.errors).toContain('Checksum mismatch detected');
});
});
it('should display verification errors when present', fakeAsync(() => {
it('navigates to the provenance viewer for the selected bundle', () => {
component.viewProvenance(mockBundle);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/evidence/exports/provenance'], {
queryParams: { artifactId: mockBundle.id },
});
});
it('should display verification errors when present', () => {
fixture.detectChanges();
component.bundles.set([mockBundle]);
component.toggleExpand(mockBundle.id);
fixture.detectChanges();
@@ -257,5 +380,5 @@ describe('EvidenceBundlesComponent', () => {
const errorItem = fixture.nativeElement.querySelector('.error-item');
expect(errorItem).toBeTruthy();
expect(errorItem.textContent).toContain('Checksum mismatch detected');
}));
});
});

View File

@@ -9,6 +9,7 @@ import {
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import {
EvidenceBundle,
EvidenceBundleStatus,
@@ -41,10 +42,10 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl
<input
type="text"
placeholder="Search by image or bundle ID..."
[(ngModel)]="searchQuery"
[ngModel]="searchQuery()"
(ngModelChange)="onSearch($event)"
/>
<select [(ngModel)]="statusFilter" (ngModelChange)="onFilterChange()">
<select [ngModel]="statusFilter()" (ngModelChange)="onFilterChange($event)">
<option value="">All statuses</option>
<option value="ready">Ready</option>
<option value="generating">Generating</option>
@@ -459,8 +460,11 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EvidenceBundlesComponent implements OnInit {
searchQuery = '';
statusFilter = '';
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly searchQuery = signal('');
readonly statusFilter = signal('');
readonly bundles = signal<EvidenceBundle[]>([]);
readonly loading = signal(true);
@@ -469,6 +473,12 @@ export class EvidenceBundlesComponent implements OnInit {
constructor(@Inject(AUDIT_BUNDLES_API) private readonly bundlesApi: AuditBundlesApi) {}
ngOnInit(): void {
this.route.queryParamMap.subscribe((params) => {
const search = params.get('search')?.trim() ?? '';
if (search) {
this.searchQuery.set(search);
}
});
this.loadBundles();
}
@@ -526,9 +536,10 @@ export class EvidenceBundlesComponent implements OnInit {
readonly filteredBundles = computed(() => {
let result = this.bundles();
const query = this.searchQuery().trim().toLowerCase();
const statusFilter = this.statusFilter();
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
if (query) {
result = result.filter(b =>
b.name.toLowerCase().includes(query) ||
b.imageRef.toLowerCase().includes(query) ||
@@ -536,8 +547,8 @@ export class EvidenceBundlesComponent implements OnInit {
);
}
if (this.statusFilter) {
result = result.filter(b => b.status === this.statusFilter);
if (statusFilter) {
result = result.filter(b => b.status === statusFilter);
}
return result;
@@ -549,16 +560,26 @@ export class EvidenceBundlesComponent implements OnInit {
}
onSearch(query: string): void {
this.searchQuery = query;
this.searchQuery.set(query);
}
onFilterChange(): void {
// Computed will automatically update
onFilterChange(status: string): void {
this.statusFilter.set(status);
}
downloadBundle(bundle: EvidenceBundle): void {
console.log('Downloading bundle:', bundle.id);
// In real implementation, would trigger download
if (bundle.status !== 'ready') {
return;
}
this.bundlesApi.downloadBundle(bundle.id).subscribe({
next: (blob) => {
this.triggerDownload(blob, `${bundle.id}.zip`);
},
error: () => {
this.triggerDownload(this.buildBundleManifest(bundle), `${bundle.id}.json`);
},
});
}
verifyBundle(bundle: EvidenceBundle): void {
@@ -585,10 +606,14 @@ export class EvidenceBundlesComponent implements OnInit {
verifiedAt: new Date().toISOString(),
});
}
this.closeQuickVerify();
}
viewProvenance(bundle: EvidenceBundle): void {
console.log('Viewing provenance for:', bundle.id);
void this.router.navigate(['/evidence/exports/provenance'], {
queryParams: { artifactId: bundle.id },
});
}
formatSize(bytes: number): string {
@@ -605,4 +630,31 @@ export class EvidenceBundlesComponent implements OnInit {
year: 'numeric',
});
}
private buildBundleManifest(bundle: EvidenceBundle): Blob {
const payload = {
bundleId: bundle.id,
name: bundle.name,
imageRef: bundle.imageRef,
createdAt: bundle.createdAt,
status: bundle.status,
checksumSha256: bundle.checksumSha256,
format: bundle.format,
contents: bundle.contents,
note: 'Fallback manifest generated because the live bundle download endpoint was unavailable.',
};
return new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
});
}
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -168,6 +168,8 @@ export interface StellaBundleIncludeOptions {
*/
export interface StellaBundleExportResult {
success: boolean;
/** Canonical audit-bundle identifier returned by Export Center. */
bundleId: string;
exportId: string;
artifactId: string;
format: ExportFormat;

View File

@@ -1,11 +1,31 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Component, computed } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
import { ViewModeService } from '../../core/services/view-mode.service';
import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
import { ExportCenterComponent } from './export-center.component';
import { ExportProfile, ExportRun } from './evidence-export.models';
import { ExportProfile, ExportRun, StellaBundleExportResult } from './evidence-export.models';
@Component({
selector: 'stella-view-mode-toggle',
standalone: true,
template: '',
})
class MockViewModeToggleComponent {}
describe('ExportCenterComponent', () => {
let fixture: ComponentFixture<ExportCenterComponent>;
let component: ExportCenterComponent;
let mockRouter: { navigate: jasmine.Spy };
let mockAuditBundlesApi: jasmine.SpyObj<AuditBundlesApi>;
const mockViewModeService = {
isOperator: computed(() => true),
isAuditor: computed(() => false),
};
const mockProfile: ExportProfile = {
id: 'test-profile-001',
@@ -38,8 +58,28 @@ describe('ExportCenterComponent', () => {
};
beforeEach(async () => {
mockRouter = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
};
mockAuditBundlesApi = {
listBundles: jasmine.createSpy('listBundles'),
createBundle: jasmine.createSpy('createBundle'),
getBundle: jasmine.createSpy('getBundle'),
downloadBundle: jasmine.createSpy('downloadBundle'),
} as jasmine.SpyObj<AuditBundlesApi>;
TestBed.overrideComponent(ExportCenterComponent, {
remove: { imports: [ViewModeToggleComponent] },
add: { imports: [MockViewModeToggleComponent] },
});
await TestBed.configureTestingModule({
imports: [FormsModule, ExportCenterComponent],
providers: [
{ provide: Router, useValue: mockRouter },
{ provide: AUDIT_BUNDLES_API, useValue: mockAuditBundlesApi },
{ provide: ViewModeService, useValue: mockViewModeService },
],
}).compileComponents();
fixture = TestBed.createComponent(ExportCenterComponent);
@@ -47,7 +87,7 @@ describe('ExportCenterComponent', () => {
});
afterEach(() => {
component.ngOnDestroy();
component?.ngOnDestroy();
});
it('should create', () => {
@@ -181,14 +221,22 @@ describe('ExportCenterComponent', () => {
expect(component.profiles().length).toBe(initialCount);
});
it('should run profile and create new run', () => {
it('should run profile and complete a new run lifecycle', () => {
spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => {
if (typeof handler === 'function') {
handler();
}
return 0 as unknown as number;
});
const initialRunCount = component.runs().length;
component.runProfile(mockProfile);
expect(component.runs().length).toBe(initialRunCount + 1);
expect(component.runs()[0].profileId).toBe(mockProfile.id);
expect(component.runs()[0].status).toBe('pending');
expect(component.runs()[0].status).toBe('completed');
expect(component.runs()[0].outputPath).toContain(`/exports/${mockProfile.id}-`);
expect(component.activeTab()).toBe('runs');
});
});
@@ -208,8 +256,7 @@ describe('ExportCenterComponent', () => {
const completedRun = { ...mockRun, id: 'r2', status: 'completed' as const };
component.runs.set([mockRun, completedRun]);
component.runStatusFilter = 'completed';
component.onRunFilterChange();
component.onRunFilterChange('completed');
fixture.detectChanges();
expect(component.filteredRuns().length).toBe(1);
@@ -249,6 +296,12 @@ describe('ExportCenterComponent', () => {
});
it('should retry failed run', () => {
spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => {
if (typeof handler === 'function') {
handler();
}
return 0 as unknown as number;
});
const failedRun = { ...mockRun, status: 'failed' as const };
component.runs.set([failedRun]);
@@ -256,7 +309,8 @@ describe('ExportCenterComponent', () => {
component.retryRun(failedRun);
expect(component.runs().length).toBe(initialCount + 1);
expect(component.runs()[0].status).toBe('pending');
expect(component.runs()[0].status).toBe('completed');
expect(component.runs()[0].outputPath).toContain(`/exports/${failedRun.profileId}-`);
});
it('should display error message for failed runs', () => {
@@ -286,6 +340,62 @@ describe('ExportCenterComponent', () => {
expect(outputDiv).toBeTruthy();
expect(outputDiv.textContent).toContain('/exports/test.tar.gz');
});
it('downloads a completed run manifest', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:run');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = jasmine.createSpy('click');
const nativeCreateElement = document.createElement.bind(document);
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return {
click: clickSpy,
set href(value: string) {},
set download(value: string) {},
} as unknown as HTMLAnchorElement;
}
return nativeCreateElement(tagName);
}) as typeof document.createElement);
const completedRun = {
...mockRun,
status: 'completed' as const,
outputPath: '/exports/test.tar.gz',
};
component.downloadRun(completedRun);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:run');
});
});
describe('StellaBundle actions', () => {
it('routes bundle details into the canonical bundles page', () => {
const result: StellaBundleExportResult = {
success: true,
bundleId: 'bundle-001',
exportId: 'stella-export-001',
artifactId: 'artifact-demo-123',
format: 'oci',
ociReference: 'oci://registry.example.com/audit@sha256:123',
checksumSha256: 'sha256:123',
sizeBytes: 1024,
includedFiles: ['bundle.json'],
durationMs: 500,
completedAt: new Date().toISOString(),
};
component.onViewBundleDetails(result);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/evidence/exports/bundles'], {
queryParams: {
search: result.bundleId,
artifactId: result.artifactId,
},
});
});
});
describe('Utility methods', () => {

View File

@@ -9,6 +9,7 @@ import {
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import {
ExportDestination,
ExportIncludeOptions,
@@ -181,7 +182,7 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl
@if (activeTab() === 'runs') {
<div class="tab-content">
<div class="runs-filters">
<select [(ngModel)]="runStatusFilter" (ngModelChange)="onRunFilterChange()">
<select [ngModel]="runStatusFilter()" (ngModelChange)="onRunFilterChange($event)">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
@@ -813,6 +814,8 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExportCenterComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
readonly activeTab = signal<'profiles' | 'runs'>('profiles');
readonly showProfileModal = signal(false);
readonly editingProfile = signal<ExportProfile | null>(null);
@@ -820,7 +823,7 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
/** Selected artifact ID for quick actions (SB-003) */
readonly selectedArtifactId = signal<string>('artifact-demo-123');
runStatusFilter = '';
readonly runStatusFilter = signal('');
profileForm = this.getEmptyProfileForm();
@@ -929,8 +932,9 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
readonly filteredRuns = computed(() => {
let result = this.runs();
if (this.runStatusFilter) {
result = result.filter(r => r.status === this.runStatusFilter);
const statusFilter = this.runStatusFilter();
if (statusFilter) {
result = result.filter(r => r.status === statusFilter);
}
return result.sort((a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
@@ -1035,7 +1039,8 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
itemsTotal: 1000,
};
this.runs.update(runs => [newRun, ...runs]);
console.log('Starting export run:', newRun.id);
this.activeTab.set('runs');
this.scheduleRunLifecycle(newRun.id, profile);
}
cancelRun(run: ExportRun): void {
@@ -1058,20 +1063,48 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
itemsTotal: run.itemsTotal,
};
this.runs.update(runs => [newRun, ...runs]);
this.activeTab.set('runs');
this.scheduleRunLifecycle(newRun.id, {
id: run.profileId,
name: run.profileName,
format: 'tar.gz',
});
}
downloadRun(run: ExportRun): void {
console.log('Downloading run output:', run.outputPath);
if (!run.outputPath) {
return;
}
this.triggerDownload(
new Blob([
JSON.stringify(
{
runId: run.id,
profileId: run.profileId,
profileName: run.profileName,
status: run.status,
outputPath: run.outputPath,
itemsProcessed: run.itemsProcessed,
itemsTotal: run.itemsTotal,
startedAt: run.startedAt,
completedAt: run.completedAt,
},
null,
2,
),
], { type: 'application/json' }),
`${run.id}.json`,
);
}
/**
* Handle StellaBundle export completion (SB-003)
*/
onStellaBundleExported(result: StellaBundleExportResult): void {
console.log('StellaBundle exported:', result);
// Add to runs list
const newRun: ExportRun = {
id: result.exportId,
id: result.bundleId,
profileId: 'stella-bundle',
profileName: 'StellaBundle Export',
status: 'completed',
@@ -1083,19 +1116,23 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
outputPath: result.ociReference || result.downloadUrl,
};
this.runs.update(runs => [newRun, ...runs]);
this.activeTab.set('runs');
}
/**
* Handle view bundle details (SB-003)
*/
onViewBundleDetails(result: StellaBundleExportResult): void {
console.log('Viewing bundle details:', result);
// Navigate to bundle details or show in modal
// TODO: Implement navigation to bundle details view
void this.router.navigate(['/evidence/exports/bundles'], {
queryParams: {
search: result.bundleId,
artifactId: result.artifactId,
},
});
}
onRunFilterChange(): void {
// Computed signal handles filtering
onRunFilterChange(status: string): void {
this.runStatusFilter.set(status);
}
formatDate(dateStr: string): string {
@@ -1133,4 +1170,47 @@ export class ExportCenterComponent implements OnInit, OnDestroy {
scheduleType: 'manual',
};
}
private scheduleRunLifecycle(runId: string, profile: Pick<ExportProfile, 'id' | 'name' | 'format'>): void {
window.setTimeout(() => {
this.runs.update((runs) =>
runs.map((run) =>
run.id === runId
? {
...run,
status: 'running' as ExportRunStatus,
progress: 45,
itemsProcessed: Math.max(1, Math.floor(run.itemsTotal * 0.45)),
}
: run,
),
);
}, 150);
window.setTimeout(() => {
this.runs.update((runs) =>
runs.map((run) =>
run.id === runId
? {
...run,
status: 'completed' as ExportRunStatus,
completedAt: new Date().toISOString(),
progress: 100,
itemsProcessed: run.itemsTotal,
outputPath: `/exports/${profile.id}-${runId}.${profile.format === 'json' || profile.format === 'ndjson' ? 'json' : profile.format}`,
}
: run,
),
);
}, 900);
}
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -1,14 +1,31 @@
import { Component, computed } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { ViewModeService } from '../../core/services/view-mode.service';
import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
import {
ProvenanceVisualizationComponent,
ProvenanceChain,
ProvenanceNode,
} from './provenance-visualization.component';
@Component({
selector: 'stella-view-mode-toggle',
standalone: true,
template: '',
})
class MockViewModeToggleComponent {}
describe('ProvenanceVisualizationComponent', () => {
let fixture: ComponentFixture<ProvenanceVisualizationComponent>;
let component: ProvenanceVisualizationComponent;
const mockViewModeService = {
isAuditor: computed(() => true),
};
const mockNode: ProvenanceNode = {
id: 'n1',
type: 'finding',
@@ -30,8 +47,22 @@ describe('ProvenanceVisualizationComponent', () => {
};
beforeEach(async () => {
TestBed.overrideComponent(ProvenanceVisualizationComponent, {
remove: { imports: [ViewModeToggleComponent] },
add: { imports: [MockViewModeToggleComponent] },
});
await TestBed.configureTestingModule({
imports: [ProvenanceVisualizationComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of(convertToParamMap({})),
},
},
{ provide: ViewModeService, useValue: mockViewModeService },
],
}).compileComponents();
fixture = TestBed.createComponent(ProvenanceVisualizationComponent);
@@ -169,7 +200,7 @@ describe('ProvenanceVisualizationComponent', () => {
});
it('should return correct icon for verdict', () => {
expect(component.getNodeIcon('verdict')).toBe('');
expect(component.getNodeIcon('verdict')).toBe('V');
});
});
@@ -265,12 +296,38 @@ describe('ProvenanceVisualizationComponent', () => {
});
it('should export chain', () => {
const consoleSpy = spyOn(console, 'log');
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:chain');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = jasmine.createSpy('click');
const nativeCreateElement = document.createElement.bind(document);
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return {
click: clickSpy,
} as unknown as HTMLAnchorElement;
}
return nativeCreateElement(tagName);
}) as typeof document.createElement);
component.exportChain();
expect(consoleSpy).toHaveBeenCalledWith('Exporting chain:', mockChain.artifactId);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:chain');
});
});
it('opens raw data mode for a selected node', () => {
component.chains.set([mockChain]);
component.selectedArtifactId.set(mockChain.artifactId);
component.viewRawData(mockNode);
expect(component.selectedNode()).toEqual(mockNode);
expect(component.rawDataVisible()).toBeTrue();
expect(component.selectedNodeRawJson()).toContain('"id": "n1"');
});
describe('Legend', () => {
beforeEach(() => {
component.chains.set([mockChain]);
@@ -410,4 +467,3 @@ describe('ProvenanceVisualizationComponent', () => {
});
});
});

View File

@@ -3,8 +3,10 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ProofChainViewerComponent, ChainNode } from '../../shared/components/proof-chain-viewer.component';
import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive';
import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component';
@@ -90,6 +92,12 @@ export interface ProvenanceChain {
</div>
</div>
@if (chainMessage(); as message) {
<div class="chain-banner" role="status">
{{ message }}
</div>
}
<!-- Provenance Chain Visualization -->
<div class="chain-visualization">
@for (node of chain.nodes; track node.id; let i = $index; let last = $last) {
@@ -208,6 +216,12 @@ export interface ProvenanceChain {
}
</div>
</div>
@if (rawDataVisible()) {
<div class="detail-section">
<h4>Raw Data</h4>
<pre class="raw-data">{{ selectedNodeRawJson() }}</pre>
</div>
}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" (click)="closeNodeDetail()">Close</button>
@@ -263,6 +277,16 @@ export interface ProvenanceChain {
}
}
.chain-banner {
margin-bottom: 1rem;
border: 1px solid var(--color-status-info-border, #93c5fd);
background: var(--color-status-info-bg, #eff6ff);
color: var(--color-status-info-text, #1d4ed8);
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.chain-summary {
display: flex;
align-items: center;
@@ -537,6 +561,17 @@ export interface ProvenanceChain {
padding: 1.5rem;
}
.raw-data {
margin: 0;
padding: 0.85rem 1rem;
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
overflow: auto;
font-size: 0.75rem;
line-height: 1.5;
}
.modal-footer {
display: flex;
justify-content: flex-end;
@@ -596,8 +631,12 @@ export interface ProvenanceChain {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProvenanceVisualizationComponent {
private readonly route = inject(ActivatedRoute);
readonly selectedArtifactId = signal('');
readonly selectedNode = signal<ProvenanceNode | null>(null);
readonly rawDataVisible = signal(false);
readonly chainMessage = signal<string | null>(null);
readonly chains = signal<ProvenanceChain[]>([
{
@@ -737,6 +776,10 @@ export class ProvenanceVisualizationComponent {
return this.chains().find((c) => c.artifactId === artifactId) || null;
});
readonly selectedNodeRawJson = computed(() =>
this.selectedNode() ? JSON.stringify(this.selectedNode(), null, 2) : '',
);
/** Map provenance nodes to shared proof-chain viewer ChainNode format. */
readonly proofChainNodes = computed((): ChainNode[] => {
const chain = this.selectedChain();
@@ -766,6 +809,8 @@ export class ProvenanceVisualizationComponent {
const select = event.target as HTMLSelectElement;
this.selectedArtifactId.set(select.value);
this.selectedNode.set(null);
this.rawDataVisible.set(false);
this.chainMessage.set(null);
}
getNodeIcon(type: ProvenanceNode['type']): string {
@@ -802,22 +847,25 @@ export class ProvenanceVisualizationComponent {
}
viewNodeDetails(node: ProvenanceNode | undefined): void {
if (node) this.selectedNode.set(node);
if (node) {
this.selectedNode.set(node);
this.rawDataVisible.set(false);
}
}
viewRawData(node: ProvenanceNode): void {
console.log('Viewing raw data for node:', node.id);
this.selectedNode.set(node);
this.rawDataVisible.set(true);
}
closeNodeDetail(): void {
this.selectedNode.set(null);
this.rawDataVisible.set(false);
}
verifyChain(): void {
const chain = this.selectedChain();
if (chain) {
console.log('Verifying chain:', chain.artifactId);
// In real implementation, call API to verify chain
this.chains.update(chains =>
chains.map(c =>
c.artifactId === chain.artifactId
@@ -825,13 +873,17 @@ export class ProvenanceVisualizationComponent {
: c
)
);
this.chainMessage.set(`Evidence chain verified for ${chain.artifactRef}.`);
}
}
exportChain(): void {
const chain = this.selectedChain();
if (chain) {
console.log('Exporting chain:', chain.artifactId);
this.triggerDownload(
new Blob([JSON.stringify(chain, null, 2)], { type: 'application/json' }),
`${chain.artifactId}-provenance.json`,
);
}
}
@@ -844,4 +896,24 @@ export class ProvenanceVisualizationComponent {
minute: '2-digit',
});
}
constructor() {
this.route.queryParamMap.subscribe((params) => {
const artifactId = params.get('artifactId')?.trim();
if (artifactId) {
this.selectedArtifactId.set(artifactId);
this.selectedNode.set(null);
this.rawDataVisible.set(false);
}
});
}
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -1,11 +1,15 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { ReplayControlsComponent } from './replay-controls.component';
import { ReplayRequest, ReplayResult } from './evidence-export.models';
describe('ReplayControlsComponent', () => {
let fixture: ComponentFixture<ReplayControlsComponent>;
let component: ReplayControlsComponent;
let mockRouter: { navigate: jasmine.Spy; createUrlTree: jasmine.Spy; serializeUrl: jasmine.Spy };
const mockRequest: ReplayRequest = {
id: 'rr-test-001',
@@ -29,8 +33,24 @@ describe('ReplayControlsComponent', () => {
};
beforeEach(async () => {
mockRouter = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
createUrlTree: jasmine.createSpy('createUrlTree').and.callFake((commands, extras) => ({ commands, extras })),
serializeUrl: jasmine.createSpy('serializeUrl').and.callFake((value) => JSON.stringify(value)),
};
await TestBed.configureTestingModule({
imports: [FormsModule, ReplayControlsComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap({}) },
queryParamMap: of(convertToParamMap({})),
},
},
{ provide: Router, useValue: mockRouter },
],
}).compileComponents();
fixture = TestBed.createComponent(ReplayControlsComponent);
@@ -67,7 +87,13 @@ describe('ReplayControlsComponent', () => {
expect(submitBtn.disabled).toBe(false);
});
it('should create new replay request', () => {
it('should create and complete a new replay request', () => {
spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => {
if (typeof handler === 'function') {
handler();
}
return 0 as unknown as number;
});
component.requests.set([]);
component.replayTarget = 'verdict-new';
component.replayReason = 'Test reason';
@@ -77,7 +103,8 @@ describe('ReplayControlsComponent', () => {
expect(component.requests().length).toBe(1);
expect(component.requests()[0].verdictId).toBe('verdict-new');
expect(component.requests()[0].reason).toBe('Test reason');
expect(component.requests()[0].status).toBe('pending');
expect(component.requests()[0].status).toBe('completed');
expect(component.results().has(component.requests()[0].id)).toBeTrue();
});
it('should clear form after request', () => {
@@ -193,13 +220,20 @@ describe('ReplayControlsComponent', () => {
describe('Retry functionality', () => {
it('should retry failed request', () => {
spyOn(window, 'setTimeout').and.callFake((handler: TimerHandler) => {
if (typeof handler === 'function') {
handler();
}
return 0 as unknown as number;
});
const failedRequest = { ...mockRequest, status: 'failed' as const };
component.requests.set([failedRequest]);
component.retryReplay(failedRequest);
const updated = component.requests().find(r => r.id === failedRequest.id);
expect(updated?.status).toBe('pending');
expect(updated?.status).toBe('completed');
expect(component.results().has(failedRequest.id)).toBeTrue();
});
});
@@ -295,7 +329,35 @@ describe('ReplayControlsComponent', () => {
component.openQuickVerify('test-artifact-123');
component.onQuickVerifyComplete({ verified: true, steps: [], totalDurationMs: 500 } as any);
expect(component.quickVerifySummary()).toContain('Quick verify passed');
expect(component.quickVerifyOpen()).toBeFalse();
});
});
it('opens a full comparison modal for a replay result', () => {
component.viewFullComparison(mockResult);
expect(component.comparisonResult()).toEqual(mockResult);
});
it('exports a replay report as a download', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:report');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const clickSpy = jasmine.createSpy('click');
const nativeCreateElement = document.createElement.bind(document);
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
if (tagName.toLowerCase() === 'a') {
return {
click: clickSpy,
} as unknown as HTMLAnchorElement;
}
return nativeCreateElement(tagName);
}) as typeof document.createElement);
component.exportReport(mockResult);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:report');
});
});

View File

@@ -45,6 +45,12 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component
}
</header>
@if (quickVerifySummary(); as summary) {
<div class="status-banner" role="status">
{{ summary }}
</div>
}
<!-- Request New Replay -->
<section class="replay-request-section">
<h2>Request Replay</h2>
@@ -240,6 +246,51 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component
(verifyComplete)="onQuickVerifyComplete($event)"
/>
@if (comparisonResult(); as comparison) {
<div class="modal-overlay" (click)="closeComparison()">
<div class="comparison-modal" (click)="$event.stopPropagation()">
<div class="comparison-modal__header">
<div>
<h3>Replay Comparison</h3>
<p>{{ comparison.imageRef }}</p>
</div>
<button class="btn btn-secondary" type="button" (click)="closeComparison()">
Close
</button>
</div>
<div class="comparison-modal__summary">
<div class="comparison-pill">
Original
<code>{{ comparison.originalVerdictId }}</code>
</div>
<div class="comparison-pill">
Replay
<code>{{ comparison.replayVerdictId }}</code>
</div>
<div class="comparison-pill">
Duration
<span>{{ formatDuration(comparison.durationMs) }}</span>
</div>
</div>
<div class="comparison-modal__body">
@if (comparison.differences.length === 0) {
<p class="comparison-modal__empty">No differences were detected for this replay.</p>
} @else {
@for (diff of comparison.differences; track diff.field) {
<div class="comparison-diff" [class]="'comparison-diff--' + diff.severity">
<strong>{{ diff.field }}</strong>
<span>Original: <code>{{ diff.originalValue }}</code></span>
<span>Replay: <code>{{ diff.replayValue }}</code></span>
</div>
}
}
</div>
</div>
</div>
}
<!-- Determinism Dashboard -->
<section class="determinism-section">
<h2>Determinism Overview</h2>
@@ -713,6 +764,107 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component
}
}
.status-banner {
border: 1px solid var(--color-status-info-border, #93c5fd);
background: var(--color-status-info-bg, #eff6ff);
color: var(--color-status-info-text, #1d4ed8);
border-radius: var(--radius-md);
padding: 0.75rem 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.comparison-modal {
width: min(720px, 100%);
max-height: 80vh;
overflow: auto;
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.2);
padding: 1.25rem;
display: grid;
gap: 1rem;
}
.comparison-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.comparison-modal__header h3 {
margin: 0 0 0.25rem;
}
.comparison-modal__header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.comparison-modal__summary {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.comparison-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: 999px;
padding: 0.45rem 0.75rem;
background: var(--color-surface-secondary);
font-size: 0.875rem;
}
.comparison-modal__body {
display: grid;
gap: 0.75rem;
}
.comparison-modal__empty {
margin: 0;
color: var(--color-text-secondary);
}
.comparison-diff {
display: grid;
gap: 0.35rem;
border: 1px solid var(--color-border-primary);
border-left-width: 4px;
border-radius: var(--radius-md);
padding: 0.9rem 1rem;
background: var(--color-surface-secondary);
font-size: 0.875rem;
}
.comparison-diff--info {
border-left-color: var(--color-status-info, #2563eb);
}
.comparison-diff--warning {
border-left-color: var(--color-status-warning-text, #b45309);
}
.comparison-diff--error {
border-left-color: var(--color-status-error-text, #b91c1c);
}
.empty-state {
text-align: center;
padding: 3rem;
@@ -731,6 +883,8 @@ export class ReplayControlsComponent {
readonly quickVerifyOpen = signal(false);
readonly quickVerifyArtifactId = signal<string | null>(null);
readonly quickVerifySummary = signal<string | null>(null);
readonly comparisonResult = signal<ReplayResult | null>(null);
readonly expandedRequest = signal<string | null>(null);
readonly releaseId = signal<string | null>(
@@ -893,9 +1047,11 @@ export class ReplayControlsComponent {
reason: this.replayReason,
};
this.requests.update(requests => [newRequest, ...requests]);
this.expandedRequest.set(newRequest.id);
this.replayTarget = '';
this.replayReason = '';
console.log('Replay requested:', newRequest.id);
this.quickVerifySummary.set(null);
this.scheduleReplayLifecycle(newRequest.id);
}
retryReplay(request: ReplayRequest): void {
@@ -904,6 +1060,8 @@ export class ReplayControlsComponent {
r.id === request.id ? { ...r, status: 'pending' as ReplayStatus } : r
)
);
this.expandedRequest.set(request.id);
this.scheduleReplayLifecycle(request.id);
}
openQuickVerify(artifactId: string): void {
@@ -917,15 +1075,24 @@ export class ReplayControlsComponent {
}
onQuickVerifyComplete(result: VerifyResult): void {
console.log('Quick verify complete:', result.verified);
const artifactId = this.quickVerifyArtifactId();
this.quickVerifySummary.set(
result.verified
? `Quick verify passed for ${artifactId ?? 'the selected artifact'}.`
: `Quick verify failed for ${artifactId ?? 'the selected artifact'}: ${result.failureReason ?? 'verification reported an error'}.`,
);
this.closeQuickVerify();
}
viewFullComparison(result: ReplayResult): void {
console.log('Viewing full comparison:', result.requestId);
this.comparisonResult.set(result);
}
exportReport(result: ReplayResult): void {
console.log('Exporting report:', result.requestId);
this.triggerDownload(
new Blob([JSON.stringify(result, null, 2)], { type: 'application/json' }),
`${result.requestId}-replay-report.json`,
);
}
formatDateTime(dateStr: string): string {
@@ -974,4 +1141,57 @@ export class ReplayControlsComponent {
})
);
}
closeComparison(): void {
this.comparisonResult.set(null);
}
private scheduleReplayLifecycle(requestId: string): void {
window.setTimeout(() => {
this.requests.update((requests) =>
requests.map((request) =>
request.id === requestId ? { ...request, status: 'running' as ReplayStatus } : request,
),
);
}, 150);
window.setTimeout(() => {
const request = this.requests().find((entry) => entry.id === requestId);
if (!request) {
return;
}
this.requests.update((requests) =>
requests.map((entry) =>
entry.id === requestId ? { ...entry, status: 'completed' as ReplayStatus } : entry,
),
);
const completedResult: ReplayResult = {
requestId,
originalVerdictId: request.verdictId,
replayVerdictId: `${request.verdictId}-replay`,
imageRef: request.imageRef,
completedAt: new Date().toISOString(),
durationMs: 1200,
matchesOriginal: true,
differences: [],
};
this.results.update((results) => {
const next = new Map(results);
next.set(requestId, completedResult);
return next;
});
}, 900);
}
private triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -1,370 +1,254 @@
// -----------------------------------------------------------------------------
// stella-bundle-export-button.component.spec.ts
// Sprint: SPRINT_20260125_005_FE_stella_bundle_export
// Unit tests for StellaBundle export button
// -----------------------------------------------------------------------------
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import {
StellaBundleExportButtonComponent,
ExportState,
} from './stella-bundle-export-button.component';
import { StellaBundleExportResult } from '../evidence-export.models';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../../core/api/audit-bundles.client';
import type { AuditBundleJobResponse } from '../../../core/api/audit-bundles.models';
import { StellaBundleExportButtonComponent } from './stella-bundle-export-button.component';
describe('StellaBundleExportButtonComponent', () => {
let fixture: ComponentFixture<StellaBundleExportButtonComponent>;
let component: StellaBundleExportButtonComponent;
let auditBundlesApi: jasmine.SpyObj<AuditBundlesApi>;
const queuedJob: AuditBundleJobResponse = {
bundleId: 'bundle-live-001',
status: 'queued',
createdAt: '2026-03-11T10:00:00Z',
subject: { type: 'OTHER', name: 'artifact-demo-123', digest: {} },
statusUrl: '/v1/audit-bundles/bundle-live-001',
traceId: 'trace-live-001',
};
const completedJob: AuditBundleJobResponse = {
...queuedJob,
status: 'completed',
completedAt: '2026-03-11T10:00:05Z',
sha256: 'sha256:bundle-live-001',
downloadUrl: '/v1/audit-bundles/bundle-live-001/download',
ociReference: 'oci://registry.example.com/audit@sha256:bundle-live-001',
};
beforeEach(async () => {
auditBundlesApi = {
listBundles: jasmine.createSpy('listBundles'),
createBundle: jasmine.createSpy('createBundle').and.returnValue(of(queuedJob)),
getBundle: jasmine.createSpy('getBundle').and.returnValue(of(completedJob)),
downloadBundle: jasmine.createSpy('downloadBundle'),
} as jasmine.SpyObj<AuditBundlesApi>;
await TestBed.configureTestingModule({
imports: [StellaBundleExportButtonComponent],
providers: [
{ provide: AUDIT_BUNDLES_API, useValue: auditBundlesApi },
],
}).compileComponents();
fixture = TestBed.createComponent(StellaBundleExportButtonComponent);
component = fixture.componentInstance;
component.artifactId = 'test-artifact-123';
component.artifactId = 'artifact-demo-123';
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
it('renders the idle CTA and advisory tooltip text', () => {
const button = fixture.nativeElement.querySelector('.stella-bundle-btn') as HTMLButtonElement;
const badge = fixture.nativeElement.querySelector('.oci-badge') as HTMLElement;
expect(button.textContent).toContain('Export StellaBundle');
expect(button.getAttribute('title')).toBe(
'Export StellaBundle - creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).',
);
expect(badge.textContent).toContain('OCI');
});
describe('SB-001: Button rendering', () => {
it('renders button with correct text', () => {
fixture.detectChanges();
it('shows the exporting state while the request is still running', () => {
spyOn(component as any, 'executeExport').and.returnValue(new Promise(() => {}));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button).toBeTruthy();
expect(button.textContent).toContain('Export StellaBundle');
});
const button = fixture.nativeElement.querySelector('.stella-bundle-btn') as HTMLButtonElement;
button.click();
fixture.detectChanges();
it('has correct tooltip per advisory spec', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.getAttribute('title')).toBe(
'Export StellaBundle — creates signed audit pack (DSSE+Rekor) suitable for auditor delivery (OCI referrer).'
);
});
it('shows OCI badge by default', () => {
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.oci-badge');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('OCI');
});
it('hides OCI badge when showOciBadge is false', () => {
component.showOciBadge = false;
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.oci-badge');
expect(badge).toBeFalsy();
});
it('renders in compact mode (icon only)', () => {
component.compact = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.classList.contains('compact')).toBeTrue();
const label = fixture.nativeElement.querySelector('.btn-label');
const style = window.getComputedStyle(label);
// In compact mode, label should be hidden via CSS
expect(button.classList.contains('compact')).toBeTrue();
});
expect(component.state()).toBe('exporting');
expect(button.disabled).toBeTrue();
expect(button.textContent).toContain('Exporting...');
});
describe('SB-001: Export flow', () => {
it('emits exportStarted on click', () => {
fixture.detectChanges();
let emittedId = '';
component.exportStarted.subscribe((id) => (emittedId = id));
it('creates and polls a live audit bundle, then emits the canonical bundle result', async () => {
spyOn(component as any, 'delay').and.returnValue(Promise.resolve());
spyOn(window, 'setTimeout').and.returnValue(0 as unknown as number);
const telemetrySpy = spyOn(console, 'log');
let emittedResult = null as ReturnType<typeof component['lastResult']> | null;
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
expect(emittedId).toBe('test-artifact-123');
component.exportComplete.subscribe((result) => {
emittedResult = result;
});
it('shows loading state during export', () => {
fixture.detectChanges();
await component.onExport();
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(component.state()).toBe('exporting');
expect(button.textContent).toContain('Exporting...');
expect(button.classList.contains('exporting')).toBeTrue();
expect(auditBundlesApi.createBundle).toHaveBeenCalledWith({
subject: { type: 'OTHER', name: 'artifact-demo-123', digest: {} },
contents: {
vulnReports: true,
sbom: true,
vex: true,
policyEvals: true,
attestations: true,
},
});
it('disables button during export', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(button.disabled).toBeTrue();
expect(auditBundlesApi.getBundle).toHaveBeenCalledWith('bundle-live-001', {
traceId: 'trace-live-001',
});
it('emits exportComplete on success', fakeAsync(() => {
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500); // Wait for mock export
fixture.detectChanges();
expect(result).toBeTruthy();
expect(result!.success).toBeTrue();
expect(result!.artifactId).toBe('test-artifact-123');
expect(emittedResult).toEqual(jasmine.objectContaining({
success: true,
bundleId: 'bundle-live-001',
exportId: 'bundle-live-001',
artifactId: 'artifact-demo-123',
ociReference: completedJob.ociReference,
downloadUrl: completedJob.downloadUrl,
}));
it('prevents multiple concurrent exports', () => {
fixture.detectChanges();
let emitCount = 0;
component.exportStarted.subscribe(() => emitCount++);
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
button.click();
button.click();
expect(emitCount).toBe(1);
});
it('respects disabled input', () => {
component.disabled = true;
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.disabled).toBeTrue();
let emitCount = 0;
component.exportStarted.subscribe(() => emitCount++);
button.click();
expect(emitCount).toBe(0);
});
expect(component.showToast()).toBeTrue();
expect(component.state()).toBe('success');
expect(telemetrySpy).toHaveBeenCalledWith(
'[Telemetry]',
jasmine.objectContaining({
event: 'stella.bundle.exported',
}),
);
});
describe('SB-004: Post-export toast', () => {
it('shows toast after successful export', fakeAsync(() => {
fixture.detectChanges();
it('maps digested artifact references into IMAGE subjects', async () => {
component.artifactId = 'registry.example.com/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
spyOn(component as any, 'delay').and.returnValue(Promise.resolve());
spyOn(window, 'setTimeout').and.returnValue(0 as unknown as number);
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
await component.onExport();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeTruthy();
expect(toast.classList.contains('success')).toBeTrue();
}));
it('displays OCI reference in monospace', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const ociRef = fixture.nativeElement.querySelector('.oci-ref');
expect(ociRef).toBeTruthy();
expect(ociRef.textContent).toContain('oci://');
}));
it('has Copy reference button', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector(
'.toast-actions .toast-btn'
);
expect(copyBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy reference');
}));
it('toast persists until dismissed', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
// Wait additional time - toast should still be visible
tick(5000);
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeTruthy();
}));
it('dismisses toast on close button click', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const closeBtn = fixture.nativeElement.querySelector('.toast-close');
closeBtn.click();
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast).toBeFalsy();
}));
it('has View details link', fakeAsync(() => {
fixture.detectChanges();
let emittedResult: StellaBundleExportResult | null = null;
component.viewDetails.subscribe((r) => (emittedResult = r));
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const detailsBtn = fixture.nativeElement.querySelector('.toast-btn-link');
expect(detailsBtn).toBeTruthy();
expect(detailsBtn.textContent).toContain('View details');
detailsBtn.click();
expect(emittedResult).toBeTruthy();
expect(auditBundlesApi.createBundle).toHaveBeenCalledWith(jasmine.objectContaining({
subject: {
type: 'IMAGE',
name: 'registry.example.com/app',
digest: { sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
},
}));
});
describe('SB-002: Include options', () => {
it('uses StellaBundle preset by default', fakeAsync(() => {
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
it('honors custom include options in the audit bundle request and result manifest', async () => {
component.includeOptions = {
canonicalizedSbom: false,
replayLog: false,
vexDecisions: false,
};
spyOn(component as any, 'delay').and.returnValue(Promise.resolve());
spyOn(window, 'setTimeout').and.returnValue(0 as unknown as number);
let emittedFiles: string[] = [];
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(result!.includedFiles).toContain('sbom.cdx.json');
expect(result!.includedFiles).toContain('dsse-envelope.json');
expect(result!.includedFiles).toContain('rekor-receipt.json');
expect(result!.includedFiles).toContain('replay_log.json');
}));
it('accepts custom include options', () => {
component.includeOptions = {
replayLog: false,
vexDecisions: false,
};
fixture.detectChanges();
// Custom options should be merged with defaults
expect(component.includeOptions.replayLog).toBeFalse();
expect(component.includeOptions.vexDecisions).toBeFalse();
component.exportComplete.subscribe((result) => {
emittedFiles = result.includedFiles;
});
await component.onExport();
expect(auditBundlesApi.createBundle).toHaveBeenCalledWith(jasmine.objectContaining({
contents: jasmine.objectContaining({
sbom: false,
vex: false,
}),
}));
expect(emittedFiles).not.toContain('sbom.cdx.json');
expect(emittedFiles).not.toContain('replay_log.json');
expect(emittedFiles).not.toContain('vex-decisions.json');
expect(emittedFiles).toContain('dsse-envelope.json');
});
describe('SB-006: Telemetry', () => {
it('logs telemetry event on successful export', fakeAsync(() => {
fixture.detectChanges();
const consoleSpy = spyOn(console, 'log');
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(consoleSpy).toHaveBeenCalledWith(
'[Telemetry]',
jasmine.objectContaining({
event: 'stella.bundle.exported',
properties: jasmine.objectContaining({
artifact_id: 'test-artifact-123',
format: 'oci',
includes_replay_log: true,
includes_dsse: true,
includes_rekor: true,
success: true,
}),
})
);
it('emits an error toast when the backend returns a failed bundle', async () => {
auditBundlesApi.getBundle.and.returnValue(of({
...queuedJob,
status: 'failed',
error: 'bundle export failed',
}));
spyOn(component as any, 'delay').and.returnValue(Promise.resolve());
spyOn(window, 'setTimeout').and.returnValue(0 as unknown as number);
const errorSpy = jasmine.createSpy('exportError');
component.exportError.subscribe(errorSpy);
it('includes duration in telemetry', fakeAsync(() => {
fixture.detectChanges();
const consoleSpy = spyOn(console, 'log');
await component.onExport();
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
const telemetryCall = consoleSpy.calls.mostRecent();
const telemetry = telemetryCall.args[1];
expect(telemetry.properties.duration_ms).toBeGreaterThan(0);
}));
expect(component.state()).toBe('error');
expect(component.showToast()).toBeTrue();
expect(errorSpy).toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('bundle export failed');
});
describe('Accessibility', () => {
it('has correct aria-label in idle state', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
expect(button.getAttribute('aria-label')).toContain('Export StellaBundle');
});
it('sets aria-busy during export', () => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-busy')).toBe('true');
});
it('toast has role=alert', fakeAsync(() => {
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
fixture.detectChanges();
const toast = fixture.nativeElement.querySelector('.export-toast');
expect(toast.getAttribute('role')).toBe('alert');
expect(toast.getAttribute('aria-live')).toBe('polite');
it('guards against disabled and concurrent export attempts', async () => {
const executeSpy = spyOn(component as any, 'executeExport').and.returnValue(Promise.resolve({
success: true,
bundleId: 'bundle-live-001',
exportId: 'bundle-live-001',
artifactId: 'artifact-demo-123',
format: 'oci',
checksumSha256: 'sha256:test',
sizeBytes: 0,
includedFiles: [],
durationMs: 1,
completedAt: '2026-03-11T10:00:05Z',
}));
component.disabled = true;
await component.onExport();
expect(executeSpy).not.toHaveBeenCalled();
component.disabled = false;
component.state.set('exporting');
await component.onExport();
expect(executeSpy).not.toHaveBeenCalled();
});
describe('Format options', () => {
it('defaults to OCI format', () => {
expect(component.format).toBe('oci');
it('copies the OCI reference to the clipboard', async () => {
const clipboardWriteSpy = jasmine.createSpy('writeText').and.returnValue(Promise.resolve());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteSpy },
});
component.lastResult.set({
success: true,
bundleId: 'bundle-live-001',
exportId: 'bundle-live-001',
artifactId: 'artifact-demo-123',
format: 'oci',
ociReference: completedJob.ociReference,
checksumSha256: 'sha256:test',
sizeBytes: 0,
includedFiles: [],
durationMs: 1,
completedAt: '2026-03-11T10:00:05Z',
});
it('supports tar.gz format', fakeAsync(() => {
component.format = 'tar.gz';
fixture.detectChanges();
let result: StellaBundleExportResult | null = null;
component.exportComplete.subscribe((r) => (result = r));
await component.copyOciReference();
const button = fixture.nativeElement.querySelector('.stella-bundle-btn');
button.click();
tick(2500);
expect(clipboardWriteSpy).toHaveBeenCalledWith(completedJob.ociReference);
expect(component.copied()).toBeTrue();
});
expect(result!.format).toBe('tar.gz');
expect(result!.downloadUrl).toBeTruthy();
expect(result!.ociReference).toBeFalsy();
}));
it('emits the bundle payload when bundle details are requested', () => {
const result = {
success: true,
bundleId: 'bundle-live-001',
exportId: 'bundle-live-001',
artifactId: 'artifact-demo-123',
format: 'oci' as const,
checksumSha256: 'sha256:test',
sizeBytes: 0,
includedFiles: [],
durationMs: 1,
completedAt: '2026-03-11T10:00:05Z',
};
component.lastResult.set(result);
const viewDetailsSpy = jasmine.createSpy('viewDetails');
component.viewDetails.subscribe(viewDetailsSpy);
component.viewBundleDetails();
expect(viewDetailsSpy).toHaveBeenCalledWith(result);
});
});

View File

@@ -12,9 +12,17 @@ import {
EventEmitter,
inject,
Input,
OnDestroy,
Output,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { AUDIT_BUNDLES_API } from '../../../core/api/audit-bundles.client';
import type {
AuditBundleCreateRequest,
AuditBundleJobResponse,
BundleSubjectRef,
} from '../../../core/api/audit-bundles.models';
import {
ExportFormat,
StellaBundleExportRequest,
@@ -28,6 +36,9 @@ import {
*/
export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
const STELLA_BUNDLE_POLL_DELAY_MS = 750;
const STELLA_BUNDLE_MAX_POLLS = 12;
/**
* StellaBundle Export Button Component (SB-001)
*
@@ -404,7 +415,9 @@ export type ExportState = 'idle' | 'exporting' | 'success' | 'error';
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StellaBundleExportButtonComponent {
export class StellaBundleExportButtonComponent implements OnDestroy {
private readonly auditBundlesApi = inject(AUDIT_BUNDLES_API);
/** Artifact ID to export */
@Input({ required: true }) artifactId!: string;
@@ -442,6 +455,7 @@ export class StellaBundleExportButtonComponent {
readonly copied = signal(false);
private exportStartTime = 0;
private destroyed = false;
/** Tooltip text per advisory spec */
readonly tooltipText =
@@ -498,6 +512,7 @@ export class StellaBundleExportButtonComponent {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.lastResult.set({
success: false,
bundleId: '',
exportId: '',
artifactId: this.artifactId,
format: this.format,
@@ -513,6 +528,10 @@ export class StellaBundleExportButtonComponent {
}
}
ngOnDestroy(): void {
this.destroyed = true;
}
/**
* Build export request with StellaBundle preset options
*/
@@ -534,44 +553,35 @@ export class StellaBundleExportButtonComponent {
}
/**
* Execute the export (mock implementation - integrate with actual service)
* Execute the export against the real audit-bundle contract and poll until the
* bundle reaches a terminal state. StellaBundle is the canonical audit-bundle
* experience, so the UI must surface the backend-issued bundle identifier.
*/
private async executeExport(
request: StellaBundleExportRequest
): Promise<StellaBundleExportResult> {
// TODO: Replace with actual ExportService call
// return this.exportService.exportStellaBundle(request);
// Mock implementation for UI development
await new Promise((resolve) => setTimeout(resolve, 2000));
const refDigest = this.generateMockSha(`${request.artifactId}|${request.format}|oci-reference`);
const checksum = this.generateMockSha(`${request.artifactId}|${request.format}|checksum`);
const exportIdSuffix = this.generateMockSha(`${request.artifactId}|${request.format}|export-id`).slice(0, 12);
const mockOciRef = `oci://registry.example.com/artifacts/${request.artifactId}@sha256:${refDigest}`;
const createdJob = await firstValueFrom(
this.auditBundlesApi.createBundle(this.buildAuditBundleRequest(request)),
);
const completedJob = await this.waitForBundleCompletion(createdJob);
return {
success: true,
exportId: `exp-${exportIdSuffix}`,
success: completedJob.status === 'completed',
bundleId: completedJob.bundleId,
exportId: completedJob.bundleId,
artifactId: request.artifactId,
format: request.format,
ociReference: request.format === 'oci' ? mockOciRef : undefined,
downloadUrl:
request.format !== 'oci'
? `/api/v1/exports/download/${request.artifactId}.${request.format}`
: undefined,
checksumSha256: `sha256:${checksum}`,
sizeBytes: 2567890,
includedFiles: [
'sbom.cdx.json',
'dsse-envelope.json',
'rekor-receipt.json',
'replay_log.json',
'vex-decisions.json',
'policy-evaluations.json',
],
ociReference: completedJob.ociReference,
downloadUrl: completedJob.downloadUrl,
checksumSha256: completedJob.sha256 ?? '',
sizeBytes: 0,
includedFiles: this.buildIncludedFiles(request.includeOptions),
durationMs: Date.now() - this.exportStartTime,
completedAt: new Date().toISOString(),
completedAt: completedJob.completedAt ?? new Date().toISOString(),
errorMessage:
completedJob.status === 'failed'
? completedJob.error ?? `Bundle ${completedJob.bundleId} failed to complete.`
: undefined,
};
}
@@ -630,23 +640,96 @@ export class StellaBundleExportButtonComponent {
}
}
/**
* Generate mock SHA256 for demo
*/
private generateMockSha(seed: string): string {
const chars = 'abcdef0123456789';
let state = 2166136261;
for (const ch of seed) {
state ^= ch.charCodeAt(0);
state = Math.imul(state, 16777619);
private buildAuditBundleRequest(request: StellaBundleExportRequest): AuditBundleCreateRequest {
return {
subject: this.buildSubjectRef(request.artifactId),
contents: {
vulnReports: true,
sbom: request.includeOptions.canonicalizedSbom,
vex: request.includeOptions.vexDecisions,
policyEvals: request.includeOptions.policyEvaluations,
attestations: request.includeOptions.dsseEnvelope || request.includeOptions.rekorTileReceipt,
},
};
}
private buildSubjectRef(artifactId: string): BundleSubjectRef {
const trimmedArtifactId = artifactId.trim();
const digestMatch = trimmedArtifactId.match(/@sha256:([a-f0-9]{12,64})$/i);
if (digestMatch) {
return {
type: 'IMAGE',
name: trimmedArtifactId.slice(0, digestMatch.index),
digest: { sha256: digestMatch[1] },
};
}
let digest = '';
for (let i = 0; i < 64; i++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
digest += chars[state & 0x0f];
return {
type: 'OTHER',
name: trimmedArtifactId,
digest: {},
};
}
private buildIncludedFiles(options: StellaBundleIncludeOptions): string[] {
const files: string[] = [];
if (options.canonicalizedSbom) {
files.push('sbom.cdx.json');
}
return digest;
if (options.dsseEnvelope) {
files.push('dsse-envelope.json');
}
if (options.rekorTileReceipt) {
files.push('rekor-receipt.json');
}
if (options.replayLog) {
files.push('replay_log.json');
}
if (options.vexDecisions) {
files.push('vex-decisions.json');
}
if (options.policyEvaluations) {
files.push('policy-evaluations.json');
}
return files;
}
private async waitForBundleCompletion(initialJob: AuditBundleJobResponse): Promise<AuditBundleJobResponse> {
let currentJob = initialJob;
for (let attempt = 0; attempt < STELLA_BUNDLE_MAX_POLLS; attempt++) {
if (currentJob.status === 'completed' || currentJob.status === 'failed') {
return currentJob;
}
if (this.destroyed) {
throw new Error('Bundle export was interrupted before completion.');
}
await this.delay(STELLA_BUNDLE_POLL_DELAY_MS);
currentJob = await firstValueFrom(
this.auditBundlesApi.getBundle(initialJob.bundleId, { traceId: initialJob.traceId }),
);
}
return {
...currentJob,
status: 'failed',
error: currentJob.error ?? `Timed out waiting for bundle ${initialJob.bundleId} to finish exporting.`,
};
}
private async delay(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
}
}

View File

@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../app/core/api/audit-bundles.client';
import { StellaBundleExportResult } from '../../app/features/evidence-export/evidence-export.models';
import { StellaBundleExportButtonComponent } from '../../app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component';
@@ -9,6 +11,7 @@ describe('stellabundle-export-button-component behavior', () => {
const successResult: StellaBundleExportResult = {
success: true,
bundleId: 'bundle-deterministic01',
exportId: 'exp-deterministic01',
artifactId: 'artifact-prod-001',
format: 'oci',
@@ -30,6 +33,30 @@ describe('stellabundle-export-button-component behavior', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StellaBundleExportButtonComponent],
providers: [
{
provide: AUDIT_BUNDLES_API,
useValue: {
listBundles: jasmine.createSpy('listBundles'),
createBundle: jasmine.createSpy('createBundle').and.returnValue(of({
bundleId: 'bundle-deterministic01',
status: 'queued',
createdAt: '2026-02-11T18:00:00Z',
subject: { type: 'OTHER', name: 'artifact-prod-001', digest: {} },
traceId: 'trace-deterministic01',
})),
getBundle: jasmine.createSpy('getBundle').and.returnValue(of({
bundleId: 'bundle-deterministic01',
status: 'completed',
createdAt: '2026-02-11T18:00:00Z',
completedAt: '2026-02-11T18:00:02Z',
subject: { type: 'OTHER', name: 'artifact-prod-001', digest: {} },
traceId: 'trace-deterministic01',
})),
downloadBundle: jasmine.createSpy('downloadBundle'),
} as jasmine.SpyObj<AuditBundlesApi>,
},
],
}).compileComponents();
fixture = TestBed.createComponent(StellaBundleExportButtonComponent);
@@ -168,13 +195,17 @@ describe('stellabundle-export-button-component behavior', () => {
expect(viewDetailsSpy).toHaveBeenCalledWith(successResult);
});
it('generates deterministic mock SHA values for identical seeds', () => {
const first = (component as any).generateMockSha('artifact-prod-001|oci|checksum') as string;
const second = (component as any).generateMockSha('artifact-prod-001|oci|checksum') as string;
const third = (component as any).generateMockSha('artifact-prod-001|zip|checksum') as string;
it('builds stable audit-bundle subject refs for plain and digested artifacts', () => {
const plain = (component as any).buildSubjectRef('artifact-prod-001') as { type: string; name: string; digest: Record<string, string> };
const digested = (component as any).buildSubjectRef(
'registry.example.com/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
) as { type: string; name: string; digest: Record<string, string> };
expect(first).toBe(second);
expect(first).toMatch(/^[a-f0-9]{64}$/);
expect(third).not.toBe(first);
expect(plain).toEqual({ type: 'OTHER', name: 'artifact-prod-001', digest: {} });
expect(digested).toEqual({
type: 'IMAGE',
name: 'registry.example.com/app',
digest: { sha256: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
});
});
});

View File

@@ -16,6 +16,11 @@
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
"src/app/features/evidence-export/evidence-bundles.component.spec.ts",
"src/app/features/evidence-export/export-center.component.spec.ts",
"src/app/features/evidence-export/provenance-visualization.component.spec.ts",
"src/app/features/evidence-export/replay-controls.component.spec.ts",
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",