/** * @jest-environment jsdom */ import axios from 'axios'; import { setTokenHeader } from '../src/headers-helpers'; /** * The response interceptor in request.ts registers at import time when * `typeof window !== 'undefined'` (jsdom provides window). * * We use axios's built-in request adapter mock to avoid real HTTP calls, * and verify the interceptor's behavior by observing whether a 401 triggers * a refresh POST or is immediately rejected. */ const mockAdapter = jest.fn(); let originalAdapter: typeof axios.defaults.adapter; let savedLocation: Location; beforeAll(async () => { originalAdapter = axios.defaults.adapter; axios.defaults.adapter = mockAdapter; await import('../src/request'); }); beforeEach(() => { mockAdapter.mockReset(); savedLocation = window.location; }); afterAll(() => { axios.defaults.adapter = originalAdapter; }); afterEach(() => { delete axios.defaults.headers.common['Authorization']; Object.defineProperty(window, 'location', { value: savedLocation, writable: true, }); }); function setWindowLocation(overrides: Partial) { Object.defineProperty(window, 'location', { value: { ...window.location, ...overrides }, writable: true, }); } describe('axios 401 interceptor — Authorization header guard', () => { it('skips refresh and rejects when Authorization header is cleared', async () => { expect.assertions(1); setTokenHeader(undefined); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/messages', headers: {} }, }); try { await axios.get('/api/messages'); } catch { // expected rejection } expect(mockAdapter).toHaveBeenCalledTimes(1); }); it('attempts refresh on shared link page even without Authorization header', async () => { expect.assertions(2); setTokenHeader(undefined); setWindowLocation({ href: 'http://localhost/share/abc123', pathname: '/share/abc123', search: '', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/share/abc123', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ data: { token: 'new-token' }, status: 200, headers: {}, config: {}, }); mockAdapter.mockResolvedValueOnce({ data: { sharedLink: {} }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/share/abc123'); } catch { // may reject depending on exact flow } expect(mockAdapter.mock.calls.length).toBe(3); const refreshCall = mockAdapter.mock.calls[1]; expect(refreshCall[0].url).toContain('api/auth/refresh'); }); it('does not bypass guard when share/ appears only in query params', async () => { expect.assertions(1); setTokenHeader(undefined); setWindowLocation({ href: 'http://localhost/c/chat?ref=share/token', pathname: '/c/chat', search: '?ref=share/token', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/messages', headers: {} }, }); try { await axios.get('/api/messages'); } catch { // expected rejection } expect(mockAdapter).toHaveBeenCalledTimes(1); }); it('redirects to login with redirect_to when unauthenticated on share page and refresh fails', async () => { expect.assertions(1); setTokenHeader(undefined); setWindowLocation({ href: 'http://localhost/share/abc123', pathname: '/share/abc123', search: '', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/share/abc123', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ data: { token: '' }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/share/abc123'); } catch { // expected rejection } expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123'); }); it('redirects to login with redirect_to when authenticated and refresh returns no token on share page', async () => { expect.assertions(1); setTokenHeader('some-token'); setWindowLocation({ href: 'http://localhost/share/abc123', pathname: '/share/abc123', search: '', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/share/abc123', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ data: { token: '' }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/share/abc123'); } catch { // expected rejection } expect(window.location.href).toBe('/login?redirect_to=%2Fshare%2Fabc123'); }); it('redirects to login with redirect_to when refresh returns no token on regular page', async () => { expect.assertions(1); setTokenHeader('some-token'); setWindowLocation({ href: 'http://localhost/c/some-conversation', pathname: '/c/some-conversation', search: '', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/messages', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ data: { token: '' }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/messages'); } catch { // expected rejection } expect(window.location.href).toBe('/login?redirect_to=%2Fc%2Fsome-conversation'); }); it('redirects to plain /login without redirect_to when already on a login path', async () => { expect.assertions(1); setTokenHeader('some-token'); setWindowLocation({ href: 'http://localhost/login/2fa', pathname: '/login/2fa', search: '', hash: '', } as Partial); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/messages', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ data: { token: '' }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/messages'); } catch { // expected rejection } expect(window.location.href).toBe('/login'); }); it('attempts refresh when Authorization header is present', async () => { expect.assertions(2); setTokenHeader('valid-token'); mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, config: { url: '/api/messages', headers: {}, _retry: false }, }); mockAdapter.mockResolvedValueOnce({ data: { token: 'new-token' }, status: 200, headers: {}, config: {}, }); mockAdapter.mockResolvedValueOnce({ data: { messages: [] }, status: 200, headers: {}, config: {}, }); try { await axios.get('/api/messages'); } catch { // may reject depending on exact flow } expect(mockAdapter.mock.calls.length).toBe(3); const refreshCall = mockAdapter.mock.calls[1]; expect(refreshCall[0].url).toContain('api/auth/refresh'); }); });