@@ -31,13 +31,23 @@ function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string {
3131 return `${ headerB64 } .${ payloadB64 } .${ signature } ` ;
3232}
3333
34+ /**
35+ * Helper to create a JWT with custom iat AND oiat header for monotonic-freshness tests
36+ */
37+ function createJwtWithOiat ( iatSeconds : number , oiatSeconds : number , ttlSeconds = 60 ) : string {
38+ const header = { alg : 'HS256' , typ : 'JWT' , oiat : oiatSeconds } ;
39+ const payload = { sid : 'session_123' , exp : iatSeconds + ttlSeconds , iat : iatSeconds } ;
40+ const b64 = ( o : object ) => btoa ( JSON . stringify ( o ) ) . replace ( / \+ / g, '-' ) . replace ( / \/ / g, '_' ) . replace ( / = / g, '' ) ;
41+ return `${ b64 ( header ) } .${ b64 ( payload ) } .test-signature` ;
42+ }
43+
3444describe ( 'SessionTokenCache' , ( ) => {
3545 let mockBroadcastChannel : {
3646 addEventListener : ReturnType < typeof vi . fn > ;
3747 close : ReturnType < typeof vi . fn > ;
3848 postMessage : ReturnType < typeof vi . fn > ;
3949 } ;
40- let broadcastListener : ( e : MessageEvent < SessionTokenEvent > ) => void ;
50+ let broadcastListener : ( e : MessageEvent < SessionTokenEvent > ) => void | Promise < void > ;
4151 let originalBroadcastChannel : any ;
4252
4353 beforeEach ( ( ) => {
@@ -96,7 +106,7 @@ describe('SessionTokenCache', () => {
96106 } ,
97107 } as MessageEvent < SessionTokenEvent > ;
98108
99- broadcastListener ( event ) ;
109+ void broadcastListener ( event ) ;
100110
101111 expect ( SessionTokenCache . size ( ) ) . toBe ( 0 ) ;
102112 } ) ;
@@ -113,7 +123,7 @@ describe('SessionTokenCache', () => {
113123 } ,
114124 } as MessageEvent < SessionTokenEvent > ;
115125
116- broadcastListener ( event ) ;
126+ void broadcastListener ( event ) ;
117127
118128 expect ( SessionTokenCache . size ( ) ) . toBe ( 1 ) ;
119129 } ) ;
@@ -130,7 +140,7 @@ describe('SessionTokenCache', () => {
130140 } ,
131141 } as MessageEvent < SessionTokenEvent > ;
132142
133- broadcastListener ( event ) ;
143+ void broadcastListener ( event ) ;
134144
135145 expect ( SessionTokenCache . size ( ) ) . toBe ( 1 ) ;
136146 } ) ;
@@ -148,7 +158,7 @@ describe('SessionTokenCache', () => {
148158 } as MessageEvent < SessionTokenEvent > ;
149159
150160 expect ( ( ) => {
151- broadcastListener ( event ) ;
161+ void broadcastListener ( event ) ;
152162 } ) . not . toThrow ( ) ;
153163
154164 expect ( SessionTokenCache . size ( ) ) . toBe ( 0 ) ;
@@ -168,7 +178,7 @@ describe('SessionTokenCache', () => {
168178 } ,
169179 } as MessageEvent < SessionTokenEvent > ;
170180
171- broadcastListener ( event ) ;
181+ void broadcastListener ( event ) ;
172182
173183 expect ( SessionTokenCache . size ( ) ) . toBe ( 0 ) ;
174184 } ) ;
@@ -188,31 +198,33 @@ describe('SessionTokenCache', () => {
188198 } ,
189199 } as MessageEvent < SessionTokenEvent > ;
190200
191- broadcastListener ( event ) ;
201+ void broadcastListener ( event ) ;
192202
193203 expect ( SessionTokenCache . size ( ) ) . toBe ( 0 ) ;
194204 } ) ;
195205
196- it ( 'enforces monotonicity: does not overwrite newer token with older one' , ( ) => {
206+ it ( 'enforces monotonicity: does not overwrite newer token with older one' , async ( ) => {
207+ // Both tokens carry oiat (the production case post-rollout). Older oiat
208+ // broadcast must not clobber the newer one already in cache.
209+ const newerJwt = createJwtWithOiat ( 1666648250 , 1666648250 ) ;
210+ const olderJwt = createJwtWithOiat ( 1666648190 , 1666648190 ) ;
211+
197212 const newerEvent : MessageEvent < SessionTokenEvent > = {
198213 data : {
199214 organizationId : null ,
200215 sessionId : 'session_123' ,
201216 template : undefined ,
202217 tokenId : 'session_123' ,
203- tokenRaw : mockJwt ,
218+ tokenRaw : newerJwt ,
204219 traceId : 'test_trace_7' ,
205220 } ,
206221 } as MessageEvent < SessionTokenEvent > ;
207222
208- broadcastListener ( newerEvent ) ;
223+ await broadcastListener ( newerEvent ) ;
209224 const cachedEntryAfterNewer = SessionTokenCache . get ( { tokenId : 'session_123' } ) ;
210225 expect ( cachedEntryAfterNewer ) . toBeDefined ( ) ;
211226 const newerCreatedAt = cachedEntryAfterNewer ?. createdAt ;
212227
213- // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier)
214- const olderJwt =
215- 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg4NTAsImlhdCI6MTY2NjY0ODE5MH0.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg' ;
216228 const olderEvent : MessageEvent < SessionTokenEvent > = {
217229 data : {
218230 organizationId : null ,
@@ -224,13 +236,54 @@ describe('SessionTokenCache', () => {
224236 } ,
225237 } as MessageEvent < SessionTokenEvent > ;
226238
227- broadcastListener ( olderEvent ) ;
239+ await broadcastListener ( olderEvent ) ;
228240
229241 const cachedEntryAfterOlder = SessionTokenCache . get ( { tokenId : 'session_123' } ) ;
230242 expect ( cachedEntryAfterOlder ) . toBeDefined ( ) ;
231243 expect ( cachedEntryAfterOlder ?. createdAt ) . toBe ( newerCreatedAt ) ;
232244 } ) ;
233245
246+ it ( 'enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives' , async ( ) => {
247+ // Inverse of the previous test: a fresher-oiat broadcast must overwrite
248+ // an older-oiat token already in cache. Use ttl=120 so both tokens stay
249+ // valid against the test clock (nowSec=1666648260) - cache.get drops
250+ // entries past their expiry.
251+ const olderJwt = createJwtWithOiat ( 1666648190 , 1666648190 , 120 ) ;
252+ const newerJwt = createJwtWithOiat ( 1666648250 , 1666648250 , 120 ) ;
253+
254+ const olderEvent : MessageEvent < SessionTokenEvent > = {
255+ data : {
256+ organizationId : null ,
257+ sessionId : 'session_123' ,
258+ template : undefined ,
259+ tokenId : 'session_123' ,
260+ tokenRaw : olderJwt ,
261+ traceId : 'test_trace_older_first' ,
262+ } ,
263+ } as MessageEvent < SessionTokenEvent > ;
264+
265+ await broadcastListener ( olderEvent ) ;
266+ const cachedEntryAfterOlder = SessionTokenCache . get ( { tokenId : 'session_123' } ) ;
267+ expect ( cachedEntryAfterOlder ) . toBeDefined ( ) ;
268+ const olderCreatedAt = cachedEntryAfterOlder ?. createdAt ;
269+
270+ const newerEvent : MessageEvent < SessionTokenEvent > = {
271+ data : {
272+ organizationId : null ,
273+ sessionId : 'session_123' ,
274+ template : undefined ,
275+ tokenId : 'session_123' ,
276+ tokenRaw : newerJwt ,
277+ traceId : 'test_trace_newer_second' ,
278+ } ,
279+ } as MessageEvent < SessionTokenEvent > ;
280+
281+ await broadcastListener ( newerEvent ) ;
282+ const cachedEntryAfterNewer = SessionTokenCache . get ( { tokenId : 'session_123' } ) ;
283+ expect ( cachedEntryAfterNewer ) . toBeDefined ( ) ;
284+ expect ( cachedEntryAfterNewer ?. createdAt ) . not . toBe ( olderCreatedAt ) ;
285+ } ) ;
286+
234287 it ( 'successfully updates cache with valid token' , ( ) => {
235288 const event : MessageEvent < SessionTokenEvent > = {
236289 data : {
@@ -243,7 +296,7 @@ describe('SessionTokenCache', () => {
243296 } ,
244297 } as MessageEvent < SessionTokenEvent > ;
245298
246- broadcastListener ( event ) ;
299+ void broadcastListener ( event ) ;
247300
248301 const cachedEntry = SessionTokenCache . get ( { tokenId : 'session_123' } ) ;
249302 expect ( cachedEntry ) . toBeDefined ( ) ;
@@ -265,7 +318,7 @@ describe('SessionTokenCache', () => {
265318 } ,
266319 } as MessageEvent < SessionTokenEvent > ;
267320
268- broadcastListener ( event ) ;
321+ void broadcastListener ( event ) ;
269322
270323 // Flush microtasks to let the tokenResolver promise settle without advancing timers
271324 await Promise . resolve ( ) ;
@@ -757,7 +810,7 @@ describe('SessionTokenCache', () => {
757810 } as MessageEvent < SessionTokenEvent > ;
758811
759812 expect ( ( ) => {
760- broadcastListener ( event ) ;
813+ void broadcastListener ( event ) ;
761814 } ) . not . toThrow ( ) ;
762815 } ) ;
763816 } ) ;
@@ -801,7 +854,7 @@ describe('SessionTokenCache', () => {
801854 } ,
802855 } as MessageEvent < SessionTokenEvent > ;
803856
804- broadcastListener ( broadcastEvent ) ;
857+ void broadcastListener ( broadcastEvent ) ;
805858
806859 await vi . waitFor ( ( ) => {
807860 expect ( SessionTokenCache . get ( { tokenId : session2Id } ) ) . toBeDefined ( ) ;
@@ -862,7 +915,7 @@ describe('SessionTokenCache', () => {
862915 } ,
863916 } as MessageEvent < SessionTokenEvent > ;
864917
865- broadcastListener ( broadcastEvent ) ;
918+ void broadcastListener ( broadcastEvent ) ;
866919
867920 await vi . waitFor ( async ( ) => {
868921 const updatedCached = SessionTokenCache . get ( { tokenId : sessionId } ) ;
0 commit comments