Files
HealthLog/e2e/setup/global-setup.ts
T
Marc-André BombeckandClaude Opus 4.7 535465c0ef test(e2e): add seeded test user + auth storageState fixture
Authenticated specs need a deterministic user and a persistent session
cookie. global-setup.ts upserts an `e2e-tester` admin via raw `pg` (the
generated Prisma client uses ESM `import.meta.url`, which Playwright's
TS loader cannot resolve), then logs the user in once and writes
`storageState.json` so every authenticated spec reuses the same
session — the per-IP login rate-limit (5 attempts / 15 min) would
otherwise lock out the suite the moment six specs run in parallel.

Adds `pg` + `@types/pg` as devDependencies — required for the seed
step. `pg` is already used by `scripts/seed-demo.ts` for the same
loader-incompatibility reason, so we're not introducing a new
dependency footprint, only making it explicit in the lockfile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:48:45 +02:00

180 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string> {
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<void> {
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}`,
);
}