@donotdev/functions 0.0.9 → 0.0.11

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 (80) hide show
  1. package/package.json +31 -7
  2. package/src/firebase/auth/setCustomClaims.ts +26 -4
  3. package/src/firebase/baseFunction.ts +43 -20
  4. package/src/firebase/billing/cancelSubscription.ts +9 -1
  5. package/src/firebase/billing/changePlan.ts +8 -2
  6. package/src/firebase/billing/createCustomerPortal.ts +16 -2
  7. package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
  8. package/src/firebase/billing/webhookHandler.ts +13 -1
  9. package/src/firebase/config/constants.ts +0 -3
  10. package/src/firebase/crud/aggregate.ts +20 -5
  11. package/src/firebase/crud/create.ts +31 -7
  12. package/src/firebase/crud/get.ts +16 -8
  13. package/src/firebase/crud/list.ts +70 -29
  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 +15 -4
  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 +16 -54
  28. package/src/shared/firebase.ts +1 -25
  29. package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
  30. package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
  31. package/src/shared/schema.ts +7 -1
  32. package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
  33. package/src/shared/utils/external/subscription.ts +10 -0
  34. package/src/shared/utils/internal/auth.ts +140 -16
  35. package/src/shared/utils/internal/rateLimiter.ts +101 -90
  36. package/src/shared/utils/internal/validation.ts +47 -3
  37. package/src/shared/utils.ts +154 -39
  38. package/src/supabase/auth/deleteAccount.ts +59 -0
  39. package/src/supabase/auth/getCustomClaims.ts +56 -0
  40. package/src/supabase/auth/getUserAuthStatus.ts +64 -0
  41. package/src/supabase/auth/removeCustomClaims.ts +75 -0
  42. package/src/supabase/auth/setCustomClaims.ts +73 -0
  43. package/src/supabase/baseFunction.ts +302 -0
  44. package/src/supabase/billing/cancelSubscription.ts +57 -0
  45. package/src/supabase/billing/changePlan.ts +62 -0
  46. package/src/supabase/billing/createCheckoutSession.ts +82 -0
  47. package/src/supabase/billing/createCustomerPortal.ts +58 -0
  48. package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
  49. package/src/supabase/crud/aggregate.ts +169 -0
  50. package/src/supabase/crud/create.ts +225 -0
  51. package/src/supabase/crud/delete.ts +154 -0
  52. package/src/supabase/crud/get.ts +89 -0
  53. package/src/supabase/crud/index.ts +24 -0
  54. package/src/supabase/crud/list.ts +357 -0
  55. package/src/supabase/crud/update.ts +199 -0
  56. package/src/supabase/helpers/authProvider.ts +45 -0
  57. package/src/supabase/index.ts +73 -0
  58. package/src/supabase/registerCrudFunctions.ts +180 -0
  59. package/src/supabase/utils/idempotency.ts +141 -0
  60. package/src/supabase/utils/monitoring.ts +187 -0
  61. package/src/supabase/utils/rateLimiter.ts +216 -0
  62. package/src/vercel/api/auth/get-custom-claims.ts +3 -2
  63. package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
  64. package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
  65. package/src/vercel/api/auth/set-custom-claims.ts +5 -2
  66. package/src/vercel/api/billing/cancel.ts +2 -1
  67. package/src/vercel/api/billing/change-plan.ts +3 -1
  68. package/src/vercel/api/billing/customer-portal.ts +4 -1
  69. package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
  70. package/src/vercel/api/billing/webhook-handler.ts +24 -4
  71. package/src/vercel/api/crud/create.ts +14 -8
  72. package/src/vercel/api/crud/delete.ts +15 -6
  73. package/src/vercel/api/crud/get.ts +16 -8
  74. package/src/vercel/api/crud/list.ts +22 -10
  75. package/src/vercel/api/crud/update.ts +16 -10
  76. package/src/vercel/api/oauth/check-github-access.ts +2 -5
  77. package/src/vercel/api/oauth/grant-github-access.ts +1 -5
  78. package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
  79. package/src/vercel/api/utils/cors.ts +13 -2
  80. package/src/vercel/baseFunction.ts +40 -25
@@ -16,7 +16,11 @@ import {
16
16
  hasRoleAccess,
17
17
  HIDDEN_STATUSES,
18
18
  } from '@donotdev/core/server';
19
- import type { UserRole } from '@donotdev/core/server';
19
+ import type {
20
+ EntityOwnershipConfig,
21
+ EntityOwnershipPublicCondition,
22
+ UserRole,
23
+ } from '@donotdev/core/server';
20
24
  import { getFirebaseAdminFirestore, Query } from '@donotdev/firebase/server';
