Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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 });
});
});