@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.
- package/README.md +1 -1
- package/package.json +32 -8
- package/src/firebase/auth/setCustomClaims.ts +26 -4
- package/src/firebase/baseFunction.ts +43 -20
- package/src/firebase/billing/cancelSubscription.ts +9 -1
- package/src/firebase/billing/changePlan.ts +8 -2
- package/src/firebase/billing/createCheckoutSession.ts +3 -1
- package/src/firebase/billing/createCustomerPortal.ts +16 -2
- package/src/firebase/billing/refreshSubscriptionStatus.ts +3 -1
- package/src/firebase/billing/webhookHandler.ts +13 -1
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/list.ts +36 -24
- package/src/firebase/crud/update.ts +29 -7
- package/src/firebase/oauth/exchangeToken.ts +30 -4
- package/src/firebase/oauth/githubAccess.ts +8 -3
- package/src/firebase/registerCrudFunctions.ts +2 -2
- package/src/firebase/scheduled/checkExpiredSubscriptions.ts +20 -3
- package/src/shared/__tests__/detectFirestore.test.ts +52 -0
- package/src/shared/__tests__/errorHandling.test.ts +144 -0
- package/src/shared/__tests__/idempotency.test.ts +95 -0
- package/src/shared/__tests__/rateLimiter.test.ts +142 -0
- package/src/shared/__tests__/validation.test.ts +172 -0
- package/src/shared/billing/__tests__/createCheckout.test.ts +393 -0
- package/src/shared/billing/__tests__/webhookHandler.test.ts +1091 -0
- package/src/shared/billing/webhookHandler.ts +16 -7
- package/src/shared/errorHandling.ts +22 -60
- package/src/shared/firebase.ts +1 -25
- package/src/shared/index.ts +2 -1
- package/src/shared/logger.ts +3 -7
- package/src/shared/oauth/__tests__/exchangeToken.test.ts +116 -0
- package/src/shared/oauth/__tests__/grantAccess.test.ts +237 -0
- package/src/shared/utils/__tests__/functionWrapper.test.ts +122 -0
- package/src/shared/utils/external/subscription.ts +12 -2
- package/src/shared/utils/internal/auth.ts +140 -16
- package/src/shared/utils/internal/rateLimiter.ts +101 -90
- package/src/shared/utils/internal/validation.ts +47 -3
- package/src/shared/utils.ts +170 -66
- package/src/supabase/auth/deleteAccount.ts +52 -0
- package/src/supabase/auth/getCustomClaims.ts +56 -0
- package/src/supabase/auth/getUserAuthStatus.ts +64 -0
- package/src/supabase/auth/removeCustomClaims.ts +75 -0
- package/src/supabase/auth/setCustomClaims.ts +73 -0
- package/src/supabase/baseFunction.ts +306 -0
- package/src/supabase/billing/cancelSubscription.ts +57 -0
- package/src/supabase/billing/changePlan.ts +62 -0
- package/src/supabase/billing/createCheckoutSession.ts +82 -0
- package/src/supabase/billing/createCustomerPortal.ts +58 -0
- package/src/supabase/billing/refreshSubscriptionStatus.ts +89 -0
- package/src/supabase/crud/aggregate.ts +169 -0
- package/src/supabase/crud/create.ts +225 -0
- package/src/supabase/crud/delete.ts +154 -0
- package/src/supabase/crud/get.ts +89 -0
- package/src/supabase/crud/index.ts +24 -0
- package/src/supabase/crud/list.ts +388 -0
- package/src/supabase/crud/update.ts +199 -0
- package/src/supabase/helpers/authProvider.ts +45 -0
- package/src/supabase/index.ts +73 -0
- package/src/supabase/registerCrudFunctions.ts +180 -0
- package/src/supabase/utils/idempotency.ts +141 -0
- package/src/supabase/utils/monitoring.ts +187 -0
- package/src/supabase/utils/rateLimiter.ts +216 -0
- package/src/vercel/api/auth/get-custom-claims.ts +3 -2
- package/src/vercel/api/auth/get-user-auth-status.ts +3 -2
- package/src/vercel/api/auth/remove-custom-claims.ts +3 -2
- package/src/vercel/api/auth/set-custom-claims.ts +5 -2
- package/src/vercel/api/billing/cancel.ts +2 -1
- package/src/vercel/api/billing/change-plan.ts +3 -1
- package/src/vercel/api/billing/customer-portal.ts +4 -1
- package/src/vercel/api/billing/refresh-subscription-status.ts +3 -1
- package/src/vercel/api/billing/webhook-handler.ts +24 -4
- package/src/vercel/api/crud/create.ts +14 -8
- package/src/vercel/api/crud/delete.ts +15 -6
- package/src/vercel/api/crud/get.ts +16 -8
- package/src/vercel/api/crud/list.ts +22 -10
- package/src/vercel/api/crud/update.ts +16 -10
- package/src/vercel/api/oauth/check-github-access.ts +2 -5
- package/src/vercel/api/oauth/grant-github-access.ts +1 -5
- package/src/vercel/api/oauth/revoke-github-access.ts +7 -8
- package/src/vercel/api/utils/cors.ts +13 -2
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
16
|
-
import { EntityHookError } from '@donotdev/core/server';
|
|
14
|
+
import { DoNotDevError, EntityHookError } from '@donotdev/core/server';
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
package/src/shared/firebase.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -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.
|
package/src/shared/logger.ts
CHANGED
|
@@ -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:
|
|
32
|
-
|
|
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
|
+
});
|