@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
@@ -23,14 +23,22 @@ import { logger } from '../logger.js';
23
23
  import { createIdempotencyStore } from './idempotency.js';
24
24
  import { calculateSubscriptionEndDate } from '../utils/external/date.js';
25
25
 
26
- // ✅ CREATE: Singleton idempotency store (auto-detects Firestore)
27
- const idempotencyStore = createIdempotencyStore();
28
-
29
26
  import type {
30
27
  SubscriptionData,
31
28
  AuthProvider,
32
29
  } from './helpers/updateUserSubscription.js';
33
30
 
31
+ // W7: Lazy singleton — resolving at import time reads env vars / Firebase Admin
32
+ // before they are fully injected (e.g., defineSecret values on Firebase Functions v2).
33
+ // Resolved on first use instead.
34
+ let _idempotencyStore: ReturnType<typeof createIdempotencyStore> | null = null;
35
+ function getIdempotencyStore() {
36
+ if (!_idempotencyStore) {
37
+ _idempotencyStore = createIdempotencyStore();
38
+ }
39
+ return _idempotencyStore;
40
+ }
41
+
34
42
  /**
35
43
  * Platform-agnostic auth provider interface for webhook handlers
36
44
  */
@@ -94,8 +102,8 @@ export async function processWebhook(
94
102
  operation: 'webhook_processing',
95
103
  });
96
104
 
