@mandujs/core 0.41.2 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
|
@@ -1,642 +1,642 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rate-limit middleware tests.
|
|
3
|
-
*
|
|
4
|
-
* Structure:
|
|
5
|
-
* 1. Middleware — uses the injectable clock (__now) to drive scenarios
|
|
6
|
-
* deterministically; no real timers.
|
|
7
|
-
* 2. In-memory store — same clock injection, symmetric to SQLite
|
|
8
|
-
* contract below.
|
|
9
|
-
* 3. SQLite store — uses real Bun.SQL through @mandujs/core/db (gated on
|
|
10
|
-
* Bun.SQL being available so a generic Node runner can still load the
|
|
11
|
-
* file without failing to compile imports).
|
|
12
|
-
* 4. Guard — exercises the imperative `enforce` / `check` surface.
|
|
13
|
-
*
|
|
14
|
-
* The module-level `__now` clock is mutated in tests via the internal
|
|
15
|
-
* `_setClockForTests` export. `beforeEach` / `afterEach` restore the real
|
|
16
|
-
* clock so a failing assert cannot leak a frozen clock into another test.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
afterAll,
|
|
21
|
-
afterEach,
|
|
22
|
-
beforeEach,
|
|
23
|
-
describe,
|
|
24
|
-
expect,
|
|
25
|
-
it,
|
|
26
|
-
} from "bun:test";
|
|
27
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
28
|
-
import { tmpdir } from "node:os";
|
|
29
|
-
import { join } from "node:path";
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
createInMemoryStore,
|
|
33
|
-
createRateLimitGuard,
|
|
34
|
-
createSqliteStore,
|
|
35
|
-
rateLimit,
|
|
36
|
-
RateLimitError,
|
|
37
|
-
_setClockForTests,
|
|
38
|
-
type RateLimitStore,
|
|
39
|
-
} from "../index";
|
|
40
|
-
import { ManduContext } from "../../../filling/context";
|
|
41
|
-
|
|
42
|
-
// ─── Test helpers ───────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
/** Mutable fake clock. Reset in beforeEach. */
|
|
45
|
-
let fakeNow = 0;
|
|
46
|
-
|
|
47
|
-
function setNow(ms: number): void {
|
|
48
|
-
fakeNow = ms;
|
|
49
|
-
_setClockForTests(() => fakeNow);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function advance(ms: number): void {
|
|
53
|
-
fakeNow += ms;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function restoreClock(): void {
|
|
57
|
-
_setClockForTests(null);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function makeReq(
|
|
61
|
-
url: string,
|
|
62
|
-
init: RequestInit & { xForwardedFor?: string; xRealIp?: string } = {},
|
|
63
|
-
): Request {
|
|
64
|
-
const { xForwardedFor, xRealIp, headers: rawHeaders, ...rest } = init;
|
|
65
|
-
const headers = new Headers(rawHeaders as HeadersInit | undefined);
|
|
66
|
-
if (xForwardedFor) headers.set("x-forwarded-for", xForwardedFor);
|
|
67
|
-
if (xRealIp) headers.set("x-real-ip", xRealIp);
|
|
68
|
-
return new Request(url, { ...rest, headers });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function makeCtx(req: Request): ManduContext {
|
|
72
|
-
return new ManduContext(req);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Run the middleware. When it returns void (allowed), synthesise a 200
|
|
77
|
-
* response so tests can uniformly inspect `res.headers`. When it returns a
|
|
78
|
-
* response (blocked), pass it through.
|
|
79
|
-
*/
|
|
80
|
-
async function runMw(
|
|
81
|
-
mw: (ctx: ManduContext) => Promise<Response | void>,
|
|
82
|
-
ctx: ManduContext,
|
|
83
|
-
): Promise<{ res: Response; passed: boolean }> {
|
|
84
|
-
const out = await mw(ctx);
|
|
85
|
-
if (out) return { res: out, passed: false };
|
|
86
|
-
return { res: new Response(null, { status: 200 }), passed: true };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Keep fake clock fresh across every test.
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
setNow(1_700_000_000_000); // a fixed moment in 2023
|
|
92
|
-
});
|
|
93
|
-
afterEach(() => {
|
|
94
|
-
restoreClock();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// ─── Middleware tests ──────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
describe("rateLimit middleware", () => {
|
|
100
|
-
it("allows a request under the limit and returns void (pass-through)", async () => {
|
|
101
|
-
const mw = rateLimit({ limit: 5, windowMs: 60_000 });
|
|
102
|
-
const ctx = makeCtx(
|
|
103
|
-
makeReq("http://localhost/", {
|
|
104
|
-
method: "POST",
|
|
105
|
-
xForwardedFor: "10.0.0.1",
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
const { passed, res } = await runMw(mw, ctx);
|
|
109
|
-
expect(passed).toBe(true);
|
|
110
|
-
expect(res.status).toBe(200);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("blocks the (limit+1)th request with 429 + Retry-After", async () => {
|
|
114
|
-
const mw = rateLimit({ limit: 2, windowMs: 60_000 });
|
|
115
|
-
const ip = "10.0.0.2";
|
|
116
|
-
|
|
117
|
-
// burn the budget
|
|
118
|
-
for (let i = 0; i < 2; i++) {
|
|
119
|
-
const ctx = makeCtx(
|
|
120
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
121
|
-
);
|
|
122
|
-
const { passed } = await runMw(mw, ctx);
|
|
123
|
-
expect(passed).toBe(true);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const blockedCtx = makeCtx(
|
|
127
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
128
|
-
);
|
|
129
|
-
const { passed, res } = await runMw(mw, blockedCtx);
|
|
130
|
-
expect(passed).toBe(false);
|
|
131
|
-
expect(res.status).toBe(429);
|
|
132
|
-
const retryAfter = res.headers.get("Retry-After");
|
|
133
|
-
expect(retryAfter).not.toBeNull();
|
|
134
|
-
expect(Number(retryAfter)).toBeGreaterThan(0);
|
|
135
|
-
expect(res.headers.get("X-RateLimit-Limit")).toBe("2");
|
|
136
|
-
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
|
137
|
-
const resetHeader = res.headers.get("X-RateLimit-Reset");
|
|
138
|
-
expect(resetHeader).not.toBeNull();
|
|
139
|
-
// Reset header is Unix seconds (not ms).
|
|
140
|
-
expect(Number(resetHeader)).toBeLessThan(2_000_000_000);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("resets the budget after windowMs elapses", async () => {
|
|
144
|
-
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
145
|
-
const ip = "10.0.0.3";
|
|
146
|
-
|
|
147
|
-
// exhaust then confirm blocked
|
|
148
|
-
const c1 = makeCtx(
|
|
149
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
150
|
-
);
|
|
151
|
-
expect((await runMw(mw, c1)).passed).toBe(true);
|
|
152
|
-
const c2 = makeCtx(
|
|
153
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
154
|
-
);
|
|
155
|
-
expect((await runMw(mw, c2)).passed).toBe(false);
|
|
156
|
-
|
|
157
|
-
// advance past the window → allowed again
|
|
158
|
-
advance(60_001);
|
|
159
|
-
const c3 = makeCtx(
|
|
160
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
161
|
-
);
|
|
162
|
-
expect((await runMw(mw, c3)).passed).toBe(true);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("honours a custom keyFn", async () => {
|
|
166
|
-
const observedKeys: string[] = [];
|
|
167
|
-
const store = createInMemoryStore();
|
|
168
|
-
// Wrap store to observe the key passed through.
|
|
169
|
-
const wrapped: RateLimitStore = {
|
|
170
|
-
async hit(key, limit, windowMs) {
|
|
171
|
-
observedKeys.push(key);
|
|
172
|
-
return store.hit(key, limit, windowMs);
|
|
173
|
-
},
|
|
174
|
-
async gcNow(olderThanMs) {
|
|
175
|
-
return store.gcNow(olderThanMs);
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
const mw = rateLimit({
|
|
179
|
-
limit: 5,
|
|
180
|
-
windowMs: 60_000,
|
|
181
|
-
store: wrapped,
|
|
182
|
-
keyFn: () => "custom-key-42",
|
|
183
|
-
});
|
|
184
|
-
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
185
|
-
await runMw(mw, ctx);
|
|
186
|
-
expect(observedKeys).toEqual(["custom-key-42"]);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("treats keyFn returning null as skip (store untouched, no headers)", async () => {
|
|
190
|
-
let hitCount = 0;
|
|
191
|
-
const store: RateLimitStore = {
|
|
192
|
-
async hit(...args) {
|
|
193
|
-
hitCount++;
|
|
194
|
-
return createInMemoryStore().hit(...args);
|
|
195
|
-
},
|
|
196
|
-
async gcNow() {
|
|
197
|
-
return 0;
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
const mw = rateLimit({
|
|
201
|
-
limit: 1,
|
|
202
|
-
windowMs: 60_000,
|
|
203
|
-
store,
|
|
204
|
-
keyFn: () => null,
|
|
205
|
-
});
|
|
206
|
-
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
207
|
-
const { passed, res } = await runMw(mw, ctx);
|
|
208
|
-
expect(passed).toBe(true);
|
|
209
|
-
expect(hitCount).toBe(0);
|
|
210
|
-
// No rate-limit headers on pass-through.
|
|
211
|
-
expect(res.headers.has("X-RateLimit-Limit")).toBe(false);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("bypasses the store when skip(ctx) returns true", async () => {
|
|
215
|
-
let hitCount = 0;
|
|
216
|
-
const store: RateLimitStore = {
|
|
217
|
-
async hit(...args) {
|
|
218
|
-
hitCount++;
|
|
219
|
-
return createInMemoryStore().hit(...args);
|
|
220
|
-
},
|
|
221
|
-
async gcNow() {
|
|
222
|
-
return 0;
|
|
223
|
-
},
|
|
224
|
-
};
|
|
225
|
-
const mw = rateLimit({
|
|
226
|
-
limit: 1,
|
|
227
|
-
windowMs: 60_000,
|
|
228
|
-
store,
|
|
229
|
-
skip: () => true,
|
|
230
|
-
});
|
|
231
|
-
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
232
|
-
const { passed } = await runMw(mw, ctx);
|
|
233
|
-
expect(passed).toBe(true);
|
|
234
|
-
expect(hitCount).toBe(0);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("isolates limits across keys — blocking key A does not affect key B", async () => {
|
|
238
|
-
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
239
|
-
|
|
240
|
-
// A: burn and block
|
|
241
|
-
const a1 = makeCtx(
|
|
242
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }),
|
|
243
|
-
);
|
|
244
|
-
expect((await runMw(mw, a1)).passed).toBe(true);
|
|
245
|
-
const a2 = makeCtx(
|
|
246
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }),
|
|
247
|
-
);
|
|
248
|
-
expect((await runMw(mw, a2)).passed).toBe(false);
|
|
249
|
-
|
|
250
|
-
// B: first hit still allowed
|
|
251
|
-
const b1 = makeCtx(
|
|
252
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "2.2.2.2" }),
|
|
253
|
-
);
|
|
254
|
-
expect((await runMw(mw, b1)).passed).toBe(true);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it("uses x-real-ip fallback when x-forwarded-for is absent", async () => {
|
|
258
|
-
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
259
|
-
const c1 = makeCtx(
|
|
260
|
-
makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }),
|
|
261
|
-
);
|
|
262
|
-
expect((await runMw(mw, c1)).passed).toBe(true);
|
|
263
|
-
const c2 = makeCtx(
|
|
264
|
-
makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }),
|
|
265
|
-
);
|
|
266
|
-
expect((await runMw(mw, c2)).passed).toBe(false);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("skips safe methods via a custom `skip` predicate", async () => {
|
|
270
|
-
const mw = rateLimit({
|
|
271
|
-
limit: 1,
|
|
272
|
-
windowMs: 60_000,
|
|
273
|
-
skip: (ctx) =>
|
|
274
|
-
ctx.request.method === "GET" || ctx.request.method === "HEAD",
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// GET: allowed and again allowed (skipped).
|
|
278
|
-
const g1 = makeCtx(
|
|
279
|
-
makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }),
|
|
280
|
-
);
|
|
281
|
-
expect((await runMw(mw, g1)).passed).toBe(true);
|
|
282
|
-
const g2 = makeCtx(
|
|
283
|
-
makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }),
|
|
284
|
-
);
|
|
285
|
-
expect((await runMw(mw, g2)).passed).toBe(true);
|
|
286
|
-
|
|
287
|
-
// POST on the same IP is still rate-limited.
|
|
288
|
-
const p1 = makeCtx(
|
|
289
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }),
|
|
290
|
-
);
|
|
291
|
-
expect((await runMw(mw, p1)).passed).toBe(true);
|
|
292
|
-
const p2 = makeCtx(
|
|
293
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }),
|
|
294
|
-
);
|
|
295
|
-
expect((await runMw(mw, p2)).passed).toBe(false);
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("custom handler: shapes the 429 body the caller's way", async () => {
|
|
299
|
-
const mw = rateLimit({
|
|
300
|
-
limit: 1,
|
|
301
|
-
windowMs: 60_000,
|
|
302
|
-
handler: (_ctx, result) =>
|
|
303
|
-
Response.json(
|
|
304
|
-
{ kind: "too_many", wait: result.retryAfterSeconds },
|
|
305
|
-
{ status: 429 },
|
|
306
|
-
),
|
|
307
|
-
});
|
|
308
|
-
const c1 = makeCtx(
|
|
309
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }),
|
|
310
|
-
);
|
|
311
|
-
await runMw(mw, c1);
|
|
312
|
-
const c2 = makeCtx(
|
|
313
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }),
|
|
314
|
-
);
|
|
315
|
-
const { res } = await runMw(mw, c2);
|
|
316
|
-
const body = await res.json();
|
|
317
|
-
expect(body.kind).toBe("too_many");
|
|
318
|
-
expect(typeof body.wait).toBe("number");
|
|
319
|
-
expect(body.wait).toBeGreaterThan(0);
|
|
320
|
-
// Retry-After is still stamped by the middleware on the caller's response.
|
|
321
|
-
expect(res.headers.get("Retry-After")).not.toBeNull();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it("concurrent hits increment strictly — no lost updates", async () => {
|
|
325
|
-
const mw = rateLimit({ limit: 100, windowMs: 60_000 });
|
|
326
|
-
const ip = "6.6.6.6";
|
|
327
|
-
const ctxs = Array.from({ length: 50 }, () =>
|
|
328
|
-
makeCtx(
|
|
329
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
330
|
-
),
|
|
331
|
-
);
|
|
332
|
-
const results = await Promise.all(ctxs.map((c) => runMw(mw, c)));
|
|
333
|
-
const passed = results.filter((r) => r.passed).length;
|
|
334
|
-
// All 50 well under limit 100 — all must pass.
|
|
335
|
-
expect(passed).toBe(50);
|
|
336
|
-
|
|
337
|
-
// 51st more: must still pass. (Total 51, still under 100.)
|
|
338
|
-
const extra = makeCtx(
|
|
339
|
-
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
340
|
-
);
|
|
341
|
-
expect((await runMw(mw, extra)).passed).toBe(true);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("rejects invalid options (limit / windowMs must be positive integers)", () => {
|
|
345
|
-
expect(() => rateLimit({ limit: 0, windowMs: 1000 })).toThrow();
|
|
346
|
-
expect(() => rateLimit({ limit: 1, windowMs: 0 })).toThrow();
|
|
347
|
-
expect(() => rateLimit({ limit: 1.5, windowMs: 1000 })).toThrow();
|
|
348
|
-
expect(() => rateLimit({ limit: -1, windowMs: 1000 })).toThrow();
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// ─── In-memory store tests ─────────────────────────────────────────────────
|
|
353
|
-
|
|
354
|
-
describe("createInMemoryStore", () => {
|
|
355
|
-
let store: RateLimitStore;
|
|
356
|
-
|
|
357
|
-
beforeEach(() => {
|
|
358
|
-
store = createInMemoryStore();
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
afterEach(async () => {
|
|
362
|
-
if (store.close) await store.close();
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it("allows hits under the limit, blocks above", async () => {
|
|
366
|
-
const r1 = await store.hit("k", 3, 60_000);
|
|
367
|
-
expect(r1.allowed).toBe(true);
|
|
368
|
-
expect(r1.remaining).toBe(2);
|
|
369
|
-
|
|
370
|
-
const r2 = await store.hit("k", 3, 60_000);
|
|
371
|
-
expect(r2.allowed).toBe(true);
|
|
372
|
-
expect(r2.remaining).toBe(1);
|
|
373
|
-
|
|
374
|
-
const r3 = await store.hit("k", 3, 60_000);
|
|
375
|
-
expect(r3.allowed).toBe(true);
|
|
376
|
-
expect(r3.remaining).toBe(0);
|
|
377
|
-
|
|
378
|
-
const r4 = await store.hit("k", 3, 60_000);
|
|
379
|
-
expect(r4.allowed).toBe(false);
|
|
380
|
-
expect(r4.remaining).toBe(0);
|
|
381
|
-
expect(r4.retryAfterSeconds).toBeGreaterThan(0);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it("gcNow drops stale entries and reports the count", async () => {
|
|
385
|
-
// Timeline (T0 = fakeNow at start):
|
|
386
|
-
// T0: hit("old") → windowStart=T0, count=1
|
|
387
|
-
// T0+30_000: hit("fresh") → windowStart=T0+30_000, count=1
|
|
388
|
-
// advance to T0+40_000.
|
|
389
|
-
// gcNow(35_000) → stale when (now - windowStart) > 35_000:
|
|
390
|
-
// old: 40_000 - 0 = 40_000 > 35_000 → stale ✓
|
|
391
|
-
// fresh: 40_000 - 30_000 = 10_000 → alive ✓
|
|
392
|
-
await store.hit("old", 5, 60_000);
|
|
393
|
-
advance(30_000);
|
|
394
|
-
await store.hit("fresh", 5, 60_000);
|
|
395
|
-
advance(10_000);
|
|
396
|
-
|
|
397
|
-
const deleted = await store.gcNow(35_000);
|
|
398
|
-
expect(deleted).toBe(1);
|
|
399
|
-
|
|
400
|
-
// `fresh` survives: its window is still the original one (not rolled
|
|
401
|
-
// over, since only 10_000 ms have elapsed since it started). Second
|
|
402
|
-
// hit increments count from 1 → 2; remaining = limit(5) - 2 = 3.
|
|
403
|
-
const after = await store.hit("fresh", 5, 60_000);
|
|
404
|
-
expect(after.remaining).toBe(3);
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it("close() prevents subsequent use", async () => {
|
|
408
|
-
await store.hit("k", 5, 60_000);
|
|
409
|
-
await store.close!();
|
|
410
|
-
await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
it("rollover: fresh window after the previous one fully elapsed", async () => {
|
|
414
|
-
const r1 = await store.hit("k", 2, 60_000);
|
|
415
|
-
expect(r1.allowed).toBe(true);
|
|
416
|
-
const r2 = await store.hit("k", 2, 60_000);
|
|
417
|
-
expect(r2.allowed).toBe(true);
|
|
418
|
-
const r3 = await store.hit("k", 2, 60_000);
|
|
419
|
-
expect(r3.allowed).toBe(false);
|
|
420
|
-
|
|
421
|
-
advance(60_001);
|
|
422
|
-
const r4 = await store.hit("k", 2, 60_000);
|
|
423
|
-
expect(r4.allowed).toBe(true);
|
|
424
|
-
expect(r4.remaining).toBe(1);
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
// ─── SQLite store tests ─────────────────────────────────────────────────────
|
|
429
|
-
|
|
430
|
-
const hasBunSql = (() => {
|
|
431
|
-
const g = globalThis as unknown as { Bun?: { SQL?: unknown } };
|
|
432
|
-
return typeof g.Bun?.SQL === "function";
|
|
433
|
-
})();
|
|
434
|
-
const describeIfBunSql = hasBunSql ? describe : describe.skip;
|
|
435
|
-
|
|
436
|
-
describeIfBunSql("createSqliteStore", () => {
|
|
437
|
-
let scratch: string;
|
|
438
|
-
|
|
439
|
-
beforeEach(() => {
|
|
440
|
-
scratch = mkdtempSync(join(tmpdir(), "mandu-rl-"));
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
afterEach(() => {
|
|
444
|
-
try {
|
|
445
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
446
|
-
} catch {
|
|
447
|
-
// best-effort cleanup
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it("symmetric contract with in-memory: allowed → blocked → rolls over", async () => {
|
|
452
|
-
const store = createSqliteStore({
|
|
453
|
-
dbPath: ":memory:",
|
|
454
|
-
gcSchedule: false,
|
|
455
|
-
});
|
|
456
|
-
try {
|
|
457
|
-
const r1 = await store.hit("k", 2, 60_000);
|
|
458
|
-
expect(r1.allowed).toBe(true);
|
|
459
|
-
const r2 = await store.hit("k", 2, 60_000);
|
|
460
|
-
expect(r2.allowed).toBe(true);
|
|
461
|
-
const r3 = await store.hit("k", 2, 60_000);
|
|
462
|
-
expect(r3.allowed).toBe(false);
|
|
463
|
-
expect(r3.remaining).toBe(0);
|
|
464
|
-
expect(r3.retryAfterSeconds).toBeGreaterThan(0);
|
|
465
|
-
} finally {
|
|
466
|
-
await store.close?.();
|
|
467
|
-
}
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
it("WAL mode is enabled against a file-backed DB", async () => {
|
|
471
|
-
// File-backed so the pragma is observable.
|
|
472
|
-
const dbPath = join(scratch, "rl.db");
|
|
473
|
-
const store = createSqliteStore({ dbPath, gcSchedule: false });
|
|
474
|
-
try {
|
|
475
|
-
// Issue a dummy hit to force init.
|
|
476
|
-
await store.hit("probe", 5, 60_000);
|
|
477
|
-
|
|
478
|
-
// Reopen the same file via createDb and check the journal mode.
|
|
479
|
-
const { createDb } = await import("../../../db");
|
|
480
|
-
const probe = createDb({ url: `sqlite:${dbPath}` });
|
|
481
|
-
const rows = await probe<{ journal_mode: string }>`PRAGMA journal_mode`;
|
|
482
|
-
const mode = rows[0]?.journal_mode?.toLowerCase();
|
|
483
|
-
expect(mode).toBe("wal");
|
|
484
|
-
await probe.close();
|
|
485
|
-
} finally {
|
|
486
|
-
await store.close?.();
|
|
487
|
-
}
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
it("concurrent hits on the same key serialise (no lost updates)", async () => {
|
|
491
|
-
const store = createSqliteStore({
|
|
492
|
-
dbPath: join(scratch, "concurrent.db"),
|
|
493
|
-
gcSchedule: false,
|
|
494
|
-
});
|
|
495
|
-
try {
|
|
496
|
-
// Fire 20 parallel hits under a limit of 100. All must pass AND the
|
|
497
|
-
// reported remaining values must form a strictly-decreasing set from
|
|
498
|
-
// 99 down to 80 (modulo ordering). If SQLite let the transactions
|
|
499
|
-
// interleave with lost updates we'd see duplicates.
|
|
500
|
-
const hits = await Promise.all(
|
|
501
|
-
Array.from({ length: 20 }, () => store.hit("concurrent", 100, 60_000)),
|
|
502
|
-
);
|
|
503
|
-
expect(hits.every((r) => r.allowed)).toBe(true);
|
|
504
|
-
const remainings = hits.map((r) => r.remaining).sort((a, b) => a - b);
|
|
505
|
-
// 20 hits → remaining values 80..99 (each unique).
|
|
506
|
-
const unique = new Set(remainings);
|
|
507
|
-
expect(unique.size).toBe(20);
|
|
508
|
-
expect(Math.min(...remainings)).toBe(80);
|
|
509
|
-
expect(Math.max(...remainings)).toBe(99);
|
|
510
|
-
} finally {
|
|
511
|
-
await store.close?.();
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
it("gcNow deletes stale entries and reports accurate count", async () => {
|
|
516
|
-
const store = createSqliteStore({
|
|
517
|
-
dbPath: ":memory:",
|
|
518
|
-
gcSchedule: false,
|
|
519
|
-
});
|
|
520
|
-
try {
|
|
521
|
-
// Real clock here (no injection into SQLite helper). Insert a hit,
|
|
522
|
-
// then call gcNow with olderThanMs = 0 which sets cutoff = now and
|
|
523
|
-
// deletes everything whose window_start < now. The INSERT used
|
|
524
|
-
// `Date.now()` as window_start — since Date.now() at cutoff
|
|
525
|
-
// computation time is equal-or-greater, rows inserted strictly before
|
|
526
|
-
// this instant are stale.
|
|
527
|
-
await store.hit("a", 5, 60_000);
|
|
528
|
-
// Brief async yield so the next Date.now() is likely greater.
|
|
529
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
530
|
-
await store.hit("b", 5, 60_000);
|
|
531
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
532
|
-
|
|
533
|
-
// `olderThanMs = 0` → cutoff = now; any row whose window_start < now
|
|
534
|
-
// is stale. Both rows qualify.
|
|
535
|
-
const deleted = await store.gcNow(0);
|
|
536
|
-
expect(deleted).toBeGreaterThanOrEqual(2);
|
|
537
|
-
} finally {
|
|
538
|
-
await store.close?.();
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it("close() prevents subsequent use", async () => {
|
|
543
|
-
const store = createSqliteStore({
|
|
544
|
-
dbPath: ":memory:",
|
|
545
|
-
gcSchedule: false,
|
|
546
|
-
});
|
|
547
|
-
await store.hit("k", 5, 60_000);
|
|
548
|
-
await store.close?.();
|
|
549
|
-
await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("rejects unsafe table names at construction", () => {
|
|
553
|
-
expect(() =>
|
|
554
|
-
createSqliteStore({
|
|
555
|
-
dbPath: ":memory:",
|
|
556
|
-
table: "bad name",
|
|
557
|
-
gcSchedule: false,
|
|
558
|
-
}),
|
|
559
|
-
).toThrow(/Invalid table name/);
|
|
560
|
-
expect(() =>
|
|
561
|
-
createSqliteStore({
|
|
562
|
-
dbPath: ":memory:",
|
|
563
|
-
table: "drop;--",
|
|
564
|
-
gcSchedule: false,
|
|
565
|
-
}),
|
|
566
|
-
).toThrow(/Invalid table name/);
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
// ─── Guard tests ───────────────────────────────────────────────────────────
|
|
571
|
-
|
|
572
|
-
describe("createRateLimitGuard", () => {
|
|
573
|
-
it("enforce: under limit resolves; at limit throws RateLimitError", async () => {
|
|
574
|
-
const guard = createRateLimitGuard({ limit: 2, windowMs: 60_000 });
|
|
575
|
-
await guard.enforce("u:1");
|
|
576
|
-
await guard.enforce("u:1");
|
|
577
|
-
await expect(guard.enforce("u:1")).rejects.toBeInstanceOf(RateLimitError);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it("RateLimitError carries a result with retryAfterSeconds", async () => {
|
|
581
|
-
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
582
|
-
await guard.enforce("u:2");
|
|
583
|
-
try {
|
|
584
|
-
await guard.enforce("u:2");
|
|
585
|
-
throw new Error("expected RateLimitError");
|
|
586
|
-
} catch (err) {
|
|
587
|
-
expect(err).toBeInstanceOf(RateLimitError);
|
|
588
|
-
const rle = err as RateLimitError;
|
|
589
|
-
expect(rle.result.allowed).toBe(false);
|
|
590
|
-
expect(rle.result.retryAfterSeconds).toBeGreaterThan(0);
|
|
591
|
-
expect(typeof rle.result.resetAt).toBe("number");
|
|
592
|
-
}
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
it("check: never throws, returns the result object (allowed & blocked)", async () => {
|
|
596
|
-
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
597
|
-
const r1 = await guard.check("u:3");
|
|
598
|
-
expect(r1.allowed).toBe(true);
|
|
599
|
-
expect(r1.remaining).toBe(0);
|
|
600
|
-
const r2 = await guard.check("u:3");
|
|
601
|
-
expect(r2.allowed).toBe(false);
|
|
602
|
-
expect(r2.retryAfterSeconds).toBeGreaterThan(0);
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
it("different guards have independent default stores", async () => {
|
|
606
|
-
const g1 = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
607
|
-
const g2 = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
608
|
-
await g1.enforce("k");
|
|
609
|
-
await expect(g1.enforce("k")).rejects.toBeInstanceOf(RateLimitError);
|
|
610
|
-
// Different guard → same key has its own budget.
|
|
611
|
-
await g2.enforce("k");
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
it("shared store between guards pools the budget", async () => {
|
|
615
|
-
const shared = createInMemoryStore();
|
|
616
|
-
const g1 = createRateLimitGuard({
|
|
617
|
-
limit: 1,
|
|
618
|
-
windowMs: 60_000,
|
|
619
|
-
store: shared,
|
|
620
|
-
});
|
|
621
|
-
const g2 = createRateLimitGuard({
|
|
622
|
-
limit: 1,
|
|
623
|
-
windowMs: 60_000,
|
|
624
|
-
store: shared,
|
|
625
|
-
});
|
|
626
|
-
await g1.enforce("k");
|
|
627
|
-
// Same key on the shared store → second guard now sees the budget used.
|
|
628
|
-
await expect(g2.enforce("k")).rejects.toBeInstanceOf(RateLimitError);
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it("rejects empty keys", async () => {
|
|
632
|
-
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
633
|
-
await expect(guard.enforce("")).rejects.toThrow();
|
|
634
|
-
await expect(guard.check("")).rejects.toThrow();
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
// Keep test file fully tidy on early exit — global cleanup for the scratch
|
|
639
|
-
// dirs is per-test. This block is a belt-and-braces guarantee.
|
|
640
|
-
afterAll(() => {
|
|
641
|
-
restoreClock();
|
|
642
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit middleware tests.
|
|
3
|
+
*
|
|
4
|
+
* Structure:
|
|
5
|
+
* 1. Middleware — uses the injectable clock (__now) to drive scenarios
|
|
6
|
+
* deterministically; no real timers.
|
|
7
|
+
* 2. In-memory store — same clock injection, symmetric to SQLite
|
|
8
|
+
* contract below.
|
|
9
|
+
* 3. SQLite store — uses real Bun.SQL through @mandujs/core/db (gated on
|
|
10
|
+
* Bun.SQL being available so a generic Node runner can still load the
|
|
11
|
+
* file without failing to compile imports).
|
|
12
|
+
* 4. Guard — exercises the imperative `enforce` / `check` surface.
|
|
13
|
+
*
|
|
14
|
+
* The module-level `__now` clock is mutated in tests via the internal
|
|
15
|
+
* `_setClockForTests` export. `beforeEach` / `afterEach` restore the real
|
|
16
|
+
* clock so a failing assert cannot leak a frozen clock into another test.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
afterAll,
|
|
21
|
+
afterEach,
|
|
22
|
+
beforeEach,
|
|
23
|
+
describe,
|
|
24
|
+
expect,
|
|
25
|
+
it,
|
|
26
|
+
} from "bun:test";
|
|
27
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createInMemoryStore,
|
|
33
|
+
createRateLimitGuard,
|
|
34
|
+
createSqliteStore,
|
|
35
|
+
rateLimit,
|
|
36
|
+
RateLimitError,
|
|
37
|
+
_setClockForTests,
|
|
38
|
+
type RateLimitStore,
|
|
39
|
+
} from "../index";
|
|
40
|
+
import { ManduContext } from "../../../filling/context";
|
|
41
|
+
|
|
42
|
+
// ─── Test helpers ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Mutable fake clock. Reset in beforeEach. */
|
|
45
|
+
let fakeNow = 0;
|
|
46
|
+
|
|
47
|
+
function setNow(ms: number): void {
|
|
48
|
+
fakeNow = ms;
|
|
49
|
+
_setClockForTests(() => fakeNow);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function advance(ms: number): void {
|
|
53
|
+
fakeNow += ms;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function restoreClock(): void {
|
|
57
|
+
_setClockForTests(null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeReq(
|
|
61
|
+
url: string,
|
|
62
|
+
init: RequestInit & { xForwardedFor?: string; xRealIp?: string } = {},
|
|
63
|
+
): Request {
|
|
64
|
+
const { xForwardedFor, xRealIp, headers: rawHeaders, ...rest } = init;
|
|
65
|
+
const headers = new Headers(rawHeaders as HeadersInit | undefined);
|
|
66
|
+
if (xForwardedFor) headers.set("x-forwarded-for", xForwardedFor);
|
|
67
|
+
if (xRealIp) headers.set("x-real-ip", xRealIp);
|
|
68
|
+
return new Request(url, { ...rest, headers });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeCtx(req: Request): ManduContext {
|
|
72
|
+
return new ManduContext(req);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run the middleware. When it returns void (allowed), synthesise a 200
|
|
77
|
+
* response so tests can uniformly inspect `res.headers`. When it returns a
|
|
78
|
+
* response (blocked), pass it through.
|
|
79
|
+
*/
|
|
80
|
+
async function runMw(
|
|
81
|
+
mw: (ctx: ManduContext) => Promise<Response | void>,
|
|
82
|
+
ctx: ManduContext,
|
|
83
|
+
): Promise<{ res: Response; passed: boolean }> {
|
|
84
|
+
const out = await mw(ctx);
|
|
85
|
+
if (out) return { res: out, passed: false };
|
|
86
|
+
return { res: new Response(null, { status: 200 }), passed: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Keep fake clock fresh across every test.
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
setNow(1_700_000_000_000); // a fixed moment in 2023
|
|
92
|
+
});
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
restoreClock();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── Middleware tests ──────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("rateLimit middleware", () => {
|
|
100
|
+
it("allows a request under the limit and returns void (pass-through)", async () => {
|
|
101
|
+
const mw = rateLimit({ limit: 5, windowMs: 60_000 });
|
|
102
|
+
const ctx = makeCtx(
|
|
103
|
+
makeReq("http://localhost/", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
xForwardedFor: "10.0.0.1",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const { passed, res } = await runMw(mw, ctx);
|
|
109
|
+
expect(passed).toBe(true);
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("blocks the (limit+1)th request with 429 + Retry-After", async () => {
|
|
114
|
+
const mw = rateLimit({ limit: 2, windowMs: 60_000 });
|
|
115
|
+
const ip = "10.0.0.2";
|
|
116
|
+
|
|
117
|
+
// burn the budget
|
|
118
|
+
for (let i = 0; i < 2; i++) {
|
|
119
|
+
const ctx = makeCtx(
|
|
120
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
121
|
+
);
|
|
122
|
+
const { passed } = await runMw(mw, ctx);
|
|
123
|
+
expect(passed).toBe(true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const blockedCtx = makeCtx(
|
|
127
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
128
|
+
);
|
|
129
|
+
const { passed, res } = await runMw(mw, blockedCtx);
|
|
130
|
+
expect(passed).toBe(false);
|
|
131
|
+
expect(res.status).toBe(429);
|
|
132
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
133
|
+
expect(retryAfter).not.toBeNull();
|
|
134
|
+
expect(Number(retryAfter)).toBeGreaterThan(0);
|
|
135
|
+
expect(res.headers.get("X-RateLimit-Limit")).toBe("2");
|
|
136
|
+
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
|
137
|
+
const resetHeader = res.headers.get("X-RateLimit-Reset");
|
|
138
|
+
expect(resetHeader).not.toBeNull();
|
|
139
|
+
// Reset header is Unix seconds (not ms).
|
|
140
|
+
expect(Number(resetHeader)).toBeLessThan(2_000_000_000);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("resets the budget after windowMs elapses", async () => {
|
|
144
|
+
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
145
|
+
const ip = "10.0.0.3";
|
|
146
|
+
|
|
147
|
+
// exhaust then confirm blocked
|
|
148
|
+
const c1 = makeCtx(
|
|
149
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
150
|
+
);
|
|
151
|
+
expect((await runMw(mw, c1)).passed).toBe(true);
|
|
152
|
+
const c2 = makeCtx(
|
|
153
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
154
|
+
);
|
|
155
|
+
expect((await runMw(mw, c2)).passed).toBe(false);
|
|
156
|
+
|
|
157
|
+
// advance past the window → allowed again
|
|
158
|
+
advance(60_001);
|
|
159
|
+
const c3 = makeCtx(
|
|
160
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
161
|
+
);
|
|
162
|
+
expect((await runMw(mw, c3)).passed).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("honours a custom keyFn", async () => {
|
|
166
|
+
const observedKeys: string[] = [];
|
|
167
|
+
const store = createInMemoryStore();
|
|
168
|
+
// Wrap store to observe the key passed through.
|
|
169
|
+
const wrapped: RateLimitStore = {
|
|
170
|
+
async hit(key, limit, windowMs) {
|
|
171
|
+
observedKeys.push(key);
|
|
172
|
+
return store.hit(key, limit, windowMs);
|
|
173
|
+
},
|
|
174
|
+
async gcNow(olderThanMs) {
|
|
175
|
+
return store.gcNow(olderThanMs);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const mw = rateLimit({
|
|
179
|
+
limit: 5,
|
|
180
|
+
windowMs: 60_000,
|
|
181
|
+
store: wrapped,
|
|
182
|
+
keyFn: () => "custom-key-42",
|
|
183
|
+
});
|
|
184
|
+
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
185
|
+
await runMw(mw, ctx);
|
|
186
|
+
expect(observedKeys).toEqual(["custom-key-42"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("treats keyFn returning null as skip (store untouched, no headers)", async () => {
|
|
190
|
+
let hitCount = 0;
|
|
191
|
+
const store: RateLimitStore = {
|
|
192
|
+
async hit(...args) {
|
|
193
|
+
hitCount++;
|
|
194
|
+
return createInMemoryStore().hit(...args);
|
|
195
|
+
},
|
|
196
|
+
async gcNow() {
|
|
197
|
+
return 0;
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const mw = rateLimit({
|
|
201
|
+
limit: 1,
|
|
202
|
+
windowMs: 60_000,
|
|
203
|
+
store,
|
|
204
|
+
keyFn: () => null,
|
|
205
|
+
});
|
|
206
|
+
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
207
|
+
const { passed, res } = await runMw(mw, ctx);
|
|
208
|
+
expect(passed).toBe(true);
|
|
209
|
+
expect(hitCount).toBe(0);
|
|
210
|
+
// No rate-limit headers on pass-through.
|
|
211
|
+
expect(res.headers.has("X-RateLimit-Limit")).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("bypasses the store when skip(ctx) returns true", async () => {
|
|
215
|
+
let hitCount = 0;
|
|
216
|
+
const store: RateLimitStore = {
|
|
217
|
+
async hit(...args) {
|
|
218
|
+
hitCount++;
|
|
219
|
+
return createInMemoryStore().hit(...args);
|
|
220
|
+
},
|
|
221
|
+
async gcNow() {
|
|
222
|
+
return 0;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const mw = rateLimit({
|
|
226
|
+
limit: 1,
|
|
227
|
+
windowMs: 60_000,
|
|
228
|
+
store,
|
|
229
|
+
skip: () => true,
|
|
230
|
+
});
|
|
231
|
+
const ctx = makeCtx(makeReq("http://localhost/", { method: "POST" }));
|
|
232
|
+
const { passed } = await runMw(mw, ctx);
|
|
233
|
+
expect(passed).toBe(true);
|
|
234
|
+
expect(hitCount).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("isolates limits across keys — blocking key A does not affect key B", async () => {
|
|
238
|
+
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
239
|
+
|
|
240
|
+
// A: burn and block
|
|
241
|
+
const a1 = makeCtx(
|
|
242
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }),
|
|
243
|
+
);
|
|
244
|
+
expect((await runMw(mw, a1)).passed).toBe(true);
|
|
245
|
+
const a2 = makeCtx(
|
|
246
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "1.1.1.1" }),
|
|
247
|
+
);
|
|
248
|
+
expect((await runMw(mw, a2)).passed).toBe(false);
|
|
249
|
+
|
|
250
|
+
// B: first hit still allowed
|
|
251
|
+
const b1 = makeCtx(
|
|
252
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "2.2.2.2" }),
|
|
253
|
+
);
|
|
254
|
+
expect((await runMw(mw, b1)).passed).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("uses x-real-ip fallback when x-forwarded-for is absent", async () => {
|
|
258
|
+
const mw = rateLimit({ limit: 1, windowMs: 60_000 });
|
|
259
|
+
const c1 = makeCtx(
|
|
260
|
+
makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }),
|
|
261
|
+
);
|
|
262
|
+
expect((await runMw(mw, c1)).passed).toBe(true);
|
|
263
|
+
const c2 = makeCtx(
|
|
264
|
+
makeReq("http://localhost/", { method: "POST", xRealIp: "3.3.3.3" }),
|
|
265
|
+
);
|
|
266
|
+
expect((await runMw(mw, c2)).passed).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("skips safe methods via a custom `skip` predicate", async () => {
|
|
270
|
+
const mw = rateLimit({
|
|
271
|
+
limit: 1,
|
|
272
|
+
windowMs: 60_000,
|
|
273
|
+
skip: (ctx) =>
|
|
274
|
+
ctx.request.method === "GET" || ctx.request.method === "HEAD",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// GET: allowed and again allowed (skipped).
|
|
278
|
+
const g1 = makeCtx(
|
|
279
|
+
makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }),
|
|
280
|
+
);
|
|
281
|
+
expect((await runMw(mw, g1)).passed).toBe(true);
|
|
282
|
+
const g2 = makeCtx(
|
|
283
|
+
makeReq("http://localhost/", { method: "GET", xForwardedFor: "4.4.4.4" }),
|
|
284
|
+
);
|
|
285
|
+
expect((await runMw(mw, g2)).passed).toBe(true);
|
|
286
|
+
|
|
287
|
+
// POST on the same IP is still rate-limited.
|
|
288
|
+
const p1 = makeCtx(
|
|
289
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }),
|
|
290
|
+
);
|
|
291
|
+
expect((await runMw(mw, p1)).passed).toBe(true);
|
|
292
|
+
const p2 = makeCtx(
|
|
293
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "4.4.4.4" }),
|
|
294
|
+
);
|
|
295
|
+
expect((await runMw(mw, p2)).passed).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("custom handler: shapes the 429 body the caller's way", async () => {
|
|
299
|
+
const mw = rateLimit({
|
|
300
|
+
limit: 1,
|
|
301
|
+
windowMs: 60_000,
|
|
302
|
+
handler: (_ctx, result) =>
|
|
303
|
+
Response.json(
|
|
304
|
+
{ kind: "too_many", wait: result.retryAfterSeconds },
|
|
305
|
+
{ status: 429 },
|
|
306
|
+
),
|
|
307
|
+
});
|
|
308
|
+
const c1 = makeCtx(
|
|
309
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }),
|
|
310
|
+
);
|
|
311
|
+
await runMw(mw, c1);
|
|
312
|
+
const c2 = makeCtx(
|
|
313
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: "5.5.5.5" }),
|
|
314
|
+
);
|
|
315
|
+
const { res } = await runMw(mw, c2);
|
|
316
|
+
const body = await res.json();
|
|
317
|
+
expect(body.kind).toBe("too_many");
|
|
318
|
+
expect(typeof body.wait).toBe("number");
|
|
319
|
+
expect(body.wait).toBeGreaterThan(0);
|
|
320
|
+
// Retry-After is still stamped by the middleware on the caller's response.
|
|
321
|
+
expect(res.headers.get("Retry-After")).not.toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("concurrent hits increment strictly — no lost updates", async () => {
|
|
325
|
+
const mw = rateLimit({ limit: 100, windowMs: 60_000 });
|
|
326
|
+
const ip = "6.6.6.6";
|
|
327
|
+
const ctxs = Array.from({ length: 50 }, () =>
|
|
328
|
+
makeCtx(
|
|
329
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
const results = await Promise.all(ctxs.map((c) => runMw(mw, c)));
|
|
333
|
+
const passed = results.filter((r) => r.passed).length;
|
|
334
|
+
// All 50 well under limit 100 — all must pass.
|
|
335
|
+
expect(passed).toBe(50);
|
|
336
|
+
|
|
337
|
+
// 51st more: must still pass. (Total 51, still under 100.)
|
|
338
|
+
const extra = makeCtx(
|
|
339
|
+
makeReq("http://localhost/", { method: "POST", xForwardedFor: ip }),
|
|
340
|
+
);
|
|
341
|
+
expect((await runMw(mw, extra)).passed).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("rejects invalid options (limit / windowMs must be positive integers)", () => {
|
|
345
|
+
expect(() => rateLimit({ limit: 0, windowMs: 1000 })).toThrow();
|
|
346
|
+
expect(() => rateLimit({ limit: 1, windowMs: 0 })).toThrow();
|
|
347
|
+
expect(() => rateLimit({ limit: 1.5, windowMs: 1000 })).toThrow();
|
|
348
|
+
expect(() => rateLimit({ limit: -1, windowMs: 1000 })).toThrow();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ─── In-memory store tests ─────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
describe("createInMemoryStore", () => {
|
|
355
|
+
let store: RateLimitStore;
|
|
356
|
+
|
|
357
|
+
beforeEach(() => {
|
|
358
|
+
store = createInMemoryStore();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
afterEach(async () => {
|
|
362
|
+
if (store.close) await store.close();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("allows hits under the limit, blocks above", async () => {
|
|
366
|
+
const r1 = await store.hit("k", 3, 60_000);
|
|
367
|
+
expect(r1.allowed).toBe(true);
|
|
368
|
+
expect(r1.remaining).toBe(2);
|
|
369
|
+
|
|
370
|
+
const r2 = await store.hit("k", 3, 60_000);
|
|
371
|
+
expect(r2.allowed).toBe(true);
|
|
372
|
+
expect(r2.remaining).toBe(1);
|
|
373
|
+
|
|
374
|
+
const r3 = await store.hit("k", 3, 60_000);
|
|
375
|
+
expect(r3.allowed).toBe(true);
|
|
376
|
+
expect(r3.remaining).toBe(0);
|
|
377
|
+
|
|
378
|
+
const r4 = await store.hit("k", 3, 60_000);
|
|
379
|
+
expect(r4.allowed).toBe(false);
|
|
380
|
+
expect(r4.remaining).toBe(0);
|
|
381
|
+
expect(r4.retryAfterSeconds).toBeGreaterThan(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("gcNow drops stale entries and reports the count", async () => {
|
|
385
|
+
// Timeline (T0 = fakeNow at start):
|
|
386
|
+
// T0: hit("old") → windowStart=T0, count=1
|
|
387
|
+
// T0+30_000: hit("fresh") → windowStart=T0+30_000, count=1
|
|
388
|
+
// advance to T0+40_000.
|
|
389
|
+
// gcNow(35_000) → stale when (now - windowStart) > 35_000:
|
|
390
|
+
// old: 40_000 - 0 = 40_000 > 35_000 → stale ✓
|
|
391
|
+
// fresh: 40_000 - 30_000 = 10_000 → alive ✓
|
|
392
|
+
await store.hit("old", 5, 60_000);
|
|
393
|
+
advance(30_000);
|
|
394
|
+
await store.hit("fresh", 5, 60_000);
|
|
395
|
+
advance(10_000);
|
|
396
|
+
|
|
397
|
+
const deleted = await store.gcNow(35_000);
|
|
398
|
+
expect(deleted).toBe(1);
|
|
399
|
+
|
|
400
|
+
// `fresh` survives: its window is still the original one (not rolled
|
|
401
|
+
// over, since only 10_000 ms have elapsed since it started). Second
|
|
402
|
+
// hit increments count from 1 → 2; remaining = limit(5) - 2 = 3.
|
|
403
|
+
const after = await store.hit("fresh", 5, 60_000);
|
|
404
|
+
expect(after.remaining).toBe(3);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("close() prevents subsequent use", async () => {
|
|
408
|
+
await store.hit("k", 5, 60_000);
|
|
409
|
+
await store.close!();
|
|
410
|
+
await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("rollover: fresh window after the previous one fully elapsed", async () => {
|
|
414
|
+
const r1 = await store.hit("k", 2, 60_000);
|
|
415
|
+
expect(r1.allowed).toBe(true);
|
|
416
|
+
const r2 = await store.hit("k", 2, 60_000);
|
|
417
|
+
expect(r2.allowed).toBe(true);
|
|
418
|
+
const r3 = await store.hit("k", 2, 60_000);
|
|
419
|
+
expect(r3.allowed).toBe(false);
|
|
420
|
+
|
|
421
|
+
advance(60_001);
|
|
422
|
+
const r4 = await store.hit("k", 2, 60_000);
|
|
423
|
+
expect(r4.allowed).toBe(true);
|
|
424
|
+
expect(r4.remaining).toBe(1);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ─── SQLite store tests ─────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
const hasBunSql = (() => {
|
|
431
|
+
const g = globalThis as unknown as { Bun?: { SQL?: unknown } };
|
|
432
|
+
return typeof g.Bun?.SQL === "function";
|
|
433
|
+
})();
|
|
434
|
+
const describeIfBunSql = hasBunSql ? describe : describe.skip;
|
|
435
|
+
|
|
436
|
+
describeIfBunSql("createSqliteStore", () => {
|
|
437
|
+
let scratch: string;
|
|
438
|
+
|
|
439
|
+
beforeEach(() => {
|
|
440
|
+
scratch = mkdtempSync(join(tmpdir(), "mandu-rl-"));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
afterEach(() => {
|
|
444
|
+
try {
|
|
445
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
446
|
+
} catch {
|
|
447
|
+
// best-effort cleanup
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("symmetric contract with in-memory: allowed → blocked → rolls over", async () => {
|
|
452
|
+
const store = createSqliteStore({
|
|
453
|
+
dbPath: ":memory:",
|
|
454
|
+
gcSchedule: false,
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
const r1 = await store.hit("k", 2, 60_000);
|
|
458
|
+
expect(r1.allowed).toBe(true);
|
|
459
|
+
const r2 = await store.hit("k", 2, 60_000);
|
|
460
|
+
expect(r2.allowed).toBe(true);
|
|
461
|
+
const r3 = await store.hit("k", 2, 60_000);
|
|
462
|
+
expect(r3.allowed).toBe(false);
|
|
463
|
+
expect(r3.remaining).toBe(0);
|
|
464
|
+
expect(r3.retryAfterSeconds).toBeGreaterThan(0);
|
|
465
|
+
} finally {
|
|
466
|
+
await store.close?.();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("WAL mode is enabled against a file-backed DB", async () => {
|
|
471
|
+
// File-backed so the pragma is observable.
|
|
472
|
+
const dbPath = join(scratch, "rl.db");
|
|
473
|
+
const store = createSqliteStore({ dbPath, gcSchedule: false });
|
|
474
|
+
try {
|
|
475
|
+
// Issue a dummy hit to force init.
|
|
476
|
+
await store.hit("probe", 5, 60_000);
|
|
477
|
+
|
|
478
|
+
// Reopen the same file via createDb and check the journal mode.
|
|
479
|
+
const { createDb } = await import("../../../db");
|
|
480
|
+
const probe = createDb({ url: `sqlite:${dbPath}` });
|
|
481
|
+
const rows = await probe<{ journal_mode: string }>`PRAGMA journal_mode`;
|
|
482
|
+
const mode = rows[0]?.journal_mode?.toLowerCase();
|
|
483
|
+
expect(mode).toBe("wal");
|
|
484
|
+
await probe.close();
|
|
485
|
+
} finally {
|
|
486
|
+
await store.close?.();
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("concurrent hits on the same key serialise (no lost updates)", async () => {
|
|
491
|
+
const store = createSqliteStore({
|
|
492
|
+
dbPath: join(scratch, "concurrent.db"),
|
|
493
|
+
gcSchedule: false,
|
|
494
|
+
});
|
|
495
|
+
try {
|
|
496
|
+
// Fire 20 parallel hits under a limit of 100. All must pass AND the
|
|
497
|
+
// reported remaining values must form a strictly-decreasing set from
|
|
498
|
+
// 99 down to 80 (modulo ordering). If SQLite let the transactions
|
|
499
|
+
// interleave with lost updates we'd see duplicates.
|
|
500
|
+
const hits = await Promise.all(
|
|
501
|
+
Array.from({ length: 20 }, () => store.hit("concurrent", 100, 60_000)),
|
|
502
|
+
);
|
|
503
|
+
expect(hits.every((r) => r.allowed)).toBe(true);
|
|
504
|
+
const remainings = hits.map((r) => r.remaining).sort((a, b) => a - b);
|
|
505
|
+
// 20 hits → remaining values 80..99 (each unique).
|
|
506
|
+
const unique = new Set(remainings);
|
|
507
|
+
expect(unique.size).toBe(20);
|
|
508
|
+
expect(Math.min(...remainings)).toBe(80);
|
|
509
|
+
expect(Math.max(...remainings)).toBe(99);
|
|
510
|
+
} finally {
|
|
511
|
+
await store.close?.();
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("gcNow deletes stale entries and reports accurate count", async () => {
|
|
516
|
+
const store = createSqliteStore({
|
|
517
|
+
dbPath: ":memory:",
|
|
518
|
+
gcSchedule: false,
|
|
519
|
+
});
|
|
520
|
+
try {
|
|
521
|
+
// Real clock here (no injection into SQLite helper). Insert a hit,
|
|
522
|
+
// then call gcNow with olderThanMs = 0 which sets cutoff = now and
|
|
523
|
+
// deletes everything whose window_start < now. The INSERT used
|
|
524
|
+
// `Date.now()` as window_start — since Date.now() at cutoff
|
|
525
|
+
// computation time is equal-or-greater, rows inserted strictly before
|
|
526
|
+
// this instant are stale.
|
|
527
|
+
await store.hit("a", 5, 60_000);
|
|
528
|
+
// Brief async yield so the next Date.now() is likely greater.
|
|
529
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
530
|
+
await store.hit("b", 5, 60_000);
|
|
531
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
532
|
+
|
|
533
|
+
// `olderThanMs = 0` → cutoff = now; any row whose window_start < now
|
|
534
|
+
// is stale. Both rows qualify.
|
|
535
|
+
const deleted = await store.gcNow(0);
|
|
536
|
+
expect(deleted).toBeGreaterThanOrEqual(2);
|
|
537
|
+
} finally {
|
|
538
|
+
await store.close?.();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("close() prevents subsequent use", async () => {
|
|
543
|
+
const store = createSqliteStore({
|
|
544
|
+
dbPath: ":memory:",
|
|
545
|
+
gcSchedule: false,
|
|
546
|
+
});
|
|
547
|
+
await store.hit("k", 5, 60_000);
|
|
548
|
+
await store.close?.();
|
|
549
|
+
await expect(store.hit("k", 5, 60_000)).rejects.toThrow(/closed/);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("rejects unsafe table names at construction", () => {
|
|
553
|
+
expect(() =>
|
|
554
|
+
createSqliteStore({
|
|
555
|
+
dbPath: ":memory:",
|
|
556
|
+
table: "bad name",
|
|
557
|
+
gcSchedule: false,
|
|
558
|
+
}),
|
|
559
|
+
).toThrow(/Invalid table name/);
|
|
560
|
+
expect(() =>
|
|
561
|
+
createSqliteStore({
|
|
562
|
+
dbPath: ":memory:",
|
|
563
|
+
table: "drop;--",
|
|
564
|
+
gcSchedule: false,
|
|
565
|
+
}),
|
|
566
|
+
).toThrow(/Invalid table name/);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ─── Guard tests ───────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
describe("createRateLimitGuard", () => {
|
|
573
|
+
it("enforce: under limit resolves; at limit throws RateLimitError", async () => {
|
|
574
|
+
const guard = createRateLimitGuard({ limit: 2, windowMs: 60_000 });
|
|
575
|
+
await guard.enforce("u:1");
|
|
576
|
+
await guard.enforce("u:1");
|
|
577
|
+
await expect(guard.enforce("u:1")).rejects.toBeInstanceOf(RateLimitError);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("RateLimitError carries a result with retryAfterSeconds", async () => {
|
|
581
|
+
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
582
|
+
await guard.enforce("u:2");
|
|
583
|
+
try {
|
|
584
|
+
await guard.enforce("u:2");
|
|
585
|
+
throw new Error("expected RateLimitError");
|
|
586
|
+
} catch (err) {
|
|
587
|
+
expect(err).toBeInstanceOf(RateLimitError);
|
|
588
|
+
const rle = err as RateLimitError;
|
|
589
|
+
expect(rle.result.allowed).toBe(false);
|
|
590
|
+
expect(rle.result.retryAfterSeconds).toBeGreaterThan(0);
|
|
591
|
+
expect(typeof rle.result.resetAt).toBe("number");
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("check: never throws, returns the result object (allowed & blocked)", async () => {
|
|
596
|
+
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
597
|
+
const r1 = await guard.check("u:3");
|
|
598
|
+
expect(r1.allowed).toBe(true);
|
|
599
|
+
expect(r1.remaining).toBe(0);
|
|
600
|
+
const r2 = await guard.check("u:3");
|
|
601
|
+
expect(r2.allowed).toBe(false);
|
|
602
|
+
expect(r2.retryAfterSeconds).toBeGreaterThan(0);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("different guards have independent default stores", async () => {
|
|
606
|
+
const g1 = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
607
|
+
const g2 = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
608
|
+
await g1.enforce("k");
|
|
609
|
+
await expect(g1.enforce("k")).rejects.toBeInstanceOf(RateLimitError);
|
|
610
|
+
// Different guard → same key has its own budget.
|
|
611
|
+
await g2.enforce("k");
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("shared store between guards pools the budget", async () => {
|
|
615
|
+
const shared = createInMemoryStore();
|
|
616
|
+
const g1 = createRateLimitGuard({
|
|
617
|
+
limit: 1,
|
|
618
|
+
windowMs: 60_000,
|
|
619
|
+
store: shared,
|
|
620
|
+
});
|
|
621
|
+
const g2 = createRateLimitGuard({
|
|
622
|
+
limit: 1,
|
|
623
|
+
windowMs: 60_000,
|
|
624
|
+
store: shared,
|
|
625
|
+
});
|
|
626
|
+
await g1.enforce("k");
|
|
627
|
+
// Same key on the shared store → second guard now sees the budget used.
|
|
628
|
+
await expect(g2.enforce("k")).rejects.toBeInstanceOf(RateLimitError);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("rejects empty keys", async () => {
|
|
632
|
+
const guard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
633
|
+
await expect(guard.enforce("")).rejects.toThrow();
|
|
634
|
+
await expect(guard.check("")).rejects.toThrow();
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// Keep test file fully tidy on early exit — global cleanup for the scratch
|
|
639
|
+
// dirs is per-test. This block is a belt-and-braces guarantee.
|
|
640
|
+
afterAll(() => {
|
|
641
|
+
restoreClock();
|
|
642
|
+
});
|