Align live evidence export with audit bundles
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user