| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- 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 <path> (${(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([]);
- });
|