import { test, expect } from '@playwright/test'; import { loadFixture, probeReadiness, readinessMessage } from './helpers'; import { ensureLoggedIn, awaitSession } from './flow'; declare const require: (id: string) => any; test.use({ ignoreHTTPSErrors: true }); test.beforeAll(async () => { const readiness = await probeReadiness(); expect(readiness.ok, readinessMessage(readiness)).toBe(true); }); function loadFixtureOrSkip() { try { return loadFixture(); } catch (err) { test.skip(true, `run provision first: cargo run -p provision -- bootstrap --password-file (${(err as Error).message})`); throw new Error('unreachable'); } } function axeOrSkip() { try { return require('@axe-core/playwright').AxeBuilder; } catch { test.skip(true, 'install required: (cd e2e && npm install) for @axe-core/playwright 4.13.0'); throw new Error('unreachable'); } } function noCritical(results: { violations: Array<{ impact?: string | null; id: string }> }) { const critical = results.violations.filter((v) => v.impact === 'critical'); return { critical, detail: critical.map((v) => v.id).join(', ') }; } test('unauthenticated /api/session is 401 with landing state', async ({ page }) => { // Must see the anonymous state: drop the shared authenticated cookies first // (the project storageState would otherwise send a live sid here). await page.context().clearCookies(); const res = await page.request.get('/api/session'); expect(res.status()).toBe(401); const body = await res.json().catch(() => null); expect(body?.subject, 'anonymous session must not carry identity').toBeUndefined(); await page.goto('/'); await expect(page.getByRole('link', { name: /sign in/i }).first()).toBeVisible(); }); test('revoking both tokens clears the session: transparent refresh cannot revive it', async ({ page }) => { // Own session, not the shared one: this test destroys its BFF session, so // it logs in privately (empty storage) while the other tests reuse the // setup project's authenticated state untouched. `/api/logout` revokes BOTH // the access and refresh tokens, so the BFF's transparent renewal on // `GET /api/session` has nothing left to rotate: the probe must stay 401 // rather than self-healing to 200. await page.context().clearCookies(); const fixture = loadFixtureOrSkip(); const { authenticator } = require('otplib'); await ensureLoggedIn(page, fixture, (secret: string) => authenticator.generate(secret)); const session = await awaitSession(page); expect(session.csrf_token).toBeTruthy(); const logout = await page.request.post('/api/logout', { headers: { 'x-csrf-token': session.csrf_token } }); expect(logout.status()).toBe(200); const after = await page.request.get('/api/session'); expect(after.status(), 'revoked access + refresh must read as 401, never as identity').toBe(401); const data = await after.json().catch(() => ({})); expect(data.subject, 'cleared session must not carry identity').toBeUndefined(); const cookies = await page.context().cookies('http://127.0.0.1:3000'); const sid = cookies.find((c) => c.name === 'sid'); if (sid) { const replay = await page.request.get('/api/session', { headers: { cookie: `sid=${sid.value}` } }); expect(replay.status(), 'replaying a destroyed sid must stay 401').toBe(401); } }); test('wrong-issuer callback shows a typed error and creates no session', async ({ page }) => { // Anonymous probe: the project storageState carries a live sid, so use the // cleared page context instead of the shared-state request fixture. await page.context().clearCookies(); const forged = await page.request.get('/callback?code=forged-code&state=forged-state&iss=https://wrong-issuer.example'); expect(forged.status()).toBe(400); expect(await forged.text()).toContain('Invalid callback'); expect((await page.request.get('/api/session')).status()).toBe(401); }); test('state-changing API rejects missing CSRF and foreign origin', async ({ page }) => { // Reuses the setup project's authenticated state (no fresh login: the AS // rate-limits /login, so the suite authenticates once per run). const session = await awaitSession(page); const noCsrf = await page.request.post('/api/refresh'); expect(noCsrf.status(), 'refresh without CSRF must not succeed').toBe(403); const forged = await page.request.post('/api/refresh', { headers: { Origin: 'https://evil.example', 'x-csrf-token': session.csrf_token }, }); expect(forged.status(), 'cross-site refresh must not succeed').toBe(403); expect((await page.request.get('/api/session')).status()).toBe(200); }); test('landing has no critical accessibility violations', async ({ page }) => { const AxeBuilder = axeOrSkip(); await page.goto('/'); const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze()); expect(critical, `critical axe violations on landing: ${detail}`).toEqual([]); }); test('dashboard has no critical accessibility violations', async ({ page }) => { const AxeBuilder = axeOrSkip(); // Same shared authenticated state as the CSRF test above; awaitSession // proves it is live before scanning. await page.goto('/dashboard'); await awaitSession(page); const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze()); expect(critical, `critical axe violations on dashboard: ${detail}`).toEqual([]); });