test(e2e): measure the login cap the journey keeps clearing

The journey emptied every `auth:%` bucket before each sign-in, which is wider
than it needs and takes the register, passkey and reset buckets with it — a
future spec measuring one of those throttles would find its evidence deleted by
an unrelated sign-in. The clear now names the two surfaces this file spends.

It also left the throttle as the one auth control the journey rides on and
never sees: no spec in the suite asserted a 429, and this is the only one that
posts a real login at runtime. A closing stage spends the cap from an empty
bucket and asserts the answer that goes over — 429, with the bucket's own
remaining and reset headers. It costs nothing: the account is dropped
immediately after and nothing else signs in from there.

The bucket is keyed per IP and every spec on the machine is that IP, the
sibling browser project included, so the measurement is taken in one project
and a round disturbed by the sibling is retried rather than asserted. Two
probes emptying and filling one bucket beside each other were seen to measure
their own interference instead of the limiter.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 04:52:33 +02:00
1 parent b428fcf74f
commit 78789e090e
2 files changed
+87 -9

No files matched your search

+72 -3
View File
@@ -1,4 +1,9 @@
import type { BrowserContext, Page, Response } from "@playwright/test";
import type {
APIResponse,
BrowserContext,
Page,
Response,
} from "@playwright/test";
import * as OTPAuth from "otpauth";
import { expect, test } from "./setup/test";
@@ -30,7 +35,7 @@ import {
*
* ## Controls
*
* Four refusals ride alongside the four positives, because a gate that has
* 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
@@ -43,7 +48,9 @@ import {
* - 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.
* 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
*
@@ -73,6 +80,10 @@ import {
const TOTP_PERIOD_SECONDS = 30;
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
@@ -186,6 +197,38 @@ async function visibleRecoveryCodes(page: Page): Promise<string[]> {
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));
@@ -370,5 +413,31 @@ test.describe("second factor", () => {
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();
});
}
});
});
+15 -6
View File
@@ -138,17 +138,26 @@ export async function dropMfaAccount(
}
/**
* Empty the anonymous auth buckets.
* Empty the two anonymous auth buckets this journey spends.
*
* 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.
* 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:%'`);
await pool.query(
`DELETE FROM rate_limits
WHERE key LIKE 'auth:login:%' OR key LIKE 'auth:mfa-verify:%'`,
);
} finally {
await pool.end();
}