@@ -109,7 +109,11 @@ describe('AuthenticationService', () => {
109109 'neg-cache-token'
110110 )
111111 expect ( global . fetch ) . toHaveBeenCalledWith (
112- expect . stringContaining ( '/api/v1/password-protection/status' ) ,
112+ expect . objectContaining ( {
113+ href : expect . stringMatching (
114+ / \/ a p i \/ v 1 \/ p a s s w o r d - p r o t e c t i o n \/ s t a t u s .* s t o r e I d = t e s t - s t o r e /
115+ ) ,
116+ } ) ,
113117 expect . any ( Object )
114118 )
115119 } )
@@ -150,6 +154,58 @@ describe('AuthenticationService', () => {
150154 expect ( response . status ) . toBe ( 200 )
151155 } )
152156
157+ it ( 'redirects to login when protected for ALL_DOMAINS on custom host' , async ( ) => {
158+ process . env . CUSTOM_DOMAINS_PROTECTION_ENABLED = 'true'
159+ ; ( global . fetch as jest . Mock ) . mockResolvedValue ( {
160+ ok : true ,
161+ json : async ( ) => ( {
162+ protected : true ,
163+ scope : 'ALL_DOMAINS' ,
164+ } ) ,
165+ } )
166+
167+ const service = new AuthenticationService ( )
168+ const { response } = await service . authenticateRequest (
169+ new NextRequest ( 'https://shop.example.com/checkout' , {
170+ headers : { host : 'shop.example.com' } ,
171+ } )
172+ )
173+
174+ expect ( response . status ) . toBe ( 307 )
175+ expect ( response . headers . get ( 'location' ) ) . toContain ( 'returnTo=%2Fcheckout' )
176+ } )
177+
178+ it ( 'allows traffic when JWT is valid and store is password-protected' , async ( ) => {
179+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
180+ ok : true ,
181+ json : async ( ) => ( { publicKey : 'test-pem' } ) ,
182+ } )
183+
184+ jwtVerifyMock . mockResolvedValueOnce ( {
185+ payload : {
186+ storeId : 'test-store' ,
187+ protected : true ,
188+ scope : 'DEFAULT_DOMAINS' ,
189+ } ,
190+ protectedHeader : { alg : 'RS256' } ,
191+ } as unknown as Awaited < ReturnType < typeof jwtVerify > > )
192+
193+ const service = new AuthenticationService ( )
194+ const { response } = await service . authenticateRequest (
195+ previewRequest ( '/account' , {
196+ headers : { cookie : '__fs_auth_token=valid' } ,
197+ } )
198+ )
199+
200+ expect ( response . status ) . toBe ( 200 )
201+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
202+ expect ( ( global . fetch as jest . Mock ) . mock . calls [ 0 ] [ 0 ] ) . toEqual (
203+ expect . objectContaining ( {
204+ pathname : '/api/v1/password-protection/public-key' ,
205+ } )
206+ )
207+ } )
208+
153209 it ( 'verifies JWT locally when cookie present (not protected payload)' , async ( ) => {
154210 ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
155211 ok : true ,
@@ -210,6 +266,33 @@ describe('AuthenticationService', () => {
210266 expect ( response . status ) . toBe ( 307 )
211267 } )
212268
269+ it ( 'falls back to status when cookie JWT fails verification for non-expiry reasons' , async ( ) => {
270+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
271+ ok : true ,
272+ json : async ( ) => ( { publicKey : 'test-pem' } ) ,
273+ } )
274+ jwtVerifyMock . mockRejectedValueOnce ( new Error ( 'invalid signature' ) )
275+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
276+ ok : true ,
277+ json : async ( ) => ( {
278+ protected : false ,
279+ token : 'after-bad-jwt' ,
280+ } ) ,
281+ } )
282+
283+ const service = new AuthenticationService ( )
284+ const { response } = await service . authenticateRequest (
285+ previewRequest ( '/p' , {
286+ headers : { cookie : '__fs_auth_token=garbage' } ,
287+ } )
288+ )
289+
290+ expect ( response . status ) . toBe ( 200 )
291+ expect ( response . cookies . get ( '__fs_auth_token' ) ?. value ) . toBe ( 'after-bad-jwt' )
292+ expect ( jwtVerifyMock ) . toHaveBeenCalled ( )
293+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
294+ } )
295+
213296 it ( 'renews session when JWT is expired and WebOps renew succeeds' , async ( ) => {
214297 ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
215298 ok : true ,
@@ -244,6 +327,87 @@ describe('AuthenticationService', () => {
244327 expect ( response . cookies . get ( '__fs_auth_token' ) ?. value ) . toBe ( 'renewed-token' )
245328 } )
246329
330+ it ( 'redirects to login when JWT is expired and renew response is not ok' , async ( ) => {
331+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
332+ ok : true ,
333+ json : async ( ) => ( { publicKey : 'test-pem' } ) ,
334+ } )
335+ jwtVerifyMock . mockRejectedValueOnce (
336+ new errors . JWTExpired ( 'jwt expired' , { storeId : 'test-store' } )
337+ )
338+ decodeJwtMock . mockReturnValueOnce ( {
339+ storeId : 'test-store' ,
340+ protected : true ,
341+ scope : 'DEFAULT_DOMAINS' ,
342+ } as ReturnType < typeof decodeJwt > )
343+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
344+ ok : false ,
345+ status : 502 ,
346+ } )
347+
348+ const service = new AuthenticationService ( )
349+ const { response } = await service . authenticateRequest (
350+ previewRequest ( '/p' , {
351+ headers : { cookie : '__fs_auth_token=expired' } ,
352+ } )
353+ )
354+
355+ expect ( response . status ) . toBe ( 307 )
356+ } )
357+
358+ it ( 'redirects to login when JWT is expired and renew body is not valid' , async ( ) => {
359+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
360+ ok : true ,
361+ json : async ( ) => ( { publicKey : 'test-pem' } ) ,
362+ } )
363+ jwtVerifyMock . mockRejectedValueOnce (
364+ new errors . JWTExpired ( 'jwt expired' , { storeId : 'test-store' } )
365+ )
366+ decodeJwtMock . mockReturnValueOnce ( {
367+ storeId : 'test-store' ,
368+ protected : true ,
369+ scope : 'DEFAULT_DOMAINS' ,
370+ } as ReturnType < typeof decodeJwt > )
371+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
372+ ok : true ,
373+ json : async ( ) => ( { valid : false } ) ,
374+ } )
375+
376+ const service = new AuthenticationService ( )
377+ const { response } = await service . authenticateRequest (
378+ previewRequest ( '/p' , {
379+ headers : { cookie : '__fs_auth_token=expired' } ,
380+ } )
381+ )
382+
383+ expect ( response . status ) . toBe ( 307 )
384+ } )
385+
386+ it ( 'allows traffic when renew fails but expired JWT scope does not apply to this host' , async ( ) => {
387+ ; ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
388+ ok : true ,
389+ json : async ( ) => ( { publicKey : 'test-pem' } ) ,
390+ } )
391+ jwtVerifyMock . mockRejectedValueOnce (
392+ new errors . JWTExpired ( 'jwt expired' , { storeId : 'test-store' } )
393+ )
394+ decodeJwtMock . mockReturnValueOnce ( {
395+ storeId : 'test-store' ,
396+ protected : true ,
397+ scope : 'CUSTOM_DOMAINS' ,
398+ } as ReturnType < typeof decodeJwt > )
399+ ; ( global . fetch as jest . Mock ) . mockRejectedValueOnce ( new Error ( 'renew down' ) )
400+
401+ const service = new AuthenticationService ( )
402+ const { response } = await service . authenticateRequest (
403+ previewRequest ( '/p' , {
404+ headers : { cookie : '__fs_auth_token=old' } ,
405+ } )
406+ )
407+
408+ expect ( response . status ) . toBe ( 200 )
409+ } )
410+
247411 it ( 'fail-closes to login on default domain when status request fails' , async ( ) => {
248412 ; ( global . fetch as jest . Mock ) . mockRejectedValue ( new Error ( 'network' ) )
249413
@@ -254,7 +418,20 @@ describe('AuthenticationService', () => {
254418 expect ( response . headers . get ( 'location' ) ) . toContain ( '/fs-auth-login' )
255419 } )
256420
257- it ( 'fail-opens on custom domain when status request fails' , async ( ) => {
421+ it ( 'fail-closes to login when status endpoint returns a non-ok HTTP status' , async ( ) => {
422+ ; ( global . fetch as jest . Mock ) . mockResolvedValue ( {
423+ ok : false ,
424+ status : 503 ,
425+ } )
426+
427+ const service = new AuthenticationService ( )
428+ const { response } = await service . authenticateRequest ( previewRequest ( '/p' ) )
429+
430+ expect ( response . status ) . toBe ( 307 )
431+ expect ( response . headers . get ( 'location' ) ) . toContain ( '/fs-auth-login' )
432+ } )
433+
434+ it ( 'fail-closes to login on custom domain when status request fails' , async ( ) => {
258435 process . env . CUSTOM_DOMAINS_PROTECTION_ENABLED = 'true'
259436 ; ( global . fetch as jest . Mock ) . mockRejectedValue ( new Error ( 'network' ) )
260437
@@ -265,7 +442,8 @@ describe('AuthenticationService', () => {
265442
266443 const { response } = await service . authenticateRequest ( request )
267444
268- expect ( response . status ) . toBe ( 200 )
445+ expect ( response . status ) . toBe ( 307 )
446+ expect ( response . headers . get ( 'location' ) ) . toContain ( '/fs-auth-login' )
269447 } )
270448
271449 it ( 'does not enforce protection on custom domain when env gate is off' , async ( ) => {
0 commit comments