diff --git a/Makefile b/Makefile index a0f0cbf..5f63f48 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e + tier1-up: docker compose -f testing/docker-compose.yml up --build -d diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..aaa9103 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,2 @@ +test-results/ +playwright-report/ diff --git a/e2e/lib/mailpit.js b/e2e/lib/mailpit.js index 32f3bfa..b752f3e 100644 --- a/e2e/lib/mailpit.js +++ b/e2e/lib/mailpit.js @@ -10,9 +10,15 @@ export async function waitForLatestOtc(toAddress, { attempts = 20, delayMs = 500 ) if (msg) { const full = await fetch(`${MAILSINK}/api/v1/message/${msg.ID}`) + if (!full.ok) continue const body = await full.json() - const text = `${body.Text || ''} ${body.HTML || ''}` - const code = text.match(/\b(\d{6})\b/) + // Search the plain-text part first — the OTC mail is plain text + // (see backend/app/email_otc.py), and scanning Text before HTML + // keeps the \d{6} match from latching onto a stray number that a + // future HTML template might carry (style widths, year, etc.). + const text = body.Text || '' + const html = body.HTML || '' + const code = text.match(/\b(\d{6})\b/) || html.match(/\b(\d{6})\b/) if (code) return code[1] } } diff --git a/e2e/smoke.spec.js b/e2e/smoke.spec.js new file mode 100644 index 0000000..e377ed9 --- /dev/null +++ b/e2e/smoke.spec.js @@ -0,0 +1,48 @@ +import { test, expect } from '@playwright/test' +import { waitForLatestOtc, clearMailpit } from './lib/mailpit.js' + +// Unique per-run address. The §6.2 request path enforces a per-email +// cooldown (OTC_REQUEST_COOLDOWN_SECONDS, default 60s), so a fixed +// address would 429 on any re-run inside the window. A fresh address per +// run sidesteps that without touching backend config. +const EMAIL = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.test` + +// First end-to-end smoke spec (M3-0 Task 7). Validates the whole Tier-1 +// harness: the web tier serves the app, the backend's OTC sign-in path +// (§6.2) issues a code, Mailpit captures the outbound mail, and a verify +// of that code succeeds and mints a session. +// +// No precondition/provisioning step is needed: the §6.2 OTC path admits +// any syntactically-valid email and provisions a fresh `pending` user on +// verify (see backend/app/otc.py). Turnstile is open in this stack +// (TURNSTILE_REQUIRED=false, no secret), so the request body carries only +// the email. +test('app loads and an OTC sign-in succeeds', async ({ page, request }) => { + await clearMailpit() + + await page.goto('/') + await expect(page).toHaveTitle(/.+/) + + const reqRes = await request.post('/auth/otc/request', { + data: { email: EMAIL }, + }) + expect(reqRes.ok()).toBeTruthy() + + const code = await waitForLatestOtc(EMAIL) + expect(code).toMatch(/^\d{6}$/) + + const verifyRes = await request.post('/auth/otc/verify', { + data: { email: EMAIL, code }, + }) + expect(verifyRes.ok()).toBeTruthy() + + // Prove the sign-in actually took: the verify handler returns ok:true + // and sets the `rfc_session` session cookie (§ SessionMiddleware, + // backend/app/main.py). Asserting the cookie — not brittle UI text — + // is what distinguishes a real sign-in from a bare 2xx. + const verifyBody = await verifyRes.json() + expect(verifyBody.ok).toBe(true) + + const setCookie = verifyRes.headers()['set-cookie'] || '' + expect(setCookie).toContain('rfc_session=') +})