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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+11
View File
@@ -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
"""
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();
});
});
});
+363
View File
@@ -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('&lt;script&gt;');
expect(escapeHtml('a & b')).toBe('a &amp; b');
expect(escapeHtml('"quoted"')).toBe('&quot;quoted&quot;');
expect(escapeHtml("'single'")).toBe('&#39;single&#39;');
});
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();
});
});