mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
- Seed adult and managed whole-record grants for browser coverage\n- Verify scoped writes, settings boundaries, and refused coach reads
639 lines
24 KiB
TypeScript
639 lines
24 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;
|
||
|
||
/** 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",
|
||
);
|
||
|
||
async function hashPassword(password: string): Promise<string> {
|
||
return hash(password, {
|
||
memoryCost: 19456,
|
||
timeCost: 2,
|
||
outputLen: 32,
|
||
parallelism: 1,
|
||
});
|
||
}
|
||
|
||
function cuid(): string {
|
||
// Match Prisma's default cuid format closely enough that downstream
|
||
// queries that filter on `id LIKE 'c%'` (none currently) keep working.
|
||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||
let id = "c";
|
||
for (let i = 0; i < 24; i++) {
|
||
id += chars[Math.floor(Math.random() * chars.length)];
|
||
}
|
||
return id;
|
||
}
|
||
|
||
export default async function globalSetup(config: FullConfig): Promise<void> {
|
||
const url = process.env.DATABASE_URL;
|
||
if (!url) {
|
||
throw new Error(
|
||
"[e2e/global-setup] DATABASE_URL is not set — Playwright cannot seed the test user.",
|
||
);
|
||
}
|
||
|
||
const pool = new pg.Pool({ connectionString: url });
|
||
|
||
try {
|
||
const passwordHash = await hashPassword(E2E_USER.password);
|
||
const now = new Date();
|
||
const dob = new Date("1985-06-15T00:00:00.000Z");
|
||
|
||
// Postgres-native upsert keyed on the unique username column. The
|
||
// SET clause clears any leftover Codex / insights state from a
|
||
// previous run so the "Settings → AI" spec sees the disconnected
|
||
// baseline; we also stamp `onboarding_completed_at` so the auth
|
||
// shell does not bounce the user to /onboarding, AND set
|
||
// `onboarding_tour_completed = true` so the spotlight tour does
|
||
// NOT auto-launch on the dashboard. Without that flag the tour
|
||
// mounts a full-viewport `role="dialog"` overlay with `z-index:200`
|
||
// that intercepts every pointer event — every authenticated spec
|
||
// that tries to click a header / sidebar / quick-add button times
|
||
// out (50+ failed CI runs since v1.4.13). Specs that need the
|
||
// tour can opt back in by mocking `/api/auth/me`.
|
||
// Nutrients is intentionally opt-in in production. The shared fixture
|
||
// enables it because the water-capture specs exercise that real gate.
|
||
await pool.query(
|
||
`INSERT INTO users
|
||
(id, username, email, password_hash, role,
|
||
created_at, updated_at,
|
||
height_cm, date_of_birth, gender,
|
||
codex_connection_status,
|
||
insights_privacy_mode,
|
||
onboarding_completed_at,
|
||
onboarding_tour_completed,
|
||
module_preferences_json)
|
||
VALUES ($1, $2, $3, $4, $5,
|
||
$6, $6,
|
||
180, $7, 'MALE',
|
||
'disconnected',
|
||
'aggregated',
|
||
$6,
|
||
true,
|
||
'{"nutrients":true}'::jsonb)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
role = EXCLUDED.role,
|
||
updated_at = EXCLUDED.updated_at,
|
||
height_cm = EXCLUDED.height_cm,
|
||
date_of_birth = EXCLUDED.date_of_birth,
|
||
gender = EXCLUDED.gender,
|
||
codex_access_token_encrypted = NULL,
|
||
codex_refresh_token_encrypted = NULL,
|
||
codex_token_expires_at = NULL,
|
||
codex_connected_at = NULL,
|
||
codex_connection_status = 'disconnected',
|
||
insights_cached_at = NULL,
|
||
insights_cached_text = NULL,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||
[
|
||
cuid(),
|
||
E2E_USER.username,
|
||
E2E_USER.email,
|
||
passwordHash,
|
||
E2E_USER.role,
|
||
now,
|
||
dob,
|
||
],
|
||
);
|
||
|
||
// 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)
|
||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)
|
||
ON CONFLICT (username) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
password_hash = EXCLUDED.password_hash,
|
||
updated_at = EXCLUDED.updated_at,
|
||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||
[
|
||
cuid(),
|
||
E2E_OWNER.username,
|
||
E2E_OWNER.email,
|
||
await hashPassword(E2E_OWNER.password),
|
||
now,
|
||
],
|
||
);
|
||
|
||
// Every grant between the pair, and any switch stamp either of them is
|
||
// carrying. Deleted rather than revoked: this is fixture hygiene, not a
|
||
// lifecycle transition, and a revoked row would still show in the panel.
|
||
await pool.query(
|
||
`DELETE FROM account_grants
|
||
WHERE grantor_id IN (SELECT id FROM users WHERE username = ANY($1))
|
||
OR grantee_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||
[[E2E_USER.username, E2E_OWNER.username]],
|
||
);
|
||
await pool.query(
|
||
`UPDATE sessions SET acting_as_user_id = NULL
|
||
WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1))`,
|
||
[[E2E_USER.username, E2E_OWNER.username]],
|
||
);
|
||
await pool.query(
|
||
`DELETE FROM rate_limits
|
||
WHERE key LIKE 'sharing:%'`,
|
||
);
|
||
|
||
// The login bucket, for the same reason. This setup signs in FOUR times
|
||
// now (the shared jar, the owner, and the sharing journey's own delegate
|
||
// jar), and the ceiling is five attempts per IP per quarter-hour — so two
|
||
// local runs in a row would otherwise end with a 429 from the fixture
|
||
// rather than a failure from the product. Only the auth surfaces' own
|
||
// buckets are cleared; nothing else in the table is touched.
|
||
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
|
||
|
||
// One marker weight per record. Re-seeded by delete-then-insert so a
|
||
// re-run cannot accumulate duplicates and so an edited constant takes
|
||
// effect rather than sitting beside the old value.
|
||
for (const [username, kg] of [
|
||
[E2E_USER.username, E2E_DELEGATE_MARKER_KG],
|
||
[E2E_OWNER.username, E2E_OWNER_MARKER_KG],
|
||
] as const) {
|
||
await pool.query(
|
||
`DELETE FROM measurements
|
||
WHERE user_id = (SELECT id FROM users WHERE username = $1)
|
||
AND external_id = 'e2e-sharing-marker'`,
|
||
[username],
|
||
);
|
||
await pool.query(
|
||
`INSERT INTO measurements
|
||
(id, user_id, type, value, unit, source, measured_at,
|
||
external_id, created_at, updated_at)
|
||
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
|
||
'e2e-sharing-marker', $3, $3
|
||
FROM users u WHERE u.username = $4`,
|
||
[cuid(), kg, now, username],
|
||
);
|
||
}
|
||
|
||
// Console (instead of structured logging) is intentional here —
|
||
// global-setup runs outside the app's logging context, and the
|
||
// line is useful when debugging a CI failure where the seed didn't
|
||
// commit.
|
||
console.log(
|
||
`[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`,
|
||
);
|
||
} finally {
|
||
await pool.end();
|
||
}
|
||
|
||
// Log the fixture accounts in here and persist their cookie jars so specs
|
||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec
|
||
// login + rate-limit dance.
|
||
const baseURL =
|
||
config.projects[0]?.use.baseURL ??
|
||
process.env.E2E_BASE_URL ??
|
||
"http://localhost:3000";
|
||
|
||
// The shared jar every authenticated spec reads.
|
||
await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH);
|
||
|
||
// v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant
|
||
// signed in at once, and the login endpoint is IP-rate-limited, so the owner
|
||
// is logged in here rather than inside the spec.
|
||
await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH);
|
||
|
||
// The delegate's own jar for that journey. Same account as the shared one,
|
||
// deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for
|
||
// what happens when the journey switches the shared row instead.
|
||
await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH);
|
||
|
||
await captureAuthState(
|
||
baseURL,
|
||
E2E_SCOPE_DELEGATE,
|
||
SCOPE_DELEGATE_STORAGE_STATE_PATH,
|
||
);
|
||
await captureAuthState(
|
||
baseURL,
|
||
E2E_SCOPE_DELEGATE,
|
||
SCOPE_A11Y_STORAGE_STATE_PATH,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Log one account in and write its cookie jar to `path`.
|
||
*
|
||
* Each call mints a fresh session row, so two jars for the same account are
|
||
* two independent browser sessions — which is the whole point of the delegate
|
||
* jar above.
|
||
*
|
||
* Only the cookies the auth shell needs survive the filter. v1.4.22 C4 added
|
||
* `hl_onboarding`: the login route sets it to "pending" for a new account and
|
||
* DELETES it for an already-onboarded one, so for these pre-seeded accounts it
|
||
* is absent and the proxy passes the dashboard through.
|
||
*
|
||
* v1.27.11 — the locale cookie is pinned to English rather than inherited. The
|
||
* server honours `User.locale` when the cookie is absent (the ITP fix), and
|
||
* the locale-switch spec mirrors its German pick into the SHARED account's
|
||
* profile — without this cookie every spec running after it would render
|
||
* German and the English-string assertions would fail. The cookie sits at the
|
||
* top of the resolution ladder, so each spec context stays deterministically
|
||
* English; locale-switch overrides it inside its own context only.
|
||
*/
|
||
async function captureAuthState(
|
||
baseURL: string,
|
||
account: { username: string; password: string },
|
||
path: string,
|
||
): Promise<void> {
|
||
const ctx = await playwrightRequest.newContext({ baseURL });
|
||
try {
|
||
const res = await ctx.post("/api/auth/login", {
|
||
data: { email: account.username, password: account.password },
|
||
});
|
||
if (res.status() !== 200) {
|
||
const body = await res.text();
|
||
throw new Error(
|
||
`[e2e/global-setup] login failed for ${account.username}: HTTP ${res.status()} — ${body.slice(0, 200)}`,
|
||
);
|
||
}
|
||
const state = await ctx.storageState();
|
||
state.cookies = state.cookies.filter((c) =>
|
||
["healthlog_session", "hl_onboarding"].includes(c.name),
|
||
);
|
||
state.cookies.push({
|
||
name: "healthlog-locale",
|
||
value: "en",
|
||
domain: new URL(baseURL).hostname,
|
||
path: "/",
|
||
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
|
||
httpOnly: false,
|
||
secure: false,
|
||
sameSite: "Lax",
|
||
});
|
||
await writeFile(path, JSON.stringify(state, null, 2));
|
||
console.log(`[e2e/global-setup] auth state captured to ${path}`);
|
||
} finally {
|
||
await ctx.dispose();
|
||
}
|
||
}
|