7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
490 lines
17 KiB
JavaScript
490 lines
17 KiB
JavaScript
/**
|
|
* 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 });
|
|
});
|
|
});
|