@@ -51,6 +51,14 @@ const tick = async () => {
5151
5252const makeToken = ( raw : string , id = 'session_123' ) => new Token ( { id, jwt : raw , object : 'token' } ) as TokenResource ;
5353
54+ const deferred = ( ) => {
55+ let resolve ! : ( token : TokenResource ) => void ;
56+ const promise = new Promise < TokenResource > ( r => {
57+ resolve = r ;
58+ } ) ;
59+ return { promise, resolve } ;
60+ } ;
61+
5462describe ( 'SessionTokenCache' , ( ) => {
5563 let mockBroadcastChannel : {
5664 addEventListener : ReturnType < typeof vi . fn > ;
@@ -429,21 +437,13 @@ describe('SessionTokenCache', () => {
429437 describe ( 'same-tab monotonic resolve' , ( ) => {
430438 const tokenId = 'session_123' ;
431439
432- const deferred = ( ) => {
433- let resolve ! : ( token : TokenResource ) => void ;
434- const promise = new Promise < TokenResource > ( r => {
435- resolve = r ;
436- } ) ;
437- return { promise, resolve } ;
438- } ;
439-
440440 it ( 'keeps the fresher token when a staler set resolves into the slot after it' , async ( ) => {
441441 const highRaw = createJwtWithOiat ( 1666648250 , 1666648250 , 120 ) ;
442442 const lowRaw = createJwtWithOiat ( 1666648190 , 1666648190 , 120 ) ;
443443
444444 // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves.
445445 // The staler resolve reconciles against the previous token (carried onto the new slot's
446- // baseline at set time) and loses.
446+ // freshest token at set time) and loses.
447447 SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( highRaw ) ) } ) ;
448448 await tick ( ) ;
449449 SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( lowRaw ) ) } ) ;
@@ -536,8 +536,8 @@ describe('SessionTokenCache', () => {
536536
537537 // Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler)
538538 // before either resolves. When the fresher one resolves it is now foreign, so it only raises
539- // the live slot's baseline , leaving resolvedToken undefined. The staler live resolve then
540- // reconciles against that baseline and the slot publishes the fresher token.
539+ // the live slot's freshest token , leaving resolvedToken undefined. The staler live resolve then
540+ // reconciles against that freshest token and the slot publishes the fresher token.
541541 const dHigh = deferred ( ) ;
542542 const dLow = deferred ( ) ;
543543 SessionTokenCache . set ( { tokenId, tokenResolver : dHigh . promise } ) ;
@@ -564,7 +564,7 @@ describe('SessionTokenCache', () => {
564564 const lowRaw = createJwtWithOiat ( 1666648190 , 1666648190 , 120 ) ;
565565
566566 // Inverse ordering of the previous test: the staler set is replaced by the fresher one before
567- // either resolves. The staler foreign resolve lands first and only advances the baseline ; the
567+ // either resolves. The staler foreign resolve lands first and only advances the freshest token ; the
568568 // fresher live resolve then publishes high directly.
569569 const dLow = deferred ( ) ;
570570 const dHigh = deferred ( ) ;
@@ -588,7 +588,7 @@ describe('SessionTokenCache', () => {
588588 // The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1)
589589 // resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token
590590 // advances resolvedToken to high and re-derives the slot's timers, rather than only raising the
591- // baseline .
591+ // freshest token .
592592 const dHigh = deferred ( ) ;
593593 const dLow = deferred ( ) ;
594594 SessionTokenCache . set ( { tokenId, tokenResolver : dHigh . promise } ) ;
@@ -606,22 +606,22 @@ describe('SessionTokenCache', () => {
606606 expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( highRaw ) ;
607607 } ) ;
608608
609- it ( 'never exposes the baseline while a replacement is pending, then publishes high once it resolves' , async ( ) => {
609+ it ( 'never exposes the freshest token while a replacement is pending, then publishes high once it resolves' , async ( ) => {
610610 const highRaw = createJwtWithOiat ( 1666648250 , 1666648250 , 120 ) ;
611611 const lowRaw = createJwtWithOiat ( 1666648190 , 1666648190 , 120 ) ;
612612
613- // The first set resolves to high, so the slot's resolvedToken and baseline are both high.
613+ // The first set resolves to high, so the slot's resolvedToken and freshest token are both high.
614614 SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( highRaw ) ) } ) ;
615615 await tick ( ) ;
616616 expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( highRaw ) ;
617617
618- // A replacement whose resolver never settles must not expose the carried baseline : the slot
618+ // A replacement whose resolver never settles must not expose the carried freshest token : the slot
619619 // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch.
620620 const pending = deferred ( ) ;
621621 SessionTokenCache . set ( { tokenId, tokenResolver : pending . promise } ) ;
622622 expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ) . toBeUndefined ( ) ;
623623
624- // When it finally resolves staler, the own resolve reconciles against the baseline and the slot
624+ // When it finally resolves staler, the own resolve reconciles against the freshest token and the slot
625625 // publishes high.
626626 pending . resolve ( makeToken ( lowRaw ) ) ;
627627 await tick ( ) ;
@@ -824,6 +824,155 @@ describe('SessionTokenCache', () => {
824824 vi . advanceTimersByTime ( 11 * 1000 ) ;
825825 expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
826826 } ) ;
827+
828+ it ( 'keeps the fresher token expiry when it is carried into a later slot' , async ( ) => {
829+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
830+ const stalerRaw = createJwtWithOiat ( deviceNow , deviceNow , 60 ) ;
831+
832+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
833+ await tick ( ) ;
834+
835+ vi . advanceTimersByTime ( 30 * 1000 ) ;
836+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( stalerRaw ) ) } ) ;
837+ await tick ( ) ;
838+
839+ vi . advanceTimersByTime ( 20 * 1000 ) ;
840+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( freshRaw ) ;
841+
842+ vi . advanceTimersByTime ( 6 * 1000 ) ;
843+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
844+ } ) ;
845+
846+ it ( 'does not extend expiry when the same token is cached again as a new Token' , async ( ) => {
847+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
848+
849+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
850+ await tick ( ) ;
851+
852+ vi . advanceTimersByTime ( 30 * 1000 ) ;
853+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
854+ await tick ( ) ;
855+
856+ vi . advanceTimersByTime ( 20 * 1000 ) ;
857+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( freshRaw ) ;
858+
859+ vi . advanceTimersByTime ( 6 * 1000 ) ;
860+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
861+ } ) ;
862+
863+ it ( 'does not extend expiry when the same token is received by broadcast after a local cache' , async ( ) => {
864+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
865+
866+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
867+ await tick ( ) ;
868+
869+ vi . advanceTimersByTime ( 30 * 1000 ) ;
870+ await broadcastListener ( {
871+ data : { organizationId : null , sessionId : tokenId , tokenId, tokenRaw : freshRaw , traceId : 'skew' } ,
872+ } as MessageEvent < SessionTokenEvent > ) ;
873+ await tick ( ) ;
874+
875+ vi . advanceTimersByTime ( 20 * 1000 ) ;
876+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( freshRaw ) ;
877+
878+ vi . advanceTimersByTime ( 6 * 1000 ) ;
879+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
880+ } ) ;
881+
882+ it ( 'counts a replaced resolver token lifetime from its own set, not the live slot' , async ( ) => {
883+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
884+ const stalerRaw = createJwtWithOiat ( deviceNow , deviceNow , 60 ) ;
885+ const dFresh = deferred ( ) ;
886+ const dStaler = deferred ( ) ;
887+
888+ SessionTokenCache . set ( { tokenId, tokenResolver : dFresh . promise } ) ;
889+
890+ vi . advanceTimersByTime ( 10 * 1000 ) ;
891+ SessionTokenCache . set ( { tokenId, tokenResolver : dStaler . promise } ) ;
892+
893+ dFresh . resolve ( makeToken ( freshRaw ) ) ;
894+ await tick ( ) ;
895+ dStaler . resolve ( makeToken ( stalerRaw ) ) ;
896+ await tick ( ) ;
897+
898+ vi . advanceTimersByTime ( 40 * 1000 ) ;
899+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( freshRaw ) ;
900+
901+ vi . advanceTimersByTime ( 6 * 1000 ) ;
902+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
903+ } ) ;
904+
905+ it ( 'gives a fresher token with a different raw string its own lifetime' , async ( ) => {
906+ const firstRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
907+ const fresherRaw = createJwtWithOiat ( deviceNow + 30 + deviceLag , deviceNow + 30 + deviceLag , 60 ) ;
908+
909+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( firstRaw ) ) } ) ;
910+ await tick ( ) ;
911+
912+ vi . advanceTimersByTime ( 30 * 1000 ) ;
913+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( fresherRaw ) ) } ) ;
914+ await tick ( ) ;
915+
916+ vi . advanceTimersByTime ( 26 * 1000 ) ;
917+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( fresherRaw ) ;
918+
919+ vi . advanceTimersByTime ( 30 * 1000 ) ;
920+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
921+ } ) ;
922+
923+ it ( 'fires the proactive refresh from the carried token expiry, not the later slot' , async ( ) => {
924+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
925+ const stalerRaw = createJwtWithOiat ( deviceNow , deviceNow , 60 ) ;
926+ const onRefresh = vi . fn ( ) ;
927+
928+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) , onRefresh } ) ;
929+ await tick ( ) ;
930+
931+ vi . advanceTimersByTime ( 30 * 1000 ) ;
932+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( stalerRaw ) ) , onRefresh } ) ;
933+ await tick ( ) ;
934+
935+ vi . advanceTimersByTime ( 12 * 1000 ) ;
936+ expect ( onRefresh ) . not . toHaveBeenCalled ( ) ;
937+
938+ vi . advanceTimersByTime ( 1 * 1000 ) ;
939+ expect ( onRefresh ) . toHaveBeenCalledTimes ( 1 ) ;
940+ } ) ;
941+
942+ it ( 'takes the earlier expiry when a replaced resolver delivers the same token after the slot resolved' , async ( ) => {
943+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
944+ const dFirst = deferred ( ) ;
945+
946+ SessionTokenCache . set ( { tokenId, tokenResolver : dFirst . promise } ) ;
947+
948+ vi . advanceTimersByTime ( 10 * 1000 ) ;
949+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
950+ await tick ( ) ;
951+
952+ dFirst . resolve ( makeToken ( freshRaw ) ) ;
953+ await tick ( ) ;
954+
955+ vi . advanceTimersByTime ( 40 * 1000 ) ;
956+ expect ( SessionTokenCache . get ( { tokenId } ) ?. entry . resolvedToken ?. getRawString ( ) ) . toBe ( freshRaw ) ;
957+
958+ vi . advanceTimersByTime ( 6 * 1000 ) ;
959+ expect ( SessionTokenCache . get ( { tokenId } ) ) . toBeUndefined ( ) ;
960+ } ) ;
961+
962+ it ( 'keeps a pending slot when the token carried into it is close to expiry' , async ( ) => {
963+ const freshRaw = createJwtWithOiat ( deviceNow + deviceLag , deviceNow + deviceLag , 60 ) ;
964+
965+ SessionTokenCache . set ( { tokenId, tokenResolver : Promise . resolve ( makeToken ( freshRaw ) ) } ) ;
966+ await tick ( ) ;
967+
968+ vi . advanceTimersByTime ( 50 * 1000 ) ;
969+ SessionTokenCache . set ( { tokenId, tokenResolver : deferred ( ) . promise } ) ;
970+
971+ vi . advanceTimersByTime ( 8 * 1000 ) ;
972+ const result = SessionTokenCache . get ( { tokenId } ) ;
973+ expect ( result ) . toBeDefined ( ) ;
974+ expect ( result ?. entry . resolvedToken ) . toBeUndefined ( ) ;
975+ } ) ;
827976 } ) ;
828977
829978 describe ( 'broadcast sending' , ( ) => {
0 commit comments