yap-blog/tests/unit/rate-limit.test.ts

72 lines
2.9 KiB
TypeScript

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);
});
});