Initial Commit
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { registerRateLimitServiceSlot } from "../src/services/registry-augmentation.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgRoot = join(here, "..");
|
||||
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("ships ten subpath exports including the root", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const exportKeys = Object.keys(pkg.exports).filter(
|
||||
(k: string) => k !== "./package.json"
|
||||
);
|
||||
expect(exportKeys.length).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("declares the canonical runtime deps", { timeout: 5000 }, () => {
|
||||
const deps = pkg.dependencies as Record<string, string>;
|
||||
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/rate-limiter"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/error"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/types"]).toBe("workspace:^");
|
||||
expect(deps.hono).toBe("catalog:");
|
||||
expect(deps.zod).toBe("catalog:forms");
|
||||
});
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) walk(p, out);
|
||||
else if (
|
||||
entry.endsWith(".ts") &&
|
||||
!entry.endsWith(".test.ts") &&
|
||||
!entry.endsWith(".types.ts") &&
|
||||
entry !== "index.ts"
|
||||
)
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("registerRateLimitServiceSlot is callable without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registerRateLimitServiceSlot()).not.toThrow();
|
||||
});
|
||||
|
||||
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const sources = walk(join(pkgRoot, "src"));
|
||||
const missing: string[] = [];
|
||||
for (const ts of sources) {
|
||||
const md = ts.replace(/\.ts$/, ".md");
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-rate-limit.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user