mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The integration leg reads GET /api/auth/me as a guardian acting on a managed profile and asserts both lists, lands a mood entry and an allergy on the profile's record, is refused a vault write, and shows a WRITE delegate writable sections without manageable ones together with the refused mood entry the list withheld. The browser leg opens the managed profile, adds a mood entry through the control that used to be hidden and asserts the POST's status, and finds the owner-only sentence and no upload control on the documents page. The spec gets its own session jar and runs in the desktop project only, like every other spec that moves the record selector.
839 lines
33 KiB
TypeScript
839 lines
33 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;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* v1.37.2 — the owner carries a full name, so the switcher journey can assert
|
||
* that a delegate sees it. Generic on purpose: fixtures hold no real name.
|
||
*/
|
||
export const E2E_OWNER_FULL_NAME = "Test Full Name";
|
||
|
||
/**
|
||
* v1.37.0 — the account that creates and administers managed profiles.
|
||
*
|
||
* Its own account, and not one of the others, for one reason: every route in
|
||
* the managed-profile family resolves `requireFreshMfa`, which refuses an
|
||
* account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So
|
||
* this account carries a confirmed TOTP secret, and an account with a confirmed
|
||
* secret cannot be logged in by the password-only capture below. The setup
|
||
* therefore logs it in FIRST and stamps the factor afterwards — see the
|
||
* enrolment step at the end of `globalSetup`.
|
||
*
|
||
* Enrolling the factor on a shared account would have changed behaviour for
|
||
* every spec that touches a step-up-gated surface, starting with the MANAGE
|
||
* invitation on the sharing panel.
|
||
*/
|
||
export const E2E_GUARDIAN = {
|
||
email: "e2e-guardian@healthlog.test",
|
||
username: "e2e-guardian",
|
||
password: "Rt5!Nm8xQ3wZ6bJp",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/** A separate delegate session for the eight scoped-record browser journeys. */
|
||
export const E2E_SCOPE_DELEGATE = {
|
||
email: "e2e-scope-delegate@healthlog.test",
|
||
username: "e2e-scope-delegate",
|
||
password: "Qz8!Vp4rL2nX7mKs",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/** One non-personal target record per closed sharing domain. */
|
||
export const E2E_SCOPE_RECORDS = [
|
||
{
|
||
domain: "measurements",
|
||
username: "e2e-scope-measurements",
|
||
href: "/measurements",
|
||
},
|
||
{
|
||
domain: "medications",
|
||
username: "e2e-scope-medications",
|
||
href: "/medications",
|
||
},
|
||
{ domain: "labs", username: "e2e-scope-labs", href: "/labs" },
|
||
{ domain: "profile", username: "e2e-scope-profile", href: "/profile" },
|
||
{ domain: "illness", username: "e2e-scope-illness", href: "/illness" },
|
||
{ domain: "mind", username: "e2e-scope-mind", href: "/mood" },
|
||
{ domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" },
|
||
{ domain: "documents", username: "e2e-scope-documents", href: "/documents" },
|
||
] as const;
|
||
|
||
/**
|
||
* Whole-record grants used to prove the three adult access levels and the
|
||
* separate managed-profile boundary in a real browser session.
|
||
*/
|
||
export const E2E_LEVEL_RECORDS = [
|
||
{
|
||
username: "e2e-level-read",
|
||
access: "READ",
|
||
recordKind: "shared",
|
||
},
|
||
{
|
||
username: "e2e-level-write",
|
||
access: "WRITE",
|
||
recordKind: "shared",
|
||
},
|
||
{
|
||
username: "e2e-level-manage",
|
||
access: "MANAGE",
|
||
recordKind: "shared",
|
||
},
|
||
{
|
||
username: "e2e-level-managed",
|
||
access: "MANAGE",
|
||
recordKind: "managed",
|
||
},
|
||
] 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",
|
||
);
|
||
|
||
export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateScopeDelegate.json",
|
||
);
|
||
|
||
export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateScopeA11y.json",
|
||
);
|
||
|
||
/**
|
||
* v1.38.12 — the managed-record capabilities journey's own jar. Same scope
|
||
* delegate, its own session row, for the reason every switching spec has
|
||
* one: it moves the record selector, and two specs driving one row from two
|
||
* workers would test their interference rather than the controls.
|
||
*/
|
||
export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateScopeCapabilities.json",
|
||
);
|
||
|
||
/**
|
||
* v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at
|
||
* `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as
|
||
* well.
|
||
*
|
||
* The record-session fence made the cost visible. Four specs moved the same
|
||
* session row's record selector — the two fence specs, the cross-tab journey
|
||
* and the scoped-sharing journey — and the fence's whole subject is that row's
|
||
* context and its monotonic epoch. On one worker they never overlapped; on two
|
||
* they moved the epoch under each other, and the failure was the harness racing
|
||
* itself. Passing only at `--workers=1` is not a passing suite: CI runs two.
|
||
*
|
||
* A separate LOGIN, not a separate account: the selector lives on the session
|
||
* row, so two jars for one account are two independent contexts. The grants,
|
||
* the fixtures and the assertions all stay exactly as they were.
|
||
*/
|
||
export const FENCE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateFence.json",
|
||
);
|
||
|
||
export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateFenceOffline.json",
|
||
);
|
||
|
||
/**
|
||
* The managed-profile journey's own jar. Its own session row, like every other
|
||
* switching spec, so a run at CI's worker count cannot have two specs moving
|
||
* the same session's record selector.
|
||
*/
|
||
export const GUARDIAN_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateGuardian.json",
|
||
);
|
||
|
||
export const CROSS_TAB_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateCrossTab.json",
|
||
);
|
||
|
||
/**
|
||
* The banner-geometry spec's jar, and the reason it exists is a release-shaped
|
||
* one rather than a load-shaped one.
|
||
*
|
||
* `chrome-header-seam-banners.spec.ts` needs a session that is INSIDE somebody
|
||
* else's record, because one of the strips it stacks only paints there. It used
|
||
* to produce that state by capturing `/api/auth/me` and serving it back with
|
||
* the sharing block filled — cheaper than a switch, and self-contained, which is
|
||
* exactly why it was written that way.
|
||
*
|
||
* v1.37.0 ended that. The record context is now proved across three places at
|
||
* once — the payload's own validator (fail-closed, no partial credit), the fence
|
||
* store's scope, and the transition machine that has to agree with both — so a
|
||
* payload claiming a shared record while the session row still says `self` is a
|
||
* state the shell refuses on purpose. A single route mock can no longer make it,
|
||
* and the honest fix is to stop faking it: switch for real, and take a jar of
|
||
* its own so moving one session row cannot move anybody else's.
|
||
*/
|
||
export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSeamBanners.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 so the nutrients/hydration display specs render the card.
|
||
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,
|
||
],
|
||
);
|
||
|
||
// v1.37.0 — the managed-profile guardian. Seeded exactly like the other
|
||
// fixture accounts; its second factor is stamped AFTER the login capture,
|
||
// because the password-only capture cannot complete a TOTP login.
|
||
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,
|
||
totp_confirmed_at = NULL`,
|
||
[
|
||
cuid(),
|
||
E2E_GUARDIAN.username,
|
||
E2E_GUARDIAN.email,
|
||
await hashPassword(E2E_GUARDIAN.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// Whatever a previous run created. A managed profile is a real account row,
|
||
// so a suite that left one behind would find the guardian looking after two
|
||
// records on the next run and the roster assertions would read the wrong
|
||
// one. Deleting the profile takes its grants with it.
|
||
await pool.query(
|
||
`DELETE FROM users
|
||
WHERE managed_profile_at IS NOT NULL
|
||
AND id IN (
|
||
SELECT g.grantor_id FROM account_grants g
|
||
JOIN users u ON u.id = g.grantee_id
|
||
WHERE u.username = $1
|
||
)`,
|
||
[E2E_GUARDIAN.username],
|
||
);
|
||
|
||
// The scoped-record journey uses a dedicated delegate so its preseeded
|
||
// grants cannot affect the invitation lifecycle exercised by the sharing
|
||
// journey above. Its own module preferences are explicitly off: a target
|
||
// record's resolved scope, rather than the actor's preferences, must keep
|
||
// each granted doorway visible.
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role, created_at, updated_at,
|
||
onboarding_completed_at, onboarding_tour_completed,
|
||
module_preferences_json)
|
||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb)
|
||
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,
|
||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||
[
|
||
cuid(),
|
||
E2E_SCOPE_DELEGATE.username,
|
||
E2E_SCOPE_DELEGATE.email,
|
||
await hashPassword(E2E_SCOPE_DELEGATE.password),
|
||
now,
|
||
JSON.stringify({
|
||
medications: false,
|
||
labs: false,
|
||
illness: false,
|
||
mood: false,
|
||
mentalHealth: false,
|
||
inboundDocuments: false,
|
||
}),
|
||
],
|
||
);
|
||
|
||
await pool.query(
|
||
`DELETE FROM account_grants
|
||
WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`,
|
||
[E2E_SCOPE_DELEGATE.username],
|
||
);
|
||
await pool.query(
|
||
`UPDATE sessions SET acting_as_user_id = NULL
|
||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||
[E2E_SCOPE_DELEGATE.username],
|
||
);
|
||
|
||
for (const record of E2E_SCOPE_RECORDS) {
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role, created_at, updated_at,
|
||
onboarding_completed_at, onboarding_tour_completed,
|
||
module_preferences_json)
|
||
VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||
[
|
||
cuid(),
|
||
record.username,
|
||
`${record.username}@healthlog.test`,
|
||
now,
|
||
JSON.stringify({
|
||
medications: true,
|
||
labs: true,
|
||
illness: true,
|
||
mood: true,
|
||
mentalHealth: true,
|
||
inboundDocuments: true,
|
||
}),
|
||
],
|
||
);
|
||
|
||
await pool.query(
|
||
`INSERT INTO account_grants
|
||
(id, grantor_id, grantee_id, access, scope_json, invited_at,
|
||
accepted_at, created_at)
|
||
SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3
|
||
FROM users owner, users delegate
|
||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||
[
|
||
cuid(),
|
||
JSON.stringify([record.domain]),
|
||
now,
|
||
record.username,
|
||
E2E_SCOPE_DELEGATE.username,
|
||
],
|
||
);
|
||
}
|
||
|
||
// Whole-record fixtures intentionally sit beside the narrow records
|
||
// above. They make the browser prove the presentation and mutation
|
||
// boundaries of READ, WRITE, MANAGE, and a guardian-managed record
|
||
// without changing the invitation lifecycle fixture below.
|
||
for (const record of E2E_LEVEL_RECORDS) {
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role, created_at, updated_at,
|
||
managed_profile_at, onboarding_completed_at,
|
||
onboarding_tour_completed, module_preferences_json)
|
||
VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = NULL,
|
||
updated_at = EXCLUDED.updated_at,
|
||
managed_profile_at = EXCLUDED.managed_profile_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||
[
|
||
cuid(),
|
||
record.username,
|
||
`${record.username}@healthlog.test`,
|
||
now,
|
||
record.recordKind === "managed" ? now : null,
|
||
JSON.stringify({
|
||
medications: true,
|
||
labs: true,
|
||
illness: true,
|
||
mood: true,
|
||
mentalHealth: true,
|
||
inboundDocuments: true,
|
||
}),
|
||
],
|
||
);
|
||
|
||
await pool.query(
|
||
`INSERT INTO account_grants
|
||
(id, grantor_id, grantee_id, access, scope_json, invited_at,
|
||
accepted_at, created_at)
|
||
SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3
|
||
FROM users owner, users delegate
|
||
WHERE owner.username = $4 AND delegate.username = $5`,
|
||
[
|
||
cuid(),
|
||
record.access,
|
||
now,
|
||
record.username,
|
||
E2E_SCOPE_DELEGATE.username,
|
||
],
|
||
);
|
||
}
|
||
|
||
// Cycle is delegated to its profile gate rather than module preferences.
|
||
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-scope-cycle"],
|
||
);
|
||
|
||
// 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, full_name)
|
||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6)
|
||
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,
|
||
full_name = EXCLUDED.full_name`,
|
||
[
|
||
cuid(),
|
||
E2E_OWNER.username,
|
||
E2E_OWNER.email,
|
||
await hashPassword(E2E_OWNER.password),
|
||
now,
|
||
E2E_OWNER_FULL_NAME,
|
||
],
|
||
);
|
||
|
||
// 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 TEN times now
|
||
// (the shared jar, the owner, and one jar apiece for every spec that moves
|
||
// a session's record selector), 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],
|
||
);
|
||
}
|
||
|
||
// v1.37.0 — clear what the browser suite WRITES, so the suite can be run
|
||
// twice.
|
||
//
|
||
// The adult-WRITE journey saves a blood-pressure reading through the real
|
||
// form, and the form stamps a minute-rounded `measuredAt`. Two runs inside
|
||
// the same minute therefore collide on the natural key
|
||
// (user, type, measured_at, source) — the second save is a duplicate, and
|
||
// the journey asserts its POST succeeded. It has always been that way; it
|
||
// used to surface as a 500 and now surfaces as the 409 the route learned to
|
||
// give, which is what made it legible. A suite that cannot be re-run is a
|
||
// suite whose green is a statement about the clock.
|
||
//
|
||
// Only rows the journey itself creates: MANUAL, and carrying no
|
||
// `external_id`, which is what distinguishes them from the seeded markers
|
||
// above.
|
||
await pool.query(
|
||
`DELETE FROM measurements
|
||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||
AND source = 'MANUAL'
|
||
AND external_id IS NULL`,
|
||
["e2e-level-write"],
|
||
);
|
||
|
||
// 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})`,
|
||
);
|
||
// 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";
|
||
|
||
/**
|
||
* Log one account in, clearing the login bucket first.
|
||
*
|
||
* The ceiling is FIVE attempts per IP per quarter-hour and this setup now
|
||
* signs in ten times, so clearing once before the batch is no longer
|
||
* enough — the sixth would be answered by the fixture's own 429 rather
|
||
* than by the product. Only the auth surfaces' buckets are touched, and
|
||
* only between logins this setup is itself performing.
|
||
*/
|
||
const capture = async (
|
||
account: { username: string; password: string },
|
||
path: string,
|
||
): Promise<void> => {
|
||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||
await captureAuthState(baseURL, account, path);
|
||
};
|
||
|
||
// The shared jar every authenticated spec reads.
|
||
await capture(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 capture(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 capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||
|
||
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
|
||
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
|
||
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
|
||
|
||
// v1.37.0 — one jar per switching spec. See the block comment on
|
||
// FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's
|
||
// row with the scoped-sharing journey, which is why the suite only passed
|
||
// on a single worker.
|
||
await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH);
|
||
await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH);
|
||
await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH);
|
||
await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH);
|
||
|
||
// v1.37.0 — the guardian's jar, and only then its second factor.
|
||
//
|
||
// Order is the whole of it. `requireFreshMfa` refuses an account with no
|
||
// factor enrolled, so the managed-profile routes are unreachable without
|
||
// this; and `captureAuthState` posts a password to `/api/auth/login`, which
|
||
// for a TOTP account answers with a challenge rather than a session. Log in
|
||
// first, enrol second. The freshness stamp on the session is refreshed by
|
||
// the spec immediately before it acts — the window is five minutes and a
|
||
// full suite run is longer than that.
|
||
await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH);
|
||
await pool.query(
|
||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||
[E2E_GUARDIAN.username, now],
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
}
|