import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { _resetForTesting, createRateLimit, rateLimit, } from "../../src/server/middleware/rateLimit"; function createApp() { const app = new Hono(); app.post("/login", rateLimit, (c) => c.json({ ok: true })); app.post("/setup", rateLimit, (c) => c.json({ ok: true })); return app; } function makeRequest(app: Hono, path: string, ip = "127.0.0.1") { return app.request(path, { method: "POST", headers: { "x-forwarded-for": ip }, }); } function makeGetRequest(app: Hono, path: string, ip = "127.0.0.1") { return app.request(path, { method: "GET", headers: { "x-forwarded-for": ip }, }); } describe("rateLimit middleware", () => { beforeEach(() => { _resetForTesting(); }); it("allows first request through", async () => { const app = createApp(); const res = await makeRequest(app, "/login"); expect(res.status).toBe(200); }); it("allows up to 5 requests", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { const res = await makeRequest(app, "/login"); expect(res.status).toBe(200); } }); it("returns 429 after 5 requests", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const res = await makeRequest(app, "/login"); expect(res.status).toBe(429); const body = await res.json(); expect(body.error).toBe("Too many attempts. Try again later."); }); it("includes Retry-After header on 429", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const res = await makeRequest(app, "/login"); expect(res.status).toBe(429); const retryAfter = res.headers.get("Retry-After"); expect(retryAfter).toBeTruthy(); expect(Number(retryAfter)).toBeGreaterThan(0); }); it("tracks different IPs independently", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login", "10.0.0.1"); } const blocked = await makeRequest(app, "/login", "10.0.0.1"); expect(blocked.status).toBe(429); const allowed = await makeRequest(app, "/login", "10.0.0.2"); expect(allowed.status).toBe(200); }); it("tracks different paths independently", async () => { const app = createApp(); for (let i = 0; i < 5; i++) { await makeRequest(app, "/login"); } const blockedLogin = await makeRequest(app, "/login"); expect(blockedLogin.status).toBe(429); const allowedSetup = await makeRequest(app, "/setup"); expect(allowedSetup.status).toBe(200); }); }); describe("createRateLimit factory", () => { beforeEach(() => { _resetForTesting(); }); it("blocks on 4th request when limit is 3", async () => { const limit3 = createRateLimit(3, 60_000); const app = new Hono(); app.get("/items", limit3, (c) => c.json({ ok: true })); for (let i = 0; i < 3; i++) { const res = await makeGetRequest(app, "/items"); expect(res.status).toBe(200); } const res = await makeGetRequest(app, "/items"); expect(res.status).toBe(429); }); it("allows exactly 10 requests then blocks on 11th", async () => { const limit10 = createRateLimit(10, 60_000); const app = new Hono(); app.get("/catalog", limit10, (c) => c.json({ ok: true })); for (let i = 0; i < 10; i++) { const res = await makeGetRequest(app, "/catalog"); expect(res.status).toBe(200); } const res = await makeGetRequest(app, "/catalog"); expect(res.status).toBe(429); }); it("tracks different IPs independently", async () => { const limit3 = createRateLimit(3, 60_000); const app = new Hono(); app.get("/items", limit3, (c) => c.json({ ok: true })); for (let i = 0; i < 3; i++) { await makeGetRequest(app, "/items", "192.168.1.1"); } const blocked = await makeGetRequest(app, "/items", "192.168.1.1"); expect(blocked.status).toBe(429); // Different IP should still be allowed const allowed = await makeGetRequest(app, "/items", "192.168.1.2"); expect(allowed.status).toBe(200); }); it("includes Retry-After header on 429 response", async () => { const limit2 = createRateLimit(2, 60_000); const app = new Hono(); app.get("/tags", limit2, (c) => c.json({ ok: true })); await makeGetRequest(app, "/tags"); await makeGetRequest(app, "/tags"); const res = await makeGetRequest(app, "/tags"); expect(res.status).toBe(429); const retryAfter = res.headers.get("Retry-After"); expect(retryAfter).toBeTruthy(); expect(Number(retryAfter)).toBeGreaterThan(0); }); it("two instances with different limits operate independently", async () => { const limit3 = createRateLimit(3, 60_000); const limit5 = createRateLimit(5, 60_000); const app = new Hono(); app.get("/browse", limit3, (c) => c.json({ ok: true })); app.get("/detail", limit5, (c) => c.json({ ok: true })); // Exhaust browse limit (3) for (let i = 0; i < 3; i++) { await makeGetRequest(app, "/browse"); } const blockedBrowse = await makeGetRequest(app, "/browse"); expect(blockedBrowse.status).toBe(429); // Detail endpoint still has its own limit (5) and should work for (let i = 0; i < 5; i++) { const res = await makeGetRequest(app, "/detail"); expect(res.status).toBe(200); } const blockedDetail = await makeGetRequest(app, "/detail"); expect(blockedDetail.status).toBe(429); }); });