120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
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<string, { count: number; resetAt: number }>();
|
|
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 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");
|
|
const limiter = createRateLimiter(60_000, 2, {
|
|
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);
|
|
});
|
|
});
|