test(dashboard): add e2e cross-cutting security spec (B8)
Five tests covering platform-wide guarantees: expired-token redirect, HttpOnly session cookie, bootstrap password not leaked into the DOM after login, missing-app slug fails gracefully, and an XSS-sink probe across the main authed routes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
@@ -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 <script> tags from server responses.
|
||||
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
|
||||
// Svelte itself injects no inline <script> in the
|
||||
// production bundle; vite dev does, but never with
|
||||
// onerror/alert payload text in them.
|
||||
const evilInline = await page
|
||||
.locator('script:has-text("alert"), script:has-text("__xss")')
|
||||
.count();
|
||||
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
|
||||
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user