@@ -4,6 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vite
44
55import { clerkMock , createUser , mockJwt , mockNetworkFailedFetch } from '@/test/core-fixtures' ;
66
7+ /**
8+ * Creates a JWT string with the specified iat (issued at) and ttl (time to live).
9+ * The token will expire at iat + ttl seconds.
10+ */
11+ function createJwtWithTtl ( iatSeconds : number , ttlSeconds : number ) : string {
12+ const payload = { exp : iatSeconds + ttlSeconds , iat : iatSeconds , sid : 'session_1' , sub : 'user_1' } ;
13+ const payloadB64 = btoa ( JSON . stringify ( payload ) ) . replace ( / \+ / g, '-' ) . replace ( / \/ / g, '_' ) . replace ( / = / g, '' ) ;
14+ return `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${ payloadB64 } .signature` ;
15+ }
16+
717import { eventBus } from '../../events' ;
818import { createFapiClient } from '../../fapiClient' ;
919import { SessionTokenCache } from '../../tokenCache' ;
@@ -1233,4 +1243,279 @@ describe('Session', () => {
12331243 expect ( fetchSpy ) . toHaveBeenCalledTimes ( 1 ) ;
12341244 } ) ;
12351245 } ) ;
1246+
1247+ /**
1248+ * Proactive Token Refresh Tests
1249+ *
1250+ * Token timing (for 60-second tokens):
1251+ * - LEEWAY = 10s (token considered "expiring soon")
1252+ * - SYNC_LEEWAY = 5s (buffer for cookie polling)
1253+ * - REFRESH_BUFFER = 2s (buffer before leeway)
1254+ * - Leeway zone starts at: 60 - 10 - 5 = 45 seconds
1255+ * - Proactive timer fires at: 60 - 10 - 5 - 2 = 43 seconds
1256+ */
1257+ describe ( 'proactive token refresh behavior' , ( ) => {
1258+ let fetchSpy : ReturnType < typeof vi . spyOn > ;
1259+ let dispatchSpy : ReturnType < typeof vi . spyOn > ;
1260+
1261+ // Use a fixed timestamp for predictable timing
1262+ const BASE_TIME_SECONDS = 1700000000 ;
1263+ const TOKEN_TTL = 60 ;
1264+
1265+ beforeEach ( ( ) => {
1266+ SessionTokenCache . clear ( ) ;
1267+ dispatchSpy = vi . spyOn ( eventBus , 'emit' ) ;
1268+ fetchSpy = vi . spyOn ( BaseResource , '_fetch' as any ) ;
1269+ BaseResource . clerk = clerkMock ( ) as any ;
1270+ } ) ;
1271+
1272+ afterEach ( ( ) => {
1273+ dispatchSpy ?. mockRestore ( ) ;
1274+ fetchSpy ?. mockRestore ( ) ;
1275+ BaseResource . clerk = null as any ;
1276+ } ) ;
1277+
1278+ it ( 'returns cached token before proactive timer fires (t < 43)' , async ( ) => {
1279+ // Token issued at BASE_TIME, expires at BASE_TIME + 60
1280+ const jwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1281+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1282+
1283+ const session = new Session ( {
1284+ status : 'active' ,
1285+ id : 'session_1' ,
1286+ object : 'session' ,
1287+ user : createUser ( { } ) ,
1288+ last_active_organization_id : null ,
1289+ last_active_token : { object : 'token' , jwt } ,
1290+ actor : null ,
1291+ created_at : new Date ( ) . getTime ( ) ,
1292+ updated_at : new Date ( ) . getTime ( ) ,
1293+ } as SessionJSON ) ;
1294+
1295+ fetchSpy . mockClear ( ) ;
1296+
1297+ // Advance to t=40 (before the proactive timer at t=43)
1298+ vi . advanceTimersByTime ( 40 * 1000 ) ;
1299+
1300+ const token = await session . getToken ( ) ;
1301+
1302+ expect ( token ) . toEqual ( jwt ) ;
1303+ expect ( fetchSpy ) . not . toHaveBeenCalled ( ) ;
1304+ } ) ;
1305+
1306+ it ( 'returns OLD token while proactive fetch is in progress (43 < t < 45)' , async ( ) => {
1307+ const jwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1308+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1309+
1310+ // Create a promise that never resolves during this test (simulating slow network)
1311+ let resolveProactiveFetch : ( value : any ) => void ;
1312+ const pendingPromise = new Promise ( resolve => {
1313+ resolveProactiveFetch = resolve ;
1314+ } ) ;
1315+ fetchSpy . mockReturnValue ( pendingPromise ) ;
1316+
1317+ const session = new Session ( {
1318+ status : 'active' ,
1319+ id : 'session_1' ,
1320+ object : 'session' ,
1321+ user : createUser ( { } ) ,
1322+ last_active_organization_id : null ,
1323+ last_active_token : { object : 'token' , jwt } ,
1324+ actor : null ,
1325+ created_at : new Date ( ) . getTime ( ) ,
1326+ updated_at : new Date ( ) . getTime ( ) ,
1327+ } as SessionJSON ) ;
1328+
1329+ // Allow the initial cache hydration to set up the timer
1330+ await Promise . resolve ( ) ;
1331+
1332+ fetchSpy . mockClear ( ) ;
1333+ fetchSpy . mockReturnValue ( pendingPromise ) ;
1334+
1335+ // Advance to t=43 - proactive timer fires, starting background fetch
1336+ vi . advanceTimersByTime ( 43 * 1000 ) ;
1337+
1338+ // Advance to t=44 (still before leeway at t=45)
1339+ vi . advanceTimersByTime ( 1 * 1000 ) ;
1340+
1341+ // Call getToken - should return OLD token instantly (non-blocking)
1342+ const token = await session . getToken ( ) ;
1343+
1344+ expect ( token ) . toEqual ( jwt ) ;
1345+ // Only the proactive fetch should have been triggered
1346+ expect ( fetchSpy ) . toHaveBeenCalledTimes ( 1 ) ;
1347+
1348+ // Clean up the pending promise
1349+ resolveProactiveFetch ! ( { object : 'token' , jwt : createJwtWithTtl ( BASE_TIME_SECONDS + 43 , TOKEN_TTL ) } ) ;
1350+ } ) ;
1351+
1352+ it ( 'returns NEW token after proactive fetch completes (43 < t < 45)' , async ( ) => {
1353+ const oldJwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1354+ const newJwt = createJwtWithTtl ( BASE_TIME_SECONDS + 43 , TOKEN_TTL ) ;
1355+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1356+
1357+ const session = new Session ( {
1358+ status : 'active' ,
1359+ id : 'session_1' ,
1360+ object : 'session' ,
1361+ user : createUser ( { } ) ,
1362+ last_active_organization_id : null ,
1363+ last_active_token : { object : 'token' , jwt : oldJwt } ,
1364+ actor : null ,
1365+ created_at : new Date ( ) . getTime ( ) ,
1366+ updated_at : new Date ( ) . getTime ( ) ,
1367+ } as SessionJSON ) ;
1368+
1369+ // Allow the initial cache hydration to set up the timer
1370+ await Promise . resolve ( ) ;
1371+
1372+ fetchSpy . mockClear ( ) ;
1373+
1374+ // Mock the proactive fetch to return new token immediately
1375+ fetchSpy . mockResolvedValueOnce ( { object : 'token' , jwt : newJwt } ) ;
1376+
1377+ // Advance to t=43 - proactive timer fires, fetch completes immediately
1378+ vi . advanceTimersByTime ( 43 * 1000 ) ;
1379+
1380+ // Allow the proactive fetch promise to resolve and cache update to complete
1381+ await Promise . resolve ( ) ;
1382+ await Promise . resolve ( ) ;
1383+
1384+ fetchSpy . mockClear ( ) ;
1385+
1386+ // Advance to t=44
1387+ vi . advanceTimersByTime ( 1 * 1000 ) ;
1388+
1389+ // Call getToken - should return NEW token from cache
1390+ const token = await session . getToken ( ) ;
1391+
1392+ expect ( token ) . toEqual ( newJwt ) ;
1393+ // No additional API call should be made
1394+ expect ( fetchSpy ) . not . toHaveBeenCalled ( ) ;
1395+ } ) ;
1396+
1397+ it ( 'returns NEW token in leeway zone when proactive fetch completed (t >= 45)' , async ( ) => {
1398+ const oldJwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1399+ const newJwt = createJwtWithTtl ( BASE_TIME_SECONDS + 43 , TOKEN_TTL ) ;
1400+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1401+
1402+ const session = new Session ( {
1403+ status : 'active' ,
1404+ id : 'session_1' ,
1405+ object : 'session' ,
1406+ user : createUser ( { } ) ,
1407+ last_active_organization_id : null ,
1408+ last_active_token : { object : 'token' , jwt : oldJwt } ,
1409+ actor : null ,
1410+ created_at : new Date ( ) . getTime ( ) ,
1411+ updated_at : new Date ( ) . getTime ( ) ,
1412+ } as SessionJSON ) ;
1413+
1414+ // Allow the initial cache hydration to set up the timer
1415+ await Promise . resolve ( ) ;
1416+
1417+ fetchSpy . mockClear ( ) ;
1418+
1419+ // Mock the proactive fetch to return new token
1420+ fetchSpy . mockResolvedValueOnce ( { object : 'token' , jwt : newJwt } ) ;
1421+
1422+ // Advance to t=43 - proactive timer fires
1423+ vi . advanceTimersByTime ( 43 * 1000 ) ;
1424+
1425+ // Allow the proactive fetch promise to resolve and cache update to complete
1426+ await Promise . resolve ( ) ;
1427+ await Promise . resolve ( ) ;
1428+
1429+ fetchSpy . mockClear ( ) ;
1430+
1431+ // Advance to t=46 (old token would be in leeway zone, but new token is fresh)
1432+ vi . advanceTimersByTime ( 3 * 1000 ) ;
1433+
1434+ const token = await session . getToken ( ) ;
1435+
1436+ expect ( token ) . toEqual ( newJwt ) ;
1437+ // No additional API call needed - new token is still fresh
1438+ expect ( fetchSpy ) . not . toHaveBeenCalled ( ) ;
1439+ } ) ;
1440+
1441+ it ( 'blocks and fetches new token in leeway zone when proactive fetch failed (t >= 45)' , async ( ) => {
1442+ const oldJwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1443+ const newJwt = createJwtWithTtl ( BASE_TIME_SECONDS + 46 , TOKEN_TTL ) ;
1444+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1445+
1446+ const session = new Session ( {
1447+ status : 'active' ,
1448+ id : 'session_1' ,
1449+ object : 'session' ,
1450+ user : createUser ( { } ) ,
1451+ last_active_organization_id : null ,
1452+ last_active_token : { object : 'token' , jwt : oldJwt } ,
1453+ actor : null ,
1454+ created_at : new Date ( ) . getTime ( ) ,
1455+ updated_at : new Date ( ) . getTime ( ) ,
1456+ } as SessionJSON ) ;
1457+
1458+ fetchSpy . mockClear ( ) ;
1459+
1460+ // Proactive fetch fails silently
1461+ fetchSpy . mockRejectedValueOnce ( new Error ( 'Network error' ) ) ;
1462+
1463+ // Advance to t=43 - proactive timer fires, fetch fails
1464+ vi . advanceTimersByTime ( 43 * 1000 ) ;
1465+
1466+ // Allow the proactive fetch promise to reject
1467+ await vi . runAllTimersAsync ( ) ;
1468+
1469+ // Second call (from getToken) succeeds
1470+ fetchSpy . mockResolvedValueOnce ( { object : 'token' , jwt : newJwt } ) ;
1471+
1472+ // Advance to t=46 (in leeway zone)
1473+ vi . advanceTimersByTime ( 3 * 1000 ) ;
1474+
1475+ const token = await session . getToken ( ) ;
1476+
1477+ expect ( token ) . toEqual ( newJwt ) ;
1478+ // Two API calls: proactive fetch (failed) + getToken fetch (success)
1479+ expect ( fetchSpy ) . toHaveBeenCalledTimes ( 2 ) ;
1480+ } ) ;
1481+
1482+ it ( 'blocks and fetches new token when timer did not fire (background tab scenario, t >= 45)' , async ( ) => {
1483+ const oldJwt = createJwtWithTtl ( BASE_TIME_SECONDS , TOKEN_TTL ) ;
1484+ const newJwt = createJwtWithTtl ( BASE_TIME_SECONDS + 46 , TOKEN_TTL ) ;
1485+ vi . setSystemTime ( new Date ( BASE_TIME_SECONDS * 1000 ) ) ;
1486+
1487+ // Create session which hydrates the cache with onExpiringSoon callback
1488+ const session = new Session ( {
1489+ status : 'active' ,
1490+ id : 'session_1' ,
1491+ object : 'session' ,
1492+ user : createUser ( { } ) ,
1493+ last_active_organization_id : null ,
1494+ last_active_token : { object : 'token' , jwt : oldJwt } ,
1495+ actor : null ,
1496+ created_at : new Date ( ) . getTime ( ) ,
1497+ updated_at : new Date ( ) . getTime ( ) ,
1498+ } as SessionJSON ) ;
1499+
1500+ // Allow the initial cache hydration to complete
1501+ await Promise . resolve ( ) ;
1502+
1503+ // Simulate background tab scenario: clear cache completely
1504+ // This simulates what happens when the tab was suspended and the cache is empty
1505+ SessionTokenCache . clear ( ) ;
1506+
1507+ fetchSpy . mockClear ( ) ;
1508+ fetchSpy . mockResolvedValueOnce ( { object : 'token' , jwt : newJwt } ) ;
1509+
1510+ // Advance to t=46 (timer never fired because cache was cleared)
1511+ vi . advanceTimersByTime ( 46 * 1000 ) ;
1512+
1513+ // getToken() with no cache entry should make an API call
1514+ const token = await session . getToken ( ) ;
1515+
1516+ expect ( token ) . toEqual ( newJwt ) ;
1517+ // Should make an API call since there's no cache entry
1518+ expect ( fetchSpy ) . toHaveBeenCalledTimes ( 1 ) ;
1519+ } ) ;
1520+ } ) ;
12361521} ) ;
0 commit comments