/** * Tests for services/api.js — fetchWithErrorHandling 401 -> login redirect. * * fetchWithErrorHandling is the shared entry point for the core API surface * (research start/status/details/logs/history/report, config saves, deletes). * It throws on any non-2xx, so callers never see a bare 401. These tests lock * in the behavior that an expired session on an internal API call bounces the * user to /auth/login (preserving where they were) instead of surfacing an * opaque "API Error: 401", while the /auth/* flow and external URLs are left * untouched to avoid redirect loops and false positives. */ import '@js/services/api.js'; const { fetchWithErrorHandling, shouldRedirectToLoginOn401 } = window.api; describe('shouldRedirectToLoginOn401', () => { const originalLocation = window.location; beforeEach(() => { delete window.location; window.location = { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '' }; }); afterEach(() => { window.location = originalLocation; }); it('redirects for an internal API URL', () => { expect(shouldRedirectToLoginOn401('/api/history')).toBe(true); }); it('does not redirect for an external URL', () => { expect(shouldRedirectToLoginOn401('https://api.example.com/foo')).toBe(false); }); it('does not redirect for a protocol-relative (cross-origin) URL', () => { expect(shouldRedirectToLoginOn401('//api.example.com/foo')).toBe(false); }); it('does not redirect for a backslash-bypass URL that resolves cross-origin', () => { // `/\evil.com` is normalized by browsers to https://evil.com (cross-origin) expect(shouldRedirectToLoginOn401('/\\evil.com')).toBe(false); }); it('does not redirect for a request to /auth/*', () => { expect(shouldRedirectToLoginOn401('/auth/check')).toBe(false); }); it('does not redirect while already on an /auth/* page', () => { window.location.pathname = '/auth/login'; expect(shouldRedirectToLoginOn401('/api/history')).toBe(false); }); }); describe('fetchWithErrorHandling — 401 handling', () => { const originalFetch = globalThis.fetch; const originalLocation = window.location; beforeEach(() => { // Writable stub so the redirect assignment doesn't navigate the runner. delete window.location; window.location = { href: 'http://localhost/dashboard', pathname: '/dashboard', search: '', hash: '', }; }); afterEach(() => { globalThis.fetch = originalFetch; window.location = originalLocation; }); it('returns parsed JSON on 200 without redirecting', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"ok":true}', { status: 200 })) ); const data = await fetchWithErrorHandling('/api/history'); expect(data).toEqual({ ok: true }); expect(window.location.href).toBe('http://localhost/dashboard'); }); it('redirects to /auth/login with next= on 401 for an internal URL', async () => { globalThis.fetch = vi.fn(() => Promise.resolve( new Response('{"error":"Authentication required"}', { status: 401 }) ) ); // On the redirect path the call returns a never-resolving Promise, so // race it against a short timeout to keep the test from hanging. const result = await Promise.race([ fetchWithErrorHandling('/api/history'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), ]); expect(result).toBe('timeout'); // never resolved expect(window.location.href).toBe( `/auth/login?next=${encodeURIComponent('/dashboard')}` ); }); it('preserves the query string and hash in next= (so #logs-style state survives re-login)', async () => { window.location.pathname = '/settings'; window.location.search = '?tab=embeddings'; window.location.hash = '#logs'; window.location.href = 'http://localhost/settings?tab=embeddings#logs'; globalThis.fetch = vi.fn(() => Promise.resolve(new Response('', { status: 401 })) ); await Promise.race([ fetchWithErrorHandling('/api/history'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), ]); expect(window.location.href).toBe( `/auth/login?next=${encodeURIComponent('/settings?tab=embeddings#logs')}` ); }); it('does not redirect on 401 while already on an /auth/* page', async () => { window.location.pathname = '/auth/login'; window.location.href = 'http://localhost/auth/login'; globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"error":"bad creds"}', { status: 401 })) ); await expect(fetchWithErrorHandling('/api/history')).rejects.toThrow(); expect(window.location.href).toBe('http://localhost/auth/login'); }); it('does not redirect on 401 when the request itself targets /auth/*', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"error":"bad creds"}', { status: 401 })) ); await expect(fetchWithErrorHandling('/auth/check')).rejects.toThrow(); expect(window.location.href).toBe('http://localhost/dashboard'); }); it('does not redirect on a non-401 error and throws the message', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"error":"boom"}', { status: 500 })) ); await expect(fetchWithErrorHandling('/api/history')).rejects.toThrow('boom'); expect(window.location.href).toBe('http://localhost/dashboard'); }); });