From f0b2ef331941eb0535091ee15b5664d20f7af1ae Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 11 Mar 2026 18:21:47 +0200 Subject: [PATCH] Align live evidence export with audit bundles --- ...idence_export_bundle_contract_alignment.md | 77 +++ docs/modules/export-center/architecture.md | 16 +- .../export-center/implementation_plan.md | 3 +- .../live-evidence-export-action-sweep.mjs | 322 +++++++++++ .../evidence-bundles.component.spec.ts | 147 ++++- .../evidence-bundles.component.ts | 80 ++- .../evidence-export/evidence-export.models.ts | 2 + .../export-center.component.spec.ts | 126 ++++- .../export-center.component.ts | 106 +++- ...provenance-visualization.component.spec.ts | 64 ++- .../provenance-visualization.component.ts | 82 ++- .../replay-controls.component.spec.ts | 68 ++- .../replay-controls.component.ts | 228 +++++++- ...lla-bundle-export-button.component.spec.ts | 518 +++++++----------- .../stella-bundle-export-button.component.ts | 171 ++++-- ...e-export-button-component.behavior.spec.ts | 45 +- .../StellaOps.Web/tsconfig.spec.features.json | 5 + 17 files changed, 1621 insertions(+), 439 deletions(-) create mode 100644 docs/implplan/SPRINT_20260311_006_FE_live_evidence_export_bundle_contract_alignment.md create mode 100644 src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs diff --git a/docs/implplan/SPRINT_20260311_006_FE_live_evidence_export_bundle_contract_alignment.md b/docs/implplan/SPRINT_20260311_006_FE_live_evidence_export_bundle_contract_alignment.md new file mode 100644 index 000000000..41208e34a --- /dev/null +++ b/docs/implplan/SPRINT_20260311_006_FE_live_evidence_export_bundle_contract_alignment.md @@ -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. diff --git a/docs/modules/export-center/architecture.md b/docs/modules/export-center/architecture.md index 0fe81166f..a34c78564 100644 --- a/docs/modules/export-center/architecture.md +++ b/docs/modules/export-center/architecture.md @@ -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`).** diff --git a/docs/modules/export-center/implementation_plan.md b/docs/modules/export-center/implementation_plan.md index 93ba67a2e..8f16dc946 100644 --- a/docs/modules/export-center/implementation_plan.md +++ b/docs/modules/export-center/implementation_plan.md @@ -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` diff --git a/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs new file mode 100644 index 000000000..3e396a0dd --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-evidence-export-action-sweep.mjs @@ -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®ions=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); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts index 61b144cd6..06d8aa72f 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts @@ -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; 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'); - })); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts index 2245e2a18..f9b3d42d6 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts @@ -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 - @@ -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([]); 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); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.models.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.models.ts index ed68e290d..432fdc0eb 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.models.ts @@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts index fc8fe5ec4..585dc4e1f 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts @@ -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; let component: ExportCenterComponent; + let mockRouter: { navigate: jasmine.Spy }; + let mockAuditBundlesApi: jasmine.SpyObj; + + 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; + + 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', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts index b31b135ea..36a388dda 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts @@ -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') {
- @@ -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(null); @@ -820,7 +823,7 @@ export class ExportCenterComponent implements OnInit, OnDestroy { /** Selected artifact ID for quick actions (SB-003) */ readonly selectedArtifactId = signal('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): 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); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts index e800ae8cc..355874e86 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts @@ -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; 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', () => { }); }); }); - diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts index 95c029573..9f596b515 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts @@ -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 {
+ @if (chainMessage(); as message) { +
+ {{ message }} +
+ } +
@for (node of chain.nodes; track node.id; let i = $index; let last = $last) { @@ -208,6 +216,12 @@ export interface ProvenanceChain { }
+ @if (rawDataVisible()) { +
+

Raw Data

+
{{ selectedNodeRawJson() }}
+
+ }