import { env, exports } from "cloudflare:workers"; import { describe, expect, it, vi } from "vitest"; import { createLogger, type Logger } from "@/worker/logger"; import { enforceRateLimit } from "@/worker/middleware/rate-limit"; import { ISSUER } from "./helpers"; const SELF = exports.default; const uniqueIdentifier = (prefix: string): string => `${prefix}-${crypto.randomUUID()}`; // Quiet logger for the happy-path tests; the fail-closed test below uses // its own captured logger instead. const testLog: Logger = createLogger("error"); describe("enforceRateLimit (Workers Rate Limiting binding)", () => { it("allows the first 10 calls and blocks the 11th with Retry-After 60s", async () => { const identifier = uniqueIdentifier("threshold"); const decisions = []; for (let i = 0; i < 10; i += 1) { decisions.push(await enforceRateLimit(testLog, env.RL_AUTH, "test-bucket", identifier)); } for (const d of decisions) { expect(d.allowed).toBe(true); expect(d.retryAfterSeconds).toBe(0); } const eleventh = await enforceRateLimit(testLog, env.RL_AUTH, "test-bucket", identifier); expect(eleventh.allowed).toBe(false); expect(eleventh.retryAfterSeconds).toBe(60); }); it("isolates counts per identifier", async () => { const aIdentifier = uniqueIdentifier("identifier-a"); const bIdentifier = uniqueIdentifier("identifier-b"); for (let i = 0; i < 10; i += 1) { await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", aIdentifier); } const aBlocked = await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", aIdentifier); expect(aBlocked.allowed).toBe(false); // A fresh identifier in the same bucket is unaffected. const bFirst = await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", bIdentifier); expect(bFirst.allowed).toBe(true); }); it("isolates counts per bucket", async () => { const identifier = uniqueIdentifier("bucket-isolation"); for (let i = 0; i < 10; i += 1) { await enforceRateLimit(testLog, env.RL_AUTH, "bucket-a", identifier); } expect((await enforceRateLimit(testLog, env.RL_AUTH, "bucket-a", identifier)).allowed).toBe(false); // Same IP in a different bucket starts fresh. const otherBucket = await enforceRateLimit(testLog, env.RL_AUTH, "bucket-b", identifier); expect(otherBucket.allowed).toBe(true); }); it("fails closed when the binding throws", async () => { // Cloudflare's RateLimit binding can reject (overlong key, namespace // error, transient service issue). The wrapper must NOT let the // throw escape to the global handler — that would surface as a 500 // without charging the limit, silently disabling throttling on // abuse-prone endpoints during a binding hiccup. Required behavior: // return `allowed: false` and emit a structured error log. const error = new Error("simulated binding outage"); const throwingBinding = { limit: vi.fn().mockRejectedValue(error), } as unknown as RateLimit; const errorEvents: { event: string; fields: unknown }[] = []; const captured: Logger = { debug: () => {}, info: () => {}, warn: () => {}, error: (event, fields) => errorEvents.push({ event, fields: fields ?? {} }), child: () => captured, }; const decision = await enforceRateLimit(captured, throwingBinding, "test-bucket", "ident"); expect(decision.allowed).toBe(false); expect(decision.retryAfterSeconds).toBe(60); expect(errorEvents).toHaveLength(1); expect(errorEvents[0]!.event).toBe("rate_limit_binding_error"); }); it("isolates counts per binding (RL_AUTH vs RL_API)", async () => { const identifier = uniqueIdentifier("binding-isolation"); // Exhaust the tight RL_AUTH budget (10/60s). for (let i = 0; i < 10; i += 1) { await enforceRateLimit(testLog, env.RL_AUTH, "binding-test", identifier); } expect((await enforceRateLimit(testLog, env.RL_AUTH, "binding-test", identifier)).allowed).toBe(false); // RL_API uses an independent counter even with the same key. const apiDecision = await enforceRateLimit(testLog, env.RL_API, "binding-test", identifier); expect(apiDecision.allowed).toBe(true); }); }); // /api/auth/* is gated by `rateLimitAuthSurface` in // src/worker/middleware/rate-limit.ts. The middleware splits requests // into two tiers backed by independent Cloudflare RateLimit bindings: // RL_AUTH (10/60s, default) and RL_API (300/60s, hot-read paths). // JWKS / ok / error are exempt. // // These tests drive the middleware end-to-end by hammering the same // fixed CF-Connecting-IP through SELF.fetch. Each test uses a UUID-based // IP so binding state from one test cannot bleed into another within // the same `npm test` run. describe("rateLimitAuthSurface (/api/auth/* multi-bucket)", () => { it("RL_AUTH (tight) returns 429 on the 11th request to a default-tier path", async () => { const ip = `test-ip-${crypto.randomUUID()}`; let lastStatus = 0; for (let i = 0; i < 10; i += 1) { const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip }, body: "grant_type=client_credentials", }); lastStatus = res.status; // Never 429 on the first 10 — Better Auth answers with a 4xx on the // malformed body but the rate-limit middleware lets it through. expect(res.status).not.toBe(429); } expect(lastStatus).not.toBe(429); const eleventh = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip }, body: "grant_type=client_credentials", }); expect(eleventh.status).toBe(429); expect(eleventh.headers.get("retry-after")).toBe("60"); const body = (await eleventh.json()) as { error?: string }; expect(body.error).toBe("rate_limited"); }); it("RL_API (loose) does not 429 a hot-read path within the tight 11-call budget", async () => { const ip = `test-ip-${crypto.randomUUID()}`; for (let i = 0; i < 12; i += 1) { const res = await SELF.fetch(`${ISSUER}/api/auth/get-session`, { headers: { "CF-Connecting-IP": ip }, }); // get-session bucket is 300/60s; 12 calls stay well under the cap. expect(res.status).not.toBe(429); } }); it("exempt path /api/auth/jwks does not 429 even past the tight tier threshold", async () => { const ip = `test-ip-${crypto.randomUUID()}`; for (let i = 0; i < 12; i += 1) { const res = await SELF.fetch(`${ISSUER}/api/auth/jwks`, { headers: { "CF-Connecting-IP": ip }, }); expect(res.status).not.toBe(429); } }); it("RL_AUTH and RL_API are independent — exhausting tight does not block loose for the same IP", async () => { const ip = `test-ip-${crypto.randomUUID()}`; for (let i = 0; i < 11; i += 1) { await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip }, body: "grant_type=client_credentials", }); } const tightAgain = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip }, body: "grant_type=client_credentials", }); expect(tightAgain.status).toBe(429); // Same IP, hot-read path still passes — it bills against RL_API, not // RL_AUTH. const loose = await SELF.fetch(`${ISSUER}/api/auth/get-session`, { headers: { "CF-Connecting-IP": ip }, }); expect(loose.status).not.toBe(429); }); it("missing CF-Connecting-IP skips the limiter (non-Cloudflare request)", async () => { // Without CF-Connecting-IP the middleware passes through. This // mirrors miniflare/test environments and any direct workers.dev // reach that bypasses the configured route. Production CF requests // always carry the header, so this branch is never hit in prod. for (let i = 0; i < 12; i += 1) { const res = await SELF.fetch(`${ISSUER}/api/auth/jwks`); expect(res.status).not.toBe(429); } }); });