flow.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. 'use strict';
  2. // Shared sign-in flow for the E2E specs (todos 37/38). Drives the real
  3. // user-run stack only: landing -> /oauth/start -> d-OAuth login (with the
  4. // one-time forced password change + TOTP enrollment on a fresh user) ->
  5. // /consent approve -> BFF dashboard. Never starts a server, never mocks.
  6. //
  7. // The TOTP code generator is injected (`totpCode(secret) -> code`) so the
  8. // specs can lazily require the pinned `otplib` only when the flow runs;
  9. // --list and the readiness probe never touch it.
  10. const crypto = require('node:crypto');
  11. const { saveSpecFields } = require('./helpers');
  12. function newPassword() {
  13. return crypto.randomBytes(15).toString('base64url');
  14. }
  15. async function submitFirstForm(page) {
  16. const btn = page.locator('form button[type="submit"], form input[type="submit"], form button:not([type])');
  17. if ((await btn.count()) > 0) {
  18. await btn.first().click();
  19. } else {
  20. await page.locator('form').first().evaluate((form) => form.submit());
  21. }
  22. }
  23. async function extractTotpSecret(page) {
  24. const text = await page.locator('body').innerText();
  25. const uriMatch = text.match(/secret=([A-Z2-7]{16,})/);
  26. if (uriMatch) {
  27. return uriMatch[1].replace(/=+$/, '');
  28. }
  29. const href = await page.locator('a[href^="otpauth://"]').first().getAttribute('href').catch(() => null);
  30. if (href) {
  31. const secret = new URL(href).searchParams.get('secret');
  32. if (secret) {
  33. return secret.replace(/=+$/, '');
  34. }
  35. }
  36. throw new Error('could not capture the TOTP secret from /login/totp/setup (shown once)');
  37. }
  38. async function fillLoginForm(page, fixture, totpCode) {
  39. await page.locator('[name="email"]').fill(fixture.email);
  40. await page.locator('[name="password"]').fill(fixture.password);
  41. const codeField = page.locator('[name="code"]');
  42. if ((await codeField.count()) > 0) {
  43. if (!fixture.totp_secret) {
  44. throw new Error('login shows a code field but the fixture has no totp_secret');
  45. }
  46. await codeField.fill(totpCode(fixture.totp_secret));
  47. }
  48. await submitAndSettle(page);
  49. }
  50. function msUntilNextTotpStep() {
  51. return 30_000 - (Date.now() % 30_000) + 2000;
  52. }
  53. // Honor the AS throttle: prefer its Retry-After delay, else 60s, clamped to
  54. // 5s..300s so a missing or absurd header cannot stall or spin the suite.
  55. function retryAfterMs(resp) {
  56. const raw = resp.headers()['retry-after'];
  57. const secs = Number.parseInt(Array.isArray(raw) ? raw[0] : raw, 10);
  58. if (Number.isFinite(secs)) {
  59. return Math.min(300_000, Math.max(5_000, secs * 1000));
  60. }
  61. return 60_000;
  62. }
  63. async function waitForUrlSettled(page, timeoutMs = 15000) {
  64. const deadline = Date.now() + timeoutMs;
  65. let last = page.url();
  66. let stableSince = Date.now();
  67. for (;;) {
  68. await page.waitForTimeout(500);
  69. const now = page.url();
  70. if (now !== last) {
  71. last = now;
  72. stableSince = Date.now();
  73. } else if (Date.now() - stableSince >= 1000) {
  74. return;
  75. }
  76. if (Date.now() > deadline) {
  77. return;
  78. }
  79. }
  80. }
  81. async function submitAndSettle(page) {
  82. await submitFirstForm(page);
  83. await page.waitForLoadState('domcontentloaded');
  84. await waitForUrlSettled(page);
  85. }
  86. async function approveConsent(page, requestId, csrf) {
  87. const url = `${new URL(page.url()).origin}/consent`;
  88. for (let attempt = 1; attempt <= 2; attempt += 1) {
  89. const resp = await page.request.post(url, {
  90. form: { request_id: requestId, csrf, decision: 'approve' },
  91. });
  92. if (resp.ok()) {
  93. return;
  94. }
  95. const body = await resp.text().catch(() => '');
  96. const limited = resp.status() === 429 || body.includes('rate_limited');
  97. if (limited && attempt < 2) {
  98. await page.waitForTimeout(retryAfterMs(resp));
  99. continue;
  100. }
  101. if (limited) {
  102. throw new Error(`BLOCKED: AS rate-limited on POST ${url} (attempt ${attempt}): ${body.slice(0, 200)}`);
  103. }
  104. throw new Error(`consent approve failed: ${resp.status()} at ${resp.url()}: ${body.slice(0, 200)}`);
  105. }
  106. }
  107. async function gotoWithRetry(page, url) {
  108. let lastError;
  109. for (let attempt = 0; attempt < 3; attempt++) {
  110. try {
  111. await page.goto(url, { timeout: 30_000 });
  112. return;
  113. } catch (err) {
  114. lastError = err;
  115. if (!/net::ERR_|ECONNREFUSED|ETIMEDOUT/.test(err.message)) {
  116. throw err;
  117. }
  118. await page.waitForTimeout(1000);
  119. }
  120. }
  121. throw lastError;
  122. }
  123. async function ensureLoggedIn(page, fixture, totpCode, rateLimitRetried = false) {
  124. await gotoWithRetry(page, '/oauth/start');
  125. await page.waitForLoadState('domcontentloaded');
  126. if ((await page.locator('[name="email"]').count()) > 0) {
  127. await page.waitForTimeout(msUntilNextTotpStep());
  128. await fillLoginForm(page, fixture, totpCode);
  129. if (page.url().includes('/login')) {
  130. const body = await page.locator('body').innerText().catch(() => '');
  131. if (body.includes('rate_limited')) {
  132. if (rateLimitRetried) {
  133. throw new Error(`BLOCKED: AS rate-limited at ${page.url()}: ${body.slice(0, 200)}`);
  134. }
  135. await page.waitForTimeout(60_000);
  136. return ensureLoggedIn(page, fixture, totpCode, true);
  137. }
  138. throw new Error(`login did not leave /login at ${page.url()}: ${body.slice(0, 200)}`);
  139. }
  140. }
  141. if (page.url().includes('/login/change-password')) {
  142. const next = newPassword();
  143. await page.locator('[name="new_password"]').fill(next);
  144. await page.locator('[name="confirm"]').fill(next);
  145. fixture.password = next;
  146. await submitAndSettle(page);
  147. }
  148. if (page.url().includes('/login/totp/setup')) {
  149. fixture.totp_secret = await extractTotpSecret(page);
  150. saveSpecFields(fixture);
  151. const origin = new URL(page.url()).origin;
  152. await page.goto(`${origin}/login/totp/confirm`);
  153. await page.waitForLoadState('domcontentloaded');
  154. }
  155. if (page.url().includes('/login/totp/confirm') || page.url().includes('/login/totp')) {
  156. await page.locator('[name="code"]').fill(totpCode(fixture.totp_secret));
  157. await submitAndSettle(page);
  158. }
  159. // Approve via the context API client, not an in-page click: this Chromium
  160. // build aborts following the approval 302 (https AS -> http BFF callback,
  161. // net::ERR_ABORTED, proven by netlog: 302 received then CANCELLED with no
  162. // follow-up request). Same endpoint, same cookies, same form fields.
  163. if (page.url().includes('/consent')) {
  164. const requestId = await page.locator('[name="request_id"]').inputValue();
  165. const csrf = await page.locator('[name="csrf"]').inputValue();
  166. await approveConsent(page, requestId, csrf);
  167. }
  168. const check = await page.request.get('/api/session').catch(() => null);
  169. if (!check || check.status() !== 200) {
  170. const body = await page.locator('body').innerText().catch(() => '');
  171. const hasForm = (await page.locator('[name="email"]').count()) > 0;
  172. throw new Error(
  173. `no BFF session after sign-in (page at ${page.url()}, session status ${check ? check.status() : 'request failed'}, hasLoginForm=${hasForm}): ${body.slice(0, 200)}`,
  174. );
  175. }
  176. }
  177. async function awaitSession(page, timeoutMs = 15000) {
  178. const deadline = Date.now() + timeoutMs;
  179. let lastStatus = 0;
  180. for (;;) {
  181. const res = await page.request.get('/api/session');
  182. lastStatus = res.status();
  183. if (res.status() === 200) {
  184. return await res.json();
  185. }
  186. if (Date.now() > deadline) {
  187. throw new Error(`timed out waiting for /api/session 200 (last status ${lastStatus})`);
  188. }
  189. await page.waitForTimeout(500);
  190. }
  191. }
  192. module.exports = { ensureLoggedIn, awaitSession, submitFirstForm, extractTotpSecret, newPassword };