/** * 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>/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(''); expect(element.textContent).toBe(''); }); 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 = 'safe'; 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 = 'search'; 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 += '' + text.substring(idx, idx + search.length) + ''; 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-flash-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 += '' + text.substring(idx, idx + search.length) + ''; 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 += '' + text.substring(idx, idx + search.length) + ''; 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 += '' + text.substring(idx, idx + search.length) + ''; 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 = 'text'; 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 = 'text'; 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 = ''; 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 = '
test
'; 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/gemma-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/gemma-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, '$1'); 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, 'test', 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, 'test', true); // Should use DOMPurify, not fall back to textContent expect(mockDOMPurify.sanitize).toHaveBeenCalled(); expect(element.innerHTML).toBeTruthy(); }); });