Files
HealthLog/e2e/setup/global-setup.ts
T
Marc-André Bombeck 20f5ae27a0 test(e2e): give the account-switching journey its own session
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.

The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.

Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.

The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
2026-08-02 16:49:47 +02:00

406 lines
16 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;
/**
* v1.36.0 — the SECOND account, so account sharing has two sides to test.
*
* Sharing is the one feature in the product that cannot be exercised by one
* person: somebody has to own a record and somebody else has to be given it.
* This account owns the record; `E2E_USER` is the delegate throughout the
* sharing spec.
*/
export const E2E_OWNER = {
email: "e2e-owner@healthlog.test",
username: "e2e-owner",
password: "Qv7bR2n!Ks9wLd4T",
role: "USER",
} as const;
/**
* Weights seeded one per account, chosen so neither can be mistaken for the
* other in rendered markup and neither collides with a value any other spec
* writes.
*
* The delegate's number is the whole point of the stale-paint check: after
* switching into the owner's record it must appear NOWHERE, at no moment. A
* spec that only waited for the owner's data to arrive would prove nothing
* about what was on screen in between.
*/
export const E2E_DELEGATE_MARKER_KG = 77.7;
export const E2E_OWNER_MARKER_KG = 63.3;
export const OWNER_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateOwner.json",
);
/**
* A SECOND cookie jar for the same delegate account, and the reason it exists
* is the one property that makes account switching work at all: the switch is
* stamped on the session row, not held in the tab. Every session opened from
* `STORAGE_STATE_PATH` is therefore the same session — so a spec that switches
* that jar into somebody else's record switches it for every spec that
* authenticates with it, whichever of them happens to be mid-navigation on the
* other worker.
*
* The symptom is a wandering one: `/settings/ai` painting "Not part of shared
* access", an axe scan reading a banner nobody asked for, a `waitForResponse`
* on a request the refused surface never sends. Nothing in the failing spec is
* wrong and nothing in its file explains it.
*
* So the journey that switches gets its own login, and the shared jar is never
* pointed anywhere. Same account, different session row, no overlap.
*/
export const DELEGATE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateDelegate.json",
);
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`.
// Nutrients is intentionally opt-in in production. The shared fixture
// enables it because the water-capture specs exercise that real gate.
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,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5,
$6, $6,
180, $7, 'MALE',
'disconnected',
'aggregated',
$6,
true,
'{"nutrients":true}'::jsonb)
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,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_USER.username,
E2E_USER.email,
passwordHash,
E2E_USER.role,
now,
dob,
],
);
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
// the share flow. Reset only this fixture user's bucket; never touch
// unrelated rate-limit state in a developer database.
await pool.query(
`DELETE FROM rate_limits
WHERE key = 'share-link:' || (
SELECT id::text FROM users WHERE username = $1
)`,
[E2E_USER.username],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
// derives from gender, so a male account with no profile reads as
// cycle-OFF and `/cycle` redirects home. An explicit `true` opts the
// account in regardless of gender, which is what the cycle spec needs.
// Keyed by the username subquery so we never depend on the cuid above
// (the user upsert may have hit the ON CONFLICT path on a re-run).
await pool.query(
`INSERT INTO cycle_profiles
(id, user_id, cycle_tracking_enabled, created_at, updated_at)
SELECT $1, u.id, true, $2, $2
FROM users u
WHERE u.username = $3
ON CONFLICT (user_id) DO UPDATE SET
cycle_tracking_enabled = true,
updated_at = EXCLUDED.updated_at`,
[cuid(), now, E2E_USER.username],
);
// ── v1.36.0 — the sharing fixture ────────────────────────────────────
//
// A second account, one distinctive weight in each record, and a clean
// grant table. The reset matters as much as the seed: grants and switch
// stamps outlive a run, and a second run starting with the delegate
// already inside the owner's record would skip the invitation half of
// the journey while still going green.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
[
cuid(),
E2E_OWNER.username,
E2E_OWNER.email,
await hashPassword(E2E_OWNER.password),
now,
],
);
// Every grant between the pair, and any switch stamp either of them is
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
// lifecycle transition, and a revoked row would still show in the panel.
await pool.query(
`DELETE FROM account_grants
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`UPDATE sessions SET acting_as_user_id = NULL
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
[[E2E_USER.username, E2E_OWNER.username]],
);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'sharing:%'`,
);
// The login bucket, for the same reason. This setup signs in THREE times
// now (the shared jar, the owner, and the sharing journey's own delegate
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
// local runs in a row would otherwise end with a 429 from the fixture
// rather than a failure from the product. Only the auth surfaces' own
// buckets are cleared; nothing else in the table is touched.
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
// One marker weight per record. Re-seeded by delete-then-insert so a
// re-run cannot accumulate duplicates and so an edited constant takes
// effect rather than sitting beside the old value.
for (const [username, kg] of [
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
] as const) {
await pool.query(
`DELETE FROM measurements
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND external_id = 'e2e-sharing-marker'`,
[username],
);
await pool.query(
`INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at,
external_id, created_at, updated_at)
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
'e2e-sharing-marker', $3, $3
FROM users u WHERE u.username = $4`,
[cuid(), kg, now, username],
);
}
// 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();
}
// Log the fixture accounts in here and persist their cookie jars so specs
// can reuse them via `test.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";
// The shared jar every authenticated spec reads.
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
// is logged in here rather than inside the spec.
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
// The delegate's own jar for that journey. Same account as the shared one,
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
// what happens when the journey switches the shared row instead.
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
}
/**
* Log one account in and write its cookie jar to `path`.
*
* Each call mints a fresh session row, so two jars for the same account are
* two independent browser sessions — which is the whole point of the delegate
* jar above.
*
* Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added
* `hl_onboarding`: the login route sets it to "pending" for a new account and
* DELETES it for an already-onboarded one, so for these pre-seeded accounts it
* is absent and the proxy passes the dashboard through.
*
* v1.27.11 — the locale cookie is pinned to English rather than inherited. The
* server honours `User.locale` when the cookie is absent (the ITP fix), and
* the locale-switch spec mirrors its German pick into the SHARED account's
* profile — without this cookie every spec running after it would render
* German and the English-string assertions would fail. The cookie sits at the
* top of the resolution ladder, so each spec context stays deterministically
* English; locale-switch overrides it inside its own context only.
*/
async function captureAuthState(
baseURL: string,
account: { username: string; password: string },
path: string,
): Promise<void> {
const ctx = await playwrightRequest.newContext({ baseURL });
try {
const res = await ctx.post("/api/auth/login", {
data: { email: account.username, password: account.password },
});
if (res.status() !== 200) {
const body = await res.text();
throw new Error(
`[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`,
);
}
const state = await ctx.storageState();
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "hl_onboarding"].includes(c.name),
);
state.cookies.push({
name: "healthlog-locale",
value: "en",
domain: new URL(baseURL).hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(path, JSON.stringify(state, null, 2));
console.log(`[e2e/global-setup] auth state captured to ${path}`);
} finally {
await ctx.dispose();
}
}