Merge the second-factor end-to-end journey and the confirmation dialog fix

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:53:50 +02:00
commit 8865e1b225
6 files changed
+830 -9

No files matched your search

+448
View File
@@ -0,0 +1,448 @@
import type {
APIResponse,
BrowserContext,
Page,
Response,
} from "@playwright/test";
import * as OTPAuth from "otpauth";
import {
TOTP_ALGORITHM,
TOTP_DIGITS,
TOTP_PERIOD_SECONDS,
} from "@/lib/auth/mfa/totp";
import { expect, test } from "./setup/test";
import {
clearAuthRateLimits,
dropMfaAccount,
expireStepUp,
latestChallengeAttempts,
latestMfaFailure,
mfaJourneyAccount,
seedMfaAccount,
type MfaJourneyAccount,
} from "./setup/mfa-journey-fixture";
/**
* The whole second-factor path, end to end, in a real browser: enrol TOTP from
* the settings surface, sign out, sign back in through the challenge, and step
* up for an action that demands a recent factor.
*
* ## Why one test and not six
*
* Every stage here is the previous stage's output. The code the sign-in spends
* is the code the next stage replays; the recovery batch that sign-in spends is
* the one the successful regeneration issued; the expired-step-up refusal is
* only meaningful against the session the successful one just used. Split
* across tests, each would have to re-enrol a factor, and enrolment is exactly
* what the first stage is testing. So it is one journey, with `test.step`
* marking the stages in the report, and the account is thrown away afterwards.
*
* ## Controls
*
* Five refusals ride alongside the four positives, because a gate that has
* never been seen to refuse is not known to be a gate:
*
* - a wrong code at the login challenge — 401, no session cookie, and the
* ticket's attempt counter moved (read from the row, since the wire's 401
* is deliberately identical for every reason);
* - the code the previous sign-in just spent, replayed at the next challenge
* — 401. The server implements this as a monotonic `User.totpLastStep`
* floor advanced on every accept (`src/lib/auth/mfa/verify-factor.ts`), so
* a code already spent is refused even inside its own 30-second life;
* - the sensitive action once the step-up stamp has aged out — 401 with
* `meta.errorCode = "auth.stepup.required"`, and the card's prompt;
* - a recovery code presented a second time — 401. The matched row is burned
* on first use;
* - one login past the documented per-IP cap — 429, with the bucket's own
* headers. Measured in one project, for the reason written at that step.
*
* ## Reading the replay refusal, not guessing it
*
* Both TOTP refusals answer with the same generic 401, so the status code
* cannot say WHY. A code that has drifted out of its ±1-step window is refused
* before the replay floor is ever consulted, which means a status-only control
* would stay green with the floor deleted. The route records the distinction —
* `auth.mfa.failed` with `details.replay`, awaited before it answers — so both
* refusals assert that verdict as a pair: false for the wrong code, true for
* the replay.
*
* ## Codes without a clock
*
* Every code is computed from the enrolment secret with the `otpauth`
* dependency the server itself verifies against, at an explicit timestamp. The
* sign-in code is generated one step AHEAD of now: the server accepts ±1 step
* of drift, and a future step is necessarily above the replay floor the
* enrolment left behind, whichever side of a boundary the request lands on.
*
* The one place the boundary matters is that code's second life as the replay:
* it stays in-window for two steps past the one it names, and the sign-out and
* sign-in between the two submissions eat into that. So the journey parks for
* the next step boundary — bounded by a single period, and only when the
* current step has too little left — instead of hoping the wall clock is kind.
*/
const RECOVERY_CODE_COUNT = 10;
const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate";
/** `/api/auth/login`, per IP per 15 minutes (`src/lib/rate-limit.ts`). */
const LOGIN_ATTEMPT_LIMIT = 5;
/** The project that measures that cap — see the closing step for why one. */
const THROTTLE_PROBE_PROJECT = "chromium-desktop";
/**
* The margin the accept→replay pair needs. A code named for step N verifies
* until the end of step N+1 (drift −1), so generating it one step ahead leaves
* at least two periods; below this the journey waits for the next boundary.
*/
const REPLAY_MARGIN_MS = 75_000;
/** A code for `atMs`, built the way `src/lib/auth/mfa/totp.ts` verifies it. */
function totpCodeAt(secretBase32: string, atMs: number): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: TOTP_ALGORITHM,
digits: TOTP_DIGITS,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
}).generate({ timestamp: atMs });
}
/**
* Park until the next TOTP step starts, when the current one has too little
* left for the code about to be minted to survive its replay.
*
* Bounded by a single period and usually a no-op: it waits only for the last
* seconds of a step, and a step is 30 of them.
*/
async function alignToStepBoundary(page: Page): Promise<void> {
const periodMs = TOTP_PERIOD_SECONDS * 1000;
const now = Date.now();
const nextBoundary = (Math.floor(now / periodMs) + 1) * periodMs;
// A code minted now names the next step and verifies until two periods
// after that step begins.
if (nextBoundary + 2 * periodMs - now >= REPLAY_MARGIN_MS) return;
await page.waitForTimeout(nextBoundary - now);
}
async function sessionCookie(context: BrowserContext) {
const cookies = await context.cookies();
return cookies.find((c) => c.name === "healthlog_session");
}
/**
* Open the password form and submit the account's credentials.
*
* The login page opens in passkey mode; the password form mounts only after
* the switch. Returns the `/api/auth/login` response so the caller can assert
* what the SERVER said, not only what the page painted — the two answers this
* journey cares about (a session, or a challenge) are both HTTP 200 and only
* the body tells them apart.
*/
async function submitPassword(
page: Page,
account: MfaJourneyAccount,
): Promise<Response> {
await clearAuthRateLimits();
await page.goto("/auth/login");
await page.getByTestId("login-use-password").click();
await page.getByTestId("login-email").fill(account.username);
await page.getByTestId("login-password").fill(account.password);
const answer = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/login") &&
res.request().method() === "POST",
);
await page.getByTestId("login-password-submit").click();
return answer;
}
/** Submit a factor at the login challenge and return the verify response. */
async function submitFactor(page: Page, code: string): Promise<Response> {
await page.getByTestId("mfa-code-input").fill(code);
const answer = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/mfa/verify") &&
res.request().method() === "POST",
);
await page.getByTestId("mfa-code-submit").click();
return answer;
}
/**
* Open the regeneration dialog, confirm it, and return the route's answer.
*
* The confirming click calls `preventDefault()` so the dialog survives the
* request in flight; the mutation takes it down again when the request
* settles, whatever the answer was. That matters on a REFUSAL, where the card
* behind the dialog is the only thing that says why — on a success the
* fresh-codes panel replaces the whole branch the dialog lives in and it would
* unmount either way. So the dialog being gone here is a product assertion,
* not housekeeping: nothing in this helper dismisses it.
*/
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
await page.getByTestId("recovery-regenerate").click();
const answer = page.waitForResponse(
(res) =>
res.url().includes(REGENERATE_ENDPOINT) &&
res.request().method() === "POST",
);
await page.getByTestId("recovery-regenerate-confirm").click();
const settled = await answer;
await expect(page.getByTestId("recovery-regenerate-dialog")).toBeHidden();
return settled;
}
/** The codes currently on screen, gated on the panel being painted. */
async function visibleRecoveryCodes(page: Page): Promise<string[]> {
const panel = page.getByTestId("recovery-codes");
await expect(panel).toBeVisible();
const items = panel.locator("li");
await expect(items).toHaveCount(RECOVERY_CODE_COUNT);
return items.allInnerTexts();
}
/**
* Spend the login bucket from empty and hand back the answer that went over.
*
* The bucket is per-IP, and the sibling browser project running this same file
* shares it: its sign-ins clear the bucket, and its own attempts fill it. Both
* directions disturb a round rather than fake one — a disturbed round is
* retried, and a round only counts when the whole documented shape holds, the
* cap's worth of attempts answered normally and the next one refused. Returns
* null when no round came out clean, which fails loudly at the call site.
*/
async function reachLoginCap(
page: Page,
account: MfaJourneyAccount,
): Promise<APIResponse | null> {
const attempt = () =>
page.request.post("/api/auth/login", {
data: { email: account.username, password: "not-this-account-password" },
});
for (let round = 0; round < 3; round += 1) {
await clearAuthRateLimits();
let clean = true;
for (let i = 0; i < LOGIN_ATTEMPT_LIMIT && clean; i += 1) {
clean = (await attempt()).status() === 401;
}
if (!clean) continue;
const capped = await attempt();
if (capped.status() === 429) return capped;
}
return null;
}
test.describe("second factor", () => {
test.afterEach(async () => {
await dropMfaAccount(mfaJourneyAccount(test.info().project.name));
});
test("enrols TOTP, signs in with it, and steps up for a sensitive action", async ({
page,
context,
}) => {
// Five sign-ins, an Argon2id verify on each, an enrolment, two
// regenerations and a bounded park on a step boundary. Comfortably
// outside the suite's 30 s default.
test.setTimeout(240_000);
// One account per project: both browser projects run this file, and the
// journey moves the account's factor state, its replay floor and its
// recovery batch. See the fixture's header.
const account: MfaJourneyAccount = mfaJourneyAccount(
test.info().project.name,
);
await seedMfaAccount(account);
let secret = "";
let spentCode = "";
let liveRecoveryCodes: string[] = [];
await test.step("signs in with a password alone while no factor exists", async () => {
const answer = await submitPassword(page, account);
expect(answer.status()).toBe(200);
const body = (await answer.json()) as {
meta?: { mfaRequired?: boolean };
};
expect(body.meta?.mfaRequired).toBeUndefined();
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("enrols an authenticator from the settings surface", async () => {
await page.goto("/settings/security");
await expect(page.getByTestId("totp-card")).toBeVisible();
const setup = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/me/mfa/totp/setup") &&
res.request().method() === "POST",
);
await page.getByTestId("totp-setup-start").click();
const setupAnswer = await setup;
expect(setupAnswer.status()).toBe(200);
const setupBody = (await setupAnswer.json()) as {
data: { otpauthUri: string; totpSecret: string };
};
secret = setupBody.data.totpSecret;
// The QR the page renders and the manual secret beside it must describe
// the same authenticator, or a user who scans and a user who types end
// up enrolled against different secrets.
expect(setupBody.data.otpauthUri).toContain(secret);
await page
.getByTestId("totp-confirm-code")
.fill(totpCodeAt(secret, Date.now()));
const confirm = page.waitForResponse(
(res) =>
res.url().includes("/api/auth/me/mfa/totp/confirm") &&
res.request().method() === "POST",
);
await page.getByTestId("totp-confirm-submit").click();
expect((await confirm).status()).toBe(200);
liveRecoveryCodes = await visibleRecoveryCodes(page);
await expect(page.getByTestId("totp-error")).toBeHidden();
});
await test.step("rotates the recovery codes while the step-up is fresh", async () => {
// The confirming session is stamped `mfaVerifiedAt` by the confirm route,
// so the freshest possible step-up is the one right after enrolment.
await page.getByTestId("recovery-codes-dismiss").click();
const answer = await regenerateRecoveryCodes(page);
expect(answer.status()).toBe(200);
const rotated = await visibleRecoveryCodes(page);
expect(rotated).not.toEqual(liveRecoveryCodes);
await expect(page.getByTestId("totp-error")).toBeHidden();
liveRecoveryCodes = rotated;
});
await test.step("refuses the same rotation once the step-up has aged out", async () => {
await page.getByTestId("recovery-codes-dismiss").click();
await expireStepUp(account);
const answer = await regenerateRecoveryCodes(page);
expect(answer.status()).toBe(401);
const body = (await answer.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("auth.stepup.required");
// And the card says so, on a card the closed dialog no longer covers.
await expect(page.getByTestId("totp-error")).toBeVisible();
});
await test.step("asks for the second factor on the next sign-in", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
const answer = await submitPassword(page, account);
expect(answer.status()).toBe(200);
const body = (await answer.json()) as {
data: null;
meta?: { mfaRequired?: boolean; methods?: string[] };
};
// A correct password is not a session here: the partial state lives in
// the ticket and nothing is minted until the factor passes.
expect(body.data).toBeNull();
expect(body.meta?.mfaRequired).toBe(true);
expect(body.meta?.methods).toEqual(
expect.arrayContaining(["totp", "recovery"]),
);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
});
await test.step("refuses wrong codes and counts each attempt", async () => {
const answer = await submitFactor(page, "000000");
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
// The negative half of the replay pair: a code that never matched the
// secret is not a replay, and the server says so.
expect((await latestMfaFailure(account)).replay).toBe(false);
// A second refusal moves the counter again rather than resetting it.
expect((await submitFactor(page, "000001")).status()).toBe(401);
expect(await latestChallengeAttempts(account)).toBe(2);
});
await test.step("accepts a current code and lands on the dashboard", async () => {
await alignToStepBoundary(page);
// One step ahead of now — inside the ±1 drift window and necessarily
// above the floor the enrolment code left. See the file header.
spentCode = totpCodeAt(secret, Date.now() + TOTP_PERIOD_SECONDS * 1000);
const answer = await submitFactor(page, spentCode);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("refuses the code the last sign-in spent", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
await submitPassword(page, account);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
const answer = await submitFactor(page, spentCode);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
// The code is still inside its own life — the floor is what refused it,
// and this is the assertion that fails if the floor is removed.
expect((await latestMfaFailure(account)).replay).toBe(true);
});
await test.step("spends a recovery code once", async () => {
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("refuses the same recovery code a second time", async () => {
expect((await page.request.post("/api/auth/logout")).status()).toBe(200);
await submitPassword(page, account);
await expect(page.getByTestId("mfa-login-step")).toBeVisible();
await page.getByTestId("mfa-recovery-toggle").click();
const answer = await submitFactor(page, liveRecoveryCodes[0]);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(1);
});
// Every sign-in above clears this bucket, which makes the throttle the one
// auth control the journey rides on and never sees. It costs one step to
// see it: the account is about to be dropped and nothing else signs in
// from here.
//
// Measured in ONE project. The bucket is keyed per IP and every spec on
// the machine is that one IP, the sibling browser project running this
// same file included — two probes emptying and filling one bucket beside
// each other measure their own interference, not the limiter, and were
// seen to. The limit is server behaviour with no viewport in it, so the
// second project would only re-measure the first.
if (test.info().project.name === THROTTLE_PROBE_PROJECT) {
await test.step("refuses the login past the per-IP cap", async () => {
const capped = await reachLoginCap(page, account);
expect(
capped,
"no round of login attempts reached the cap",
).not.toBeNull();
expect(capped?.status()).toBe(429);
expect(capped?.headers()["x-ratelimit-remaining"]).toBe("0");
expect(capped?.headers()["x-ratelimit-reset"]).toBeTruthy();
// Hand the bucket back to the machine — it is shared per IP.
await clearAuthRateLimits();
});
}
});
});
+268
View File
@@ -0,0 +1,268 @@
/**
* The second-factor journey's account, and nothing else.
*
* ## Why a throwaway account
*
* Enrolling TOTP changes how an account logs in for every spec that shares it:
* `/api/auth/login` stops minting a session and answers with a challenge
* instead. `global-setup.ts` already learned that once — it has to log the
* guardian in BEFORE stamping its factor, because the password-only capture
* cannot complete a TOTP login. So this journey, which enrols a factor from a
* clean slate and then signs in through it four times, owns an account nobody
* else touches and re-seeds it from scratch on every run.
*
* The username carries the Playwright PROJECT name. Both browser projects run
* this file, `fullyParallel` is on, and the journey moves one account's factor
* state, its `totpLastStep` replay floor, and its recovery codes. Two projects
* driving one account would be testing which browser got there first. A second
* account costs one INSERT and removes the question.
*
* ## Why Postgres rather than the product
*
* Two of the things this journey needs have no endpoint, by design:
*
* - an EXPIRED step-up. `requireFreshMfa` reads `Session.mfaVerifiedAt` and
* compares it against a five-minute window. Nothing clears that stamp on
* request, and waiting five minutes inside a 30-second test is not a test.
* Ageing the stamp is not a bypass and does not weaken the route: the route
* still runs its gate, and what the fixture simulates is time passing.
* - the anonymous auth buckets. `/api/auth/login` allows five attempts per IP
* per fifteen minutes and `/api/auth/mfa/verify` ten; this journey spends
* four and five of them, and every other spec on the machine shares the
* same localhost bucket. `global-setup.ts` clears the same keys between its
* own logins for the same reason.
*
* Seeded through `pg`, the transport `global-setup.ts`, `vault-fixture.ts` and
* `managed-profile-fixture.ts` all use.
*/
import { hash } from "@node-rs/argon2";
import pg from "pg";
export interface MfaJourneyAccount {
username: string;
email: string;
password: string;
}
/** One account per Playwright project — see the file header. */
export function mfaJourneyAccount(projectName: string): MfaJourneyAccount {
const slug = projectName.replace(/[^a-z0-9]+/gi, "-").toLowerCase();
return {
username: `e2e-mfa-${slug}`,
email: `e2e-mfa-${slug}@healthlog.test`,
// 16 chars, same shape as the fixture passwords in `global-setup.ts`.
password: "Hw6!Tq2pB9nZ4vLd",
};
}
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("[mfa-journey-fixture] DATABASE_URL is not set");
}
return new pg.Pool({ connectionString: url });
}
/** Prisma's `cuid()` shape, close enough for a hand-written row. */
function cuid(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "c";
for (let i = 0; i < 24; i += 1) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
/** Argon2id at the app's own parameters (`src/lib/auth/argon2-params.mjs`). */
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
}
/**
* Drop whatever a previous run left and insert the account fresh.
*
* DELETE rather than upsert: the journey's whole subject is the transition from
* no second factor to one, and an account carrying a confirmed secret, a
* `totpLastStep` replay floor, or a half-spent recovery batch from an earlier
* run would start it in the middle. The row cascades to its sessions, its
* recovery codes and its MFA challenges, so one statement clears all of it.
*
* `onboarding_completed_at` and `onboarding_tour_completed` are stamped for the
* reason `global-setup.ts` documents: without them the proxy bounces the new
* session to `/onboarding` and the tour overlay swallows every click.
*/
export async function seedMfaAccount(
account: MfaJourneyAccount,
): Promise<void> {
const pool = connect();
try {
await pool.query("DELETE FROM users WHERE username = $1", [
account.username,
]);
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true)`,
[
cuid(),
account.username,
account.email,
await hashPassword(account.password),
new Date(),
],
);
} finally {
await pool.end();
}
}
/** Remove the account and everything that cascades off it. */
export async function dropMfaAccount(
account: MfaJourneyAccount,
): Promise<void> {
const pool = connect();
try {
await pool.query("DELETE FROM users WHERE username = $1", [
account.username,
]);
} finally {
await pool.end();
}
}
/**
* Empty the two anonymous auth buckets this journey spends.
*
* Both keys are `${prefix}:${ip}` and every spec on the machine is the same IP,
* so this is the clear `global-setup.ts` runs between its own logins — narrowed
* to the surfaces the journey actually touches. The wider `auth:%` it used to
* run also swept register, passkey and reset buckets, which this file has no
* business emptying: a future spec measuring one of those throttles would find
* its evidence deleted by an unrelated sign-in and never know.
*
* Call it before each sign-in; the product's 429 is a real answer, but it is
* not the answer the journey's stages are about — the closing stage asks for it
* deliberately, and clears afterwards.
*/
export async function clearAuthRateLimits(): Promise<void> {
const pool = connect();
try {
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'auth:login:%' OR key LIKE 'auth:mfa-verify:%'`,
);
} finally {
await pool.end();
}
}
/**
* The attempt counter on the account's most recent login challenge.
*
* `/api/auth/mfa/verify` answers a wrong factor with the same generic 401 it
* gives an unknown ticket, so the throttle it applies is invisible from the
* wire. The counter is the only place the refusal is recorded, and a refusal
* the server does not count is a refusal an attacker can repeat for free — so
* the journey reads it rather than taking the 401 as proof on its own.
*/
export async function latestChallengeAttempts(
account: MfaJourneyAccount,
): Promise<number> {
const pool = connect();
try {
const res = await pool.query<{ attempts: number }>(
`SELECT attempts FROM mfa_challenges
WHERE user_id = (SELECT id FROM users WHERE username = $1)
ORDER BY created_at DESC
LIMIT 1`,
[account.username],
);
const row = res.rows[0];
if (!row) {
throw new Error(
`[mfa-journey-fixture] no login challenge for ${account.username}`,
);
}
return row.attempts;
} finally {
await pool.end();
}
}
/**
* The server's own verdict on the account's most recent refused factor.
*
* `/api/auth/mfa/verify` answers every refusal with the same 401, so the wire
* cannot tell a replayed code from a wrong one — and that distinction is the
* whole subject of the replay control. The route does record it: it writes
* `auth.mfa.failed` with `details.replay` and AWAITS that write before
* responding, so the row is on disk by the time the response lands.
*
* Read it rather than inferring it. A replayed code that has drifted out of its
* ±1-step window is refused as out-of-window — same 401, `replay: false` — and
* a control that only reads the status code cannot tell the two apart, which
* means it would stay green with the replay floor deleted.
*/
export async function latestMfaFailure(
account: MfaJourneyAccount,
): Promise<{ replay: boolean }> {
const pool = connect();
try {
const res = await pool.query<{ details: string | null }>(
`SELECT details FROM audit_logs
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND action = 'auth.mfa.failed'
ORDER BY created_at DESC
LIMIT 1`,
[account.username],
);
const row = res.rows[0];
if (!row?.details) {
throw new Error(
`[mfa-journey-fixture] no refused factor recorded for ${account.username}`,
);
}
const details = JSON.parse(row.details) as { replay?: unknown };
if (typeof details.replay !== "boolean") {
throw new Error(
`[mfa-journey-fixture] refusal for ${account.username} carries no replay verdict`,
);
}
return { replay: details.replay };
} finally {
await pool.end();
}
}
/**
* Age the session's step-up stamp past the five-minute window.
*
* A zero-row update means the jar the journey is driving points at no session
* at all, and the refusal that follows would have nothing to do with freshness.
* Fail loudly here instead, the way `managed-profile-fixture.ts` does.
*/
export async function expireStepUp(account: MfaJourneyAccount): Promise<void> {
const pool = connect();
try {
const aged = await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW() - INTERVAL '10 minutes'
WHERE user_id = (SELECT id FROM users WHERE username = $1)
AND mfa_verified_at IS NOT NULL`,
[account.username],
);
if ((aged.rowCount ?? 0) === 0) {
throw new Error(
`[mfa-journey-fixture] no step-up-stamped session for ${account.username}`,
);
}
} finally {
await pool.end();
}
}
+4
View File
@@ -424,6 +424,7 @@ export default function LoginPage() {
{mode === "passkey" ? (
<Button
variant="outline"
data-testid="login-use-password"
className="min-h-11 w-full"
size="lg"
onClick={() => setMode("password")}
@@ -437,6 +438,7 @@ export default function LoginPage() {
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
<Input
id="email"
data-testid="login-email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -458,6 +460,7 @@ export default function LoginPage() {
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
id="password"
data-testid="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -472,6 +475,7 @@ export default function LoginPage() {
</div>
<Button
type="submit"
data-testid="login-password-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
+4
View File
@@ -129,6 +129,7 @@ export function MfaLoginStep({
</Label>
<Input
id={codeFieldId}
data-testid="mfa-code-input"
value={code}
onChange={(e) =>
setCode(
@@ -148,6 +149,7 @@ export function MfaLoginStep({
</div>
<Button
type="submit"
data-testid="mfa-code-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading || code.trim().length < 6}
@@ -196,6 +198,7 @@ export function MfaLoginStep({
{hasTotp && (
<button
type="button"
data-testid="mfa-recovery-toggle"
onClick={() => {
setUseRecovery((v) => !v);
setCode("");
@@ -212,6 +215,7 @@ export function MfaLoginStep({
{error && (
<div
id={errorId}
data-testid="mfa-error"
role="alert"
aria-live="polite"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -8,6 +8,26 @@
* Project convention is SSR-only tests (no `@testing-library/react`); the
* dialog's open-state behaviour is Radix's own, so we assert the source wires
* the trigger + consequence copy rather than driving a portal at runtime.
*
* ## The refused mutation
*
* A refusal used to leave the confirmation up: the confirming click
* `preventDefault()`s Radix's close so the request can stay in flight behind a
* disabled button, and nothing took the dialog down again. On success that was
* invisible — the fresh-codes panel replaces the branch the dialog lives in,
* so it unmounted anyway — but a refused rotation (or a mistyped code at
* disable) left an overlay covering the one place the card explains itself.
*
* Two halves are pinned below: the sentence a step-up refusal puts on the
* card, called directly; and the wiring that takes the dialog down when the
* request settles, read off the source. The second half is structural because
* the DOM is out of reach here — what a person actually sees, a closed dialog
* over a visible message, is asserted in `e2e/mfa-step-up-journey.spec.ts`
* against a real browser.
*
* Mutation check: drop either `onSettled` and the wiring case goes red; drop
* an `open=` binding and the controlled case goes red; return `err.message`
* from `describeError`'s step-up arm and the sentence case goes red.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -15,8 +35,12 @@ import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/api/api-fetch";
import { I18nProvider } from "@/lib/i18n/context";
import { TotpCard } from "../totp-card";
import en from "@/../messages/en.json";
import { describeError, TotpCard } from "../totp-card";
const STEP_UP_SENTENCE = en.settings.security.stepUpRequired;
function render(node: React.ReactNode) {
const client = new QueryClient({
@@ -29,6 +53,10 @@ function render(node: React.ReactNode) {
);
}
function source(): string {
return readFileSync(resolve(__dirname, "../totp-card.tsx"), "utf8");
}
describe("<TotpCard> — recovery-code regeneration", () => {
it("SSRs the enabled card with the regenerate action", () => {
const html = render(<TotpCard enabled recoveryCodesRemaining={3} />);
@@ -36,7 +64,7 @@ describe("<TotpCard> — recovery-code regeneration", () => {
});
it("wraps regenerate in a confirmation dialog with a consequence line", () => {
const src = readFileSync(resolve(__dirname, "../totp-card.tsx"), "utf8");
const src = source();
// The regenerate action is a dialog trigger, not a bare mutate-on-click.
expect(src).toContain("settings.security.recovery.regenerateTitle");
expect(src).toContain("settings.security.recovery.regenerateConfirm");
@@ -44,3 +72,39 @@ describe("<TotpCard> — recovery-code regeneration", () => {
expect(src).toContain("regenerate.mutate()");
});
});
describe("<TotpCard> — a refused mutation", () => {
it("puts the step-up sentence on the card, not the server's prose", () => {
const refusal = new ApiError(
"Recent second-factor verification required",
401,
{ errorCode: "auth.stepup.required" },
);
const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE);
expect(shown).toBe(STEP_UP_SENTENCE);
expect(shown).not.toContain("second-factor verification required");
});
it("closes each confirmation when its request settles", () => {
const src = source();
// Whatever the answer was — the refusal is the case that needs it, and a
// handler that only closed on success would leave the refusal covered.
expect(src).toContain("onSettled: () => setRegenerateOpen(false)");
expect(src).toContain("onSettled: () => setDisableOpen(false)");
});
it("leaves no confirmation for Radix to close on its own", () => {
const src = source();
// Every dialog on this card is controlled, so none of them can be left
// holding an overlay after a `preventDefault()`ed confirm.
const dialogs = src.match(/<AlertDialog[\s>]/g);
const controlled = src.match(/<AlertDialog\s+open=\{/g);
expect(dialogs).toHaveLength(2);
expect(controlled).toHaveLength(dialogs?.length ?? 0);
// And the confirming click still holds the dialog open while the request
// is in flight — that is what `onSettled` exists to release.
expect(src).toContain("e.preventDefault()");
});
});
@@ -45,8 +45,13 @@ interface ConfirmResult {
recoveryCodesRemaining: number;
}
/** Map a step-up 401 to a clear re-login message; otherwise the raw message. */
function describeError(
/**
* Map a step-up 401 to a clear re-login message; otherwise the raw message.
*
* Exported for the unit suite: this is the sentence a refused mutation leaves
* on the card, and the card is where a person reads it.
*/
export function describeError(
err: unknown,
fallback: string,
stepUpMsg: string,
@@ -89,6 +94,22 @@ export function TotpCard({
"totp",
);
// Both confirmations are controlled rather than left to Radix.
//
// The confirming click has to `preventDefault()` — that is what keeps the
// dialog up, and the button disabled, while the request is in flight. But
// suppressing the close means nothing closes it afterwards either, and on a
// REFUSAL the branch below survives the answer: the dialog stayed open over
// a card whose message the overlay covered, with the confirm button live
// again, so the same refusal could be re-triggered forever with no way to
// read why. On success it went unnoticed only because the fresh-codes panel
// replaces this branch and the dialog unmounts with it.
//
// So each mutation puts its own dialog down when the request settles,
// whatever the answer was, and the message lands on an uncovered card.
const [regenerateOpen, setRegenerateOpen] = useState(false);
const [disableOpen, setDisableOpen] = useState(false);
function resetWizard() {
setSetup(null);
setQrDataUrl(null);
@@ -162,6 +183,7 @@ export function TotpCard({
t("settings.security.stepUpRequired"),
),
),
onSettled: () => setDisableOpen(false),
});
const regenerate = useMutation({
@@ -183,6 +205,7 @@ export function TotpCard({
t("settings.security.stepUpRequired"),
),
),
onSettled: () => setRegenerateOpen(false),
});
async function copySecret() {
@@ -197,7 +220,7 @@ export function TotpCard({
}
return (
<SettingsCard>
<SettingsCard data-testid="totp-card">
<SettingsCardHeader
icon={Smartphone}
title={t("settings.security.totp.title")}
@@ -216,6 +239,7 @@ export function TotpCard({
{!enabled && !setup && !freshCodes && (
<Button
type="button"
data-testid="totp-setup-start"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate()}
disabled={beginSetup.isPending}
@@ -291,6 +315,7 @@ export function TotpCard({
<div className="flex gap-2">
<Input
id={codeFieldId}
data-testid="totp-confirm-code"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
@@ -303,6 +328,7 @@ export function TotpCard({
/>
<Button
type="submit"
data-testid="totp-confirm-submit"
className="min-h-11 sm:min-h-9"
disabled={confirm.isPending || code.length !== 6}
>
@@ -330,6 +356,7 @@ export function TotpCard({
<SettingsCardActions>
<Button
type="button"
data-testid="recovery-codes-dismiss"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => setFreshCodes(null)}
@@ -352,10 +379,14 @@ export function TotpCard({
)}
</p>
<div className="flex flex-wrap gap-2">
<AlertDialog>
<AlertDialog
open={regenerateOpen}
onOpenChange={setRegenerateOpen}
>
<AlertDialogTrigger asChild>
<Button
type="button"
data-testid="recovery-regenerate"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={regenerate.isPending}
@@ -366,7 +397,7 @@ export function TotpCard({
{t("settings.security.recovery.regenerate")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent data-testid="recovery-regenerate-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.recovery.regenerateTitle")}
@@ -378,6 +409,7 @@ export function TotpCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-testid="recovery-regenerate-confirm"
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
onClick={(e) => {
@@ -394,7 +426,7 @@ export function TotpCard({
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
<AlertDialogTrigger asChild>
<Button
type="button"
@@ -404,7 +436,7 @@ export function TotpCard({
{t("settings.security.totp.disable")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent data-testid="totp-disable-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.totp.disableTitle")}
@@ -479,6 +511,7 @@ export function TotpCard({
{error && (
<div
role="alert"
data-testid="totp-error"
className="text-destructive mt-3 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />