@donotdev/functions 0.0.10 → 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.
- package/package.json +31 -7
- 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/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 +7 -22
- 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 +16 -54
- package/src/shared/firebase.ts +1 -25
- 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 +10 -0
- 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 +154 -39
- package/src/supabase/auth/deleteAccount.ts +59 -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 +302 -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 +357 -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
|
|
|
@@ -12,49 +12,10 @@
|
|
|
12
12
|
import { HttpsError } from 'firebase-functions/v2/https';
|
|
13
13
|
import * as v from 'valibot';
|
|
14
14
|
|
|
15
|
-
import
|
|
16
|
-
import { EntityHookError } from '@donotdev/core/server';
|
|
15
|
+
import { DoNotDevError, EntityHookError } from '@donotdev/core/server';
|
|
17
16
|
|
|
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
|
-
}
|
|
17
|
+
// Re-export DoNotDevError so existing imports from this module continue to work
|
|
18
|
+
export { DoNotDevError };
|
|
58
19
|
|
|
59
20
|
/**
|
|
60
21
|
* Platform-aware error handling that throws in the correct format
|
|
@@ -66,7 +27,16 @@ export class DoNotDevError extends Error {
|
|
|
66
27
|
* @author AMBROISE PARK Consulting
|
|
67
28
|
*/
|
|
68
29
|
export function handleError(error: unknown): never {
|
|
69
|
-
|
|
30
|
+
// W11: Log only in development to avoid double-logging with platform loggers.
|
|
31
|
+
// Known DoNotDevError, ValiError, and EntityHookError are handled by callers.
|
|
32
|
+
if (
|
|
33
|
+
process.env.NODE_ENV === 'development' &&
|
|
34
|
+
!(error instanceof DoNotDevError) &&
|
|
35
|
+
!(error instanceof Error && error.name === 'ValiError') &&
|
|
36
|
+
!(error instanceof Error && error.name === 'EntityHookError')
|
|
37
|
+
) {
|
|
38
|
+
console.error('Function error:', error);
|
|
39
|
+
}
|
|
70
40
|
|
|
71
41
|
// Error classification logic (same for all platforms)
|
|
72
42
|
let code: string;
|
|
@@ -117,20 +87,12 @@ export function handleError(error: unknown): never {
|
|
|
117
87
|
message = entityError.message;
|
|
118
88
|
details = undefined;
|
|
119
89
|
} else {
|
|
120
|
-
// Preserve original error message for debugging
|
|
121
90
|
code = 'internal';
|
|
91
|
+
// W10: Never expose error.stack in details — it leaks internal file paths
|
|
92
|
+
// and implementation details to clients. Log server-side only.
|
|
122
93
|
message =
|
|
123
94
|
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
|
-
};
|
|
95
|
+
details = undefined;
|
|
134
96
|
}
|
|
135
97
|
|
|
136
98
|
// Platform-specific error throwing
|
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
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// packages/functions/src/shared/utils/__tests__/functionWrapper.test.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Tests for withValidation, firebaseFunction, vercelFunction
|
|
5
|
+
* @description Unit tests for schema validation wrappers.
|
|
6
|
+
*
|
|
7
|
+
* @version 0.0.1
|
|
8
|
+
* @since 0.0.1
|
|
9
|
+
* @author AMBROISE PARK Consulting
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
import * as v from 'valibot';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
withValidation,
|
|
17
|
+
firebaseFunction,
|
|
18
|
+
vercelFunction,
|
|
19
|
+
} from '../functionWrapper';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Schemas and fixtures
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const TestSchema = v.object({
|
|
26
|
+
name: v.pipe(v.string(), v.minLength(1)),
|
|
27
|
+
count: v.optional(v.pipe(v.number(), v.minValue(0))),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type TestData = v.InferOutput<typeof TestSchema>;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// withValidation
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
describe('withValidation', () => {
|
|
37
|
+
it('returns handler result when data is valid', async () => {
|
|
38
|
+
const handler = vi.fn().mockResolvedValue({ id: '123' });
|
|
39
|
+
const wrapped = withValidation(TestSchema, handler);
|
|
40
|
+
const data = { name: 'foo', count: 2 };
|
|
41
|
+
|
|
42
|
+
const result = await wrapped(data);
|
|
43
|
+
|
|
44
|
+
expect(result).toEqual({ id: '123' });
|
|
45
|
+
expect(handler).toHaveBeenCalledWith({ name: 'foo', count: 2 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws when data fails validation', async () => {
|
|
49
|
+
const handler = vi.fn();
|
|
50
|
+
const wrapped = withValidation(TestSchema, handler);
|
|
51
|
+
|
|
52
|
+
await expect(wrapped({ name: '' })).rejects.toThrow();
|
|
53
|
+
expect(handler).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('passes validated (parsed) data to handler', async () => {
|
|
57
|
+
const handler = vi.fn().mockResolvedValue(undefined);
|
|
58
|
+
const wrapped = withValidation(TestSchema, handler);
|
|
59
|
+
await wrapped({ name: 'a', count: 0 });
|
|
60
|
+
|
|
61
|
+
expect(handler).toHaveBeenCalledWith(
|
|
62
|
+
expect.objectContaining({ name: 'a', count: 0 })
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// firebaseFunction
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('firebaseFunction', () => {
|
|
72
|
+
it('calls handler with parsed request.data', async () => {
|
|
73
|
+
const handler = vi.fn().mockResolvedValue({ success: true });
|
|
74
|
+
const fn = firebaseFunction(TestSchema, handler);
|
|
75
|
+
const request = {
|
|
76
|
+
data: { name: 'test', count: 1 },
|
|
77
|
+
rawRequest: {} as any,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const result = await fn(request);
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ success: true });
|
|
83
|
+
expect(handler).toHaveBeenCalledWith({ name: 'test', count: 1 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws when request.data is invalid', async () => {
|
|
87
|
+
const handler = vi.fn();
|
|
88
|
+
const fn = firebaseFunction(TestSchema, handler);
|
|
89
|
+
const request = { data: { name: '' }, rawRequest: {} as any };
|
|
90
|
+
|
|
91
|
+
await expect(fn(request)).rejects.toThrow();
|
|
92
|
+
expect(handler).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// vercelFunction
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe('vercelFunction', () => {
|
|
101
|
+
it('parses req.body and calls handler with (req, res, data)', async () => {
|
|
102
|
+
const handler = vi.fn().mockResolvedValue({ ok: true });
|
|
103
|
+
const fn = vercelFunction(TestSchema, handler);
|
|
104
|
+
const req = { body: { name: 'v', count: 0 } } as any;
|
|
105
|
+
const res = {} as any;
|
|
106
|
+
|
|
107
|
+
const result = await fn(req, res);
|
|
108
|
+
|
|
109
|
+
expect(result).toEqual({ ok: true });
|
|
110
|
+
expect(handler).toHaveBeenCalledWith(req, res, { name: 'v', count: 0 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('throws when req.body is invalid', async () => {
|
|
114
|
+
const handler = vi.fn();
|
|
115
|
+
const fn = vercelFunction(TestSchema, handler);
|
|
116
|
+
const req = { body: {} } as any;
|
|
117
|
+
const res = {} as any;
|
|
118
|
+
|
|
119
|
+
await expect(fn(req, res)).rejects.toThrow();
|
|
120
|
+
expect(handler).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -89,8 +89,13 @@ export async function updateUserSubscription(
|
|
|
89
89
|
const auth = getFirebaseAdminAuth();
|
|
90
90
|
const db = getFirebaseAdminFirestore();
|
|
91
91
|
|
|
92
|
+
// Read existing claims and merge to avoid overwriting other claims
|
|
93
|
+
const user = await auth.getUser(firebaseUid);
|
|
94
|
+
const currentClaims = user.customClaims || {};
|
|
95
|
+
|
|
92
96
|
// Update Firebase Auth custom claims
|
|
93
97
|
await auth.setCustomUserClaims(firebaseUid, {
|
|
98
|
+
...currentClaims,
|
|
94
99
|
subscription: subscriptionClaims,
|
|
95
100
|
});
|
|
96
101
|
|
|
@@ -150,8 +155,13 @@ export async function cancelUserSubscription(
|
|
|
150
155
|
const auth = getFirebaseAdminAuth();
|
|
151
156
|
const db = getFirebaseAdminFirestore();
|
|
152
157
|
|
|
158
|
+
// Read existing claims and merge to avoid overwriting other claims
|
|
159
|
+
const user = await auth.getUser(firebaseUid);
|
|
160
|
+
const currentClaims = user.customClaims || {};
|
|
161
|
+
|
|
153
162
|
// Update Firebase Auth custom claims
|
|
154
163
|
await auth.setCustomUserClaims(firebaseUid, {
|
|
164
|
+
...currentClaims,
|
|
155
165
|
subscription: subscriptionClaims,
|
|
156
166
|
});
|
|
157
167
|
|