GenerAI / worldmonitor /server /__tests__ /gateway-api-rate-limit.test.ts
amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 5)
20f83d9 verified
Raw
History Blame Contribute Delete
8.46 kB
// @vitest-environment node
/**
* U4 (#3199) β€” gateway wiring for the per-account API rate-limit layer.
*
* The per-account burst/meter math is unit-tested in
* tests/api-key-rate-limit.test.mts; here we STUB that module (per the plan)
* and assert only the GATEWAY wiring at server/gateway.ts:1034 β€” the parts the
* reviewers flagged as defect-prone:
* - eligibility via isUserApiKey (user keys carry NO keyCheck.kind)
* - the global fallback bypass is ENFORCE-only (shadow keeps it active)
* - ordering + 429 shape
* - downgraded / ineligible keys are rejected before limiting
*/
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
// --- Stub the per-account module: control the burst/meter decisions ---------
const checkBurst = vi.fn();
const reserveDailyMeter = vi.fn();
vi.mock("../_shared/api-key-rate-limit", () => ({
checkBurst: (...a: unknown[]) => checkBurst(...a),
reserveDailyMeter: (...a: unknown[]) => reserveDailyMeter(...a),
rateLimitHeaders: () => ({ "X-RateLimit-Limit": "60", "Retry-After": "30" }),
ENTERPRISE_API_RATE_LIMIT: 1000,
}));
// --- Stub the global fallback layer: spy whether checkRateLimit runs --------
const checkRateLimit = vi.fn().mockResolvedValue(null);
const checkFailClosedScopedIpRateLimit = vi.fn().mockResolvedValue(null);
vi.mock("../_shared/rate-limit", async (importActual) => {
const actual = await importActual<typeof import("../_shared/rate-limit")>();
return {
...actual,
checkRateLimit: (...a: unknown[]) => checkRateLimit(...a),
checkFailClosedScopedIpRateLimit: (...a: unknown[]) => checkFailClosedScopedIpRateLimit(...a),
checkEndpointRateLimit: vi.fn().mockResolvedValue(null),
hasEndpointRatePolicy: () => false,
};
});
// --- Stub entitlement resolution: a Starter user, non-tier-gated route -------
const STARTER = {
planKey: "api_starter",
features: {
tier: 2,
apiAccess: true,
apiRateLimit: 60,
apiDailyAllowance: 1000,
maxDashboards: 25,
prioritySupport: false,
exportFormats: ["csv"],
mcpAccess: true,
},
validUntil: Date.now() + 86_400_000,
};
let entitlement: typeof STARTER | { planKey: string; features: Record<string, unknown>; validUntil: number } | null = STARTER;
vi.mock("../_shared/entitlement-check", async (importActual) => {
const actual = await importActual<typeof import("../_shared/entitlement-check")>();
return {
...actual,
getRequiredTier: () => null, // not tier-gated
checkEntitlement: vi.fn().mockResolvedValue(null), // passes
checkEntitlementDetailed: vi.fn().mockResolvedValue({ response: null, entitlements: null }), // passes
getEntitlements: vi.fn(async () => entitlement),
};
});
// --- Stub user-key validation: a valid wm_ key resolves to a userId ----------
vi.mock("../_shared/user-api-key", () => ({
validateUserApiKey: vi.fn(async () => ({ userId: "acct_starter", keyId: "k1", name: "t" })),
}));
import { createDomainGateway } from "../gateway";
function makeGateway() {
return createDomainGateway([
{
method: "GET",
path: "/api/news/v1/list-feed-digest",
handler: async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
},
]);
}
function userKeyRequest() {
return new Request("https://www.worldmonitor.app/api/news/v1/list-feed-digest", {
method: "GET",
headers: { "X-Api-Key": "wm_test_starter_key" },
});
}
const ctx = { waitUntil: () => {} };
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
entitlement = STARTER;
checkBurst.mockReset().mockResolvedValue({ ok: true });
reserveDailyMeter.mockReset().mockResolvedValue({
count: 1,
overLimit: false,
metered: true,
retryAfterSec: 100,
rollback: async () => {},
});
checkRateLimit.mockClear().mockResolvedValue(null);
checkFailClosedScopedIpRateLimit.mockReset().mockResolvedValue(null);
delete process.env.UPSTASH_REDIS_REST_URL;
delete process.env.UPSTASH_REDIS_REST_TOKEN;
});
afterEach(() => {
for (const k of Object.keys(process.env)) if (!(k in ORIGINAL_ENV)) delete process.env[k];
Object.assign(process.env, ORIGINAL_ENV);
});
describe("#3199 U4 β€” gateway per-account rate-limit wiring", () => {
test("eligible Starter wm_ key engages the per-account layer (isUserApiKey discriminator)", async () => {
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(200);
// The block ran the burst check for the user key β€” proves eligibility keys
// on isUserApiKey, not keyCheck.kind (which is undefined for wm_ keys).
expect(checkBurst).toHaveBeenCalledWith(60, "acct_starter");
});
test("ENFORCE + burst trip β†’ 429 and per-IP checkRateLimit is BYPASSED", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
checkBurst.mockResolvedValue({ ok: false, limit: 60, reset: Date.now() + 30_000 });
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(429);
expect(checkRateLimit).not.toHaveBeenCalled();
});
test("SHADOW + burst trip β†’ served (200) and principal global fallback still runs", async () => {
delete process.env.API_RATE_LIMIT_ENFORCE; // shadow (default)
checkBurst.mockResolvedValue({ ok: false, limit: 60, reset: Date.now() + 30_000 });
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(200);
expect(checkRateLimit).toHaveBeenCalledWith(
expect.any(Request),
expect.any(Object),
{ principalUserId: "acct_starter" },
); // protection retained in shadow, isolated by the validated key owner
});
test("ENFORCE + over daily limit β†’ 429, meter rolled back, per-IP bypassed", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
const rollback = vi.fn(async () => {});
reserveDailyMeter.mockResolvedValue({
count: 10_001,
overLimit: true,
metered: true,
retryAfterSec: 100,
rollback,
});
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(429);
expect(rollback).toHaveBeenCalledTimes(1);
expect(checkRateLimit).not.toHaveBeenCalled();
});
test("#4635 U4 β€” ENFORCE burst 429 β†’ informative body (plan, limit, limit_type, upgrade_url)", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
checkBurst.mockResolvedValue({ ok: false, limit: 60, reset: Date.now() + 30_000 });
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(429);
const body = await res.json();
expect(body).toMatchObject({
plan: "api_starter",
limit: 60,
limit_type: "per_minute",
upgrade_url: expect.any(String),
});
expect(typeof body.reset).toBe("string");
});
test("#4635 U4 β€” ENFORCE daily 429 β†’ informative body names the sold limit + daily type", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
reserveDailyMeter.mockResolvedValue({
count: 1_001, overLimit: true, metered: true, retryAfterSec: 100, rollback: async () => {},
});
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(429);
const body = await res.json();
expect(body).toMatchObject({
plan: "api_starter",
limit: 1000,
limit_type: "daily",
upgrade_url: expect.any(String),
});
});
test("ENFORCE + within limits β†’ served, per-IP bypassed", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
const res = await makeGateway()(userKeyRequest(), ctx);
expect(res.status).toBe(200);
expect(checkRateLimit).not.toHaveBeenCalled();
});
test("downgraded entitlement (apiAccess:false) β†’ 403 (#4611), rejected before the rate-limit block", async () => {
process.env.API_RATE_LIMIT_ENFORCE = "true";
entitlement = { planKey: "pro", features: { tier: 1, apiAccess: false, apiRateLimit: 0 }, validUntil: Date.now() + 86_400_000 };
const res = await makeGateway()(userKeyRequest(), ctx);
// #4611: a wm_ key whose owner lost apiAccess is rejected outright, not
// silently downgraded to the per-IP path. The apiAccess gate runs BEFORE
// the #3199 per-account rate-limit block, so neither limiter is consulted.
expect(res.status).toBe(403);
expect(checkBurst).not.toHaveBeenCalled();
expect(checkRateLimit).not.toHaveBeenCalled();
});
});