/** * JavaScript tests for api.js CSRF handling and error behavior * Run with: cd tests/infrastructure_tests && npm test test_csrf_api.test.js * * Tests getCsrfToken, fetchWithErrorHandling, header merging, error handling, * timeouts, and the module.exports dual-guard pattern. */ // --- Global mocks (set BEFORE require) --- global.document = { querySelector: jest.fn() }; global.fetch = jest.fn(); global.SafeLogger = { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }; global.window = {}; // Load urls.js first (dependency for URLS and URLBuilder globals) const urlsModule = require('../../src/local_deep_research/web/static/js/config/urls.js'); global.URLS = global.window.URLS; global.URLBuilder = global.window.URLBuilder; // Load the module under test const apiModule = require('../../src/local_deep_research/web/static/js/services/api.js'); // --- Helpers --- /** Create a mock Response object */ function mockResponse({ ok = true, status = 200, statusText = 'OK', json = {}, jsonRejects = false } = {}) { return { ok, status, statusText, json: jsonRejects ? jest.fn().mockRejectedValue(new Error('JSON parse error')) : jest.fn().mockResolvedValue(json) }; } /** Set up document.querySelector to return a meta tag with the given token */ function setMetaToken(token) { global.document.querySelector.mockReturnValue({ getAttribute: jest.fn().mockReturnValue(token) }); } /** Set up document.querySelector to return null (no meta tag) */ function clearMetaToken() { global.document.querySelector.mockReturnValue(null); } // ============================================================ // 1. Module exports shape // ============================================================ describe('Module exports shape', () => { test('require() returns object with expected function names', () => { const expectedExports = [ 'getCsrfToken', 'fetchWithErrorHandling', 'getApiUrl', 'postJSON', 'startResearch', 'getResearchStatus', 'getResearchDetails', 'getResearchLogs', 'getResearchHistory', 'getReport', 'getMarkdownExport', 'terminateResearch', 'deleteResearch', 'clearResearchHistory', 'openFileLocation', 'saveRawConfig', 'saveMainConfig', 'saveSearchEnginesConfig', 'saveCollectionsConfig', 'saveApiKeysConfig', 'saveLlmConfig' ]; expectedExports.forEach(name => { expect(apiModule[name]).toBeDefined(); expect(typeof apiModule[name]).toBe('function'); }); }); test('getCsrfToken and fetchWithErrorHandling are functions on the export', () => { expect(typeof apiModule.getCsrfToken).toBe('function'); expect(typeof apiModule.fetchWithErrorHandling).toBe('function'); }); test('window.api is also populated (browser path)', () => { expect(global.window.api).toBeDefined(); expect(typeof global.window.api.startResearch).toBe('function'); expect(typeof global.window.api.postJSON).toBe('function'); }); test('window.api.getCsrfToken exists (enables PR #2453 delegation pattern)', () => { expect(typeof global.window.api.getCsrfToken).toBe('function'); }); }); // ============================================================ // 2. getCsrfToken - core behavior // ============================================================ describe('getCsrfToken', () => { const { getCsrfToken } = apiModule; beforeEach(() => { jest.clearAllMocks(); }); test('returns token string when meta tag is present', () => { setMetaToken('test-csrf-token-123'); expect(getCsrfToken()).toBe('test-csrf-token-123'); }); test('returns empty string when querySelector returns null', () => { clearMetaToken(); expect(getCsrfToken()).toBe(''); }); test('returns null when getAttribute("content") returns null', () => { global.document.querySelector.mockReturnValue({ getAttribute: jest.fn().mockReturnValue(null) }); // Documenting latent behavior: null is falsy so downstream is safe expect(getCsrfToken()).toBeNull(); }); test('queries the correct selector meta[name="csrf-token"]', () => { clearMetaToken(); getCsrfToken(); expect(global.document.querySelector).toHaveBeenCalledWith('meta[name="csrf-token"]'); }); }); // ============================================================ // 3. fetchWithErrorHandling - CSRF injection // ============================================================ describe('fetchWithErrorHandling - CSRF injection', () => { const { fetchWithErrorHandling } = apiModule; beforeEach(() => { jest.clearAllMocks(); global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } })); }); test('injects X-CSRFToken for POST (via postJSON)', async () => { setMetaToken('post-token'); await apiModule.postJSON('/api/test', { data: 1 }); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].headers['X-CSRFToken']).toBe('post-token'); expect(fetchCall[1].method).toBe('POST'); }); test('injects X-CSRFToken for DELETE (via deleteResearch)', async () => { setMetaToken('delete-token'); await apiModule.deleteResearch(42); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].headers['X-CSRFToken']).toBe('delete-token'); expect(fetchCall[1].method).toBe('DELETE'); }); test('does NOT inject X-CSRFToken when meta tag absent (empty string is falsy)', async () => { clearMetaToken(); await fetchWithErrorHandling('/api/test'); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].headers['X-CSRFToken']).toBeUndefined(); }); test('always sets Content-Type: application/json', async () => { clearMetaToken(); await fetchWithErrorHandling('/api/test'); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].headers['Content-Type']).toBe('application/json'); }); }); // ============================================================ // 4. Header merge order // ============================================================ describe('Header merge order', () => { const { fetchWithErrorHandling } = apiModule; beforeEach(() => { jest.clearAllMocks(); global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } })); }); test('default Content-Type: application/json is set', async () => { clearMetaToken(); await fetchWithErrorHandling('/api/test'); const headers = global.fetch.mock.calls[0][1].headers; expect(headers['Content-Type']).toBe('application/json'); }); test('caller Content-Type overrides default', async () => { clearMetaToken(); await fetchWithErrorHandling('/api/test', { headers: { 'Content-Type': 'text/plain' } }); const headers = global.fetch.mock.calls[0][1].headers; expect(headers['Content-Type']).toBe('text/plain'); }); test('caller X-CSRFToken overrides auto-injected one', async () => { setMetaToken('auto-token'); await fetchWithErrorHandling('/api/test', { headers: { 'X-CSRFToken': 'manual-token' } }); const headers = global.fetch.mock.calls[0][1].headers; expect(headers['X-CSRFToken']).toBe('manual-token'); }); test('caller headers coexist with defaults (no key loss)', async () => { setMetaToken('my-token'); await fetchWithErrorHandling('/api/test', { headers: { 'X-Custom': 'custom-value' } }); const headers = global.fetch.mock.calls[0][1].headers; expect(headers['Content-Type']).toBe('application/json'); expect(headers['X-CSRFToken']).toBe('my-token'); expect(headers['X-Custom']).toBe('custom-value'); }); test('null/undefined caller headers handled via || {} guard', async () => { setMetaToken('safe-token'); // Explicitly pass headers: undefined await fetchWithErrorHandling('/api/test', { headers: undefined }); const headers1 = global.fetch.mock.calls[0][1].headers; expect(headers1['Content-Type']).toBe('application/json'); expect(headers1['X-CSRFToken']).toBe('safe-token'); // Explicitly pass headers: null await fetchWithErrorHandling('/api/test', { headers: null }); const headers2 = global.fetch.mock.calls[1][1].headers; expect(headers2['Content-Type']).toBe('application/json'); expect(headers2['X-CSRFToken']).toBe('safe-token'); }); }); // ============================================================ // 5. Token rotation / idempotency // ============================================================ describe('Token rotation / idempotency', () => { const { fetchWithErrorHandling } = apiModule; beforeEach(() => { jest.clearAllMocks(); global.fetch.mockResolvedValue(mockResponse({ json: { ok: true } })); }); test('fresh token read on each call (no caching)', async () => { setMetaToken('token-v1'); await fetchWithErrorHandling('/api/test'); expect(global.fetch.mock.calls[0][1].headers['X-CSRFToken']).toBe('token-v1'); setMetaToken('token-v2'); await fetchWithErrorHandling('/api/test'); expect(global.fetch.mock.calls[1][1].headers['X-CSRFToken']).toBe('token-v2'); }); test('token change between calls is reflected', async () => { setMetaToken('first'); await fetchWithErrorHandling('/api/a'); setMetaToken('second'); await fetchWithErrorHandling('/api/b'); const firstHeaders = global.fetch.mock.calls[0][1].headers; const secondHeaders = global.fetch.mock.calls[1][1].headers; expect(firstHeaders['X-CSRFToken']).toBe('first'); expect(secondHeaders['X-CSRFToken']).toBe('second'); }); test('document.querySelector called exactly once per fetchWithErrorHandling invocation', async () => { setMetaToken('once-token'); await fetchWithErrorHandling('/api/test'); // One call from fetchWithErrorHandling's getCsrfToken() expect(global.document.querySelector).toHaveBeenCalledTimes(1); }); }); // ============================================================ // 6. Error handling - non-ok responses // ============================================================ describe('Error handling - non-ok responses', () => { const { fetchWithErrorHandling } = apiModule; beforeEach(() => { jest.clearAllMocks(); clearMetaToken(); }); test('response.ok === false with .message in JSON body throws server message', async () => { global.fetch.mockResolvedValue(mockResponse({ ok: false, status: 422, statusText: 'Unprocessable Entity', json: { message: 'Validation failed: bad input' } })); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('Validation failed: bad input'); }); test('response.ok === false without .message throws API Error template', async () => { global.fetch.mockResolvedValue(mockResponse({ ok: false, status: 500, statusText: 'Internal Server Error', json: {} })); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('API Error: 500 Internal Server Error'); }); test('response.ok === false and response.json() rejects falls to status template', async () => { global.fetch.mockResolvedValue(mockResponse({ ok: false, status: 503, statusText: 'Service Unavailable', jsonRejects: true })); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('API Error: 503 Service Unavailable'); }); test('SafeLogger.error called on all non-abort errors', async () => { global.fetch.mockResolvedValue(mockResponse({ ok: false, status: 400, statusText: 'Bad Request', json: { message: 'oops' } })); await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow('oops'); expect(global.SafeLogger.error).toHaveBeenCalledWith('API Error:', expect.any(Error)); }); }); // ============================================================ // 7. Error handling - network and timeout // ============================================================ describe('Error handling - network and timeout', () => { const { fetchWithErrorHandling } = apiModule; beforeEach(() => { jest.clearAllMocks(); clearMetaToken(); }); test('network error: CSRF header still set before rejection', async () => { setMetaToken('net-error-token'); global.fetch.mockRejectedValue(new TypeError('Failed to fetch')); await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow('Failed to fetch'); expect(global.SafeLogger.error).toHaveBeenCalled(); }); test('AbortError maps to "Request timed out"', async () => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; global.fetch.mockRejectedValue(abortError); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('Request timed out'); }); test('SafeLogger.error NOT called on AbortError', async () => { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; global.fetch.mockRejectedValue(abortError); await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow(); expect(global.SafeLogger.error).not.toHaveBeenCalled(); }); test('non-abort errors re-thrown unchanged', async () => { const customError = new Error('Custom network failure'); global.fetch.mockRejectedValue(customError); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('Custom network failure'); }); test('happy-path response.json() rejects is caught, logged, and re-thrown', async () => { const resp = mockResponse({ json: {} }); resp.ok = true; resp.json = jest.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON')); global.fetch.mockResolvedValue(resp); await expect(fetchWithErrorHandling('/api/test')) .rejects.toThrow('Unexpected end of JSON'); expect(global.SafeLogger.error).toHaveBeenCalledWith('API Error:', expect.any(SyntaxError)); }); }); // ============================================================ // 8. finally block - clearTimeout // ============================================================ describe('finally block - clearTimeout', () => { const { fetchWithErrorHandling } = apiModule; let clearTimeoutSpy; beforeEach(() => { jest.clearAllMocks(); clearMetaToken(); clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); }); afterEach(() => { clearTimeoutSpy.mockRestore(); }); test('clearTimeout called on success path', async () => { global.fetch.mockResolvedValue(mockResponse({ json: { data: 1 } })); await fetchWithErrorHandling('/api/test'); expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); }); test('clearTimeout called on error path', async () => { global.fetch.mockResolvedValue(mockResponse({ ok: false, status: 500, statusText: 'Error', json: { message: 'fail' } })); await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow(); expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); }); test('clearTimeout called on timeout/abort path', async () => { const abortError = new Error('aborted'); abortError.name = 'AbortError'; global.fetch.mockRejectedValue(abortError); await expect(fetchWithErrorHandling('/api/test')).rejects.toThrow(); expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); }); }); // ============================================================ // 9. deleteResearch - regression guard // ============================================================ describe('deleteResearch - regression guard', () => { beforeEach(() => { jest.clearAllMocks(); global.fetch.mockResolvedValue(mockResponse({ json: { deleted: true } })); }); test('sends DELETE method', async () => { setMetaToken('del-token'); await apiModule.deleteResearch(99); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].method).toBe('DELETE'); }); test('CSRF token present in outgoing headers', async () => { setMetaToken('csrf-for-delete'); await apiModule.deleteResearch(99); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[1].headers['X-CSRFToken']).toBe('csrf-for-delete'); }); test('correct URL constructed via URLBuilder', async () => { setMetaToken('url-token'); await apiModule.deleteResearch(42); const fetchCall = global.fetch.mock.calls[0]; expect(fetchCall[0]).toBe('/api/delete/42'); }); test('returns parsed JSON response', async () => { setMetaToken('ret-token'); const result = await apiModule.deleteResearch(7); expect(result).toEqual({ deleted: true }); }); });