Files
EpicNext-Cms/src/lib/rate-limit.test.ts
T
openhands 179484642f
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Failing after 1m45s
CI / tests-ui (push) Successful in 2m29s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat: per-account login lockout, mail index, resend captcha, i18n scoping
Closes the four HIGH/MEDIUM items left open after the previous pass.

Login lockout
- The only login limits were keyed on the client IP, so a distributed attempt
  could grind on one account indefinitely. Added a per-account lockout with a
  budget of 8 failures per 15 minutes.
- The bucket is keyed on the RESOLVED account id, not on the submitted string:
  users may sign in with either username or e-mail and neither the lookup nor
  the input normaliser folds case, so an input-keyed bucket would hand out a
  fresh budget per spelling of the same account.
- precheckLogin and NextAuth's authorize share the bucket, so the pre-check
  cannot be used to buy extra attempts and a client that skips it entirely is
  still bounded. Both check the lockout BEFORE verifying the password: the
  success path clears the counter, which would otherwise walk a locked account
  straight back in on the right password.
- A successful login clears the failures, which needs two new primitives in
  rate-limit.ts: peekRateLimit (read-only, does not consume a unit) and
  clearRateLimit.
- Fixed a latent inconsistency while doing so: the in-process bucket capped its
  counter at the limit while Redis' INCR kept climbing, so the two backends
  disagreed about how far over the limit a key was. Both now track the true
  count.

Mail lookup index
- Added an index on users.mail (0035). Password reset, e-mail verification and
  the resend cooldown all resolve a single account from a submitted address and
  were full table scans of `users`. Deliberately non-unique: legacy rows can
  hold the same address more than once, so a unique index would fail to apply.

Resend captcha
- /verify's resend form triggers real outbound mail and was reachable with only
  a cooldown. It now runs the configured captcha before the account lookup and
  before any send.

Client message payload
- The root layout serialised the whole catalogue into every page. pages.admin
  and admin are ~177 KB of the ~235 KB and are unreachable from the public route
  group, so that layout now installs its own provider with the staff namespaces
  removed. Nested providers replace rather than merge, which is why this has to
  live in the segment layout. /admin, /mod, /client and /admin-next keep the
  full set; a guard test fails if a public page ever references a staff
  namespace.
2026-10-09 17:12:50 +02:00

109 lines
3.5 KiB
TypeScript

// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/redis", () => ({
redis: null,
}));
import { clearRateLimit, peekRateLimit, rateLimit } from "./rate-limit";
beforeEach(() => {
vi.restoreAllMocks();
});
describe("rateLimit (in-memory fallback)", () => {
it("allows the first request", async () => {
const res = await rateLimit("test:1", 3, 60_000);
expect(res.ok).toBe(true);
expect(res.retryAfter).toBe(0);
});
it("allows up to the limit within a window", async () => {
const key = `test:2:${Date.now()}`;
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
const res = await rateLimit(key, 2, 60_000);
expect(res.ok).toBe(false);
expect(res.retryAfter).toBeGreaterThan(0);
});
it("resets after the window expires", async () => {
const key = `test:3:${Date.now()}`;
await rateLimit(key, 1, 50);
const res1 = await rateLimit(key, 1, 50);
expect(res1.ok).toBe(false);
await new Promise((r) => setTimeout(r, 60));
const res2 = await rateLimit(key, 1, 50);
expect(res2.ok).toBe(true);
});
it("uses separate keys independently", async () => {
const a = await rateLimit("key-a", 1, 60_000);
const b = await rateLimit("key-b", 1, 60_000);
expect(a.ok).toBe(true);
expect(b.ok).toBe(true);
const a2 = await rateLimit("key-a", 1, 60_000);
expect(a2.ok).toBe(false);
});
});
describe("peekRateLimit", () => {
it("reports a fresh bucket as available without consuming a unit", async () => {
const key = `peek:fresh:${Date.now()}`;
const first = await peekRateLimit(key, 2, 60_000);
expect(first.ok).toBe(true);
expect(first.retryAfter).toBe(0);
});
it("does not consume a unit", async () => {
const key = `peek:noconsume:${Date.now()}`;
await peekRateLimit(key, 2, 60_000);
await peekRateLimit(key, 2, 60_000);
await peekRateLimit(key, 2, 60_000);
// Three peeks, budget of two: still allowed, and count is still 0.
expect((await peekRateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(false);
});
it("reports a bucket that is already over the limit", async () => {
const key = `peek:over:${Date.now()}`;
await rateLimit(key, 1, 60_000);
expect((await peekRateLimit(key, 1, 60_000)).ok).toBe(true);
await rateLimit(key, 1, 60_000);
const res = await peekRateLimit(key, 1, 60_000);
expect(res.ok).toBe(false);
expect(res.retryAfter).toBeGreaterThan(0);
});
it("reports an expired bucket as available again", async () => {
const key = `peek:expired:${Date.now()}`;
await rateLimit(key, 1, 50);
await rateLimit(key, 1, 50);
expect((await peekRateLimit(key, 1, 50)).ok).toBe(false);
await new Promise((r) => setTimeout(r, 60));
expect((await peekRateLimit(key, 1, 50)).ok).toBe(true);
});
});
describe("clearRateLimit", () => {
it("empties the bucket so the limit is fully available again", async () => {
const key = `clear:basic:${Date.now()}`;
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(false);
await clearRateLimit(key);
expect((await peekRateLimit(key, 2, 60_000)).ok).toBe(true);
expect((await rateLimit(key, 2, 60_000)).ok).toBe(true);
});
it("is a no-op on an unknown key", async () => {
await expect(
clearRateLimit(`clear:missing:${Date.now()}`),
).resolves.toBeUndefined();
});
});