import { OpenAPIHono } from "@hono/zod-openapi"; import { createPluginContext } from "@suaveplan/api-plugin"; import { EventBus } from "@suaveplan/events"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { createRateLimitPlugin } from "../../src/plugin/plugin.js"; import type { RateLimitPluginConfig } from "../../src/types/types.js"; const noopLogger = { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined, }; async function bootApp(extra: Partial = {}) { const plugin = createRateLimitPlugin({ defaultLimit: 3, defaultWindowMs: 60_000, routes: [ { path: "/api/login", method: "POST", limit: 1, windowMs: 60_000, id: "login", }, ], tenants: [ { tenantId: "ent", multiplier: 5 }, { tenantId: "internal", bypass: true }, ], ...extra, }); const app = new OpenAPIHono(); const services = new Map(); services.set("app", app); const ctx = createPluginContext({ logger: noopLogger, eventBus: new EventBus(), signal: new AbortController().signal, config: plugin.defaults as RateLimitPluginConfig, services, }); await plugin.onInit?.(ctx); app.get("/api/users", (c) => c.text("ok")); app.post("/api/login", (c) => c.text("ok")); return { app, plugin }; } describe("integration — full app", () => { it("isolates rate-limit counters per tenant", { timeout: 30_000, }, async () => { const { app } = await bootApp(); // Tenant A exhausts a defaultLimit of 3 for (let i = 0; i < 3; i++) { const res = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "1.1.1.1", "x-tenant-id": "A", }, }) ); expect(res.status).toBe(200); } const denied = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "1.1.1.1", "x-tenant-id": "A", }, }) ); expect(denied.status).toBe(429); // Tenant B still has its own counter const bRes = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "1.1.1.1", "x-tenant-id": "B", }, }) ); expect(bRes.status).toBe(200); expect(bRes.headers.get("X-RateLimit-Remaining")).toBe("2"); }); it("honours per-route limit (login = 1 per 60s)", { timeout: 30_000, }, async () => { const { app } = await bootApp(); const first = await app.request( new Request("http://x/api/login", { method: "POST", headers: { "x-forwarded-for": "2.2.2.2" }, }) ); expect(first.status).toBe(200); const second = await app.request( new Request("http://x/api/login", { method: "POST", headers: { "x-forwarded-for": "2.2.2.2" }, }) ); expect(second.status).toBe(429); expect(second.headers.get("Retry-After")).toBeTruthy(); }); it("applies tenant multiplier to the limit", { timeout: 30_000, }, async () => { const { app } = await bootApp(); // ent has multiplier 5 on a default of 3 -> 15 allowed let lastStatus = 0; for (let i = 0; i < 15; i++) { const res = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "3.3.3.3", "x-tenant-id": "ent", }, }) ); lastStatus = res.status; } expect(lastStatus).toBe(200); const denied = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "3.3.3.3", "x-tenant-id": "ent", }, }) ); expect(denied.status).toBe(429); }); it("bypasses internal tenant entirely", { timeout: 30_000 }, async () => { const { app } = await bootApp({ defaultLimit: 1 }); for (let i = 0; i < 25; i++) { const res = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "4.4.4.4", "x-tenant-id": "internal", }, }) ); expect(res.status).toBe(200); } }); it("emits headers on every response", { timeout: 30_000 }, async () => { const { app } = await bootApp({ headerStyle: "both" }); const res = await app.request( new Request("http://x/api/users", { headers: { "x-forwarded-for": "5.5.5.5" }, }) ); expect(res.headers.get("X-RateLimit-Limit")).toBeTruthy(); expect(res.headers.get("RateLimit-Limit")).toBeTruthy(); }); });