Align live evidence export with audit bundles
This commit is contained in:
@@ -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.
|
||||
@@ -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`).**
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user