chore: import upstream snapshot with attribution
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
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
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Infrastructure Tests
|
||||
|
||||
This directory contains tests for infrastructure-related code such as:
|
||||
- Route registry and URL management
|
||||
- Configuration management
|
||||
- Database schema and migrations
|
||||
- Logging infrastructure
|
||||
- Utility functions and helpers
|
||||
- System integration points
|
||||
"""
|
||||
+4324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Jest unit tests for Re-run Research configuration handling.
|
||||
*
|
||||
* Tests the logic for:
|
||||
* 1. Building rerun config from research items (history.js handleRerun)
|
||||
* 2. Parsing and applying rerun config (research.js checkAndApplyRerunConfig)
|
||||
*
|
||||
* Note: Re-run only stores query and mode. All other settings (model, provider,
|
||||
* search engine, etc.) come from the user's current defaults via the settings manager.
|
||||
*
|
||||
* Run with: npx jest tests/infrastructure_tests/test_rerun_config.test.js
|
||||
*/
|
||||
|
||||
// Mock sessionStorage
|
||||
const mockSessionStorage = {
|
||||
store: {},
|
||||
getItem: jest.fn((key) => mockSessionStorage.store[key] || null),
|
||||
setItem: jest.fn((key, value) => { mockSessionStorage.store[key] = value; }),
|
||||
removeItem: jest.fn((key) => { delete mockSessionStorage.store[key]; }),
|
||||
clear: jest.fn(() => { mockSessionStorage.store = {}; })
|
||||
};
|
||||
|
||||
// Mock URLValidator
|
||||
const mockURLValidator = {
|
||||
safeAssign: jest.fn()
|
||||
};
|
||||
|
||||
// Setup globals
|
||||
global.sessionStorage = mockSessionStorage;
|
||||
global.URLValidator = mockURLValidator;
|
||||
global.window = {
|
||||
location: { href: '' }
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// handleRerun Logic Tests (from history.js)
|
||||
// ============================================================================
|
||||
|
||||
describe('handleRerun Configuration Building', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSessionStorage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulates the handleRerun function logic.
|
||||
* Only stores query and mode - no advanced settings.
|
||||
*/
|
||||
function buildRerunConfig(item) {
|
||||
return {
|
||||
query: item.query,
|
||||
mode: item.mode
|
||||
};
|
||||
}
|
||||
|
||||
describe('Basic Config Building', () => {
|
||||
test('should include query and mode from item', () => {
|
||||
const item = {
|
||||
query: 'What is quantum computing?',
|
||||
mode: 'detailed'
|
||||
};
|
||||
|
||||
const config = buildRerunConfig(item);
|
||||
|
||||
expect(config.query).toBe('What is quantum computing?');
|
||||
expect(config.mode).toBe('detailed');
|
||||
});
|
||||
|
||||
test('should handle missing mode gracefully', () => {
|
||||
const item = {
|
||||
query: 'Test query'
|
||||
// mode is undefined
|
||||
};
|
||||
|
||||
const config = buildRerunConfig(item);
|
||||
|
||||
expect(config.query).toBe('Test query');
|
||||
expect(config.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle empty query', () => {
|
||||
const item = {
|
||||
query: '',
|
||||
mode: 'quick'
|
||||
};
|
||||
|
||||
const config = buildRerunConfig(item);
|
||||
|
||||
expect(config.query).toBe('');
|
||||
expect(config.mode).toBe('quick');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Advanced settings are NOT included', () => {
|
||||
test('should not include submission params even when present', () => {
|
||||
const item = {
|
||||
query: 'Test query',
|
||||
mode: 'detailed',
|
||||
metadata: {
|
||||
submission: {
|
||||
model_provider: 'OLLAMA',
|
||||
model: 'llama3',
|
||||
search_engine: 'duckduckgo',
|
||||
iterations: 5,
|
||||
questions_per_iteration: 3,
|
||||
strategy: 'focused-iteration'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = buildRerunConfig(item);
|
||||
|
||||
expect(config.query).toBe('Test query');
|
||||
expect(config.mode).toBe('detailed');
|
||||
expect(config.model_provider).toBeUndefined();
|
||||
expect(config.model).toBeUndefined();
|
||||
expect(config.search_engine).toBeUndefined();
|
||||
expect(config.iterations).toBeUndefined();
|
||||
expect(config.questions_per_iteration).toBeUndefined();
|
||||
expect(config.strategy).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should only have query and mode keys', () => {
|
||||
const item = {
|
||||
query: 'Test query',
|
||||
mode: 'quick',
|
||||
metadata: { submission: { model_provider: 'OPENAI' } }
|
||||
};
|
||||
|
||||
const config = buildRerunConfig(item);
|
||||
const keys = Object.keys(config);
|
||||
|
||||
expect(keys).toEqual(['query', 'mode']);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// checkAndApplyRerunConfig Logic Tests (from research.js)
|
||||
// ============================================================================
|
||||
|
||||
describe('checkAndApplyRerunConfig Parsing', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSessionStorage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulates the config parsing logic from checkAndApplyRerunConfig
|
||||
*/
|
||||
function parseRerunConfig() {
|
||||
const rerunConfigStr = sessionStorage.getItem('rerunConfig');
|
||||
if (!rerunConfigStr) return null;
|
||||
|
||||
try {
|
||||
const config = JSON.parse(rerunConfigStr);
|
||||
sessionStorage.removeItem('rerunConfig');
|
||||
return config;
|
||||
} catch {
|
||||
sessionStorage.removeItem('rerunConfig');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Valid Config Parsing', () => {
|
||||
test('should parse valid JSON config', () => {
|
||||
const config = { query: 'Test', mode: 'quick' };
|
||||
sessionStorage.setItem('rerunConfig', JSON.stringify(config));
|
||||
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toEqual(config);
|
||||
expect(sessionStorage.getItem('rerunConfig')).toBeNull();
|
||||
});
|
||||
|
||||
test('should parse config with query and mode', () => {
|
||||
const config = {
|
||||
query: 'Full config test',
|
||||
mode: 'detailed'
|
||||
};
|
||||
sessionStorage.setItem('rerunConfig', JSON.stringify(config));
|
||||
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toEqual(config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Config Handling', () => {
|
||||
test('should return null for invalid JSON', () => {
|
||||
sessionStorage.setItem('rerunConfig', 'not valid json {{{');
|
||||
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
expect(sessionStorage.getItem('rerunConfig')).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null for empty string', () => {
|
||||
sessionStorage.setItem('rerunConfig', '');
|
||||
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
test('should return null when no config exists', () => {
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle truncated JSON', () => {
|
||||
sessionStorage.setItem('rerunConfig', '{"query": "test"');
|
||||
|
||||
const parsed = parseRerunConfig();
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
expect(sessionStorage.getItem('rerunConfig')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Test route registry functionality
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.web.routes.route_registry import (
|
||||
ROUTE_REGISTRY,
|
||||
find_route,
|
||||
get_all_routes,
|
||||
get_routes_by_blueprint,
|
||||
)
|
||||
|
||||
|
||||
class TestRouteRegistry:
|
||||
"""Test the route registry module"""
|
||||
|
||||
def test_route_registry_structure(self):
|
||||
"""Test that ROUTE_REGISTRY has the expected structure"""
|
||||
# Check that we have the expected blueprints
|
||||
expected_blueprints = [
|
||||
"research",
|
||||
"api_v1",
|
||||
"history",
|
||||
"settings",
|
||||
"metrics",
|
||||
]
|
||||
assert set(ROUTE_REGISTRY.keys()) == set(expected_blueprints)
|
||||
|
||||
# Check each blueprint has required keys
|
||||
for blueprint_name, blueprint_info in ROUTE_REGISTRY.items():
|
||||
assert "blueprint" in blueprint_info
|
||||
assert "url_prefix" in blueprint_info
|
||||
assert "routes" in blueprint_info
|
||||
assert isinstance(blueprint_info["routes"], list)
|
||||
|
||||
# Check each route has the expected format
|
||||
for route in blueprint_info["routes"]:
|
||||
assert len(route) == 4 # (method, path, endpoint, description)
|
||||
method, path, endpoint, description = route
|
||||
assert isinstance(method, str)
|
||||
assert method in ["GET", "POST", "DELETE", "PUT", "PATCH"]
|
||||
assert isinstance(path, str)
|
||||
assert isinstance(endpoint, str)
|
||||
assert isinstance(description, str)
|
||||
|
||||
def test_get_all_routes(self):
|
||||
"""Test get_all_routes function"""
|
||||
all_routes = get_all_routes()
|
||||
|
||||
# Should return a list
|
||||
assert isinstance(all_routes, list)
|
||||
assert len(all_routes) > 0
|
||||
|
||||
# Check structure of returned routes
|
||||
for route in all_routes:
|
||||
assert isinstance(route, dict)
|
||||
assert "method" in route
|
||||
assert "path" in route
|
||||
assert "endpoint" in route
|
||||
assert "description" in route
|
||||
assert "blueprint" in route
|
||||
|
||||
# Check that paths are properly prefixed
|
||||
api_v1_routes = [r for r in all_routes if r["blueprint"] == "api_v1"]
|
||||
for route in api_v1_routes:
|
||||
assert route["path"].startswith("/api/v1")
|
||||
|
||||
# Check that research routes are at root level
|
||||
research_routes = [
|
||||
r for r in all_routes if r["blueprint"] == "research"
|
||||
]
|
||||
root_route = next(
|
||||
(r for r in research_routes if r["endpoint"] == "research.index"),
|
||||
None,
|
||||
)
|
||||
assert root_route is not None
|
||||
assert root_route["path"] == "/"
|
||||
|
||||
def test_get_routes_by_blueprint(self):
|
||||
"""Test get_routes_by_blueprint function"""
|
||||
# Test valid blueprint
|
||||
settings_routes = get_routes_by_blueprint("settings")
|
||||
assert isinstance(settings_routes, list)
|
||||
assert len(settings_routes) > 0
|
||||
|
||||
# All routes should be from settings blueprint
|
||||
for route in settings_routes:
|
||||
assert route["path"].startswith("/settings")
|
||||
|
||||
# Test invalid blueprint
|
||||
invalid_routes = get_routes_by_blueprint("invalid_blueprint")
|
||||
assert invalid_routes == []
|
||||
|
||||
# Test specific routes
|
||||
settings_page = next(
|
||||
(r for r in settings_routes if r["endpoint"] == "settings_page"),
|
||||
None,
|
||||
)
|
||||
assert settings_page is not None
|
||||
assert settings_page["path"] == "/settings/"
|
||||
assert settings_page["method"] == "GET"
|
||||
|
||||
def test_find_route(self):
|
||||
"""Test find_route function"""
|
||||
# Test finding routes by pattern
|
||||
api_routes = find_route("/api")
|
||||
assert len(api_routes) > 0
|
||||
for route in api_routes:
|
||||
assert "/api" in route["path"].lower()
|
||||
|
||||
# Test finding specific route
|
||||
history_routes = find_route("history")
|
||||
assert len(history_routes) > 0
|
||||
|
||||
# Test case insensitive search
|
||||
metrics_routes = find_route("METRICS")
|
||||
assert len(metrics_routes) > 0
|
||||
|
||||
# Test pattern not found
|
||||
not_found = find_route("nonexistent_pattern")
|
||||
assert not_found == []
|
||||
|
||||
def test_route_uniqueness(self):
|
||||
"""Test that route paths are unique within their method"""
|
||||
all_routes = get_all_routes()
|
||||
|
||||
# Group by method and path
|
||||
route_map = {}
|
||||
for route in all_routes:
|
||||
key = (route["method"], route["path"])
|
||||
if key in route_map:
|
||||
# Found duplicate
|
||||
pytest.fail(
|
||||
f"Duplicate route found: {route['method']} {route['path']} "
|
||||
f"in {route['blueprint']} and {route_map[key]['blueprint']}"
|
||||
)
|
||||
route_map[key] = route
|
||||
|
||||
def test_api_routes_consistency(self):
|
||||
"""Test that API routes follow consistent patterns"""
|
||||
all_routes = get_all_routes()
|
||||
api_routes = [r for r in all_routes if "/api" in r["path"]]
|
||||
|
||||
for route in api_routes:
|
||||
# API routes should use proper REST methods
|
||||
if "get" in route["endpoint"] or "status" in route["endpoint"]:
|
||||
assert route["method"] == "GET", (
|
||||
f"GET method expected for {route['endpoint']}"
|
||||
)
|
||||
elif (
|
||||
"create" in route["endpoint"]
|
||||
or "save" in route["endpoint"]
|
||||
or "update" in route["endpoint"]
|
||||
):
|
||||
assert route["method"] in ["POST", "PUT", "PATCH"], (
|
||||
f"POST/PUT/PATCH expected for {route['endpoint']}"
|
||||
)
|
||||
elif "delete" in route["endpoint"] or "clear" in route["endpoint"]:
|
||||
assert route["method"] in ["DELETE", "POST"], (
|
||||
f"DELETE/POST expected for {route['endpoint']}"
|
||||
)
|
||||
|
||||
def test_research_id_routes(self):
|
||||
"""Test that routes with research_id parameter are consistent"""
|
||||
all_routes = get_all_routes()
|
||||
research_id_routes = [
|
||||
r
|
||||
for r in all_routes
|
||||
if "<string:research_id>" in r["path"]
|
||||
or "<research_id>" in r["path"]
|
||||
]
|
||||
|
||||
assert len(research_id_routes) > 0, (
|
||||
"Should have routes with research_id parameter"
|
||||
)
|
||||
|
||||
# Check that research_id routes exist in multiple blueprints
|
||||
blueprints_with_research_id = set(
|
||||
r["blueprint"] for r in research_id_routes
|
||||
)
|
||||
assert len(blueprints_with_research_id) >= 3, (
|
||||
"Multiple blueprints should have research_id routes"
|
||||
)
|
||||
|
||||
def test_settings_api_routes(self):
|
||||
"""Test settings API routes specifically"""
|
||||
settings_routes = get_routes_by_blueprint("settings")
|
||||
|
||||
# Check for CRUD operations on settings
|
||||
api_routes = [r for r in settings_routes if "/api" in r["path"]]
|
||||
|
||||
# Should have routes for all CRUD operations
|
||||
methods = set(r["method"] for r in api_routes)
|
||||
assert "GET" in methods
|
||||
assert "POST" in methods
|
||||
assert "DELETE" in methods
|
||||
|
||||
# Check for specific important routes
|
||||
route_endpoints = [r["endpoint"] for r in api_routes]
|
||||
assert "api_get_all_settings" in route_endpoints
|
||||
assert "api_update_setting" in route_endpoints
|
||||
assert "api_get_available_models" in route_endpoints
|
||||
|
||||
def test_route_documentation(self):
|
||||
"""Test that all routes have descriptions"""
|
||||
all_routes = get_all_routes()
|
||||
|
||||
for route in all_routes:
|
||||
# Every route should have a non-empty description
|
||||
assert route["description"], (
|
||||
f"Route {route['method']} {route['path']} missing description"
|
||||
)
|
||||
assert len(route["description"]) > 5, (
|
||||
f"Route {route['method']} {route['path']} has too short description"
|
||||
)
|
||||
|
||||
def test_blueprint_naming_consistency(self):
|
||||
"""Test that blueprint names are consistent"""
|
||||
for blueprint_name, blueprint_info in ROUTE_REGISTRY.items():
|
||||
# Blueprint field should match the key with _bp suffix (except api_v1)
|
||||
if blueprint_name == "api_v1":
|
||||
assert blueprint_info["blueprint"] == "api_blueprint"
|
||||
else:
|
||||
assert blueprint_info["blueprint"] == f"{blueprint_name}_bp"
|
||||
|
||||
def test_url_prefix_consistency(self):
|
||||
"""Test that URL prefixes match blueprint names"""
|
||||
for blueprint_name, blueprint_info in ROUTE_REGISTRY.items():
|
||||
if blueprint_name == "research":
|
||||
# Research blueprint is at root
|
||||
assert blueprint_info["url_prefix"] is None
|
||||
elif blueprint_name == "api_v1":
|
||||
assert blueprint_info["url_prefix"] == "/api/v1"
|
||||
else:
|
||||
# Other blueprints should have prefix matching their name
|
||||
assert blueprint_info["url_prefix"] == f"/{blueprint_name}"
|
||||
|
||||
def test_parameterized_routes(self):
|
||||
"""Test routes with parameters"""
|
||||
all_routes = get_all_routes()
|
||||
|
||||
# Check for consistent parameter naming
|
||||
param_routes = [
|
||||
r for r in all_routes if "<" in r["path"] and ">" in r["path"]
|
||||
]
|
||||
|
||||
for route in param_routes:
|
||||
# Check common parameters
|
||||
if "research_id" in route["path"]:
|
||||
# Should be typed as string (research IDs are UUIDs)
|
||||
assert (
|
||||
"<string:research_id>" in route["path"]
|
||||
or "<research_id>" in route["path"]
|
||||
), f"research_id should be typed as string in {route['path']}"
|
||||
|
||||
if "resource_id" in route["path"]:
|
||||
assert "<int:resource_id>" in route["path"], (
|
||||
f"resource_id should be typed as int in {route['path']}"
|
||||
)
|
||||
|
||||
def test_special_routes(self):
|
||||
"""Test special routes like health checks and documentation"""
|
||||
all_routes = get_all_routes()
|
||||
|
||||
# Should have a health check endpoint
|
||||
health_routes = [r for r in all_routes if "health" in r["endpoint"]]
|
||||
assert len(health_routes) > 0, (
|
||||
"Should have at least one health check route"
|
||||
)
|
||||
|
||||
# Should have documentation routes
|
||||
doc_routes = [
|
||||
r for r in all_routes if "documentation" in r["description"].lower()
|
||||
]
|
||||
assert len(doc_routes) > 0, "Should have documentation routes"
|
||||
|
||||
def test_metrics_routes(self):
|
||||
"""Test metrics blueprint routes"""
|
||||
metrics_routes = get_routes_by_blueprint("metrics")
|
||||
|
||||
# Should have dashboard and API routes
|
||||
dashboard_routes = [
|
||||
r
|
||||
for r in metrics_routes
|
||||
if r["method"] == "GET" and "/api" not in r["path"]
|
||||
]
|
||||
api_routes = [r for r in metrics_routes if "/api" in r["path"]]
|
||||
|
||||
assert len(dashboard_routes) >= 3, (
|
||||
"Should have multiple dashboard routes"
|
||||
)
|
||||
assert len(api_routes) >= 5, "Should have multiple API routes"
|
||||
|
||||
# Check for essential metrics routes
|
||||
route_paths = [r["path"] for r in metrics_routes]
|
||||
assert "/metrics/" in route_paths
|
||||
assert "/metrics/costs" in route_paths
|
||||
assert "/metrics/api/metrics" in route_paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run a quick verification when executed directly
|
||||
print("Running route registry verification...")
|
||||
|
||||
all_routes = get_all_routes()
|
||||
print(f"\nTotal routes: {len(all_routes)}")
|
||||
|
||||
by_blueprint = {}
|
||||
for route in all_routes:
|
||||
bp = route["blueprint"]
|
||||
by_blueprint[bp] = by_blueprint.get(bp, 0) + 1
|
||||
|
||||
print("\nRoutes by blueprint:")
|
||||
for bp, count in sorted(by_blueprint.items()):
|
||||
print(f" {bp}: {count}")
|
||||
|
||||
print("\nSample routes:")
|
||||
for route in all_routes[:5]:
|
||||
print(
|
||||
f" {route['method']:6} {route['path']:40} - {route['description']}"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* JavaScript tests for safe-fetch.js
|
||||
* Run with: npm test tests/infrastructure_tests/test_safe_fetch.test.js
|
||||
*
|
||||
* Tests the safeFetch function which wraps fetch() with URL validation.
|
||||
*/
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn(() => Promise.resolve({ ok: true }));
|
||||
|
||||
// Mock SafeLogger (used by url-validator.js)
|
||||
global.SafeLogger = {
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn()
|
||||
};
|
||||
|
||||
// Mock window.location for URL parsing
|
||||
global.window = {
|
||||
location: {
|
||||
href: 'http://localhost:5000/',
|
||||
origin: 'http://localhost:5000'
|
||||
}
|
||||
};
|
||||
|
||||
// Load the URL validator first (dependency) and expose as global
|
||||
const URLValidator = require('../../src/local_deep_research/web/static/js/security/url-validator.js');
|
||||
global.URLValidator = URLValidator;
|
||||
|
||||
// Load safe-fetch module and expose as global
|
||||
const { safeFetch } = require('../../src/local_deep_research/web/static/js/security/safe-fetch.js');
|
||||
global.safeFetch = safeFetch;
|
||||
|
||||
describe('safeFetch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch.mockClear();
|
||||
});
|
||||
|
||||
describe('Internal URLs (starting with /)', () => {
|
||||
test('should allow internal API URLs without validation', async () => {
|
||||
await safeFetch('/api/settings');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/settings', {});
|
||||
});
|
||||
|
||||
test('should allow internal page URLs', async () => {
|
||||
await safeFetch('/history');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/history', {});
|
||||
});
|
||||
|
||||
test('should pass options through for internal URLs', async () => {
|
||||
const options = { method: 'POST', body: JSON.stringify({ key: 'value' }) };
|
||||
await safeFetch('/api/data', options);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/data', options);
|
||||
});
|
||||
|
||||
test('should allow deep internal paths', async () => {
|
||||
await safeFetch('/api/v1/research/123/status');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/v1/research/123/status', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('External URLs with safe schemes', () => {
|
||||
test('should allow https URLs', async () => {
|
||||
await safeFetch('https://example.com/api');
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://example.com/api', {});
|
||||
});
|
||||
|
||||
test('should allow http URLs', async () => {
|
||||
await safeFetch('http://example.com/api');
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://example.com/api', {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unsafe URLs', () => {
|
||||
test('should block javascript: URLs', async () => {
|
||||
await expect(safeFetch('javascript:alert(1)')).rejects.toThrow('Blocked unsafe URL');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block data: URLs', async () => {
|
||||
await expect(safeFetch('data:text/html,<script>alert(1)</script>')).rejects.toThrow('Blocked unsafe URL');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block vbscript: URLs', async () => {
|
||||
await expect(safeFetch('vbscript:msgbox(1)')).rejects.toThrow('Blocked unsafe URL');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block javascript: with mixed case', async () => {
|
||||
await expect(safeFetch('JavaScript:alert(1)')).rejects.toThrow('Blocked unsafe URL');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block javascript: with leading whitespace', async () => {
|
||||
await expect(safeFetch(' javascript:alert(1)')).rejects.toThrow('Blocked unsafe URL');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
test('should handle empty options object', async () => {
|
||||
await safeFetch('/api/test', {});
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/test', {});
|
||||
});
|
||||
|
||||
test('should handle URLs with query parameters', async () => {
|
||||
await safeFetch('/api/search?q=test&limit=10');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/search?q=test&limit=10', {});
|
||||
});
|
||||
|
||||
test('should handle URLs with fragments', async () => {
|
||||
await safeFetch('/page#section');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/page#section', {});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URLValidator integration', () => {
|
||||
test('URLValidator should be available globally', () => {
|
||||
expect(global.URLValidator).toBeDefined();
|
||||
expect(typeof global.URLValidator.isSafeUrl).toBe('function');
|
||||
});
|
||||
|
||||
test('URLValidator.isSafeUrl should validate https URLs', () => {
|
||||
expect(global.URLValidator.isSafeUrl('https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
test('URLValidator.isSafeUrl should reject javascript URLs', () => {
|
||||
expect(global.URLValidator.isSafeUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
test('URLValidator.isUnsafeScheme should detect unsafe schemes', () => {
|
||||
expect(global.URLValidator.isUnsafeScheme('javascript:void(0)')).toBe(true);
|
||||
expect(global.URLValidator.isUnsafeScheme('data:text/html,test')).toBe(true);
|
||||
expect(global.URLValidator.isUnsafeScheme('https://example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* JavaScript tests for URL configuration
|
||||
* Run with: npm test tests/infrastructure_tests/test_urls.test.js
|
||||
*/
|
||||
|
||||
// Mock the window object for testing
|
||||
global.window = {};
|
||||
|
||||
// Load the URLs configuration
|
||||
const urlsModule = require('../../src/local_deep_research/web/static/js/config/urls.js');
|
||||
|
||||
// Get the exported values from window (for browser environment tests)
|
||||
const { URLS, URLBuilder } = global.window;
|
||||
|
||||
describe('URLs Configuration', () => {
|
||||
describe('URL Structure', () => {
|
||||
test('URLS object should have required sections', () => {
|
||||
expect(URLS).toBeDefined();
|
||||
expect(URLS.API).toBeDefined();
|
||||
expect(URLS.PAGES).toBeDefined();
|
||||
expect(URLS.HISTORY_API).toBeDefined();
|
||||
expect(URLS.SETTINGS_API).toBeDefined();
|
||||
expect(URLS.METRICS_API).toBeDefined();
|
||||
});
|
||||
|
||||
test('Critical API endpoints should be defined', () => {
|
||||
const criticalAPIs = [
|
||||
'START_RESEARCH',
|
||||
'RESEARCH_STATUS',
|
||||
'RESEARCH_DETAILS',
|
||||
'RESEARCH_LOGS',
|
||||
'RESEARCH_REPORT',
|
||||
'TERMINATE_RESEARCH',
|
||||
'DELETE_RESEARCH',
|
||||
'HISTORY'
|
||||
];
|
||||
|
||||
criticalAPIs.forEach(api => {
|
||||
expect(URLS.API[api]).toBeDefined();
|
||||
expect(typeof URLS.API[api]).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
test('Page routes should be defined', () => {
|
||||
const pages = ['HOME', 'PROGRESS', 'RESULTS', 'DETAILS', 'HISTORY', 'SETTINGS', 'METRICS',
|
||||
'NEWS', 'NEWS_SUBSCRIPTIONS', 'BENCHMARK', 'BENCHMARK_RESULTS', 'EMBEDDING_SETTINGS'];
|
||||
|
||||
pages.forEach(page => {
|
||||
expect(URLS.PAGES[page]).toBeDefined();
|
||||
expect(typeof URLS.PAGES[page]).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URLBuilder Functionality', () => {
|
||||
test('build() should replace {id} placeholders', () => {
|
||||
const template = '/api/research/{id}/status';
|
||||
const result = URLBuilder.build(template, 123);
|
||||
expect(result).toBe('/api/research/123/status');
|
||||
});
|
||||
|
||||
test('buildWithReplacements() should handle multiple placeholders', () => {
|
||||
const template = '/api/{type}/{id}/action';
|
||||
const result = URLBuilder.buildWithReplacements(template, {
|
||||
type: 'research',
|
||||
id: 456
|
||||
});
|
||||
expect(result).toBe('/api/research/456/action');
|
||||
});
|
||||
|
||||
test('Convenience methods should return correct URLs', () => {
|
||||
expect(URLBuilder.progressPage(123)).toBe('/progress/123');
|
||||
expect(URLBuilder.resultsPage(456)).toBe('/results/456');
|
||||
expect(URLBuilder.detailsPage(789)).toBe('/details/789');
|
||||
expect(URLBuilder.researchStatus(111)).toBe('/api/research/111/status');
|
||||
expect(URLBuilder.researchDetails(222)).toBe('/api/research/222');
|
||||
expect(URLBuilder.researchLogs(333)).toBe('/api/research/333/logs');
|
||||
expect(URLBuilder.researchReport(444)).toBe('/api/report/444');
|
||||
expect(URLBuilder.terminateResearch(555)).toBe('/api/terminate/555');
|
||||
expect(URLBuilder.deleteResearch(666)).toBe('/api/delete/666');
|
||||
});
|
||||
|
||||
test('History API convenience methods should work', () => {
|
||||
expect(URLBuilder.historyStatus(123)).toBe('/history/status/123');
|
||||
expect(URLBuilder.historyDetails(456)).toBe('/history/details/456');
|
||||
expect(URLBuilder.historyLogs(789)).toBe('/history/logs/789');
|
||||
expect(URLBuilder.historyReport(111)).toBe('/history/report/111');
|
||||
expect(URLBuilder.markdownExport(222)).toBe('/api/markdown/222');
|
||||
});
|
||||
|
||||
test('Settings API convenience methods should work', () => {
|
||||
expect(URLBuilder.getSetting('llm.model')).toBe('/settings/api/llm.model');
|
||||
expect(URLBuilder.updateSetting('search.tool')).toBe('/settings/api/search.tool');
|
||||
expect(URLBuilder.deleteSetting('custom.key')).toBe('/settings/api/custom.key');
|
||||
});
|
||||
|
||||
test('Metrics API convenience methods should work', () => {
|
||||
expect(URLBuilder.researchMetrics(123)).toBe('/metrics/api/metrics/research/123');
|
||||
expect(URLBuilder.researchTimelineMetrics(456)).toBe('/metrics/api/metrics/research/456/timeline');
|
||||
expect(URLBuilder.researchSearchMetrics(789)).toBe('/metrics/api/metrics/research/789/search');
|
||||
expect(URLBuilder.getRating(111)).toBe('/metrics/api/ratings/111');
|
||||
expect(URLBuilder.saveRating(222)).toBe('/metrics/api/ratings/222');
|
||||
expect(URLBuilder.researchCosts(333)).toBe('/metrics/api/research-costs/333');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Pattern Extraction', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location for testing
|
||||
delete global.window.location;
|
||||
global.window.location = { pathname: '/' };
|
||||
});
|
||||
|
||||
test('extractResearchId() should extract ID from various patterns', () => {
|
||||
const testCases = [
|
||||
{ path: '/results/123', expected: '123' },
|
||||
{ path: '/details/456', expected: '456' },
|
||||
{ path: '/progress/789', expected: '789' },
|
||||
{ path: '/home', expected: null },
|
||||
{ path: '/settings/', expected: null }
|
||||
];
|
||||
|
||||
testCases.forEach(({ path, expected }) => {
|
||||
global.window.location.pathname = path;
|
||||
expect(URLBuilder.extractResearchId()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test('extractResearchIdFromPattern() should work for specific patterns', () => {
|
||||
global.window.location.pathname = '/results/999';
|
||||
expect(URLBuilder.extractResearchIdFromPattern('results')).toBe('999');
|
||||
|
||||
global.window.location.pathname = '/details/888';
|
||||
expect(URLBuilder.extractResearchIdFromPattern('details')).toBe('888');
|
||||
expect(URLBuilder.extractResearchIdFromPattern('results')).toBe(null);
|
||||
});
|
||||
|
||||
test('getCurrentPageType() should identify page types', () => {
|
||||
const testCases = [
|
||||
{ path: '/', expected: 'home' },
|
||||
{ path: '/index', expected: 'home' },
|
||||
{ path: '/results/123', expected: 'results' },
|
||||
{ path: '/details/456', expected: 'details' },
|
||||
{ path: '/progress/789', expected: 'progress' },
|
||||
{ path: '/history', expected: 'history' },
|
||||
{ path: '/history/', expected: 'history' },
|
||||
{ path: '/settings', expected: 'settings' },
|
||||
{ path: '/settings/advanced', expected: 'settings' },
|
||||
{ path: '/metrics', expected: 'metrics' },
|
||||
{ path: '/metrics/costs', expected: 'metrics' },
|
||||
{ path: '/unknown/path', expected: 'unknown' }
|
||||
];
|
||||
|
||||
testCases.forEach(({ path, expected }) => {
|
||||
global.window.location.pathname = path;
|
||||
expect(URLBuilder.getCurrentPageType()).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Consistency Checks', () => {
|
||||
test('All URLs should start with /', () => {
|
||||
const checkUrls = (obj, path = '') => {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
expect(value).toMatch(/^\//);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
checkUrls(value, `${path}.${key}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkUrls(URLS);
|
||||
});
|
||||
|
||||
test('URLs with parameters should use consistent placeholder format', () => {
|
||||
const checkPlaceholders = (obj) => {
|
||||
Object.entries(obj).forEach(([_key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
// Check that placeholders use {name} format
|
||||
const placeholders = value.match(/\{[^}]+\}/g) || [];
|
||||
placeholders.forEach(placeholder => {
|
||||
expect(placeholder).toMatch(/^\{[a-z_]+\}$/i);
|
||||
});
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
checkPlaceholders(value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkPlaceholders(URLS);
|
||||
});
|
||||
|
||||
test('API URLs should follow RESTful conventions', () => {
|
||||
// URLs ending with ID should be for single resource operations
|
||||
const singleResourcePatterns = [
|
||||
'RESEARCH_STATUS',
|
||||
'RESEARCH_DETAILS',
|
||||
'RESEARCH_LOGS',
|
||||
'RESEARCH_REPORT',
|
||||
'TERMINATE_RESEARCH',
|
||||
'DELETE_RESEARCH'
|
||||
];
|
||||
|
||||
singleResourcePatterns.forEach(pattern => {
|
||||
expect(URLS.API[pattern]).toMatch(/\{id\}/);
|
||||
});
|
||||
|
||||
// Collection URLs should not have ID
|
||||
expect(URLS.API.HISTORY).not.toMatch(/\{id\}/);
|
||||
expect(URLS.API.CLEAR_HISTORY).not.toMatch(/\{id\}/);
|
||||
});
|
||||
|
||||
test('Settings API URLs should be consistent', () => {
|
||||
Object.entries(URLS.SETTINGS_API).forEach(([key, url]) => {
|
||||
if (key !== 'BASE') {
|
||||
expect(url).toMatch(/^\/settings\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('Metrics API URLs should be consistent', () => {
|
||||
Object.entries(URLS.METRICS_API).forEach(([key, url]) => {
|
||||
if (key !== 'BASE') {
|
||||
expect(url).toMatch(/^\/metrics\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Export Validation', () => {
|
||||
test('Module should export correctly for different environments', () => {
|
||||
// Browser environment (already tested via global.window)
|
||||
expect(global.window.URLS).toBeDefined();
|
||||
expect(global.window.URLBuilder).toBeDefined();
|
||||
|
||||
// Node.js environment - check the imported module
|
||||
expect(urlsModule).toBeDefined();
|
||||
expect(urlsModule.URLS).toBeDefined();
|
||||
expect(urlsModule.URLBuilder).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Test JavaScript URL configuration consistency
|
||||
This tests that the JavaScript URL configuration matches the backend routes
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.web.routes.route_registry import (
|
||||
get_all_routes,
|
||||
)
|
||||
|
||||
|
||||
class TestJavaScriptURLConfiguration:
|
||||
"""Test that JavaScript URL configuration matches backend routes"""
|
||||
|
||||
@pytest.fixture
|
||||
def js_urls_content(self):
|
||||
"""Load the JavaScript URLs configuration file"""
|
||||
js_file_path = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "src"
|
||||
/ "local_deep_research"
|
||||
/ "web"
|
||||
/ "static"
|
||||
/ "js"
|
||||
/ "config"
|
||||
/ "urls.js"
|
||||
)
|
||||
with open(js_file_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def extract_js_urls(self, js_content):
|
||||
"""Extract URL patterns from JavaScript content"""
|
||||
urls = {}
|
||||
|
||||
# More robust extraction pattern that handles multiline and comments
|
||||
# Extract the URLS object
|
||||
urls_match = re.search(
|
||||
r"const\s+URLS\s*=\s*\{(.*?)\n\};", js_content, re.DOTALL
|
||||
)
|
||||
if not urls_match:
|
||||
return urls
|
||||
|
||||
urls_content = urls_match.group(1)
|
||||
|
||||
# Extract each section
|
||||
sections = {
|
||||
"API": r"API:\s*\{(.*?)\n\s*\}",
|
||||
"PAGES": r"PAGES:\s*\{(.*?)\n\s*\}",
|
||||
"HISTORY_API": r"HISTORY_API:\s*\{(.*?)\n\s*\}",
|
||||
"SETTINGS_API": r"SETTINGS_API:\s*\{(.*?)\n\s*\}",
|
||||
"METRICS_API": r"METRICS_API:\s*\{(.*?)\n\s*\}",
|
||||
}
|
||||
|
||||
for section_name, section_pattern in sections.items():
|
||||
section_match = re.search(section_pattern, urls_content, re.DOTALL)
|
||||
if section_match:
|
||||
section_content = section_match.group(1)
|
||||
# Extract key-value pairs, handling comments and multiline
|
||||
kv_pattern = r"(\w+):\s*'([^']+)'(?:\s*,|\s*(?://[^\n]*)?\s*$)"
|
||||
for match in re.finditer(kv_pattern, section_content):
|
||||
key, url = match.groups()
|
||||
urls[f"{section_name}.{key}"] = url
|
||||
|
||||
return urls
|
||||
|
||||
def normalize_url_pattern(self, url):
|
||||
"""Normalize URL patterns for comparison"""
|
||||
# Replace {id} with Flask-style <int:id> or <id>
|
||||
url = re.sub(r"\{id\}", "<string:research_id>", url)
|
||||
url = re.sub(r"\{key\}", "<path:key>", url)
|
||||
url = re.sub(r"\{(\w+)\}", r"<\1>", url)
|
||||
return url
|
||||
|
||||
def test_js_urls_exist(self, js_urls_content):
|
||||
"""Test that the JavaScript URLs file exists and has content"""
|
||||
assert js_urls_content
|
||||
assert "URLS" in js_urls_content
|
||||
assert "URLBuilder" in js_urls_content
|
||||
|
||||
def test_critical_api_urls_match_backend(self, js_urls_content):
|
||||
"""Test that critical API URLs in JS match backend routes"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
backend_routes = get_all_routes()
|
||||
|
||||
# Create a map of backend routes for easy lookup
|
||||
backend_map = {}
|
||||
for route in backend_routes:
|
||||
# Normalize the path for comparison
|
||||
path = route["path"]
|
||||
backend_map[path] = route
|
||||
|
||||
# Critical URLs that must match
|
||||
critical_mappings = [
|
||||
("API.START_RESEARCH", "/api/start_research"),
|
||||
("API.RESEARCH_STATUS", "/api/research/<research_id>/status"),
|
||||
("API.RESEARCH_DETAILS", "/api/research/<string:research_id>"),
|
||||
("API.RESEARCH_LOGS", "/api/research/<string:research_id>/logs"),
|
||||
("API.RESEARCH_REPORT", "/api/report/<string:research_id>"),
|
||||
("API.TERMINATE_RESEARCH", "/api/terminate/<string:research_id>"),
|
||||
("API.DELETE_RESEARCH", "/api/delete/<string:research_id>"),
|
||||
("API.HISTORY", "/history/api"),
|
||||
("PAGES.HOME", "/"),
|
||||
("PAGES.HISTORY", "/history/"),
|
||||
("PAGES.SETTINGS", "/settings/"),
|
||||
("PAGES.METRICS", "/metrics/"),
|
||||
]
|
||||
|
||||
for js_key, expected_backend_path in critical_mappings:
|
||||
assert js_key in js_urls, f"Missing JS URL key: {js_key}"
|
||||
|
||||
js_url = js_urls[js_key]
|
||||
normalized_js_url = self.normalize_url_pattern(js_url)
|
||||
|
||||
# Check if the URL exists in backend (with some flexibility for parameter names)
|
||||
found = False
|
||||
for backend_path in backend_map:
|
||||
if self.urls_match(normalized_js_url, backend_path):
|
||||
found = True
|
||||
break
|
||||
|
||||
assert found, (
|
||||
f"JS URL {js_key}='{js_url}' not found in backend routes"
|
||||
)
|
||||
|
||||
def urls_match(self, js_url, backend_url):
|
||||
"""Check if two URLs match, accounting for parameter differences"""
|
||||
# Normalize both URLs
|
||||
js_parts = js_url.strip("/").split("/")
|
||||
backend_parts = backend_url.strip("/").split("/")
|
||||
|
||||
if len(js_parts) != len(backend_parts):
|
||||
return False
|
||||
|
||||
for js_part, backend_part in zip(js_parts, backend_parts, strict=False):
|
||||
# If either part is a parameter, consider it a match
|
||||
if "<" in js_part or "<" in backend_part:
|
||||
continue
|
||||
# Otherwise, parts must match exactly
|
||||
if js_part != backend_part:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def test_url_builder_methods_exist(self, js_urls_content):
|
||||
"""Test that URLBuilder utility methods are defined"""
|
||||
expected_methods = [
|
||||
"build",
|
||||
"buildWithReplacements",
|
||||
"progressPage",
|
||||
"resultsPage",
|
||||
"detailsPage",
|
||||
"researchStatus",
|
||||
"researchDetails",
|
||||
"researchLogs",
|
||||
"researchReport",
|
||||
"terminateResearch",
|
||||
"deleteResearch",
|
||||
"extractResearchId",
|
||||
"getCurrentPageType",
|
||||
]
|
||||
|
||||
for method in expected_methods:
|
||||
assert (
|
||||
f"{method}(" in js_urls_content
|
||||
or f"{method}:" in js_urls_content
|
||||
), f"URLBuilder method '{method}' not found"
|
||||
|
||||
def test_no_hardcoded_urls_in_pattern(self, js_urls_content):
|
||||
"""Test that URLs follow consistent patterns"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
|
||||
# Check for consistent parameter naming
|
||||
for key, url in js_urls.items():
|
||||
# Research ID parameters should use {id}
|
||||
if "research" in key.lower() and "{" in url:
|
||||
assert "{id}" in url or "{research_id}" in url, (
|
||||
f"Research URL {key} should use {{id}} parameter: {url}"
|
||||
)
|
||||
|
||||
# No URLs should have trailing slashes except root paths
|
||||
if (
|
||||
url != "/"
|
||||
and not url.endswith("/{id}")
|
||||
and not url.endswith("/{key}")
|
||||
):
|
||||
# Allow trailing slash for directory-like paths
|
||||
if url.count("/") > 2:
|
||||
assert not url.endswith("//"), (
|
||||
f"URL {key} has double slash: {url}"
|
||||
)
|
||||
|
||||
def test_api_url_patterns(self, js_urls_content):
|
||||
"""Test that API URLs follow RESTful patterns"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
api_urls = {k: v for k, v in js_urls.items() if k.startswith("API.")}
|
||||
|
||||
for key, url in api_urls.items():
|
||||
# API URLs should start with /api (with some exceptions)
|
||||
if key not in ["API.HISTORY", "API.HEALTH"]: # Known exceptions
|
||||
assert url.startswith("/api"), (
|
||||
f"API URL {key} should start with /api: {url}"
|
||||
)
|
||||
|
||||
# Check REST patterns
|
||||
if "DELETE" in key:
|
||||
# Delete operations should have an ID parameter
|
||||
assert "{id}" in url, (
|
||||
f"DELETE URL {key} should have ID parameter: {url}"
|
||||
)
|
||||
|
||||
if "SAVE" in key or "CREATE" in key:
|
||||
# Save/Create operations typically don't have ID in URL
|
||||
if (
|
||||
"RATING" not in key
|
||||
): # Exception for ratings which need research ID
|
||||
assert "{id}" not in url, (
|
||||
f"CREATE/SAVE URL {key} shouldn't have ID in path: {url}"
|
||||
)
|
||||
|
||||
def test_settings_api_consistency(self, js_urls_content):
|
||||
"""Test that settings API URLs are consistent"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
settings_urls = {
|
||||
k: v for k, v in js_urls.items() if k.startswith("SETTINGS_API.")
|
||||
}
|
||||
|
||||
# All settings API URLs should start with /settings/
|
||||
for key, url in settings_urls.items():
|
||||
assert url.startswith("/settings/"), (
|
||||
f"Settings API URL {key} should start with /settings/: {url}"
|
||||
)
|
||||
|
||||
# Check that CRUD operations exist
|
||||
assert "SETTINGS_API.GET_SETTING" in js_urls
|
||||
assert "SETTINGS_API.UPDATE_SETTING" in js_urls
|
||||
assert "SETTINGS_API.DELETE_SETTING" in js_urls
|
||||
|
||||
def test_metrics_api_consistency(self, js_urls_content):
|
||||
"""Test that metrics API URLs are consistent"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
metrics_urls = {
|
||||
k: v for k, v in js_urls.items() if k.startswith("METRICS_API.")
|
||||
}
|
||||
|
||||
# All metrics API URLs should start with /metrics/
|
||||
for key, url in metrics_urls.items():
|
||||
assert url.startswith("/metrics/"), (
|
||||
f"Metrics API URL {key} should start with /metrics/: {url}"
|
||||
)
|
||||
|
||||
# Check research-specific metrics URLs have ID parameter
|
||||
research_metrics = [
|
||||
"RESEARCH",
|
||||
"RESEARCH_TIMELINE",
|
||||
"RESEARCH_SEARCH",
|
||||
"RATINGS_GET",
|
||||
"RATINGS_SAVE",
|
||||
"RESEARCH_COSTS",
|
||||
]
|
||||
for metric in research_metrics:
|
||||
key = f"METRICS_API.{metric}"
|
||||
if key in js_urls:
|
||||
assert "{id}" in js_urls[key], (
|
||||
f"Metrics URL {key} should have {{id}} parameter: {js_urls[key]}"
|
||||
)
|
||||
|
||||
def test_page_routes_consistency(self, js_urls_content):
|
||||
"""Test that page routes are consistent"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
|
||||
# Pages with IDs should have {id} parameter
|
||||
id_pages = ["PROGRESS", "RESULTS", "DETAILS"]
|
||||
for page in id_pages:
|
||||
key = f"PAGES.{page}"
|
||||
if key in js_urls:
|
||||
assert "{id}" in js_urls[key], (
|
||||
f"Page URL {key} should have {{id}} parameter: {js_urls[key]}"
|
||||
)
|
||||
|
||||
# Root pages should end with /
|
||||
root_pages = ["HOME", "HISTORY", "SETTINGS", "METRICS"]
|
||||
for page in root_pages:
|
||||
key = f"PAGES.{page}"
|
||||
if key in js_urls and page != "HOME": # HOME is just '/'
|
||||
assert js_urls[key].endswith("/"), (
|
||||
f"Root page URL {key} should end with /: {js_urls[key]}"
|
||||
)
|
||||
|
||||
def test_url_builder_convenience_methods_match_urls(self, js_urls_content):
|
||||
"""Test that URLBuilder convenience methods use correct URL constants"""
|
||||
# Extract method implementations
|
||||
method_checks = [
|
||||
("progressPage", "URLS.PAGES.PROGRESS"),
|
||||
("resultsPage", "URLS.PAGES.RESULTS"),
|
||||
("detailsPage", "URLS.PAGES.DETAILS"),
|
||||
("researchStatus", "URLS.API.RESEARCH_STATUS"),
|
||||
("researchDetails", "URLS.API.RESEARCH_DETAILS"),
|
||||
("researchLogs", "URLS.API.RESEARCH_LOGS"),
|
||||
("researchReport", "URLS.API.RESEARCH_REPORT"),
|
||||
("terminateResearch", "URLS.API.TERMINATE_RESEARCH"),
|
||||
("deleteResearch", "URLS.API.DELETE_RESEARCH"),
|
||||
]
|
||||
|
||||
for method_name, expected_url_const in method_checks:
|
||||
# Find the method implementation
|
||||
method_pattern = rf"{method_name}\([^)]*\)\s*\{{[^}}]*\}}"
|
||||
method_match = re.search(method_pattern, js_urls_content, re.DOTALL)
|
||||
|
||||
assert method_match, f"Method {method_name} not found"
|
||||
|
||||
method_body = method_match.group(0)
|
||||
assert expected_url_const in method_body, (
|
||||
f"Method {method_name} should use {expected_url_const}"
|
||||
)
|
||||
|
||||
def test_no_duplicate_urls(self, js_urls_content):
|
||||
"""Test that there are no duplicate URL patterns"""
|
||||
js_urls = self.extract_js_urls(js_urls_content)
|
||||
|
||||
# Normalize URLs for comparison
|
||||
normalized_urls = {}
|
||||
for key, url in js_urls.items():
|
||||
normalized = self.normalize_url_pattern(url)
|
||||
if normalized in normalized_urls:
|
||||
# Allow some known duplicates
|
||||
allowed_duplicates = [
|
||||
# RESTful APIs use same URL with different methods
|
||||
("SETTINGS_API.GET_SETTING", "SETTINGS_API.UPDATE_SETTING"),
|
||||
(
|
||||
"SETTINGS_API.UPDATE_SETTING",
|
||||
"SETTINGS_API.DELETE_SETTING",
|
||||
),
|
||||
("SETTINGS_API.GET_SETTING", "SETTINGS_API.DELETE_SETTING"),
|
||||
("METRICS_API.RATINGS_GET", "METRICS_API.RATINGS_SAVE"),
|
||||
# History API might share patterns with main API
|
||||
("HISTORY_API", "API"),
|
||||
]
|
||||
|
||||
# Check if this is an allowed duplicate
|
||||
is_allowed = False
|
||||
for pattern1, pattern2 in allowed_duplicates:
|
||||
if (
|
||||
key.startswith(pattern1)
|
||||
and normalized_urls[normalized].startswith(pattern2)
|
||||
) or (
|
||||
key.startswith(pattern2)
|
||||
and normalized_urls[normalized].startswith(pattern1)
|
||||
):
|
||||
is_allowed = True
|
||||
break
|
||||
|
||||
if not is_allowed:
|
||||
pytest.fail(
|
||||
f"Duplicate URL pattern found:\n"
|
||||
f" {key} = {url}\n"
|
||||
f" {normalized_urls[normalized]} = {url}\n"
|
||||
f" (normalized: {normalized})"
|
||||
)
|
||||
normalized_urls[normalized] = key
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* JavaScript tests for XSS Protection and Dropdown Highlighting
|
||||
* Run with: npm test tests/infrastructure_tests/test_xss_protection.test.js
|
||||
*
|
||||
* Tests the safeSetInnerHTML function and dropdown search highlighting
|
||||
* which requires DOMPurify to be loaded.
|
||||
*/
|
||||
|
||||
// Mock DOMPurify for testing
|
||||
const mockDOMPurify = {
|
||||
sanitize: jest.fn((content) => {
|
||||
// Simple mock that allows span tags with class attribute
|
||||
// but removes script tags
|
||||
let result = content;
|
||||
// Remove script tags
|
||||
result = result.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
// Keep allowed tags like span with class
|
||||
return result;
|
||||
}),
|
||||
addHook: jest.fn((hookName, callback) => {
|
||||
// Mock addHook for tabnabbing protection
|
||||
// Stores callbacks but doesn't execute them in tests
|
||||
mockDOMPurify._hooks ||= {};
|
||||
mockDOMPurify._hooks[hookName] ||= [];
|
||||
mockDOMPurify._hooks[hookName].push(callback);
|
||||
}),
|
||||
_hooks: {}
|
||||
};
|
||||
|
||||
// Setup global mocks
|
||||
global.DOMPurify = mockDOMPurify;
|
||||
global.window = {
|
||||
DOMPurify: mockDOMPurify
|
||||
};
|
||||
global.document = {
|
||||
createElement: jest.fn(() => ({
|
||||
className: '',
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
style: {},
|
||||
setAttribute: jest.fn(),
|
||||
appendChild: jest.fn(),
|
||||
removeChild: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
querySelectorAll: jest.fn(() => []),
|
||||
firstChild: null
|
||||
}))
|
||||
};
|
||||
|
||||
// Load the XSS protection module
|
||||
require('../../src/local_deep_research/web/static/js/security/xss-protection.js');
|
||||
|
||||
describe('XSS Protection Module', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('DOMPurify Integration', () => {
|
||||
test('DOMPurify should be available globally', () => {
|
||||
expect(global.DOMPurify).toBeDefined();
|
||||
expect(global.DOMPurify.sanitize).toBeDefined();
|
||||
});
|
||||
|
||||
test('window.DOMPurify should be set', () => {
|
||||
expect(global.window.DOMPurify).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
test('should escape HTML special characters', () => {
|
||||
const { escapeHtml } = global.window;
|
||||
expect(escapeHtml('<script>')).toBe('<script>');
|
||||
expect(escapeHtml('a & b')).toBe('a & b');
|
||||
expect(escapeHtml('"quoted"')).toBe('"quoted"');
|
||||
expect(escapeHtml("'single'")).toBe(''single'');
|
||||
});
|
||||
|
||||
test('should handle non-string input', () => {
|
||||
const { escapeHtml } = global.window;
|
||||
expect(escapeHtml(123)).toBe('123');
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeSetInnerHTML', () => {
|
||||
test('should use DOMPurify when allowHtmlTags is true', () => {
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
const element = {
|
||||
innerHTML: '',
|
||||
textContent: ''
|
||||
};
|
||||
|
||||
const htmlContent = '<span class="ldr-highlight">test</span>';
|
||||
safeSetInnerHTML(element, htmlContent, true);
|
||||
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
|
||||
expect(element.innerHTML).toBe(htmlContent);
|
||||
});
|
||||
|
||||
test('should use textContent when allowHtmlTags is false', () => {
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
const element = {
|
||||
innerHTML: '',
|
||||
textContent: ''
|
||||
};
|
||||
|
||||
const content = 'plain text';
|
||||
safeSetInnerHTML(element, content, false);
|
||||
|
||||
expect(element.textContent).toBe(content);
|
||||
});
|
||||
|
||||
test('should handle null element gracefully', () => {
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
expect(() => safeSetInnerHTML(null, 'content')).not.toThrow();
|
||||
});
|
||||
|
||||
test('should handle null content gracefully', () => {
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
const element = { innerHTML: '', textContent: '' };
|
||||
expect(() => safeSetInnerHTML(element, null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeSetTextContent', () => {
|
||||
test('should set textContent safely', () => {
|
||||
const { safeSetTextContent } = global.window;
|
||||
const element = { textContent: '' };
|
||||
|
||||
safeSetTextContent(element, '<script>alert("xss")</script>');
|
||||
expect(element.textContent).toBe('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
test('should handle null element gracefully', () => {
|
||||
const { safeSetTextContent } = global.window;
|
||||
expect(() => safeSetTextContent(null, 'content')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
test('should use DOMPurify to sanitize HTML', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
const dirty = '<script>alert("xss")</script><b>safe</b>';
|
||||
const result = sanitizeHtml(dirty);
|
||||
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
dirty,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('should return empty string for empty input', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
expect(sanitizeHtml('')).toBe('');
|
||||
expect(sanitizeHtml(null)).toBe('');
|
||||
expect(sanitizeHtml(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dropdown Highlight Rendering', () => {
|
||||
describe('Highlight HTML Structure', () => {
|
||||
test('highlight span should have correct class', () => {
|
||||
const highlightHtml = '<span class="ldr-highlight">search</span>';
|
||||
expect(highlightHtml).toContain('class="ldr-highlight"');
|
||||
});
|
||||
|
||||
test('highlight pattern should match search terms', () => {
|
||||
// Simulate the highlightText function from custom_dropdown.js (indexOf implementation)
|
||||
const showAllOptions = false;
|
||||
function highlightText(text, search) {
|
||||
if (!search.trim() || showAllOptions) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
let result = '';
|
||||
let lastIdx = 0;
|
||||
let idx;
|
||||
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
|
||||
result += text.substring(lastIdx, idx);
|
||||
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
|
||||
lastIdx = idx + search.length;
|
||||
}
|
||||
result += text.substring(lastIdx);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = highlightText('bytedance-seed/seed-1.6-flash-h', 'flas');
|
||||
expect(result).toBe('bytedance-seed/seed-1.6-<span class="ldr-highlight">flas</span>h-h');
|
||||
});
|
||||
|
||||
test('highlight should be case-insensitive', () => {
|
||||
const showAllOptions = false;
|
||||
function highlightText(text, search) {
|
||||
if (!search.trim() || showAllOptions) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
let result = '';
|
||||
let lastIdx = 0;
|
||||
let idx;
|
||||
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
|
||||
result += text.substring(lastIdx, idx);
|
||||
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
|
||||
lastIdx = idx + search.length;
|
||||
}
|
||||
result += text.substring(lastIdx);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = highlightText('FLASH Model', 'flash');
|
||||
expect(result).toContain('ldr-highlight');
|
||||
expect(result).toContain('FLASH');
|
||||
});
|
||||
|
||||
test('highlight should handle multiple matches', () => {
|
||||
const showAllOptions = false;
|
||||
function highlightText(text, search) {
|
||||
if (!search.trim() || showAllOptions) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
let result = '';
|
||||
let lastIdx = 0;
|
||||
let idx;
|
||||
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
|
||||
result += text.substring(lastIdx, idx);
|
||||
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
|
||||
lastIdx = idx + search.length;
|
||||
}
|
||||
result += text.substring(lastIdx);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = highlightText('test test test', 'test');
|
||||
const matches = result.match(/ldr-highlight/g);
|
||||
expect(matches).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('highlight should handle special characters in search without error', () => {
|
||||
const showAllOptions = false;
|
||||
function highlightText(text, search) {
|
||||
if (!search.trim() || showAllOptions) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerSearch = search.toLowerCase();
|
||||
let result = '';
|
||||
let lastIdx = 0;
|
||||
let idx;
|
||||
while ((idx = lowerText.indexOf(lowerSearch, lastIdx)) !== -1) {
|
||||
result += text.substring(lastIdx, idx);
|
||||
result += '<span class="ldr-highlight">' + text.substring(idx, idx + search.length) + '</span>';
|
||||
lastIdx = idx + search.length;
|
||||
}
|
||||
result += text.substring(lastIdx);
|
||||
return result;
|
||||
}
|
||||
|
||||
// indexOf naturally handles special characters — no regex to break
|
||||
expect(() => highlightText('test (parentheses)', '(paren')).not.toThrow();
|
||||
expect(() => highlightText('test [brackets]', '[brack')).not.toThrow();
|
||||
expect(() => highlightText('test.dot', '.')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOMPurify Sanitization Config', () => {
|
||||
test('should allow span tags', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
const input = '<span class="ldr-highlight">text</span>';
|
||||
sanitizeHtml(input);
|
||||
|
||||
// Verify DOMPurify was called with config that allows span
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.objectContaining({
|
||||
ALLOWED_TAGS: expect.arrayContaining(['span'])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow class attribute', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
const input = '<span class="ldr-highlight">text</span>';
|
||||
sanitizeHtml(input);
|
||||
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.objectContaining({
|
||||
ALLOWED_ATTR: expect.arrayContaining(['class'])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should forbid script tags', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
const input = '<script>alert("xss")</script>';
|
||||
sanitizeHtml(input);
|
||||
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.objectContaining({
|
||||
FORBID_TAGS: expect.arrayContaining(['script'])
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should forbid event handlers', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
const input = '<div onclick="alert()">test</div>';
|
||||
sanitizeHtml(input);
|
||||
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
input,
|
||||
expect.objectContaining({
|
||||
FORBID_ATTR: expect.arrayContaining(['onclick'])
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('App.js DOMPurify Export', () => {
|
||||
test('DOMPurify should be exported to window object', () => {
|
||||
// This test verifies the fix - DOMPurify must be on window
|
||||
// for safeSetInnerHTML to use it instead of falling back to textContent
|
||||
expect(global.window.DOMPurify).toBeDefined();
|
||||
expect(typeof global.window.DOMPurify.sanitize).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOMPurify KEEP_CONTENT Configuration', () => {
|
||||
test('should preserve text content when sanitizing highlighted dropdown items', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
|
||||
// Simulate a model name with highlighting like in dropdowns
|
||||
const modelWithHighlight = 'google/<span class="ldr-highlight">gemma</span>-3n-e2b-it:free (OPENROUTER)';
|
||||
const result = sanitizeHtml(modelWithHighlight);
|
||||
|
||||
// Verify DOMPurify was called with KEEP_CONTENT: true
|
||||
// This ensures text content is preserved even if tags are processed
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalledWith(
|
||||
modelWithHighlight,
|
||||
expect.objectContaining({
|
||||
KEEP_CONTENT: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('sanitized output should contain original text content', () => {
|
||||
// The mock preserves content, simulating KEEP_CONTENT: true behavior
|
||||
const input = 'google/<span class="ldr-highlight">gemma</span>-3n-e2b-it:free';
|
||||
const result = mockDOMPurify.sanitize(input, {});
|
||||
|
||||
// Text content should be preserved, not empty
|
||||
expect(result).toContain('google/');
|
||||
expect(result).toContain('gemma');
|
||||
expect(result).toContain('-3n-e2b-it:free');
|
||||
});
|
||||
|
||||
test('should handle complex model names with special characters', () => {
|
||||
const { sanitizeHtml } = global.window;
|
||||
|
||||
// Test various model name formats that appear in dropdowns
|
||||
const testCases = [
|
||||
'meta-llama/llama-3.3-70b-instruct:free',
|
||||
'anthropic/claude-3.5-sonnet:beta',
|
||||
'openai/gpt-4o-2024-11-20',
|
||||
'bytedance-seed/seed-1.6-flash-h'
|
||||
];
|
||||
|
||||
testCases.forEach(modelName => {
|
||||
const highlighted = modelName.replace(/(llama|claude|gpt|seed)/gi,
|
||||
'<span class="ldr-highlight">$1</span>');
|
||||
const result = sanitizeHtml(highlighted);
|
||||
|
||||
// Should call sanitize and preserve content
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dynamic DOMPurify Detection', () => {
|
||||
test('safeSetInnerHTML should check for DOMPurify availability at call time', () => {
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
const element = { innerHTML: '', textContent: '' };
|
||||
|
||||
// Clear previous calls
|
||||
mockDOMPurify.sanitize.mockClear();
|
||||
|
||||
// Call safeSetInnerHTML with HTML content
|
||||
safeSetInnerHTML(element, '<span class="ldr-highlight">test</span>', true);
|
||||
|
||||
// DOMPurify should be used since it's available
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('safeSetInnerHTML should work even if DOMPurify loaded after script init', () => {
|
||||
// This test verifies the dynamic check behavior
|
||||
// In production, Vite modules load after regular scripts
|
||||
// The hasDOMPurify() function checks availability at call time, not load time
|
||||
|
||||
const { safeSetInnerHTML } = global.window;
|
||||
const element = { innerHTML: '', textContent: '' };
|
||||
|
||||
// Simulate calling after DOMPurify is available
|
||||
expect(global.DOMPurify).toBeDefined();
|
||||
|
||||
safeSetInnerHTML(element, '<b>test</b>', true);
|
||||
|
||||
// Should use DOMPurify, not fall back to textContent
|
||||
expect(mockDOMPurify.sanitize).toHaveBeenCalled();
|
||||
expect(element.innerHTML).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user