Skip to content
File

Blob: tests/worker/rate-limit.test.ts

typescript199 lines
1import { env, exports } from "cloudflare:workers";
2import { describe, expect, it, vi } from "vitest";
3 
4import { createLogger, type Logger } from "@/worker/logger";
5import { enforceRateLimit } from "@/worker/middleware/rate-limit";
6 
7import { ISSUER } from "./helpers";
8 
9const SELF = exports.default;
10 
11const uniqueIdentifier = (prefix: string): string => `${prefix}-${crypto.randomUUID()}`;
12 
13// Quiet logger for the happy-path tests; the fail-closed test below uses
14// its own captured logger instead.
15const testLog: Logger = createLogger("error");
16 
17describe("enforceRateLimit (Workers Rate Limiting binding)", () => {
18 it("allows the first 10 calls and blocks the 11th with Retry-After 60s", async () => {
19 const identifier = uniqueIdentifier("threshold");
20 
21 const decisions = [];
22 for (let i = 0; i < 10; i += 1) {
23 decisions.push(await enforceRateLimit(testLog, env.RL_AUTH, "test-bucket", identifier));
24 }
25 for (const d of decisions) {
26 expect(d.allowed).toBe(true);
27 expect(d.retryAfterSeconds).toBe(0);
28 }
29 
30 const eleventh = await enforceRateLimit(testLog, env.RL_AUTH, "test-bucket", identifier);
31 expect(eleventh.allowed).toBe(false);
32 expect(eleventh.retryAfterSeconds).toBe(60);
33 });
34 
35 it("isolates counts per identifier", async () => {
36 const aIdentifier = uniqueIdentifier("identifier-a");
37 const bIdentifier = uniqueIdentifier("identifier-b");
38 
39 for (let i = 0; i < 10; i += 1) {
40 await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", aIdentifier);
41 }
42 const aBlocked = await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", aIdentifier);
43 expect(aBlocked.allowed).toBe(false);
44 
45 // A fresh identifier in the same bucket is unaffected.
46 const bFirst = await enforceRateLimit(testLog, env.RL_AUTH, "isolation-bucket", bIdentifier);
47 expect(bFirst.allowed).toBe(true);
48 });
49 
50 it("isolates counts per bucket", async () => {
51 const identifier = uniqueIdentifier("bucket-isolation");
52 for (let i = 0; i < 10; i += 1) {
53 await enforceRateLimit(testLog, env.RL_AUTH, "bucket-a", identifier);
54 }
55 expect((await enforceRateLimit(testLog, env.RL_AUTH, "bucket-a", identifier)).allowed).toBe(false);
56 
57 // Same IP in a different bucket starts fresh.
58 const otherBucket = await enforceRateLimit(testLog, env.RL_AUTH, "bucket-b", identifier);
59 expect(otherBucket.allowed).toBe(true);
60 });
61 
62 it("fails closed when the binding throws", async () => {
63 // Cloudflare's RateLimit binding can reject (overlong key, namespace
64 // error, transient service issue). The wrapper must NOT let the
65 // throw escape to the global handler — that would surface as a 500
66 // without charging the limit, silently disabling throttling on
67 // abuse-prone endpoints during a binding hiccup. Required behavior:
68 // return `allowed: false` and emit a structured error log.
69 const error = new Error("simulated binding outage");
70 const throwingBinding = {
71 limit: vi.fn().mockRejectedValue(error),
72 } as unknown as RateLimit;
73 
74 const errorEvents: { event: string; fields: unknown }[] = [];
75 const captured: Logger = {
76 debug: () => {},
77 info: () => {},
78 warn: () => {},
79 error: (event, fields) => errorEvents.push({ event, fields: fields ?? {} }),
80 child: () => captured,
81 };
82 
83 const decision = await enforceRateLimit(captured, throwingBinding, "test-bucket", "ident");
84 expect(decision.allowed).toBe(false);
85 expect(decision.retryAfterSeconds).toBe(60);
86 expect(errorEvents).toHaveLength(1);
87 expect(errorEvents[0]!.event).toBe("rate_limit_binding_error");
88 });
89 
90 it("isolates counts per binding (RL_AUTH vs RL_API)", async () => {
91 const identifier = uniqueIdentifier("binding-isolation");
92 
93 // Exhaust the tight RL_AUTH budget (10/60s).
94 for (let i = 0; i < 10; i += 1) {
95 await enforceRateLimit(testLog, env.RL_AUTH, "binding-test", identifier);
96 }
97 expect((await enforceRateLimit(testLog, env.RL_AUTH, "binding-test", identifier)).allowed).toBe(false);
98 
99 // RL_API uses an independent counter even with the same key.
100 const apiDecision = await enforceRateLimit(testLog, env.RL_API, "binding-test", identifier);
101 expect(apiDecision.allowed).toBe(true);
102 });
103});
104 
105// /api/auth/* is gated by `rateLimitAuthSurface` in
106// src/worker/middleware/rate-limit.ts. The middleware splits requests
107// into two tiers backed by independent Cloudflare RateLimit bindings:
108// RL_AUTH (10/60s, default) and RL_API (300/60s, hot-read paths).
109// JWKS / ok / error are exempt.
110//
111// These tests drive the middleware end-to-end by hammering the same
112// fixed CF-Connecting-IP through SELF.fetch. Each test uses a UUID-based
113// IP so binding state from one test cannot bleed into another within
114// the same `npm test` run.
115describe("rateLimitAuthSurface (/api/auth/* multi-bucket)", () => {
116 it("RL_AUTH (tight) returns 429 on the 11th request to a default-tier path", async () => {
117 const ip = `test-ip-${crypto.randomUUID()}`;
118 let lastStatus = 0;
119 for (let i = 0; i < 10; i += 1) {
120 const res = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
121 method: "POST",
122 headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip },
123 body: "grant_type=client_credentials",
124 });
125 lastStatus = res.status;
126 // Never 429 on the first 10 — Better Auth answers with a 4xx on the
127 // malformed body but the rate-limit middleware lets it through.
128 expect(res.status).not.toBe(429);
129 }
130 expect(lastStatus).not.toBe(429);
131 
132 const eleventh = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
133 method: "POST",
134 headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip },
135 body: "grant_type=client_credentials",
136 });
137 expect(eleventh.status).toBe(429);
138 expect(eleventh.headers.get("retry-after")).toBe("60");
139 const body = (await eleventh.json()) as { error?: string };
140 expect(body.error).toBe("rate_limited");
141 });
142 
143 it("RL_API (loose) does not 429 a hot-read path within the tight 11-call budget", async () => {
144 const ip = `test-ip-${crypto.randomUUID()}`;
145 for (let i = 0; i < 12; i += 1) {
146 const res = await SELF.fetch(`${ISSUER}/api/auth/get-session`, {
147 headers: { "CF-Connecting-IP": ip },
148 });
149 // get-session bucket is 300/60s; 12 calls stay well under the cap.
150 expect(res.status).not.toBe(429);
151 }
152 });
153 
154 it("exempt path /api/auth/jwks does not 429 even past the tight tier threshold", async () => {
155 const ip = `test-ip-${crypto.randomUUID()}`;
156 for (let i = 0; i < 12; i += 1) {
157 const res = await SELF.fetch(`${ISSUER}/api/auth/jwks`, {
158 headers: { "CF-Connecting-IP": ip },
159 });
160 expect(res.status).not.toBe(429);
161 }
162 });
163 
164 it("RL_AUTH and RL_API are independent — exhausting tight does not block loose for the same IP", async () => {
165 const ip = `test-ip-${crypto.randomUUID()}`;
166 for (let i = 0; i < 11; i += 1) {
167 await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
168 method: "POST",
169 headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip },
170 body: "grant_type=client_credentials",
171 });
172 }
173 const tightAgain = await SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, {
174 method: "POST",
175 headers: { "content-type": "application/x-www-form-urlencoded", "CF-Connecting-IP": ip },
176 body: "grant_type=client_credentials",
177 });
178 expect(tightAgain.status).toBe(429);
179 
180 // Same IP, hot-read path still passes — it bills against RL_API, not
181 // RL_AUTH.
182 const loose = await SELF.fetch(`${ISSUER}/api/auth/get-session`, {
183 headers: { "CF-Connecting-IP": ip },
184 });
185 expect(loose.status).not.toBe(429);
186 });
187 
188 it("missing CF-Connecting-IP skips the limiter (non-Cloudflare request)", async () => {
189 // Without CF-Connecting-IP the middleware passes through. This
190 // mirrors miniflare/test environments and any direct workers.dev
191 // reach that bypasses the configured route. Production CF requests
192 // always carry the header, so this branch is never hit in prod.
193 for (let i = 0; i < 12; i += 1) {
194 const res = await SELF.fetch(`${ISSUER}/api/auth/jwks`);
195 expect(res.status).not.toBe(429);
196 }
197 });
198});