feat: enhance refresh token handling with grace period and rotation detection
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s

This commit is contained in:
2026-07-26 18:35:56 -04:00
parent 11f68c7f76
commit d4800be3b7
7 changed files with 413 additions and 33 deletions
@@ -6,6 +6,25 @@ vi.mock('@/stores/auth', () => ({
logoutUser: () => mockLogoutUser(),
}))
function makeLocalStorageStub() {
const store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = value
},
removeItem: (key: string) => {
delete store[key]
},
clear: () => {
for (const k of Object.keys(store)) delete store[k]
},
_store: store,
}
}
const localStorageStub = makeLocalStorageStub()
describe('installUnauthorizedFetchInterceptor', () => {
const originalFetch = globalThis.fetch
@@ -13,10 +32,13 @@ describe('installUnauthorizedFetchInterceptor', () => {
vi.resetModules()
mockLogoutUser.mockReset()
globalThis.fetch = vi.fn()
localStorageStub.clear()
vi.stubGlobal('localStorage', localStorageStub)
})
afterEach(() => {
globalThis.fetch = originalFetch
vi.unstubAllGlobals()
})
it('attempts refresh on 401, retries the original request on success', async () => {
@@ -181,4 +203,67 @@ describe('installUnauthorizedFetchInterceptor', () => {
expect(mockLogoutUser).not.toHaveBeenCalled()
expect(redirectSpy).not.toHaveBeenCalled()
})
it('sets lastRefreshAt in localStorage after a successful refresh', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ ok: true, status: 200 } as Response)
.mockResolvedValueOnce({ status: 200 } as Response)
window.history.pushState({}, '', '/parent')
const redirectSpy = vi.fn()
const {
installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor()
await fetch('/api/user/profile')
const lastRefresh = localStorageStub.getItem('lastRefreshAt')
expect(lastRefresh).not.toBeNull()
expect(Number(lastRefresh)).toBeLessThanOrEqual(Date.now())
expect(mockLogoutUser).not.toHaveBeenCalled()
expect(redirectSpy).not.toHaveBeenCalled()
})
it('skips refresh call when another tab recently refreshed', async () => {
const fetchMock = globalThis.fetch as unknown as ReturnType<typeof vi.fn>
// Only original request and retry; refresh should be skipped due to cross-tab coordination
fetchMock
.mockResolvedValueOnce({ status: 401 } as Response)
.mockResolvedValueOnce({ status: 200, body: 'retried' } as unknown as Response)
window.history.pushState({}, '', '/parent')
const redirectSpy = vi.fn()
const {
installUnauthorizedFetchInterceptor,
setUnauthorizedRedirectHandlerForTests,
resetInterceptorStateForTests,
} = await import('../api')
resetInterceptorStateForTests()
setUnauthorizedRedirectHandlerForTests(redirectSpy)
installUnauthorizedFetchInterceptor()
// Simulate another tab having refreshed 1 second ago, after resetInterceptorStateForTests
localStorageStub.setItem('lastRefreshAt', String(Date.now() - 1000))
const result = await fetch('/api/user/profile')
// Should not call /api/auth/refresh; only original + retry
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
'/api/user/profile',
'/api/user/profile',
])
expect(mockLogoutUser).not.toHaveBeenCalled()
expect(redirectSpy).not.toHaveBeenCalled()
expect((result as Response).status).toBe(200)
})
})