@@ -10,11 +10,12 @@ const noopExecFile = vi.fn() as unknown as typeof import('node:child_process').e
1010 * (built from the localhost callback). Returns the callback URL so the test can
1111 * drive the localhost server directly.
1212 */
13- function startCallback ( options ?: {
13+ function startCallback < T = URLSearchParams > ( options ?: {
1414 describeError ?: ( params : URLSearchParams ) => string | null ;
15+ finalize ?: ( params : URLSearchParams ) => Promise < T > ;
1516 state ?: string ;
1617} ) : {
17- resultPromise : Promise < URLSearchParams > ;
18+ resultPromise : Promise < T > ;
1819 callbackPromise : Promise < URL > ;
1920 state : string ;
2021} {
@@ -24,10 +25,14 @@ function startCallback(options?: {
2425 resolveCallback = resolve ;
2526 } ) ;
2627
27- const resultPromise = awaitBrowserCallback ( {
28+ const finalize =
29+ options ?. finalize ?? ( ( params : URLSearchParams ) => Promise . resolve ( params as unknown as T ) ) ;
30+
31+ const resultPromise = awaitBrowserCallback < T > ( {
2832 state,
2933 execFile : noopExecFile ,
3034 describeError : options ?. describeError ,
35+ finalize,
3136 buildAuthUrl : ( callback ) => {
3237 resolveCallback ( callback ) ;
3338 return 'https://example.test/authorize' ;
@@ -38,8 +43,11 @@ function startCallback(options?: {
3843}
3944
4045describe ( 'awaitBrowserCallback' , ( ) => {
41- it ( 'resolves with the callback params on a valid /callback request' , async ( ) => {
42- const { resultPromise, callbackPromise, state } = startCallback ( ) ;
46+ it ( 'runs finalize in the request path and resolves with its return value' , async ( ) => {
47+ const finalize = vi . fn ( async ( params : URLSearchParams ) => ( {
48+ linked : params . get ( 'link_code' ) ,
49+ } ) ) ;
50+ const { resultPromise, callbackPromise, state } = startCallback ( { finalize } ) ;
4351 const callback = await callbackPromise ;
4452
4553 // The helper appends the caller-supplied state to the callback URL.
@@ -54,13 +62,45 @@ describe('awaitBrowserCallback', () => {
5462 const res = await fetch ( hit ) ;
5563 expect ( res . status ) . toBe ( 200 ) ;
5664
57- const params = await resultPromise ;
58- expect ( params . get ( 'link_code' ) ) . toBe ( 'abc' ) ;
59- expect ( params . get ( 'state' ) ) . toBe ( state ) ;
65+ // finalize ran inside the request handler with the callback params, and the
66+ // outer promise resolves with finalize's return value.
67+ expect ( finalize ) . toHaveBeenCalledTimes ( 1 ) ;
68+ expect ( finalize . mock . calls [ 0 ] ! [ 0 ] . get ( 'link_code' ) ) . toBe ( 'abc' ) ;
69+ expect ( finalize . mock . calls [ 0 ] ! [ 0 ] . get ( 'state' ) ) . toBe ( state ) ;
70+
71+ await expect ( resultPromise ) . resolves . toEqual ( { linked : 'abc' } ) ;
6072 } ) ;
6173
62- it ( 'returns 400 and keeps listening on a state mismatch (does not resolve)' , async ( ) => {
63- const { resultPromise, callbackPromise, state } = startCallback ( ) ;
74+ it ( 'renders the failure page and rejects when finalize throws (F3 regression pin)' , async ( ) => {
75+ const finalizeError = new Error ( 'exchange blew up' ) ;
76+ const finalize = vi . fn ( async ( ) => {
77+ throw finalizeError ;
78+ } ) ;
79+ const { resultPromise, callbackPromise, state } = startCallback ( { finalize } ) ;
80+ const callback = await callbackPromise ;
81+
82+ // Attach the rejection assertion before triggering the request so the
83+ // handler is in place when the promise settles (no unhandled rejection).
84+ const rejection = expect ( resultPromise ) . rejects . toBe ( finalizeError ) ;
85+
86+ const hit = new URL ( callback ) ;
87+ hit . searchParams . set ( 'state' , state ) ;
88+ hit . searchParams . set ( 'link_code' , 'abc' ) ;
89+
90+ const res = await fetch ( hit ) ;
91+ // The browser must NOT see the success page when finalize fails.
92+ expect ( res . status ) . toBe ( 400 ) ;
93+ const body = await res . text ( ) ;
94+ expect ( body ) . not . toContain ( 'successful' ) ;
95+ expect ( body ) . toContain ( 'Could not complete' ) ;
96+
97+ expect ( finalize ) . toHaveBeenCalledTimes ( 1 ) ;
98+ await rejection ;
99+ } ) ;
100+
101+ it ( 'returns 400 and keeps listening on a state mismatch (does not call finalize)' , async ( ) => {
102+ const finalize = vi . fn ( async ( params : URLSearchParams ) => params ) ;
103+ const { resultPromise, callbackPromise, state } = startCallback ( { finalize } ) ;
64104 const callback = await callbackPromise ;
65105
66106 const bad = new URL ( callback ) ;
@@ -70,6 +110,9 @@ describe('awaitBrowserCallback', () => {
70110 const badRes = await fetch ( bad ) ;
71111 expect ( badRes . status ) . toBe ( 400 ) ;
72112
113+ // A state mismatch is rejected before finalize runs.
114+ expect ( finalize ) . not . toHaveBeenCalled ( ) ;
115+
73116 // The genuine callback still completes — the server stayed open.
74117 const good = new URL ( callback ) ;
75118 good . searchParams . set ( 'state' , state ) ;
@@ -79,6 +122,7 @@ describe('awaitBrowserCallback', () => {
79122 expect ( goodRes . status ) . toBe ( 200 ) ;
80123
81124 const params = await resultPromise ;
125+ expect ( finalize ) . toHaveBeenCalledTimes ( 1 ) ;
82126 expect ( params . get ( 'link_code' ) ) . toBe ( 'real' ) ;
83127 } ) ;
84128
@@ -107,6 +151,7 @@ describe('awaitBrowserCallback', () => {
107151 state : 'timeout-state' ,
108152 execFile : noopExecFile ,
109153 timeoutMs : 10 ,
154+ finalize : async ( params ) => params ,
110155 buildAuthUrl : ( callback ) =>
111156 `https://example.test/authorize?cb=${ encodeURIComponent ( callback . href ) } ` ,
112157 } ) ;
@@ -124,6 +169,7 @@ describe('awaitBrowserCallback', () => {
124169 const resultPromise = awaitBrowserCallback ( {
125170 state : 's' ,
126171 execFile,
172+ finalize : async ( params ) => params ,
127173 buildAuthUrl : ( callback ) => {
128174 resolveCallback ( callback ) ;
129175 return 'https://example.test/authorize' ;
0 commit comments