te.js 2.1.6 → 2.2.1

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 (55) hide show
  1. package/README.md +1 -12
  2. package/auto-docs/analysis/handler-analyzer.test.js +106 -0
  3. package/auto-docs/analysis/source-resolver.test.js +58 -0
  4. package/auto-docs/constants.js +13 -2
  5. package/auto-docs/openapi/generator.js +7 -5
  6. package/auto-docs/openapi/generator.test.js +132 -0
  7. package/auto-docs/openapi/spec-builders.js +39 -19
  8. package/cli/docs-command.js +44 -36
  9. package/cors/index.test.js +82 -0
  10. package/docs/README.md +1 -2
  11. package/docs/api-reference.md +124 -186
  12. package/docs/configuration.md +0 -13
  13. package/docs/getting-started.md +19 -21
  14. package/docs/rate-limiting.md +59 -58
  15. package/lib/llm/client.js +7 -2
  16. package/lib/llm/index.js +14 -1
  17. package/lib/llm/parse.test.js +60 -0
  18. package/package.json +3 -1
  19. package/radar/index.js +382 -0
  20. package/rate-limit/base.js +12 -15
  21. package/rate-limit/index.js +19 -22
  22. package/rate-limit/index.test.js +93 -0
  23. package/rate-limit/storage/memory.js +13 -13
  24. package/rate-limit/storage/redis-install.js +70 -0
  25. package/rate-limit/storage/redis.js +94 -52
  26. package/server/ammo/body-parser.js +156 -152
  27. package/server/ammo/body-parser.test.js +79 -0
  28. package/server/ammo/enhancer.js +8 -4
  29. package/server/ammo.js +138 -12
  30. package/server/context/request-context.js +51 -0
  31. package/server/context/request-context.test.js +53 -0
  32. package/server/endpoint.js +15 -0
  33. package/server/error.js +56 -3
  34. package/server/error.test.js +45 -0
  35. package/server/errors/channels/channels.test.js +148 -0
  36. package/server/errors/channels/index.js +1 -1
  37. package/server/errors/llm-cache.js +1 -1
  38. package/server/errors/llm-cache.test.js +160 -0
  39. package/server/errors/llm-error-service.js +1 -1
  40. package/server/errors/llm-rate-limiter.test.js +105 -0
  41. package/server/files/uploader.js +38 -26
  42. package/server/handler.js +1 -1
  43. package/server/targets/registry.js +3 -3
  44. package/server/targets/registry.test.js +108 -0
  45. package/te.js +233 -183
  46. package/utils/auto-register.js +1 -1
  47. package/utils/configuration.js +23 -9
  48. package/utils/configuration.test.js +58 -0
  49. package/utils/errors-llm-config.js +74 -8
  50. package/utils/request-logger.js +49 -3
  51. package/utils/startup.js +80 -0
  52. package/database/index.js +0 -165
  53. package/database/mongodb.js +0 -146
  54. package/database/redis.js +0 -201
  55. package/docs/database.md +0 -390
