diff --git a/src/web/src/api/axiosClient.ts b/src/web/src/api/axiosClient.ts index 5367d21..b86ad81 100644 --- a/src/web/src/api/axiosClient.ts +++ b/src/web/src/api/axiosClient.ts @@ -1,10 +1,92 @@ -import axios from 'axios' +import axios, { AxiosError } from 'axios' +import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' +import { useAuthStore } from '@/stores/authStore' const API_URL = import.meta.env['VITE_API_URL'] ?? 'http://localhost:5212' export const axiosClient = axios.create({ baseURL: API_URL, - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, }) + +// --- Request interceptor: attach Bearer from authStore +axiosClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const token = useAuthStore.getState().accessToken + if (token && !config.headers.Authorization) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// --- Singleton promise queue for refresh +// N concurrent 401s share the same refresh promise — only ONE call to /auth/refresh +let refreshPromise: Promise | null = null + +async function performRefresh(): Promise { + const { refreshToken, accessToken, updateAccess, clearAuth } = useAuthStore.getState() + if (!refreshToken || !accessToken) { + clearAuth() + throw new Error('no tokens available for refresh') + } + try { + // IMPORTANT: use plain axios, NOT axiosClient, to avoid the response interceptor loop + const res = await axios.post<{ accessToken: string; refreshToken: string; expiresIn: number }>( + `${API_URL}/api/v1/auth/refresh`, + { accessToken, refreshToken }, + { headers: { 'Content-Type': 'application/json' } }, + ) + const { accessToken: newAccess, refreshToken: newRefresh, expiresIn } = res.data + updateAccess(newAccess, newRefresh, Date.now() + expiresIn * 1000) + return newAccess + } catch (e) { + clearAuth() + if (typeof window !== 'undefined') { + window.location.assign('/login') + } + throw e + } +} + +interface RetryConfig extends AxiosRequestConfig { + _retry?: boolean +} + +// --- Response interceptor: handle 401 with token refresh +axiosClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const original = error.config as RetryConfig | undefined + const status = error.response?.status + const url = original?.url ?? '' + + // Only attempt refresh for 401s on non-auth endpoints + if ( + status !== 401 || + !original || + original._retry || + url.includes('/auth/refresh') || + url.includes('/auth/login') + ) { + return Promise.reject(error) + } + + original._retry = true + + try { + // Singleton: if refresh is already in flight, await the same promise + if (refreshPromise === null) { + refreshPromise = performRefresh().finally(() => { + refreshPromise = null + }) + } + const newAccess = await refreshPromise + + // Retry original request with new token + original.headers = original.headers ?? {} + ;(original.headers as Record).Authorization = `Bearer ${newAccess}` + return axiosClient(original) + } catch (e) { + return Promise.reject(e) + } + }, +) diff --git a/src/web/src/tests/api/axiosClient.test.ts b/src/web/src/tests/api/axiosClient.test.ts new file mode 100644 index 0000000..bfd176a --- /dev/null +++ b/src/web/src/tests/api/axiosClient.test.ts @@ -0,0 +1,250 @@ +/** + * T-064 — axiosClient interceptor tests (MSW v2) + * + * Tests cover: + * 1. request_includesBearer_whenAccessTokenPresent + * 2. request_noBearer_whenAccessTokenNull + * 3. response_401_triggersRefreshAndRetries + * 4. response_401_threeParallel_singleRefresh (CRITICAL — singleton promise) + * 5. response_401_refreshFails_clearsAuthAndRejects + * 6. response_401_onLoginUrl_noRefresh + * 7. response_401_onRefreshUrl_noLoop + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { useAuthStore } from '../../stores/authStore' + +const API_URL = 'http://localhost:5000' + +// Helper to reset the refreshPromise singleton between tests +// by reimporting the module. We use a direct import instead. +async function getAxiosClient() { + const mod = await import('../../api/axiosClient') + return mod.axiosClient +} + +// MSW server +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' })) +afterAll(() => server.close()) + +beforeEach(() => { + // Reset store to clean slate + useAuthStore.setState({ + user: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + }) + // Reset window.location mock if applied + vi.restoreAllMocks() +}) + +afterEach(() => { + server.resetHandlers() +}) + +function setAuth(accessToken: string, refreshToken: string) { + useAuthStore.setState({ + user: { id: 1, username: 'admin', nombre: 'Admin', rol: 'admin' }, + accessToken, + refreshToken, + expiresAt: Date.now() + 3600 * 1000, + }) +} + +describe('axiosClient', () => { + describe('request interceptor', () => { + it('request_includesBearer_whenAccessTokenPresent', async () => { + setAuth('my-access-token', 'my-refresh-token') + + let capturedAuthHeader: string | null = null + server.use( + http.get(`${API_URL}/api/v1/test`, ({ request }) => { + capturedAuthHeader = request.headers.get('Authorization') + return HttpResponse.json({ ok: true }) + }), + ) + + const client = await getAxiosClient() + await client.get('/api/v1/test') + + expect(capturedAuthHeader).toBe('Bearer my-access-token') + }) + + it('request_noBearer_whenAccessTokenNull', async () => { + // No auth set — accessToken is null + + let capturedAuthHeader: string | null | undefined = undefined + server.use( + http.get(`${API_URL}/api/v1/test`, ({ request }) => { + capturedAuthHeader = request.headers.get('Authorization') + return HttpResponse.json({ ok: true }) + }), + ) + + const client = await getAxiosClient() + await client.get('/api/v1/test') + + expect(capturedAuthHeader).toBeNull() + }) + }) + + describe('response interceptor — 401 handling', () => { + it('response_401_triggersRefreshAndRetries', async () => { + setAuth('expired-access', 'valid-refresh') + + let requestCount = 0 + server.use( + // Protected endpoint: returns 401 first, then 200 after refresh + http.get(`${API_URL}/api/v1/protected`, ({ request }) => { + requestCount++ + const auth = request.headers.get('Authorization') + if (auth === 'Bearer new-access-token') { + return HttpResponse.json({ data: 'secret' }) + } + return new HttpResponse(null, { status: 401 }) + }), + // Refresh endpoint + http.post(`${API_URL}/api/v1/auth/refresh`, () => { + return HttpResponse.json({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + }) + }), + ) + + const client = await getAxiosClient() + const res = await client.get('/api/v1/protected') + + expect(res.data).toEqual({ data: 'secret' }) + expect(requestCount).toBe(2) // 1st: 401, 2nd: 200 with new token + expect(useAuthStore.getState().accessToken).toBe('new-access-token') + expect(useAuthStore.getState().refreshToken).toBe('new-refresh-token') + }) + + it('response_401_threeParallel_singleRefresh', async () => { + setAuth('expired-access', 'valid-refresh') + + let refreshCallCount = 0 + let requestCount = 0 + + server.use( + http.get(`${API_URL}/api/v1/protected`, ({ request }) => { + requestCount++ + const auth = request.headers.get('Authorization') + if (auth === 'Bearer new-access-from-refresh') { + return HttpResponse.json({ data: 'ok' }) + } + return new HttpResponse(null, { status: 401 }) + }), + http.post(`${API_URL}/api/v1/auth/refresh`, async () => { + refreshCallCount++ + // Simulate slight delay so concurrent requests queue up + await new Promise((r) => setTimeout(r, 20)) + return HttpResponse.json({ + accessToken: 'new-access-from-refresh', + refreshToken: 'new-refresh-from-refresh', + expiresIn: 3600, + }) + }), + ) + + const client = await getAxiosClient() + + // Fire 3 requests simultaneously — all get 401, all should wait on single refresh + const [r1, r2, r3] = await Promise.all([ + client.get('/api/v1/protected'), + client.get('/api/v1/protected'), + client.get('/api/v1/protected'), + ]) + + expect(r1.data).toEqual({ data: 'ok' }) + expect(r2.data).toEqual({ data: 'ok' }) + expect(r3.data).toEqual({ data: 'ok' }) + + // CRITICAL: only ONE call to /auth/refresh despite 3 parallel 401s + expect(refreshCallCount).toBe(1) + }) + + it('response_401_refreshFails_clearsAuthAndRejects', async () => { + setAuth('expired-access', 'invalid-refresh') + + // Mock window.location.assign to avoid jsdom navigation error + const assignMock = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignMock }, + writable: true, + }) + + server.use( + http.get(`${API_URL}/api/v1/protected`, () => { + return new HttpResponse(null, { status: 401 }) + }), + http.post(`${API_URL}/api/v1/auth/refresh`, () => { + return new HttpResponse(null, { status: 401 }) + }), + ) + + const client = await getAxiosClient() + + await expect(client.get('/api/v1/protected')).rejects.toThrow() + + // Auth should be cleared + const state = useAuthStore.getState() + expect(state.accessToken).toBeNull() + expect(state.user).toBeNull() + }) + + it('response_401_onLoginUrl_noRefresh', async () => { + setAuth('expired-access', 'valid-refresh') + + let refreshCalled = false + server.use( + http.post(`${API_URL}/api/v1/auth/login`, () => { + return new HttpResponse(null, { status: 401 }) + }), + http.post(`${API_URL}/api/v1/auth/refresh`, () => { + refreshCalled = true + return HttpResponse.json({ + accessToken: 'new-access', + refreshToken: 'new-refresh', + expiresIn: 3600, + }) + }), + ) + + const client = await getAxiosClient() + + // Login 401 should NOT trigger refresh — just reject + await expect(client.post('/api/v1/auth/login', {})).rejects.toThrow() + expect(refreshCalled).toBe(false) + }) + + it('response_401_onRefreshUrl_noLoop', async () => { + setAuth('expired-access', 'valid-refresh') + + let refreshCallCount = 0 + server.use( + http.post(`${API_URL}/api/v1/auth/refresh`, () => { + refreshCallCount++ + return new HttpResponse(null, { status: 401 }) + }), + ) + + const client = await getAxiosClient() + + // Calling refresh endpoint that returns 401 should NOT re-trigger refresh + await expect( + client.post('/api/v1/auth/refresh', { accessToken: 'x', refreshToken: 'y' }), + ).rejects.toThrow() + + // Should have called /refresh exactly once (the explicit call), no loop + expect(refreshCallCount).toBe(1) + }) + }) +})