mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Two unrelated root-causes were keeping the Integration tests and e2e workflows red on every push to main since `d8c549e` (50+ runs). 1. Integration: `.github/workflows/integration.yml` had `ENCRYPTION_KEY: 0000…` as a bare YAML scalar. YAML 1.2 parses a run of zeros as the integer 0 — so the runner exported `ENCRYPTION_KEY=0`, and `decodeKey()` rejected it as "must be hex (>= 32 chars) or base64 (32 bytes)". 6 tests in admin-backups-* and integration-status failed at module load. Quoting the value forces a literal string. 2. e2e: the global-setup seeded user had `onboarding_tour_completed` left at its default `false`, so the spotlight tour mounted a full-viewport `role="dialog"` overlay (z-index 200) on every authenticated spec. Playwright's hit-test then caught the tour's skip-button instead of the intended target — every spec that clicked sidebar / quick-add / logo timed out. Setting the column to `true` in the upsert keeps the tour off the dashboard for the rest of the suite. The two `onboarding-flicker.spec.ts` mocks that fulfil `/api/auth/me` from inside the spec also need the field, since they replace the seeded payload. Workflow change is comment + quoted strings only — no secrets, no runtime cost.
190 lines
7.0 KiB
TypeScript
190 lines
7.0 KiB
TypeScript
/**
|
||
* 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, AND set
|
||
// `onboarding_tour_completed = true` so the spotlight tour does
|
||
// NOT auto-launch on the dashboard. Without that flag the tour
|
||
// mounts a full-viewport `role="dialog"` overlay with `z-index:200`
|
||
// that intercepts every pointer event — every authenticated spec
|
||
// that tries to click a header / sidebar / quick-add button times
|
||
// out (50+ failed CI runs since v1.4.13). Specs that need the
|
||
// tour can opt back in by mocking `/api/auth/me`.
|
||
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,
|
||
onboarding_tour_completed)
|
||
VALUES ($1, $2, $3, $4, $5,
|
||
$6, $6,
|
||
180, $7, 'MALE',
|
||
'disconnected',
|
||
'aggregated',
|
||
$6,
|
||
true)
|
||
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,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||
[
|
||
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}`,
|
||
);
|
||
}
|