From 539d063c221bb3e3920990bb38a981f8f8abb111 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 3 Jun 2026 22:57:02 -0700 Subject: [PATCH] =?UTF-8?q?test(e2e):=20smoke=20spec=20=E2=80=94=20app=20l?= =?UTF-8?q?oads=20and=20OTC=20sign-in=20succeeds=20via=20Mailpit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first end-to-end smoke spec, run live against the Tier-1 stack (§22 M3-0 Task 7). Drives the §6.2 email OTC sign-in: loads the app, requests a code, reads it from Mailpit, verifies it, and asserts the verify returns ok:true and sets the rfc_session cookie. Supporting harness fixes: - Makefile: add .PHONY so `make e2e` actually runs (the e2e/ directory was shadowing the target, making it a no-op). - mailpit.js: guard the per-message detail fetch (if !full.ok continue) and scan the plain-text part before HTML so the \d{6} match can't latch onto a stray number. - spec uses a unique per-run email to avoid the per-email request cooldown (OTC_REQUEST_COOLDOWN_SECONDS) on re-runs. Co-Authored-By: Claude Opus 4.8 --- Makefile | 2 ++ e2e/.gitignore | 2 ++ e2e/lib/mailpit.js | 10 ++++++++-- e2e/smoke.spec.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 e2e/.gitignore create mode 100644 e2e/smoke.spec.js 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=') +})