@tuturuuu/utils 0.33.3 → 0.35.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 (54) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +6 -4
  3. package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
  4. package/src/__tests__/api-proxy-guard.test.ts +6 -13
  5. package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
  6. package/src/__tests__/api-proxy-test-request.ts +19 -0
  7. package/src/__tests__/calendar-provider-readonly.test.ts +45 -0
  8. package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
  9. package/src/__tests__/calendar-recurrence.test.ts +479 -0
  10. package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
  11. package/src/__tests__/offline-download-guard.test.ts +340 -0
  12. package/src/__tests__/permissions.test.tsx +21 -0
  13. package/src/__tests__/request-workspace.test.ts +63 -0
  14. package/src/__tests__/tutoring-teacher-hours.test.ts +274 -0
  15. package/src/__tests__/username-policy.test.ts +70 -0
  16. package/src/account-benefits-server.ts +43 -0
  17. package/src/account-benefits.ts +45 -0
  18. package/src/api-proxy-guard.ts +8 -42
  19. package/src/api-proxy-read-policy.ts +43 -0
  20. package/src/calendar-provider-readonly.ts +49 -0
  21. package/src/calendar-recurrence-edit.ts +148 -0
  22. package/src/calendar-recurrence-expansion.ts +131 -0
  23. package/src/calendar-recurrence-local.ts +45 -0
  24. package/src/calendar-recurrence-pattern.ts +54 -0
  25. package/src/calendar-recurrence-providers.ts +361 -0
  26. package/src/calendar-recurrence-validation.ts +180 -0
  27. package/src/calendar-recurrence.ts +5 -0
  28. package/src/calendar-utils.ts +41 -3
  29. package/src/common/metadata.test.ts +0 -6
  30. package/src/constants.ts +3 -0
  31. package/src/deployment-vault-permissions.tsx +26 -0
  32. package/src/desktop-signing-profiles.ts +20 -0
  33. package/src/google-calendar-import-fence.test.ts +182 -0
  34. package/src/google-calendar-import-fence.ts +166 -0
  35. package/src/next-config.ts +8 -1
  36. package/src/offline-download-guard.ts +160 -0
  37. package/src/permissions.tsx +2 -10
  38. package/src/platform-release.test.ts +1 -1
  39. package/src/platform-release.ts +1 -1
  40. package/src/playground-schema.test.ts +119 -0
  41. package/src/playground-schema.ts +173 -0
  42. package/src/playground-templates.ts +65 -0
  43. package/src/release-note-policy-core.cjs +28 -0
  44. package/src/release-note-policy-core.d.cts +5 -0
  45. package/src/release-note-policy.json +9 -0
  46. package/src/release-note-policy.ts +4 -0
  47. package/src/request-workspace.ts +29 -0
  48. package/src/seo-policy.json +196 -0
  49. package/src/seo.test.ts +168 -0
  50. package/src/seo.ts +49 -0
  51. package/src/tutoring-teacher-hours.ts +220 -0
  52. package/src/username-policy.json +87 -0
  53. package/src/username-policy.ts +18 -0
  54. package/vitest.config.ts +7 -0
