mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge the second-factor end-to-end journey and the confirmation dialog fix
This commit is contained in:
commit
8865e1b225
6 files changed
+830
-9
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user