21
25
 
22
26
  import { transformFirestoreData } from '../../shared/index.js';
@@ -40,6 +44,20 @@ export interface ListEntityRequest {
40
44
  };
41
45
  }
42
46
 
47
+ /**
48
+ * Apply public-condition where clauses to a query (for listCard when ownership is set).
49
+ */
50
+ function applyPublicCondition(
51
+ query: Query,
52
+ publicCondition: EntityOwnershipPublicCondition[]
53
+ ): Query {
54
+ let q = query;
55
+ for (const c of publicCondition) {
56
+ q = q.where(c.field, c.op as any, c.value);
57
+ }
58
+ return q;
59
+ }
60
+
43
61
  /**
44
62
  * Generic business logic for listing entities
45
63
  * Base function handles: validation, auth, rate limiting, monitoring
@@ -47,7 +65,9 @@ export interface ListEntityRequest {
47
65
  function listEntitiesLogicFactory(
48
66
  collection: string,
49
67
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
50
- listFields?: string[]
68
+ listFields?: string[],
69
+ ownership?: EntityOwnershipConfig,
70
+ isListCard?: boolean
51
71
  ) {
52
72
  return async function listEntitiesLogic(
53
73
  data: ListEntityRequest,
@@ -58,7 +78,8 @@ function listEntitiesLogicFactory(
58
78
  }
59
79
  ) {
60
80
  const { where = [], orderBy = [], limit, startAfterId, search } = data;
61
- const { userRole } = context;
81
+ // Extract uid and userRole once (reused for all document masking)
82
+ const { userRole, uid } = context;
62
83
 
63
84
  const isAdmin = hasRoleAccess(userRole, 'admin'); // Uses role hierarchy
64
85
 
@@ -71,6 +92,26 @@ function listEntitiesLogicFactory(
71
92
  query = query.where('status', 'not-in', [...HIDDEN_STATUSES]);
72
93
  }
73
94
 
95
+ // Ownership: when set and not admin, listCard = public condition, list = mine filter
96
+ if (ownership && !isAdmin) {
97
+ if (
98
+ isListCard &&
99
+ ownership.publicCondition &&
100
+ ownership.publicCondition.length > 0
101
+ ) {
102
+ query = applyPublicCondition(query, ownership.publicCondition);
103
+ } else if (!isListCard && ownership.ownerFields.length > 0) {
104
+ // "Mine" filter: Firestore does not support OR across different fields in one query.
105
+ // Use first owner field only for single query; recommend ownerIds array-contains for multiple.
106
+ const firstOwnerField = ownership.ownerFields[0];
107
+ query = query.where(firstOwnerField, '==', uid);
108
+ if (ownership.ownerFields.length > 1) {
109
+ // Optional: run second query and merge (complex pagination). For now, single field only.
110
+ // Document ownerIds pattern for multiple stakeholders.
111
+ }
112
+ }
113
+ }
114
+
74
115
  // Apply search if provided
75
116
  if (search) {
76
117
  const { field, query: searchQuery } = search;
@@ -103,31 +144,16 @@ function listEntitiesLogicFactory(
103
144
  query = query.startAfter(startAfterDoc);
104
145
  }
105
146
 
106
- // Apply limit only if provided (no limit = fetch all)
107
- if (limit !== undefined && limit > 0) {
108
- query = query.limit(limit);
109
- }
110
-
111
- // DEBUG: Log query details
112
- console.log(
113
- `[listEntities] Collection: ${collection}, UserRole: ${userRole}, IsAdmin: ${isAdmin}`
114
- );
115
- console.log(
116
- `[listEntities] Filters - where: ${JSON.stringify(where)}, orderBy: ${JSON.stringify(orderBy)}, limit: ${limit}`
117
- );
147
+ // W13: Cap at MAX_LIST_LIMIT to prevent unbounded reads (DoS via cost amplification).
148
+ const MAX_LIST_LIMIT = 500;
149
+ const effectiveLimit = limit !== undefined && limit > 0
150
+ ? Math.min(limit, MAX_LIST_LIMIT)
151
+ : MAX_LIST_LIMIT;
152
+ query = query.limit(effectiveLimit);
118
153
 
119
154
  // Execute the query
120
155
  const snapshot = await query.get();
121
156
 
122
- // DEBUG: Log result count
123
- console.log(
124
- `[listEntities] Query returned ${snapshot.docs.length} documents`
125
- );
126
-
127
- // DEBUG: Log schema info
128
- const schemaHasEntries = !!(documentSchema as any)?.entries;
129
- console.log(`[listEntities] Schema has entries: ${schemaHasEntries}`);
130
-
131
157
  // Helper: Check if value is a Picture object (has thumbUrl and fullUrl)
132
158
  const isPictureObject = (value: any): boolean => {
133
159
  return (
@@ -158,13 +184,18 @@ function listEntitiesLogicFactory(
158
184
  return value;
159
185
  };
160
186
 
161
- // Filter document fields based on visibility and user role
187
+ const visibilityOptions = ownership && uid ? { uid, ownership } : undefined;
188
+
189
+ // Filter document fields based on visibility and user role (uid/userRole from context, once)
162
190
  const docs = snapshot.docs.map((doc: any) => {
163
191
  const rawData = doc.data() || {};
164
192
  const visibleData = filterVisibleFields(
165
193
  rawData,
166
194
  documentSchema,
167
- userRole
195
+ userRole,
196
+ visibilityOptions
197
+ ? { ...visibilityOptions, documentData: rawData }
198
+ : undefined
168
199
  );
169
200
 
170
201
  // If listFields specified, filter to only those fields (plus id always)
@@ -206,6 +237,8 @@ function listEntitiesLogicFactory(
206
237
  * @param requiredRole - Minimum role required for this operation
207
238
  * @param customSchema - Optional custom request schema
208
239
  * @param listFields - Optional array of field names to include (plus id). If not provided, all visible fields are returned.
240
+ * @param ownership - Optional ownership config for list constraints and visibility: 'owner' masking
241
+ * @param isListCard - When true and ownership is set, applies public condition; when false, applies "mine" filter
209
242
  * @returns Firebase callable function
210
243
  */
