@donotdev/functions 0.0.10 → 0.0.12

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 (81) hide show
  1. package/README.md +1 -1
  2. package/package.json +32 -8
  3. package/src/firebase/auth/setCustomClaims.ts +26 -4
  4. package/src/firebase/baseFunction.ts +43 -20
  5. package/src/firebase/billing/cancelSubscription.ts +9 -1
  6. package/src/firebase/billing/changePlan.ts +8 -2
  7. package/src/firebase/billing/createCheckoutSession.ts +3 -1
  8. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  9. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  10. package/src/firebase/billing/webhookHandler.ts +13 -1
  11. package/src/firebase/crud/aggregate.ts +20 -5
  12. package/src/firebase/crud/create.ts +31 -7
  13. package/src/firebase/crud/list.ts +36 -24
  14. package/src/firebase/crud/update.ts +29 -7
  15. package/src/firebase/oauth/exchangeToken.ts +30 -4
  16. package/src/firebase/oauth/githubAccess.ts +8 -3
  17. package/src/firebase/registerCrudFunctions.ts +2 -2
  18. package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
  19. package/src/shared/__tests__/detectFirestore.test.ts +52 -0
  20. package/src/shared/__tests__/errorHandling.test.ts +144 -0
  21. package/src/shared/__tests__/idempotency.test.ts +95 -0
  22. package/src/shared/__tests__/rateLimiter.test.ts +142 -0
  23. package/src/shared/__tests__/validation.test.ts +172 -0
  24. package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
  25. package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
  26. package/src/shared/billing/webhookHandler.ts +16 -7
  27. package/src/shared/errorHandling.ts +22 -60
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/index.ts +2 -1
  30. package/src/shared/logger.ts +3 -7
  31. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  32. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  33. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  34. package/src/shared/utils/external/subscription.ts +12 -2
  35. package/src/shared/utils/internal/auth.ts +140 -16
  36. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  37. package/src/shared/utils/internal/validation.ts +47 -3
  38. package/src/shared/utils.ts +170 -66
  39. package/src/supabase/auth/deleteAccount.ts +52 -0
  40. package/src/supabase/auth/getCustomClaims.ts +56 -0
  41. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  42. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  43. package/src/supabase/auth/setCustomClaims.ts +73 -0
  44. package/src/supabase/baseFunction.ts +306 -0
  45. package/src/supabase/billing/cancelSubscription.ts +57 -0
  46. package/src/supabase/billing/changePlan.ts +62 -0
  47. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  48. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  49. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  50. package/src/supabase/crud/aggregate.ts +169 -0
  51. package/src/supabase/crud/create.ts +225 -0
  52. package/src/supabase/crud/delete.ts +154 -0
  53. package/src/supabase/crud/get.ts +89 -0
  54. package/src/supabase/crud/index.ts +24 -0
  55. package/src/supabase/crud/list.ts +388 -0
  56. package/src/supabase/crud/update.ts +199 -0
  57. package/src/supabase/helpers/authProvider.ts +45 -0
  58. package/src/supabase/index.ts +73 -0
  59. package/src/supabase/registerCrudFunctions.ts +180 -0
  60. package/src/supabase/utils/idempotency.ts +141 -0
  61. package/src/supabase/utils/monitoring.ts +187 -0
  62. package/src/supabase/utils/rateLimiter.ts +216 -0
  63. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  64. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  65. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  66. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  67. package/src/vercel/api/billing/cancel.ts +2 -1
  68. package/src/vercel/api/billing/change-plan.ts +3 -1
  69. package/src/vercel/api/billing/customer-portal.ts +4 -1
  70. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  71. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  72. package/src/vercel/api/crud/create.ts +14 -8
  73. package/src/vercel/api/crud/delete.ts +15 -6
  74. package/src/vercel/api/crud/get.ts +16 -8
  75. package/src/vercel/api/crud/list.ts +22 -10
  76. package/src/vercel/api/crud/update.ts +16 -10
  77. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  78. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  79. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  80. package/src/vercel/api/utils/cors.ts +13 -2
  81. package/src/vercel/baseFunction.ts +40 -25
