diff --git a/dashboard/tests/e2e/security/security.spec.ts b/dashboard/tests/e2e/security/security.spec.ts new file mode 100644 index 0000000..55a3d09 --- /dev/null +++ b/dashboard/tests/e2e/security/security.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +// Phase B8 — Cross-cutting security. Things that aren't tied to a +// single page: session handling, secret leakage, error states for +// missing resources, and a sanity check that no XSS sink fires +// anywhere in the dashboard's main authed routes. + +const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin'; +const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin'; + +test.describe('B8 cross-cutting security', () => { + test('expired/stale token: any authed call redirects to /login', async ({ page }) => { + // Replace the storageState token with an obvious garbage + // value; the fetch wrapper treats 401 as "go to /login". + await page.goto('/admin/login'); + await page.evaluate(() => { + localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token'); + }); + await page.goto('/admin/apps'); + await expect(page).toHaveURL(/\/admin\/login$/); + }); + + test('login response cookie is HttpOnly', async ({ request }) => { + const res = await request.post('/api/v1/admin/auth/login', { + data: { username: VALID_USERNAME, password: VALID_PASSWORD }, + headers: { 'content-type': 'application/json' } + }); + expect(res.ok()).toBe(true); + const headers = res.headers(); + const setCookie = headers['set-cookie']; + // Backend may or may not set a cookie (the dashboard primarily + // uses bearer-in-localStorage). If it does, it must be + // HttpOnly so XSS can't exfiltrate it. + if (setCookie) { + expect(setCookie.toLowerCase()).toContain('httponly'); + } + }); + + test('bootstrap password is not present in the DOM after login', async ({ page }) => { + await page.goto('/admin/apps'); + const body = await page.locator('body').innerText(); + expect(body).not.toContain(VALID_PASSWORD); + }); + + test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => { + await page.goto('/admin/apps/does-not-exist-e2e-9999'); + // Page must render *something* and the layout must remain + // intact (header link to Apps still works). + await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible(); + // And surface the failure to the user — either a "couldn't + // load" message or a "back to apps" link. + const errorOrBack = page.locator('.error, a[href$="/admin/apps"]'); + await expect(errorOrBack.first()).toBeVisible(); + }); + + test('xss probe across major surfaces never fires a dialog', async ({ page }) => { + page.on('dialog', async (dialog) => { + await dialog.dismiss(); + throw new Error( + `XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"` + ); + }); + + // Cover each main authed route. None should evaluate any + // payload that earlier tests may have stored, and none should + // inject inline