@@ -35,14 +35,21 @@ function jsonResponse(body, { ok = true, status = 200 } = {}) {
3535}
3636
3737let originalFetch ;
38+ let originalNavigator ;
3839
3940beforeEach ( ( ) => {
4041 originalFetch = globalThis . fetch ;
42+ originalNavigator = globalThis . navigator ;
4143 __resetAuthRefreshForTests ( ) ;
4244} ) ;
4345
4446afterEach ( ( ) => {
4547 globalThis . fetch = originalFetch ;
48+ // Plain reassignment can throw: the real `navigator` global is an accessor
49+ // with no setter, and a test may have left it in that pristine state.
50+ // Deleting first (a no-op if a test already replaced it) sidesteps that.
51+ delete globalThis . navigator ;
52+ if ( originalNavigator !== undefined ) globalThis . navigator = originalNavigator ;
4653 vi . restoreAllMocks ( ) ;
4754} ) ;
4855
@@ -377,3 +384,151 @@ describe('refresh on 401', () => {
377384 expect ( res . status ) . toBe ( 401 ) ;
378385 } ) ;
379386} ) ;
387+
388+ // The real `navigator` global (Node 21+, and every browser) exposes
389+ // `navigator` as an accessor with no setter, so a plain `globalThis.navigator
390+ // = ...` throws in this ESM test file's strict mode. Delete it first, same
391+ // as this codebase's own withRefreshLock() feature-detects its absence.
392+ function stubNavigator ( value ) {
393+ delete globalThis . navigator ;
394+ if ( value !== undefined ) globalThis . navigator = value ;
395+ }
396+
397+ describe ( 'cross-tab refresh coordination' , ( ) => {
398+ it ( 'still sends exactly one refresh for concurrent 401s when navigator.locks exists' , async ( ) => {
399+ configureAuthRefresh ( { loginFlow : 'supertokens' } ) ;
400+ // A trivial single-tab-shaped stub: Web Locks exists, but only one caller
401+ // is ever asking, so it should behave exactly like having no lock at all.
402+ stubNavigator ( { locks : { request : ( name , fn ) => fn ( ) } } ) ;
403+
404+ let refreshes = 0 ;
405+ let refreshResolve ;
406+ const refreshGate = new Promise ( ( resolve ) => { refreshResolve = resolve ; } ) ;
407+ let refreshed = false ;
408+
409+ globalThis . fetch = vi . fn ( async ( url ) => {
410+ if ( url === '/auth/session/refresh' ) {
411+ refreshes += 1 ;
412+ await refreshGate ;
413+ refreshed = true ;
414+ return { ok : true , status : 200 , text : async ( ) => '' } ;
415+ }
416+ if ( ! refreshed ) return jsonResponse ( { error : 'unauthorized' } , { ok : false , status : 401 } ) ;
417+ return jsonResponse ( { ok : true } ) ;
418+ } ) ;
419+
420+ const all = Promise . all ( [ fetchState ( ) , fetchState ( ) , fetchState ( ) ] ) ;
421+ await new Promise ( ( r ) => setTimeout ( r , 10 ) ) ;
422+ refreshResolve ( ) ;
423+ await all ;
424+
425+ // Proves the lock wrapper doesn't defeat the existing in-tab single-flight
426+ // guard now that a navigator.locks happens to exist.
427+ expect ( refreshes ) . toBe ( 1 ) ;
428+ } ) ;
429+
430+ it ( 'serialises refreshes across tabs so neither ever overlaps the other' , async ( ) => {
431+ // Two tabs = two independent module instances, each with its own private
432+ // inFlightRefresh closure - exactly what two separate browsing contexts
433+ // running the same bundle would have. vi.resetModules() + a fresh
434+ // dynamic import gets us that inside one test file; it does not disturb
435+ // the module instance this file's own top-level `fetchState` etc. are
436+ // bound to (those bindings were already linked when the file loaded).
437+ vi . resetModules ( ) ;
438+ const tab1 = await import ( '../client/src/game/api.js' ) ;
439+ vi . resetModules ( ) ;
440+ const tab2 = await import ( '../client/src/game/api.js' ) ;
441+
442+ tab1 . configureAuthRefresh ( { loginFlow : 'supertokens' } ) ;
443+ tab2 . configureAuthRefresh ( { loginFlow : 'supertokens' } ) ;
444+
445+ // Both "tabs" share one browser-wide Web Locks manager in reality; model
446+ // that with one real FIFO mutex shared by both module instances (they
447+ // already share globalThis, exactly as two tabs share it).
448+ let queue = Promise . resolve ( ) ;
449+ stubNavigator ( {
450+ locks : {
451+ request : ( name , fn ) => {
452+ const run = queue . then ( fn , fn ) ;
453+ queue = run . catch ( ( ) => { } ) ;
454+ return run ;
455+ } ,
456+ } ,
457+ } ) ;
458+
459+ let active = 0 ;
460+ let overlapped = false ;
461+ let refreshCalls = 0 ;
462+ globalThis . fetch = vi . fn ( async ( url ) => {
463+ if ( url === '/auth/session/refresh' ) {
464+ refreshCalls += 1 ;
465+ if ( active > 0 ) overlapped = true ;
466+ active += 1 ;
467+ // Wide enough to make an unguarded race show up reliably.
468+ await new Promise ( ( r ) => setTimeout ( r , 10 ) ) ;
469+ active -= 1 ;
470+ return { ok : true , status : 200 , text : async ( ) => '' } ;
471+ }
472+ // Both tabs' access tokens are expired - each retries its own request
473+ // exactly once, same as every other test in this file.
474+ return jsonResponse ( { error : 'unauthorized' } , { ok : false , status : 401 } ) ;
475+ } ) ;
476+
477+ await Promise . all ( [ tab1 . fetchState ( ) , tab2 . fetchState ( ) ] ) ;
478+
479+ // The invariant that actually prevents SuperTokens' theft detection: the
480+ // two tabs' refresh network calls never overlap in time.
481+ expect ( overlapped ) . toBe ( false ) ;
482+ // And the fix doesn't falsely dedupe tab2's refresh away - a lock delays,
483+ // it does not merge, so each tab still gets exactly one refresh call.
484+ expect ( refreshCalls ) . toBe ( 2 ) ;
485+ } ) ;
486+
487+ it ( 'falls back to per-tab-only refresh when navigator is entirely unavailable' , async ( ) => {
488+ configureAuthRefresh ( { loginFlow : 'supertokens' } ) ;
489+ stubNavigator ( undefined ) ;
490+
491+ let refreshes = 0 ;
492+ let refreshed = false ;
493+ globalThis . fetch = vi . fn ( async ( url ) => {
494+ if ( url === '/auth/session/refresh' ) {
495+ refreshes += 1 ;
496+ refreshed = true ;
497+ return { ok : true , status : 200 , text : async ( ) => '' } ;
498+ }
499+ if ( ! refreshed ) return jsonResponse ( { error : 'unauthorized' } , { ok : false , status : 401 } ) ;
500+ return jsonResponse ( { ok : true } ) ;
501+ } ) ;
502+
503+ const res = await fetchState ( ) ;
504+
505+ expect ( refreshes ) . toBe ( 1 ) ;
506+ expect ( res ) . toEqual ( { ok : true } ) ;
507+ } ) ;
508+
509+ it ( 'falls back when navigator exists but has no locks (this test runtime today)' , async ( ) => {
510+ configureAuthRefresh ( { loginFlow : 'supertokens' } ) ;
511+ // Exactly the shape Node's own built-in `navigator` global has - no
512+ // `.locks` - which is why every OTHER test in this file already exercises
513+ // this path implicitly. This test pins it explicitly so it can't regress
514+ // silently if a future Node/vitest version adds navigator.locks.
515+ stubNavigator ( { userAgent : 'test' } ) ;
516+
517+ let refreshes = 0 ;
518+ let refreshed = false ;
519+ globalThis . fetch = vi . fn ( async ( url ) => {
520+ if ( url === '/auth/session/refresh' ) {
521+ refreshes += 1 ;
522+ refreshed = true ;
523+ return { ok : true , status : 200 , text : async ( ) => '' } ;
524+ }
525+ if ( ! refreshed ) return jsonResponse ( { error : 'unauthorized' } , { ok : false , status : 401 } ) ;
526+ return jsonResponse ( { ok : true } ) ;
527+ } ) ;
528+
529+ const res = await fetchState ( ) ;
530+
531+ expect ( refreshes ) . toBe ( 1 ) ;
532+ expect ( res ) . toEqual ( { ok : true } ) ;
533+ } ) ;
534+ } ) ;
0 commit comments