@@ -89,9 +89,8 @@ async function checkUniqueKeysForUpdate(
89
89
  const db = getFirebaseAdminFirestore();
90
90
 
91
91
  for (const uniqueKey of uniqueKeys) {
92
- // Skip validation for drafts if configured (default: true)
93
- const skipForDrafts = uniqueKey.skipForDrafts !== false;
94
- if (isDraft && skipForDrafts) continue;
92
+ // Skip validation for drafts only if explicitly opted in (default: false)
93
+ if (isDraft && uniqueKey.skipForDrafts === true) continue;
95
94
 
96
95
  // Check if any of the unique key fields are being updated
97
96
  const isUpdatingUniqueKeyField = uniqueKey.fields.some(
@@ -152,15 +151,38 @@ function updateEntityLogicFactory(
152
151
  const { id, payload, idempotencyKey } = data;
153
152
  const { uid } = context;
154
153
 
155
- // Idempotency check if key provided
154
+ // W17: Validate idempotency key length and content.
155
+ if (idempotencyKey !== undefined) {
156
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
157
+ throw new DoNotDevError('idempotencyKey must be a non-empty string of at most 256 characters', 'invalid-argument');
158
+ }
159
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
160
+ throw new DoNotDevError('idempotencyKey contains invalid characters', 'invalid-argument');
161
+ }
162
+ }
163
+
164
+ // C9: Atomic idempotency check — reserve key in a transaction to eliminate TOCTOU race.
156
165
  if (idempotencyKey) {
157
166
  const db = getFirebaseAdminFirestore();
158
167
  const idempotencyRef = db
159
168
  .collection('idempotency')
160
169
  .doc(`update_${idempotencyKey}`);
161
- const idempotencyDoc = await idempotencyRef.get();
162
- if (idempotencyDoc.exists) {
163
- return idempotencyDoc.data()?.result;
170
+
171
+ let existingResult: unknown = undefined;
172
+ let alreadyProcessed = false;
173
+
174
+ await db.runTransaction(async (tx) => {
175
+ const idempotencyDoc = await tx.get(idempotencyRef);
176
+ if (idempotencyDoc.exists) {
177
+ existingResult = idempotencyDoc.data()?.result;
178
+ alreadyProcessed = true;
179
+ return;
180
+ }
181
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
182
+ });
183
+
184
+ if (alreadyProcessed) {
185
+ return existingResult;
164
186
  }
165
187
  }
166
188
 
@@ -37,15 +37,41 @@ export const exchangeToken = onCall<ExchangeTokenRequest>(async (request) => {
37
37
  const { provider, purpose, code, redirectUri, codeVerifier, idempotencyKey } =
38
38
  request.data;
39
39
 
40
- // Idempotency check if key provided
40
+ // W17: Validate idempotency key length and content.
41
+ if (idempotencyKey !== undefined) {
42
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
43
+ throw new HttpsError('invalid-argument', 'idempotencyKey must be a non-empty string of at most 256 characters');
44
+ }
45
+ if (!/^[\w\-.:@]+$/.test(idempotencyKey)) {
46
+ throw new HttpsError('invalid-argument', 'idempotencyKey contains invalid characters');
47
+ }
48
+ }
49
+
50
+ // C9: Atomic idempotency check using Firestore transaction to prevent TOCTOU race.
51
+ // A concurrent request with the same key would see the 'pending' sentinel and wait
52
+ // or return early instead of executing duplicate business logic.
41
53
  if (idempotencyKey) {
42
54
  const db = getFirebaseAdminFirestore();
43
55
  const idempotencyRef = db
44
56
  .collection('idempotency')
45
57
  .doc(`oauth_${idempotencyKey}`);
46
- const idempotencyDoc = await idempotencyRef.get();
47
- if (idempotencyDoc.exists) {
48
- return idempotencyDoc.data()?.result;
58
+
59
+ let existingResult: unknown = undefined;
60
+ let alreadyProcessed = false;
61
+
62
+ await db.runTransaction(async (tx) => {
63
+ const idempotencyDoc = await tx.get(idempotencyRef);
64
+ if (idempotencyDoc.exists) {
65
+ existingResult = idempotencyDoc.data()?.result;
66
+ alreadyProcessed = true;
67
+ return;
68
+ }
69
+ // Reserve the key before executing business logic
70
+ tx.set(idempotencyRef, { processing: true, reservedAt: new Date().toISOString() });
71
+ });
72
+
73
+ if (alreadyProcessed) {
74
+ return existingResult;
49
75
  }
50
76
  }
51
77
 
@@ -68,12 +68,13 @@ async function grantGitHubAccessInternal(
68
68
 
69
69
  const validatedData = validationResult.output;
70
70
  const {
71
- userId,
72
71
  githubUsername,
73
72
  repoConfig,
74
73
  permission = 'push',
75
74
  customClaims,
76
75
  } = validatedData;
76
+ // W20: Enforce authenticated user ID — never trust client-supplied userId
77
+ const userId = uid;
77
78
 
78
79
  // Get GitHub token
79
80
  const githubToken = githubPersonalAccessToken.value();
@@ -206,7 +207,9 @@ async function revokeGitHubAccessInternal(
206
207
  );
207
208
  }
208
209
 
209
- const { userId, githubUsername, repoConfig } = validationResult.output;
210
+ const { githubUsername, repoConfig } = validationResult.output;
211
+ // W20: Enforce authenticated user ID — never trust client-supplied userId
212
+ const userId = uid;
210
213
 
211
214
  // Get GitHub token
212
215
  const githubToken = githubPersonalAccessToken.value();
@@ -335,7 +338,9 @@ async function checkGitHubAccessInternal(
335
338
  );
336
339
  }
337
340
 
338
- const { userId, githubUsername, repoConfig } = validationResult.output;
341
+ const { githubUsername, repoConfig } = validationResult.output;
342
+ // W20: Enforce authenticated user ID — never trust client-supplied userId
343
+ const userId = uid;
339
344
 
340
345
  // Get GitHub token
341
346
  const githubToken = githubPersonalAccessToken.value();
@@ -9,7 +9,7 @@
9
9
  * @author AMBROISE PARK Consulting
10
10
  */
11
11
 
12
- import { createSchemas } from '@donotdev/core/server';
12
+ import { createSchemas, getListCardFieldNames } from '@donotdev/core/server';
13
13
  import type { Entity } from '@donotdev/core/server';
14
14
 
15
15
  import {
@@ -91,7 +91,7 @@ export function createCrudFunctions(
91
91
  schemas.get,
92
92
  access.read,
93
93
  undefined,
94
- entity.listCardFields ?? entity.listFields ?? undefined,
94
+ getListCardFieldNames(entity),
95
95
  entity.ownership,
96
96
  true
97
97
  );
@@ -21,7 +21,16 @@ import { handleError } from '../../shared/errorHandling.js';
21
21
 
22
22
  /**
23
23
  * Scheduled function that runs daily to check for expired subscriptions
24
- * and update custom claims with expired status
24
+ * and update custom claims with expired status.
25
+ *
26
+ * **Architecture decision — opt-in scheduled function:**
27
+ * This function is not automatically deployed. Consumer apps opt in by
28
+ * exporting it from their functions entry point (e.g. `index.ts`). If not
29
+ * exported, Firebase never schedules it.
30
+ *
31
+ * The `users` collection path defaults to `'users'` but is configurable via
32
+ * the `USERS_COLLECTION_PATH` environment variable, allowing consumers to
33
+ * use their own collection structure (e.g. `'app_users'`, `'tenants/{id}/users'`).
25
34
  */
26
35
  export const checkExpiredSubscriptions = onSchedule(
27
36
  {
@@ -36,9 +45,17 @@ export const checkExpiredSubscriptions = onSchedule(
36
45
  const db = getFirebaseAdminFirestore();
37
46
  const now = new Date();
38
47
 
48
+ // W18: The 'users' collection name is configurable. Consumer apps may use
49
+ // a different collection. Default to 'users' but respect
50
+ // USERS_COLLECTION_PATH env var if set.
51
+ const usersCollection = process.env.USERS_COLLECTION_PATH || 'users';
52
+
53
+ // Guard: if the collection doesn't exist the query returns empty, which
54
+ // is safe. Log a warning so operators know if misconfigured.
55
+ const collectionRef = db.collection(usersCollection);
56
+
39
57
  // Get all users with active subscriptions
40
- const usersSnapshot = await db
41
- .collection('users')
58
+ const usersSnapshot = await collectionRef
42
59
  .where('subscription.status', '==', 'active')
43
60
  .get();
44
61
 
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ import { isFirestoreConfigured } from '../utils/detectFirestore';
4
+
5
+ describe('isFirestoreConfigured', () => {
6
+ const originalEnv = { ...process.env };
7
+
8
+ beforeEach(() => {
9
+ // Clear all relevant env vars
10
+ delete process.env.FUNCTION_NAME;
11
+ delete process.env.FIREBASE_CONFIG;
12
+ delete process.env.FIREBASE_PROJECT_ID;
13
+ delete process.env.FIREBASE_CLIENT_EMAIL;
14
+ delete process.env.FIREBASE_PRIVATE_KEY;
15
+ });
16
+
17
+ afterEach(() => {
18
+ // Restore original env
19
+ process.env = { ...originalEnv };
20
+ });
21
+
22
+ it('returns true when FUNCTION_NAME is set (Firebase Functions runtime)', () => {
23
+ process.env.FUNCTION_NAME = 'myFunction';
24
+
25
+ expect(isFirestoreConfigured()).toBe(true);
26
+ });
27
+
28
+ it('returns true when FIREBASE_CONFIG is set', () => {
29
+ process.env.FIREBASE_CONFIG = '{"projectId":"test"}';
30
+
31
+ expect(isFirestoreConfigured()).toBe(true);
32
+ });
33
+
34
+ it('returns true when all manual credentials are set', () => {
35
+ process.env.FIREBASE_PROJECT_ID = 'my-project';
36
+ process.env.FIREBASE_CLIENT_EMAIL = 'sa@project.iam.gserviceaccount.com';
37
+ process.env.FIREBASE_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\n...';
38
+
39
+ expect(isFirestoreConfigured()).toBe(true);
40
+ });
41
+
42
+ it('returns false when only partial credentials are set', () => {
43
+ process.env.FIREBASE_PROJECT_ID = 'my-project';
44
+ // Missing CLIENT_EMAIL and PRIVATE_KEY
45
+
46
+ expect(isFirestoreConfigured()).toBe(false);
47
+ });
48
+
49
+ it('returns false when no env vars are set', () => {
50
+ expect(isFirestoreConfigured()).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // Mock firebase-functions
4
+ vi.mock('firebase-functions/v2/https', () => ({
5
+ HttpsError: class HttpsError extends Error {
6
+ code: string;
7
+ details: any;
8
+ constructor(code: string, message: string, details?: any) {
9
+ super(message);
10
+ this.code = code;
11
+ this.details = details;
12
+ this.name = 'HttpsError';
13
+ }
14
+ },
15
+ }));
16
+
17
+ vi.mock('firebase-functions/v2', () => ({
18
+ logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), log: vi.fn() },
19
+ }));
20
+
21
+ vi.mock('@donotdev/firebase/server', () => ({
22
+ getFirebaseAdminFirestore: vi.fn(),
23
+ }));
24
+
25
+ import { handleError, DoNotDevError } from '../errorHandling';
26
+
27
+ describe('DoNotDevError (functions)', () => {
28
+ it('creates with default code "unknown"', () => {
29
+ const error = new DoNotDevError('test');
30
+
31
+ expect(error.code).toBe('unknown');
32
+ expect(error.message).toBe('test');
33
+ expect(error.name).toBe('DoNotDevError');
34
+ });
35
+
36
+ it('creates with explicit code and details', () => {
37
+ const error = new DoNotDevError('bad input', 'invalid-argument', {
38
+ field: 'email',
39
+ });
40
+
41
+ expect(error.code).toBe('invalid-argument');
42
+ expect(error.details).toEqual({ field: 'email' });
43
+ });
44
+
45
+ it('toString formats correctly', () => {
46
+ const error = new DoNotDevError('test', 'not-found');
47
+
48
+ expect(error.toString()).toBe('DoNotDevError [not-found]: test');
49
+ });
50
+
51
+ it('toJSON includes all fields', () => {
52
+ const error = new DoNotDevError('test', 'internal', { key: 'val' });
53
+ const json = error.toJSON();
54
+
55
+ expect(json).toEqual({
56
+ name: 'DoNotDevError',
57
+ code: 'internal',
58
+ message: 'test',
59
+ details: { key: 'val' },
60
+ });
61
+ });
62
+
63
+ it('instanceof chain works', () => {
64
+ const error = new DoNotDevError('test');
65
+
66
+ expect(error instanceof DoNotDevError).toBe(true);
67
+ expect(error instanceof Error).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('handleError', () => {
72
+ const originalEnv = { ...process.env };
73
+
74
+ beforeEach(() => {
75
+ vi.spyOn(console, 'error').mockImplementation(() => {});
76
+ // Clear env to force fallback (no Firebase, no Vercel)
77
+ delete process.env.FUNCTIONS_EMULATOR;
78
+ delete process.env.FIREBASE_CONFIG;
79
+ delete process.env.GCLOUD_PROJECT;
80
+ delete process.env.VERCEL;
81
+ delete process.env.VERCEL_ENV;
82
+ });
83
+
84
+ afterEach(() => {
85
+ process.env = { ...originalEnv };
86
+ });
87
+
88
+ it('throws for DoNotDevError (fallback env)', () => {
89
+ const original = new DoNotDevError('Bad', 'invalid-argument');
90
+
91
+ expect(() => handleError(original)).toThrow('invalid-argument: Bad');
92
+ });
93
+
94
+ it('throws for generic Error (fallback env)', () => {
95
+ const original = new Error('Something broke');
96
+
97
+ expect(() => handleError(original)).toThrow('internal: Something broke');
98
+ });
99
+
100
+ it('throws for string error (fallback env)', () => {
101
+ expect(() => handleError('random string')).toThrow(
102
+ 'internal: An unexpected error occurred'
103
+ );
104
+ });
105
+
106
+ it('throws HttpsError in Firebase environment', () => {
107
+ process.env.FIREBASE_CONFIG = '{}';
108
+
109
+ try {
110
+ handleError(new DoNotDevError('test', 'not-found'));
111
+ expect.fail('should have thrown');
112
+ } catch (error: any) {
113
+ expect(error.name).toBe('HttpsError');
114
+ expect(error.code).toBe('not-found');
115
+ }
116
+ });
117
+
118
+ it('maps EntityHookError types correctly', () => {
119
+ // Simulate EntityHookError (name-based detection)
120
+ const entityError = new Error('Permission denied');
121
+ (entityError as any).name = 'EntityHookError';
122
+ (entityError as any).type = 'PERMISSION_DENIED';
123
+
124
+ try {
125
+ handleError(entityError);
126
+ expect.fail('should have thrown');
127
+ } catch (error: any) {
128
+ expect(error.message).toContain('permission-denied');
129
+ }
130
+ });
131
+
132
+ it('maps ValiError to invalid-argument', () => {
133
+ const valiError = new Error('Validation failed');
134
+ (valiError as any).name = 'ValiError';
135
+ (valiError as any).issues = [{ path: [], message: 'required' }];
136
+
137
+ try {
138
+ handleError(valiError);
139
+ expect.fail('should have thrown');
140
+ } catch (error: any) {
141
+ expect(error.message).toContain('invalid-argument');
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock detectFirestore to return false (force in-memory)
4
+ vi.mock('../utils/detectFirestore.js', () => ({
5
+ isFirestoreConfigured: vi.fn(() => false),
6
+ }));
7
+
8
+ import {
9
+ createIdempotencyStore,
10
+ resetIdempotencyStore,
11
+ } from '../billing/idempotency';
12
+
13
+ describe('InMemoryIdempotency', () => {
14
+ beforeEach(() => {
15
+ resetIdempotencyStore();
16
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
17
+ vi.spyOn(console, 'log').mockImplementation(() => {});
18
+ });
19
+
20
+ it('creates in-memory store when Firestore not configured', () => {
21
+ const store = createIdempotencyStore();
22
+
23
+ expect(store).toBeDefined();
24
+ expect(typeof store.isProcessed).toBe('function');
25
+ expect(typeof store.markProcessed).toBe('function');
26
+ });
27
+
28
+ it('returns singleton instance', () => {
29
+ const store1 = createIdempotencyStore();
30
+ const store2 = createIdempotencyStore();
31
+
32
+ expect(store1).toBe(store2);
33
+ });
34
+
35
+ it('reports event as not processed initially', async () => {
36
+ const store = createIdempotencyStore();
37
+
38
+ const result = await store.isProcessed('evt_001');
39
+ expect(result).toBe(false);
40
+ });
41
+
42
+ it('reports event as processed after marking', async () => {
43
+ const store = createIdempotencyStore();
44
+
45
+ await store.markProcessed('evt_002');
46
+ const result = await store.isProcessed('evt_002');
47
+
48
+ expect(result).toBe(true);
49
+ });
50
+
51
+ it('distinguishes between different events', async () => {
52
+ const store = createIdempotencyStore();
53
+
54
+ await store.markProcessed('evt_a');
55
+
56
+ expect(await store.isProcessed('evt_a')).toBe(true);
57
+ expect(await store.isProcessed('evt_b')).toBe(false);
58
+ });
59
+
60
+ it('warns once on first isProcessed call', async () => {
61
+ const warnSpy = vi.spyOn(console, 'warn');
62
+ const store = createIdempotencyStore();
63
+
64
+ await store.isProcessed('evt_x');
65
+ await store.isProcessed('evt_y');
66
+
67
+ // Should warn at least once about in-memory usage
68
+ const inMemoryWarns = warnSpy.mock.calls.filter(
69
+ (call) => typeof call[0] === 'string' && call[0].includes('in-memory')
70
+ );
71
+ expect(inMemoryWarns.length).toBeGreaterThanOrEqual(1);
72
+ });
73
+
74
+ it('auto-cleans after 1000 entries (keeps last entries)', async () => {
75
+ const store = createIdempotencyStore();
76
+
77
+ // Add 1001 entries
78
+ for (let i = 0; i < 1001; i++) {
79
+ await store.markProcessed(`evt_${i}`);
80
+ }
81
+
82
+ // First entry should have been evicted
83
+ expect(await store.isProcessed('evt_0')).toBe(false);
84
+ // Recent entry should still exist
85
+ expect(await store.isProcessed('evt_1000')).toBe(true);
86
+ });
87
+
88
+ it('resetIdempotencyStore clears singleton', () => {
89
+ const store1 = createIdempotencyStore();
90
+ resetIdempotencyStore();
91
+ const store2 = createIdempotencyStore();
92
+
93
+ expect(store1).not.toBe(store2);
94
+ });
95
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // Mock firebase-functions/v2 logger
4
+ vi.mock('firebase-functions/v2', () => ({
5
+ logger: {
6
+ warn: vi.fn(),
7
+ info: vi.fn(),
8
+ error: vi.fn(),
9
+ log: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ // Mock @donotdev/firebase/server (not used in in-memory path, but imported)
14
+ vi.mock('@donotdev/firebase/server', () => ({
15
+ getFirebaseAdminFirestore: vi.fn(),
16
+ }));
17
+
18
+ import {
19
+ checkRateLimit,
20
+ resetRateLimit,
21
+ getRateLimitStatus,
22
+ DEFAULT_RATE_LIMITS,
23
+ } from '../utils/internal/rateLimiter';
24
+
25
+ const TEST_CONFIG = {
26
+ maxAttempts: 3,
27
+ windowMs: 60_000,
28
+ blockDurationMs: 300_000,
29
+ };
30
+
31
+ describe('checkRateLimit (in-memory)', () => {
32
+ beforeEach(() => {
33
+ // Reset rate limit store between tests
34
+ resetRateLimit('test-key');
35
+ resetRateLimit('other-key');
36
+ });
37
+
38
+ it('allows first request', async () => {
39
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
40
+
41
+ expect(result.allowed).toBe(true);
42
+ expect(result.remaining).toBe(2); // 3 max - 1 used
43
+ expect(result.blockRemainingSeconds).toBeNull();
44
+ });
45
+
46
+ it('decrements remaining on each request', async () => {
47
+ await checkRateLimit('test-key', TEST_CONFIG);
48
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
49
+
50
+ expect(result.allowed).toBe(true);
51
+ expect(result.remaining).toBe(1); // 3 max - 2 used
52
+ });
53
+
54
+ it('blocks after max attempts exceeded', async () => {
55
+ // Use all 3 attempts
56
+ await checkRateLimit('test-key', TEST_CONFIG);
57
+ await checkRateLimit('test-key', TEST_CONFIG);
58
+ await checkRateLimit('test-key', TEST_CONFIG);
59
+
60
+ // 4th request should be blocked
61
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
62
+
63
+ expect(result.allowed).toBe(false);
64
+ expect(result.remaining).toBe(0);
65
+ expect(result.blockRemainingSeconds).toBeGreaterThan(0);
66
+ expect(result.resetAt).toBeInstanceOf(Date);
67
+ });
68
+
69
+ it('isolates keys from each other', async () => {
70
+ await checkRateLimit('test-key', TEST_CONFIG);
71
+ await checkRateLimit('test-key', TEST_CONFIG);
72
+ await checkRateLimit('test-key', TEST_CONFIG);
73
+
74
+ // Different key should still be allowed
75
+ const result = await checkRateLimit('other-key', TEST_CONFIG);
76
+ expect(result.allowed).toBe(true);
77
+ expect(result.remaining).toBe(2);
78
+ });
79
+ });
80
+
81
+ describe('resetRateLimit', () => {
82
+ it('clears rate limit allowing new requests', async () => {
83
+ // Exhaust attempts
84
+ await checkRateLimit('test-key', TEST_CONFIG);
85
+ await checkRateLimit('test-key', TEST_CONFIG);
86
+ await checkRateLimit('test-key', TEST_CONFIG);
87
+
88
+ // Reset
89
+ resetRateLimit('test-key');
90
+
91
+ // Should be allowed again
92
+ const result = await checkRateLimit('test-key', TEST_CONFIG);
93
+ expect(result.allowed).toBe(true);
94
+ expect(result.remaining).toBe(2);
95
+ });
96
+ });
97
+
98
+ describe('getRateLimitStatus', () => {
99
+ beforeEach(() => {
100
+ resetRateLimit('test-key');
101
+ });
102
+
103
+ it('returns full remaining for unknown key', () => {
104
+ const result = getRateLimitStatus('unknown-key', TEST_CONFIG);
105
+
106
+ expect(result.allowed).toBe(true);
107
+ expect(result.remaining).toBe(3);
108
+ expect(result.resetAt).toBeNull();
109
+ expect(result.blockRemainingSeconds).toBeNull();
110
+ });
111
+
112
+ it('reflects current usage without consuming attempt', async () => {
113
+ await checkRateLimit('test-key', TEST_CONFIG);
114
+ await checkRateLimit('test-key', TEST_CONFIG);
115
+
116
+ const status = getRateLimitStatus('test-key', TEST_CONFIG);
117
+ expect(status.remaining).toBe(1);
118
+
119
+ // Check again — should be same (no attempt consumed)
120
+ const status2 = getRateLimitStatus('test-key', TEST_CONFIG);
121
+ expect(status2.remaining).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe('DEFAULT_RATE_LIMITS', () => {
126
+ it('has checkout config', () => {
127
+ expect(DEFAULT_RATE_LIMITS.checkout.maxAttempts).toBe(5);
128
+ expect(DEFAULT_RATE_LIMITS.checkout.windowMs).toBe(60_000);
129
+ });
130
+
131
+ it('has webhook config', () => {
132
+ expect(DEFAULT_RATE_LIMITS.webhook.maxAttempts).toBe(100);
133
+ });
134
+
135
+ it('has auth config', () => {
136
+ expect(DEFAULT_RATE_LIMITS.auth.maxAttempts).toBe(10);
137
+ });
138
+
139
+ it('has api config', () => {
140
+ expect(DEFAULT_RATE_LIMITS.api.maxAttempts).toBe(100);
141
+ });
142
+ });