| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- // 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 <path>\` 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;
|