165 lines
5.4 KiB
TypeScript
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();
|
|
});
|
|
});
|