import { describe, expect, it } from "vitest"; import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit"; describe("createRateLimiter", () => { it("allows up to the limit within a window, then refuses", () => { const limiter = createRateLimiter({ limit: 3, windowMs: 1000 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("a", 1)).toBe(true); expect(limiter.allow("a", 2)).toBe(true); expect(limiter.allow("a", 3)).toBe(false); expect(limiter.allow("a", 999)).toBe(false); }); it("starts a fresh window once the previous one expires", () => { const limiter = createRateLimiter({ limit: 1, windowMs: 1000 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("a", 500)).toBe(false); expect(limiter.allow("a", 1000)).toBe(true); }); it("tracks keys independently", () => { const limiter = createRateLimiter({ limit: 1, windowMs: 1000 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("a", 1)).toBe(false); expect(limiter.allow("b", 1)).toBe(true); }); it("reset forgets a key's attempts", () => { const limiter = createRateLimiter({ limit: 1, windowMs: 1000 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("a", 1)).toBe(false); limiter.reset("a"); expect(limiter.allow("a", 2)).toBe(true); }); it("sweeps expired entries instead of growing past maxKeys", () => { const limiter = createRateLimiter({ limit: 1, windowMs: 1000, maxKeys: 3 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("b", 0)).toBe(true); expect(limiter.allow("c", 0)).toBe(true); // All three are expired at t=1000; the sweep makes room, and the new // key is not throttled by leftover state. expect(limiter.allow("d", 1000)).toBe(true); expect(limiter.allow("d", 1001)).toBe(false); }); it("keeps live windows intact across a sweep", () => { const limiter = createRateLimiter({ limit: 1, windowMs: 1000, maxKeys: 2 }); expect(limiter.allow("a", 0)).toBe(true); expect(limiter.allow("b", 500)).toBe(true); expect(limiter.allow("c", 600)).toBe(true); // sweep drops nothing live expect(limiter.allow("b", 700)).toBe(false); // b's window survived }); }); describe("clientKeyFrom", () => { it("uses the first hop of x-forwarded-for", () => { const headers = new Headers({ "x-forwarded-for": "203.0.113.7, 10.0.0.1" }); expect(clientKeyFrom(headers)).toBe("203.0.113.7"); }); it("falls back to x-real-ip, then to a shared bucket", () => { expect(clientKeyFrom(new Headers({ "x-real-ip": "203.0.113.9" }))).toBe("203.0.113.9"); expect(clientKeyFrom(new Headers())).toBe("unknown"); }); it("caps the key length so junk headers cannot bloat the store", () => { const headers = new Headers({ "x-forwarded-for": "x".repeat(500) }); expect(clientKeyFrom(headers)).toHaveLength(100); }); });