@tuturuuu/utils 0.0.3 → 0.6.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 (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,1451 @@
1
+ import { NextRequest } from 'next/server';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ limit: vi.fn(),
6
+ ratelimitConfigs: [] as Array<{ limit: number; window: string }>,
7
+ ratelimitPrefixes: [] as string[],
8
+ redis: vi.fn(),
9
+ extractIp: vi.fn(),
10
+ isBlocked: vi.fn(),
11
+ validateEmoji: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('@upstash/ratelimit', () => {
15
+ class MockRatelimit {
16
+ prefix: string;
17
+
18
+ static slidingWindow(limit: number, window: string) {
19
+ mocks.ratelimitConfigs.push({ limit, window });
20
+ return { limit, window };
21
+ }
22
+
23
+ constructor(config: { prefix: string }) {
24
+ this.prefix = config.prefix;
25
+ mocks.ratelimitPrefixes.push(config.prefix);
26
+ }
27
+
28
+ limit(ip: string) {
29
+ return mocks.limit(ip);
30
+ }
31
+ }
32
+
33
+ return { Ratelimit: MockRatelimit };
34
+ });
35
+
36
+ vi.mock('@upstash/redis', () => ({
37
+ Redis: class MockRedis {
38
+ static fromEnv() {
39
+ return mocks.redis();
40
+ }
41
+
42
+ constructor(config: unknown) {
43
+ mocks.redis(config);
44
+ }
45
+ },
46
+ }));
47
+
48
+ vi.mock('../abuse-protection/edge', () => ({
49
+ extractIPFromRequest: (headers: Headers) => mocks.extractIp(headers),
50
+ isIPBlockedEdge: (ip: string) => mocks.isBlocked(ip),
51
+ }));
52
+
53
+ vi.mock('../request-emoji-limit', () => ({
54
+ validateRequestEmojiLimit: (
55
+ req: NextRequest,
56
+ options?: {
57
+ allowDescriptionYjsState?: boolean;
58
+ skipValidationForFields?: string[];
59
+ }
60
+ ) => mocks.validateEmoji(req, options),
61
+ }));
62
+
63
+ function makeRequest(
64
+ pathname = '/api/test',
65
+ method = 'POST',
66
+ headers?: Record<string, string>,
67
+ body = '{}'
68
+ ) {
69
+ return new NextRequest(`http://localhost${pathname}`, {
70
+ method,
71
+ headers,
72
+ body: method === 'GET' || method === 'HEAD' ? undefined : body,
73
+ });
74
+ }
75
+
76
+ function singleReadRoutePolicies() {
77
+ return [
78
+ {
79
+ key: 'redis-fallback-test',
80
+ matches: () => true,
81
+ rateLimits: {
82
+ get: [
83
+ { duration: '1 m' as const, limit: 1, window: 'minute' as const },
84
+ ],
85
+ mutate: [],
86
+ },
87
+ },
88
+ ];
89
+ }
90
+
91
+ describe('guardApiProxyRequest', () => {
92
+ beforeEach(() => {
93
+ vi.resetModules();
94
+ mocks.limit.mockReset();
95
+ mocks.ratelimitConfigs.length = 0;
96
+ mocks.ratelimitPrefixes.length = 0;
97
+ mocks.redis.mockReset();
98
+ mocks.extractIp.mockReset();
99
+ mocks.isBlocked.mockReset();
100
+ mocks.validateEmoji.mockReset();
101
+ });
102
+
103
+ afterEach(() => {
104
+ vi.unstubAllEnvs();
105
+ });
106
+
107
+ it('rejects oversized payloads before rate limiting', async () => {
108
+ const { guardApiProxyRequest } = await import('../api-proxy-guard.js');
109
+ const response = await guardApiProxyRequest(
110
+ makeRequest('/api/test', 'POST', {
111
+ 'content-length': `${1024 * 1024 + 1}`,
112
+ }),
113
+ { prefixBase: 'proxy:test:api' }
114
+ );
115
+
116
+ expect(response?.status).toBe(413);
117
+ expect(mocks.extractIp).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('rejects oversized payloads without trusting Content-Length', async () => {
121
+ const { guardApiProxyRequest } = await import('../api-proxy-guard.js');
122
+ const request = makeRequest(
123
+ '/api/test',
124
+ 'POST',
125
+ { 'content-type': 'application/json' },
126
+ JSON.stringify({ value: 'x'.repeat(1024 * 1024) })
127
+ );
128
+
129
+ expect(request.headers.get('content-length')).toBeNull();
130
+
131
+ const response = await guardApiProxyRequest(request, {
132
+ prefixBase: 'proxy:test:api',
133
+ });
134
+
135
+ expect(response?.status).toBe(413);
136
+ expect(mocks.extractIp).not.toHaveBeenCalled();
137
+ expect(mocks.validateEmoji).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('returns an IP-block response when the client is blocked', async () => {
141
+ vi.stubEnv('NODE_ENV', 'production');
142
+ mocks.extractIp.mockReturnValue('1.2.3.4');
143
+ mocks.isBlocked.mockResolvedValue({
144
+ expiresAt: new Date(Date.now() + 30_000),
145
+ reason: 'abuse',
146
+ blockLevel: 'temporary',
147
+ });
148
+
149
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
150
+ await import('../api-proxy-guard.js');
151
+ clearApiProxyGuardLimiterCache();
152
+
153
+ const response = await guardApiProxyRequest(
154
+ makeRequest('/api/test', 'POST'),
155
+ {
156
+ prefixBase: 'proxy:test:api',
157
+ }
158
+ );
159
+
160
+ expect(response?.status).toBe(429);
161
+ expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
162
+ 'ip-already-blocked'
163
+ );
164
+ expect(mocks.limit).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('rate limits anonymous generic GET routes', async () => {
168
+ vi.stubEnv('NODE_ENV', 'production');
169
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
170
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
171
+ mocks.redis.mockReturnValue({});
172
+ mocks.extractIp.mockReturnValue('1.2.3.4');
173
+ mocks.isBlocked.mockResolvedValue(null);
174
+ mocks.limit.mockResolvedValueOnce({
175
+ success: false,
176
+ limit: 20,
177
+ remaining: 0,
178
+ reset: Date.now() + 15_000,
179
+ });
180
+
181
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
182
+ await import('../api-proxy-guard.js');
183
+ clearApiProxyGuardLimiterCache();
184
+
185
+ const response = await guardApiProxyRequest(
186
+ makeRequest('/api/test', 'GET'),
187
+ {
188
+ prefixBase: 'proxy:test:api',
189
+ }
190
+ );
191
+
192
+ expect(response?.status).toBe(429);
193
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('20');
194
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
195
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
196
+ });
197
+
198
+ it('uses the task-board read bucket for high-fanout board task reads', async () => {
199
+ vi.stubEnv('NODE_ENV', 'production');
200
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
201
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
202
+ mocks.redis.mockReturnValue({});
203
+ mocks.extractIp.mockReturnValue('1.2.3.4');
204
+ mocks.isBlocked.mockResolvedValue(null);
205
+ mocks.limit.mockResolvedValueOnce({
206
+ success: false,
207
+ limit: 300,
208
+ remaining: 0,
209
+ reset: Date.now() + 15_000,
210
+ });
211
+
212
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
213
+ await import('../api-proxy-guard.js');
214
+ clearApiProxyGuardLimiterCache();
215
+
216
+ const response = await guardApiProxyRequest(
217
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
218
+ {
219
+ prefixBase: 'proxy:test:api',
220
+ }
221
+ );
222
+
223
+ expect(response?.status).toBe(429);
224
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('300');
225
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
226
+ expect(mocks.ratelimitConfigs).toContainEqual({
227
+ limit: 300,
228
+ window: '1 m',
229
+ });
230
+ expect(mocks.ratelimitPrefixes).toContain(
231
+ 'proxy:test:api:task-board-read:anonymous:get:minute'
232
+ );
233
+ });
234
+
235
+ it('uses the task-board read bucket for task-board detail and list reads', async () => {
236
+ vi.stubEnv('NODE_ENV', 'production');
237
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
238
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
239
+ mocks.redis.mockReturnValue({});
240
+ mocks.extractIp.mockReturnValue('1.2.3.4');
241
+ mocks.isBlocked.mockResolvedValue(null);
242
+ mocks.limit
243
+ .mockResolvedValueOnce({
244
+ success: true,
245
+ limit: 300,
246
+ remaining: 299,
247
+ reset: Date.now() + 15_000,
248
+ })
249
+ .mockResolvedValueOnce({
250
+ success: true,
251
+ limit: 3000,
252
+ remaining: 2999,
253
+ reset: Date.now() + 15_000,
254
+ })
255
+ .mockResolvedValueOnce({
256
+ success: true,
257
+ limit: 20000,
258
+ remaining: 19999,
259
+ reset: Date.now() + 15_000,
260
+ })
261
+ .mockResolvedValueOnce({
262
+ success: false,
263
+ limit: 300,
264
+ remaining: 0,
265
+ reset: Date.now() + 15_000,
266
+ });
267
+ mocks.validateEmoji.mockResolvedValue(null);
268
+
269
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
270
+ await import('../api-proxy-guard.js');
271
+ clearApiProxyGuardLimiterCache();
272
+
273
+ const detailResponse = await guardApiProxyRequest(
274
+ makeRequest('/api/v1/workspaces/ws-1/task-boards/board-1', 'GET'),
275
+ {
276
+ prefixBase: 'proxy:test:api',
277
+ }
278
+ );
279
+ const listsResponse = await guardApiProxyRequest(
280
+ makeRequest('/api/v1/workspaces/ws-1/task-boards/board-1/lists', 'GET'),
281
+ {
282
+ prefixBase: 'proxy:test:api',
283
+ }
284
+ );
285
+
286
+ expect(detailResponse).toBeNull();
287
+ expect(listsResponse?.status).toBe(429);
288
+ expect(listsResponse?.headers.get('X-RateLimit-Policy')).toBe(
289
+ 'task-board-read'
290
+ );
291
+ });
292
+
293
+ it('uses the finance read bucket for common Finance app reads', async () => {
294
+ vi.stubEnv('NODE_ENV', 'production');
295
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
296
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
297
+ mocks.redis.mockReturnValue({});
298
+ mocks.extractIp.mockReturnValue('1.2.3.4');
299
+ mocks.isBlocked.mockResolvedValue(null);
300
+ mocks.limit.mockResolvedValue({
301
+ success: false,
302
+ limit: 1200,
303
+ remaining: 0,
304
+ reset: Date.now() + 15_000,
305
+ });
306
+
307
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
308
+ await import('../api-proxy-guard.js');
309
+ clearApiProxyGuardLimiterCache();
310
+
311
+ const financeReads = [
312
+ { method: 'GET', path: '/api/workspaces/ws-1/finance/overview' },
313
+ {
314
+ method: 'GET',
315
+ path: '/api/workspaces/ws-1/finance/charts/categories',
316
+ },
317
+ {
318
+ method: 'GET',
319
+ path: '/api/workspaces/ws-1/transactions/infinite?limit=20',
320
+ },
321
+ {
322
+ method: 'GET',
323
+ path: '/api/workspaces/ws-1/transactions/categories',
324
+ },
325
+ { method: 'GET', path: '/api/workspaces/ws-1/wallets/wallet-1' },
326
+ { method: 'HEAD', path: '/api/workspaces/ws-1/tags' },
327
+ {
328
+ method: 'GET',
329
+ path: '/api/v1/workspaces/ws-1/finance/invoices/subscription/context?month=2026-06',
330
+ },
331
+ { method: 'GET', path: '/api/v1/workspaces/ws-1/finance/budgets' },
332
+ { method: 'GET', path: '/api/v1/workspaces/ws-1/wallets' },
333
+ ];
334
+
335
+ for (const { method, path } of financeReads) {
336
+ const response = await guardApiProxyRequest(makeRequest(path, method), {
337
+ prefixBase: 'proxy:test:api',
338
+ });
339
+
340
+ expect(response?.status).toBe(429);
341
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
342
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('finance-read');
343
+ }
344
+
345
+ expect(mocks.ratelimitConfigs).toContainEqual({
346
+ limit: 1200,
347
+ window: '1 m',
348
+ });
349
+ expect(mocks.ratelimitConfigs).toContainEqual({
350
+ limit: 12000,
351
+ window: '1 h',
352
+ });
353
+ expect(mocks.ratelimitConfigs).toContainEqual({
354
+ limit: 80000,
355
+ window: '1 d',
356
+ });
357
+ expect(mocks.ratelimitPrefixes).toContain(
358
+ 'proxy:test:api:finance-read:anonymous:get:minute'
359
+ );
360
+ });
361
+
362
+ it('keeps Finance auth-looking reads anonymous at proxy time', async () => {
363
+ vi.stubEnv('NODE_ENV', 'production');
364
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
365
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
366
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
367
+ mocks.redis.mockReturnValue({});
368
+ mocks.extractIp.mockReturnValue('1.2.3.4');
369
+ mocks.isBlocked.mockResolvedValue(null);
370
+ mocks.limit.mockResolvedValue({
371
+ success: false,
372
+ limit: 1200,
373
+ remaining: 0,
374
+ reset: Date.now() + 15_000,
375
+ });
376
+
377
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
378
+ await import('../api-proxy-guard.js');
379
+ clearApiProxyGuardLimiterCache();
380
+
381
+ const authLookingHeaders: Record<string, string>[] = [
382
+ { authorization: 'Bearer ttr_fake' },
383
+ {
384
+ cookie:
385
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
386
+ },
387
+ { cookie: 'tuturuuu_app_session=app-session-token; theme=dark' },
388
+ ];
389
+
390
+ for (const headers of authLookingHeaders) {
391
+ const response = await guardApiProxyRequest(
392
+ makeRequest('/api/workspaces/ws-1/finance/overview', 'GET', headers),
393
+ {
394
+ prefixBase: 'proxy:test:api',
395
+ }
396
+ );
397
+
398
+ expect(response?.status).toBe(429);
399
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
400
+ 'anonymous'
401
+ );
402
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('finance-read');
403
+ }
404
+ });
405
+
406
+ it('uses the finance invoice create read bucket for invoice support reads', async () => {
407
+ vi.stubEnv('NODE_ENV', 'production');
408
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
409
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
410
+ mocks.redis.mockReturnValue({});
411
+ mocks.extractIp.mockReturnValue('1.2.3.4');
412
+ mocks.isBlocked.mockResolvedValue(null);
413
+ mocks.limit.mockResolvedValue({
414
+ success: false,
415
+ limit: 1200,
416
+ remaining: 0,
417
+ reset: Date.now() + 15_000,
418
+ });
419
+
420
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
421
+ await import('../api-proxy-guard.js');
422
+ clearApiProxyGuardLimiterCache();
423
+
424
+ const userId = '11111111-1111-4111-8111-111111111111';
425
+ const groupId = '22222222-2222-4222-8222-222222222222';
426
+ const supportReads = [
427
+ { method: 'GET', path: '/api/v1/workspaces/ws-1/users' },
428
+ {
429
+ method: 'GET',
430
+ path: `/api/v1/workspaces/ws-1/users/${userId}/user-groups`,
431
+ },
432
+ {
433
+ method: 'GET',
434
+ path: '/api/v1/workspaces/ws-1/inventory/products?page=1',
435
+ },
436
+ { method: 'GET', path: '/api/v1/workspaces/ws-1/promotions' },
437
+ {
438
+ method: 'GET',
439
+ path: '/api/v1/workspaces/ws-1/settings/configs?ids=default_wallet_id',
440
+ },
441
+ {
442
+ method: 'GET',
443
+ path: `/api/v1/workspaces/ws-1/user-groups/${groupId}/linked-products`,
444
+ },
445
+ ];
446
+
447
+ for (const { method, path } of supportReads) {
448
+ const response = await guardApiProxyRequest(makeRequest(path, method), {
449
+ prefixBase: 'proxy:test:api',
450
+ });
451
+
452
+ expect(response?.status).toBe(429);
453
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
454
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
455
+ 'finance-invoice-create-read'
456
+ );
457
+ }
458
+
459
+ expect(mocks.ratelimitConfigs).toContainEqual({
460
+ limit: 1200,
461
+ window: '1 m',
462
+ });
463
+ expect(mocks.ratelimitPrefixes).toContain(
464
+ 'proxy:test:api:finance-invoice-create-read:anonymous:get:minute'
465
+ );
466
+ });
467
+
468
+ it('honors finance read bucket environment overrides', async () => {
469
+ vi.stubEnv('NODE_ENV', 'production');
470
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
471
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
472
+ vi.stubEnv('API_PROXY_FINANCE_READ_LIMIT_MINUTE', '1500');
473
+ vi.stubEnv('API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE', '750');
474
+ mocks.redis.mockReturnValue({});
475
+ mocks.extractIp.mockReturnValue('1.2.3.4');
476
+ mocks.isBlocked.mockResolvedValue(null);
477
+ mocks.limit.mockResolvedValueOnce({
478
+ success: false,
479
+ limit: 1500,
480
+ remaining: 0,
481
+ reset: Date.now() + 15_000,
482
+ });
483
+
484
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
485
+ await import('../api-proxy-guard.js');
486
+ clearApiProxyGuardLimiterCache();
487
+
488
+ const response = await guardApiProxyRequest(
489
+ makeRequest('/api/workspaces/ws-1/finance/overview', 'GET'),
490
+ {
491
+ prefixBase: 'proxy:test:api',
492
+ }
493
+ );
494
+
495
+ expect(response?.status).toBe(429);
496
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('1500');
497
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('finance-read');
498
+ expect(mocks.ratelimitConfigs).toContainEqual({
499
+ limit: 1500,
500
+ window: '1 m',
501
+ });
502
+ expect(mocks.ratelimitConfigs).not.toContainEqual({
503
+ limit: 750,
504
+ window: '1 m',
505
+ });
506
+ });
507
+
508
+ it('uses legacy finance invoice read environment aliases when new Finance vars are absent', async () => {
509
+ vi.stubEnv('NODE_ENV', 'production');
510
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
511
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
512
+ vi.stubEnv('API_PROXY_FINANCE_INVOICE_CREATE_READ_LIMIT_MINUTE', '750');
513
+ mocks.redis.mockReturnValue({});
514
+ mocks.extractIp.mockReturnValue('1.2.3.4');
515
+ mocks.isBlocked.mockResolvedValue(null);
516
+ mocks.limit.mockResolvedValueOnce({
517
+ success: false,
518
+ limit: 750,
519
+ remaining: 0,
520
+ reset: Date.now() + 15_000,
521
+ });
522
+
523
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
524
+ await import('../api-proxy-guard.js');
525
+ clearApiProxyGuardLimiterCache();
526
+
527
+ const response = await guardApiProxyRequest(
528
+ makeRequest('/api/v1/workspaces/ws-1/users', 'GET'),
529
+ {
530
+ prefixBase: 'proxy:test:api',
531
+ }
532
+ );
533
+
534
+ expect(response?.status).toBe(429);
535
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('750');
536
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
537
+ 'finance-invoice-create-read'
538
+ );
539
+ expect(mocks.ratelimitConfigs).toContainEqual({
540
+ limit: 750,
541
+ window: '1 m',
542
+ });
543
+ });
544
+
545
+ it('keeps finance invoice mutations on the default mutation bucket', async () => {
546
+ vi.stubEnv('NODE_ENV', 'production');
547
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
548
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
549
+ mocks.redis.mockReturnValue({});
550
+ mocks.extractIp.mockReturnValue('1.2.3.4');
551
+ mocks.isBlocked.mockResolvedValue(null);
552
+ mocks.limit.mockResolvedValue({
553
+ success: false,
554
+ limit: 30,
555
+ remaining: 0,
556
+ reset: Date.now() + 15_000,
557
+ });
558
+
559
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
560
+ await import('../api-proxy-guard.js');
561
+ clearApiProxyGuardLimiterCache();
562
+
563
+ for (const path of [
564
+ '/api/v1/workspaces/ws-1/finance/invoices',
565
+ '/api/v1/workspaces/ws-1/finance/invoices/subscription',
566
+ ]) {
567
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
568
+ prefixBase: 'proxy:test:api',
569
+ });
570
+
571
+ expect(response?.status).toBe(429);
572
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
573
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
574
+ }
575
+
576
+ expect(mocks.ratelimitPrefixes).toContain(
577
+ 'proxy:test:api:default:anonymous::api:v1:workspaces:ws-1:finance:invoices:mutate:minute'
578
+ );
579
+ expect(mocks.ratelimitPrefixes).not.toContain(
580
+ 'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
581
+ );
582
+ });
583
+
584
+ it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
585
+ vi.stubEnv('NODE_ENV', 'production');
586
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
587
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
588
+ mocks.redis.mockReturnValue({});
589
+ mocks.extractIp.mockReturnValue('1.2.3.4');
590
+ mocks.isBlocked.mockResolvedValue(null);
591
+ mocks.limit.mockResolvedValueOnce({
592
+ success: false,
593
+ limit: 10,
594
+ remaining: 0,
595
+ reset: Date.now() + 15_000,
596
+ });
597
+
598
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
599
+ await import('../api-proxy-guard.js');
600
+ clearApiProxyGuardLimiterCache();
601
+
602
+ const response = await guardApiProxyRequest(
603
+ makeRequest('/api/cron/ai/sync-models', 'GET'),
604
+ {
605
+ prefixBase: 'proxy:test:api',
606
+ }
607
+ );
608
+
609
+ expect(response?.status).toBe(429);
610
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
611
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
612
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('cron');
613
+ });
614
+
615
+ it('does not grant elevated proxy budgets from authenticated-looking headers alone', async () => {
616
+ vi.stubEnv('NODE_ENV', 'production');
617
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
618
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
619
+ mocks.redis.mockReturnValue({});
620
+ mocks.extractIp.mockReturnValue('1.2.3.4');
621
+ mocks.isBlocked.mockResolvedValue(null);
622
+ mocks.limit.mockResolvedValueOnce({
623
+ success: false,
624
+ limit: 30,
625
+ remaining: 0,
626
+ reset: Date.now() + 15_000,
627
+ });
628
+ mocks.validateEmoji.mockResolvedValue(null);
629
+
630
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
631
+ await import('../api-proxy-guard.js');
632
+ clearApiProxyGuardLimiterCache();
633
+
634
+ const response = await guardApiProxyRequest(
635
+ makeRequest('/api/test', 'POST', {
636
+ authorization: 'Bearer ttr_fake',
637
+ }),
638
+ {
639
+ prefixBase: 'proxy:test:api',
640
+ }
641
+ );
642
+
643
+ expect(response?.status).toBe(429);
644
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
645
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
646
+ expect(mocks.isBlocked).toHaveBeenCalledWith('1.2.3.4');
647
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
648
+ expect(mocks.validateEmoji).not.toHaveBeenCalled();
649
+ });
650
+
651
+ it('uses local rate limits when the Redis rate limiter is unreachable', async () => {
652
+ vi.stubEnv('NODE_ENV', 'production');
653
+ vi.stubEnv(
654
+ 'UPSTASH_REDIS_REST_URL',
655
+ 'https://resolved-kingfish-21146.upstash.io'
656
+ );
657
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
658
+ mocks.redis.mockReturnValue({});
659
+ mocks.extractIp.mockReturnValue('1.2.3.4');
660
+ mocks.isBlocked.mockResolvedValue(null);
661
+ mocks.limit.mockRejectedValueOnce(new TypeError('fetch failed'));
662
+ mocks.validateEmoji.mockResolvedValue(null);
663
+
664
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
665
+ await import('../api-proxy-guard.js');
666
+ clearApiProxyGuardLimiterCache();
667
+
668
+ const options = {
669
+ prefixBase: 'proxy:test:api',
670
+ routePolicies: singleReadRoutePolicies(),
671
+ };
672
+
673
+ const response = await guardApiProxyRequest(
674
+ makeRequest('/api/test', 'GET'),
675
+ options
676
+ );
677
+
678
+ expect(response).toBeNull();
679
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
680
+
681
+ const blockedResponse = await guardApiProxyRequest(
682
+ makeRequest('/api/test', 'GET'),
683
+ options
684
+ );
685
+
686
+ expect(blockedResponse?.status).toBe(429);
687
+ expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
688
+ 'route-rate-limit'
689
+ );
690
+ expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
691
+ 'redis-fallback-test'
692
+ );
693
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
694
+ });
695
+
696
+ it('uses local rate limits when Redis client initialization fails', async () => {
697
+ vi.stubEnv('NODE_ENV', 'production');
698
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
699
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
700
+ mocks.redis.mockImplementation(() => {
701
+ throw new TypeError('Redis unavailable');
702
+ });
703
+ mocks.extractIp.mockReturnValue('1.2.3.4');
704
+ mocks.isBlocked.mockResolvedValue(null);
705
+ mocks.validateEmoji.mockResolvedValue(null);
706
+
707
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
708
+ await import('../api-proxy-guard.js');
709
+ clearApiProxyGuardLimiterCache();
710
+
711
+ const options = {
712
+ prefixBase: 'proxy:test:api',
713
+ routePolicies: singleReadRoutePolicies(),
714
+ };
715
+
716
+ const response = await guardApiProxyRequest(
717
+ makeRequest('/api/test', 'GET'),
718
+ options
719
+ );
720
+
721
+ expect(response).toBeNull();
722
+
723
+ const blockedResponse = await guardApiProxyRequest(
724
+ makeRequest('/api/test', 'GET'),
725
+ options
726
+ );
727
+
728
+ expect(blockedResponse?.status).toBe(429);
729
+ expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
730
+ 'route-rate-limit'
731
+ );
732
+ expect(mocks.limit).not.toHaveBeenCalled();
733
+ });
734
+
735
+ it('rate limits anonymous users/me reads', async () => {
736
+ vi.stubEnv('NODE_ENV', 'production');
737
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
738
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
739
+ mocks.redis.mockReturnValue({});
740
+ mocks.extractIp.mockReturnValue('1.2.3.4');
741
+ mocks.isBlocked.mockResolvedValue(null);
742
+ mocks.limit.mockResolvedValueOnce({
743
+ success: false,
744
+ limit: 20,
745
+ remaining: 0,
746
+ reset: Date.now() + 15_000,
747
+ });
748
+
749
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
750
+ await import('../api-proxy-guard.js');
751
+ clearApiProxyGuardLimiterCache();
752
+
753
+ const response = await guardApiProxyRequest(
754
+ makeRequest('/api/v1/users/me/configs/demo', 'GET'),
755
+ { prefixBase: 'proxy:test:api' }
756
+ );
757
+
758
+ expect(response?.status).toBe(429);
759
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('20');
760
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
761
+ });
762
+
763
+ it('rate limits API client-looking bearer reads until route auth validates them', async () => {
764
+ vi.stubEnv('NODE_ENV', 'production');
765
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
766
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
767
+ mocks.redis.mockReturnValue({});
768
+ mocks.extractIp.mockReturnValue('1.2.3.4');
769
+ mocks.isBlocked.mockResolvedValue(null);
770
+ mocks.limit.mockResolvedValueOnce({
771
+ success: false,
772
+ limit: 20,
773
+ remaining: 0,
774
+ reset: Date.now() + 15_000,
775
+ });
776
+
777
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
778
+ await import('../api-proxy-guard.js');
779
+ clearApiProxyGuardLimiterCache();
780
+
781
+ const response = await guardApiProxyRequest(
782
+ makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
783
+ authorization: 'Bearer ttr_test_key',
784
+ }),
785
+ { prefixBase: 'proxy:test:api' }
786
+ );
787
+
788
+ expect(response?.status).toBe(429);
789
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
790
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
791
+ });
792
+
793
+ it('rate limits Supabase-cookie-looking reads until route auth validates them', async () => {
794
+ vi.stubEnv('NODE_ENV', 'production');
795
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
796
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
797
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
798
+ mocks.redis.mockReturnValue({});
799
+ mocks.extractIp.mockReturnValue('1.2.3.4');
800
+ mocks.isBlocked.mockResolvedValue(null);
801
+ mocks.limit.mockResolvedValueOnce({
802
+ success: false,
803
+ limit: 20,
804
+ remaining: 0,
805
+ reset: Date.now() + 15_000,
806
+ });
807
+
808
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
809
+ await import('../api-proxy-guard.js');
810
+ clearApiProxyGuardLimiterCache();
811
+
812
+ const response = await guardApiProxyRequest(
813
+ makeRequest('/api/v1/users/me/profile', 'GET', {
814
+ cookie:
815
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
816
+ }),
817
+ { prefixBase: 'proxy:test:api' }
818
+ );
819
+
820
+ expect(response?.status).toBe(429);
821
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
822
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
823
+ });
824
+
825
+ it('rate limits server-url Supabase-cookie-looking reads until route auth validates them', async () => {
826
+ vi.stubEnv('NODE_ENV', 'production');
827
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
828
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
829
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://127.0.0.1:8001');
830
+ vi.stubEnv('SUPABASE_SERVER_URL', 'http://host.docker.internal:8001/');
831
+ mocks.redis.mockReturnValue({});
832
+ mocks.extractIp.mockReturnValue('1.2.3.4');
833
+ mocks.isBlocked.mockResolvedValue(null);
834
+ mocks.limit.mockResolvedValueOnce({
835
+ success: false,
836
+ limit: 20,
837
+ remaining: 0,
838
+ reset: Date.now() + 15_000,
839
+ });
840
+
841
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
842
+ await import('../api-proxy-guard.js');
843
+ clearApiProxyGuardLimiterCache();
844
+
845
+ const response = await guardApiProxyRequest(
846
+ makeRequest('/api/v1/users/me/configs/demo', 'GET', {
847
+ cookie: 'sb-host-auth-token=base64-validvalue; theme=dark',
848
+ }),
849
+ { prefixBase: 'proxy:test:api' }
850
+ );
851
+
852
+ expect(response?.status).toBe(429);
853
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
854
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
855
+ });
856
+
857
+ it('rate limits app-session-cookie-looking satellite traffic until route auth validates it', async () => {
858
+ vi.stubEnv('NODE_ENV', 'production');
859
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
860
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
861
+ mocks.redis.mockReturnValue({});
862
+ mocks.extractIp.mockReturnValue('1.2.3.4');
863
+ mocks.isBlocked.mockResolvedValue(null);
864
+ mocks.limit.mockResolvedValueOnce({
865
+ success: false,
866
+ limit: 20,
867
+ remaining: 0,
868
+ reset: Date.now() + 15_000,
869
+ });
870
+
871
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
872
+ await import('../api-proxy-guard.js');
873
+ clearApiProxyGuardLimiterCache();
874
+
875
+ const response = await guardApiProxyRequest(
876
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
877
+ cookie: 'tuturuuu_app_session=app-session-token; theme=dark',
878
+ }),
879
+ { prefixBase: 'proxy:tasks:api' }
880
+ );
881
+
882
+ expect(response?.status).toBe(429);
883
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
884
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
885
+ });
886
+
887
+ it('does not classify raw app-session cookies as authenticated sessions', async () => {
888
+ const { hasAuthenticatedApiSession } = await import(
889
+ '../api-proxy-guard.js'
890
+ );
891
+
892
+ expect(
893
+ hasAuthenticatedApiSession(
894
+ makeRequest('/api/v1/workspaces/ws-1/tasks', 'GET', {
895
+ cookie: 'tuturuuu_app_session=anything; theme=dark',
896
+ })
897
+ )
898
+ ).toBe(false);
899
+ });
900
+
901
+ it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
902
+ vi.stubEnv('NODE_ENV', 'production');
903
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
904
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
905
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
906
+ mocks.redis.mockReturnValue({});
907
+ mocks.extractIp.mockReturnValue('1.2.3.4');
908
+ mocks.isBlocked.mockResolvedValue({
909
+ expiresAt: new Date(Date.now() + 30_000),
910
+ reason: 'api_abuse',
911
+ blockLevel: 1,
912
+ blockedAt: new Date(),
913
+ });
914
+
915
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
916
+ await import('../api-proxy-guard.js');
917
+ clearApiProxyGuardLimiterCache();
918
+
919
+ const response = await guardApiProxyRequest(
920
+ makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
921
+ cookie:
922
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
923
+ }),
924
+ { prefixBase: 'proxy:test:api' }
925
+ );
926
+
927
+ expect(response?.status).toBe(429);
928
+ expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
929
+ 'ip-already-blocked'
930
+ );
931
+ expect(mocks.limit).not.toHaveBeenCalled();
932
+ });
933
+
934
+ it('scopes anonymous read buckets by pathname to avoid cross-route collisions', async () => {
935
+ vi.stubEnv('NODE_ENV', 'production');
936
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
937
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
938
+ mocks.redis.mockReturnValue({});
939
+ mocks.extractIp.mockReturnValue('1.2.3.4');
940
+ mocks.isBlocked.mockResolvedValue(null);
941
+ mocks.limit.mockResolvedValue({
942
+ success: true,
943
+ limit: 20,
944
+ remaining: 19,
945
+ reset: Date.now() + 60_000,
946
+ });
947
+
948
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
949
+ await import('../api-proxy-guard.js');
950
+ clearApiProxyGuardLimiterCache();
951
+
952
+ await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
953
+ prefixBase: 'proxy:test:api',
954
+ });
955
+
956
+ expect(mocks.ratelimitPrefixes).toContain(
957
+ 'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
958
+ );
959
+ });
960
+
961
+ it('uses a classroom-friendly OTP send bucket', async () => {
962
+ vi.stubEnv('NODE_ENV', 'production');
963
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
964
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
965
+ mocks.redis.mockReturnValue({});
966
+ mocks.extractIp.mockReturnValue('1.2.3.4');
967
+ mocks.isBlocked.mockResolvedValue(null);
968
+ mocks.limit.mockResolvedValueOnce({
969
+ success: false,
970
+ limit: 30,
971
+ remaining: 0,
972
+ reset: Date.now() + 15_000,
973
+ });
974
+
975
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
976
+ await import('../api-proxy-guard.js');
977
+ clearApiProxyGuardLimiterCache();
978
+
979
+ const response = await guardApiProxyRequest(
980
+ makeRequest('/api/v1/auth/otp/send', 'POST'),
981
+ {
982
+ prefixBase: 'proxy:test:api',
983
+ }
984
+ );
985
+
986
+ expect(response?.status).toBe(429);
987
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
988
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('otp-send');
989
+ });
990
+
991
+ it('uses a classroom-friendly bucket for cross-app return handoffs', async () => {
992
+ vi.stubEnv('NODE_ENV', 'production');
993
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
994
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
995
+ mocks.redis.mockReturnValue({});
996
+ mocks.extractIp.mockReturnValue('1.2.3.4');
997
+ mocks.isBlocked.mockResolvedValue(null);
998
+ mocks.limit.mockResolvedValueOnce({
999
+ success: false,
1000
+ limit: 180,
1001
+ remaining: 0,
1002
+ reset: Date.now() + 15_000,
1003
+ });
1004
+
1005
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1006
+ await import('../api-proxy-guard.js');
1007
+ clearApiProxyGuardLimiterCache();
1008
+
1009
+ const response = await guardApiProxyRequest(
1010
+ makeRequest('/api/v1/auth/cross-app-return', 'POST'),
1011
+ {
1012
+ prefixBase: 'proxy:test:api',
1013
+ }
1014
+ );
1015
+
1016
+ expect(response?.status).toBe(429);
1017
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('180');
1018
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1019
+ 'cross-app-return'
1020
+ );
1021
+ });
1022
+
1023
+ it('uses a separate OTP verify bucket', async () => {
1024
+ vi.stubEnv('NODE_ENV', 'production');
1025
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1026
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1027
+ mocks.redis.mockReturnValue({});
1028
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1029
+ mocks.isBlocked.mockResolvedValue(null);
1030
+ mocks.limit.mockResolvedValueOnce({
1031
+ success: false,
1032
+ limit: 60,
1033
+ remaining: 0,
1034
+ reset: Date.now() + 15_000,
1035
+ });
1036
+
1037
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1038
+ await import('../api-proxy-guard.js');
1039
+ clearApiProxyGuardLimiterCache();
1040
+
1041
+ const response = await guardApiProxyRequest(
1042
+ makeRequest('/api/v1/auth/mobile/verify-otp', 'POST'),
1043
+ { prefixBase: 'proxy:test:api' }
1044
+ );
1045
+
1046
+ expect(response?.status).toBe(429);
1047
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
1048
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('otp-verify');
1049
+ });
1050
+
1051
+ it('uses a separate classroom-friendly password-login bucket', async () => {
1052
+ vi.stubEnv('NODE_ENV', 'production');
1053
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1054
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1055
+ mocks.redis.mockReturnValue({});
1056
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1057
+ mocks.isBlocked.mockResolvedValue(null);
1058
+ mocks.limit.mockResolvedValueOnce({
1059
+ success: false,
1060
+ limit: 60,
1061
+ remaining: 0,
1062
+ reset: Date.now() + 15_000,
1063
+ });
1064
+
1065
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1066
+ await import('../api-proxy-guard.js');
1067
+ clearApiProxyGuardLimiterCache();
1068
+
1069
+ const response = await guardApiProxyRequest(
1070
+ makeRequest('/api/v1/auth/password-login', 'POST'),
1071
+ { prefixBase: 'proxy:test:api' }
1072
+ );
1073
+
1074
+ expect(response?.status).toBe(429);
1075
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
1076
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('password-login');
1077
+ });
1078
+
1079
+ it('keeps password login, OTP send, and OTP verify on distinct limiter prefixes', async () => {
1080
+ vi.stubEnv('NODE_ENV', 'production');
1081
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1082
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1083
+ mocks.redis.mockReturnValue({});
1084
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1085
+ mocks.isBlocked.mockResolvedValue(null);
1086
+ mocks.limit.mockResolvedValue({
1087
+ success: true,
1088
+ limit: 60,
1089
+ remaining: 59,
1090
+ reset: Date.now() + 15_000,
1091
+ });
1092
+
1093
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1094
+ await import('../api-proxy-guard.js');
1095
+ clearApiProxyGuardLimiterCache();
1096
+
1097
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/password-login'), {
1098
+ prefixBase: 'proxy:test:api',
1099
+ });
1100
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/otp/send'), {
1101
+ prefixBase: 'proxy:test:api',
1102
+ });
1103
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/otp/verify'), {
1104
+ prefixBase: 'proxy:test:api',
1105
+ });
1106
+
1107
+ expect(mocks.ratelimitPrefixes).toContain(
1108
+ 'proxy:test:api:password-login:anonymous:mutate:minute'
1109
+ );
1110
+ expect(mocks.ratelimitPrefixes).toContain(
1111
+ 'proxy:test:api:otp-send:anonymous:mutate:minute'
1112
+ );
1113
+ expect(mocks.ratelimitPrefixes).toContain(
1114
+ 'proxy:test:api:otp-verify:anonymous:mutate:minute'
1115
+ );
1116
+ expect(mocks.ratelimitPrefixes).not.toContain(
1117
+ 'proxy:test:api:auth:anonymous:mutate:minute'
1118
+ );
1119
+ });
1120
+
1121
+ it('honors auth-specific proxy limit environment overrides', async () => {
1122
+ vi.stubEnv('NODE_ENV', 'production');
1123
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1124
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1125
+ vi.stubEnv('API_PROXY_PASSWORD_LOGIN_LIMIT_MINUTE', '75');
1126
+ vi.stubEnv('API_PROXY_OTP_SEND_LIMIT_MINUTE', '45');
1127
+ vi.stubEnv('API_PROXY_OTP_VERIFY_LIMIT_MINUTE', '90');
1128
+ mocks.redis.mockReturnValue({});
1129
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1130
+ mocks.isBlocked.mockResolvedValue(null);
1131
+ mocks.limit.mockResolvedValue({
1132
+ success: true,
1133
+ limit: 75,
1134
+ remaining: 74,
1135
+ reset: Date.now() + 15_000,
1136
+ });
1137
+
1138
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1139
+ await import('../api-proxy-guard.js');
1140
+ clearApiProxyGuardLimiterCache();
1141
+
1142
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/password-login'), {
1143
+ prefixBase: 'proxy:test:api',
1144
+ });
1145
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/otp/send'), {
1146
+ prefixBase: 'proxy:test:api',
1147
+ });
1148
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/otp/verify'), {
1149
+ prefixBase: 'proxy:test:api',
1150
+ });
1151
+
1152
+ expect(mocks.ratelimitConfigs).toContainEqual({
1153
+ limit: 75,
1154
+ window: '1 m',
1155
+ });
1156
+ expect(mocks.ratelimitConfigs).toContainEqual({
1157
+ limit: 45,
1158
+ window: '1 m',
1159
+ });
1160
+ expect(mocks.ratelimitConfigs).toContainEqual({
1161
+ limit: 90,
1162
+ window: '1 m',
1163
+ });
1164
+ });
1165
+
1166
+ it('uses strict high-fanout buckets for email routes', async () => {
1167
+ vi.stubEnv('NODE_ENV', 'production');
1168
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1169
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1170
+ mocks.redis.mockReturnValue({});
1171
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1172
+ mocks.isBlocked.mockResolvedValue(null);
1173
+ mocks.limit.mockResolvedValueOnce({
1174
+ success: false,
1175
+ limit: 2,
1176
+ remaining: 0,
1177
+ reset: Date.now() + 15_000,
1178
+ });
1179
+
1180
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1181
+ await import('../api-proxy-guard.js');
1182
+ clearApiProxyGuardLimiterCache();
1183
+
1184
+ const response = await guardApiProxyRequest(
1185
+ makeRequest('/api/v1/workspaces/ws-1/mail/send', 'POST'),
1186
+ { prefixBase: 'proxy:test:api' }
1187
+ );
1188
+
1189
+ expect(response?.status).toBe(429);
1190
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('2');
1191
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
1192
+ });
1193
+
1194
+ it('uses relaxed buckets for task description persistence routes', async () => {
1195
+ vi.stubEnv('NODE_ENV', 'production');
1196
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1197
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1198
+ mocks.redis.mockReturnValue({});
1199
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1200
+ mocks.isBlocked.mockResolvedValue(null);
1201
+ mocks.limit.mockResolvedValueOnce({
1202
+ success: false,
1203
+ limit: 60,
1204
+ remaining: 0,
1205
+ reset: Date.now() + 15_000,
1206
+ });
1207
+
1208
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1209
+ await import('../api-proxy-guard.js');
1210
+ clearApiProxyGuardLimiterCache();
1211
+
1212
+ const response = await guardApiProxyRequest(
1213
+ makeRequest('/api/v1/workspaces/ws-1/tasks/task-1/description', 'PATCH'),
1214
+ { prefixBase: 'proxy:test:api' }
1215
+ );
1216
+
1217
+ expect(response?.status).toBe(429);
1218
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
1219
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1220
+ 'task-description'
1221
+ );
1222
+ });
1223
+
1224
+ it('keeps non-description task mutations on the default strict bucket', async () => {
1225
+ vi.stubEnv('NODE_ENV', 'production');
1226
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1227
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1228
+ mocks.redis.mockReturnValue({});
1229
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1230
+ mocks.isBlocked.mockResolvedValue(null);
1231
+ mocks.limit.mockResolvedValueOnce({
1232
+ success: false,
1233
+ limit: 12,
1234
+ remaining: 0,
1235
+ reset: Date.now() + 15_000,
1236
+ });
1237
+
1238
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1239
+ await import('../api-proxy-guard.js');
1240
+ clearApiProxyGuardLimiterCache();
1241
+
1242
+ const response = await guardApiProxyRequest(
1243
+ makeRequest('/api/v1/workspaces/ws-1/tasks/task-1', 'PUT'),
1244
+ { prefixBase: 'proxy:test:api' }
1245
+ );
1246
+
1247
+ expect(response?.status).toBe(429);
1248
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('12');
1249
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
1250
+ });
1251
+
1252
+ it('uses a larger anonymous default mutation burst budget per minute', async () => {
1253
+ vi.stubEnv('NODE_ENV', 'production');
1254
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1255
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1256
+ mocks.redis.mockReturnValue({});
1257
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1258
+ mocks.isBlocked.mockResolvedValue(null);
1259
+ mocks.limit.mockResolvedValue({
1260
+ success: true,
1261
+ limit: 30,
1262
+ remaining: 29,
1263
+ reset: Date.now() + 15_000,
1264
+ });
1265
+
1266
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1267
+ await import('../api-proxy-guard.js');
1268
+ clearApiProxyGuardLimiterCache();
1269
+
1270
+ await guardApiProxyRequest(
1271
+ makeRequest('/api/v1/workspaces/ws-1/tasks/task-1', 'PUT'),
1272
+ { prefixBase: 'proxy:test:api' }
1273
+ );
1274
+
1275
+ expect(mocks.ratelimitConfigs).toContainEqual({
1276
+ limit: 30,
1277
+ window: '1 m',
1278
+ });
1279
+ });
1280
+
1281
+ it('bypasses trusted cron traffic only when credentials are present', async () => {
1282
+ vi.stubEnv('NODE_ENV', 'production');
1283
+ vi.stubEnv('CRON_SECRET', 'cron-secret');
1284
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1285
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1286
+ mocks.redis.mockReturnValue({});
1287
+
1288
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1289
+ await import('../api-proxy-guard.js');
1290
+ clearApiProxyGuardLimiterCache();
1291
+
1292
+ const allowedResponse = await guardApiProxyRequest(
1293
+ makeRequest('/api/cron/process-notification-batches', 'POST', {
1294
+ authorization: 'Bearer cron-secret',
1295
+ }),
1296
+ { prefixBase: 'proxy:test:api' }
1297
+ );
1298
+
1299
+ expect(allowedResponse).toBeNull();
1300
+ expect(mocks.extractIp).not.toHaveBeenCalled();
1301
+
1302
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1303
+ mocks.isBlocked.mockResolvedValue(null);
1304
+ mocks.limit.mockResolvedValueOnce({
1305
+ success: false,
1306
+ limit: 12,
1307
+ remaining: 0,
1308
+ reset: Date.now() + 15_000,
1309
+ });
1310
+
1311
+ const blockedResponse = await guardApiProxyRequest(
1312
+ makeRequest('/api/cron/process-notification-batches', 'POST', {
1313
+ authorization: 'Bearer wrong-secret',
1314
+ }),
1315
+ { prefixBase: 'proxy:test:api' }
1316
+ );
1317
+
1318
+ expect(blockedResponse?.status).toBe(429);
1319
+ expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe('cron');
1320
+ });
1321
+
1322
+ it('bypasses trusted webhook traffic only with required signature headers', async () => {
1323
+ vi.stubEnv('NODE_ENV', 'production');
1324
+ vi.stubEnv('POLAR_WEBHOOK_SECRET', 'polar-secret');
1325
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1326
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1327
+ mocks.redis.mockReturnValue({});
1328
+
1329
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1330
+ await import('../api-proxy-guard.js');
1331
+ clearApiProxyGuardLimiterCache();
1332
+
1333
+ const allowedResponse = await guardApiProxyRequest(
1334
+ makeRequest('/api/payment/webhooks', 'POST', {
1335
+ 'webhook-id': 'id',
1336
+ 'webhook-signature': 'sig',
1337
+ 'webhook-timestamp': 'ts',
1338
+ }),
1339
+ { prefixBase: 'proxy:test:api' }
1340
+ );
1341
+
1342
+ expect(allowedResponse).toBeNull();
1343
+
1344
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1345
+ mocks.isBlocked.mockResolvedValue(null);
1346
+ mocks.limit.mockResolvedValueOnce({
1347
+ success: false,
1348
+ limit: 12,
1349
+ remaining: 0,
1350
+ reset: Date.now() + 15_000,
1351
+ });
1352
+
1353
+ const blockedResponse = await guardApiProxyRequest(
1354
+ makeRequest('/api/payment/webhooks', 'POST', {
1355
+ 'webhook-id': 'id',
1356
+ }),
1357
+ { prefixBase: 'proxy:test:api' }
1358
+ );
1359
+
1360
+ expect(blockedResponse?.status).toBe(429);
1361
+ });
1362
+
1363
+ it('delegates to emoji validation after rate limiting passes', async () => {
1364
+ vi.stubEnv('NODE_ENV', 'production');
1365
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1366
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1367
+ mocks.redis.mockReturnValue({});
1368
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1369
+ mocks.isBlocked.mockResolvedValue(null);
1370
+ mocks.limit
1371
+ .mockResolvedValueOnce({
1372
+ success: true,
1373
+ limit: 12,
1374
+ remaining: 11,
1375
+ reset: Date.now() + 60_000,
1376
+ })
1377
+ .mockResolvedValueOnce({
1378
+ success: true,
1379
+ limit: 120,
1380
+ remaining: 119,
1381
+ reset: Date.now() + 60_000,
1382
+ })
1383
+ .mockResolvedValueOnce({
1384
+ success: true,
1385
+ limit: 400,
1386
+ remaining: 399,
1387
+ reset: Date.now() + 60_000,
1388
+ });
1389
+
1390
+ const emojiResponse = new Response(null, { status: 400 });
1391
+ mocks.validateEmoji.mockResolvedValue(emojiResponse);
1392
+
1393
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1394
+ await import('../api-proxy-guard.js');
1395
+ clearApiProxyGuardLimiterCache();
1396
+
1397
+ const response = await guardApiProxyRequest(
1398
+ makeRequest('/api/test', 'POST'),
1399
+ { prefixBase: 'proxy:test:api' }
1400
+ );
1401
+
1402
+ expect(response).toBe(emojiResponse);
1403
+ expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
1404
+ });
1405
+
1406
+ it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
1407
+ vi.stubEnv('NODE_ENV', 'production');
1408
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1409
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1410
+ mocks.redis.mockReturnValue({});
1411
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1412
+ mocks.isBlocked.mockResolvedValue(null);
1413
+ mocks.limit
1414
+ .mockResolvedValueOnce({
1415
+ success: true,
1416
+ limit: 12,
1417
+ remaining: 11,
1418
+ reset: Date.now() + 60_000,
1419
+ })
1420
+ .mockResolvedValueOnce({
1421
+ success: true,
1422
+ limit: 120,
1423
+ remaining: 119,
1424
+ reset: Date.now() + 60_000,
1425
+ })
1426
+ .mockResolvedValueOnce({
1427
+ success: true,
1428
+ limit: 400,
1429
+ remaining: 399,
1430
+ reset: Date.now() + 60_000,
1431
+ });
1432
+ mocks.validateEmoji.mockResolvedValue(null);
1433
+
1434
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1435
+ await import('../api-proxy-guard.js');
1436
+ clearApiProxyGuardLimiterCache();
1437
+
1438
+ await guardApiProxyRequest(
1439
+ makeRequest(
1440
+ '/api/v1/workspaces/ws-1/whiteboards/11111111-1111-4111-8111-111111111111',
1441
+ 'PATCH'
1442
+ ),
1443
+ { prefixBase: 'proxy:test:api' }
1444
+ );
1445
+
1446
+ expect(mocks.validateEmoji).toHaveBeenCalledWith(expect.any(NextRequest), {
1447
+ allowDescriptionYjsState: false,
1448
+ skipValidationForFields: ['snapshot'],
1449
+ });
1450
+ });
1451
+ });