Skip to content

Commit e2950bd

Browse files
committed
test(clerk-js): add tests for proactive token refresh scenarios
Why: The timer-based proactive token refresh feature (0e812a2) needed test coverage to verify getToken() behavior across all 6 timing scenarios. What changed: Added tests covering: - Before timer fires (t < 43): returns cached token instantly - After timer, fetch in progress (43 < t < 45): returns old token non-blocking - After timer, fetch complete (43 < t < 45): returns new token from cache - In leeway zone with completed fetch (t >= 45): returns new token - In leeway zone with failed fetch (t >= 45): blocks and refetches - Background tab scenario (timer didn't fire): safe fallback behavior
1 parent 0e812a2 commit e2950bd

1 file changed

Lines changed: 285 additions & 0 deletions

File tree

packages/clerk-js/src/core/resources/__tests__/Session.test.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vite
44

55
import { 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+
717
import { eventBus } from '../../events';
818
import { createFapiClient } from '../../fapiClient';
919
import { 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

Comments
 (0)