'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 };