Files
2026-05-27 23:29:53 +01:00

165 lines
5.4 KiB
TypeScript

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<RateLimitPluginConfig> = {}) {
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<string, unknown>();
services.set("app", app);
const ctx = createPluginContext<RateLimitPluginConfig>({
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();
});
});