package/radar/index.js ADDED
@@ -0,0 +1,382 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { gzip } from 'node:zlib';
5
+ import { promisify } from 'node:util';
6
+
7
+ const gzipAsync = promisify(gzip);
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import TejLogger from 'tej-logger';
10
+
11
+ const logger = new TejLogger('Tejas.Radar');
12
+
13
+ /**
14
+ * AsyncLocalStorage instance for propagating trace context across async
15
+ * boundaries within a single request. Middleware sets `{ traceId }` on
16
+ * entry; downstream code can read it via `traceStore.getStore()?.traceId`.
17
+ */
18
+ export const traceStore = new AsyncLocalStorage();
19
+
20
+ /**
21
+ * Attempt to read the `name` field from the nearest package.json at startup.
22
+ * Returns null if the file cannot be read or parsed.
23
+ * @returns {Promise<string|null>}
24
+ */
25
+ async function readPackageJsonName() {
26
+ try {
27
+ const raw = await readFile(join(process.cwd(), 'package.json'), 'utf8');
28
+ return JSON.parse(raw).name ?? null;
29
+ } catch (err) {
30
+ logger.warn(`Could not read package.json name: ${err?.message ?? err}`);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Recursively walk a plain object/array and replace the value of any key whose
37
+ * lowercase name appears in `blocklist` with the string `"*"`. Returns a new
38
+ * deep-cloned structure; the original is never mutated.
39
+ *
40
+ * Non-object values (strings, numbers, null, …) are returned as-is.
41
+ *
42
+ * @param {unknown} value
43
+ * @param {Set<string>} blocklist Lower-cased field names to mask.
44
+ * @returns {unknown}
45
+ */
46
+ function deepMask(value, blocklist) {
47
+ if (value === null || typeof value !== 'object') return value;
48
+
49
+ if (Array.isArray(value)) {
50
+ return value.map((item) => deepMask(item, blocklist));
51
+ }
52
+
53
+ const result = Object.create(null);
54
+ for (const [k, v] of Object.entries(value)) {
55
+ result[k] = blocklist.has(k.toLowerCase()) ? '*' : deepMask(v, blocklist);
56
+ }
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Build the headers object to include in the metric record based on the
62
+ * `capture.headers` configuration value:
63
+ * - `false` → null (default; nothing sent)
64
+ * - `true` → shallow copy of all headers
65
+ * - `string[]` → object containing only the listed header names
66
+ *
67
+ * @param {Record<string, string>|undefined} rawHeaders
68
+ * @param {boolean|string[]} captureHeaders
69
+ * @returns {Record<string, string>|null}
70
+ */
71
+ function buildHeaders(rawHeaders, captureHeaders) {
72
+ if (!captureHeaders || !rawHeaders) return null;
73
+ if (captureHeaders === true) return { ...rawHeaders };
74
+ return Object.fromEntries(
75
+ captureHeaders
76
+ .map((k) => [k, rawHeaders[k.toLowerCase()]])
77
+ .filter(([, v]) => v != null),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Attempt to parse a JSON string. Returns the parsed value on success, or
83
+ * `null` on failure. Used for response bodies which may not always be JSON.
84
+ *
85
+ * @param {string|undefined|null} raw
86
+ * @returns {unknown}
87
+ */
88
+ function parseJsonSafe(raw) {
89
+ if (!raw) return null;
90
+ try {
91
+ return JSON.parse(raw);
92
+ } catch (err) {
93
+ logger.warn(`parseJsonSafe: JSON parse failed — ${err?.message ?? err}`);
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Factory that returns a te.js-compatible `(ammo, next)` middleware which
100
+ * captures HTTP request metrics and forwards them to the Tejas Radar collector.
101
+ *
102
+ * @param {Object} [config]
103
+ * @param {string} [config.apiKey] Bearer token (rdr_xxx). Falls back to RADAR_API_KEY env. Required.
104
+ * @param {string} [config.projectName] Project identifier. Falls back to RADAR_PROJECT_NAME env, then package.json `name`, then "tejas-app".
105
+ * @param {number} [config.flushInterval] Milliseconds between periodic flushes (default 2000).
106
+ * @param {number} [config.batchSize] Flush immediately when batch reaches this size (default 100).
107
+ * @param {number} [config.maxQueueSize] Maximum events held in memory before oldest are dropped (default 10000).
108
+ * @param {Function} [config.transport] Custom transport `(events) => Promise<{ok, status}>`.
109
+ * Defaults to gzip-compressed HTTP POST to the collector.
110
+ * @param {string[]} [config.ignore] Request paths to skip (default ['/health']).
111
+ * @param {Object} [config.capture] Controls what additional data is captured beyond metrics.
112
+ * @param {boolean} [config.capture.request] Capture and send request body (default false).
113
+ * @param {boolean} [config.capture.response] Capture and send response body (default false).
114
+ * @param {boolean|string[]} [config.capture.headers] Capture request headers. `true` sends all headers;
115
+ * a `string[]` sends only the named headers (allowlist);
116
+ * `false` (default) sends nothing.
117
+ * @param {Object} [config.mask] Client-side masking applied before data is sent.
118
+ * @param {string[]} [config.mask.fields] Extra field names to mask in request/response bodies.
119
+ * These are merged with the collector's server-side GDPR blocklist.
120
+ * Note: the collector enforces its own non-bypassable masking
121
+ * regardless of this setting.
122
+ * @returns {Promise<Function>} Middleware function `(ammo, next)`
123
+ */
124
+ async function radarMiddleware(config = {}) {
125
+ // RADAR_COLLECTOR_URL is an undocumented internal escape hatch used only
126
+ // during local development. In production, telemetry always goes to the
127
+ // hosted collector and this env var should not be set.
128
+ const collectorUrl =
129
+ process.env.RADAR_COLLECTOR_URL ?? 'http://localhost:3100';
130
+
131
+ const apiKey = config.apiKey ?? process.env.RADAR_API_KEY ?? null;
132
+
133
+ const projectName =
134
+ config.projectName ??
135
+ process.env.RADAR_PROJECT_NAME ??
136
+ (await readPackageJsonName()) ??
137
+ 'tejas-app';
138
+
139
+ const flushInterval = config.flushInterval ?? 2000;
140
+ const batchSize = config.batchSize ?? 100;
141
+ const maxQueueSize = config.maxQueueSize ?? 10_000;
142
+ const ignorePaths = new Set(config.ignore ?? ['/health']);
143
+
144
+ const capture = Object.freeze({
145
+ request: config.capture?.request === true,
146
+ response: config.capture?.response === true,
147
+ headers: config.capture?.headers ?? false,
148
+ });
149
+
150
+ // Build the client-side field blocklist from developer-supplied extra fields.
151
+ // The collector enforces its own non-bypassable GDPR blocklist server-side;
152
+ // this is an additional best-effort layer for application-specific fields.
153
+ const clientMaskBlocklist = new Set(
154
+ (config.mask?.fields ?? []).map((f) => f.toLowerCase()),
155
+ );
156
+
157
+ if (!apiKey) {
158
+ const mw = (_ammo, next) => next();
159
+ mw._radarStatus = {
160
+ feature: 'Radar',
161
+ ok: null,
162
+ detail: 'disabled (no API key)',
163
+ };
164
+ return mw;
165
+ }
166
+
167
+ const ingestUrl = `${collectorUrl}/ingest`;
168
+ const healthUrl = `${collectorUrl}/health`;
169
+ const authHeader = `Bearer ${apiKey}`;
170
+
171
+ /** @type {Array<Object>} */
172
+ let batch = [];
173
+ let connected = false;
174
+ let retryQueue = null;
175
+ let retryCount = 0;
176
+ const MAX_RETRIES = 3;
177
+
178
+ async function defaultHttpTransport(events) {
179
+ const json = JSON.stringify(events);
180
+ const compressed = await gzipAsync(Buffer.from(json));
181
+ return fetch(ingestUrl, {
182
+ method: 'POST',
183
+ headers: {
184
+ 'Content-Type': 'application/json',
185
+ 'Content-Encoding': 'gzip',
186
+ Authorization: authHeader,
187
+ },
188
+ body: compressed,
189
+ });
190
+ }
191
+
192
+ const send = config.transport ?? defaultHttpTransport;
193
+
194
+ /** @type {{ feature: string, ok: boolean, detail: string }} */
195
+ let radarStatus;
196
+ try {
197
+ const healthRes = await fetch(healthUrl);
198
+ if (healthRes.ok) {
199
+ radarStatus = {
200
+ feature: 'Radar',
201
+ ok: true,
202
+ detail: `connected (${collectorUrl})`,
203
+ };
204
+ } else {
205
+ radarStatus = {
206
+ feature: 'Radar',
207
+ ok: false,
208
+ detail: `collector returned ${healthRes.status} (${collectorUrl})`,
209
+ };
210
+ }
211
+ } catch (err) {
212
+ radarStatus = {
213
+ feature: 'Radar',
214
+ ok: false,
215
+ detail: `unreachable (${collectorUrl})`,
216
+ };
217
+ }
218
+
219
+ async function sendPayload(payload) {
220
+ try {
221
+ const res = await send(payload);
222
+ if (res.ok) {
223
+ if (!connected) {
224
+ connected = true;
225
+ logger.info(
226
+ `Connected — project: "${projectName}", collector: ${collectorUrl}`,
227
+ );
228
+ }
229
+ return true;
230
+ }
231
+ if (res.status === 401) {
232
+ if (connected) connected = false;
233
+ logger.warn(
234
+ 'Radar API key rejected by collector. Telemetry will not be recorded.',
235
+ );
236
+ return true;
237
+ }
238
+ return false;
239
+ } catch (err) {
240
+ logger.warn(`Radar flush failed: ${err.message}`);
241
+ return false;
242
+ }
243
+ }
244
+
245
+ async function flush() {
246
+ if (retryQueue) {
247
+ const ok = await sendPayload(retryQueue);
248
+ if (ok) {
249
+ retryQueue = null;
250
+ retryCount = 0;
251
+ } else {
252
+ retryCount++;
253
+ if (retryCount >= MAX_RETRIES) {
254
+ logger.warn(
255
+ `Radar dropping ${retryQueue.length} events after ${MAX_RETRIES} failed retries`,
256
+ );
257
+ retryQueue = null;
258
+ retryCount = 0;
259
+ }
260
+ return;
261
+ }
262
+ }
263
+
264
+ if (batch.length === 0) return;
265
+ const payload = batch;
266
+ batch = [];
267
+
268
+ const ok = await sendPayload(payload);
269
+ if (!ok) {
270
+ retryQueue = payload;
271
+ retryCount = 1;
272
+ }
273
+ }
274
+
275
+ const timer = setInterval(flush, flushInterval);
276
+ if (timer.unref) timer.unref();
277
+
278
+ function radarCapture(ammo, next) {
279
+ const startTime = Date.now();
280
+ const traceId = randomUUID().replace(/-/g, '');
281
+
282
+ ammo.res.on('finish', () => {
283
+ const path = ammo.endpoint ?? ammo.path ?? '/';
284
+
285
+ if (ammo.method === 'OPTIONS' || ignorePaths.has(path)) return;
286
+
287
+ const status = ammo.res.statusCode;
288
+ const endTimestamp = Date.now();
289
+ const duration = endTimestamp - startTime;
290
+ const payloadSize = Buffer.byteLength(
291
+ JSON.stringify(ammo.payload ?? {}),
292
+ 'utf8',
293
+ );
294
+ const responseSize = Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8');
295
+ const ip = ammo.ip ?? null;
296
+ const userAgent = ammo.headers?.['user-agent'] ?? null;
297
+ const headers = buildHeaders(ammo.headers, capture.headers);
298
+ const requestBody = capture.request
299
+ ? deepMask(ammo.payload ?? null, clientMaskBlocklist)
300
+ : null;
301
+ const responseBody = capture.response
302
+ ? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
303
+ : null;
304
+
305
+ function pushEvents() {
306
+ const errorInfo = ammo._errorInfo ?? null;
307
+
308
+ let errorField = null;
309
+ if (status >= 400 && errorInfo) {
310
+ errorField = JSON.stringify({
311
+ message: errorInfo.message ?? null,
312
+ type: errorInfo.type ?? null,
313
+ devInsight: errorInfo.devInsight ?? null,
314
+ });
315
+ }
316
+
317
+ const incoming = status >= 400 ? 2 : 1;
318
+ if (batch.length + incoming > maxQueueSize) {
319
+ const overflow = batch.length + incoming - maxQueueSize;
320
+ batch.splice(0, overflow);
321
+ }
322
+
323
+ batch.push({
324
+ type: 'log',
325
+ projectName,
326
+ method: ammo.method,
327
+ path,
328
+ status,
329
+ duration_ms: duration,
330
+ payload_size: payloadSize,
331
+ response_size: responseSize,
332
+ timestamp: endTimestamp,
333
+ ip,
334
+ traceId,
335
+ user_agent: userAgent,
336
+ headers,
337
+ request_body: requestBody,
338
+ response_body: responseBody,
339
+ error: errorField,
340
+ });
341
+
342
+ if (status >= 400) {
343
+ const message = errorInfo?.message ?? `HTTP ${status}`;
344
+ const fingerprint = createHash('sha256')
345
+ .update(`${message}:${path}`)
346
+ .digest('hex');
347
+ batch.push({
348
+ type: 'error',
349
+ projectName,
350
+ fingerprint,
351
+ message,
352
+ stack: errorInfo?.stack ?? null,
353
+ endpoint: `${ammo.method} ${path}`,
354
+ traceId,
355
+ timestamp: endTimestamp,
356
+ });
357
+ }
358
+
359
+ if (batch.length >= batchSize) flush();
360
+ }
361
+
362
+ if (ammo._llmPromise) {
363
+ const timeout = new Promise((resolve) => {
364
+ const t = setTimeout(resolve, 30000);
365
+ if (t.unref) t.unref();
366
+ });
367
+ Promise.race([ammo._llmPromise, timeout])
368
+ .catch(() => {})
369
+ .then(pushEvents);
370
+ } else {
371
+ pushEvents();
372
+ }
373
+ });
374
+
375
+ traceStore.run({ traceId }, () => next());
376
+ }
377
+
378
+ radarCapture._radarStatus = radarStatus;
379
+ return radarCapture;
380
+ }
381
+
382
+ export default radarMiddleware;
@@ -1,7 +1,6 @@
1
1
  import TejError from '../server/error.js';
2
2
  import MemoryStorage from './storage/memory.js';
3
3
  import RedisStorage from './storage/redis.js';
4
- import dbManager from '../database/index.js';
5
4
 
6
5
  /**
7
6
  * Base rate limiter class implementing common functionality for rate limiting algorithms
@@ -15,11 +14,9 @@ import dbManager from '../database/index.js';
15
14
  * object is provided (tokenBucketConfig, slidingWindowConfig, or fixedWindowConfig).
16
15
  *
17
16
  * @example
18
- * // Using with Redis storage and token bucket algorithm
19
17
  * const limiter = new TokenBucketRateLimiter({
20
18
  * maxRequests: 10,
21
19
  * timeWindowSeconds: 60,
22
- * store: 'redis',
23
20
  * tokenBucketConfig: {
24
21
  * refillRate: 0.5,
25
22
  * burstSize: 15
@@ -40,7 +37,9 @@ class RateLimiter {
40
37
  * For token bucket, this affects the default refill rate calculation.
41
38
  * @param {string} [options.keyPrefix='rl:'] - Prefix for storage keys. Useful when implementing different rate limit
42
39
  * rules with different prefixes (e.g., 'rl:api:', 'rl:web:').
43
- * @param {string} [options.store='memory'] - Storage backend to use ('memory' or 'redis')
40
+ * @param {string|Object} [options.store='memory'] - Storage backend: 'memory' (default) or
41
+ * { type: 'redis', url: 'redis://...', ...redisOptions }.
42
+ * In-memory storage is not shared across processes; use Redis for distributed deployments.
44
43
  * @param {Object} [options.tokenBucketConfig] - Token bucket algorithm specific options
45
44
  * @param {Object} [options.slidingWindowConfig] - Sliding window algorithm specific options
46
45
  * @param {Object} [options.fixedWindowConfig] - Fixed window algorithm specific options
@@ -101,18 +100,16 @@ class RateLimiter {
101
100
  }
102
101
  : null;
103
102
 
104
- // Initialize storage based on store type
105
- if (this.options.store === 'redis') {
106
- if (!dbManager.hasConnection('redis')) {
107
- throw new TejError(
108
- 500,
109
- 'Redis store selected but no Redis connection available. Call withRedis() first.',
110
- );
111
- }
112
- const redisClient = dbManager.getConnection('redis');
113
- this.storage = new RedisStorage(redisClient);
114
- } else {
103
+ const store = this.options.store;
104
+ if (!store || store === 'memory') {
115
105
  this.storage = new MemoryStorage();
106
+ } else if (typeof store === 'object' && store.type === 'redis') {
107
+ this.storage = new RedisStorage(store);
108
+ } else {
109
+ throw new TejError(
110
+ 400,
111
+ `Invalid store config. Use 'memory' or { type: 'redis', url: '...' }.`,
112
+ );
116
113
  }
117
114
  }
118
115
 
@@ -2,7 +2,6 @@ import TejError from '../server/error.js';
2
2
  import FixedWindowRateLimiter from './algorithms/fixed-window.js';
3
3
  import SlidingWindowRateLimiter from './algorithms/sliding-window.js';
4
4
  import TokenBucketRateLimiter from './algorithms/token-bucket.js';
5
- import dbManager from '../database/index.js';
6
5
 
7
6
  /**
8
7
  * Creates a rate limiting middleware function with the specified algorithm and storage
@@ -14,9 +13,11 @@ import dbManager from '../database/index.js';
14
13
  * - 'token-bucket': Best for handling traffic bursts
15
14
  * - 'sliding-window': Best for smooth rate limiting
16
15
  * - 'fixed-window': Simplest approach
17
- * @param {string} [options.store='memory'] - Storage backend to use:
18
- * - 'memory': In-memory storage (default)
19
- * - 'redis': Redis-based storage (requires global Redis config)
16
+ * @param {string|Object} [options.store='memory'] - Storage backend to use:
17
+ * - 'memory': In-memory storage (default, single-instance only)
18
+ * - { type: 'redis', url: 'redis://...' }: Redis storage for distributed deployments.
19
+ * The redis npm package is auto-installed on first use.
20
+ * Any extra properties are forwarded to node-redis createClient.
20
21
  * @param {Object} [options.algorithmOptions] - Algorithm-specific options
21
22
  * @param {Function} [options.keyGenerator] - Optional function to generate unique identifiers
22
23
  * @param {Object} [options.headerFormat] - Rate limit header format configuration
@@ -39,36 +40,32 @@ function rateLimiter(options) {
39
40
  ...limiterOptions
40
41
  } = options;
41
42
 
42
- // Check Redis connectivity if Redis store is selected
43
- if (store === 'redis' && !dbManager.hasConnection('redis', {})) {
43
+ const configMap = Object.create(null);
44
+ configMap['token-bucket'] = 'tokenBucketConfig';
45
+ configMap['sliding-window'] = 'slidingWindowConfig';
46
+ configMap['fixed-window'] = 'fixedWindowConfig';
47
+
48
+ const configKey = configMap[algorithm];
49
+ if (!configKey) {
44
50
  throw new TejError(
45
51
  400,
46
- 'Redis store selected but no Redis connection found. Please use withRedis() before using withRateLimit()',
52
+ `Invalid algorithm: ${algorithm}. Must be one of: ${Object.keys(configMap).join(', ')}`,
47
53
  );
48
54
  }
49
55
 
50
- // Map algorithm names to their config property names
51
- const configMap = {
52
- 'token-bucket': 'tokenBucketConfig',
53
- 'sliding-window': 'slidingWindowConfig',
54
- 'fixed-window': 'fixedWindowConfig',
55
- };
56
-
57
- const configKey = configMap[algorithm];
58
- if (!configKey) {
56
+ if (typeof store === 'object' && store.type === 'redis' && !store.url) {
59
57
  throw new TejError(
60
58
  400,
61
- `Invalid algorithm: ${algorithm}. Must be one of: ${Object.keys(configMap).join(', ')}`,
59
+ `Redis store requires a url. Provide store: { type: "redis", url: "redis://..." }`,
62
60
  );
63
61
  }
64
62
 
65
- // Create algorithm-specific config
66
- const limiterConfig = {
63
+ const limiterConfig = Object.freeze({
67
64
  maxRequests: limiterOptions.maxRequests,
68
65
  timeWindowSeconds: limiterOptions.timeWindowSeconds,
69
66
  [configKey]: limiterOptions.algorithmOptions || {},
70
- store, // Pass the store type to the limiter
71
- };
67
+ store,
68
+ });
72
69
 
73
70
  // Create the appropriate limiter instance
74
71
  let limiter;
@@ -128,7 +125,7 @@ function rateLimiter(options) {
128
125
 
129
126
  // Return middleware function
130
127
  return async (ammo, next) => {
131
- const key = keyGenerator(ammo);
128
+ const key = keyGenerator(ammo) ?? ammo.ip ?? 'unknown';
132
129
  const result = await limiter.consume(key);
133
130
 
134
131
  setRateLimitHeaders(ammo, result);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @fileoverview Tests for the rate limiter middleware factory.
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import rateLimiter from './index.js';
6
+
7
+ function makeAmmo(ip = '127.0.0.1') {
8
+ return {
9
+ ip,
10
+ res: {
11
+ setHeader: vi.fn(),
12
+ },
13
+ throw: vi.fn(),
14
+ };
15
+ }
16
+
17
+ describe('rateLimiter', () => {
18
+ it('should throw on invalid algorithm', () => {
19
+ expect(() =>
20
+ rateLimiter({
21
+ maxRequests: 10,
22
+ timeWindowSeconds: 60,
23
+ algorithm: 'invalid-algo',
24
+ }),
25
+ ).toThrow();
26
+ });
27
+
28
+ it('should return a middleware function', () => {
29
+ const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
30
+ expect(typeof mw).toBe('function');
31
+ });
32
+
33
+ it('should call next when under limit', async () => {
34
+ const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
35
+ const ammo = makeAmmo();
36
+ const next = vi.fn().mockResolvedValue(undefined);
37
+ await mw(ammo, next);
38
+ expect(next).toHaveBeenCalled();
39
+ });
40
+
41
+ it('should use fallback key "unknown" when ammo.ip is undefined', async () => {
42
+ const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
43
+ const ammo = makeAmmo(undefined);
44
+ const next = vi.fn().mockResolvedValue(undefined);
45
+ await mw(ammo, next);
46
+ expect(next).toHaveBeenCalled();
47
+ });
48
+
49
+ it('should throw on invalid store config', () => {
50
+ expect(() =>
51
+ rateLimiter({
52
+ maxRequests: 10,
53
+ timeWindowSeconds: 60,
54
+ store: 'postgres',
55
+ }),
56
+ ).toThrow(/Invalid store config/);
57
+ });
58
+
59
+ it('should throw when redis store has no url', () => {
60
+ expect(() =>
61
+ rateLimiter({
62
+ maxRequests: 10,
63
+ timeWindowSeconds: 60,
64
+ store: { type: 'redis' },
65
+ }),
66
+ ).toThrow(/requires a url/);
67
+ });
68
+
69
+ it('should accept redis store config without throwing on creation', () => {
70
+ vi.mock('./storage/redis.js', () => ({
71
+ default: class MockRedisStorage {
72
+ async get() {
73
+ return null;
74
+ }
75
+ async set() {}
76
+ async increment() {
77
+ return null;
78
+ }
79
+ async delete() {}
80
+ },
81
+ }));
82
+
83
+ expect(() =>
84
+ rateLimiter({
85
+ maxRequests: 10,
86
+ timeWindowSeconds: 60,
87
+ store: { type: 'redis', url: 'redis://localhost:6379' },
88
+ }),
89
+ ).not.toThrow();
90
+
91
+ vi.restoreAllMocks();
92
+ });
93
+ });