379 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|