/** * Rate-limit middleware tests. * * Structure: * 1. Middleware — uses the injectable clock (__now) to drive scenarios * deterministically; no real timers. * 2. In-memory store — same clock injection, symmetric to SQLite * contract below. * 3. SQLite store — uses real Bun.SQL through @mandujs/core/db (gated on * Bun.SQL being available so a generic Node runner can still load the * file without failing to compile imports). * 4. Guard — exercises the imperative `enforce` / `check` surface. * * The module-level `__now` clock is mutated in tests via the internal * `_setClockForTests` export. `beforeEach` / `afterEach` restore the real * clock so a failing assert cannot leak a frozen clock into another test. */ import { afterAll, afterEach, beforeEach, describe, expect, it, } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createInMemoryStore, createRateLimitGuard, createSqliteStore, rateLimit, RateLimitError, _setClockForTests, type RateLimitStore, } from "../index"; import { ManduContext } from "../../../filling/context"; // ─── Test helpers ─────────────────────────────────────────────────────────── /** Mutable fake clock. Reset in beforeEach. */ let fakeNow = 0; function setNow(ms: number): void { fakeNow = ms; _setClockForTests(() => fakeNow); } function advance(ms: number): void { fakeNow += ms; } function restoreClock(): void { _setClockForTests(null); } function makeReq( url: string, init: RequestInit & { xForwardedFor?: string; xRealIp?: string } = {}, ): Request { const { xForwardedFor, xRealIp, headers: rawHeaders, ...rest } = init; const headers = new Headers(rawHeaders as HeadersInit | undefined); if (xForwardedFor) headers.set("x-forwarded-for", xForwardedFor); if (xRealIp) headers.set("x-real-ip", xRealIp); return new Request(url, { ...rest, headers }); } function makeCtx(req: Request): ManduContext { return new ManduContext(req); } /** * Run the middleware. When it returns void (allowed), synthesise a 200 * response so tests can uniformly inspect `res.headers`. When it returns a * response (blocked), pass it through. */ async function runMw( mw: (ctx: ManduContext) => Promise, ctx: ManduContext, ): Promise<{ res: Response; passed: boolean }> { const out = await mw(ctx); if (out) return { res: out, passed: false }; return { res: new Response(null, { status: 200 }), passed: true }; } // Keep fake clock fresh across every test. beforeEach(() => { setNow(1_700_000_000_000); // a fixed moment in 2023 }); afterEach(() => { restoreClock(); }); // ─── Middleware tests ────────────────────────────────────────────────────── describe("rateLimit middleware", () => { it("allows a request under the limit and returns void (pass-through)", async () => { const mw = rateLimit({ limit: 5, windowMs: 60_000 }); const ctx = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "10.0.0.1", }), ); const { passed, res } = await runMw(mw, ctx); expect(passed).toBe(true); expect(res.status).toBe(200); }); it("blocks the (limit+1)th request with 429 + Retry-After", async () => { const mw = rateLimit({ limit: 2, windowMs: 60_000 }); const ip = "10.0.0.2"; // burn the budget for (let i = 0; i < 2; i++) { const ctx = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); const { passed } = await runMw(mw, ctx); expect(passed).toBe(true); } const blockedCtx = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); const { passed, res } = await runMw(mw, blockedCtx); expect(passed).toBe(false); expect(res.status).toBe(429); const retryAfter = res.headers.get("Retry-After"); expect(retryAfter).not.toBeNull(); expect(Number(retryAfter)).toBeGreaterThan(0); expect(res.headers.get("X-RateLimit-Limit")).toBe("2"); expect(res.headers.get("X-RateLimit-Remaining")).toBe("0"); const resetHeader = res.headers.get("X-RateLimit-Reset"); expect(resetHeader).not.toBeNull(); // Reset header is Unix seconds (not ms). expect(Number(resetHeader)).toBeLessThan(2_000_000_000); }); it("resets the budget after windowMs elapses", async () => { const mw = rateLimit({ limit: 1, windowMs: 60_000 }); const ip = "10.0.0.3"; // exhaust then confirm blocked const c1 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); expect((await runMw(mw, c1)).passed).toBe(true); const c2 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); expect((await runMw(mw, c2)).passed).toBe(false); // advance past the window → allowed again advance(60_001); const c3 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); expect((await runMw(mw, c3)).passed).toBe(true); }); it("honours a custom keyFn", async () => { const observedKeys: string[] = []; const store = createInMemoryStore(); // Wrap store to observe the key passed through. const wrapped: RateLimitStore = { async hit(key, limit, windowMs) { observedKeys.push(key); return store.hit(key, limit, windowMs); }, async gcNow(olderThanMs) { return store.gcNow(olderThanMs); }, }; const mw = rateLimit({ limit: 5, windowMs: 60_000, store: wrapped, keyFn: () => "custom-key-42", }); const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" })); await runMw(mw, ctx); expect(observedKeys).toEqual(["custom-key-42"]); }); it("treats keyFn returning null as skip (store untouched, no headers)", async () => { let hitCount = 0; const store: RateLimitStore = { async hit(...args) { hitCount++; return createInMemoryStore().hit(...args); }, async gcNow() { return 0; }, }; const mw = rateLimit({ limit: 1, windowMs: 60_000, store, keyFn: () => null, }); const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" })); const { passed, res } = await runMw(mw, ctx); expect(passed).toBe(true); expect(hitCount).toBe(0); // No rate-limit headers on pass-through. expect(res.headers.has("X-RateLimit-Limit")).toBe(false); }); it("bypasses the store when skip(ctx) returns true", async () => { let hitCount = 0; const store: RateLimitStore = { async hit(...args) { hitCount++; return createInMemoryStore().hit(...args); }, async gcNow() { return 0; }, }; const mw = rateLimit({ limit: 1, windowMs: 60_000, store, skip: () => true, }); const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" })); const { passed } = await runMw(mw, ctx); expect(passed).toBe(true); expect(hitCount).toBe(0); }); it("isolates limits across keys — blocking key A does not affect key B", async () => { const mw = rateLimit({ limit: 1, windowMs: 60_000 }); // A: burn and block const a1 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }), ); expect((await runMw(mw, a1)).passed).toBe(true); const a2 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }), ); expect((await runMw(mw, a2)).passed).toBe(false); // B: first hit still allowed const b1 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "2.2.2.2" }), ); expect((await runMw(mw, b1)).passed).toBe(true); }); it("uses x-real-ip fallback when x-forwarded-for is absent", async () => { const mw = rateLimit({ limit: 1, windowMs: 60_000 }); const c1 = makeCtx( makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }), ); expect((await runMw(mw, c1)).passed).toBe(true); const c2 = makeCtx( makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }), ); expect((await runMw(mw, c2)).passed).toBe(false); }); it("skips safe methods via a custom `skip` predicate", async () => { const mw = rateLimit({ limit: 1, windowMs: 60_000, skip: (ctx) => ctx.request.method === "GET" || ctx.request.method === "HEAD", }); // GET: allowed and again allowed (skipped). const g1 = makeCtx( makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }), ); expect((await runMw(mw, g1)).passed).toBe(true); const g2 = makeCtx( makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }), ); expect((await runMw(mw, g2)).passed).toBe(true); // POST on the same IP is still rate-limited. const p1 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }), ); expect((await runMw(mw, p1)).passed).toBe(true); const p2 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }), ); expect((await runMw(mw, p2)).passed).toBe(false); }); it("custom handler: shapes the 429 body the caller's way", async () => { const mw = rateLimit({ limit: 1, windowMs: 60_000, handler: (_ctx, result) => Response.json( { kind: "too_many", wait: result.retryAfterSeconds }, { status: 429 }, ), }); const c1 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }), ); await runMw(mw, c1); const c2 = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }), ); const { res } = await runMw(mw, c2); const body = await res.json(); expect(body.kind).toBe("too_many"); expect(typeof body.wait).toBe("number"); expect(body.wait).toBeGreaterThan(0); // Retry-After is still stamped by the middleware on the caller's response. expect(res.headers.get("Retry-After")).not.toBeNull(); }); it("concurrent hits increment strictly — no lost updates", async () => { const mw = rateLimit({ limit: 100, windowMs: 60_000 }); const ip = "6.6.6.6"; const ctxs = Array.from({ length: 50 }, () => makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ), ); const results = await Promise.all(ctxs.map((c) => runMw(mw, c))); const passed = results.filter((r) => r.passed).length; // All 50 well under limit 100 — all must pass. expect(passed).toBe(50); // 51st more: must still pass. (Total 51, still under 100.) const extra = makeCtx( makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }), ); expect((await runMw(mw, extra)).passed).toBe(true); }); it("rejects invalid options (limit / windowMs must be positive integers)", () => { expect(() => rateLimit({ limit: 0, windowMs: 1000 })).toThrow(); expect(() => rateLimit({ limit: 1, windowMs: 0 })).toThrow(); expect(() => rateLimit({ limit: 1.5, windowMs: 1000 })).toThrow(); expect(() => rateLimit({ limit: -1, windowMs: 1000 })).toThrow(); }); }); // ─── In-memory store tests ───────────────────────────────────────────────── describe("createInMemoryStore", () => { let store: RateLimitStore; beforeEach(() => { store = createInMemoryStore(); }); afterEach(async () => { if (store.close) await store.close(); }); it("allows hits under the limit, blocks above", async () => { const r1 = await store.hit("k", 3, 60_000); expect(r1.allowed).toBe(true); expect(r1.remaining).toBe(2); const r2 = await store.hit("k", 3, 60_000); expect(r2.allowed).toBe(true); expect(r2.remaining).toBe(1); const r3 = await store.hit("k", 3, 60_000); expect(r3.allowed).toBe(true); expect(r3.remaining).toBe(0); const r4 = await store.hit("k", 3, 60_000); expect(r4.allowed).toBe(false); expect(r4.remaining).toBe(0); expect(r4.retryAfterSeconds).toBeGreaterThan(0); }); it("gcNow drops stale entries and reports the count", async () => { // Timeline (T0 = fakeNow at start): // T0: hit("old") → windowStart=T0, count=1 // T0+30_000: hit("fresh") → windowStart=T0+30_000, count=1 // advance to T0+40_000. // gcNow(35_000) → stale when (now - windowStart) > 35_000: // old: 40_000 - 0 = 40_000 > 35_000 → stale ✓ // fresh: 40_000 - 30_000 = 10_000 → alive ✓ await store.hit("old", 5, 60_000); advance(30_000); await store.hit("fresh", 5, 60_000); advance(10_000); const deleted = await store.gcNow(35_000); expect(deleted).toBe(1); // `fresh` survives: its window is still the original one (not rolled // over, since only 10_000 ms have elapsed since it started). Second // hit increments count from 1 → 2; remaining = limit(5) - 2 = 3. const after = await store.hit("fresh", 5, 60_000); expect(after.remaining).toBe(3); }); it("close() prevents subsequent use", async () => { await store.hit("k", 5, 60_000); await store.close!(); await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/); }); it("rollover: fresh window after the previous one fully elapsed", async () => { const r1 = await store.hit("k", 2, 60_000); expect(r1.allowed).toBe(true); const r2 = await store.hit("k", 2, 60_000); expect(r2.allowed).toBe(true); const r3 = await store.hit("k", 2, 60_000); expect(r3.allowed).toBe(false); advance(60_001); const r4 = await store.hit("k", 2, 60_000); expect(r4.allowed).toBe(true); expect(r4.remaining).toBe(1); }); }); // ─── SQLite store tests ───────────────────────────────────────────────────── const hasBunSql = (() => { const g = globalThis as unknown as { Bun?: { SQL?: unknown } }; return typeof g.Bun?.SQL === "function"; })(); const describeIfBunSql = hasBunSql ? describe : describe.skip; describeIfBunSql("createSqliteStore", () => { let scratch: string; beforeEach(() => { scratch = mkdtempSync(join(tmpdir(), "mandu-rl-")); }); afterEach(() => { try { rmSync(scratch, { recursive: true, force: true }); } catch { // best-effort cleanup } }); it("symmetric contract with in-memory: allowed → blocked → rolls over", async () => { const store = createSqliteStore({ dbPath: ":memory:", gcSchedule: false, }); try { const r1 = await store.hit("k", 2, 60_000); expect(r1.allowed).toBe(true); const r2 = await store.hit("k", 2, 60_000); expect(r2.allowed).toBe(true); const r3 = await store.hit("k", 2, 60_000); expect(r3.allowed).toBe(false); expect(r3.remaining).toBe(0); expect(r3.retryAfterSeconds).toBeGreaterThan(0); } finally { await store.close?.(); } }); it("WAL mode is enabled against a file-backed DB", async () => { // File-backed so the pragma is observable. const dbPath = join(scratch, "rl.db"); const store = createSqliteStore({ dbPath, gcSchedule: false }); try { // Issue a dummy hit to force init. await store.hit("probe", 5, 60_000); // Reopen the same file via createDb and check the journal mode. const { createDb } = await import("../../../db"); const probe = createDb({ url: `sqlite:${dbPath}` }); const rows = await probe<{ journal_mode: string }>`PRAGMA journal_mode`; const mode = rows[0]?.journal_mode?.toLowerCase(); expect(mode).toBe("wal"); await probe.close(); } finally { await store.close?.(); } }); it("concurrent hits on the same key serialise (no lost updates)", async () => { const store = createSqliteStore({ dbPath: join(scratch, "concurrent.db"), gcSchedule: false, }); try { // Fire 20 parallel hits under a limit of 100. All must pass AND the // reported remaining values must form a strictly-decreasing set from // 99 down to 80 (modulo ordering). If SQLite let the transactions // interleave with lost updates we'd see duplicates. const hits = await Promise.all( Array.from({ length: 20 }, () => store.hit("concurrent", 100, 60_000)), ); expect(hits.every((r) => r.allowed)).toBe(true); const remainings = hits.map((r) => r.remaining).sort((a, b) => a - b); // 20 hits → remaining values 80..99 (each unique). const unique = new Set(remainings); expect(unique.size).toBe(20); expect(Math.min(...remainings)).toBe(80); expect(Math.max(...remainings)).toBe(99); } finally { await store.close?.(); } }); it("gcNow deletes stale entries and reports accurate count", async () => { const store = createSqliteStore({ dbPath: ":memory:", gcSchedule: false, }); try { // Real clock here (no injection into SQLite helper). Insert a hit, // then call gcNow with olderThanMs = 0 which sets cutoff = now and // deletes everything whose window_start < now. The INSERT used // `Date.now()` as window_start — since Date.now() at cutoff // computation time is equal-or-greater, rows inserted strictly before // this instant are stale. await store.hit("a", 5, 60_000); // Brief async yield so the next Date.now() is likely greater. await new Promise((r) => setTimeout(r, 5)); await store.hit("b", 5, 60_000); await new Promise((r) => setTimeout(r, 5)); // `olderThanMs = 0` → cutoff = now; any row whose window_start < now // is stale. Both rows qualify. const deleted = await store.gcNow(0); expect(deleted).toBeGreaterThanOrEqual(2); } finally { await store.close?.(); } }); it("close() prevents subsequent use", async () => { const store = createSqliteStore({ dbPath: ":memory:", gcSchedule: false, }); await store.hit("k", 5, 60_000); await store.close?.(); await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/); }); it("rejects unsafe table names at construction", () => { expect(() => createSqliteStore({ dbPath: ":memory:", table: "bad name", gcSchedule: false, }), ).toThrow(/Invalid table name/); expect(() => createSqliteStore({ dbPath: ":memory:", table: "drop;--", gcSchedule: false, }), ).toThrow(/Invalid table name/); }); }); // ─── Guard tests ─────────────────────────────────────────────────────────── describe("createRateLimitGuard", () => { it("enforce: under limit resolves; at limit throws RateLimitError", async () => { const guard = createRateLimitGuard({ limit: 2, windowMs: 60_000 }); await guard.enforce("u:1"); await guard.enforce("u:1"); await expect(guard.enforce("u:1")).rejects.toBeInstanceOf(RateLimitError); }); it("RateLimitError carries a result with retryAfterSeconds", async () => { const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); await guard.enforce("u:2"); try { await guard.enforce("u:2"); throw new Error("expected RateLimitError"); } catch (err) { expect(err).toBeInstanceOf(RateLimitError); const rle = err as RateLimitError; expect(rle.result.allowed).toBe(false); expect(rle.result.retryAfterSeconds).toBeGreaterThan(0); expect(typeof rle.result.resetAt).toBe("number"); } }); it("check: never throws, returns the result object (allowed & blocked)", async () => { const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); const r1 = await guard.check("u:3"); expect(r1.allowed).toBe(true); expect(r1.remaining).toBe(0); const r2 = await guard.check("u:3"); expect(r2.allowed).toBe(false); expect(r2.retryAfterSeconds).toBeGreaterThan(0); }); it("different guards have independent default stores", async () => { const g1 = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); const g2 = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); await g1.enforce("k"); await expect(g1.enforce("k")).rejects.toBeInstanceOf(RateLimitError); // Different guard → same key has its own budget. await g2.enforce("k"); }); it("shared store between guards pools the budget", async () => { const shared = createInMemoryStore(); const g1 = createRateLimitGuard({ limit: 1, windowMs: 60_000, store: shared, }); const g2 = createRateLimitGuard({ limit: 1, windowMs: 60_000, store: shared, }); await g1.enforce("k"); // Same key on the shared store → second guard now sees the budget used. await expect(g2.enforce("k")).rejects.toBeInstanceOf(RateLimitError); }); it("rejects empty keys", async () => { const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 }); await expect(guard.enforce("")).rejects.toThrow(); await expect(guard.check("")).rejects.toThrow(); }); }); // Keep test file fully tidy on early exit — global cleanup for the scratch // dirs is per-test. This block is a belt-and-braces guarantee. afterAll(() => { restoreClock(); });