@@ -8,6 +8,7 @@ import * as Http from '~test/Http.js'
88import { accounts , asset , client } from '~test/tempo/viem.js'
99
1010import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js'
11+ import * as MethodChallenge from './internal/MethodChallenge.js'
1112
1213const realm = 'api.example.com'
1314const secretKey = 'test-secret-key-test-secret-key-32'
@@ -345,6 +346,279 @@ describe('preparePayment', () => {
345346 } )
346347} )
347348
349+ describe ( 'prepareRequest' , ( ) => {
350+ function setup ( fetch : typeof globalThis . fetch ) {
351+ const method = Method . toClient (
352+ Method . from ( { name : 'test' , intent : 'charge' , schema : Methods . charge . schema } ) ,
353+ {
354+ async createCredential ( { challenge } ) {
355+ return Credential . serialize ( {
356+ challenge,
357+ payload : { signature : '0xsignature' , type : 'transaction' } ,
358+ } )
359+ } ,
360+ } ,
361+ )
362+ return Mppx . create ( { fetch, methods : [ method ] , polyfill : false } )
363+ }
364+
365+ function paymentRequired ( header ?: string ) {
366+ const challenge = Challenge . from ( {
367+ expires : new Date ( Date . now ( ) + 60_000 ) . toISOString ( ) ,
368+ header,
369+ id : 'prepared-request' ,
370+ intent : 'charge' ,
371+ method : 'test' ,
372+ realm,
373+ request : { amount : '100' , currency : asset } ,
374+ } )
375+ return new Response ( null , {
376+ headers : { 'WWW-Authenticate' : Challenge . serialize ( challenge ) } ,
377+ status : 402 ,
378+ } )
379+ }
380+
381+ test ( 'behavior: retains the redirected request and pins credential delivery' , async ( ) => {
382+ const requests : Request [ ] = [ ]
383+ const fetch = vi . fn ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
384+ const request = input instanceof Request ? input : new Request ( input , init )
385+ requests . push ( request )
386+ if ( requests . length === 1 )
387+ return new Response ( null , { headers : { location : '/checkout' } , status : 303 } )
388+ if ( requests . length === 2 ) return paymentRequired ( 'Payment-Credential' )
389+ return new Response ( null , { headers : { location : '/elsewhere' } , status : 307 } )
390+ } )
391+ const mppx = setup ( fetch as typeof globalThis . fetch )
392+
393+ const prepared = await mppx . prepareRequest ( 'https://shop.example/start' , {
394+ body : 'item=book' ,
395+ headers : {
396+ Authorization : 'Bearer caller' ,
397+ 'Content-Type' : 'application/x-www-form-urlencoded' ,
398+ } ,
399+ method : 'POST' ,
400+ } )
401+
402+ expect ( prepared . request . url ) . toBe ( 'https://shop.example/checkout' )
403+ expect ( prepared . request . method ) . toBe ( 'GET' )
404+ expect ( prepared . request . headers . has ( 'content-type' ) ) . toBe ( false )
405+ expect ( prepared . redirects ) . toEqual ( [
406+ {
407+ from : 'https://shop.example/start' ,
408+ status : 303 ,
409+ to : 'https://shop.example/checkout' ,
410+ } ,
411+ ] )
412+ expect ( Object . isFrozen ( prepared ) ) . toBe ( true )
413+ expect ( Object . isFrozen ( prepared . redirects ) ) . toBe ( true )
414+
415+ const response = await prepared . pay ( )
416+
417+ expect ( response . status ) . toBe ( 307 )
418+ expect ( requests ) . toHaveLength ( 3 )
419+ expect ( requests [ 2 ] ?. url ) . toBe ( 'https://shop.example/checkout' )
420+ expect ( requests [ 2 ] ?. redirect ) . toBe ( 'manual' )
421+ expect ( requests [ 2 ] ?. headers . get ( 'Payment-Credential' ) ) . toMatch ( / ^ P a y m e n t / )
422+ } )
423+
424+ test ( 'security: strips credentials on pre-payment cross-origin redirects' , async ( ) => {
425+ const requests : Request [ ] = [ ]
426+ const fetch = vi . fn ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
427+ const request = input instanceof Request ? input : new Request ( input , init )
428+ requests . push ( request )
429+ return requests . length === 1
430+ ? new Response ( null , {
431+ headers : { location : 'https://pay.example/resource' } ,
432+ status : 307 ,
433+ } )
434+ : paymentRequired ( )
435+ } )
436+ const mppx = setup ( fetch as typeof globalThis . fetch )
437+
438+ const prepared = await mppx . prepareRequest ( 'https://shop.example/start' , {
439+ headers : {
440+ Authorization : 'Bearer secret' ,
441+ Cookie : 'session=secret' ,
442+ 'Payment-Authorization' : 'secret' ,
443+ 'PAYMENT-SIGNATURE' : 'secret' ,
444+ 'X-Alternate-Credential' : 'Payment secret' ,
445+ 'X-PAYMENT' : 'secret' ,
446+ 'X-Public' : 'value' ,
447+ } ,
448+ } )
449+
450+ expect ( prepared . request . url ) . toBe ( 'https://pay.example/resource' )
451+ expect ( prepared . request . headers . get ( 'authorization' ) ) . toBeNull ( )
452+ expect ( prepared . request . headers . get ( 'cookie' ) ) . toBeNull ( )
453+ expect ( prepared . request . headers . get ( 'payment-authorization' ) ) . toBeNull ( )
454+ expect ( prepared . request . headers . get ( 'payment-signature' ) ) . toBeNull ( )
455+ expect ( prepared . request . headers . get ( 'x-alternate-credential' ) ) . toBeNull ( )
456+ expect ( prepared . request . headers . get ( 'x-payment' ) ) . toBeNull ( )
457+ expect ( prepared . request . headers . get ( 'x-public' ) ) . toBe ( 'value' )
458+ } )
459+
460+ test ( 'behavior: preserves string bodies for MCP-over-HTTP' , async ( ) => {
461+ const requests : Request [ ] = [ ]
462+ const challenge = Challenge . fromResponseList ( paymentRequired ( ) ) [ 0 ] !
463+ const fetch = vi . fn ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
464+ const request = input instanceof Request ? input : new Request ( input , init )
465+ requests . push ( request )
466+ if ( requests . length > 1 ) return new Response ( 'paid' )
467+ return new Response (
468+ JSON . stringify ( {
469+ error : {
470+ code : Mcp . paymentRequiredCode ,
471+ data : { challenges : [ challenge ] } ,
472+ message : 'Payment Required' ,
473+ } ,
474+ id : 1 ,
475+ jsonrpc : '2.0' ,
476+ } ) ,
477+ { headers : { 'content-type' : 'application/json' } } ,
478+ )
479+ } )
480+ const mppx = setup ( fetch as typeof globalThis . fetch )
481+ const body = JSON . stringify ( { jsonrpc : '2.0' , id : 1 , method : 'tools/call' , params : { } } )
482+
483+ const prepared = await mppx . prepareRequest ( 'https://mcp.example/messages' , {
484+ body,
485+ headers : { accept : 'application/json, text/event-stream' } ,
486+ method : 'POST' ,
487+ } )
488+ await prepared . pay ( )
489+
490+ const paidBody = JSON . parse ( await requests [ 1 ] ! . clone ( ) . text ( ) )
491+ expect ( paidBody . params . _meta [ Mcp . credentialMetaKey ] ) . toBeDefined ( )
492+ } )
493+
494+ test ( 'behavior: returns the attested request that produced the challenge' , async ( ) => {
495+ const requests : Request [ ] = [ ]
496+ const fetch = vi . fn ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
497+ const request = input instanceof Request ? input : new Request ( input , init )
498+ requests . push ( request )
499+ return paymentRequired ( )
500+ } )
501+ const methods = setup ( fetch as typeof globalThis . fetch ) . methods
502+ const mppx = Mppx . create ( {
503+ attestation : {
504+ test : {
505+ protocol : 'test' ,
506+ sign ( request ) {
507+ const headers = new Headers ( request . headers )
508+ headers . set ( 'Signature' , 'test-signature' )
509+ return new Request ( request , { headers } )
510+ } ,
511+ } ,
512+ } ,
513+ fetch : fetch as typeof globalThis . fetch ,
514+ methods,
515+ polyfill : false ,
516+ } )
517+
518+ const prepared = await mppx . prepareRequest ( 'https://shop.example/resource' )
519+
520+ expect ( prepared . request ) . toBe ( requests [ 0 ] )
521+ expect ( prepared . request . headers . get ( 'signature' ) ) . toBe ( 'test-signature' )
522+ } )
523+
524+ test ( 'behavior: bypasses previously installed payment wrappers' , async ( ) => {
525+ const originalFetch = globalThis . fetch
526+ const fetch = vi
527+ . fn ( )
528+ . mockResolvedValueOnce ( paymentRequired ( ) )
529+ . mockResolvedValueOnce ( new Response ( 'paid' ) )
530+ const methods = setup ( fetch as typeof globalThis . fetch ) . methods
531+ globalThis . fetch = fetch as typeof globalThis . fetch
532+ try {
533+ Mppx . create ( { fetch : fetch as typeof globalThis . fetch , methods } )
534+ const mppx = Mppx . create ( { methods, polyfill : false } )
535+
536+ const prepared = await mppx . prepareRequest ( 'https://shop.example/resource' )
537+
538+ expect ( prepared . response . status ) . toBe ( 402 )
539+ expect ( fetch ) . toHaveBeenCalledOnce ( )
540+ } finally {
541+ Mppx . restore ( )
542+ globalThis . fetch = originalFetch
543+ }
544+ } )
545+
546+ test ( 'behavior: applies Accept-Payment policy after redirects' , async ( ) => {
547+ const requests : Request [ ] = [ ]
548+ const fetch = vi . fn ( async ( input : RequestInfo | URL , init ?: RequestInit ) => {
549+ const request = input instanceof Request ? input : new Request ( input , init )
550+ requests . push ( request )
551+ return requests . length === 1
552+ ? new Response ( null , {
553+ headers : { location : 'https://pay.example/resource' } ,
554+ status : 307 ,
555+ } )
556+ : paymentRequired ( )
557+ } )
558+ const methods = setup ( fetch as typeof globalThis . fetch ) . methods
559+ const mppx = Mppx . create ( {
560+ acceptPaymentPolicy : { origins : [ 'https://shop.example' ] } ,
561+ fetch : fetch as typeof globalThis . fetch ,
562+ methods,
563+ polyfill : false ,
564+ } )
565+
566+ await mppx . prepareRequest ( 'https://shop.example/resource' )
567+
568+ expect ( requests [ 0 ] ! . headers . get ( 'accept-payment' ) ) . toBe ( 'test/charge' )
569+ expect ( requests [ 1 ] ! . headers . get ( 'accept-payment' ) ) . toBeNull ( )
570+ } )
571+
572+ test ( 'behavior: runs method preparation before creating a credential' , async ( ) => {
573+ const mppx = setup ( vi . fn ( async ( ) => paymentRequired ( ) ) as typeof globalThis . fetch )
574+ const prepare = vi . fn ( )
575+ MethodChallenge . register ( mppx . methods [ 0 ] ! , prepare )
576+ const prepared = await mppx . prepareRequest ( 'https://shop.example/resource' )
577+
578+ await prepared . createCredential ( )
579+
580+ expect ( prepare ) . toHaveBeenCalledOnce ( )
581+ expect ( prepare . mock . calls [ 0 ] ?. [ 0 ] . input ) . toBeInstanceOf ( Request )
582+ } )
583+
584+ test ( 'error: explains opaque browser redirects' , async ( ) => {
585+ const opaqueRedirect = {
586+ headers : new Headers ( ) ,
587+ status : 0 ,
588+ type : 'opaqueredirect' ,
589+ } as Response
590+ const mppx = setup ( vi . fn ( async ( ) => opaqueRedirect ) as typeof globalThis . fetch )
591+
592+ await expect ( mppx . prepareRequest ( 'https://shop.example/resource' ) ) . rejects . toThrow (
593+ / r u n t i m e t h a t e x p o s e s m a n u a l r e d i r e c t r e s p o n s e s / ,
594+ )
595+ } )
596+
597+ test ( 'security: rejects HTTPS downgrade redirects' , async ( ) => {
598+ const fetch = vi . fn (
599+ async ( ) =>
600+ new Response ( null , {
601+ headers : { location : 'http://shop.example/resource' } ,
602+ status : 302 ,
603+ } ) ,
604+ )
605+ const mppx = setup ( fetch as typeof globalThis . fetch )
606+
607+ await expect ( mppx . prepareRequest ( 'https://shop.example/start' ) ) . rejects . toThrow (
608+ / H T T P S d o w n g r a d e / ,
609+ )
610+ expect ( fetch ) . toHaveBeenCalledOnce ( )
611+ } )
612+
613+ test ( 'error: validates the redirect limit' , async ( ) => {
614+ const mppx = setup ( vi . fn ( ) as typeof globalThis . fetch )
615+
616+ await expect (
617+ mppx . prepareRequest ( 'https://shop.example/start' , undefined , { maxRedirects : - 1 } ) ,
618+ ) . rejects . toThrow ( / n o n - n e g a t i v e i n t e g e r / )
619+ } )
620+ } )
621+
348622describe ( 'createCredential' , ( ) => {
349623 function sessionChallenge ( id : string , sessionProtocol ?: string ) {
350624 return {
0 commit comments