| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- 'use strict';
- // Shared sign-in flow for the E2E specs (todos 37/38). Drives the real
- // user-run stack only: landing -> /oauth/start -> d-OAuth login (with the
- // one-time forced password change + TOTP enrollment on a fresh user) ->
- // /consent approve -> BFF dashboard. Never starts a server, never mocks.
- //
- // The TOTP code generator is injected (`totpCode(secret) -> code`) so the
- // specs can lazily require the pinned `otplib` only when the flow runs;
- // --list and the readiness probe never touch it.
- const crypto = require('node:crypto');
- const { saveSpecFields } = require('./helpers');
- function newPassword() {
- return crypto.randomBytes(15).toString('base64url');
- }
- async function submitFirstForm(page) {
- const btn = page.locator('form button[type="submit"], form input[type="submit"], form button:not([type])');
- if ((await btn.count()) > 0) {
- await btn.first().click();
- } else {
- await page.locator('form').first().evaluate((form) => form.submit());
- }
- }
- async function extractTotpSecret(page) {
- const text = await page.locator('body').innerText();
- const uriMatch = text.match(/secret=([A-Z2-7]{16,})/);
- if (uriMatch) {
- return uriMatch[1].replace(/=+$/, '');
- }
- const href = await page.locator('a[href^="otpauth://"]').first().getAttribute('href').catch(() => null);
- if (href) {
- const secret = new URL(href).searchParams.get('secret');
- if (secret) {
- return secret.replace(/=+$/, '');
- }
- }
- throw new Error('could not capture the TOTP secret from /login/totp/setup (shown once)');
- }
- async function fillLoginForm(page, fixture, totpCode) {
- await page.locator('[name="email"]').fill(fixture.email);
- await page.locator('[name="password"]').fill(fixture.password);
- const codeField = page.locator('[name="code"]');
- if ((await codeField.count()) > 0) {
- if (!fixture.totp_secret) {
- throw new Error('login shows a code field but the fixture has no totp_secret');
- }
- await codeField.fill(totpCode(fixture.totp_secret));
- }
- await submitAndSettle(page);
- }
- function msUntilNextTotpStep() {
- return 30_000 - (Date.now() % 30_000) + 2000;
- }
- // Honor the AS throttle: prefer its Retry-After delay, else 60s, clamped to
- // 5s..300s so a missing or absurd header cannot stall or spin the suite.
- function retryAfterMs(resp) {
- const raw = resp.headers()['retry-after'];
- const secs = Number.parseInt(Array.isArray(raw) ? raw[0] : raw, 10);
- if (Number.isFinite(secs)) {
- return Math.min(300_000, Math.max(5_000, secs * 1000));
- }
- return 60_000;
- }
- async function waitForUrlSettled(page, timeoutMs = 15000) {
- const deadline = Date.now() + timeoutMs;
- let last = page.url();
- let stableSince = Date.now();
- for (;;) {
- await page.waitForTimeout(500);
- const now = page.url();
- if (now !== last) {
- last = now;
- stableSince = Date.now();
- } else if (Date.now() - stableSince >= 1000) {
- return;
- }
- if (Date.now() > deadline) {
- return;
- }
- }
- }
- async function submitAndSettle(page) {
- await submitFirstForm(page);
- await page.waitForLoadState('domcontentloaded');
- await waitForUrlSettled(page);
- }
- async function approveConsent(page, requestId, csrf) {
- const url = `${new URL(page.url()).origin}/consent`;
- for (let attempt = 1; attempt <= 2; attempt += 1) {
- const resp = await page.request.post(url, {
- form: { request_id: requestId, csrf, decision: 'approve' },
- });
- if (resp.ok()) {
- return;
- }
- const body = await resp.text().catch(() => '');
- const limited = resp.status() === 429 || body.includes('rate_limited');
- if (limited && attempt < 2) {
- await page.waitForTimeout(retryAfterMs(resp));
- continue;
- }
- if (limited) {
- throw new Error(`BLOCKED: AS rate-limited on POST ${url} (attempt ${attempt}): ${body.slice(0, 200)}`);
- }
- throw new Error(`consent approve failed: ${resp.status()} at ${resp.url()}: ${body.slice(0, 200)}`);
- }
- }
- async function gotoWithRetry(page, url) {
- let lastError;
- for (let attempt = 0; attempt < 3; attempt++) {
- try {
- await page.goto(url, { timeout: 30_000 });
- return;
- } catch (err) {
- lastError = err;
- if (!/net::ERR_|ECONNREFUSED|ETIMEDOUT/.test(err.message)) {
- throw err;
- }
- await page.waitForTimeout(1000);
- }
- }
- throw lastError;
- }
- async function ensureLoggedIn(page, fixture, totpCode, rateLimitRetried = false) {
- await gotoWithRetry(page, '/oauth/start');
- await page.waitForLoadState('domcontentloaded');
- if ((await page.locator('[name="email"]').count()) > 0) {
- await page.waitForTimeout(msUntilNextTotpStep());
- await fillLoginForm(page, fixture, totpCode);
- if (page.url().includes('/login')) {
- const body = await page.locator('body').innerText().catch(() => '');
- if (body.includes('rate_limited')) {
- if (rateLimitRetried) {
- throw new Error(`BLOCKED: AS rate-limited at ${page.url()}: ${body.slice(0, 200)}`);
- }
- await page.waitForTimeout(60_000);
- return ensureLoggedIn(page, fixture, totpCode, true);
- }
- throw new Error(`login did not leave /login at ${page.url()}: ${body.slice(0, 200)}`);
- }
- }
- if (page.url().includes('/login/change-password')) {
- const next = newPassword();
- await page.locator('[name="new_password"]').fill(next);
- await page.locator('[name="confirm"]').fill(next);
- fixture.password = next;
- await submitAndSettle(page);
- }
- if (page.url().includes('/login/totp/setup')) {
- fixture.totp_secret = await extractTotpSecret(page);
- saveSpecFields(fixture);
- const origin = new URL(page.url()).origin;
- await page.goto(`${origin}/login/totp/confirm`);
- await page.waitForLoadState('domcontentloaded');
- }
- if (page.url().includes('/login/totp/confirm') || page.url().includes('/login/totp')) {
- await page.locator('[name="code"]').fill(totpCode(fixture.totp_secret));
- await submitAndSettle(page);
- }
- // Approve via the context API client, not an in-page click: this Chromium
- // build aborts following the approval 302 (https AS -> http BFF callback,
- // net::ERR_ABORTED, proven by netlog: 302 received then CANCELLED with no
- // follow-up request). Same endpoint, same cookies, same form fields.
- if (page.url().includes('/consent')) {
- const requestId = await page.locator('[name="request_id"]').inputValue();
- const csrf = await page.locator('[name="csrf"]').inputValue();
- await approveConsent(page, requestId, csrf);
- }
- const check = await page.request.get('/api/session').catch(() => null);
- if (!check || check.status() !== 200) {
- const body = await page.locator('body').innerText().catch(() => '');
- const hasForm = (await page.locator('[name="email"]').count()) > 0;
- throw new Error(
- `no BFF session after sign-in (page at ${page.url()}, session status ${check ? check.status() : 'request failed'}, hasLoginForm=${hasForm}): ${body.slice(0, 200)}`,
- );
- }
- }
- async function awaitSession(page, timeoutMs = 15000) {
- const deadline = Date.now() + timeoutMs;
- let lastStatus = 0;
- for (;;) {
- const res = await page.request.get('/api/session');
- lastStatus = res.status();
- if (res.status() === 200) {
- return await res.json();
- }
- if (Date.now() > deadline) {
- throw new Error(`timed out waiting for /api/session 200 (last status ${lastStatus})`);
- }
- await page.waitForTimeout(500);
- }
- }
- module.exports = { ensureLoggedIn, awaitSession, submitFirstForm, extractTotpSecret, newPassword };
|