@@ -0,0 +1,479 @@
1
+ import { Temporal } from '@js-temporal/polyfill';
2
+ import type {
3
+ CalendarRecurrenceAnchor,
4
+ CalendarRecurrenceRule,
5
+ } from '@tuturuuu/types/primitives/calendar-recurrence';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ CalendarRecurrenceRuleSchema,
9
+ expandCalendarRecurrence,
10
+ fromGoogleCalendarRecurrence,
11
+ fromGraphCalendarRecurrence,
12
+ toGoogleCalendarRecurrence,
13
+ toGraphCalendarRecurrence,
14
+ UnsupportedCalendarRecurrenceError,
15
+ } from '../calendar-recurrence';
16
+
17
+ const anchor: CalendarRecurrenceAnchor = {
18
+ startLocal: '2026-01-01T09:00:00',
19
+ endLocal: '2026-01-01T10:00:00',
20
+ allDay: false,
21
+ };
22
+ const daily: CalendarRecurrenceRule = {
23
+ version: 1,
24
+ frequency: 'daily',
25
+ interval: 1,
26
+ timeZone: 'UTC',
27
+ end: { type: 'never' },
28
+ };
29
+ const expand = (
30
+ rule: CalendarRecurrenceRule,
31
+ options: Partial<Parameters<typeof expandCalendarRecurrence>[0]> = {}
32
+ ) =>
33
+ expandCalendarRecurrence({
34
+ rule,
35
+ anchor,
36
+ from: '2026-01-01T00:00:00Z',
37
+ to: '2026-02-01T00:00:00Z',
38
+ ...options,
39
+ });
40
+ const patterns: CalendarRecurrenceRule[] = [
41
+ daily,
42
+ {
43
+ ...daily,
44
+ frequency: 'weekly',
45
+ interval: 2,
46
+ weekdays: ['MO', 'FR'],
47
+ weekStartsOn: 'SU',
48
+ },
49
+ {
50
+ ...daily,
51
+ frequency: 'monthly',
52
+ monthDay: 31,
53
+ monthDayOverflow: 'last-day',
54
+ },
55
+ { ...daily, frequency: 'monthly', weekdays: ['TH'], weekIndex: 2 },
56
+ { ...daily, frequency: 'monthly', weekdays: ['TH', 'FR'], weekIndex: -1 },
57
+ {
58
+ ...daily,
59
+ frequency: 'yearly',
60
+ month: 2,
61
+ monthDay: 29,
62
+ monthDayOverflow: 'last-day',
63
+ },
64
+ { ...daily, frequency: 'yearly', month: 11, weekdays: ['MO'], weekIndex: 1 },
65
+ ];
66
+
67
+ describe('common provider recurrence contract', () => {
68
+ for (const [index, pattern] of patterns.entries()) {
69
+ for (const end of [
70
+ { type: 'never' },
71
+ { type: 'count', count: 8 },
72
+ { type: 'until', date: '2028-12-31' },
73
+ ] as const) {
74
+ it(`round trips pattern ${index} / ${end.type} through Google and Graph`, () => {
75
+ const rule = { ...pattern, end };
76
+ const dates = [
77
+ '2026-01-01',
78
+ '2026-01-02',
79
+ '2026-01-31',
80
+ '2026-01-08',
81
+ '2026-01-29',
82
+ '2028-02-29',
83
+ '2026-11-02',
84
+ ];
85
+ const patternAnchor = {
86
+ ...anchor,
87
+ startLocal: `${dates[index]}T09:00:00`,
88
+ endLocal: `${dates[index]}T10:00:00`,
89
+ };
90
+ expect(
91
+ fromGraphCalendarRecurrence(
92
+ toGraphCalendarRecurrence(rule, patternAnchor),
93
+ rule.timeZone
94
+ )
95
+ ).toEqual(rule);
96
+ const imported = fromGoogleCalendarRecurrence(
97
+ toGoogleCalendarRecurrence(rule, patternAnchor),
98
+ patternAnchor,
99
+ rule.timeZone
100
+ );
101
+ expect(imported).toEqual(rule);
102
+ });
103
+ }
104
+ }
105
+ it('uses exclusive all-day dates and inclusive UNTIL day', () => {
106
+ const allDay = {
107
+ startLocal: '2026-01-01T00:00:00',
108
+ endLocal: '2026-01-02T00:00:00',
109
+ allDay: true,
110
+ };
111
+ const rule = {
112
+ ...daily,
113
+ end: { type: 'until', date: '2026-01-03' } as const,
114
+ };
115
+ expect(toGoogleCalendarRecurrence(rule, allDay)).toEqual([
116
+ 'RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20260103',
117
+ ]);
118
+ expect(
119
+ fromGoogleCalendarRecurrence(
120
+ toGoogleCalendarRecurrence(rule, allDay),
121
+ allDay,
122
+ 'UTC'
123
+ )
124
+ ).toEqual(rule);
125
+ expect(expand(rule, { anchor: allDay }).occurrences).toHaveLength(3);
126
+ });
127
+ it('converts Google UTC cutoff to the last eligible local day without extending it', () => {
128
+ const rule = fromGoogleCalendarRecurrence(
129
+ ['RRULE:FREQ=DAILY;UNTIL=20260103T085959Z'],
130
+ anchor,
131
+ 'UTC'
132
+ );
133
+ expect(rule.end).toEqual({ type: 'until', date: '2026-01-02' });
134
+ });
135
+ it.each([
136
+ ['RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=5'],
137
+ ['RRULE:FREQ=MONTHLY;BYDAY=1MO,1FR'],
138
+ ['RRULE:FREQ=DAILY', 'EXDATE:20260101T090000Z'],
139
+ ['RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260103T000000Z'],
140
+ ['RRULE:FREQ=DAILY;INTERVAL=1;INTERVAL=2'],
141
+ ])('rejects unsupported/lossy provider recurrence %j', (...value) => {
142
+ expect(() => fromGoogleCalendarRecurrence(value, anchor, 'UTC')).toThrow(
143
+ UnsupportedCalendarRecurrenceError
144
+ );
145
+ });
146
+ it.each([
147
+ { ...daily, timeZone: '+07:00' },
148
+ { ...daily, timeZone: 'Invalid/Zone' },
149
+ { ...daily, interval: 0 },
150
+ { ...daily, weekdays: ['MO'] },
151
+ { ...daily, frequency: 'weekly' },
152
+ {
153
+ ...daily,
154
+ frequency: 'monthly',
155
+ monthDay: 1,
156
+ weekdays: ['MO'],
157
+ weekIndex: 1,
158
+ },
159
+ { ...daily, end: { type: 'until', date: '2026-02-30' } },
160
+ ])('rejects invalid rule %j', (rule) =>
161
+ expect(CalendarRecurrenceRuleSchema.safeParse(rule).success).toBe(false)
162
+ );
163
+ });
164
+
165
+ describe('bounded timezone-aware occurrence expansion', () => {
166
+ it('maintains the same wall clock through spring DST', () => {
167
+ const result = expand(
168
+ { ...daily, timeZone: 'America/New_York' },
169
+ {
170
+ anchor: {
171
+ ...anchor,
172
+ startLocal: '2026-03-07T09:00:00',
173
+ endLocal: '2026-03-07T10:00:00',
174
+ },
175
+ from: '2026-03-07T00:00:00Z',
176
+ to: '2026-03-10T00:00:00Z',
177
+ }
178
+ );
179
+ expect(result.occurrences.map((event) => event.start_at)).toEqual([
180
+ '2026-03-07T14:00:00Z',
181
+ '2026-03-08T13:00:00Z',
182
+ '2026-03-09T13:00:00Z',
183
+ ]);
184
+ });
185
+ it('skips nonexistent local times without consuming COUNT', () => {
186
+ const result = expand(
187
+ {
188
+ ...daily,
189
+ timeZone: 'America/New_York',
190
+ end: { type: 'count', count: 2 },
191
+ },
192
+ {
193
+ anchor: {
194
+ ...anchor,
195
+ startLocal: '2026-03-07T02:30:00',
196
+ endLocal: '2026-03-07T03:30:00',
197
+ },
198
+ from: '2026-03-07T00:00:00Z',
199
+ to: '2026-03-11T00:00:00Z',
200
+ }
201
+ );
202
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
203
+ ['2026-03-07T02:30:00', '2026-03-09T02:30:00']
204
+ );
205
+ });
206
+ it('uses earlier fold and preserves end wall clock in autumn', () => {
207
+ const result = expand(
208
+ {
209
+ ...daily,
210
+ timeZone: 'America/New_York',
211
+ end: { type: 'count', count: 1 },
212
+ },
213
+ {
214
+ anchor: {
215
+ ...anchor,
216
+ startLocal: '2026-11-01T01:30:00',
217
+ endLocal: '2026-11-01T02:30:00',
218
+ },
219
+ from: '2026-11-01T00:00:00Z',
220
+ to: '2026-11-02T00:00:00Z',
221
+ }
222
+ );
223
+ expect(result.occurrences[0]).toMatchObject({
224
+ start_at: '2026-11-01T05:30:00Z',
225
+ end_at: '2026-11-01T07:30:00Z',
226
+ });
227
+ });
228
+ it('counts cancellations and moved instances without creating replacement slots', () => {
229
+ const result = expand(
230
+ { ...daily, end: { type: 'count', count: 3 } },
231
+ {
232
+ exceptions: [
233
+ { originalStartLocal: '2026-01-01T09:00:00', cancelled: true },
234
+ {
235
+ originalStartLocal: '2026-01-02T09:00:00',
236
+ startLocal: '2026-01-09T13:00:00',
237
+ endLocal: '2026-01-09T14:00:00',
238
+ },
239
+ ],
240
+ }
241
+ );
242
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
243
+ ['2026-01-03T09:00:00', '2026-01-02T09:00:00']
244
+ );
245
+ expect(result.occurrences[1]?.isException).toBe(true);
246
+ });
247
+ it('includes moved instances whose original slot is outside the requested window', () => {
248
+ const result = expand(daily, {
249
+ from: '2026-01-01T12:00:00Z',
250
+ to: '2026-01-01T15:00:00Z',
251
+ exceptions: [
252
+ {
253
+ originalStartLocal: '2026-01-20T09:00:00',
254
+ startLocal: '2026-01-01T13:00:00',
255
+ endLocal: '2026-01-01T14:00:00',
256
+ },
257
+ ],
258
+ });
259
+ expect(result.occurrences.map((event) => event.originalStartLocal)).toEqual(
260
+ ['2026-01-20T09:00:00']
261
+ );
262
+ });
263
+ it('includes events overlapping the range and excludes exact edge boundaries', () => {
264
+ expect(
265
+ expand(daily, {
266
+ from: '2026-01-01T09:30:00Z',
267
+ to: '2026-01-01T09:45:00Z',
268
+ }).occurrences
269
+ ).toHaveLength(1);
270
+ expect(
271
+ expand(daily, {
272
+ from: '2026-01-01T10:00:00Z',
273
+ to: '2026-01-02T09:00:00Z',
274
+ }).occurrences
275
+ ).toHaveLength(0);
276
+ });
277
+ it('skips unavailable month days, including non-leap February', () => {
278
+ const rule = { ...daily, frequency: 'monthly', monthDay: 31 } as const;
279
+ expect(
280
+ expand(rule, {
281
+ anchor: {
282
+ ...anchor,
283
+ startLocal: '2026-01-31T09:00:00',
284
+ endLocal: '2026-01-31T10:00:00',
285
+ },
286
+ to: '2026-05-01T00:00:00Z',
287
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
288
+ ).toEqual(['2026-01-31', '2026-03-31']);
289
+ expect(
290
+ expand(
291
+ { ...daily, frequency: 'yearly', month: 2, monthDay: 29 },
292
+ {
293
+ anchor: {
294
+ ...anchor,
295
+ startLocal: '2028-02-29T09:00:00',
296
+ endLocal: '2028-02-29T10:00:00',
297
+ },
298
+ to: '2030-01-01T00:00:00Z',
299
+ }
300
+ ).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
301
+ ).toEqual(['2028-02-29']);
302
+ });
303
+ it('handles biweekly recurrence across year and week boundaries', () => {
304
+ expect(
305
+ expand({
306
+ ...daily,
307
+ frequency: 'weekly',
308
+ interval: 2,
309
+ weekdays: ['MO', 'TH'],
310
+ weekStartsOn: 'SU',
311
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
312
+ ).toEqual([
313
+ '2026-01-01',
314
+ '2026-01-12',
315
+ '2026-01-15',
316
+ '2026-01-26',
317
+ '2026-01-29',
318
+ ]);
319
+ });
320
+ it('selects the earliest matching relative weekday', () => {
321
+ expect(
322
+ expand(
323
+ {
324
+ ...daily,
325
+ frequency: 'monthly',
326
+ weekdays: ['TH', 'FR'],
327
+ weekIndex: 2,
328
+ },
329
+ {
330
+ anchor: {
331
+ ...anchor,
332
+ startLocal: '2026-01-08T09:00:00',
333
+ endLocal: '2026-01-08T10:00:00',
334
+ },
335
+ }
336
+ ).occurrences[0]?.originalStartLocal
337
+ ).toBe('2026-01-08T09:00:00');
338
+ });
339
+ it('preserves Outlook short-month clamping through Google RRULE conversion', () => {
340
+ const rule: CalendarRecurrenceRule = {
341
+ ...daily,
342
+ frequency: 'monthly',
343
+ monthDay: 31,
344
+ monthDayOverflow: 'last-day',
345
+ };
346
+ const monthAnchor = {
347
+ ...anchor,
348
+ startLocal: '2026-01-31T09:00:00',
349
+ endLocal: '2026-01-31T10:00:00',
350
+ };
351
+ expect(toGoogleCalendarRecurrence(rule, monthAnchor)).toEqual([
352
+ 'RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=28,29,30,31;BYSETPOS=-1',
353
+ ]);
354
+ expect(
355
+ expand(rule, {
356
+ anchor: monthAnchor,
357
+ to: '2026-05-01T00:00:00Z',
358
+ }).occurrences.map((event) => event.originalStartLocal.slice(0, 10))
359
+ ).toEqual(['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']);
360
+ expect(() =>
361
+ toGraphCalendarRecurrence(
362
+ { ...rule, monthDayOverflow: 'skip' },
363
+ monthAnchor
364
+ )
365
+ ).toThrow(UnsupportedCalendarRecurrenceError);
366
+ });
367
+ it('rejects duplicate exception keys and incomplete moved times', () => {
368
+ expect(() =>
369
+ expand(daily, {
370
+ exceptions: [
371
+ { originalStartLocal: anchor.startLocal },
372
+ { originalStartLocal: anchor.startLocal, cancelled: true },
373
+ ],
374
+ })
375
+ ).toThrow('Duplicate');
376
+ expect(() =>
377
+ expand(daily, {
378
+ exceptions: [
379
+ {
380
+ originalStartLocal: anchor.startLocal,
381
+ startLocal: '2026-01-01T13:00:00',
382
+ },
383
+ ],
384
+ })
385
+ ).toThrow('Moved occurrences require');
386
+ });
387
+ it('does not resurrect a moved original slot beyond the counted series', () => {
388
+ const result = expand(
389
+ { ...daily, end: { type: 'count', count: 1 } },
390
+ {
391
+ exceptions: [
392
+ {
393
+ originalStartLocal: '2026-01-20T09:00:00',
394
+ startLocal: '2026-01-01T13:00:00',
395
+ endLocal: '2026-01-01T14:00:00',
396
+ },
397
+ ],
398
+ }
399
+ );
400
+ expect(result.occurrences).toHaveLength(1);
401
+ expect(result.occurrences[0]?.originalStartLocal).toBe(anchor.startLocal);
402
+ });
403
+ it('rejects a series anchor outside its pattern instead of inserting an extra first occurrence', () => {
404
+ expect(() =>
405
+ expand({ ...daily, frequency: 'weekly', weekdays: ['MO'] })
406
+ ).toThrow('anchor must match');
407
+ expect(() =>
408
+ fromGoogleCalendarRecurrence(
409
+ ['RRULE:FREQ=WEEKLY;BYDAY=1MO'],
410
+ anchor,
411
+ 'UTC'
412
+ )
413
+ ).toThrow(UnsupportedCalendarRecurrenceError);
414
+ });
415
+ it('reports truncation explicitly instead of claiming completeness', () => {
416
+ const result = expand(daily, { limit: 2 });
417
+ expect(result.occurrences).toHaveLength(2);
418
+ expect(result.truncated).toBe(true);
419
+ });
420
+ it('rejects an unbounded historical scan and invalid request limits', () => {
421
+ expect(() => expand(daily, { to: '2200-01-01T00:00:00Z' })).toThrow(
422
+ '100 years'
423
+ );
424
+ expect(() => expand(daily, { limit: 1001 })).toThrow('1–1000');
425
+ });
426
+ it('rejects oversized effective history before walking any dates', () => {
427
+ const add = vi.spyOn(Temporal.PlainDate.prototype, 'add');
428
+ try {
429
+ for (const options of [
430
+ { to: '2200-01-01T00:00:00Z' },
431
+ {
432
+ exceptions: [
433
+ { originalStartLocal: '2200-01-01T09:00:00', cancelled: true },
434
+ ],
435
+ },
436
+ ]) {
437
+ expect(() => expand(daily, options)).toThrow('100 years');
438
+ }
439
+ expect(add).not.toHaveBeenCalled();
440
+ } finally {
441
+ add.mockRestore();
442
+ }
443
+ });
444
+ it('lets COUNT and a nearer UNTIL terminate a far-future viewport', () => {
445
+ for (const end of [
446
+ { type: 'count', count: 2 },
447
+ { type: 'until', date: '2026-01-02' },
448
+ ] as const) {
449
+ const result = expand({ ...daily, end }, { to: '2200-01-01T00:00:00Z' });
450
+ expect(
451
+ result.occurrences.map((event) => event.originalStartLocal)
452
+ ).toEqual(['2026-01-01T09:00:00', '2026-01-02T09:00:00']);
453
+ expect(result.truncated).toBe(false);
454
+ }
455
+ });
456
+ it('uses 23-hour all-day duration across DST with exclusive end boundary', () => {
457
+ const result = expand(
458
+ {
459
+ ...daily,
460
+ timeZone: 'America/New_York',
461
+ end: { type: 'count', count: 1 },
462
+ },
463
+ {
464
+ anchor: {
465
+ startLocal: '2026-03-08T00:00:00',
466
+ endLocal: '2026-03-09T00:00:00',
467
+ allDay: true,
468
+ },
469
+ from: '2026-03-08T00:00:00Z',
470
+ to: '2026-03-10T00:00:00Z',
471
+ }
472
+ );
473
+ expect(result.occurrences[0]).toMatchObject({
474
+ start_at: '2026-03-08T05:00:00Z',
475
+ end_at: '2026-03-09T04:00:00Z',
476
+ is_all_day: true,
477
+ });
478
+ });
479
+ });
@@ -0,0 +1,137 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ rest: vi.fn(),
6
+ limiter: vi.fn(),
7
+ limit: vi.fn(),
8
+ verify: vi.fn(),
9
+ mfa: vi.fn(),
10
+ block: vi.fn(),
11
+ }));
12
+ vi.mock('../required-mfa-runtime', () => ({
13
+ enforceRequiredMfaRequest: mocks.mfa,
14
+ }));
15
+ vi.mock('../abuse-protection/edge', () => ({
16
+ extractIPFromRequest: () => '192.0.2.1',
17
+ isIPBlockedEdge: mocks.block,
18
+ readEdgeAbuseProtectionControls: async () => ({
19
+ ipBlockingEnabled: true,
20
+ rateLimitsEnabled: false,
21
+ }),
22
+ }));
23
+ vi.mock('../abuse-protection/edge-trust', () => ({
24
+ getCachedTrustEntries: async () => new Map(),
25
+ hasCachedIpBlockAppealRelief: async () => false,
26
+ }));
27
+ vi.mock('../upstash-rest', () => ({
28
+ getUpstashRestRedisClient: mocks.rest,
29
+ getUpstashRatelimitRedisClient: mocks.limiter,
30
+ }));
31
+ vi.mock('@tuturuuu/turnstile/server', () => ({
32
+ verifyTurnstileToken: mocks.verify,
33
+ }));
34
+ vi.mock('@upstash/ratelimit', () => ({
35
+ Ratelimit: class {
36
+ static slidingWindow() {
37
+ return {};
38
+ }
39
+ limit(key: string) {
40
+ return mocks.limit(key);
41
+ }
42
+ },
43
+ }));
44
+ vi.mock('../request-emoji-limit', () => ({
45
+ validateRequestEmojiLimit: async () => null,
46
+ }));
47
+
48
+ let guardApiProxyRequest: typeof import('../api-proxy-guard')['guardApiProxyRequest'];
49
+
50
+ import { OFFLINE_DOWNLOAD_HEADER } from '../offline-download-guard';
51
+
52
+ const paths = [
53
+ ['finance', '/api/workspaces/ws/wallets/infinite'],
54
+ ['inventory', '/api/v1/workspaces/ws/inventory/products'],
55
+ ['contacts', '/api/v1/workspaces/ws/users/database'],
56
+ ['cms', '/api/v1/workspaces/ws/external-projects'],
57
+ ['contacts', '/api/v1/workspaces/ws/members'],
58
+ ['cms', '/api/v1/workspaces/ws/members'],
59
+ ['tasks', '/api/v1/workspaces/ws/tasks'],
60
+ ] as const;
61
+ beforeEach(async () => {
62
+ vi.clearAllMocks();
63
+ vi.resetModules();
64
+ vi.stubEnv('NODE_ENV', 'production');
65
+ ({ guardApiProxyRequest } = await import('../api-proxy-guard.js'));
66
+ mocks.mfa.mockResolvedValue(null);
67
+ mocks.block.mockResolvedValue(null);
68
+ mocks.rest.mockRejectedValue(new Error('offline Redis unavailable'));
69
+ mocks.limiter.mockResolvedValue(null);
70
+ mocks.limit.mockResolvedValue({ success: false, reset: Date.now() + 1000 });
71
+ mocks.verify.mockRejectedValue(new Error('Turnstile unavailable'));
72
+ });
73
+ afterEach(() => vi.unstubAllEnvs());
74
+ it.each(paths)(
75
+ 'keeps %s ordinary reads independent of optional providers through the real proxy',
76
+ async (app, path) => {
77
+ for (const enabled of ['', 'false', 'true']) {
78
+ vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', enabled);
79
+ for (const method of ['GET', 'HEAD']) {
80
+ for (const headers of [
81
+ new Headers(),
82
+ new Headers({ authorization: 'Bearer synthetic-session' }),
83
+ new Headers({ cookie: 'tuturuuu_app_session=ttr_app_synthetic' }),
84
+ ]) {
85
+ const request = new NextRequest(
86
+ `https://${app}.tuturuuu.com${path}`,
87
+ {
88
+ method,
89
+ headers,
90
+ }
91
+ );
92
+ expect(
93
+ await guardApiProxyRequest(request, {
94
+ prefixBase: `proxy:${app}:api`,
95
+ })
96
+ ).toBeNull();
97
+ }
98
+ }
99
+ }
100
+ expect(mocks.rest).not.toHaveBeenCalled();
101
+ expect(mocks.limiter).not.toHaveBeenCalled();
102
+ expect(mocks.limit).not.toHaveBeenCalled();
103
+ expect(mocks.verify).not.toHaveBeenCalled();
104
+ }
105
+ );
106
+ it('keeps explicit bulk downloads unavailable when the optional provider fails', async () => {
107
+ vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'true');
108
+ const request = new NextRequest(
109
+ 'https://inventory.tuturuuu.com/api/v1/workspaces/ws/inventory/products',
110
+ { headers: { [OFFLINE_DOWNLOAD_HEADER]: '1' } }
111
+ );
112
+ const result = await guardApiProxyRequest(request, {
113
+ prefixBase: 'proxy:inventory:api',
114
+ });
115
+ expect(result?.status).toBe(503);
116
+ expect(result?.headers.get('Retry-After')).toBe('30');
117
+ });
118
+ it('keeps baseline required MFA and IP blocks effective during offline-provider outages', async () => {
119
+ vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'true');
120
+ const request = new NextRequest(
121
+ 'https://finance.tuturuuu.com/api/workspaces/ws/wallets/infinite'
122
+ );
123
+ mocks.mfa.mockResolvedValue(
124
+ NextResponse.json({ error: 'Required MFA' }, { status: 403 })
125
+ );
126
+ expect(
127
+ (await guardApiProxyRequest(request, { prefixBase: 'proxy:finance:api' }))
128
+ ?.status
129
+ ).toBe(403);
130
+ mocks.mfa.mockResolvedValue(null);
131
+ mocks.block.mockResolvedValue({ expiresAt: new Date(Date.now() + 30000) });
132
+ expect(
133
+ (await guardApiProxyRequest(request, { prefixBase: 'proxy:finance:api' }))
134
+ ?.status
135
+ ).toBe(429);
136
+ expect(mocks.rest).not.toHaveBeenCalled();
137
+ });