@@ -179,6 +179,8 @@ describe('HandshakeService', () => {
179
179
180
180
it ( 'should use proxy URL when available' , ( ) => {
181
181
mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com' ;
182
+ // Simulate what parsePublishableKey does when proxy URL is provided
183
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com' ;
182
184
const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
183
185
const location = headers . get ( constants . Headers . Location ) ;
184
186
if ( ! location ) {
@@ -195,6 +197,7 @@ describe('HandshakeService', () => {
195
197
196
198
it ( 'should handle proxy URL with trailing slash' , ( ) => {
197
199
mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com/' ;
200
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com/' ;
198
201
const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
199
202
const location = headers . get ( constants . Headers . Location ) ;
200
203
if ( ! location ) {
@@ -205,6 +208,227 @@ describe('HandshakeService', () => {
205
208
expect ( url . hostname ) . toBe ( 'my-proxy.example.com' ) ;
206
209
expect ( url . pathname ) . toBe ( '/v1/client/handshake' ) ;
207
210
} ) ;
211
+
212
+ it ( 'should handle proxy URL with multiple trailing slashes' , ( ) => {
213
+ mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com//' ;
214
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com//' ;
215
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
216
+ const location = headers . get ( constants . Headers . Location ) ;
217
+ if ( ! location ) {
218
+ throw new Error ( 'Location header is missing' ) ;
219
+ }
220
+ const url = new URL ( location ) ;
221
+
222
+ expect ( url . hostname ) . toBe ( 'my-proxy.example.com' ) ;
223
+ expect ( url . pathname ) . toBe ( '/v1/client/handshake' ) ;
224
+ expect ( location ) . not . toContain ( '//v1/client/handshake' ) ;
225
+ } ) ;
226
+
227
+ it ( 'should handle proxy URL with many trailing slashes' , ( ) => {
228
+ mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com///' ;
229
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com///' ;
230
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
231
+ const location = headers . get ( constants . Headers . Location ) ;
232
+ if ( ! location ) {
233
+ throw new Error ( 'Location header is missing' ) ;
234
+ }
235
+ const url = new URL ( location ) ;
236
+
237
+ expect ( url . hostname ) . toBe ( 'my-proxy.example.com' ) ;
238
+ expect ( url . pathname ) . toBe ( '/v1/client/handshake' ) ;
239
+ expect ( location ) . not . toContain ( '//v1/client/handshake' ) ;
240
+ } ) ;
241
+
242
+ it ( 'should handle proxy URL without trailing slash' , ( ) => {
243
+ mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com' ;
244
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com' ;
245
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
246
+ const location = headers . get ( constants . Headers . Location ) ;
247
+ if ( ! location ) {
248
+ throw new Error ( 'Location header is missing' ) ;
249
+ }
250
+ const url = new URL ( location ) ;
251
+
252
+ expect ( url . hostname ) . toBe ( 'my-proxy.example.com' ) ;
253
+ expect ( url . pathname ) . toBe ( '/v1/client/handshake' ) ;
254
+ } ) ;
255
+
256
+ it ( 'should handle proxy URL with path and trailing slashes' , ( ) => {
257
+ mockAuthenticateContext . proxyUrl = 'https://my-proxy.example.com/clerk-proxy//' ;
258
+ mockAuthenticateContext . frontendApi = 'https://my-proxy.example.com/clerk-proxy//' ;
259
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
260
+ const location = headers . get ( constants . Headers . Location ) ;
261
+ if ( ! location ) {
262
+ throw new Error ( 'Location header is missing' ) ;
263
+ }
264
+ const url = new URL ( location ) ;
265
+
266
+ expect ( url . hostname ) . toBe ( 'my-proxy.example.com' ) ;
267
+ expect ( url . pathname ) . toBe ( '/clerk-proxy/v1/client/handshake' ) ;
268
+ expect ( location ) . not . toContain ( 'clerk-proxy//v1/client/handshake' ) ;
269
+ } ) ;
270
+
271
+ it ( 'should handle non-HTTP frontendApi (domain only)' , ( ) => {
272
+ mockAuthenticateContext . frontendApi = 'api.clerk.com' ;
273
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
274
+ const location = headers . get ( constants . Headers . Location ) ;
275
+ if ( ! location ) {
276
+ throw new Error ( 'Location header is missing' ) ;
277
+ }
278
+ const url = new URL ( location ) ;
279
+
280
+ expect ( url . protocol ) . toBe ( 'https:' ) ;
281
+ expect ( url . hostname ) . toBe ( 'api.clerk.com' ) ;
282
+ expect ( url . pathname ) . toBe ( '/v1/client/handshake' ) ;
283
+ } ) ;
284
+
285
+ it ( 'should not include dev browser token in production mode' , ( ) => {
286
+ mockAuthenticateContext . instanceType = 'production' ;
287
+ mockAuthenticateContext . devBrowserToken = 'dev-token' ;
288
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
289
+ const location = headers . get ( constants . Headers . Location ) ;
290
+ if ( ! location ) {
291
+ throw new Error ( 'Location header is missing' ) ;
292
+ }
293
+ const url = new URL ( location ) ;
294
+
295
+ expect ( url . searchParams . get ( constants . QueryParameters . DevBrowser ) ) . toBeNull ( ) ;
296
+ } ) ;
297
+
298
+ it ( 'should not include dev browser token when not available in development' , ( ) => {
299
+ mockAuthenticateContext . instanceType = 'development' ;
300
+ mockAuthenticateContext . devBrowserToken = undefined ;
301
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
302
+ const location = headers . get ( constants . Headers . Location ) ;
303
+ if ( ! location ) {
304
+ throw new Error ( 'Location header is missing' ) ;
305
+ }
306
+ const url = new URL ( location ) ;
307
+
308
+ expect ( url . searchParams . get ( constants . QueryParameters . DevBrowser ) ) . toBeNull ( ) ;
309
+ } ) ;
310
+
311
+ it ( 'should handle usesSuffixedCookies returning false' , ( ) => {
312
+ mockAuthenticateContext . usesSuffixedCookies = vi . fn ( ) . mockReturnValue ( false ) ;
313
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
314
+ const location = headers . get ( constants . Headers . Location ) ;
315
+ if ( ! location ) {
316
+ throw new Error ( 'Location header is missing' ) ;
317
+ }
318
+ const url = new URL ( location ) ;
319
+
320
+ expect ( url . searchParams . get ( constants . QueryParameters . SuffixedCookies ) ) . toBe ( 'false' ) ;
321
+ } ) ;
322
+
323
+ it ( 'should include organization sync parameters when organization target is found' , ( ) => {
324
+ // Mock the organization sync methods
325
+ const mockTarget = { type : 'organization' , id : 'org_123' } ;
326
+ const mockParams = new Map ( [
327
+ [ 'org_id' , 'org_123' ] ,
328
+ [ 'org_slug' , 'test-org' ] ,
329
+ ] ) ;
330
+
331
+ vi . spyOn ( handshakeService as any , 'getOrganizationSyncTarget' ) . mockReturnValue ( mockTarget ) ;
332
+ vi . spyOn ( handshakeService as any , 'getOrganizationSyncQueryParams' ) . mockReturnValue ( mockParams ) ;
333
+
334
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
335
+ const location = headers . get ( constants . Headers . Location ) ;
336
+ if ( ! location ) {
337
+ throw new Error ( 'Location header is missing' ) ;
338
+ }
339
+ const url = new URL ( location ) ;
340
+
341
+ expect ( url . searchParams . get ( 'org_id' ) ) . toBe ( 'org_123' ) ;
342
+ expect ( url . searchParams . get ( 'org_slug' ) ) . toBe ( 'test-org' ) ;
343
+ } ) ;
344
+
345
+ it ( 'should not include organization sync parameters when no target is found' , ( ) => {
346
+ vi . spyOn ( handshakeService as any , 'getOrganizationSyncTarget' ) . mockReturnValue ( null ) ;
347
+
348
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
349
+ const location = headers . get ( constants . Headers . Location ) ;
350
+ if ( ! location ) {
351
+ throw new Error ( 'Location header is missing' ) ;
352
+ }
353
+ const url = new URL ( location ) ;
354
+
355
+ expect ( url . searchParams . get ( 'org_id' ) ) . toBeNull ( ) ;
356
+ expect ( url . searchParams . get ( 'org_slug' ) ) . toBeNull ( ) ;
357
+ } ) ;
358
+
359
+ it ( 'should handle different handshake reasons' , ( ) => {
360
+ const reasons = [ 'session-token-expired' , 'dev-browser-sync' , 'satellite-cookie-needs-syncing' ] ;
361
+
362
+ reasons . forEach ( reason => {
363
+ const headers = handshakeService . buildRedirectToHandshake ( reason ) ;
364
+ const location = headers . get ( constants . Headers . Location ) ;
365
+ if ( ! location ) {
366
+ throw new Error ( 'Location header is missing' ) ;
367
+ }
368
+ const url = new URL ( location ) ;
369
+
370
+ expect ( url . searchParams . get ( constants . QueryParameters . HandshakeReason ) ) . toBe ( reason ) ;
371
+ } ) ;
372
+ } ) ;
373
+
374
+ it ( 'should handle complex clerkUrl with query parameters and fragments' , ( ) => {
375
+ mockAuthenticateContext . clerkUrl = new URL ( 'https://example.com/path?existing=param#fragment' ) ;
376
+
377
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
378
+ const location = headers . get ( constants . Headers . Location ) ;
379
+ if ( ! location ) {
380
+ throw new Error ( 'Location header is missing' ) ;
381
+ }
382
+ const url = new URL ( location ) ;
383
+
384
+ const redirectUrl = url . searchParams . get ( 'redirect_url' ) ;
385
+ expect ( redirectUrl ) . toBe ( 'https://example.com/path?existing=param#fragment' ) ;
386
+ } ) ;
387
+
388
+ it ( 'should create valid URLs with different frontend API formats' , ( ) => {
389
+ const frontendApiFormats = [
390
+ 'api.clerk.com' ,
391
+ 'https://api.clerk.com' ,
392
+ 'https://api.clerk.com/' ,
393
+ 'foo-bar-13.clerk.accounts.dev' ,
394
+ 'https://foo-bar-13.clerk.accounts.dev' ,
395
+ 'clerk.example.com' ,
396
+ 'https://clerk.example.com/proxy-path' ,
397
+ ] ;
398
+
399
+ frontendApiFormats . forEach ( frontendApi => {
400
+ mockAuthenticateContext . frontendApi = frontendApi ;
401
+
402
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
403
+ const location = headers . get ( constants . Headers . Location ) ;
404
+
405
+ expect ( location ) . toBeDefined ( ) ;
406
+ if ( ! location ) {
407
+ throw new Error ( 'Location header should be defined' ) ;
408
+ }
409
+ expect ( ( ) => new URL ( location ) ) . not . toThrow ( ) ;
410
+
411
+ const url = new URL ( location ) ;
412
+ // Path should end with '/v1/client/handshake' (may have proxy path prefix)
413
+ expect ( url . pathname ) . toMatch ( / \/ v 1 \/ c l i e n t \/ h a n d s h a k e $ / ) ;
414
+ expect ( url . searchParams . get ( constants . QueryParameters . HandshakeReason ) ) . toBe ( 'test-reason' ) ;
415
+ } ) ;
416
+ } ) ;
417
+
418
+ it ( 'should always include required query parameters' , ( ) => {
419
+ const headers = handshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
420
+ const location = headers . get ( constants . Headers . Location ) ;
421
+ if ( ! location ) {
422
+ throw new Error ( 'Location header is missing' ) ;
423
+ }
424
+ const url = new URL ( location ) ;
425
+
426
+ // Verify all required parameters are present
427
+ expect ( url . searchParams . get ( 'redirect_url' ) ) . toBeDefined ( ) ;
428
+ expect ( url . searchParams . get ( '__clerk_api_version' ) ) . toBe ( '2025-04-10' ) ;
429
+ expect ( url . searchParams . get ( constants . QueryParameters . SuffixedCookies ) ) . toMatch ( / ^ ( t r u e | f a l s e ) $ / ) ;
430
+ expect ( url . searchParams . get ( constants . QueryParameters . HandshakeReason ) ) . toBe ( 'test-reason' ) ;
431
+ } ) ;
208
432
} ) ;
209
433
210
434
describe ( 'handleTokenVerificationErrorInDevelopment' , ( ) => {
@@ -320,4 +544,59 @@ describe('HandshakeService', () => {
320
544
spy . mockRestore ( ) ;
321
545
} ) ;
322
546
} ) ;
547
+
548
+ describe ( 'URL construction edge cases' , ( ) => {
549
+ const trailingSlashTestCases = [
550
+ { input : 'https://example.com' , expected : 'https://example.com' } ,
551
+ { input : 'https://example.com/' , expected : 'https://example.com' } ,
552
+ { input : 'https://example.com//' , expected : 'https://example.com' } ,
553
+ { input : 'https://example.com///' , expected : 'https://example.com' } ,
554
+ { input : 'https://example.com/path' , expected : 'https://example.com/path' } ,
555
+ { input : 'https://example.com/path/' , expected : 'https://example.com/path' } ,
556
+ { input : 'https://example.com/path//' , expected : 'https://example.com/path' } ,
557
+ { input : 'https://example.com/proxy-path///' , expected : 'https://example.com/proxy-path' } ,
558
+ ] ;
559
+
560
+ trailingSlashTestCases . forEach ( ( { input, expected } ) => {
561
+ it ( `should correctly handle trailing slashes: "${ input } " -> "${ expected } "` , ( ) => {
562
+ const result = input . replace ( / \/ + $ / , '' ) ;
563
+ expect ( result ) . toBe ( expected ) ;
564
+ } ) ;
565
+ } ) ;
566
+
567
+ it ( 'should construct valid handshake URLs with various proxy configurations' , ( ) => {
568
+ const proxyConfigs = [
569
+ 'https://proxy.example.com' ,
570
+ 'https://proxy.example.com/' ,
571
+ 'https://proxy.example.com//' ,
572
+ 'https://proxy.example.com/clerk' ,
573
+ 'https://proxy.example.com/clerk/' ,
574
+ 'https://proxy.example.com/clerk//' ,
575
+ 'https://api.example.com/v1/clerk///' ,
576
+ ] ;
577
+
578
+ proxyConfigs . forEach ( proxyUrl => {
579
+ const isolatedContext = {
580
+ ...mockAuthenticateContext ,
581
+ proxyUrl : proxyUrl ,
582
+ frontendApi : proxyUrl ,
583
+ } as AuthenticateContext ;
584
+
585
+ const isolatedHandshakeService = new HandshakeService ( isolatedContext , mockOptions , mockOrganizationMatcher ) ;
586
+
587
+ const headers = isolatedHandshakeService . buildRedirectToHandshake ( 'test-reason' ) ;
588
+ const location = headers . get ( constants . Headers . Location ) ;
589
+
590
+ expect ( location ) . toBeDefined ( ) ;
591
+ if ( ! location ) {
592
+ throw new Error ( 'Location header should be defined' ) ;
593
+ }
594
+ expect ( location ) . toContain ( '/v1/client/handshake' ) ;
595
+ expect ( location ) . not . toContain ( '//v1/client/handshake' ) ; // No double slashes
596
+
597
+ // Ensure URL is valid
598
+ expect ( ( ) => new URL ( location ) ) . not . toThrow ( ) ;
599
+ } ) ;
600
+ } ) ;
601
+ } ) ;
323
602
} ) ;
0 commit comments