Skip to content

Commit ba4cc58

Browse files
authored
feat(auth): check for stamped token expiration after browser idle (#575)
* feat(auth): check for stamped token expiration after browser idle
1 parent 210d48e commit ba4cc58

File tree

3 files changed

+321
-36
lines changed

3 files changed

+321
-36
lines changed

packages/core/src/auth/authStore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type LoggedInAuthState = {
3535
type: AuthStateType.LOGGED_IN
3636
token: string
3737
currentUser: CurrentUser | null
38+
lastTokenRefresh?: number
3839
}
3940

4041
/**

packages/core/src/auth/refreshStampedToken.test.ts

Lines changed: 225 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ import {createStoreState} from '../store/createStoreState'
88
import {AuthStateType} from './authStateType'
99
import {type AuthState, authStore} from './authStore'
1010
// Import only the public function
11-
import {refreshStampedToken} from './refreshStampedToken'
11+
import {
12+
getLastRefreshTime,
13+
getNextRefreshDelay,
14+
refreshStampedToken,
15+
setLastRefreshTime,
16+
} from './refreshStampedToken'
1217

1318
// Type definitions for Web Locks (can be kept if needed for context)
1419
// ... (Lock, LockOptions, LockGrantedCallback types)
1520

1621
describe('refreshStampedToken', () => {
1722
let mockStorage: Storage
1823
let originalNavigator: typeof navigator // Restored
24+
let originalDocument: Document
1925
let subscriptions: Subscription[]
2026
// mockLocksRequest removed
2127

@@ -25,6 +31,19 @@ describe('refreshStampedToken', () => {
2531
vi.useFakeTimers()
2632

2733
originalNavigator = global.navigator // Restore original navigator setup
34+
35+
// Mock document for visibility API
36+
originalDocument = global.document
37+
Object.defineProperty(global, 'document', {
38+
value: {
39+
visibilityState: 'visible',
40+
addEventListener: vi.fn(),
41+
removeEventListener: vi.fn(),
42+
},
43+
writable: true,
44+
configurable: true,
45+
})
46+
2847
mockStorage = {
2948
getItem: vi.fn(),
3049
setItem: vi.fn(),
@@ -76,6 +95,11 @@ describe('refreshStampedToken', () => {
7695
value: originalNavigator,
7796
writable: true,
7897
})
98+
// Restore original document
99+
Object.defineProperty(global, 'document', {
100+
value: originalDocument,
101+
writable: true,
102+
})
79103
// Restore real timers
80104
try {
81105
await vi.runAllTimersAsync() // Attempt to flush cleanly
@@ -122,6 +146,49 @@ describe('refreshStampedToken', () => {
122146
const locksRequest = navigator.locks.request as ReturnType<typeof vi.fn>
123147
expect(locksRequest).not.toHaveBeenCalled()
124148
})
149+
150+
it('does not refresh when tab is not visible', async () => {
151+
// Set visibility to hidden
152+
Object.defineProperty(global, 'document', {
153+
value: {
154+
visibilityState: 'hidden',
155+
addEventListener: vi.fn(),
156+
removeEventListener: vi.fn(),
157+
},
158+
writable: true,
159+
configurable: true,
160+
})
161+
162+
const mockClient = {
163+
observable: {request: vi.fn(() => of({token: 'sk-refreshed-token-st123'}))},
164+
}
165+
const mockClientFactory = vi.fn().mockReturnValue(mockClient)
166+
const instance = createSanityInstance({
167+
projectId: 'p',
168+
dataset: 'd',
169+
auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
170+
})
171+
const initialState = authStore.getInitialState(instance)
172+
initialState.authState = {
173+
type: AuthStateType.LOGGED_IN,
174+
token: 'sk-initial-token-st123',
175+
currentUser: null,
176+
}
177+
initialState.dashboardContext = {mode: 'test'}
178+
const state = createStoreState(initialState)
179+
180+
const subscription = refreshStampedToken({state, instance})
181+
subscriptions.push(subscription)
182+
183+
await vi.advanceTimersToNextTimerAsync()
184+
185+
// Verify that no refresh occurred
186+
expect(mockClient.observable.request).not.toHaveBeenCalled()
187+
const finalAuthState = state.get().authState
188+
if (finalAuthState.type === AuthStateType.LOGGED_IN) {
189+
expect(finalAuthState.token).toBe('sk-initial-token-st123')
190+
}
191+
})
125192
})
126193

127194
describe('non-dashboard context', () => {
@@ -150,11 +217,19 @@ describe('refreshStampedToken', () => {
150217
subscriptions.push(subscription!)
151218
}).not.toThrow()
152219

153-
// Avoid advancing timers here, as it would trigger the infinite loop
154-
// await vi.advanceTimersByTimeAsync(0)
155-
156-
// No assertions about navigator.locks.request call
157-
// No assertions about final token state (due to infinite loop in source)
220+
// DO NOT advance timers or yield here - focus on immediate observable logic
221+
// We cannot reliably test that failingLocksRequest is called due to async/timer issues,
222+
// but we *can* test the consequence of it resolving to false.
223+
224+
// VERIFY THE OUTCOME:
225+
// Check client request was NOT made (because filter(hasLock => hasLock) receives false)
226+
expect(mockClient.observable.request).not.toHaveBeenCalled()
227+
// Check state remains unchanged
228+
const finalAuthState = state.get().authState
229+
expect(finalAuthState.type).toBe(AuthStateType.LOGGED_IN)
230+
if (finalAuthState.type === AuthStateType.LOGGED_IN) {
231+
expect(finalAuthState.token).toBe('sk-initial-token-st123')
232+
}
158233
})
159234

160235
it('skips refresh if lock request returns false', async () => {
@@ -209,6 +284,61 @@ describe('refreshStampedToken', () => {
209284
})
210285
})
211286

287+
describe('unsupported environments', () => {
288+
it('falls back to immediate refresh if Web Locks API is not supported', async () => {
289+
// Temporarily remove navigator.locks for this test
290+
const originalLocks = navigator.locks
291+
Object.defineProperty(global.navigator, 'locks', {
292+
value: undefined,
293+
writable: true,
294+
})
295+
296+
try {
297+
const mockClient = {
298+
observable: {request: vi.fn(() => of({token: 'sk-refreshed-immediately-st123'}))},
299+
}
300+
const mockClientFactory = vi.fn().mockReturnValue(mockClient)
301+
const instance = createSanityInstance({
302+
projectId: 'p',
303+
dataset: 'd',
304+
auth: {clientFactory: mockClientFactory, storageArea: mockStorage},
305+
})
306+
const initialState = authStore.getInitialState(instance)
307+
initialState.authState = {
308+
type: AuthStateType.LOGGED_IN,
309+
token: 'sk-initial-token-st123',
310+
currentUser: null,
311+
}
312+
const state = createStoreState(initialState)
313+
314+
const subscription = refreshStampedToken({state, instance})
315+
subscriptions.push(subscription)
316+
317+
// Advance timers to allow the async `performRefresh` to execute
318+
await vi.advanceTimersToNextTimerAsync()
319+
320+
// Verify the refresh was performed and state was updated
321+
expect(mockClient.observable.request).toHaveBeenCalled()
322+
const finalAuthState = state.get().authState
323+
expect(finalAuthState.type).toBe(AuthStateType.LOGGED_IN)
324+
if (finalAuthState.type === AuthStateType.LOGGED_IN) {
325+
expect(finalAuthState.token).toBe('sk-refreshed-immediately-st123')
326+
}
327+
// Verify token was set in storage
328+
expect(mockStorage.setItem).toHaveBeenCalledWith(
329+
initialState.options.storageKey,
330+
JSON.stringify({token: 'sk-refreshed-immediately-st123'}),
331+
)
332+
} finally {
333+
// Restore navigator.locks
334+
Object.defineProperty(global.navigator, 'locks', {
335+
value: originalLocks,
336+
writable: true,
337+
})
338+
}
339+
})
340+
})
341+
212342
// Restore other tests to their simpler form
213343
it('sets an error state when token refresh fails', async () => {
214344
const error = new Error('Refresh failed')
@@ -298,3 +428,92 @@ describe('refreshStampedToken', () => {
298428
}
299429
})
300430
})
431+
432+
describe('time-based refresh helpers', () => {
433+
let mockStorage: Storage
434+
const storageKey = 'my-test-key'
435+
436+
beforeEach(() => {
437+
mockStorage = {
438+
getItem: vi.fn(),
439+
setItem: vi.fn(),
440+
removeItem: vi.fn(),
441+
clear: vi.fn(),
442+
key: vi.fn(),
443+
length: 0,
444+
}
445+
vi.useFakeTimers()
446+
})
447+
448+
afterEach(() => {
449+
vi.useRealTimers()
450+
})
451+
452+
describe('getLastRefreshTime', () => {
453+
it('returns 0 if storage is undefined', () => {
454+
expect(getLastRefreshTime(undefined, storageKey)).toBe(0)
455+
})
456+
457+
it('returns 0 if item is not in storage', () => {
458+
vi.spyOn(mockStorage, 'getItem').mockReturnValue(null)
459+
expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
460+
expect(mockStorage.getItem).toHaveBeenCalledWith(`${storageKey}_last_refresh`)
461+
})
462+
463+
it('returns the parsed timestamp from storage', () => {
464+
const now = Date.now()
465+
vi.spyOn(mockStorage, 'getItem').mockReturnValue(now.toString())
466+
expect(getLastRefreshTime(mockStorage, storageKey)).toBe(now)
467+
})
468+
469+
it('returns 0 if stored data is malformed', () => {
470+
vi.spyOn(mockStorage, 'getItem').mockReturnValue('not a number')
471+
expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
472+
})
473+
474+
it('returns 0 on storage access error', () => {
475+
vi.spyOn(mockStorage, 'getItem').mockImplementation(() => {
476+
throw new Error('Storage access failed')
477+
})
478+
expect(getLastRefreshTime(mockStorage, storageKey)).toBe(0)
479+
})
480+
})
481+
482+
describe('setLastRefreshTime', () => {
483+
it('sets the current timestamp in storage', () => {
484+
const now = Date.now()
485+
setLastRefreshTime(mockStorage, storageKey)
486+
expect(mockStorage.setItem).toHaveBeenCalledWith(`${storageKey}_last_refresh`, now.toString())
487+
})
488+
489+
it('does not throw on storage access error', () => {
490+
vi.spyOn(mockStorage, 'setItem').mockImplementation(() => {
491+
throw new Error('Storage access failed')
492+
})
493+
expect(() => setLastRefreshTime(mockStorage, storageKey)).not.toThrow()
494+
})
495+
})
496+
497+
describe('getNextRefreshDelay', () => {
498+
const REFRESH_INTERVAL = 12 * 60 * 60 * 1000
499+
500+
it('returns 0 if last refresh time is not available', () => {
501+
vi.spyOn(mockStorage, 'getItem').mockReturnValue(null)
502+
expect(getNextRefreshDelay(mockStorage, storageKey)).toBe(0)
503+
})
504+
505+
it('returns the remaining time until the next refresh', () => {
506+
const lastRefresh = Date.now() - 10000 // 10 seconds ago
507+
vi.spyOn(mockStorage, 'getItem').mockReturnValue(lastRefresh.toString())
508+
509+
const delay = getNextRefreshDelay(mockStorage, storageKey)
510+
expect(delay).toBeCloseTo(REFRESH_INTERVAL - 10000, -2)
511+
})
512+
513+
it('returns 0 if the refresh interval has passed', () => {
514+
const lastRefresh = Date.now() - REFRESH_INTERVAL - 5000 // 5 seconds past due
515+
vi.spyOn(mockStorage, 'getItem').mockReturnValue(lastRefresh.toString())
516+
expect(getNextRefreshDelay(mockStorage, storageKey)).toBe(0)
517+
})
518+
})
519+
})

0 commit comments

Comments
 (0)