mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
The vault's layout is account state. documents.spec read the default while the layout journey in v142-qol turned it on the shared account, so which one failed depended on the order they ran in. Each now runs on a vault account of its own; the vault fixture seeds the same corpus under ids tagged per account, so two vaults never claim one row. Global setup clears every rate-limit bucket keyed on a fixture account or on the loopback addresses the suite calls from, not only the login and share buckets: the check-in, write, export and import buckets outlive a run by up to an hour, and a second run on the same database met the first run's counts. A developer's own accounts are left alone. The layout journey waits for its own write rather than whichever layout write answers first.
1910 lines
74 KiB
TypeScript
1910 lines
74 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";
|
||
|
||
import { allCriticalAccounts, FORM_SIGN_IN_JOURNEYS } from "./critical-fixture";
|
||
import { seedDayDoorsRecord } from "./day-doors-fixture";
|
||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||
|
||
// 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";
|
||
|
||
/**
|
||
* The backup/restore journey's own account.
|
||
*
|
||
* Its own account, and an administrative one, because the restore path is
|
||
* admin-only by construction (`requireAdmin()` is cookie-only) and because the
|
||
* journey is destructive on purpose: it deletes rows, restores an earlier
|
||
* snapshot over them, and asserts what came back. Pointing that at an account
|
||
* any other spec reads would make every one of them depend on where this
|
||
* journey happens to be.
|
||
*/
|
||
export const E2E_BACKUP_ADMIN = {
|
||
email: "e2e-backup-admin@healthlog.test",
|
||
username: "e2e-backup-admin",
|
||
password: "Hw3!Tq7vZm2xRb8L",
|
||
role: "ADMIN",
|
||
} as const;
|
||
|
||
/**
|
||
* The read-level delegate on that account's record.
|
||
*
|
||
* The refusing half of the journey needs somebody who is inside the record and
|
||
* still has no business taking or replaying a copy of it. A separate account
|
||
* rather than one of the existing delegates: this one is granted READ over the
|
||
* whole record above, and reusing a delegate would change what the scoped
|
||
* journeys see.
|
||
*/
|
||
export const E2E_BACKUP_DELEGATE = {
|
||
email: "e2e-backup-delegate@healthlog.test",
|
||
username: "e2e-backup-delegate",
|
||
password: "Ld6!Yn4kW9pJc3Vt",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* The delegated-writes journey's own pair.
|
||
*
|
||
* That journey used to invite `E2E_USER` from `E2E_OWNER`, the same pair the
|
||
* account-sharing journey invites. Only one live grant can stand between two
|
||
* accounts, so whichever of the two files reached its invitation second met a
|
||
* 409, and on CI the two can run in parallel. A pair of its own removes the
|
||
* collision instead of ordering around it.
|
||
*/
|
||
export const E2E_WRITE_OWNER = {
|
||
email: "e2e-write-owner@healthlog.test",
|
||
username: "e2e-write-owner",
|
||
password: "Rk5!Wq8tHn3zPv6D",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/** Generic on purpose, like `E2E_OWNER_FULL_NAME`: fixtures hold no real name. */
|
||
export const E2E_WRITE_OWNER_FULL_NAME = "Test Write Owner";
|
||
|
||
export const E2E_WRITE_DELEGATE = {
|
||
email: "e2e-write-delegate@healthlog.test",
|
||
username: "e2e-write-delegate",
|
||
password: "Tm4!Xc9vBs2kLw7J",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* 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;
|
||
|
||
/**
|
||
* The medication-adherence journey's own account.
|
||
*
|
||
* The batched compliance read is capped at thirty calls a minute per ACTOR,
|
||
* and the journey spends that allowance twice over: it writes doses and then
|
||
* reads the resulting rates back off the same cabinet. On the shared account
|
||
* the allowance is also being spent by whichever sibling happens to be
|
||
* mounting `/medications` in the other worker, and the cabinet itself is
|
||
* shared with them — so the journey's `beforeEach` clear would take rows
|
||
* somebody else seeded with it. One account of its own makes the rate-limit
|
||
* bucket, the cabinet and the dashboard tile's denominator private at once.
|
||
*/
|
||
export const E2E_MEDICATION = {
|
||
email: "e2e-medication@healthlog.test",
|
||
username: "e2e-medication",
|
||
password: "Hj6!Tw2pV9sQ4nDx",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* The module-surfaces journey's own account (`modules-off.spec.ts`).
|
||
*
|
||
* It switches mood and medications off and on again, and those switches are
|
||
* read by every surface of the account. On the shared account a parallel spec
|
||
* reading a mood chart or the add menu would see them flip underneath it, so
|
||
* the journey gets a record nobody else reads.
|
||
*/
|
||
export const E2E_MODULES = {
|
||
email: "e2e-modules@healthlog.test",
|
||
username: "e2e-modules",
|
||
password: "Qm4!Vr8tK2wZp7Ls",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const MODULES_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateModules.json",
|
||
);
|
||
|
||
/**
|
||
* The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`).
|
||
*
|
||
* It holds one or two of everything a route can render
|
||
* (`mobile-routes-fixture.ts`), because the sweep is only worth something
|
||
* against populated pages, and writing that much into the shared account would
|
||
* change what every other spec reads. An administrator, so the admin console
|
||
* is swept too; `FEMALE`, so the cycle surfaces render without a switch.
|
||
*/
|
||
export const E2E_MOBILE_ROUTES = {
|
||
email: "e2e-mobile-routes@healthlog.test",
|
||
username: "e2e-mobile-routes",
|
||
password: "Wd7!Kp3mTz9qRv2N",
|
||
role: "ADMIN",
|
||
} as const;
|
||
|
||
export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateMobileRoutes.json",
|
||
);
|
||
|
||
/**
|
||
* The AI-optional journeys' own account (`ai-optional-*.spec.ts`).
|
||
*
|
||
* The consent journey gives it a provider, a stored briefing and an AI
|
||
* consent, then withdraws the consent; the Coach-memory journey hides its
|
||
* Coach and deletes a stored conversation. Both change what every AI surface
|
||
* of the account shows, so they run on a record nobody else reads.
|
||
*/
|
||
export const E2E_AI_OPTIONAL = {
|
||
email: "e2e-ai-optional@healthlog.test",
|
||
username: "e2e-ai-optional",
|
||
password: "Rw5!Nc9pT3xLq6Zb",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* The timeline journey's own account (`timeline.spec.ts`).
|
||
*
|
||
* It switches the opt-in timeline module on and off, which changes the
|
||
* navigation, the add menus and Settings for the whole account; on the shared
|
||
* account a parallel spec would see them move underneath it. The module
|
||
* starts off here, the way every account starts.
|
||
*/
|
||
export const E2E_TIMELINE = {
|
||
email: "e2e-timeline@healthlog.test",
|
||
username: "e2e-timeline",
|
||
password: "Tl6!Wq3zRm8vXn2K",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const TIMELINE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateTimeline.json",
|
||
);
|
||
|
||
/**
|
||
* The vault specs' own accounts (`documents.spec.ts`, and the documents
|
||
* journeys of `v142-qol.spec.ts`).
|
||
*
|
||
* The vault's layout (cards or a list, months stacked or side by side) is a
|
||
* per-account preference. On the shared account the layout journey turned it
|
||
* while the month-grid tests read the default, so either could fail depending
|
||
* on which ran first. Each file seeds its own vault into its own account
|
||
* (`ensureVaultFixture(username)`).
|
||
*/
|
||
export const E2E_VAULT = {
|
||
email: "e2e-vault@healthlog.test",
|
||
username: "e2e-vault",
|
||
password: "Vt4!Hq8mZr2wKx6N",
|
||
// The upload-cap test sets the instance's cap through the admin console.
|
||
role: "ADMIN",
|
||
} as const;
|
||
|
||
export const VAULT_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateVault.json",
|
||
);
|
||
|
||
export const E2E_VAULT_LAYOUT = {
|
||
email: "e2e-vault-layout@healthlog.test",
|
||
username: "e2e-vault-layout",
|
||
password: "Vl7!Bn3qXw9pRt5M",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const VAULT_LAYOUT_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateVaultLayout.json",
|
||
);
|
||
|
||
/**
|
||
* The day-doors journey's own account (`day-doors.spec.ts`).
|
||
*
|
||
* It turns a chart's comparison overlay on, which is a per-account
|
||
* preference; on a shared account a parallel spec would find its chart
|
||
* drawing a prior period it never asked for. Its record carries the series
|
||
* the journey clicks (recovery, sleep, mood, weight, a lab marker).
|
||
*/
|
||
export const E2E_DAY_DOORS = {
|
||
email: "e2e-day-doors@healthlog.test",
|
||
username: "e2e-day-doors",
|
||
password: "Dy4!Rk8wQm2vTz6P",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const DAY_DOORS_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateDayDoors.json",
|
||
);
|
||
|
||
/**
|
||
* The score-history journey's own account (`score-history.spec.ts`).
|
||
*
|
||
* It switches the dashboard's top card off and back on and remembers a range
|
||
* tab per score page, both per-account preferences; on a shared account a
|
||
* parallel spec would find its dashboard without the card it asserts.
|
||
*/
|
||
export const E2E_SCORE_HISTORY = {
|
||
email: "e2e-score-history@healthlog.test",
|
||
username: "e2e-score-history",
|
||
password: "Sh7!Vn2qLp9xWc4R",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateScoreHistory.json",
|
||
);
|
||
|
||
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateAiOptional.json",
|
||
);
|
||
|
||
/**
|
||
* The notification-dispatch journey's own account.
|
||
*
|
||
* It has one, rather than borrowing the shared fixture, because the journey's
|
||
* verdicts are counts over ONE account's `push_attempts` ledger — "one email
|
||
* attempt, no ntfy attempt, the APNs arm skipped and for which reason".
|
||
*
|
||
* The direction that matters is the one that is easy to state backwards. The
|
||
* exposure is not mainly that another spec writes into this account's window;
|
||
* it is that this journey's own trigger — the admin reminder sweep — dispatches
|
||
* for whatever accounts it is pointed at, and pointed at the instance it
|
||
* reaches all of them. A dedicated account gives the sweep something to name,
|
||
* so the run stays a writer for exactly one ledger and a reader of exactly the
|
||
* same one.
|
||
*
|
||
* ADMIN because the two surfaces the journey reads the dispatch decision
|
||
* through — the reminder trigger and the notification diagnostic — are
|
||
* `requireAdmin()`, which is cookie-only by construction.
|
||
*/
|
||
export const E2E_NOTIFY = {
|
||
email: "e2e-notify@healthlog.test",
|
||
username: "e2e-notify",
|
||
password: "Xd6!Ct3pW9qBz5Fh",
|
||
role: "ADMIN",
|
||
} as const;
|
||
|
||
export const NOTIFY_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateNotify.json",
|
||
);
|
||
|
||
/** 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;
|
||
|
||
/**
|
||
* The doctor-report journey's own account.
|
||
*
|
||
* Generating a report WRITES: the route remembers the scope and the practice
|
||
* name on the account row, and the panel a later visit opens is therefore the
|
||
* "repeat run" state with the picker collapsed. `settings-export.spec.ts`
|
||
* asserts the opposite — an expanded picker, an empty summary, a disabled
|
||
* button — against the shared account, so a journey that generated there would
|
||
* turn nine of its assertions red the moment the two files happened to run in
|
||
* the wrong order, and keep them red on every later run against the same
|
||
* database.
|
||
*
|
||
* Its own account also keeps the seeded readings, the emergency profile and
|
||
* the hourly export bucket off the account sixty other specs read.
|
||
*
|
||
* `date_format` is pinned rather than left on AUTO: the spec reads the period
|
||
* line out of the rendered PDF and has to know which field is the month.
|
||
*/
|
||
export const E2E_REPORT_OWNER = {
|
||
email: "e2e-report-owner@healthlog.test",
|
||
username: "e2e-report-owner",
|
||
password: "Hd6!Tw3pV9nQ2xLb",
|
||
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",
|
||
);
|
||
|
||
/** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */
|
||
export const MEDICATION_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateMedication.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 delegated-writes journey's jars, one per side of its own pair. */
|
||
export const WRITE_OWNER_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateWriteOwner.json",
|
||
);
|
||
|
||
export const WRITE_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateWriteDelegate.json",
|
||
);
|
||
|
||
/** The backup/restore journey's jar, for the account it owns outright. */
|
||
export const BACKUP_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateBackup.json",
|
||
);
|
||
|
||
/**
|
||
* The read-level delegate's jar for that journey. Its own session row, like
|
||
* every other switching spec's — the refusal control switches into the
|
||
* journey's record, and the switch is stamped on the row rather than held in
|
||
* the tab.
|
||
*/
|
||
export const BACKUP_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateBackupDelegate.json",
|
||
);
|
||
|
||
/**
|
||
* The doctor-report delegate journey's jar. Its own session row, for the reason
|
||
* every switching spec has one: it opens somebody else's record to prove the
|
||
* report refuses a READ-level grant, and two specs driving one row from two
|
||
* workers would test their interference rather than the refusal.
|
||
*/
|
||
export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateReportDelegate.json",
|
||
);
|
||
|
||
/**
|
||
* The doctor-report journey's jar. Its own account, so what generating a report
|
||
* stamps on the account row cannot reach the shared one — see `E2E_REPORT_OWNER`.
|
||
*/
|
||
export const REPORT_OWNER_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateReportOwner.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",
|
||
);
|
||
|
||
/**
|
||
* v1.39 — the setup flow's own accounts, one per journey.
|
||
*
|
||
* Each journey walks the questions from the welcome screen to the dashboard
|
||
* and asserts what the walk wrote: the module map, the tile order, the
|
||
* checklist, the reading or medication or profile the first-result step
|
||
* produced. Two journeys sharing an account would each read the other's
|
||
* answers, so every path has its own — and the two screen sweeps (axe, and
|
||
* the string-headroom check across every locale) have theirs, because they
|
||
* drive the same state through the API while a journey may be mid-walk.
|
||
*
|
||
* Seeded with `onboarding_completed_at` NULL, which is what makes the shell
|
||
* land them on `/onboarding` in the first place; the fixture puts each one
|
||
* back to that state before every test.
|
||
*/
|
||
export const E2E_SETUP_BP = {
|
||
email: "e2e-setup-bp@healthlog.test",
|
||
username: "e2e-setup-bp",
|
||
password: "Bp7!Wq3nZ9xLm2Kd",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const E2E_SETUP_MEDICATION = {
|
||
email: "e2e-setup-medication@healthlog.test",
|
||
username: "e2e-setup-medication",
|
||
password: "Md4!Rt8vC2yPn6Hs",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const E2E_SETUP_VISIT = {
|
||
email: "e2e-setup-visit@healthlog.test",
|
||
username: "e2e-setup-visit",
|
||
password: "Vs9!Kq2mX7bTd4Lp",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* The child-profile journey's account. It creates a managed profile on the
|
||
* confirm screen, which resolves `requireFreshMfa`, so like the guardian it
|
||
* carries a confirmed factor — stamped AFTER its login capture, because the
|
||
* password-only capture cannot complete a TOTP login.
|
||
*/
|
||
export const E2E_SETUP_CHILD = {
|
||
email: "e2e-setup-child@healthlog.test",
|
||
username: "e2e-setup-child",
|
||
password: "Ch3!Ns6wQ8kZr5Vb",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const E2E_SETUP_A11Y = {
|
||
email: "e2e-setup-a11y@healthlog.test",
|
||
username: "e2e-setup-a11y",
|
||
password: "Ay5!Ht9pL3cWn7Xq",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* v1.38.19 — the connect-source journey's account.
|
||
*
|
||
* The one arm of the flow that renders a claim about real account state had
|
||
* no journey that reached it: bp answers `manual`, visit answers `file`, and
|
||
* the child path ends at confirm. This account answers Q4 with a wearable, so
|
||
* `/onboarding/first-result` really does render the connect step.
|
||
*/
|
||
export const E2E_SETUP_CONNECT = {
|
||
email: "e2e-setup-connect@healthlog.test",
|
||
username: "e2e-setup-connect",
|
||
password: "Cn6!Zx4tB9wQm3Jf",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const E2E_SETUP_LOCALE = {
|
||
email: "e2e-setup-locale@healthlog.test",
|
||
username: "e2e-setup-locale",
|
||
password: "Lc2!Vm7dR4jYs8Nt",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
/**
|
||
* v1.38.19 — the shared-provider offer's own account.
|
||
*
|
||
* Its journey seeds INSTANCE-wide state (the operator key's presence and the
|
||
* provider-health ledger the tri-state folds) and mints a consent receipt on
|
||
* the account, so it cannot share one with a journey that asserts a module
|
||
* map or a tile order.
|
||
*/
|
||
export const E2E_SETUP_AI_OFFER = {
|
||
email: "e2e-setup-ai-offer@healthlog.test",
|
||
username: "e2e-setup-ai-offer",
|
||
password: "Ao6!Zd3fB9qMv2Rk",
|
||
role: "USER",
|
||
} as const;
|
||
|
||
export const SETUP_BP_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupBp.json",
|
||
);
|
||
export const SETUP_MEDICATION_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupMedication.json",
|
||
);
|
||
export const SETUP_VISIT_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupVisit.json",
|
||
);
|
||
export const SETUP_CHILD_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupChild.json",
|
||
);
|
||
export const SETUP_A11Y_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupA11y.json",
|
||
);
|
||
export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupConnect.json",
|
||
);
|
||
export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupLocale.json",
|
||
);
|
||
export const SETUP_AI_OFFER_STORAGE_STATE_PATH = resolve(
|
||
process.cwd(),
|
||
"e2e/setup/storageStateSetupAiOffer.json",
|
||
);
|
||
|
||
const SETUP_FLOW_ACCOUNTS = [
|
||
E2E_SETUP_BP,
|
||
E2E_SETUP_MEDICATION,
|
||
E2E_SETUP_VISIT,
|
||
E2E_SETUP_CHILD,
|
||
E2E_SETUP_A11Y,
|
||
E2E_SETUP_LOCALE,
|
||
E2E_SETUP_AI_OFFER,
|
||
E2E_SETUP_CONNECT,
|
||
] 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`.
|
||
// 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_encrypted = NULL,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
module_preferences_json = EXCLUDED.module_preferences_json,
|
||
report_selection_json = NULL,
|
||
last_report_practice_name = NULL`,
|
||
[
|
||
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 adherence journey's account. Modules are pinned rather than left to
|
||
// the defaults: the dashboard's medication tile is one of the surfaces the
|
||
// journey reads, and it only paints while the module is on.
|
||
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_MEDICATION.username,
|
||
E2E_MEDICATION.email,
|
||
await hashPassword(E2E_MEDICATION.password),
|
||
now,
|
||
JSON.stringify({ medications: true }),
|
||
],
|
||
);
|
||
|
||
// The module-surfaces journey's account. Its module switches are set by
|
||
// `e2e/setup/modules-fixture.ts` before every test.
|
||
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_MODULES.username,
|
||
E2E_MODULES.email,
|
||
await hashPassword(E2E_MODULES.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The phone-width route sweep's account. Every opt-in module is on, so
|
||
// the sweep reaches the glucose, documents, wellbeing, nutrients and
|
||
// environment surfaces; its record is written after the login capture.
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role, created_at, updated_at,
|
||
height_cm, date_of_birth, gender,
|
||
onboarding_completed_at, onboarding_tour_completed,
|
||
module_preferences_json)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
|
||
$8::jsonb)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
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_MOBILE_ROUTES.username,
|
||
E2E_MOBILE_ROUTES.email,
|
||
await hashPassword(E2E_MOBILE_ROUTES.password),
|
||
E2E_MOBILE_ROUTES.role,
|
||
now,
|
||
new Date("1988-04-12T00:00:00.000Z"),
|
||
JSON.stringify({
|
||
glucose: true,
|
||
environment: true,
|
||
inboundDocuments: true,
|
||
mentalHealth: true,
|
||
nutrients: true,
|
||
}),
|
||
],
|
||
);
|
||
|
||
// The AI-optional journeys' account. Its provider, stored briefing,
|
||
// consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts`
|
||
// before each journey.
|
||
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_AI_OPTIONAL.username,
|
||
E2E_AI_OPTIONAL.email,
|
||
await hashPassword(E2E_AI_OPTIONAL.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The timeline journey's account. The module starts off; the journey
|
||
// switches it through the real Settings switch.
|
||
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, NULL)
|
||
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 = NULL`,
|
||
[
|
||
cuid(),
|
||
E2E_TIMELINE.username,
|
||
E2E_TIMELINE.email,
|
||
await hashPassword(E2E_TIMELINE.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The vault specs' accounts — see `E2E_VAULT`. The vault fixture turns
|
||
// the documents module on and seeds the documents.
|
||
for (const account of [E2E_VAULT, E2E_VAULT_LAYOUT]) {
|
||
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, $6, $5, $5, $5, true)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||
[
|
||
cuid(),
|
||
account.username,
|
||
account.email,
|
||
await hashPassword(account.password),
|
||
now,
|
||
account.role,
|
||
],
|
||
);
|
||
}
|
||
|
||
// The day-doors journey's account. Every module the journey reaches is
|
||
// on; its record is written after the login capture.
|
||
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, NULL)
|
||
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 = NULL`,
|
||
[
|
||
cuid(),
|
||
E2E_DAY_DOORS.username,
|
||
E2E_DAY_DOORS.email,
|
||
await hashPassword(E2E_DAY_DOORS.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The score-history journey's account. Every module on, a fresh layout:
|
||
// the journey moves its top card and range tabs, so a re-run starts from
|
||
// the defaults.
|
||
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, NULL)
|
||
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 = NULL,
|
||
dashboard_widgets_json = NULL`,
|
||
[
|
||
cuid(),
|
||
E2E_SCORE_HISTORY.username,
|
||
E2E_SCORE_HISTORY.email,
|
||
await hashPassword(E2E_SCORE_HISTORY.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The notification-dispatch journey's account. Seeded like the others;
|
||
// its channels, devices, ledger rows and preferences are reset by
|
||
// `e2e/setup/notification-fixture.ts` before every test, because the
|
||
// journey has to be re-runnable and `--repeat-each` is exactly the case a
|
||
// once-per-suite reset does not cover.
|
||
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, $5, $6, $6, $6, true)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||
[
|
||
cuid(),
|
||
E2E_NOTIFY.username,
|
||
E2E_NOTIFY.email,
|
||
await hashPassword(E2E_NOTIFY.password),
|
||
E2E_NOTIFY.role,
|
||
now,
|
||
],
|
||
);
|
||
|
||
// The doctor-report journey's account. Seeded like the shared one — a
|
||
// record with a height, a date of birth and a gender, so the report has a
|
||
// patient header to print — and with the two report columns cleared, which
|
||
// is what puts the panel back into its first-run state on every run.
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role, created_at, updated_at,
|
||
height_cm, date_of_birth, gender, date_format,
|
||
onboarding_completed_at, onboarding_tour_completed)
|
||
VALUES ($1, $2, $3, $4, 'USER', $5, $5,
|
||
180, $6, 'MALE', 'MDY',
|
||
$5, true)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
updated_at = EXCLUDED.updated_at,
|
||
height_cm = EXCLUDED.height_cm,
|
||
date_of_birth = EXCLUDED.date_of_birth,
|
||
gender = EXCLUDED.gender,
|
||
date_format = EXCLUDED.date_format,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
report_selection_json = NULL,
|
||
last_report_practice_name = NULL`,
|
||
[
|
||
cuid(),
|
||
E2E_REPORT_OWNER.username,
|
||
E2E_REPORT_OWNER.email,
|
||
await hashPassword(E2E_REPORT_OWNER.password),
|
||
now,
|
||
dob,
|
||
],
|
||
);
|
||
|
||
// 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,
|
||
report_selection_json = NULL,
|
||
last_report_practice_name = NULL`,
|
||
[
|
||
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],
|
||
);
|
||
|
||
// The export bucket, for the same reason and with the same shape. Every
|
||
// export route shares one 10-per-hour bucket keyed on the ACTOR, the
|
||
// counter outlives a run by a full hour, and the doctor-report journey
|
||
// spends two of it per pass — so a fourth run inside the hour would meet a
|
||
// 429 and report a missing artefact rather than a spent quota. Only the two
|
||
// accounts that generate a report are cleared.
|
||
await pool.query(
|
||
`DELETE FROM rate_limits
|
||
WHERE key IN (
|
||
SELECT 'export:' || id::text FROM users WHERE username = ANY($1)
|
||
)`,
|
||
[[E2E_REPORT_OWNER.username, E2E_SCOPE_DELEGATE.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 more than twenty times
|
||
// (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:%'`);
|
||
|
||
// Every other bucket the suite can fill, for the same reason. A bucket is
|
||
// keyed on the account it meters (`<surface>:<id>`, sometimes with a
|
||
// further segment) or, on an anonymous surface, on the caller's address,
|
||
// and several of them outlive a run by up to an hour: the check-in
|
||
// ceiling, the per-account write bucket, the export and import buckets.
|
||
// A second run on the same database then met the first run's counts.
|
||
// Cleared are the buckets of the fixture accounts (every one of them is
|
||
// an `@healthlog.test` address or an `e2e-` record) and those of the
|
||
// loopback addresses the suite calls from; a developer's own accounts
|
||
// and their buckets are left alone.
|
||
await pool.query(
|
||
`DELETE FROM rate_limits r
|
||
USING users u
|
||
WHERE (u.email LIKE '%@healthlog.test' OR u.username LIKE 'e2e-%')
|
||
AND position(u.id IN r.key) > 0`,
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM rate_limits
|
||
WHERE key LIKE '%:127.0.0.1' OR key LIKE '%:::1'
|
||
OR key LIKE '%:::ffff:127.0.0.1' OR key LIKE '%:unknown'`,
|
||
);
|
||
|
||
// 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`,
|
||
// `measured_at` is `timestamp without time zone` and the driver
|
||
// serialises a `Date` in the HOST's zone, so a developer machine east
|
||
// of UTC seeded this row hours into the FUTURE — where every fold
|
||
// whose window ends "now" skips it, and the type stays uncovered.
|
||
// Send the instant as UTC, the same shape the app's own writes take.
|
||
[cuid(), kg, now.toISOString(), 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"],
|
||
);
|
||
|
||
// ── the delegated-writes journey's pair ──────────────────────────────
|
||
//
|
||
// Seeded like the sharing pair above and reset the same way: no grant
|
||
// between them, no switch stamp on either, so the journey always starts
|
||
// from the invitation. The spec revokes its grant when it ends; the delete
|
||
// here covers a run that died before getting there.
|
||
for (const [account, fullName] of [
|
||
[E2E_WRITE_OWNER, E2E_WRITE_OWNER_FULL_NAME],
|
||
[E2E_WRITE_DELEGATE, null],
|
||
] as const) {
|
||
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, $5, $6, $6, $6, true, $7)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
full_name = EXCLUDED.full_name,
|
||
totp_confirmed_at = NULL`,
|
||
[
|
||
cuid(),
|
||
account.username,
|
||
account.email,
|
||
await hashPassword(account.password),
|
||
account.role,
|
||
now,
|
||
fullName,
|
||
],
|
||
);
|
||
}
|
||
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_WRITE_OWNER.username, E2E_WRITE_DELEGATE.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_WRITE_OWNER.username, E2E_WRITE_DELEGATE.username]],
|
||
);
|
||
// The reading the delegate saves into the owner's record carries a
|
||
// minute-rounded `measuredAt`, so a second run inside the same minute would
|
||
// meet the natural-key 409 — the same reason `e2e-level-write` is cleared
|
||
// 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_WRITE_OWNER.username],
|
||
);
|
||
|
||
// ── the backup/restore journey's pair ────────────────────────────────
|
||
//
|
||
// The account the journey owns, with the three modules its readings need
|
||
// switched on, and the delegate that holds READ over the whole record.
|
||
// Both are re-seeded here rather than in the spec so the journey never
|
||
// spends a login on a surface the setup already owns.
|
||
for (const account of [E2E_BACKUP_ADMIN, E2E_BACKUP_DELEGATE] as const) {
|
||
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, $5, $6, $6, $6, true, $7::jsonb)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
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,
|
||
totp_confirmed_at = NULL`,
|
||
[
|
||
cuid(),
|
||
account.username,
|
||
account.email,
|
||
await hashPassword(account.password),
|
||
account.role,
|
||
now,
|
||
JSON.stringify({
|
||
medications: true,
|
||
mood: true,
|
||
inboundDocuments: true,
|
||
}),
|
||
],
|
||
);
|
||
}
|
||
|
||
// Grant hygiene before the grant, for the reason the sharing fixture
|
||
// states above: a grant and a switch stamp both outlive a run.
|
||
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_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.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_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username]],
|
||
);
|
||
// Whole-record READ: `scope_json` NULL is the un-narrowed grant, which is
|
||
// the strongest read a delegate can hold and therefore the honest subject
|
||
// for "and still cannot take or replay a copy".
|
||
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', NULL, $2, $2, $2
|
||
FROM users owner, users delegate
|
||
WHERE owner.username = $3 AND delegate.username = $4`,
|
||
[cuid(), now, E2E_BACKUP_ADMIN.username, E2E_BACKUP_DELEGATE.username],
|
||
);
|
||
|
||
// ── the @critical journeys' accounts ─────────────────────────────────
|
||
//
|
||
// One per journey and per project; `critical-fixture.ts` says why. Every
|
||
// module a journey reaches is switched on (wellbeing and labs are opt-in),
|
||
// and the passkeys the sign-in journey registers go with the row's reset,
|
||
// so a re-run registers into an empty list rather than beside the last
|
||
// run's credential.
|
||
for (const account of allCriticalAccounts()) {
|
||
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,
|
||
totp_confirmed_at = NULL`,
|
||
[
|
||
cuid(),
|
||
account.username,
|
||
account.email,
|
||
await hashPassword(account.password),
|
||
now,
|
||
JSON.stringify({
|
||
mood: true,
|
||
labs: true,
|
||
mentalHealth: true,
|
||
medications: true,
|
||
}),
|
||
],
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM passkeys
|
||
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
|
||
[account.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})`,
|
||
);
|
||
// 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.
|
||
// v1.39 — the setup flow's accounts. `onboarding_completed_at` NULL
|
||
// is the whole point: it is what lands them on `/onboarding`. The tour
|
||
// flag stays true for the reason every other fixture sets it. Whatever a
|
||
// previous run wrote — the answers row, the module map, the seeded
|
||
// dashboard, a managed profile — is cleared by the journeys' own fixture
|
||
// before each test; here the row itself is made to exist.
|
||
for (const account of SETUP_FLOW_ACCOUNTS) {
|
||
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, NULL, true)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = NULL,
|
||
onboarding_tour_completed = true,
|
||
totp_confirmed_at = NULL`,
|
||
[
|
||
cuid(),
|
||
account.username,
|
||
account.email,
|
||
await hashPassword(account.password),
|
||
now,
|
||
],
|
||
);
|
||
}
|
||
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_SETUP_CHILD.username],
|
||
);
|
||
|
||
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 more than twenty 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);
|
||
|
||
// The adherence journey's jar. Its own account, so nothing it writes and
|
||
// nothing it clears is visible to the shared one.
|
||
await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH);
|
||
|
||
// The notification-dispatch journey's jar. Its own account, so its own
|
||
// login — see `E2E_NOTIFY` for why the ledger counts need one.
|
||
await capture(E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH);
|
||
|
||
// The module-surfaces journey's jar — see `E2E_MODULES`.
|
||
await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH);
|
||
|
||
// The phone-width route sweep's jar, then its record — see
|
||
// `E2E_MOBILE_ROUTES`.
|
||
await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||
await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH);
|
||
|
||
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
|
||
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
|
||
|
||
// The timeline journey's jar — see `E2E_TIMELINE`.
|
||
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
|
||
|
||
// The vault specs' jars — see `E2E_VAULT`.
|
||
await capture(E2E_VAULT, VAULT_STORAGE_STATE_PATH);
|
||
await capture(E2E_VAULT_LAYOUT, VAULT_LAYOUT_STORAGE_STATE_PATH);
|
||
|
||
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
|
||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||
|
||
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
|
||
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_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);
|
||
await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH);
|
||
|
||
// The doctor-report journey's own account, for the reason written on
|
||
// E2E_REPORT_OWNER: generating a report writes to the account row.
|
||
await capture(E2E_REPORT_OWNER, REPORT_OWNER_STORAGE_STATE_PATH);
|
||
|
||
// The delegated-writes journey's pair.
|
||
await capture(E2E_WRITE_OWNER, WRITE_OWNER_STORAGE_STATE_PATH);
|
||
await capture(E2E_WRITE_DELEGATE, WRITE_DELEGATE_STORAGE_STATE_PATH);
|
||
|
||
// The backup/restore journey's pair.
|
||
await capture(E2E_BACKUP_ADMIN, BACKUP_STORAGE_STATE_PATH);
|
||
await capture(E2E_BACKUP_DELEGATE, BACKUP_DELEGATE_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],
|
||
);
|
||
// v1.39 — the setup flow's jars, one per journey and one per sweep.
|
||
// The child journey's factor is stamped after its capture, for the reason
|
||
// the guardian's is: the password-only capture cannot pass a TOTP login.
|
||
await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_MEDICATION, SETUP_MEDICATION_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_AI_OFFER, SETUP_AI_OFFER_STORAGE_STATE_PATH);
|
||
await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH);
|
||
await pool.query(
|
||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||
[E2E_SETUP_CHILD.username, now],
|
||
);
|
||
|
||
// The @critical journeys' jars. The sign-in journey signs in through the
|
||
// form in every test, so its account gets none.
|
||
for (const account of allCriticalAccounts()) {
|
||
if (FORM_SIGN_IN_JOURNEYS.has(account.journey)) continue;
|
||
await capture(account, account.storageStatePath);
|
||
}
|
||
} 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();
|
||
}
|
||
}
|