Files
familysync/apps/pwa/e2e/global-setup.ts
T
Lucas Berger 1f94dc5eb7 feat(19-05): global-setup local_credentials seed + login.spec.ts + CI harness env
- global-setup.ts: TRUNCATE local_credentials + seed devuser/devpass (PHC scrypt inline)
- Create login.spec.ts: real-login-form e2e (gate redirect, wrong-password error, correct login)
- ci.yml: add LOCAL_SESSION_SECRET dev value + local_credentials seed step in harness job
- Fix all test mocks: add devSessionCookieMiddleware no-op to vi.mock(devBypass.js) blocks
  in admin/setup/push/lists/localAuth/authMode/requireAdmin tests (Rule 1 - Bug: missing export)
- Full API suite: 446/446 tests pass; pnpm typecheck: exit 0
2026-06-17 17:21:05 -04:00

242 lines
12 KiB
TypeScript

/**
* global-setup.ts — Playwright globalSetup: /health readiness gate + deterministic DB seed
*
* Runs ONCE before any spec file. Plain Node.js only (fetch + mysql2/promise).
* No @playwright/test imports — globalSetup runs outside the worker context (Pitfall 2).
*
* Step 1 (D-08 readiness gate):
* Poll baseURL/health until 200 OK (60s timeout, fail fast on expiry).
*
* Step 2 (D-05/D-06/D-07 deterministic seed):
* TRUNCATE → INSERT seed rows onto calendar_id=10 + lists for user_id=1.
* Idempotent: truncate-first guarantees the same row counts on every run.
*
* Anchor values the specs assert on — do NOT change without updating the specs:
* - calendar_events.title = 'Seeded Test Event'
* - lists.name = 'E2E Grocery List'
* - list_items.text = 'Milk', 'Eggs'
*
* Run:
* pnpm --filter @familysync/pwa test:e2e
*/
import mysql from 'mysql2/promise';
import { scryptSync, randomBytes } from 'node:crypto';
// ── Inline scrypt PHC hashPassword ───────────────────────────────────────────
// global-setup is plain Node.js (no @playwright/test, cannot import compiled TS).
// Copy of apps/api/src/auth/localCredentials.ts hashPassword (Pitfall 11).
function hashPasswordInline(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
}
export default async function globalSetup(): Promise<void> {
// ── Step 0: Fail-closed environment guard (CR-01 — data-loss prevention) ─────
// This setup TRUNCATEs four tables against whatever DB_* points at. Mirror the
// hard guard in apps/api/src/auth/devBypass.ts so an operator with prod DB_*
// still exported can never wipe production data.
// 1. NODE_ENV === 'production' is the hard FIRST guard (checked before any
// other env var), matching devBypass.ts.
// 2. The harness contract requires DEV_AUTH_BYPASS=true (the same flag the API
// needs to serve Dev User 1) — refuse to seed without it.
if (process.env.NODE_ENV === 'production') {
throw new Error(
'global-setup refused: NODE_ENV=production. The E2E seed TRUNCATEs tables and must never run against production.',
);
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
throw new Error(
'global-setup refused: DEV_AUTH_BYPASS is not "true". The harness only runs against a dev-bypass stack; ' +
'refusing to TRUNCATE/seed an unconfirmed database. Export DEV_AUTH_BYPASS=true (and point DB_* at the dev DB) to proceed.',
);
}
// ── Step 1: Readiness gate (D-08) ───────────────────────────────────────────
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
const deadline = Date.now() + 60_000;
// Use an explicit success flag (WR-02): inferring success from `Date.now() >= deadline`
// after the loop can misreport a success that arrived in the final second as a timeout,
// because the `await fetch` itself can push the clock past the deadline before the
// post-loop check runs.
let ready = false;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseURL}/health`);
if (res.ok) {
ready = true;
break;
}
} catch {
// ECONNREFUSED or network error — stack not ready yet, keep polling
}
await new Promise<void>((r) => setTimeout(r, 1_000));
}
if (!ready) {
throw new Error(
`health check never returned 200 at ${baseURL}/health — is the dev stack up?\n` +
`Ensure the API is running with DEV_AUTH_BYPASS=true and the Vite dev server is on ${baseURL}.`,
);
}
// ── Step 1b: DEV_AUTH_BYPASS reachability gate (WR-01) ──────────────────────
// /health is unauthenticated and returns 200 even if the API was started WITHOUT
// DEV_AUTH_BYPASS=true. In that case every spec would fail at the first /api/me or
// /api/events with a 302 redirect to Authelia. Probe /api/me here so a mis-started
// API fails loudly IN SETUP with a clear message instead of ~40 confusing spec failures.
// redirect:'manual' surfaces the Authelia redirect as an opaque/3xx response instead of
// silently following it.
const meRes = await fetch(`${baseURL}/api/me`, { redirect: 'manual' });
if (!meRes.ok) {
throw new Error(
`/api/me did not return 200 (got ${meRes.status} ${meRes.type}) at ${baseURL}/api/me — ` +
`the API is reachable but DEV_AUTH_BYPASS is almost certainly NOT set in the API process.\n` +
`A 3xx/opaqueredirect here means /api/me is redirecting to Authelia. ` +
`Restart the API with DEV_AUTH_BYPASS=true so it serves Dev User 1 without OIDC.`,
);
}
// ── Step 2: Reset-and-seed (D-06 deterministic, D-07 in globalSetup) ────────
// Uses exact env-var names from apps/api/src/db/client.ts.
// DB_HOST defaults to '127.0.0.1' (NOT 'localhost') — per project memory api-integration-test-db.
const conn = await mysql.createConnection({
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
});
try {
// Disable FK checks so TRUNCATE order is unconstrained
await conn.execute('SET FOREIGN_KEY_CHECKS=0');
await conn.execute('TRUNCATE TABLE list_items');
await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('TRUNCATE TABLE local_credentials');
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// Phase 18: clear any stored household timezone so the timezone spec always
// starts from the first-run (isExplicitlySet:false) state. app_config is NOT
// truncated above (it can hold other non-test config), so delete only this key.
// Without this, a prior run's saved value leaks across runs and makes the
// first-run / persist-across-reload timezone assertions non-deterministic.
await conn.execute("DELETE FROM app_config WHERE `key` = 'household_timezone'");
// Seed the dev-bypass admin user row for id=1 (D-01 dev note, Phase 10).
// DEV_USER (id=1) is injected by devBypass.ts WITHOUT a DB upsert, so the users table
// has no row for id=1 by default. requireAdmin (Plan 02) does a DB lookup and would 403
// the bypass admin UI locally and in e2e. This idempotent seed ensures is_admin=true for
// id=1 so admin-UI verification works under DEV_AUTH_BYPASS=true.
// oidc_iss/oidc_sub are placeholder non-null values — the bypass path never reads them.
await conn.execute(
`INSERT INTO users (id, oidc_iss, oidc_sub, display_name, color, is_admin)
VALUES (1, 'dev-bypass', 'dev-user-1', 'Dev User', '#4A90D9', true)
ON DUPLICATE KEY UPDATE is_admin=true`,
);
// Phase 12: the setup-wizard gate (App.tsx) redirects EVERY route to /setup when
// app_config.setup_complete !== 'true' (isSetupLocked() === false). The e2e suite
// drives the real app (calendar/admin/lists/timezone), so without marking setup
// complete here every spec is redirected to the wizard and fails. Mark complete and
// seed a credential for the dev admin (id=1) so needsProviderSetup is false and the
// Phase-12 onboarding SetupBanner does not render — mirroring a post-wizard state.
// The credential blob is a synthetic placeholder; e2e read flows never decrypt it
// (the pre-Phase-12 suite ran with no credential at all). Idempotent upserts.
await conn.execute(
"INSERT INTO app_config (`key`, value) VALUES ('setup_complete', 'true') " +
"ON DUPLICATE KEY UPDATE value='true'",
);
await conn.execute(
`INSERT INTO member_credentials (user_id, encrypted_password, fastmail_email, provider_type)
VALUES (1, '{"iv":"e2e","authTag":"e2e","ciphertext":"e2e"}', 'dev@e2e.local', 'caldav')
ON DUPLICATE KEY UPDATE fastmail_email='dev@e2e.local'`,
);
// Phase 19 — Option C (AUTH-LOCAL-16): seed local_credentials for the dev user (id=1).
// devSessionCookieMiddleware issues a local-session cookie so the PWA login gate skips
// /login and the existing harness specs still reach the authed app unchanged.
// A dedicated login.spec.ts clears the cookie to test the real login form.
// hashPasswordInline is inlined (Pitfall 11 — plain Node.js, cannot import compiled TS).
await conn.execute(
`INSERT INTO local_credentials (user_id, username, password_hash)
VALUES (1, 'devuser', ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
[hashPasswordInline('devpass')],
);
// CI guard (Pitfall 4): ensure calendar row id=10 exists before inserting events.
// INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB).
await conn.execute(
`INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared)
VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)`,
);
// Seed one timed (not all-day) calendar event on shared calendar id=10.
// Anchor to NOON TODAY (UTC) — deliberately NOT "tomorrow" (deep-review BL-02):
// both device profiles are phone-width and render the month-agenda view of the
// CURRENT month. On the last day of a month "tomorrow" rolls into the next month
// and disappears from the rendered grid, making any "seeded event is visible"
// assertion date-fragile. Noon-today lands on today's local calendar date in every
// project timezone and is always inside the current-month view.
const uid = 'e2e-seed-event-001';
const _now = new Date();
const futureStart = new Date(
Date.UTC(_now.getUTCFullYear(), _now.getUTCMonth(), _now.getUTCDate(), 12, 0, 0),
);
// MariaDB TIMESTAMP requires 'YYYY-MM-DD HH:MM:SS' format, not ISO 8601 with 'T'.
const futureStartUtc = futureStart
.toISOString()
.replace('T', ' ')
.replace(/\.\d+Z$/, '');
const rawVevent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//FamilySync E2E//EN',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTART:${futureStart
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
`DTEND:${new Date(futureStart.getTime() + 60 * 60 * 1000)
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
'SUMMARY:Seeded Test Event',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
await conn.execute(
`INSERT INTO calendar_events
(calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
[uid, rawVevent, futureStartUtc],
);
// Seed one shared list owned by user 1 (D-05 populated half).
// belt-and-suspenders: seed BOTH owner_id=1 AND a list_shares row for user_id=1
// so /api/lists returns the list regardless of whether it filters by owner or by share.
const [listResult] = (await conn.execute(
`INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
)) as mysql.ResultSetHeader[];
const listId = listResult.insertId;
await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]);
// Two active items with fractional-indexing rank strings — appear in the active section.
// 'Milk' and 'Eggs' are the stable anchor texts that lists.spec.ts asserts on.
await conn.execute(
`INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
[listId, listId],
);
} finally {
await conn.end();
}
}