test(e2e): cover the second-factor journey end to end

Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.

Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.

Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.

The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.

Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:53:11 +02:00
1 parent 1d372df3a0
commit 50a65b6f80
5 files changed
+553 -1

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
import type { BrowserContext, Page, Response } from "@playwright/test";
import * as OTPAuth from "otpauth";
import { expect, test } from "./setup/test";
import {
clearAuthRateLimits,
dropMfaAccount,
expireStepUp,
latestChallengeAttempts,
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 confirm code is the one
* that sets the replay floor; the recovery batch the 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
*
* Four 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 enrolment code replayed at the login challenge — 401. The server
* implements this as a monotonic `User.totpLastStep` floor advanced on
* every accept (`src/lib/auth/mfa/verify-factor.ts:57-77`), 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.
*
* ## 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 — so
* nothing here waits for a step boundary. 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.
*/
const TOTP_PERIOD_SECONDS = 30;
const RECOVERY_CODE_COUNT = 10;
const REGENERATE_ENDPOINT = "/api/auth/me/mfa/recovery-codes/regenerate";
/** 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: "SHA1",
digits: 6,
period: TOTP_PERIOD_SECONDS,
secret: OTPAuth.Secret.fromBase32(secretBase32),
}).generate({ timestamp: atMs });
}
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.locator("#email").fill(account.username);
await page.locator("#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()`, which is what keeps the
* dialog up while the request is in flight — and nothing takes it down again.
* On SUCCESS that goes unnoticed, because the fresh-codes panel replaces the
* whole branch the dialog lives in and it unmounts with it. On a REFUSAL the
* branch stays, so the dialog is still open (`data-state="open"`, measured)
* over a card that is now showing the step-up message underneath it. So the
* journey dismisses it before reading anything on the card.
*/
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 page.keyboard.press("Escape");
await expect(page.locator('[data-slot="alert-dialog-content"]')).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();
}
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,
}) => {
// Four sign-ins, an Argon2id verify on each, an enrolment and two
// regenerations. Comfortably outside the suite's 30 s default.
test.setTimeout(180_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 enrolmentCode = "";
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);
enrolmentCode = totpCodeAt(secret, Date.now());
await page.getByTestId("totp-confirm-code").fill(enrolmentCode);
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 rather than failing silently.
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 a wrong code and counts the 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);
});
await test.step("refuses the enrolment code replayed", async () => {
const answer = await submitFactor(page, enrolmentCode);
expect(answer.status()).toBe(401);
await expect(page.getByTestId("mfa-error")).toBeVisible();
expect(await sessionCookie(context)).toBeUndefined();
expect(await latestChallengeAttempts(account)).toBe(2);
});
await test.step("accepts a current code and lands on the dashboard", async () => {
// One step ahead of now — inside the ±1 drift window and necessarily
// above the floor the enrolment code left. See the file header.
const answer = await submitFactor(
page,
totpCodeAt(secret, Date.now() + TOTP_PERIOD_SECONDS * 1000),
);
expect(answer.status()).toBe(200);
await page.waitForURL("/");
expect(await sessionCookie(context)).toBeDefined();
});
await test.step("spends a recovery code once", 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-toggle-recovery").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-toggle-recovery").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);
});
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* 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 anonymous auth buckets.
*
* Both keys are per-IP and every spec on the machine is the same IP, so this is
* the same unconditional clear `global-setup.ts` runs between its own logins.
* Call it before each sign-in this journey performs; the product's 429 is a
* real answer, but it is not the answer any of these tests is about.
*/
export async function clearAuthRateLimits(): Promise<void> {
const pool = connect();
try {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`);
} 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();
}
}
/**
* 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();
}
}
+2
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")}
@@ -472,6 +473,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-toggle-recovery"
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"
@@ -197,7 +197,7 @@ export function TotpCard({
}
return (
<SettingsCard>
<SettingsCard data-testid="totp-card">
<SettingsCardHeader
icon={Smartphone}
title={t("settings.security.totp.title")}
@@ -216,6 +216,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 +292,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 +305,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 +333,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)}
@@ -356,6 +360,7 @@ export function TotpCard({
<AlertDialogTrigger asChild>
<Button
type="button"
data-testid="recovery-regenerate"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={regenerate.isPending}
@@ -378,6 +383,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) => {
@@ -479,6 +485,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" />