@@ -385,67 +385,84 @@ describe('request', () => {
385385 } ) ;
386386
387387 describe ( 'Protect params' , ( ) => {
388- const protectParams = { __clerk_protect_assertion : 'token-abc' } ;
389- const clientWithProtect = createFapiClient ( {
390- ...baseFapiClientOptions ,
391- getProtectParams : ( ) => Promise . resolve ( protectParams ) ,
388+ // Two independent features feed this one hook — an application-supplied assertion and the
389+ // server-configured session token — so the fixture carries params from both.
390+ const protectParams = {
391+ __clerk_protect_assertion : 'token-abc' ,
392+ __clerk_protect_token : 'v1.payload.mac' ,
393+ __clerk_protect_status : 'ok' ,
394+ __clerk_protect_cid : `1-${ 'a' . repeat ( 26 ) } -${ 'b' . repeat ( 26 ) } ` ,
395+ } ;
396+ const expectedProtectQuery =
397+ '__clerk_protect_assertion=token-abc&__clerk_protect_token=v1.payload.mac&__clerk_protect_status=ok' +
398+ `&__clerk_protect_cid=${ protectParams . __clerk_protect_cid } ` ;
399+
400+ let getProtectParams : Mock ;
401+ let clientWithProtect : ReturnType < typeof createFapiClient > ;
402+
403+ beforeEach ( ( ) => {
404+ getProtectParams = vi . fn ( ) . mockResolvedValue ( protectParams ) ;
405+ clientWithProtect = createFapiClient ( { ...baseFapiClientOptions , getProtectParams } ) ;
392406 } ) ;
393407
394- it . each ( [
395- [ '/client/sign_ins' ] ,
396- [ '/client/sign_ins/sia_123/attempt_first_factor' ] ,
397- [ '/client/sign_ups' ] ,
398- [ '/client/sign_ups/sua_123/attempt_verification' ] ,
399- ] ) ( 'attaches them to POST %s' , async path => {
400- await clientWithProtect . request ( { path, method : 'POST' , body : { identifier : 'user@example.com' } as any } ) ;
408+ const bodyOf = ( ) => ( fetch as Mock ) . mock . calls [ 0 ] [ 1 ] . body as string ;
401409
402- expect ( fetch ) . toHaveBeenCalledWith (
403- expect . any ( URL ) ,
404- expect . objectContaining ( {
405- body : 'identifier=user%40example.com&__clerk_protect_assertion=token-abc' ,
406- } ) ,
407- ) ;
410+ it . each ( [
411+ '/client/sign_ins' ,
412+ '/client/sign_ups' ,
413+ '/client/sign_ins/sia_123/attempt_first_factor' ,
414+ '/client/sign_ups/sua_123/attempt_verification' ,
415+ ] ) ( 'merges them into the form-encoded body of %s' , async path => {
416+ await clientWithProtect . request ( { path, method : 'POST' , body : { identifier : 'nick@clerk.dev' } as any } ) ;
417+
418+ expect ( bodyOf ( ) ) . toBe ( `identifier=nick%40clerk.dev&${ expectedProtectQuery } ` ) ;
419+ // A signed credential must never land in the URL, which is logged all along the path.
420+ expect ( ( fetch as Mock ) . mock . calls [ 0 ] [ 0 ] . toString ( ) ) . not . toContain ( '__clerk_protect' ) ;
408421 } ) ;
409422
410- it ( 'attaches them when the request has no body of its own ' , async ( ) => {
411- await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' } ) ;
423+ it ( 'adds no request headers ' , async ( ) => {
424+ await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' , body : { } as any } ) ;
412425
413- expect ( fetch ) . toHaveBeenCalledWith (
414- expect . any ( URL ) ,
415- expect . objectContaining ( { body : '__clerk_protect_assertion=token-abc' } ) ,
416- ) ;
426+ const headers = ( fetch as Mock ) . mock . calls [ 0 ] [ 1 ] . headers as Headers ;
427+ expect ( [ ...headers . keys ( ) ] ) . toEqual ( [ 'content-type' ] ) ;
417428 } ) ;
418429
419- // All lower-case, so the camel-to-snake body key encoder has nothing to rewrite.
420- it ( 'does not mangle the param name' , async ( ) => {
421- await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' } ) ;
430+ // Also pins the param names against the camel-to-snake body key encoder: they are all
431+ // lower-case, so it has nothing to rewrite.
432+ it ( 'populates the body even when the request had none' , async ( ) => {
433+ await clientWithProtect . request ( { path : '/client/sign_ups' , method : 'POST' } ) ;
422434
423- const [ , init ] = ( fetch as Mock ) . mock . calls . at ( - 1 ) ! ;
424- expect ( init . body ) . toBe ( '__clerk_protect_assertion=token-abc' ) ;
435+ expect ( bodyOf ( ) ) . toBe ( expectedProtectQuery ) ;
425436 } ) ;
426437
427- it . each ( [
428- [ 'a GET' , 'GET' , '/client/sign_ins' ] ,
429- [ 'an unrelated path' , 'POST' , '/client/sessions' ] ,
430- [ 'a path that merely shares a prefix' , 'POST' , '/client/sign_ins_other' ] ,
431- ] ) ( 'does not attach them to %s' , async ( _label , method , path ) => {
432- await clientWithProtect . request ( { path, method : method as any , body : { a : 'b' } as any } ) ;
433-
434- const [ , init ] = ( fetch as Mock ) . mock . calls . at ( - 1 ) ! ;
435- expect ( init . body ?? '' ) . not . toContain ( '__clerk_protect_assertion' ) ;
438+ it . each ( [ '/client' , '/client/sessions' , '/environment' , '/client/sign_insomething' , '/client/sign_ins_other' ] ) (
439+ 'leaves %s alone' ,
440+ async path => {
441+ await clientWithProtect . request ( { path, method : 'POST' , body : { foo : 'bar' } as any } ) ;
442+
443+ expect ( bodyOf ( ) ) . toBe ( 'foo=bar' ) ;
444+ expect ( getProtectParams ) . not . toHaveBeenCalled ( ) ;
445+ } ,
446+ ) ;
447+
448+ it ( 'leaves GET requests alone' , async ( ) => {
449+ await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'GET' } ) ;
450+
451+ expect ( getProtectParams ) . not . toHaveBeenCalled ( ) ;
436452 } ) ;
437453
438454 // Spreading a FormData would discard the caller's payload, so non-plain bodies are left alone.
439- it ( 'leaves a FormData body untouched ' , async ( ) => {
455+ it ( 'leaves a FormData body alone ' , async ( ) => {
440456 const formData = new FormData ( ) ;
441- formData . append ( 'identifier' , 'user@example.com ' ) ;
457+ formData . append ( 'identifier' , 'nick@clerk.dev ' ) ;
442458
443459 await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' , body : formData } ) ;
444460
445- expect ( fetch ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { body : formData } ) ) ;
461+ expect ( ( fetch as Mock ) . mock . calls [ 0 ] [ 1 ] . body ) . toBe ( formData ) ;
462+ expect ( getProtectParams ) . not . toHaveBeenCalled ( ) ;
446463 } ) ;
447464
448- it ( 'leaves a string body untouched ' , async ( ) => {
465+ it ( 'leaves a string body alone ' , async ( ) => {
449466 // text/plain keeps the form-urlencoded encoder out of it.
450467 await clientWithProtect . request ( {
451468 path : '/client/sign_ins' ,
@@ -454,38 +471,44 @@ describe('request', () => {
454471 headers : { 'content-type' : 'text/plain' } ,
455472 } ) ;
456473
457- expect ( fetch ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { body : 'raw string body' } ) ) ;
474+ expect ( bodyOf ( ) ) . toBe ( 'raw string body' ) ;
475+ expect ( getProtectParams ) . not . toHaveBeenCalled ( ) ;
458476 } ) ;
459477
460- // Protect may influence a sign-in but must never fail one.
461- it ( 'sends the request unchanged when resolving the params rejects' , async ( ) => {
462- const failing = createFapiClient ( {
463- ...baseFapiClientOptions ,
464- getProtectParams : ( ) => Promise . reject ( new Error ( 'boom' ) ) ,
465- } ) ;
478+ // Merging into any of these would spread away the caller's payload rather than add to it.
479+ it . each ( [
480+ [ 'a Blob' , ( ) => new Blob ( [ 'payload' ] ) ] ,
481+ [ 'an array' , ( ) => [ 1 , 2 , 3 ] ] ,
482+ [ 'a URLSearchParams' , ( ) => new URLSearchParams ( { identifier : 'nick@clerk.dev' } ) ] ,
483+ ] ) ( 'leaves %s body alone' , async ( _label , makeBody ) => {
484+ await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' , body : makeBody ( ) as any } ) ;
485+
486+ expect ( getProtectParams ) . not . toHaveBeenCalled ( ) ;
487+ expect ( String ( ( fetch as Mock ) . mock . calls [ 0 ] [ 1 ] . body ) ) . not . toContain ( '__clerk_protect' ) ;
488+ } ) ;
466489
467- await expect (
468- failing . request ( { path : '/client/sign_ins' , method : 'POST' , body : { identifier : 'a' } as any } ) ,
469- ) . resolves . toBeTruthy ( ) ;
490+ it ( 'sends nothing extra when the instance contributes no params' , async ( ) => {
491+ getProtectParams . mockResolvedValue ( undefined ) ;
470492
471- expect ( fetch ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { body : 'identifier=a' } ) ) ;
472- } ) ;
493+ await clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' , body : { foo : 'bar' } as any } ) ;
473494
474- it ( 'sends the request unchanged when there are no params' , async ( ) => {
475- const none = createFapiClient ( {
476- ...baseFapiClientOptions ,
477- getProtectParams : ( ) => Promise . resolve ( undefined ) ,
478- } ) ;
495+ expect ( bodyOf ( ) ) . toBe ( 'foo=bar' ) ;
496+ } ) ;
479497
480- await none . request ( { path : '/client/sign_ins' , method : 'POST' , body : { identifier : 'a' } as any } ) ;
498+ it ( 'still sends the request when resolving the params rejects' , async ( ) => {
499+ getProtectParams . mockRejectedValue ( new DOMException ( 'storage is blocked' , 'SecurityError' ) ) ;
481500
482- expect ( fetch ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { body : 'identifier=a' } ) ) ;
501+ // Protect can degrade a sign-in but must never fail one before it is even sent.
502+ await expect (
503+ clientWithProtect . request ( { path : '/client/sign_ins' , method : 'POST' , body : { foo : 'bar' } as any } ) ,
504+ ) . resolves . toBeDefined ( ) ;
505+ expect ( bodyOf ( ) ) . toBe ( 'foo=bar' ) ;
483506 } ) ;
484507
485508 it ( 'is inert when no hook is configured' , async ( ) => {
486509 await fapiClient . request ( { path : '/client/sign_ins' , method : 'POST' , body : { identifier : 'a' } as any } ) ;
487510
488- expect ( fetch ) . toHaveBeenCalledWith ( expect . any ( URL ) , expect . objectContaining ( { body : 'identifier=a' } ) ) ;
511+ expect ( bodyOf ( ) ) . toBe ( 'identifier=a' ) ;
489512 } ) ;
490513 } ) ;
491514
0 commit comments