Initial Commit
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user