import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("rate limiter", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-30T10:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); vi.resetModules(); vi.unmock("ioredis"); vi.unstubAllEnvs(); }); it("enforces limits and reset in memory mode", async () => { const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "memory-test" }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(first.remaining).toBe(1); expect(second.allowed).toBe(true); expect(second.remaining).toBe(0); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); await limiter.reset(); const afterReset = await limiter("user-1"); expect(afterReset.allowed).toBe(true); expect(afterReset.remaining).toBe(1); }); it("uses a shared Redis counter when Redis mode is enabled", async () => { const store = new Map(); const delMock = vi.fn(async (...keys: string[]) => { for (const key of keys) { store.delete(key); } return keys.length; }); vi.doMock("ioredis", () => ({ Redis: vi.fn().mockImplementation(() => ({ on: vi.fn(), eval: vi.fn( async (_script: string, _numKeys: number, key: string, windowMsValue: string) => { const now = Date.now(); const windowMs = Number(windowMsValue); const existing = store.get(key); if (!existing || existing.resetAt <= now) { store.set(key, { count: 1, resetAt: now + windowMs }); } else { existing.count += 1; } const current = store.get(key)!; return [current.count, current.resetAt - now]; }, ), scan: vi.fn(async () => ["0", [...store.keys()]]), del: delMock, })), })); const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "redis", redisUrl: "redis://test", name: "redis-test", }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(second.allowed).toBe(true); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); await limiter.reset(); expect(delMock).toHaveBeenCalled(); const afterReset = await limiter("user-1"); expect(afterReset.allowed).toBe(true); expect(afterReset.remaining).toBe(1); }); it("falls back to degraded in-memory counters when Redis is unavailable", async () => { vi.doMock("ioredis", () => ({ Redis: vi.fn().mockImplementation(() => ({ on: vi.fn(), eval: vi.fn(async () => { throw new Error("redis down"); }), scan: vi.fn(async () => ["0", []]), del: vi.fn(async () => 0), })), })); const { createRateLimiter } = await import("../middleware/rate-limit.js"); // Degraded fallback uses max(1, floor(maxRequests/2)), so with // maxRequests=4 the degraded limit is 2 attempts within the window. const limiter = createRateLimiter(60_000, 4, { backend: "redis", redisUrl: "redis://test", name: "redis-fallback-test", }); const first = await limiter("user-1"); const second = await limiter("user-1"); const third = await limiter("user-1"); expect(first.allowed).toBe(true); expect(second.allowed).toBe(true); expect(third.allowed).toBe(false); expect(third.remaining).toBe(0); }); it("denies by default when called with an empty key (fail-closed)", async () => { const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 5, { backend: "memory", name: "empty-key-test" }); const empty = await limiter(""); const whitespace = await limiter(" "); const emptyArray = await limiter([]); const allEmpty = await limiter(["", " "]); expect(empty.allowed).toBe(false); expect(whitespace.allowed).toBe(false); expect(emptyArray.allowed).toBe(false); expect(allEmpty.allowed).toBe(false); }); it("denies if any key in a multi-key call is over its limit", async () => { const { createRateLimiter } = await import("../middleware/rate-limit.js"); const limiter = createRateLimiter(60_000, 2, { backend: "memory", name: "multi-key-test" }); // Exhaust the "email:a" bucket alone await limiter("email:a"); await limiter("email:a"); const emailExhausted = await limiter("email:a"); expect(emailExhausted.allowed).toBe(false); // A call keyed on both email:a AND ip:x must deny because email:a is // exhausted, even though ip:x is fresh. const combined = await limiter(["email:a", "ip:x"]); expect(combined.allowed).toBe(false); // A fresh bucket pair still succeeds. const freshPair = await limiter(["email:b", "ip:y"]); expect(freshPair.allowed).toBe(true); }); });