import { test, expect } from "@playwright/test" import { hit, clientIp } from "@/lib/rate-limit" // The limiter is module-level state shared across the process, so every test // uses a unique key prefix to stay independent of the others. let n = 0 const key = (label: string) => `test:${label}:${++n}:${Math.random()}` test.describe("hit()", () => { test("allows requests up to the limit and rejects the one after", () => { const k = key("basic") for (let i = 0; i < 5; i++) { expect(hit(k, 5, 60).ok, `request ${i + 1} of 5 should be allowed`).toBe(true) } expect(hit(k, 5, 60).ok, "the 6th request should be rejected").toBe(false) }) test("reports remaining budget accurately", () => { const k = key("remaining") expect(hit(k, 3, 60).remaining).toBe(2) expect(hit(k, 3, 60).remaining).toBe(1) expect(hit(k, 3, 60).remaining).toBe(0) // Over the limit, remaining stays clamped at zero rather than going negative. expect(hit(k, 3, 60).remaining).toBe(0) }) test("keeps separate keys independent", () => { const a = key("iso-a") const b = key("iso-b") for (let i = 0; i < 3; i++) hit(a, 3, 60) expect(hit(a, 3, 60).ok, "key A is exhausted").toBe(false) expect(hit(b, 3, 60).ok, "key B is untouched").toBe(true) }) test("resets after the window elapses", async () => { const k = key("window") // A 1-second window so the test can actually wait it out. expect(hit(k, 1, 1).ok).toBe(true) expect(hit(k, 1, 1).ok).toBe(false) await new Promise((r) => setTimeout(r, 1100)) expect(hit(k, 1, 1).ok, "a fresh window should allow requests again").toBe(true) }) test("returns a retryAfter of at least one second while limited", () => { const k = key("retry") hit(k, 1, 60) const limited = hit(k, 1, 60) expect(limited.ok).toBe(false) expect(limited.retryAfter).toBeGreaterThan(0) expect(limited.retryAfter).toBeLessThanOrEqual(60) }) test("reports the configured limit back to the caller", () => { expect(hit(key("limit"), 42, 60).limit).toBe(42) }) }) test.describe("clientIp()", () => { const req = (headers: Record) => new Request("https://x.test", { headers }) test("takes the first entry of x-forwarded-for", () => { expect(clientIp(req({ "x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.9.9.9" }))).toBe("1.2.3.4") }) test("trims whitespace around the client address", () => { expect(clientIp(req({ "x-forwarded-for": " 1.2.3.4 , 5.6.7.8" }))).toBe("1.2.3.4") }) test("falls back to x-real-ip when x-forwarded-for is absent", () => { expect(clientIp(req({ "x-real-ip": "8.8.8.8" }))).toBe("8.8.8.8") }) test("degrades to a single shared bucket rather than no limit at all", () => { // The important property: a request with no proxy headers must still land in // SOME bucket. Returning a unique value per request would silently disable // the limit for anyone who can strip headers. expect(clientIp(req({}))).toBe("unknown") expect(clientIp(req({}))).toBe("unknown") }) test("ignores an empty x-forwarded-for and falls through", () => { expect(clientIp(req({ "x-forwarded-for": "", "x-real-ip": "8.8.4.4" }))).toBe("8.8.4.4") }) })