| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; |
|
|
| |
| 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, |
| })); |
|
|
| |
| 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, |
| }; |
| }); |
|
|
| |
| 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, |
| checkEntitlement: vi.fn().mockResolvedValue(null), |
| checkEntitlementDetailed: vi.fn().mockResolvedValue({ response: null, entitlements: null }), |
| getEntitlements: vi.fn(async () => entitlement), |
| }; |
| }); |
|
|
| |
| 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); |
| |
| |
| 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; |
| 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" }, |
| ); |
| }); |
|
|
| 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); |
| |
| |
| |
| expect(res.status).toBe(403); |
| expect(checkBurst).not.toHaveBeenCalled(); |
| expect(checkRateLimit).not.toHaveBeenCalled(); |
| }); |
| }); |
|
|