// E2E fixture helper (todo 31): the JavaScript half of the single-writer // contract. `provision bootstrap` writes `{email, password}` to // `config/e2e.json` only when absent; the Playwright spec (todos 37/38) is // the single writer of the mutable fields (`password` rotation + // `totp_secret` capture). Never store the TOTP secret in git; never ask a // human to type a TOTP code (use the pinned `otplib` instead). // // One-time account path on a fresh demo user: // 1. POST /login (`email`, `password`, hidden `csrf`; `code` empty) // -> redirected to /login/change-password (restricted session). // 2. POST /login/change-password (`new_password` >= 12 chars, `confirm`, // hidden `csrf`). // 3. GET /login/totp/setup -> read the `otpauth://` URI (shown once), // persist its secret as `totp_secret` in config/e2e.json. // 4. POST /login/totp/confirm (`code` from otplib, hidden `csrf`) // -> full session re-issued; /authorize works from here. // 5. Later, POST /consent approves with hidden `request_id`, hidden // `csrf`, and `decision=approve`. 'use strict'; const fs = require('node:fs'); const http = require('node:http'); const https = require('node:https'); const path = require('node:path'); const FIXTURE_PATH = path.join(__dirname, '..', 'config', 'e2e.json'); // Playwright storageState written once per run by auth.setup.ts and reused by // the negative project so the suite logs in once instead of per test. Holds // live session cookies: gitignored, never committed, never logged. const STORAGE_STATE_PATH = path.join(__dirname, '.auth', 'state.json'); const BFF_HEALTH_URL = process.env.E2E_BFF_HEALTH_URL || 'http://127.0.0.1:3000/healthz'; const AS_READY_URL = process.env.E2E_AS_DISCOVERY_URL || 'https://localhost:8080/.well-known/oauth-authorization-server'; // Exact copy-paste to start the user-run stack (E2E target is prod mode: // BFF serves `crates/web/dist` on :3000). The authorization server discovery // URL is supplied through `E2E_AS_DISCOVERY_URL`, so any AS can be targeted. const STACK_START_MESSAGE = 'start the stack first: ' + 'UI+BFF: `cd crates/web && trunk build --release` once, then ' + '`SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server`; ' + 'point `E2E_AS_DISCOVERY_URL` at your authorization server ' + '(default: local d-OAuth on `https://localhost:8080`). ' + 'Readiness: `curl -fsS http://127.0.0.1:3000/healthz`, then a GET of the ' + 'discovery URL.'; const SELECTORS = { loginEmail: 'email', loginPassword: 'password', loginCode: 'code', totpCode: 'code', csrf: 'csrf', changeNewPassword: 'new_password', changeConfirm: 'confirm', consentRequestId: 'request_id', consentDecision: 'decision', consentApprove: 'approve', }; // Load the fixture written by `provision bootstrap`. A missing file is a // SKIP with an explicit "run provision first" message, never a false pass. function loadFixture(fixturePath = FIXTURE_PATH) { if (!fs.existsSync(fixturePath)) { const error = new Error( `E2E fixture is missing: run \`cargo run -p provision -- bootstrap --password-file \` first (looked at \`${fixturePath}\`)`, ); error.code = 'E2E_FIXTURE_MISSING'; throw error; } return JSON.parse(fs.readFileSync(fixturePath, 'utf8')); } // Persist the spec-owned fields after the one-time flow completes: // the rotated password plus the captured `otpauth://` secret. function saveSpecFields(fixture, fixturePath = FIXTURE_PATH) { const next = { email: fixture.email, password: fixture.password, totp_secret: fixture.totp_secret, }; fs.writeFileSync(fixturePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }); fs.chmodSync(fixturePath, 0o600); } module.exports = { FIXTURE_PATH, STORAGE_STATE_PATH, SELECTORS, loadFixture, saveSpecFields }; // Real HTTP readiness probe against both stack endpoints (todo 37/38). // Never mocked: a refused connection means the user-run stack is down. function probeEndpoint(url, timeoutMs = 3000) { return new Promise((resolve) => { const transport = url.startsWith('https:') ? https : http; const req = transport.get(url, (res) => { res.resume(); resolve({ url, ok: res.statusCode !== undefined && res.statusCode < 400, status: res.statusCode }); }); req.on('error', (err) => resolve({ url, ok: false, status: 0, error: err.code || err.message })); req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout after ${timeoutMs}ms`))); }); } async function probeReadiness() { const [bff, as] = await Promise.all([probeEndpoint(BFF_HEALTH_URL), probeEndpoint(AS_READY_URL)]); return { bff, as, ok: bff.ok && as.ok }; } function readinessMessage(readiness) { const fmt = (p) => `${p.url} -> ${p.ok ? `UP (${p.status})` : `DOWN (${p.error ?? p.status})`}`; return `${STACK_START_MESSAGE} [readiness: bff ${fmt(readiness.bff)}; as ${fmt(readiness.as)}]`; } module.exports.probeReadiness = probeReadiness; module.exports.readinessMessage = readinessMessage; module.exports.STACK_START_MESSAGE = STACK_START_MESSAGE; module.exports.BFF_HEALTH_URL = BFF_HEALTH_URL; module.exports.AS_READY_URL = AS_READY_URL;