211
244
  export const listEntities = (
@@ -213,7 +246,9 @@ export const listEntities = (
213
246
  documentSchema: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
214
247
  requiredRole: UserRole,
215
248
  customSchema?: v.BaseSchema<unknown, any, v.BaseIssue<unknown>>,
216
- listFields?: string[]
249
+ listFields?: string[],
250
+ ownership?: EntityOwnershipConfig,
251
+ isListCard?: boolean
217
252
  ): CallableFunction<ListEntityRequest, Promise<any>> => {
218
253
  const requestSchema =
219
254
  customSchema ||
@@ -222,7 +257,7 @@ export const listEntities = (
222
257
  orderBy: v.optional(
223
258
  v.array(v.tuple([v.string(), v.picklist(['asc', 'desc'])]))
224
259
  ),
225
- limit: v.optional(v.pipe(v.number(), v.minValue(1))),
260
+ limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(500))),
226
261
  startAfterId: v.optional(v.string()),
227
262
  search: v.optional(
228
263
  v.object({
@@ -236,7 +271,13 @@ export const listEntities = (
236
271
  CRUD_READ_CONFIG,
237
272
  requestSchema,
238
273
  'list_entities',
239
- listEntitiesLogicFactory(collection, documentSchema, listFields),
274
+ listEntitiesLogicFactory(
275
+ collection,
276
+ documentSchema,
277
+ listFields,
278
+ ownership,
279
+ isListCard
280
+ ),
240
281
  requiredRole
241
282
  );
242
283
  };
@@ -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 {
@@ -67,14 +67,23 @@ export function createCrudFunctions(
67
67
  schemas.create,
68
68
  access.create
69
69
  );
70
- functions[`${prefix}get_${col}`] = getEntity(col, schemas.get, access.read);
70
+ functions[`${prefix}get_${col}`] = getEntity(
71
+ col,
72
+ schemas.get,
73
+ access.read,
74
+ undefined,
75
+ entity.ownership
76
+ );
71
77
  // Use schemas.get for visibility filtering, entity.listFields for field selection
78
+ // When ownership is set: list = "mine" filter, listCard = public condition
72
79
  functions[`${prefix}list_${col}`] = listEntities(
73
80
  col,
74
81
  schemas.get,
75
82
  access.read,
76
83
  undefined,
77
- entity.listFields
84
+ entity.listFields,
85
+ entity.ownership,
86
+ false
78
87
  );
79
88
  // Always create listCard - uses same schemas.get, field selection via listCardFields ?? listFields ?? undefined
80
89
  functions[`${prefix}listCard_${col}`] = listEntities(
@@ -82,7 +91,9 @@ export function createCrudFunctions(
82
91
  schemas.get,
83
92
  access.read,
84
93
  undefined,
85
- entity.listCardFields ?? entity.listFields ?? undefined
94
+ getListCardFieldNames(entity),
95
+ entity.ownership,
96
+ true
86
97
  );
87
98
  functions[`${prefix}update_${col}`] = updateEntity(
88
99
  col,
@@ -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
+ });