diff --git a/.gitignore b/.gitignore index 88abd76ef..7aa41ee3b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /test-results /playwright-report /playwright/.cache +/e2e/setup/storageState.json # next.js /.next/ diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts new file mode 100644 index 000000000..84276af4d --- /dev/null +++ b/e2e/setup/global-setup.ts @@ -0,0 +1,179 @@ +/** + * Playwright globalSetup — seeds the deterministic test user used by the + * authenticated specs AND logs that user in once, persisting the resulting + * `healthlog_session` cookie to a `storageState.json` file. Runs ONCE per + * `pnpm e2e` invocation, before any worker boots. + * + * Why login here instead of in each spec: + * - The login endpoint is rate-limited to 5 attempts per IP per 15min, + * and Playwright runs specs concurrently — even one spec re-logging-in + * per `beforeEach` would burn through the limit on the first 6 + * authenticated tests, causing flake. + * - Per-spec login is also slow (argon2 verify ~300ms × N specs). + * + * The Next.js prod server that Playwright's `webServer` starts and the + * seed below both read `process.env.DATABASE_URL`, so they share the + * same Postgres instance. The seed is idempotent — re-runs against an + * existing dev DB rotate the password hash and clear AI/Codex state. + * + * Implementation note: we talk to Postgres via `pg` (raw SQL), NOT the + * Prisma client. Playwright's TS loader does not handle the generated + * Prisma client's `import.meta.url` indirection, and we don't need + * Prisma's type safety for a one-off upsert. The integration suite + * (`tests/integration/`) keeps using Prisma because Vitest's loader + * does support the generated client. + */ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { hash } from "@node-rs/argon2"; +import { + request as playwrightRequest, + type FullConfig, +} from "@playwright/test"; +import pg from "pg"; + +// Resolve relative to the project root rather than __dirname/import.meta.url — +// Playwright's TS loader runs files as CJS without `"type":"module"`, and +// loading either dirname helper at module scope crashes the test runner. +// The repo layout puts this file at e2e/setup/global-setup.ts so we use +// process.cwd() (== project root for `pnpm e2e`). +export const STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageState.json", +); + +export const E2E_USER = { + email: "e2e@healthlog.test", + username: "e2e-tester", + password: "ZJ4hN8x!Pq3vMr2C", // 16 chars, passes zxcvbn min-score-3 gate + role: "ADMIN", +} as const; + +async function hashPassword(password: string): Promise { + return hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); +} + +function cuid(): string { + // Match Prisma's default cuid format closely enough that downstream + // queries that filter on `id LIKE 'c%'` (none currently) keep working. + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "c"; + for (let i = 0; i < 24; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +export default async function globalSetup(config: FullConfig): Promise { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + "[e2e/global-setup] DATABASE_URL is not set — Playwright cannot seed the test user.", + ); + } + + const pool = new pg.Pool({ connectionString: url }); + + try { + const passwordHash = await hashPassword(E2E_USER.password); + const now = new Date(); + const dob = new Date("1985-06-15T00:00:00.000Z"); + + // Postgres-native upsert keyed on the unique username column. The + // SET clause clears any leftover Codex / insights state from a + // previous run so the "Settings → AI" spec sees the disconnected + // baseline; we also stamp `onboarding_completed_at` so the auth + // shell does not bounce the user to /onboarding. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, + created_at, updated_at, + height_cm, date_of_birth, gender, + codex_connection_status, + insights_privacy_mode, + onboarding_completed_at) + VALUES ($1, $2, $3, $4, $5, + $6, $6, + 180, $7, 'MALE', + 'disconnected', + 'aggregated', + $6) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + height_cm = EXCLUDED.height_cm, + date_of_birth = EXCLUDED.date_of_birth, + gender = EXCLUDED.gender, + codex_access_token_encrypted = NULL, + codex_refresh_token_encrypted = NULL, + codex_token_expires_at = NULL, + codex_connected_at = NULL, + codex_connection_status = 'disconnected', + insights_cached_at = NULL, + insights_cached_text = NULL, + onboarding_completed_at = EXCLUDED.onboarding_completed_at`, + [ + cuid(), + E2E_USER.username, + E2E_USER.email, + passwordHash, + E2E_USER.role, + now, + dob, + ], + ); + + // Console (instead of structured logging) is intentional here — + // global-setup runs outside the app's logging context, and the + // line is useful when debugging a CI failure where the seed didn't + // commit. + console.log( + `[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`, + ); + } finally { + await pool.end(); + } + + // Login once and persist the resulting cookie jar so every spec can + // reuse it via `playwright.config.ts → use.storageState`. This is the + // documented Playwright pattern (`docs/auth.md`) and the only way to + // avoid the per-spec login + rate-limit dance. + const baseURL = + config.projects[0]?.use.baseURL ?? + process.env.E2E_BASE_URL ?? + "http://localhost:3000"; + + const ctx = await playwrightRequest.newContext({ baseURL }); + const res = await ctx.post("/api/auth/login", { + data: { + email: E2E_USER.username, + password: E2E_USER.password, + }, + }); + if (res.status() !== 200) { + const body = await res.text(); + throw new Error( + `[e2e/global-setup] login failed: HTTP ${res.status()} — ${body.slice(0, 200)}`, + ); + } + const state = await ctx.storageState(); + // Strip any cookies we don't recognise so storageState only carries + // what the auth shell needs. (Mostly defensive — the proxy currently + // only sets `healthlog_session` and `healthlog_locale`.) + state.cookies = state.cookies.filter((c) => + ["healthlog_session", "healthlog_locale"].includes(c.name), + ); + await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2)); + await ctx.dispose(); + console.log( + `[e2e/global-setup] auth state captured to ${STORAGE_STATE_PATH}`, + ); +} diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts new file mode 100644 index 000000000..d6af56eb8 --- /dev/null +++ b/e2e/setup/test-helpers.ts @@ -0,0 +1,9 @@ +/** + * Re-export pin for the seeded test-user identity. The cookie jar that + * authenticates each spec is captured ONCE in `global-setup.ts` and the + * specs reach for it via `STORAGE_STATE_PATH` directly. We keep this + * file (instead of a barrel inside global-setup) only so future helpers + * specific to the spec-side (e.g. multi-user scenarios that need + * fresh logins) have a natural home. + */ +export { E2E_USER, STORAGE_STATE_PATH } from "./global-setup"; diff --git a/package.json b/package.json index 8bb1dd51a..161b6b825 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@tailwindcss/postcss": "^4", "@testcontainers/postgresql": "^11.14.0", "@types/node": "^25", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4", @@ -66,6 +67,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "pdf-parse": "^2.4.5", + "pg": "^8.20.0", "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.8.0", "shadcn": "^4.6.0", diff --git a/playwright.config.ts b/playwright.config.ts index 47cc160ec..8a9a4f28d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,8 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./e2e", + testIgnore: ["setup/**"], + globalSetup: "./e2e/setup/global-setup.ts", timeout: 30_000, expect: { timeout: 5_000 }, fullyParallel: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5af4b314..d13328ba2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@types/node': specifier: ^25 version: 25.6.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/react': specifier: ^19 version: 19.2.14 @@ -132,6 +135,9 @@ importers: pdf-parse: specifier: ^2.4.5 version: 2.4.5 + pg: + specifier: ^8.20.0 + version: 8.20.0 prettier: specifier: ^3.8.3 version: 3.8.3