97
- // ✅ CHECK: Idempotency (auto-detects storage)
98
- if (await idempotencyStore.isProcessed(event.id)) {
105
+ // ✅ CHECK: Idempotency (auto-detects storage, lazy init per W7)
106
+ if (await getIdempotencyStore().isProcessed(event.id)) {
99
107
  logger.info('[Webhook] Already processed', { eventId: event.id });
100
108
  return { success: true, message: 'Event already processed' };
101
109
  }
@@ -104,7 +112,7 @@ export async function processWebhook(
104
112
  await routeEvent(event, config, updateSubscription, stripe, authProvider);
105
113
 
106
114
  // ✅ MARK: Event as processed
107
- await idempotencyStore.markProcessed(event.id);
115
+ await getIdempotencyStore().markProcessed(event.id);
108
116
 
109
117
  return { success: true, message: 'Webhook processed' };
110
118
  } catch (error) {
@@ -160,7 +168,8 @@ async function routeEvent(
160
168
  break;
161
169
 
162
170
  default:
163
- console.log('[Webhook] Unhandled event type', { type: event.type });
171
+ // W23: Use structured logger instead of console.log in production
172
+ logger.debug('[Webhook] Unhandled event type', { type: event.type });
164
173
  }
165
174
  }
166
175
 
@@ -9,52 +9,12 @@
9
9
  * @author AMBROISE PARK Consulting
10
10
  */
11
11
 
12
- import { HttpsError } from 'firebase-functions/v2/https';
13
12
  import * as v from 'valibot';
14
13
 
15
- import type { ErrorCode } from '@donotdev/core/server';
16
- import { EntityHookError } from '@donotdev/core/server';
14
+ import { DoNotDevError, EntityHookError } from '@donotdev/core/server';
17
15
 
18
- /**
19
- * Custom error class for application-specific errors in functions.
20
- *
21
- * @version 0.0.1
22
- * @since 0.0.1
23
- * @author AMBROISE PARK Consulting
24
- */
25
- export class DoNotDevError extends Error {
26
- public readonly code: ErrorCode;
27
- public readonly details?: Record<string, any>;
28
-
29
- constructor(
30
- message: string,
31
- code: ErrorCode = 'unknown',
32
- details?: Record<string, any>
33
- ) {
34
- super(message);
35
- Object.setPrototypeOf(this, DoNotDevError.prototype);
36
- this.code = code;
37
- this.details = details;
38
- this.name = this.constructor.name;
39
-
40
- if (Error.captureStackTrace) {
41
- Error.captureStackTrace(this, this.constructor);
42
- }
43
- }
44
-
45
- public toString(): string {
46
- return `${this.name} [${this.code}]: ${this.message}`;
47
- }
48
-
49
- public toJSON(): object {
50
- return {
51
- name: this.name,
52
- code: this.code,
53
- message: this.message,
54
- details: this.details,
55
- };
56
- }
57
- }
16
+ // Re-export DoNotDevError so existing imports from this module continue to work
17
+ export { DoNotDevError };
58
18
 
59
19
  /**
60
20
  * Platform-aware error handling that throws in the correct format
@@ -65,8 +25,17 @@ export class DoNotDevError extends Error {
65
25
  * @since 0.0.1
66
26
  * @author AMBROISE PARK Consulting
67
27
  */
68
- export function handleError(error: unknown): never {
69
- console.error('Function error:', error);
28
+ export async function handleError(error: unknown): Promise<never> {
29
+ // W11: Log only in development to avoid double-logging with platform loggers.
30
+ // Known DoNotDevError, ValiError, and EntityHookError are handled by callers.
31
+ if (
32
+ process.env.NODE_ENV === 'development' &&
33
+ !(error instanceof DoNotDevError) &&
34
+ !(error instanceof Error && error.name === 'ValiError') &&
35
+ !(error instanceof Error && error.name === 'EntityHookError')
36
+ ) {
37
+ console.error('Function error:', error);
38
+ }
70
39
 
71
40
  // Error classification logic (same for all platforms)
72
41
  let code: string;
@@ -117,25 +86,17 @@ export function handleError(error: unknown): never {
117
86
  message = entityError.message;
118
87
  details = undefined;
119
88
  } else {
120
- // Preserve original error message for debugging
121
89
  code = 'internal';
90
+ // W10: Never expose error.stack in details — it leaks internal file paths
91
+ // and implementation details to clients. Log server-side only.
122
92
  message =
123
93
  error instanceof Error ? error.message : 'An unexpected error occurred';
124
- details = {
125
- originalError:
126
- error instanceof Error
127
- ? {
128
- name: error.name,
129
- message: error.message,
130
- stack: error.stack,
131
- }
132
- : String(error),
133
- };
94
+ details = undefined;
134
95
  }
135
96
 
136
97
  // Platform-specific error throwing
137
98
  if (isFirebaseEnvironment()) {
138
- throwFirebaseError(code, message, details);
99
+ await throwFirebaseError(code, message, details);
139
100
  } else if (isVercelEnvironment()) {
140
101
  throwVercelError(code, message, details);
141
102
  } else {
@@ -181,12 +142,13 @@ function isVercelEnvironment(): boolean {
181
142
  * @since 0.0.1
182
143
  * @author AMBROISE PARK Consulting
183
144
  */
184
- function throwFirebaseError(
145
+ async function throwFirebaseError(
185
146
  code: string,
186
147
  message: string,
187
148
  details?: any
188
- ): never {
189
- // Static import - Firebase is always available in Firebase functions
149
+ ): Promise<never> {
150
+ // Dynamic import — firebase-functions is only loaded in Firebase environments
151
+ const { HttpsError } = await import('firebase-functions/v2/https');
190
152
  throw new HttpsError(code as any, message, details);
191
153
  }
192
154
 
@@ -212,28 +212,4 @@ export function prepareForFirestore<T = any>(
212
212
 
213
213
  // Return primitive values unchanged
214
214
  return data;
215
- }
216
-
217
- /**
218
- * Checks if a string is an ISO date string
219
- * @param str - The string to check
220
- * @returns True if the string is an ISO date
221
- *
222
- * @version 0.0.1
223
- * @since 0.0.1
224
- * @author AMBROISE PARK Consulting
225
- */
226
- function isISODateString(str: string): boolean {
227
- if (typeof str !== 'string') return false;
228
-
229
- // Basic ISO date pattern check
230
- const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/;
231
- if (!isoDatePattern.test(str)) return false;
232
-
233
- // Try to parse it as a date
234
- const date = new Date(str);
235
- return (
236
- !isNaN(date.getTime()) &&
237
- date.toISOString().startsWith(str.substring(0, 19))
238
- );
239
- }
215
+ }
@@ -19,8 +19,9 @@ export * from './oauth/index.js';
19
19
  export {
20
20
  createTimestamp,
21
21
  toTimestamp,
22
- toISOString,
23
22
  isTimestamp,
24
23
  transformFirestoreData,
25
24
  prepareForFirestore,
26
25
  } from './firebase.js';
26
+ // Note: toISOString is exported from ./utils/external/date.js (handles DateValue).
27
+ // The Firebase-specific toISOString (FirestoreTimestamp only) is available via direct import from ./firebase.js.
@@ -28,10 +28,8 @@ if (typeof globalThis !== 'undefined' && 'window' in globalThis) {
28
28
  throw new Error('Server logger cannot be imported on client side');
29
29
  }
30
30
 
31
- // ServerShim: Ensure we're in a Node.js environment
32
- if (typeof process === 'undefined' || !process.versions?.node) {
33
- throw new Error('Server logger requires Node.js environment');
34
- }
31
+ // ServerShim: Warn if not in Node.js (e.g., Deno) but don't throw — allow fallback logging
32
+ const _isNodeEnv = typeof process !== 'undefined' && !!process.versions?.node;
35
33
 
36
34
  let sentryEnabled = false;
37
35
  let sentryClient: any = null;
@@ -247,9 +245,7 @@ export const logger = {
247
245
  context,
248
246
  error: context?.error,
249
247
  metadata: {
250
- pid: process.pid,
251
- nodeVersion: process.version,
252
- platform: process.platform,
248
+ ...(_isNodeEnv ? { pid: process.pid, nodeVersion: process.version, platform: process.platform } : {}),
253
249
  ...context?.metadata,
254
250
  },
255
251
  };
@@ -0,0 +1,116 @@
1
+ // packages/functions/src/shared/oauth/__tests__/exchangeToken.test.ts
2
+
3
+ /**
4
+ * @fileoverview Tests for exchangeTokenAlgorithm
5
+ * @description Unit tests using dependency injection — no real OAuth or network calls.
6
+ *
7
+ * @version 0.0.1
8
+ * @since 0.0.1
9
+ * @author AMBROISE PARK Consulting
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest';
13
+
14
+ import {
15
+ exchangeTokenAlgorithm,
16
+ type OAuthProvider,
17
+ } from '../exchangeToken';
18
+ import type { ExchangeTokenRequest, TokenResponse } from '@donotdev/core/server';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeProvider(result: TokenResponse): OAuthProvider {
25
+ return {
26
+ exchangeCodeForToken: vi.fn().mockResolvedValue(result),
27
+ };
28
+ }
29
+
30
+ function makeRejectingProvider(error: Error): OAuthProvider {
31
+ return {
32
+ exchangeCodeForToken: vi.fn().mockRejectedValue(error),
33
+ };
34
+ }
35
+
36
+ const BASE_REQUEST: ExchangeTokenRequest = {
37
+ provider: 'github',
38
+ purpose: 'login',
39
+ code: 'auth-code-abc',
40
+ redirectUri: 'https://app.example.com/oauth/callback',
41
+ };
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Tests
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('exchangeTokenAlgorithm', () => {
48
+ describe('successful exchange', () => {
49
+ it('returns token response from provider', async () => {
50
+ const tokenResponse: TokenResponse = {
51
+ access_token: 'access-token-xyz',
52
+ token_type: 'Bearer',
53
+ expires_in: 3600,
54
+ refresh_token: 'refresh-token-123',
55
+ };
56
+ const oauthProvider = makeProvider(tokenResponse);
57
+
58
+ const result = await exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider);
59
+
60
+ expect(result).toEqual(tokenResponse);
61
+ });
62
+
63
+ it('forwards code, redirectUri, and codeVerifier to provider', async () => {
64
+ const oauthProvider = makeProvider({
65
+ access_token: 'tok',
66
+ });
67
+ const request: ExchangeTokenRequest = {
68
+ ...BASE_REQUEST,
69
+ codeVerifier: 'pkce-verifier-xyz',
70
+ };
71
+
72
+ await exchangeTokenAlgorithm(request, oauthProvider);
73
+
74
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledOnce();
75
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledWith({
76
+ code: request.code,
77
+ redirectUri: request.redirectUri,
78
+ codeVerifier: 'pkce-verifier-xyz',
79
+ });
80
+ });
81
+
82
+ it('forwards without codeVerifier when omitted', async () => {
83
+ const oauthProvider = makeProvider({ access_token: 'tok' });
84
+
85
+ await exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider);
86
+
87
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledWith({
88
+ code: BASE_REQUEST.code,
89
+ redirectUri: BASE_REQUEST.redirectUri,
90
+ codeVerifier: undefined,
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('error scenarios', () => {
96
+ it('re-throws errors from provider', async () => {
97
+ const oauthProvider = makeRejectingProvider(
98
+ new Error('invalid_grant')
99
+ );
100
+
101
+ await expect(
102
+ exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider)
103
+ ).rejects.toThrow('invalid_grant');
104
+ });
105
+
106
+ it('calls provider exactly once on error', async () => {
107
+ const oauthProvider = makeRejectingProvider(new Error('network error'));
108
+
109
+ await expect(
110
+ exchangeTokenAlgorithm(BASE_REQUEST, oauthProvider)
111
+ ).rejects.toThrow('network error');
112
+
113
+ expect(oauthProvider.exchangeCodeForToken).toHaveBeenCalledOnce();
114
+ });
115
+ });
116
+ });
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ import {
4
+ grantAccessAlgorithm,
5
+ type OAuthGrantProvider,
6
+ } from '../grantAccess';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeProvider(
13
+ result: { success: boolean; message: string }
14
+ ): OAuthGrantProvider {
15
+ return { grantAccess: vi.fn().mockResolvedValue(result) };
16
+ }
17
+
18
+ function makeRejectingProvider(error: Error): OAuthGrantProvider {
19
+ return { grantAccess: vi.fn().mockRejectedValue(error) };
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Tests
24
+ // ---------------------------------------------------------------------------
25
+
26
+ describe('grantAccessAlgorithm', () => {
27
+ const userId = 'user-123';
28
+ const provider = 'github';
29
+ const accessToken = 'access-token-abc';
30
+ const refreshToken = 'refresh-token-xyz';
31
+
32
+ // -------------------------------------------------------------------------
33
+ // Successful grant
34
+ // -------------------------------------------------------------------------
35
+
36
+ describe('successful grant', () => {
37
+ it('returns success result from provider', async () => {
38
+ const oauthProvider = makeProvider({ success: true, message: 'OK' });
39
+
40
+ const result = await grantAccessAlgorithm(
41
+ userId,
42
+ provider,
43
+ accessToken,
44
+ refreshToken,
45
+ oauthProvider
46
+ );
47
+
48
+ expect(result).toEqual({ success: true, message: 'OK' });
49
+ });
50
+
51
+ it('forwards all parameters to the provider', async () => {
52
+ const oauthProvider = makeProvider({ success: true, message: 'granted' });
53
+
54
+ await grantAccessAlgorithm(
55
+ userId,
56
+ provider,
57
+ accessToken,
58
+ refreshToken,
59
+ oauthProvider
60
+ );
61
+
62
+ expect(oauthProvider.grantAccess).toHaveBeenCalledOnce();
63
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
64
+ userId,
65
+ provider,
66
+ accessToken,
67
+ refreshToken,
68
+ });
69
+ });
70
+
71
+ it('works without a refreshToken (undefined)', async () => {
72
+ const oauthProvider = makeProvider({ success: true, message: 'granted' });
73
+
74
+ const result = await grantAccessAlgorithm(
75
+ userId,
76
+ provider,
77
+ accessToken,
78
+ undefined,
79
+ oauthProvider
80
+ );
81
+
82
+ expect(result.success).toBe(true);
83
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
84
+ userId,
85
+ provider,
86
+ accessToken,
87
+ refreshToken: undefined,
88
+ });
89
+ });
90
+ });
91
+
92
+ // -------------------------------------------------------------------------
93
+ // Invalid / missing user
94
+ // -------------------------------------------------------------------------
95
+
96
+ describe('invalid / missing user', () => {
97
+ it('passes empty userId to provider (provider owns validation)', async () => {
98
+ const oauthProvider = makeProvider({
99
+ success: false,
100
+ message: 'user not found',
101
+ });
102
+
103
+ const result = await grantAccessAlgorithm(
104
+ '',
105
+ provider,
106
+ accessToken,
107
+ undefined,
108
+ oauthProvider
109
+ );
110
+
111
+ expect(result.success).toBe(false);
112
+ expect(result.message).toBe('user not found');
113
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith({
114
+ userId: '',
115
+ provider,
116
+ accessToken,
117
+ refreshToken: undefined,
118
+ });
119
+ });
120
+ });
121
+
122
+ // -------------------------------------------------------------------------
123
+ // Permission checks (provider denies access)
124
+ // -------------------------------------------------------------------------
125
+
126
+ describe('permission checks', () => {
127
+ it('returns provider failure when access is denied', async () => {
128
+ const oauthProvider = makeProvider({
129
+ success: false,
130
+ message: 'permission denied',
131
+ });
132
+
133
+ const result = await grantAccessAlgorithm(
134
+ userId,
135
+ provider,
136
+ accessToken,
137
+ refreshToken,
138
+ oauthProvider
139
+ );
140
+
141
+ expect(result.success).toBe(false);
142
+ expect(result.message).toBe('permission denied');
143
+ });
144
+
145
+ it('propagates the exact message returned by the provider', async () => {
146
+ const expectedMessage = 'scope insufficient for this resource';
147
+ const oauthProvider = makeProvider({
148
+ success: false,
149
+ message: expectedMessage,
150
+ });
151
+
152
+ const result = await grantAccessAlgorithm(
153
+ userId,
154
+ provider,
155
+ accessToken,
156
+ refreshToken,
157
+ oauthProvider
158
+ );
159
+
160
+ expect(result.message).toBe(expectedMessage);
161
+ });
162
+ });
163
+
164
+ // -------------------------------------------------------------------------
165
+ // Provider-specific grant logic
166
+ // -------------------------------------------------------------------------
167
+
168
+ describe('provider-specific grant logic', () => {
169
+ it.each([
170
+ ['github', 'gh-token'],
171
+ ['google', 'goog-token'],
172
+ ['stripe', 'stripe-token'],
173
+ ])(
174
+ 'correctly forwards provider "%s" with its access token',
175
+ async (providerName, token) => {
176
+ const oauthProvider = makeProvider({
177
+ success: true,
178
+ message: 'granted',
179
+ });
180
+
181
+ await grantAccessAlgorithm(
182
+ userId,
183
+ providerName,
184
+ token,
185
+ undefined,
186
+ oauthProvider
187
+ );
188
+
189
+ expect(oauthProvider.grantAccess).toHaveBeenCalledWith(
190
+ expect.objectContaining({ provider: providerName, accessToken: token })
191
+ );
192
+ }
193
+ );
194
+ });
195
+
196
+ // -------------------------------------------------------------------------
197
+ // Error scenarios
198
+ // -------------------------------------------------------------------------
199
+
200
+ describe('error scenarios', () => {
201
+ it('re-throws synchronous errors from provider', async () => {
202
+ const oauthProvider = makeRejectingProvider(
203
+ new Error('network failure')
204
+ );
205
+
206
+ await expect(
207
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
208
+ ).rejects.toThrow('network failure');
209
+ });
210
+
211
+ it('re-throws custom provider errors without wrapping', async () => {
212
+ class ProviderError extends Error {
213
+ constructor(msg: string) {
214
+ super(msg);
215
+ this.name = 'ProviderError';
216
+ }
217
+ }
218
+
219
+ const originalError = new ProviderError('token expired');
220
+ const oauthProvider = makeRejectingProvider(originalError);
221
+
222
+ await expect(
223
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
224
+ ).rejects.toThrow('token expired');
225
+ });
226
+
227
+ it('calls provider exactly once even on error', async () => {
228
+ const oauthProvider = makeRejectingProvider(new Error('boom'));
229
+
230
+ await expect(
231
+ grantAccessAlgorithm(userId, provider, accessToken, refreshToken, oauthProvider)
232
+ ).rejects.toThrow('boom');
233
+
234
+ expect(oauthProvider.grantAccess).toHaveBeenCalledOnce();
235
+ });
236
+ });
237
+ });