fix(e2e): make the TOTP 2fa step window-safe to stop /login/2fa stalls #486

Merged
longjacksonle merged 1 commit from fix/e2e-totp-window-race into main 2026-08-02 22:57:20 +02:00

Fix the flaky /login/2fa stall in the E2E login helper

Symptom

Merge-gate E2E runs intermittently failed the interactive-login precondition (loginViaSpa) with "SPA login never navigated away from the /login or /oauth2/consent flow (30s timeout; last path: /login/2fa)" or a waitFor timeout on the login form. It was browser-nondeterministic: webkit failed one run while firefox passed, firefox failed the next while webkit passed. The bearer-based [api] tests (login captured once in setup) always passed - only the interactive browser login flaked.

Root cause

fillTotpStep generated the TOTP code up front, then waited up to 10s for the code input to become visible before submitting:

const code = authenticator.generate(env.totpSecret);   // generated now
...
await codeInput.waitFor({ state: 'visible', timeout: 10_000 });   // up to 10s later
await setInputValue(codeInput, code);                             // submits a possibly-stale code

TOTP codes live for a 30s window. On a slow browser run the code crossed its window boundary before it reached the hub, bunyip rejected the stale code, and the page stayed on /login/2fa until the caller's 30s poll gave up. The timing dependence is exactly why it moved between browsers run to run.

Fix (e2e/lib/login.ts, fillTotpStep)

  1. Generate late + window guard: generate the code only after the input is visible, and only when a near-full window remains - authenticator.timeRemaining() >= 5, otherwise wait for the next window first - so a submitted code cannot expire in flight (network + hub validation).
  2. One retry with a fresh code: a boundary crossing is transient, so retry once with a newly generated (again window-guarded) code.
  3. Rate-limit safe: bunyip caps 2fa_verify at 5 FAILED codes / 15 min per account and a success resets the counter (BUNYIP-201). Each attempt relies on the BUNYIP-331 input-event auto-submit (one POST, no button click), so the same code is never re-POSTed; worst case is three failed submits, well under the cap. The explicit Verify click is kept only as a single fallback for a hub that stops auto-submitting.

Verification

npx tsc --noEmit passes. otplib primitives confirmed against the installed types and at runtime (timeRemaining() in (0, 30], generate() a valid 6-digit code) - and in a local check timeRemaining() returned 4, precisely the sub-5s boundary the guard now waits out. Full E2E runs against staging (needs the E2E account + TOTP secret + a deployed target), so the behavioural proof is the next gate run.

Separate from the PMS-710 seed work; this only touches the E2E login helper and unblocks the gate for every PR.

## Fix the flaky `/login/2fa` stall in the E2E login helper ### Symptom Merge-gate E2E runs intermittently failed the interactive-login precondition (`loginViaSpa`) with *"SPA login never navigated away from the /login or /oauth2/consent flow (30s timeout; last path: /login/2fa)"* or a `waitFor` timeout on the login form. It was browser-nondeterministic: webkit failed one run while firefox passed, firefox failed the next while webkit passed. The bearer-based `[api]` tests (login captured once in setup) always passed - only the interactive browser login flaked. ### Root cause `fillTotpStep` generated the TOTP code up front, then waited up to 10s for the code input to become visible before submitting: ``` const code = authenticator.generate(env.totpSecret); // generated now ... await codeInput.waitFor({ state: 'visible', timeout: 10_000 }); // up to 10s later await setInputValue(codeInput, code); // submits a possibly-stale code ``` TOTP codes live for a 30s window. On a slow browser run the code crossed its window boundary before it reached the hub, bunyip rejected the stale code, and the page stayed on `/login/2fa` until the caller's 30s poll gave up. The timing dependence is exactly why it moved between browsers run to run. ### Fix (`e2e/lib/login.ts`, `fillTotpStep`) 1. **Generate late + window guard:** generate the code only after the input is visible, and only when a near-full window remains - `authenticator.timeRemaining() >= 5`, otherwise wait for the next window first - so a submitted code cannot expire in flight (network + hub validation). 2. **One retry with a fresh code:** a boundary crossing is transient, so retry once with a newly generated (again window-guarded) code. 3. **Rate-limit safe:** bunyip caps `2fa_verify` at 5 FAILED codes / 15 min per account and a success resets the counter (BUNYIP-201). Each attempt relies on the BUNYIP-331 input-event auto-submit (one POST, no button click), so the same code is never re-POSTed; worst case is three failed submits, well under the cap. The explicit Verify click is kept only as a single fallback for a hub that stops auto-submitting. ### Verification `npx tsc --noEmit` passes. otplib primitives confirmed against the installed types and at runtime (`timeRemaining()` in `(0, 30]`, `generate()` a valid 6-digit code) - and in a local check `timeRemaining()` returned 4, precisely the sub-5s boundary the guard now waits out. Full E2E runs against staging (needs the E2E account + TOTP secret + a deployed target), so the behavioural proof is the next gate run. Separate from the PMS-710 seed work; this only touches the E2E login helper and unblocks the gate for every PR.
fix(e2e): make the TOTP 2fa step window-safe to stop /login/2fa stalls
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 2m25s
Check / fmt + clippy + build + tests (pull_request) Successful in 3m39s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 13m28s
6748170271
The login helper generated the TOTP code before the up-to-10s wait for the code input, so the code could be up to ~10s old by the time it reached the hub. On a slow browser run it crossed its 30s TOTP window boundary, the hub rejected the stale code, and the page sat on /login/2fa until loginViaSpa's 30s poll timed out - a browser-nondeterministic flake (seen on webkit and firefox on different runs, the other passing) that blocked otherwise-green merge gates.

Generate the code as late as possible and only when a near-full window remains (`authenticator.timeRemaining() >= 5`, else wait for the next window), so a submitted code cannot expire in flight, and retry once with a fresh code since a boundary crossing is transient. Each attempt relies on the BUNYIP-331 input-event auto-submit (one POST, no button click), so we never re-POST the same code and burn a failed attempt; bunyip caps 2fa_verify at 5 failed codes / 15 min per account with a reset on success (BUNYIP-201), and the worst case here stays at three. The explicit Verify click is kept only as a single fallback for a hub that stops auto-submitting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9DhtRyWubFuzzKohE3JBt
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-02 22:54:24 +02:00
longjacksonle deleted branch fix/e2e-totp-window-race 2026-08-02 22:57:20 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!486
No description provided.