@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.
- 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/config/constants.ts +0 -3
- package/src/firebase/crud/aggregate.ts +20 -5
- package/src/firebase/crud/create.ts +31 -7
- package/src/firebase/crud/get.ts +16 -8
- package/src/firebase/crud/list.ts +70 -29
- 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 +15 -4
- 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/schema.ts +7 -1
- 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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock detectFirestore to return false (force in-memory)
|
|
4
|
+
vi.mock('../utils/detectFirestore.js', () => ({
|
|
5
|
+
isFirestoreConfigured: vi.fn(() => false),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createIdempotencyStore,
|
|
10
|
+
resetIdempotencyStore,
|
|
11
|
+
} from '../billing/idempotency';
|
|
12
|
+
|
|
13
|
+
describe('InMemoryIdempotency', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
resetIdempotencyStore();
|
|
16
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
17
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('creates in-memory store when Firestore not configured', () => {
|
|
21
|
+
const store = createIdempotencyStore();
|
|
22
|
+
|
|
23
|
+
expect(store).toBeDefined();
|
|
24
|
+
expect(typeof store.isProcessed).toBe('function');
|
|
25
|
+
expect(typeof store.markProcessed).toBe('function');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns singleton instance', () => {
|
|
29
|
+
const store1 = createIdempotencyStore();
|
|
30
|
+
const store2 = createIdempotencyStore();
|
|
31
|
+
|
|
32
|
+
expect(store1).toBe(store2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reports event as not processed initially', async () => {
|
|
36
|
+
const store = createIdempotencyStore();
|
|
37
|
+
|
|
38
|
+
const result = await store.isProcessed('evt_001');
|
|
39
|
+
expect(result).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('reports event as processed after marking', async () => {
|
|
43
|
+
const store = createIdempotencyStore();
|
|
44
|
+
|
|
45
|
+
await store.markProcessed('evt_002');
|
|
46
|
+
const result = await store.isProcessed('evt_002');
|
|
47
|
+
|
|
48
|
+
expect(result).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('distinguishes between different events', async () => {
|
|
52
|
+
const store = createIdempotencyStore();
|
|
53
|
+
|
|
54
|
+
await store.markProcessed('evt_a');
|
|
55
|
+
|
|
56
|
+
expect(await store.isProcessed('evt_a')).toBe(true);
|
|
57
|
+
expect(await store.isProcessed('evt_b')).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('warns once on first isProcessed call', async () => {
|
|
61
|
+
const warnSpy = vi.spyOn(console, 'warn');
|
|
62
|
+
const store = createIdempotencyStore();
|
|
63
|
+
|
|
64
|
+
await store.isProcessed('evt_x');
|
|
65
|
+
await store.isProcessed('evt_y');
|
|
66
|
+
|
|
67
|
+
// Should warn at least once about in-memory usage
|
|
68
|
+
const inMemoryWarns = warnSpy.mock.calls.filter(
|
|
69
|
+
(call) => typeof call[0] === 'string' && call[0].includes('in-memory')
|
|
70
|
+
);
|
|
71
|
+
expect(inMemoryWarns.length).toBeGreaterThanOrEqual(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('auto-cleans after 1000 entries (keeps last entries)', async () => {
|
|
75
|
+
const store = createIdempotencyStore();
|
|
76
|
+
|
|
77
|
+
// Add 1001 entries
|
|
78
|
+
for (let i = 0; i < 1001; i++) {
|
|
79
|
+
await store.markProcessed(`evt_${i}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// First entry should have been evicted
|
|
83
|
+
expect(await store.isProcessed('evt_0')).toBe(false);
|
|
84
|
+
// Recent entry should still exist
|
|
85
|
+
expect(await store.isProcessed('evt_1000')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('resetIdempotencyStore clears singleton', () => {
|
|
89
|
+
const store1 = createIdempotencyStore();
|
|
90
|
+
resetIdempotencyStore();
|
|
91
|
+
const store2 = createIdempotencyStore();
|
|
92
|
+
|
|
93
|
+
expect(store1).not.toBe(store2);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock firebase-functions/v2 logger
|
|
4
|
+
vi.mock('firebase-functions/v2', () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
log: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock @donotdev/firebase/server (not used in in-memory path, but imported)
|
|
14
|
+
vi.mock('@donotdev/firebase/server', () => ({
|
|
15
|
+
getFirebaseAdminFirestore: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
checkRateLimit,
|
|
20
|
+
resetRateLimit,
|
|
21
|
+
getRateLimitStatus,
|
|
22
|
+
DEFAULT_RATE_LIMITS,
|
|
23
|
+
} from '../utils/internal/rateLimiter';
|
|
24
|
+
|
|
25
|
+
const TEST_CONFIG = {
|
|
26
|
+
maxAttempts: 3,
|
|
27
|
+
windowMs: 60_000,
|
|
28
|
+
blockDurationMs: 300_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('checkRateLimit (in-memory)', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
// Reset rate limit store between tests
|
|
34
|
+
resetRateLimit('test-key');
|
|
35
|
+
resetRateLimit('other-key');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('allows first request', async () => {
|
|
39
|
+
const result = await checkRateLimit('test-key', TEST_CONFIG);
|
|
40
|
+
|
|
41
|
+
expect(result.allowed).toBe(true);
|
|
42
|
+
expect(result.remaining).toBe(2); // 3 max - 1 used
|
|
43
|
+
expect(result.blockRemainingSeconds).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('decrements remaining on each request', async () => {
|
|
47
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
48
|
+
const result = await checkRateLimit('test-key', TEST_CONFIG);
|
|
49
|
+
|
|
50
|
+
expect(result.allowed).toBe(true);
|
|
51
|
+
expect(result.remaining).toBe(1); // 3 max - 2 used
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('blocks after max attempts exceeded', async () => {
|
|
55
|
+
// Use all 3 attempts
|
|
56
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
57
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
58
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
59
|
+
|
|
60
|
+
// 4th request should be blocked
|
|
61
|
+
const result = await checkRateLimit('test-key', TEST_CONFIG);
|
|
62
|
+
|
|
63
|
+
expect(result.allowed).toBe(false);
|
|
64
|
+
expect(result.remaining).toBe(0);
|
|
65
|
+
expect(result.blockRemainingSeconds).toBeGreaterThan(0);
|
|
66
|
+
expect(result.resetAt).toBeInstanceOf(Date);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('isolates keys from each other', async () => {
|
|
70
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
71
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
72
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
73
|
+
|
|
74
|
+
// Different key should still be allowed
|
|
75
|
+
const result = await checkRateLimit('other-key', TEST_CONFIG);
|
|
76
|
+
expect(result.allowed).toBe(true);
|
|
77
|
+
expect(result.remaining).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('resetRateLimit', () => {
|
|
82
|
+
it('clears rate limit allowing new requests', async () => {
|
|
83
|
+
// Exhaust attempts
|
|
84
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
85
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
86
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
87
|
+
|
|
88
|
+
// Reset
|
|
89
|
+
resetRateLimit('test-key');
|
|
90
|
+
|
|
91
|
+
// Should be allowed again
|
|
92
|
+
const result = await checkRateLimit('test-key', TEST_CONFIG);
|
|
93
|
+
expect(result.allowed).toBe(true);
|
|
94
|
+
expect(result.remaining).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('getRateLimitStatus', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
resetRateLimit('test-key');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns full remaining for unknown key', () => {
|
|
104
|
+
const result = getRateLimitStatus('unknown-key', TEST_CONFIG);
|
|
105
|
+
|
|
106
|
+
expect(result.allowed).toBe(true);
|
|
107
|
+
expect(result.remaining).toBe(3);
|
|
108
|
+
expect(result.resetAt).toBeNull();
|
|
109
|
+
expect(result.blockRemainingSeconds).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reflects current usage without consuming attempt', async () => {
|
|
113
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
114
|
+
await checkRateLimit('test-key', TEST_CONFIG);
|
|
115
|
+
|
|
116
|
+
const status = getRateLimitStatus('test-key', TEST_CONFIG);
|
|
117
|
+
expect(status.remaining).toBe(1);
|
|
118
|
+
|
|
119
|
+
// Check again — should be same (no attempt consumed)
|
|
120
|
+
const status2 = getRateLimitStatus('test-key', TEST_CONFIG);
|
|
121
|
+
expect(status2.remaining).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('DEFAULT_RATE_LIMITS', () => {
|
|
126
|
+
it('has checkout config', () => {
|
|
127
|
+
expect(DEFAULT_RATE_LIMITS.checkout.maxAttempts).toBe(5);
|
|
128
|
+
expect(DEFAULT_RATE_LIMITS.checkout.windowMs).toBe(60_000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('has webhook config', () => {
|
|
132
|
+
expect(DEFAULT_RATE_LIMITS.webhook.maxAttempts).toBe(100);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('has auth config', () => {
|
|
136
|
+
expect(DEFAULT_RATE_LIMITS.auth.maxAttempts).toBe(10);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('has api config', () => {
|
|
140
|
+
expect(DEFAULT_RATE_LIMITS.api.maxAttempts).toBe(100);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock firebase-functions/v2 logger
|
|
4
|
+
vi.mock('firebase-functions/v2', () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
warn: vi.fn(),
|
|
7
|
+
info: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
log: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
validateGitHubUsername,
|
|
15
|
+
validateEmail,
|
|
16
|
+
validateStripePriceId,
|
|
17
|
+
validateStripeSessionId,
|
|
18
|
+
validateUrl,
|
|
19
|
+
validateMetadata,
|
|
20
|
+
} from '../utils/validation';
|
|
21
|
+
|
|
22
|
+
describe('validateGitHubUsername', () => {
|
|
23
|
+
it('accepts valid usernames', () => {
|
|
24
|
+
expect(validateGitHubUsername('octocat')).toBe(true);
|
|
25
|
+
expect(validateGitHubUsername('user-name')).toBe(true);
|
|
26
|
+
expect(validateGitHubUsername('a')).toBe(true);
|
|
27
|
+
expect(validateGitHubUsername('user123')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects invalid usernames', () => {
|
|
31
|
+
expect(validateGitHubUsername('')).toBe(false);
|
|
32
|
+
expect(validateGitHubUsername('-leadingdash')).toBe(false);
|
|
33
|
+
expect(validateGitHubUsername('has spaces')).toBe(false);
|
|
34
|
+
expect(validateGitHubUsername('special@char')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects username exceeding 39 characters', () => {
|
|
38
|
+
const longName = 'a'.repeat(40);
|
|
39
|
+
expect(validateGitHubUsername(longName)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts username at max 39 characters', () => {
|
|
43
|
+
const maxName = 'a'.repeat(39);
|
|
44
|
+
expect(validateGitHubUsername(maxName)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('validateEmail', () => {
|
|
49
|
+
it('accepts valid emails', () => {
|
|
50
|
+
expect(validateEmail('user@example.com')).toBe(true);
|
|
51
|
+
expect(validateEmail('name+tag@domain.co')).toBe(true);
|
|
52
|
+
expect(validateEmail('a@b.c')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects invalid emails', () => {
|
|
56
|
+
expect(validateEmail('')).toBe(false);
|
|
57
|
+
expect(validateEmail('noemail')).toBe(false);
|
|
58
|
+
expect(validateEmail('@missing.local')).toBe(false);
|
|
59
|
+
expect(validateEmail('missing@')).toBe(false);
|
|
60
|
+
expect(validateEmail('has space@test.com')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('validateStripePriceId', () => {
|
|
65
|
+
it('accepts valid price IDs', () => {
|
|
66
|
+
expect(validateStripePriceId('price_1234567890')).toBe(true);
|
|
67
|
+
expect(validateStripePriceId('price_abc')).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('rejects invalid price IDs', () => {
|
|
71
|
+
expect(validateStripePriceId('price_')).toBe(false);
|
|
72
|
+
expect(validateStripePriceId('prod_123')).toBe(false);
|
|
73
|
+
expect(validateStripePriceId('')).toBe(false);
|
|
74
|
+
expect(validateStripePriceId('123')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('validateStripeSessionId', () => {
|
|
79
|
+
it('accepts valid session IDs', () => {
|
|
80
|
+
expect(validateStripeSessionId('cs_test_abc123')).toBe(true);
|
|
81
|
+
expect(validateStripeSessionId('cs_live_xyz')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects invalid session IDs', () => {
|
|
85
|
+
expect(validateStripeSessionId('cs_')).toBe(false);
|
|
86
|
+
expect(validateStripeSessionId('sess_123')).toBe(false);
|
|
87
|
+
expect(validateStripeSessionId('')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('validateUrl', () => {
|
|
92
|
+
it('accepts valid HTTP/HTTPS URLs', () => {
|
|
93
|
+
expect(validateUrl('https://example.com')).toBe('https://example.com');
|
|
94
|
+
expect(validateUrl('http://localhost:3000')).toBe('http://localhost:3000');
|
|
95
|
+
expect(validateUrl('https://app.example.com/path?q=1')).toBe(
|
|
96
|
+
'https://app.example.com/path?q=1'
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('rejects invalid URLs', () => {
|
|
101
|
+
expect(() => validateUrl('not-a-url')).toThrow();
|
|
102
|
+
expect(() => validateUrl('')).toThrow();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects non-HTTP protocols', () => {
|
|
106
|
+
expect(() => validateUrl('ftp://example.com')).toThrow('Invalid protocol');
|
|
107
|
+
expect(() => validateUrl('file:///etc/passwd')).toThrow('Invalid protocol');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('includes custom name in error message', () => {
|
|
111
|
+
expect(() => validateUrl('bad', 'Success URL')).toThrow('Invalid Success URL');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('validateMetadata', () => {
|
|
116
|
+
it('passes through valid string metadata', () => {
|
|
117
|
+
const result = validateMetadata({ key: 'value', name: 'test' });
|
|
118
|
+
|
|
119
|
+
expect(result).toEqual({ key: 'value', name: 'test' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects non-string values', () => {
|
|
123
|
+
expect(() => validateMetadata({ key: 123 as any })).toThrow(
|
|
124
|
+
"must be a string"
|
|
125
|
+
);
|
|
126
|
+
expect(() => validateMetadata({ key: true as any })).toThrow(
|
|
127
|
+
"must be a string"
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('sanitizes script tags', () => {
|
|
132
|
+
const result = validateMetadata({
|
|
133
|
+
xss: '<script>alert("xss")</script>',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.xss).not.toContain('<script');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('sanitizes javascript: protocol', () => {
|
|
140
|
+
const result = validateMetadata({
|
|
141
|
+
link: 'javascript:alert(1)',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.link).not.toContain('javascript:');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('sanitizes inline event handlers', () => {
|
|
148
|
+
const result = validateMetadata({
|
|
149
|
+
html: 'onclick=alert(1)',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(result.html).not.toContain('onclick=');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects values exceeding 1000 characters', () => {
|
|
156
|
+
const longValue = 'a'.repeat(1001);
|
|
157
|
+
|
|
158
|
+
expect(() => validateMetadata({ key: longValue })).toThrow('too long');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects keys exceeding 100 characters', () => {
|
|
162
|
+
const longKey = 'k'.repeat(101);
|
|
163
|
+
|
|
164
|
+
expect(() => validateMetadata({ [longKey]: 'value' })).toThrow('too long');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('handles empty metadata object', () => {
|
|
168
|
+
const result = validateMetadata({});
|
|
169
|
+
|
|
170
|
+
expect(result).toEqual({});
|
|
171
|
+
});
|
|
172
|
+
});
|