mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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>
This commit is contained in:
1 parent
1a85da0b05
commit
535465c0ef
6 files changed
+199
No files matched your search
@@ -15,6 +15,7 @@
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
/e2e/setup/storageState.json
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -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<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}`,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
Reference in new issue
Block a user