1
+ import { Webhook } from 'standardwebhooks' ;
1
2
import { beforeEach , describe , expect , it } from 'vitest' ;
2
3
3
4
import { verifyWebhook } from '../webhooks' ;
4
5
5
6
describe ( 'verifyWebhook' , ( ) => {
6
- const mockSecret = 'test_signing_secret' ;
7
+ const mockSecret = 'whsec_' + Buffer . from ( 'test_signing_secret_32_chars_long' ) . toString ( 'base64' ) ;
7
8
const mockBody = JSON . stringify ( { type : 'user.created' , data : { id : 'user_123' } } ) ;
8
9
9
10
beforeEach ( ( ) => {
10
11
process . env . CLERK_WEBHOOK_SIGNING_SECRET = mockSecret ;
11
12
} ) ;
12
13
14
+ // Helper function to create a valid signature with Standard Webhooks
15
+ const createValidSignature = ( id : string , timestamp : string , body : string ) => {
16
+ const webhook = new Webhook ( mockSecret ) ;
17
+ // Create a signature using the Standard Webhooks library
18
+ return webhook . sign ( id , new Date ( parseInt ( timestamp ) * 1000 ) , body ) ;
19
+ } ;
20
+
13
21
it ( 'throws when required headers are missing' , async ( ) => {
14
22
const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
15
23
method : 'POST' ,
16
24
body : mockBody ,
17
25
headers : new Headers ( {
18
- // Missing svix-signature but with valid format for others
19
- 'svix-id' : 'msg_123' ,
20
- 'svix-timestamp' : '1614265330' ,
26
+ // Missing all required headers
21
27
} ) ,
22
28
} ) ;
23
29
24
- await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'Missing required Svix headers: svix-signature ' ) ;
30
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'Missing required webhook headers' ) ;
25
31
} ) ;
26
32
27
33
it ( 'throws with all missing headers in error message' , async ( ) => {
28
34
const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
29
35
method : 'POST' ,
30
36
body : mockBody ,
31
- headers : new Headers ( { } ) ,
37
+ headers : new Headers ( {
38
+ // Missing all required headers
39
+ } ) ,
32
40
} ) ;
33
41
34
- await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow (
35
- 'Missing required Svix headers: svix-id, svix-timestamp, svix-signature' ,
36
- ) ;
42
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'svix-id, svix-timestamp, svix-signature' ) ;
37
43
} ) ;
38
44
39
45
it ( 'throws when signing secret is missing' , async ( ) => {
@@ -44,24 +50,26 @@ describe('verifyWebhook', () => {
44
50
body : mockBody ,
45
51
headers : new Headers ( {
46
52
'svix-id' : 'msg_123' ,
47
- 'svix-timestamp' : '1614265330' ,
53
+ 'svix-timestamp' : ( Date . now ( ) / 1000 ) . toString ( ) ,
48
54
'svix-signature' : 'v1,test_signature' ,
49
55
} ) ,
50
56
} ) ;
51
57
52
- await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow (
53
- 'Missing webhook signing secret. Set the CLERK_WEBHOOK_SIGNING_SECRET environment variable with the webhook secret from the Clerk Dashboard.' ,
54
- ) ;
58
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'Missing webhook signing secret' ) ;
55
59
} ) ;
56
60
57
61
it ( 'validates webhook request requirements' , async ( ) => {
62
+ const svixId = 'msg_123' ;
63
+ const svixTimestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
64
+ const validSignature = createValidSignature ( svixId , svixTimestamp , mockBody ) ;
65
+
58
66
const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
59
67
method : 'POST' ,
60
68
body : mockBody ,
61
69
headers : new Headers ( {
62
- 'svix-id' : 'msg_123' ,
63
- 'svix-timestamp' : '1614265330' ,
64
- 'svix-signature' : 'v1,test_signature' ,
70
+ 'svix-id' : svixId ,
71
+ 'svix-timestamp' : svixTimestamp ,
72
+ 'svix-signature' : validSignature ,
65
73
} ) ,
66
74
} ) ;
67
75
@@ -72,4 +80,141 @@ describe('verifyWebhook', () => {
72
80
expect ( result ) . toHaveProperty ( 'type' , 'user.created' ) ;
73
81
expect ( result ) . toHaveProperty ( 'data.id' , 'user_123' ) ;
74
82
} ) ;
83
+
84
+ it ( 'should accept valid signatures' , async ( ) => {
85
+ const svixId = 'msg_123' ;
86
+ const svixTimestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
87
+ const validSignature = createValidSignature ( svixId , svixTimestamp , mockBody ) ;
88
+
89
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
90
+ method : 'POST' ,
91
+ body : mockBody ,
92
+ headers : new Headers ( {
93
+ 'svix-id' : svixId ,
94
+ 'svix-timestamp' : svixTimestamp ,
95
+ 'svix-signature' : validSignature ,
96
+ } ) ,
97
+ } ) ;
98
+
99
+ // Should accept and return parsed data
100
+ const result = await verifyWebhook ( mockRequest ) ;
101
+ expect ( result ) . toHaveProperty ( 'type' , 'user.created' ) ;
102
+ expect ( result ) . toHaveProperty ( 'data.id' , 'user_123' ) ;
103
+ } ) ;
104
+
105
+ it ( 'should reject invalid signatures' , async ( ) => {
106
+ const svixId = 'msg_123' ;
107
+ const svixTimestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
108
+ const invalidSignature = 'v1,invalid_signature_here' ;
109
+
110
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
111
+ method : 'POST' ,
112
+ body : mockBody ,
113
+ headers : new Headers ( {
114
+ 'svix-id' : svixId ,
115
+ 'svix-timestamp' : svixTimestamp ,
116
+ 'svix-signature' : invalidSignature ,
117
+ } ) ,
118
+ } ) ;
119
+
120
+ // Should reject invalid signatures
121
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'No matching signature found' ) ;
122
+ } ) ;
123
+
124
+ it ( 'should handle multiple signatures in header' , async ( ) => {
125
+ const svixId = 'msg_123' ;
126
+ const svixTimestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
127
+ const validSignature = createValidSignature ( svixId , svixTimestamp , mockBody ) ;
128
+ const invalidSignature = 'v1,invalid_signature' ;
129
+
130
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
131
+ method : 'POST' ,
132
+ body : mockBody ,
133
+ headers : new Headers ( {
134
+ 'svix-id' : svixId ,
135
+ 'svix-timestamp' : svixTimestamp ,
136
+ 'svix-signature' : `${ invalidSignature } ${ validSignature } ` ,
137
+ } ) ,
138
+ } ) ;
139
+
140
+ // Should accept if any signature in the list is valid
141
+ const result = await verifyWebhook ( mockRequest ) ;
142
+ expect ( result ) . toHaveProperty ( 'type' , 'user.created' ) ;
143
+ expect ( result ) . toHaveProperty ( 'data.id' , 'user_123' ) ;
144
+ } ) ;
145
+
146
+ it ( 'should handle signatures without version prefixes for backward compatibility' , async ( ) => {
147
+ const svixId = 'msg_123' ;
148
+ const svixTimestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
149
+ // Test with Standard Webhooks generated signature without custom prefix
150
+ const validSignature = createValidSignature ( svixId , svixTimestamp , mockBody ) ;
151
+
152
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
153
+ method : 'POST' ,
154
+ body : mockBody ,
155
+ headers : new Headers ( {
156
+ 'svix-id' : svixId ,
157
+ 'svix-timestamp' : svixTimestamp ,
158
+ 'svix-signature' : validSignature ,
159
+ } ) ,
160
+ } ) ;
161
+
162
+ // Should accept signatures without version prefixes
163
+ const result = await verifyWebhook ( mockRequest ) ;
164
+ expect ( result ) . toHaveProperty ( 'type' , 'user.created' ) ;
165
+ expect ( result ) . toHaveProperty ( 'data.id' , 'user_123' ) ;
166
+ } ) ;
167
+
168
+ it ( 'should verify against Standard Webhooks specification' , async ( ) => {
169
+ // Test with proper Clerk webhook format
170
+ const clerkPayload = '{"type":"user.created","data":{"id":"user_123","email":"[email protected] "}}' ;
171
+ const msgId = 'msg_test123' ;
172
+ const timestamp = ( Date . now ( ) / 1000 ) . toString ( ) ;
173
+
174
+ const validSignature = createValidSignature ( msgId , timestamp , clerkPayload ) ;
175
+
176
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
177
+ method : 'POST' ,
178
+ body : clerkPayload ,
179
+ headers : new Headers ( {
180
+ 'svix-id' : msgId ,
181
+ 'svix-timestamp' : timestamp ,
182
+ 'svix-signature' : validSignature ,
183
+ } ) ,
184
+ } ) ;
185
+
186
+ const result = await verifyWebhook ( mockRequest , { signingSecret : mockSecret } ) ;
187
+ expect ( result ) . toHaveProperty ( 'type' , 'user.created' ) ;
188
+ expect ( result ) . toHaveProperty ( 'data.id' , 'user_123' ) ;
189
+ } ) ;
190
+
191
+ it ( 'should handle whitespace-only header values correctly' , async ( ) => {
192
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
193
+ method : 'POST' ,
194
+ body : mockBody ,
195
+ headers : new Headers ( {
196
+ 'svix-id' : '' , // Empty - should be caught
197
+ 'svix-timestamp' : ' ' , // Whitespace - should be caught
198
+ 'svix-signature' : 'v1,signature' ,
199
+ } ) ,
200
+ } ) ;
201
+
202
+ // This should fail because whitespace-only headers should be treated as missing
203
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'Missing required webhook headers' ) ;
204
+ } ) ;
205
+
206
+ it ( 'should handle mixed empty and whitespace headers correctly' , async ( ) => {
207
+ const mockRequest = new Request ( 'https://clerk.com/webhooks' , {
208
+ method : 'POST' ,
209
+ body : mockBody ,
210
+ headers : new Headers ( {
211
+ 'svix-id' : ' \t ' , // Mixed whitespace and tabs
212
+ 'svix-timestamp' : '\n' , // Newline character
213
+ 'svix-signature' : '' , // Empty string
214
+ } ) ,
215
+ } ) ;
216
+
217
+ // All should be treated as missing
218
+ await expect ( verifyWebhook ( mockRequest ) ) . rejects . toThrow ( 'svix-id, svix-timestamp, svix-signature' ) ;
219
+ } ) ;
75
220
} ) ;
0 commit comments