admin/config/axios.functional.test.ts
alisaza 64e2c11076 feat(axios): implement token synchronization across tabs for access token refresh
Added functionality to synchronize access token refresh across multiple tabs using the `navigator.locks` API. This prevents race conditions when refreshing tokens, ensuring that a newer token published by another tab is reused instead of rotating the refresh token unnecessarily. Introduced a new method `refreshAccessTokenAcrossTabs` and updated the `getOrRefreshAccessToken` function to utilize this new method. Enhanced error handling for 401 responses to retry with the latest stored token when applicable.
2026-09-10 20:26:20 +03:30

103 lines
3.3 KiB
TypeScript

import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
expireRefreshSession: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/expireRefreshSession', () => ({
expireRefreshSessionOnThisOrigin: () => mocks.expireRefreshSession(),
}))
const writeStoredToken = (accessToken: string): void => {
window.localStorage.setItem(
'user',
JSON.stringify({
accessToken,
userId: 'admin-1',
role: 'admin',
sessionId: accessToken === 'access-a' ? 'session-a' : 'session-b',
AccessTokenExpireTime: Date.now() + 10 * 60_000,
})
)
}
describe('admin access-token refresh races', () => {
beforeEach(async () => {
vi.clearAllMocks()
vi.restoreAllMocks()
window.localStorage.clear()
const { resetAxiosAuthModuleState } = await import('@/config/axios')
resetAxiosAuthModuleState()
})
it('replays a late 401 with the newer stored token without rotating refresh again', async () => {
writeStoredToken('access-a')
const { default: axiosInstance } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post')
const authorizationHeaders: string[] = []
let requestCount = 0
const adapter = async (config: InternalAxiosRequestConfig) => {
requestCount += 1
authorizationHeaders.push(String(config.headers.get('Authorization')))
if (requestCount === 1) {
// Another request/tab finished refresh while this request was in flight.
writeStoredToken('access-b')
throw new AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
config,
data: { message: 'Unauthorized' },
headers: {},
status: 401,
statusText: 'Unauthorized',
})
}
return {
config,
data: { ok: true },
headers: {},
status: 200,
statusText: 'OK',
}
}
await expect(axiosInstance.get('/race', { adapter })).resolves.toMatchObject({ data: { ok: true } })
expect(authorizationHeaders).toEqual(['Bearer access-a', 'Bearer access-b'])
expect(refreshRequest).not.toHaveBeenCalled()
expect(mocks.expireRefreshSession).not.toHaveBeenCalled()
})
it('reuses a token published by another tab while waiting for the refresh lock', async () => {
writeStoredToken('access-a')
const locksDescriptor = Object.getOwnPropertyDescriptor(navigator, 'locks')
const requestLock = vi.fn(async (_name: string, callback: () => Promise<string | null>) => {
// The other lock owner completes rotation before this callback starts.
writeStoredToken('access-b')
return callback()
})
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: requestLock },
})
try {
const { getOrRefreshAccessToken } = await import('@/config/axios')
const refreshRequest = vi.spyOn(axios, 'post')
await expect(getOrRefreshAccessToken()).resolves.toBe('access-b')
expect(requestLock).toHaveBeenCalledOnce()
expect(refreshRequest).not.toHaveBeenCalled()
} finally {
if (locksDescriptor) {
Object.defineProperty(navigator, 'locks', locksDescriptor)
} else {
Reflect.deleteProperty(navigator, 'locks')
}
}
})
})