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
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m17s
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user