From 876d8b0d0852b68374a97825298446ab90ca23a5 Mon Sep 17 00:00:00 2001 From: Jared Ondricek Date: Wed, 29 Apr 2026 00:11:03 -0500 Subject: [PATCH 01/10] feat(search): enhance search functionality and UI improvements - Added searchCacheSchemaVersion to settings for cache management. - Updated search file paths to include 'misc.json'. - Refactored SCSS for search overlay, improving layout and responsiveness. - Introduced new pagination controls in search results for better navigation. - Replaced button elements with label and checkbox for search filters to enhance accessibility. - Implemented domain lookup for search results to improve domain filtering accuracy. - Cleaned up unused code and improved overall code structure for maintainability. --- attack-search/__tests__/search-events.test.js | 119 ++++++++ .../__tests__/search-filters.test.js | 73 ++++- attack-search/__tests__/search-loader.test.js | 26 ++ .../__tests__/search-template.test.js | 36 +++ attack-search/__tests__/settings.test.js | 4 + attack-search/src/components.js | 10 +- attack-search/src/index.js | 148 ++++++---- attack-search/src/search-loader.js | 34 +++ attack-search/src/search-service.js | 263 +++++++++++++----- attack-search/src/settings.js | 3 + attack-style/components/_search.scss | 151 +++++++--- attack-theme/static/style-attack.css | 131 +++++++-- attack-theme/static/style-user.css | 131 +++++++-- attack-theme/templates/macros/search.html | 200 ++++--------- modules/search/search.py | 111 +++++++- 15 files changed, 1052 insertions(+), 388 deletions(-) create mode 100644 attack-search/__tests__/search-events.test.js create mode 100644 attack-search/__tests__/search-loader.test.js create mode 100644 attack-search/__tests__/search-template.test.js create mode 100644 attack-search/src/search-loader.js diff --git a/attack-search/__tests__/search-events.test.js b/attack-search/__tests__/search-events.test.js new file mode 100644 index 00000000000..f5dc8a6a20d --- /dev/null +++ b/attack-search/__tests__/search-events.test.js @@ -0,0 +1,119 @@ +const mockJqueryCalls = []; +const mockJqueryApis = {}; + +const mockJquery = jest.fn((selector) => { + if (typeof selector === 'object' && selector.dataset) { + return { + data: jest.fn((key) => selector.dataset?.[toCamelCase(key)]), + }; + } + + const api = { + addClass: jest.fn(() => api), + attr: jest.fn(() => api), + data: jest.fn(), + focus: jest.fn(() => api), + hide: jest.fn(() => api), + on: jest.fn((events, delegatedSelector, handler) => { + mockJqueryCalls.push({ + delegatedSelector: typeof delegatedSelector === 'string' ? delegatedSelector : null, + events, + handler: typeof delegatedSelector === 'function' ? delegatedSelector : handler, + selector, + }); + return api; + }), + prop: jest.fn(() => api), + removeClass: jest.fn(() => api), + show: jest.fn(() => api), + toggle: jest.fn(() => api), + toggleClass: jest.fn(() => api), + val: jest.fn(() => ''), + keyup: jest.fn((handler) => { + mockJqueryCalls.push({ + delegatedSelector: null, + events: 'keyup', + handler, + selector, + }); + return api; + }), + }; + mockJqueryApis[selector] = api; + return api; +}); + +mockJquery.getJSON = jest.fn(); + +jest.mock('jquery', () => mockJquery); + +console.debug = jest.fn(); +console.error = jest.fn(); + +describe('search event bindings', () => { + beforeEach(() => { + jest.resetModules(); + mockJqueryCalls.length = 0; + Object.keys(mockJqueryApis).forEach(key => delete mockJqueryApis[key]); + global.build_uuid = 'test-build'; + global.document = {}; + global.localStorage = { + getItem: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + }; + global.window = {}; + }); + + afterEach(() => { + delete global.build_uuid; + delete global.document; + delete global.localStorage; + delete global.window; + }); + + test('filter controls listen for touch activation events', () => { + require('../src/index'); + + expect(eventsForSelector('[data-search-filter-dropdown-toggle]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-page-type]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-domain]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-domain-action]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-group-action]')).toContain('touchend'); + expect(eventsForSelector('[data-search-filter-reset]')).toContain('touchend'); + }); + + test('dropdown toggles open before the search service is initialized', () => { + require('../src/index'); + + const handler = handlerForSelector('[data-search-filter-dropdown-toggle]'); + handler({ + currentTarget: { dataset: { searchFilterDropdownToggle: 'core' } }, + stopPropagation: jest.fn(), + type: 'click', + }); + + expect(mockJqueryApis['[data-search-filter-dropdown-toggle="core"]'].toggleClass) + .toHaveBeenCalledWith('open', true); + expect(mockJqueryApis['[data-search-filter-dropdown-toggle="core"]'].attr) + .toHaveBeenCalledWith('aria-expanded', 'true'); + expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].toggle) + .toHaveBeenCalledWith(true); + expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].attr) + .toHaveBeenCalledWith('aria-hidden', 'false'); + }); +}); + +function eventsForSelector(selector) { + return mockJqueryCalls + .filter(call => call.selector === selector || call.delegatedSelector === selector) + .flatMap(call => call.events.split(/\s+/)); +} + +function handlerForSelector(selector) { + return mockJqueryCalls.find(call => call.selector === selector || call.delegatedSelector === selector).handler; +} + +function toCamelCase(key) { + return key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} diff --git a/attack-search/__tests__/search-filters.test.js b/attack-search/__tests__/search-filters.test.js index 8acd59f0474..dbd78af3e76 100644 --- a/attack-search/__tests__/search-filters.test.js +++ b/attack-search/__tests__/search-filters.test.js @@ -49,6 +49,20 @@ describe('SearchService filters', () => { pageType: 'resources', domains: [], }, + { + id: 6, + title: 'Uncategorized', + content: 'Credential uncategorized material', + pageType: 'misc', + domains: [], + }, + { + id: 7, + title: 'Multi-domain analytic', + content: 'Credential analytic', + pageType: 'analytics', + domains: ['enterprise', 'mobile'], + }, ]; }); @@ -61,6 +75,16 @@ describe('SearchService filters', () => { expect(searchService.applyFilters(documents)).toEqual(documents); }); + test('unknown page types are searchable by default but excluded by narrowed page type filters', () => { + searchService.setSelectedPageTypes(['techniques']); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1]); + + searchService.selectAllPageTypes(); + + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + test('page type filters treat techniques and sub-techniques independently', () => { searchService.setSelectedPageTypes(['techniques']); @@ -70,7 +94,7 @@ describe('SearchService filters', () => { test('domain filters only match documents with explicit selected domains', () => { searchService.setSelectedDomains(['enterprise']); - expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 7]); }); test('empty page type selection returns no results', () => { @@ -86,7 +110,7 @@ describe('SearchService filters', () => { const counts = searchService.getFilterCounts(documents); expect(counts.pageTypes).toEqual({ - analytics: 0, + analytics: 1, assets: 0, campaigns: 0, datacomponents: 0, @@ -102,11 +126,22 @@ describe('SearchService filters', () => { }); expect(counts.domains).toEqual({ enterprise: 2, - ics: 0, mobile: 1, + ics: 0, }); }); + test('multi-domain results count once per matching domain but once in total results', () => { + const counts = searchService.getFilterCounts(documents); + + expect(counts.domains).toEqual({ + enterprise: 3, + mobile: 2, + ics: 0, + }); + expect(searchService.applyFilters(documents)).toHaveLength(7); + }); + test('clear session resets filters and query-scoped results', () => { searchService.allSearchResults = documents; searchService.setSelectedPageTypes(['techniques']); @@ -138,15 +173,35 @@ describe('SearchService filters', () => { searchService.closeFilterDropdowns(); expect(searchService.getOpenFilterDropdown()).toBeNull(); - expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2]); + expect(searchService.applyFilters(documents).map(result => result.id)).toEqual([1, 2, 7]); }); - test('full filters panel closes subgroup dropdowns', () => { - searchService.toggleFilterDropdown('cti'); + test('pagination uses five-result pages and a compact five-page window', () => { + const manyDocuments = Array.from({ length: 47 }, (_, index) => ({ + id: index + 1, + title: `Result ${index + 1}`, + content: 'Credential result', + pageType: 'resources', + domains: [], + })); + + searchService.allSearchResults = manyDocuments; + searchService.resetFilters(); + + expect(searchService.getPaginationState()).toEqual({ + currentPage: 1, + endResult: 5, + pageSize: 5, + pages: [1, 2, 3, 4, 5], + showPagination: true, + startResult: 1, + totalPages: 10, + totalResults: 47, + }); - searchService.toggleFiltersPanel(); + searchService.goToPage(4); - expect(searchService.getOpenFilterDropdown()).toBeNull(); - expect(searchService.filtersExpanded).toBe(true); + expect(searchService.getPaginationState().pages).toEqual([2, 3, 4, 5, 6]); + expect(searchService.getPaginationState().startResult).toBe(16); }); }); diff --git a/attack-search/__tests__/search-loader.test.js b/attack-search/__tests__/search-loader.test.js new file mode 100644 index 00000000000..e2b90ce395b --- /dev/null +++ b/attack-search/__tests__/search-loader.test.js @@ -0,0 +1,26 @@ +const { loadSearchDocuments } = require('../src/search-loader'); + +describe('search loader', () => { + test('skips missing optional search files and returns loaded documents', async () => { + const getJSON = jest.fn((url) => { + if (url.endsWith('misc.json')) return Promise.reject({ status: 404 }); + return Promise.resolve([{ id: url, title: 'Loaded' }]); + }); + + const documents = await loadSearchDocuments('/search/', ['techniques.json', 'misc.json'], getJSON); + + expect(documents).toEqual([{ id: '/search/techniques.json', title: 'Loaded' }]); + }); + + test('fails when every optional search file is missing', async () => { + const getJSON = jest.fn(() => Promise.reject({ status: 404 })); + + await expect(loadSearchDocuments('/search/', ['misc.json'], getJSON)).rejects.toThrow('No search index files'); + }); + + test('fails malformed existing search files', async () => { + const getJSON = jest.fn(() => Promise.reject(new SyntaxError('Unexpected token'))); + + await expect(loadSearchDocuments('/search/', ['techniques.json'], getJSON)).rejects.toThrow('Unexpected token'); + }); +}); diff --git a/attack-search/__tests__/search-template.test.js b/attack-search/__tests__/search-template.test.js new file mode 100644 index 00000000000..68d2c3f7b0a --- /dev/null +++ b/attack-search/__tests__/search-template.test.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +describe('search template', () => { + test('places result counts and pagination above the result list', () => { + const template = fs.readFileSync( + path.join(__dirname, '../../attack-theme/templates/macros/search.html'), + 'utf8', + ); + + const searchBodyStart = template.indexOf('
'); + const footerStart = template.indexOf('