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>
82 lines
3.4 KiB
TypeScript
82 lines
3.4 KiB
TypeScript
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
|
|
}
|
|
});
|
|
});
|