Files
git.stella-ops.org/src/Web/StellaOps.Web/tests/e2e/nav-shell.spec.ts

379 lines
12 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'ui.admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'health:read',
'notify:viewer',
'release:read',
'release:write',
'release:publish',
'sbom:read',
'signer:read',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'http://127.0.0.1:4400/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: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://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'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage access errors in restricted contexts
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
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('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
}),
);
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('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-shell-e2e' }),
}),
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 30000 });
}
async function assertMainHasContent(page: Page): Promise<void> {
const main = page.locator('main');
await expect(main).toHaveCount(1);
await expect(main).toBeVisible();
const text = ((await main.textContent()) ?? '').replace(/\s+/g, '');
const childNodes = await main.locator('*').count();
expect(text.length > 12 || childNodes > 4).toBe(true);
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
test.describe('Nav shell canonical domains', () => {
test('sidebar renders all canonical root labels', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
const labels = [
'Release Control',
'Security & Evidence',
'Platform & Setup',
'Dashboard',
'Releases',
'Vulnerabilities',
'Evidence',
'Operations',
'Setup',
];
for (const label of labels) {
expect(navText).toContain(label);
}
});
test('sidebar excludes deprecated root labels', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
expect(navText).not.toContain('Security & Risk');
expect(navText).not.toContain('Evidence & Audit');
expect(navText).not.toContain('Platform Ops');
expect(navText).not.toContain('Administration');
expect(navText).not.toContain('Policy Studio');
});
test('group headers are unique and navigate to group landing routes', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' });
const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' });
const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' });
await expect(releaseGroup).toHaveCount(1);
await expect(securityGroup).toHaveCount(1);
await expect(platformGroup).toHaveCount(1);
await releaseGroup.click();
await expect(page).toHaveURL(/\/mission-control\/board$/);
await securityGroup.click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await platformGroup.click();
await expect(page).toHaveURL(/\/ops(\/|$)/);
});
test('grouped root entries navigate when clicked', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click();
await expect(page).toHaveURL(/\/releases\/deployments$/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click();
await expect(page).toHaveURL(/\/evidence\/overview$/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click();
await expect(page).toHaveURL(/\/ops\/operations$/);
});
});
test.describe('Nav shell breadcrumbs and stability', () => {
const breadcrumbRoutes: Array<{ path: string; expected: string }> = [
{ path: '/mission-control/board', expected: 'Mission Board' },
{ path: '/releases/versions', expected: 'Release Versions' },
{ path: '/security/triage', expected: 'Triage' },
{ path: '/evidence/verify-replay', expected: 'Verify & Replay' },
{ path: '/ops/operations/data-integrity', expected: 'Data Integrity' },
{ path: '/setup/topology/agents', expected: 'Agent Fleet' },
];
for (const route of breadcrumbRoutes) {
test(`breadcrumb renders on ${route.path}`, async ({ page }) => {
await go(page, route.path);
await ensureShell(page);
const breadcrumb = page.locator('app-breadcrumb nav.breadcrumb');
await expect(breadcrumb).toHaveCount(1);
await expect(breadcrumb).toContainText(route.expected);
});
}
test('canonical roots produce no app runtime errors', async ({ page }) => {
const errors = collectConsoleErrors(page);
const routes = ['/mission-control/board', '/releases', '/security', '/evidence', '/ops', '/setup'];
for (const route of routes) {
await go(page, route);
await ensureShell(page);
}
const appErrors = errors.filter(
(error) =>
!error.includes('ERR_FAILED') &&
!error.includes('ERR_BLOCKED') &&
!error.includes('ERR_CONNECTION_REFUSED') &&
!error.includes('404') &&
error.length > 0,
);
expect(appErrors).toEqual([]);
});
});
test.describe('Pack route render checks', () => {
test('release routes render non-blank content', async ({ page }) => {
test.setTimeout(60_000);
const routes = [
'/releases/overview',
'/releases/versions',
'/releases/runs',
'/releases/approvals',
'/releases/hotfixes',
'/releases/promotion-queue',
'/releases/environments',
'/releases/deployments',
'/releases/versions/new',
];
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
});
test('security and evidence routes render non-blank content', async ({ page }) => {
test.setTimeout(60_000);
const routes = [
'/security/posture',
'/security/triage',
'/security/advisories-vex',
'/security/supply-chain-data',
'/security/reachability',
'/security/reports',
'/evidence/overview',
'/evidence/capsules',
'/evidence/verify-replay',
'/evidence/exports',
'/evidence/audit-log',
];
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
});
test('ops and setup routes render non-blank content', async ({ page }) => {
test.setTimeout(180_000);
const routes = [
'/ops',
'/ops/operations',
'/ops/operations/data-integrity',
'/ops/operations/jobengine',
'/ops/integrations',
'/ops/integrations/advisory-vex-sources',
'/ops/policy',
'/ops/platform-setup',
'/setup',
'/setup/topology/overview',
'/setup/topology/targets',
'/setup/topology/hosts',
'/setup/topology/agents',
];
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
});
});
test.describe('Nav shell responsive layout', () => {
test('desktop viewport shows sidebar', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await go(page, '/mission-control/board');
await expect(page.locator('aside.sidebar')).toBeVisible();
});
test('mobile viewport remains usable without horizontal overflow', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await go(page, '/mission-control/board');
await expect(page.locator('.topbar__menu-toggle')).toBeVisible();
const hasHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(hasHorizontalScroll).toBe(false);
});
});