@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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. 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
+ });