/** * Tests for security/safe-fetch.js * * Tests the safeFetch wrapper that validates URLs before making * fetch requests, blocking unsafe external URLs. */ // Load url-validator first (safe-fetch depends on it) import '@js/security/url-validator.js'; import '@js/security/safe-fetch.js'; // safeFetchWithAuth reuses the shared 401 helpers exposed on window.api import '@js/services/api.js'; describe('safeFetch', () => { const originalFetch = globalThis.fetch; beforeEach(() => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('ok', { status: 200 })) ); }); afterEach(() => { globalThis.fetch = originalFetch; }); it('allows internal URLs starting with /', async () => { await window.safeFetch('/api/research/1'); expect(globalThis.fetch).toHaveBeenCalledWith('/api/research/1', {}); }); it('allows internal URLs with custom options', async () => { const opts = { method: 'POST', body: '{}' }; await window.safeFetch('/api/start', opts); expect(globalThis.fetch).toHaveBeenCalledWith('/api/start', opts); }); it('allows safe external URLs (https)', async () => { await window.safeFetch('https://example.com/api'); expect(globalThis.fetch).toHaveBeenCalled(); }); it('allows safe external URLs (http)', async () => { await window.safeFetch('http://example.com/api'); expect(globalThis.fetch).toHaveBeenCalled(); }); it('blocks javascript: URLs', async () => { await expect(window.safeFetch('javascript:alert(1)')) .rejects.toThrow('Blocked unsafe URL'); }); it('blocks data: URLs', async () => { await expect(window.safeFetch('data:text/html,

xss

')) .rejects.toThrow('Blocked unsafe URL'); }); it('blocks vbscript: URLs', async () => { await expect(window.safeFetch('vbscript:msgbox')) .rejects.toThrow('Blocked unsafe URL'); }); it('does not call fetch for blocked URLs', async () => { try { await window.safeFetch('javascript:void(0)'); } catch {} expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); describe('safeFetchWithAuth', () => { 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/library/collections', pathname: '/library/collections', search: '', hash: '', }; }); afterEach(() => { globalThis.fetch = originalFetch; window.location = originalLocation; }); it('returns the Response unchanged on 200 (no redirect)', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"ok":true}', { status: 200 })) ); const response = await window.safeFetchWithAuth('/library/api/collections'); expect(response.status).toBe(200); expect(window.location.href).toBe('http://localhost/library/collections'); }); it('redirects to /auth/login with next= on 401 for an internal URL', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"error":"auth"}', { status: 401 })) ); // On the redirect path the call returns a never-resolving Promise. const result = await Promise.race([ window.safeFetchWithAuth('/library/api/collections'), new Promise((resolve) => setTimeout(() => resolve('timeout'), 50)), ]); expect(result).toBe('timeout'); // never resolved expect(window.location.href).toBe( `/auth/login?next=${encodeURIComponent('/library/collections')}` ); }); it('returns the Response unchanged on a non-401 error (no redirect)', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('{"error":"boom"}', { status: 500 })) ); const response = await window.safeFetchWithAuth('/library/api/collections'); expect(response.status).toBe(500); expect(window.location.href).toBe('http://localhost/library/collections'); }); it('does not redirect on 401 from an external URL', async () => { globalThis.fetch = vi.fn(() => Promise.resolve(new Response('', { status: 401 })) ); const response = await window.safeFetchWithAuth('https://api.example.com/foo'); expect(response.status).toBe(401); expect(window.location.href).toBe('http://localhost/library/collections'); }); 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('', { status: 401 })) ); const response = await window.safeFetchWithAuth('/library/api/collections'); expect(response.status).toBe(401); expect(window.location.href).toBe('http://localhost/auth/login'); }); it('still validates URLs (blocks javascript:)', async () => { await expect( window.safeFetchWithAuth('javascript:alert(1)') ).rejects.toThrow('Blocked unsafe URL'); }); });