helpers.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. // E2E fixture helper (todo 31): the JavaScript half of the single-writer
  2. // contract. `provision bootstrap` writes `{email, password}` to
  3. // `config/e2e.json` only when absent; the Playwright spec (todos 37/38) is
  4. // the single writer of the mutable fields (`password` rotation +
  5. // `totp_secret` capture). Never store the TOTP secret in git; never ask a
  6. // human to type a TOTP code (use the pinned `otplib` instead).
  7. //
  8. // One-time account path on a fresh demo user:
  9. // 1. POST /login (`email`, `password`, hidden `csrf`; `code` empty)
  10. // -> redirected to /login/change-password (restricted session).
  11. // 2. POST /login/change-password (`new_password` >= 12 chars, `confirm`,
  12. // hidden `csrf`).
  13. // 3. GET /login/totp/setup -> read the `otpauth://` URI (shown once),
  14. // persist its secret as `totp_secret` in config/e2e.json.
  15. // 4. POST /login/totp/confirm (`code` from otplib, hidden `csrf`)
  16. // -> full session re-issued; /authorize works from here.
  17. // 5. Later, POST /consent approves with hidden `request_id`, hidden
  18. // `csrf`, and `decision=approve`.
  19. 'use strict';
  20. const fs = require('node:fs');
  21. const http = require('node:http');
  22. const https = require('node:https');
  23. const path = require('node:path');
  24. const FIXTURE_PATH = path.join(__dirname, '..', 'config', 'e2e.json');
  25. // Playwright storageState written once per run by auth.setup.ts and reused by
  26. // the negative project so the suite logs in once instead of per test. Holds
  27. // live session cookies: gitignored, never committed, never logged.
  28. const STORAGE_STATE_PATH = path.join(__dirname, '.auth', 'state.json');
  29. const BFF_HEALTH_URL =
  30. process.env.E2E_BFF_HEALTH_URL || 'http://127.0.0.1:3000/healthz';
  31. const AS_READY_URL =
  32. process.env.E2E_AS_DISCOVERY_URL ||
  33. 'https://localhost:8080/.well-known/oauth-authorization-server';
  34. // Exact copy-paste to start the user-run stack (E2E target is prod mode:
  35. // BFF serves `crates/web/dist` on :3000). The authorization server discovery
  36. // URL is supplied through `E2E_AS_DISCOVERY_URL`, so any AS can be targeted.
  37. const STACK_START_MESSAGE =
  38. 'start the stack first: ' +
  39. 'UI+BFF: `cd crates/web && trunk build --release` once, then ' +
  40. '`SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server`; ' +
  41. 'point `E2E_AS_DISCOVERY_URL` at your authorization server ' +
  42. '(default: local d-OAuth on `https://localhost:8080`). ' +
  43. 'Readiness: `curl -fsS http://127.0.0.1:3000/healthz`, then a GET of the ' +
  44. 'discovery URL.';
  45. const SELECTORS = {
  46. loginEmail: 'email',
  47. loginPassword: 'password',
  48. loginCode: 'code',
  49. totpCode: 'code',
  50. csrf: 'csrf',
  51. changeNewPassword: 'new_password',
  52. changeConfirm: 'confirm',
  53. consentRequestId: 'request_id',
  54. consentDecision: 'decision',
  55. consentApprove: 'approve',
  56. };
  57. // Load the fixture written by `provision bootstrap`. A missing file is a
  58. // SKIP with an explicit "run provision first" message, never a false pass.
  59. function loadFixture(fixturePath = FIXTURE_PATH) {
  60. if (!fs.existsSync(fixturePath)) {
  61. const error = new Error(
  62. `E2E fixture is missing: run \`cargo run -p provision -- bootstrap --password-file <path>\` first (looked at \`${fixturePath}\`)`,
  63. );
  64. error.code = 'E2E_FIXTURE_MISSING';
  65. throw error;
  66. }
  67. return JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
  68. }
  69. // Persist the spec-owned fields after the one-time flow completes:
  70. // the rotated password plus the captured `otpauth://` secret.
  71. function saveSpecFields(fixture, fixturePath = FIXTURE_PATH) {
  72. const next = {
  73. email: fixture.email,
  74. password: fixture.password,
  75. totp_secret: fixture.totp_secret,
  76. };
  77. fs.writeFileSync(fixturePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
  78. fs.chmodSync(fixturePath, 0o600);
  79. }
  80. module.exports = { FIXTURE_PATH, STORAGE_STATE_PATH, SELECTORS, loadFixture, saveSpecFields };
  81. // Real HTTP readiness probe against both stack endpoints (todo 37/38).
  82. // Never mocked: a refused connection means the user-run stack is down.
  83. function probeEndpoint(url, timeoutMs = 3000) {
  84. return new Promise((resolve) => {
  85. const transport = url.startsWith('https:') ? https : http;
  86. const req = transport.get(url, (res) => {
  87. res.resume();
  88. resolve({ url, ok: res.statusCode !== undefined && res.statusCode < 400, status: res.statusCode });
  89. });
  90. req.on('error', (err) => resolve({ url, ok: false, status: 0, error: err.code || err.message }));
  91. req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
  92. });
  93. }
  94. async function probeReadiness() {
  95. const [bff, as] = await Promise.all([probeEndpoint(BFF_HEALTH_URL), probeEndpoint(AS_READY_URL)]);
  96. return { bff, as, ok: bff.ok && as.ok };
  97. }
  98. function readinessMessage(readiness) {
  99. const fmt = (p) => `${p.url} -> ${p.ok ? `UP (${p.status})` : `DOWN (${p.error ?? p.status})`}`;
  100. return `${STACK_START_MESSAGE} [readiness: bff ${fmt(readiness.bff)}; as ${fmt(readiness.as)}]`;
  101. }
  102. module.exports.probeReadiness = probeReadiness;
  103. module.exports.readinessMessage = readinessMessage;
  104. module.exports.STACK_START_MESSAGE = STACK_START_MESSAGE;
  105. module.exports.BFF_HEALTH_URL = BFF_HEALTH_URL;
  106. module.exports.AS_READY_URL = AS_READY_URL;