Files
scan-receipts/tests/security/password_reset_rate_limit.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

228 lines
8.9 KiB
TypeScript

/**
* Password-Reset Rate-Limit Security Suite
*
* Pure-logic tests for the auth flood control the password-reset endpoints run
* on: the fixed-window limiter, the trusted client-IP extraction and the shared
* 429 response helper. No database, no network — the route handlers themselves
* are not imported because they call `requireDatabase()` first, which needs a
* live Postgres. The primitives they call are exactly what is exercised here.
*
* Covered budgets (kept in sync with the routes):
* forgot-password : forgot:ip:<ip> 5/h + forgot:email:<key> 3/h
* reset-password : reset:burst:ip:<ip> 5/10min + reset:ip:<ip> 10/h
* resend-verification: resend:ip:<ip> 5/h + resend:email:<key> 3/h
* login : login:ip:<ip> 20/15min + login:email:<key> 10/15min
* signup : signup:ip:<ip> 5/h + signup:email:<key> 3/h
* change-password : change:ip:<ip> 10/15min
* verify : verify:ip:<ip> 60/h
*/
import { describe, test, expect, runAllTests } from "../e2e/runner";
import {
clientIp,
rateLimit,
resetRateLimits,
} from "../../src/lib/security/rateLimit";
// sucrase-node runs plain CJS without Next.js's path-alias loader, so the
// "@/..." imports inside the shared HTTP helper would not resolve. Teach Node's
// resolver to map "@/x" onto <cwd>/src/x before loading that helper. This is
// module plumbing only — no test logic and no database.
const nodeModule = require("node:module") as typeof import("node:module") & {
_resolveFilename: (request: string, ...args: unknown[]) => string;
};
const nodePath: typeof import("node:path") = require("node:path");
const originalResolve = nodeModule._resolveFilename;
nodeModule._resolveFilename = function (
this: unknown,
request: string,
...args: unknown[]
): string {
if (request.startsWith("@/")) {
return originalResolve.call(
this,
nodePath.join(process.cwd(), "src", request.slice(2)),
...args
);
}
return originalResolve.call(this, request, ...args);
};
// Must be loaded after the shim above is installed.
const { rateLimited } = require("../../src/lib/auth/http") as typeof import("../../src/lib/auth/http");
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
describe("Rate limit — fixed-window semantics", () => {
test("the first `limit` calls within a window are allowed", () => {
resetRateLimits();
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
expect(rateLimit("rl:a", 3, MINUTE).allowed).toBe(true);
});
test("the next call is denied with a positive retryAfter", () => {
resetRateLimits();
rateLimit("rl:b", 1, MINUTE);
const denied = rateLimit("rl:b", 1, MINUTE);
expect(denied.allowed).toBe(false);
expect(denied.retryAfter).toBeGreaterThan(0);
});
test("denied calls never push the window back (fixed window)", () => {
resetRateLimits();
rateLimit("rl:c", 1, MINUTE);
const first = rateLimit("rl:c", 1, MINUTE);
const second = rateLimit("rl:c", 1, MINUTE);
expect(first.allowed).toBe(false);
expect(second.allowed).toBe(false);
// retryAfter only ever shrinks as time passes; a larger value would mean the
// window was extended on a denial, which must not happen.
expect(second.retryAfter).toBeLessThanOrEqual(first.retryAfter);
});
test("different keys are fully isolated", () => {
resetRateLimits();
rateLimit("rl:exhausted", 1, MINUTE);
expect(rateLimit("rl:exhausted", 1, MINUTE).allowed).toBe(false);
expect(rateLimit("rl:fresh", 1, MINUTE).allowed).toBe(true);
expect(rateLimit("rl:other", 5, MINUTE).allowed).toBe(true);
});
test("the window resets after it elapses", async () => {
resetRateLimits();
expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
expect(rateLimit("rl:window", 1, 50).allowed).toBe(false);
await sleep(60);
expect(rateLimit("rl:window", 1, 50).allowed).toBe(true);
});
test("malformed input fails open instead of locking anyone out", () => {
resetRateLimits();
expect(rateLimit("", 5, MINUTE).allowed).toBe(true);
expect(rateLimit("rl:zero-limit", 0, MINUTE).allowed).toBe(true);
expect(rateLimit("rl:zero-window", 5, 0).allowed).toBe(true);
expect(rateLimit("rl:nan", Number.NaN, MINUTE).allowed).toBe(true);
});
test("resetRateLimits empties every bucket", () => {
resetRateLimits();
rateLimit("rl:drain", 1, MINUTE);
expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(false);
resetRateLimits();
expect(rateLimit("rl:drain", 1, MINUTE).allowed).toBe(true);
});
});
describe("Rate limit — the password-reset budgets in production", () => {
test("reset endpoint: the burst cap (5/10min) blocks a rapid scripted run", () => {
resetRateLimits();
const key = "reset:burst:ip:203.0.113.7";
for (let i = 0; i < 5; i++) {
expect(rateLimit(key, 5, 10 * MINUTE).allowed).toBe(true);
}
const denied = rateLimit(key, 5, 10 * MINUTE);
expect(denied.allowed).toBe(false);
expect(denied.retryAfter).toBeGreaterThan(0);
});
test("reset endpoint: the hourly budget (10/h) still applies on top", () => {
resetRateLimits();
const key = "reset:ip:203.0.113.7";
for (let i = 0; i < 10; i++) {
expect(rateLimit(key, 10, HOUR).allowed).toBe(true);
}
expect(rateLimit(key, 10, HOUR).allowed).toBe(false);
});
test("verify endpoint: the generous per-IP budget (60/h) survives a normal click", () => {
resetRateLimits();
const key = "verify:ip:203.0.113.7";
for (let i = 0; i < 60; i++) {
expect(rateLimit(key, 60, HOUR).allowed).toBe(true);
}
expect(rateLimit(key, 60, HOUR).allowed).toBe(false);
});
test("forgot-password: IP 5/h and per-email 3/h are independent", () => {
resetRateLimits();
for (let i = 0; i < 5; i++) {
expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(true);
}
expect(rateLimit("forgot:ip:203.0.113.7", 5, HOUR).allowed).toBe(false);
for (let i = 0; i < 3; i++) {
expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(true);
}
expect(rateLimit("forgot:email:timo%40example.com", 3, HOUR).allowed).toBe(false);
});
});
describe("Rate limit — the 429 response helper", () => {
test("rateLimited() answers 429 with a Retry-After header", () => {
const response = rateLimited(42);
expect(response.status).toBe(429);
expect(response.headers.get("retry-after")).toBe("42");
});
test("rateLimited() carries the stable rate_limited error code", async () => {
const response = rateLimited(9);
const body = await response.json();
expect(body.error).toBe("rate_limited");
expect(body.retryAfter).toBe(9);
});
test("the limiter's retryAfter round-trips into the Retry-After header", () => {
resetRateLimits();
const key = "rl:roundtrip";
rateLimit(key, 1, MINUTE);
const denied = rateLimit(key, 1, MINUTE);
const response = rateLimited(denied.retryAfter);
expect(response.status).toBe(429);
expect(Number(response.headers.get("retry-after"))).toBe(denied.retryAfter);
expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0);
});
});
describe("Rate limit — client IP extraction", () => {
const request = (headers: Record<string, string>): Request =>
new Request("https://example.com/api/auth/reset-password", { headers });
test("x-forwarded-for: the right-most valid IP wins over a spoofed prefix", () => {
const req = request({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" });
expect(clientIp(req)).toBe("203.0.113.7");
});
test("x-forwarded-for: a three-hop chain resolves to the proxy-appended tail", () => {
const req = request({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 198.51.100.9" });
expect(clientIp(req)).toBe("198.51.100.9");
});
test("x-forwarded-for: junk entries are skipped, the last valid one wins", () => {
const req = request({ "x-forwarded-for": "not-an-ip, also-junk, 203.0.113.7" });
expect(clientIp(req)).toBe("203.0.113.7");
});
test("x-real-ip is the fallback when x-forwarded-for is absent", () => {
const req = request({ "x-real-ip": "198.51.100.9" });
expect(clientIp(req)).toBe("198.51.100.9");
});
test("x-real-ip also saves the day when x-forwarded-for has no valid IP", () => {
const req = request({ "x-forwarded-for": "garbage", "x-real-ip": "198.51.100.9" });
expect(clientIp(req)).toBe("198.51.100.9");
});
test("an empty or invalid header chain resolves to \"unknown\"", () => {
expect(clientIp(request({}))).toBe("unknown");
expect(clientIp(request({ "x-forwarded-for": "nonsense" }))).toBe("unknown");
expect(clientIp(request({ "x-real-ip": "nonsense" }))).toBe("unknown");
});
});
if (typeof require !== "undefined" && require.main === module) {
runAllTests().then((ok) => process.exit(ok ? 0 : 1));
}