negative.spec.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { test, expect } from '@playwright/test';
  2. import { loadFixture, probeReadiness, readinessMessage } from './helpers';
  3. import { ensureLoggedIn, awaitSession } from './flow';
  4. declare const require: (id: string) => any;
  5. test.use({ ignoreHTTPSErrors: true });
  6. test.beforeAll(async () => {
  7. const readiness = await probeReadiness();
  8. expect(readiness.ok, readinessMessage(readiness)).toBe(true);
  9. });
  10. function loadFixtureOrSkip() {
  11. try {
  12. return loadFixture();
  13. } catch (err) {
  14. test.skip(true, `run provision first: cargo run -p provision -- bootstrap --password-file <path> (${(err as Error).message})`);
  15. throw new Error('unreachable');
  16. }
  17. }
  18. function axeOrSkip() {
  19. try {
  20. return require('@axe-core/playwright').AxeBuilder;
  21. } catch {
  22. test.skip(true, 'install required: (cd e2e && npm install) for @axe-core/playwright 4.13.0');
  23. throw new Error('unreachable');
  24. }
  25. }
  26. function noCritical(results: { violations: Array<{ impact?: string | null; id: string }> }) {
  27. const critical = results.violations.filter((v) => v.impact === 'critical');
  28. return { critical, detail: critical.map((v) => v.id).join(', ') };
  29. }
  30. test('unauthenticated /api/session is 401 with landing state', async ({ page }) => {
  31. // Must see the anonymous state: drop the shared authenticated cookies first
  32. // (the project storageState would otherwise send a live sid here).
  33. await page.context().clearCookies();
  34. const res = await page.request.get('/api/session');
  35. expect(res.status()).toBe(401);
  36. const body = await res.json().catch(() => null);
  37. expect(body?.subject, 'anonymous session must not carry identity').toBeUndefined();
  38. await page.goto('/');
  39. await expect(page.getByRole('link', { name: /sign in/i }).first()).toBeVisible();
  40. });
  41. test('revoking both tokens clears the session: transparent refresh cannot revive it', async ({ page }) => {
  42. // Own session, not the shared one: this test destroys its BFF session, so
  43. // it logs in privately (empty storage) while the other tests reuse the
  44. // setup project's authenticated state untouched. `/api/logout` revokes BOTH
  45. // the access and refresh tokens, so the BFF's transparent renewal on
  46. // `GET /api/session` has nothing left to rotate: the probe must stay 401
  47. // rather than self-healing to 200.
  48. await page.context().clearCookies();
  49. const fixture = loadFixtureOrSkip();
  50. const { authenticator } = require('otplib');
  51. await ensureLoggedIn(page, fixture, (secret: string) => authenticator.generate(secret));
  52. const session = await awaitSession(page);
  53. expect(session.csrf_token).toBeTruthy();
  54. const logout = await page.request.post('/api/logout', { headers: { 'x-csrf-token': session.csrf_token } });
  55. expect(logout.status()).toBe(200);
  56. const after = await page.request.get('/api/session');
  57. expect(after.status(), 'revoked access + refresh must read as 401, never as identity').toBe(401);
  58. const data = await after.json().catch(() => ({}));
  59. expect(data.subject, 'cleared session must not carry identity').toBeUndefined();
  60. const cookies = await page.context().cookies('http://127.0.0.1:3000');
  61. const sid = cookies.find((c) => c.name === 'sid');
  62. if (sid) {
  63. const replay = await page.request.get('/api/session', { headers: { cookie: `sid=${sid.value}` } });
  64. expect(replay.status(), 'replaying a destroyed sid must stay 401').toBe(401);
  65. }
  66. });
  67. test('wrong-issuer callback shows a typed error and creates no session', async ({ page }) => {
  68. // Anonymous probe: the project storageState carries a live sid, so use the
  69. // cleared page context instead of the shared-state request fixture.
  70. await page.context().clearCookies();
  71. const forged = await page.request.get('/callback?code=forged-code&state=forged-state&iss=https://wrong-issuer.example');
  72. expect(forged.status()).toBe(400);
  73. expect(await forged.text()).toContain('Invalid callback');
  74. expect((await page.request.get('/api/session')).status()).toBe(401);
  75. });
  76. test('state-changing API rejects missing CSRF and foreign origin', async ({ page }) => {
  77. // Reuses the setup project's authenticated state (no fresh login: the AS
  78. // rate-limits /login, so the suite authenticates once per run).
  79. const session = await awaitSession(page);
  80. const noCsrf = await page.request.post('/api/refresh');
  81. expect(noCsrf.status(), 'refresh without CSRF must not succeed').toBe(403);
  82. const forged = await page.request.post('/api/refresh', {
  83. headers: { Origin: 'https://evil.example', 'x-csrf-token': session.csrf_token },
  84. });
  85. expect(forged.status(), 'cross-site refresh must not succeed').toBe(403);
  86. expect((await page.request.get('/api/session')).status()).toBe(200);
  87. });
  88. test('landing has no critical accessibility violations', async ({ page }) => {
  89. const AxeBuilder = axeOrSkip();
  90. await page.goto('/');
  91. const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze());
  92. expect(critical, `critical axe violations on landing: ${detail}`).toEqual([]);
  93. });
  94. test('dashboard has no critical accessibility violations', async ({ page }) => {
  95. const AxeBuilder = axeOrSkip();
  96. // Same shared authenticated state as the CSRF test above; awaitSession
  97. // proves it is live before scanning.
  98. await page.goto('/dashboard');
  99. await awaitSession(page);
  100. const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze());
  101. expect(critical, `critical axe violations on dashboard: ${detail}`).toEqual([]);
  102. });