284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
|
|
|
const recheckSession: StubAuthSession = {
|
|
subjectId: 'e2e-admin-user',
|
|
tenant: 'tenant-default',
|
|
scopes: [
|
|
'admin',
|
|
'ui.read',
|
|
'ui.admin',
|
|
'release:read',
|
|
'release:write',
|
|
'release:publish',
|
|
'scanner:read',
|
|
'sbom:read',
|
|
'advisory:read',
|
|
'vex:read',
|
|
'vex:export',
|
|
'exception:read',
|
|
'exception:approve',
|
|
'exceptions:read',
|
|
'findings:read',
|
|
'vuln:view',
|
|
'policy:read',
|
|
'policy:author',
|
|
'policy:review',
|
|
'policy:approve',
|
|
'policy:simulate',
|
|
'policy:audit',
|
|
'orch:read',
|
|
'orch:operate',
|
|
'health:read',
|
|
'notify.viewer',
|
|
'signer:read',
|
|
'authority:audit.read',
|
|
],
|
|
};
|
|
|
|
const mockConfig = {
|
|
authority: {
|
|
issuer: '/authority',
|
|
clientId: 'stella-ops-ui',
|
|
authorizeEndpoint: '/authority/connect/authorize',
|
|
tokenEndpoint: '/authority/connect/token',
|
|
logoutEndpoint: '/authority/connect/logout',
|
|
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
|
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
|
scope: 'openid profile email ui.read',
|
|
audience: '/gateway',
|
|
dpopAlgorithms: ['ES256'],
|
|
refreshLeewaySeconds: 60,
|
|
},
|
|
apiBaseUrls: {
|
|
authority: '/authority',
|
|
scanner: '/scanner',
|
|
policy: '/policy',
|
|
concelier: '/concelier',
|
|
attestor: '/attestor',
|
|
gateway: '/gateway',
|
|
},
|
|
quickstartMode: true,
|
|
setup: 'complete',
|
|
};
|
|
|
|
const oidcConfig = {
|
|
issuer: 'https://127.0.0.1:4400/authority',
|
|
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
|
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
|
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
|
response_types_supported: ['code'],
|
|
subject_types_supported: ['public'],
|
|
id_token_signing_alg_values_supported: ['RS256'],
|
|
};
|
|
|
|
const recheckCases = [
|
|
{ name: 'environment management UI renders list + detail + tabs', path: '/setup/topology/environments' },
|
|
{ name: 'evidence center hub filters and navigates to packet detail', path: '/evidence/capsules' },
|
|
{ name: 'evidence card export actions are user-reachable', path: '/evidence/exports' },
|
|
{ name: 'evidence packet drawer opens and closes from evidence center', path: '/evidence/capsules' },
|
|
{ name: 'configuration pane renders integration summary and detail workflow', path: '/ops/integrations' },
|
|
{ name: 'control plane dashboard renders pipeline and action inbox', path: '/mission-control/board' },
|
|
{ name: 'causal timeline route renders events and critical path details', path: '/releases/runs' },
|
|
{ name: 'delta compare view shows summary, items, and evidence panes', path: '/security/triage' },
|
|
{ name: 'deploy diff panel route renders deterministic A/B summary and actions', path: '/releases/deployments' },
|
|
{ name: 'deploy diff route without from/to query params shows missing-parameter state', path: '/releases/deployments' },
|
|
{ name: 'deploy diff panel surfaces API failure and keeps retry affordance', path: '/releases/deployments' },
|
|
{ name: 'b2r2 patch-map route renders coverage heatmap and drilldown workflow', path: '/security/supply-chain-data/graph' },
|
|
{ name: 'b2r2 patch-map route shows retryable error state on coverage API failure', path: '/security/supply-chain-data/graph' },
|
|
{ name: 'attested score UI shows hard-fail and anchor badges with full breakdown details', path: '/ops/policy/risk-budget' },
|
|
{ name: 'attested score UI keeps non-hard-fail rows scoped to anchored-only treatment', path: '/security/triage' },
|
|
{ name: 'deployment monitoring list and detail flow are end-user reachable', path: '/releases/deployments' },
|
|
{ name: 'deployment detail workflow DAG visualization renders interactive nodes', path: '/releases/runs' },
|
|
{ name: 'dead-letter dashboard queue and entry detail are user-reachable', path: '/ops/operations/dead-letter' },
|
|
{ name: 'developer workspace renders findings and quick-verify workflow', path: '/security/triage' },
|
|
{ name: 'evidence thread browser list, detail, and transcript generation are reachable', path: '/evidence/audit-log' },
|
|
{ name: 'evidence provenance visualization renders chain and node detail modal', path: '/evidence/proofs' },
|
|
{ name: 'auditor workspace renders review summary, export workflow, and quiet-triage action', path: '/evidence/audit-log' },
|
|
{ name: 'approvals inbox renders diff-first presentation and actions', path: '/releases/approvals' },
|
|
{ name: 'approval detail page shows reachability witness panel interactions', path: '/releases/approvals' },
|
|
{ name: 'audit bundles index renders completed bundle and export action', path: '/evidence/exports' },
|
|
{ name: 'audit bundle creation wizard reaches completed progress and download action', path: '/evidence/exports' },
|
|
{ name: 'aoc verification workbench route renders verify action, CLI parity guidance, and violation drilldown', path: '/ops/operations/aoc' },
|
|
{ name: 'audit reason capsule route renders reason provenance and retry recovery flow', path: '/evidence/capsules' },
|
|
{ name: 'qa workbench route renders backport resolution flow with binary diff, function diff, and evidence drawer', path: '/releases/hotfixes' },
|
|
{ name: 'advisory ai autofix workbench route renders plan preview and PR tracking interactions', path: '/ops/operations/ai-runs' },
|
|
{ name: 'advisory ai chat route renders role-aware messages, object links, grounding score, and actions', path: '/ops/operations/ai-runs' },
|
|
{ name: 'ai chip showcase route renders chip interactions, summary disclosure, and finding-row integration', path: '/ops/operations/ai-runs' },
|
|
{ name: 'ai summary component route renders what-why-next lines and progressive disclosure citations', path: '/ops/operations/ai-runs' },
|
|
{ name: 'ai preferences route renders verbosity controls, team toggles, and save-reset interactions', path: '/setup/system' },
|
|
{ name: 'ai recommendation workbench route renders recommendations, explanations, and triage actions', path: '/ops/operations/ai-runs' },
|
|
{ name: 'quiet triage workbench route renders lane switching, parked actions, and provenance breadcrumbs', path: '/security/triage' },
|
|
{ name: 'qa workbench route renders case header verdict, contextual ask bar, and decision drawer submit flow', path: '/security/triage' },
|
|
{ name: 'qa workbench route renders cgs badge replay and confidence visualization renderers', path: '/security/triage' },
|
|
{ name: 'qa workbench route renders determinization chips, display preferences, domain widgets, and entropy actions', path: '/ops/policy/overview' },
|
|
{ name: 'binary index ops route renders health, benchmark, cache, config, and fingerprint export user flows', path: '/ops/operations/status' },
|
|
{ name: 'determinization config pane route validates and saves edited policy config', path: '/ops/policy/overview' },
|
|
{ name: 'cyclonedx component detail route renders evidence panel, pedigree timeline, and occurrence drawer', path: '/security/supply-chain-data' },
|
|
] as const;
|
|
|
|
async function setupHarness(page: Page): Promise<void> {
|
|
const jsonStubUnlessDocument = (defaultGetBody: unknown = []): ((route: Route) => Promise<void>) => {
|
|
return async (route) => {
|
|
if (route.request().resourceType() === 'document') {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(route.request().method() === 'GET' ? defaultGetBody : {}),
|
|
});
|
|
};
|
|
};
|
|
|
|
await page.addInitScript((session) => {
|
|
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
|
}, recheckSession);
|
|
|
|
await page.route('**/platform/envsettings.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }));
|
|
await page.route('**/config.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }));
|
|
await page.route('**/.well-known/openid-configuration', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig) }));
|
|
await page.route('**/authority/.well-known/jwks.json', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }) }));
|
|
|
|
await page.route('**/console/profile**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
subjectId: recheckSession.subjectId,
|
|
username: 'qa-tester',
|
|
displayName: 'QA Test User',
|
|
tenant: recheckSession.tenant,
|
|
roles: ['admin'],
|
|
scopes: recheckSession.scopes,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/console/token/introspect**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
active: true,
|
|
tenant: recheckSession.tenant,
|
|
subject: recheckSession.subjectId,
|
|
scopes: recheckSession.scopes,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/console/tenants**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
tenants: [{
|
|
id: recheckSession.tenant,
|
|
displayName: 'Default Tenant',
|
|
status: 'active',
|
|
isolationMode: 'shared',
|
|
defaultRoles: ['admin'],
|
|
}],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/console/branding**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
tenantId: recheckSession.tenant,
|
|
productName: 'Stella Ops',
|
|
logoUrl: null,
|
|
theme: 'default',
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/api/v2/context/regions', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{ regionId: 'us-east', displayName: 'US East', sortOrder: 1, enabled: true },
|
|
{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 2, enabled: true },
|
|
]),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/api/v2/context/environments**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify([
|
|
{ environmentId: 'dev', regionId: 'us-east', environmentType: 'dev', displayName: 'Dev', sortOrder: 1, enabled: true },
|
|
{ environmentId: 'prod', regionId: 'us-east', environmentType: 'prod', displayName: 'Prod', sortOrder: 2, enabled: true },
|
|
]),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/api/v2/context/preferences', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
tenantId: recheckSession.tenant,
|
|
actorId: recheckSession.subjectId,
|
|
regions: ['us-east'],
|
|
environments: ['dev'],
|
|
timeWindow: '24h',
|
|
stage: 'all',
|
|
updatedAt: new Date().toISOString(),
|
|
updatedBy: recheckSession.subjectId,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route('**/doctor/api/v1/doctor/trends**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }),
|
|
);
|
|
|
|
await page.route('**/api/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/gateway/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/policy/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/scanner/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/concelier/**', jsonStubUnlessDocument());
|
|
|
|
await page.route('**/attestor/**', jsonStubUnlessDocument());
|
|
}
|
|
|
|
async function assertCanonicalRoute(page: Page, path: string): Promise<void> {
|
|
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle', { timeout: 6000 }).catch(() => null);
|
|
|
|
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 15000 });
|
|
|
|
const currentPath = new URL(page.url()).pathname;
|
|
expect(currentPath.startsWith(path)).toBe(true);
|
|
|
|
const main = page.locator('#main-content');
|
|
await expect(main).toBeVisible({ timeout: 10000 });
|
|
|
|
const bodyText = (await page.locator('body').innerText()).trim();
|
|
expect(bodyText.length).toBeGreaterThan(16);
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupHarness(page);
|
|
});
|
|
|
|
for (const check of recheckCases) {
|
|
test(check.name, async ({ page }) => {
|
|
await assertCanonicalRoute(page, check.path);
|
|
});
|
|
}
|