diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 2b494f00aa6..000a7329776 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,19 +1,7 @@ - - - - - +## Description -## Description of what has changed - - - +## Problem Solved -## Issues addressed by pull request - - - - - - - +## Alternatives Considered + +## Related issue(s) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2039c128b43..0efffb5be97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Website Changelog +## Unreleased + +### Features + +* Add dark mode! + +### Bug Fixes + +* Improve search results for exact ATT&CK ID and numeric ID queries by prioritizing matching object pages and relevant references. +* Fix sidebar loading for HTTPS redirects. +* Settle the search index write when an IndexedDB write fails, instead of leaving the promise pending and the search spinner up. +* Disable the search controls and explain why when the search index cannot be built, instead of leaving the spinner running for as long as the page is open. + ## v5.0.0 (2026-08-06) * Release ATT&CK content version 19.2. diff --git a/attack-search/__tests__/indexed-db-wrapper.test.js b/attack-search/__tests__/indexed-db-wrapper.test.js index 51e4727b3fc..2b379212209 100644 --- a/attack-search/__tests__/indexed-db-wrapper.test.js +++ b/attack-search/__tests__/indexed-db-wrapper.test.js @@ -57,4 +57,29 @@ describe('IndexedDBWrapper', () => { const count = await contentDb.count(); expect(count).toEqual(data.length); }); + + // A failed write must settle the promise. Racing against a sentinel tells a + // rejection apart from a promise that never settles at all, which a plain + // rejects assertion cannot do: it would time out and look like a slow test. + const settle = (promise) => Promise.race([ + promise.then(() => 'resolved', (error) => `rejected:${error.message}`), + new Promise((resolve) => setTimeout(() => resolve('HUNG'), 1000)), + ]); + + test('Bulk put rejects when the underlying write fails', async () => { + jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut') + .mockRejectedValue(new Error('QuotaExceededError')); + + await expect(settle(contentDb.bulkPut(data))).resolves.toBe('rejected:QuotaExceededError'); + }); + + test('Bulk put rejects when a later chunk fails', async () => { + let calls = 0; + jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut') + .mockImplementation(() => (++calls === 2 + ? Promise.reject(new Error('DatabaseClosedError')) + : Promise.resolve())); + + await expect(settle(contentDb.bulkPut(data, 1))).resolves.toBe('rejected:DatabaseClosedError'); + }); }); diff --git a/attack-search/__tests__/search-events.test.js b/attack-search/__tests__/search-events.test.js index 86af5fc0e19..45d30e8c3c7 100644 --- a/attack-search/__tests__/search-events.test.js +++ b/attack-search/__tests__/search-events.test.js @@ -112,8 +112,90 @@ describe('search event bindings', () => { expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].attr) .toHaveBeenCalledWith('aria-hidden', 'false'); }); + + test('a failed index build stops search from waiting for an index that never arrives', async () => { + await loadIndexWithAFailingColdStart(); + + const parsingIcon = mockJqueryApis['#search-parsing-icon']; + parsingIcon.show.mockClear(); + parsingIcon.hide.mockClear(); + + handlerForSelector('#search-input')({ target: { value: 'mimikatz' } }); + + // Before the fix `search` looped on the loaded flag, so it showed the parsing icon on + // its first pass and kept doing so every 100ms for as long as the page stayed open. + expect(parsingIcon.show).not.toHaveBeenCalled(); + expect(parsingIcon.hide).toHaveBeenCalled(); + }); + + test('a failed restore from the cache is not reported as a successful load', async () => { + const { cacheKey, deleteCachedDatabase } = await loadIndexWithAFailingWarmRestore(); + + // The catch used to set the loaded flag false and the finally set it straight back to + // true, so `search` went on to query an index that was never populated. + expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon'); + expect(global.localStorage.removeItem).toHaveBeenCalledWith(cacheKey); + expect(deleteCachedDatabase).toHaveBeenCalledTimes(1); + }); + + test('a failed index build puts the search controls into their unavailable state', async () => { + await loadIndexWithAFailingColdStart(); + + expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-button'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-icon'].removeClass).toHaveBeenCalledWith('search-icon'); + expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon'); + expect(mockJqueryApis['#search-button'].prop) + .toHaveBeenCalledWith('title', expect.stringContaining('search index could not be built')); + }); }); +// Load the module on the cold-start path with the document fetch failing, and run the +// debouncer straight through so the input handler reaches `search` without a timer. +async function loadIndexWithAFailingColdStart() { + global.window = { indexedDB: {} }; + global.localStorage.getItem.mockReturnValue(null); + + jest.doMock('../src/search-loader.js', () => ({ + loadSearchDocuments: () => Promise.reject(new Error('documents unavailable')), + })); + jest.doMock('../src/debouncer.js', () => class { + debounce(callback) { + callback(); + } + }); + + require('../src/index'); + await new Promise(resolve => setImmediate(resolve)); +} + +// Load the module on the cached path, with restoring the index from IndexedDB failing. +async function loadIndexWithAFailingWarmRestore() { + const { searchCacheCompatibilityVersion, searchCacheSchemaVersion } = require('../src/settings'); + const version = `${searchCacheSchemaVersion}-${searchCacheCompatibilityVersion}`; + const cacheKey = `saved_uuid_search_schema_${version}`; + const deleteCachedDatabase = jest.fn(() => Promise.resolve()); + + global.window = { indexedDB: {} }; + global.localStorage.getItem.mockReturnValue(`${global.build_uuid}-search-${version}`); + + jest.doMock('../src/search-service.js', () => class { + constructor() { + this.db = { indexeddb: { delete: deleteCachedDatabase } }; + } + + initializeAsync() { + return Promise.reject(new Error('cached index is unreadable')); + } + }); + + require('../src/index'); + await new Promise(resolve => setImmediate(resolve)); + + return { cacheKey, deleteCachedDatabase }; +} + function eventsForSelector(selector) { return mockJqueryCalls .filter(call => call.selector === selector || call.delegatedSelector === selector) diff --git a/attack-search/__tests__/search-service.test.js b/attack-search/__tests__/search-service.test.js index 87beddcec06..5c5c1c91771 100644 --- a/attack-search/__tests__/search-service.test.js +++ b/attack-search/__tests__/search-service.test.js @@ -22,11 +22,17 @@ describe('SearchService', () => { }); beforeEach(() => { + global.base_url = '/'; searchService = new SearchService('search-service', null); + searchService.render_container = { + append: jest.fn(), + html: jest.fn(), + }; }); afterEach(async () => { searchService = null; + delete global.base_url; }); it('Access data from mock-index.json', () => { @@ -111,4 +117,117 @@ describe('SearchService', () => { }); }); + + test('Keeps only exact ATT&CK ID matches and references, with the object first', async () => { + const documents = { + 1: { + id: 1, + title: 'TA577, Group G1037', + path: '/groups/G1037/index.html', + content: 'A group with no reference to the queried technique.', + attackId: 'G1037', + }, + 2: { + id: 2, + title: 'Ingress Tool Transfer, Technique T1105 - Enterprise', + path: '/techniques/T1105/index.html', + content: 'The T1105 technique.', + attackId: 'T1105', + }, + 3: { + id: 3, + title: 'A valid reference', + path: '/resources/reference/index.html', + content: 'This page references T1105.', + }, + }; + searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2, 3] }]); + searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position])); + + await searchService.query('t1105'); + + expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 3]); + }); + + test('Treats a four-digit query as an exact ATT&CK ID suffix search', async () => { + const documents = { + 1: { + id: 1, + title: 'TA577, Group G1037', + path: '/groups/G1037/index.html', + content: 'A group with no reference to the queried technique.', + attackId: 'G1037', + }, + 2: { + id: 2, + title: 'Data from Local System, Technique T1005 - Enterprise', + path: '/techniques/T1005/index.html', + content: 'The T1005 technique.', + attackId: 'T1005', + }, + 3: { + id: 3, + title: 'Matching software, Software S1005', + path: '/software/S1005/index.html', + content: 'The S1005 software.', + attackId: 'S1005', + }, + 4: { + id: 4, + title: 'A valid reference', + path: '/resources/reference/index.html', + content: 'This page references T1005.', + }, + 5: { + id: 5, + title: 'Data from Local System: Archive Collected Data, Sub-technique T1005.001', + path: '/techniques/T1005/001/index.html', + content: 'The T1005.001 sub-technique.', + attackId: 'T1005.001', + }, + }; + searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 3, 4, 2, 5] }]); + searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position])); + + await searchService.query('1005'); + + expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 5, 3, 4]); + }); + + test.each(['TA0001', '0001'])('Promotes a tactic page for ATT&CK ID query %s', async (query) => { + const documents = { + 1: { + id: 1, + title: 'A valid reference', + path: '/resources/reference/index.html', + content: 'This page references TA0001.', + }, + 2: { + id: 2, + title: 'Initial Access, Tactic TA0001 - Enterprise', + path: '/tactics/TA0001/index.html', + content: 'The TA0001 tactic.', + attackId: 'TA0001', + }, + }; + searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2] }]); + searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position])); + + await searchService.query(query); + + expect(searchService.allSearchResults.map(result => result.id)).toEqual([2, 1]); + }); + + test('Preserves result ordering for non-ID queries', async () => { + const documents = { + 1: { id: 1, title: 'First result', path: '/resources/faq/index.html', content: 'Resources' }, + 2: { id: 2, title: 'Second result', path: '/resources/attackcon/index.html', content: 'Resources' }, + }; + searchService.attackIndex.search = jest.fn().mockResolvedValue([{ field: 'title', result: [1, 2] }]); + searchService.resolveSearchResults = jest.fn(async positions => positions.map(position => documents[position])); + + await searchService.query('Resources'); + + expect(searchService.allSearchResults.map(result => result.id)).toEqual([1, 2]); + }); }); diff --git a/attack-search/__tests__/search-style.test.js b/attack-search/__tests__/search-style.test.js index 73a43d2718e..f5713431710 100644 --- a/attack-search/__tests__/search-style.test.js +++ b/attack-search/__tests__/search-style.test.js @@ -20,7 +20,7 @@ describe('search styles', () => { const badgeStyle = styles.match(/\.search-result-badge\s*\{(?
[^}]+)\}/)?.groups?.body ?? ''; - expect(badgeStyle).toContain('color: white;'); + expect(badgeStyle).toContain('color: color-functions.on-color(active);'); expect(badgeStyle).toContain('font-size: 0.8rem;'); expect(styles).toContain('.search-result-badge-page-type'); diff --git a/attack-search/__tests__/theme.test.js b/attack-search/__tests__/theme.test.js new file mode 100644 index 00000000000..ea6325bf4d3 --- /dev/null +++ b/attack-search/__tests__/theme.test.js @@ -0,0 +1,403 @@ +const fs = require('fs'); +const path = require('path'); + +const themeModulePath = '../../attack-theme/static/scripts/theme.js'; + +describe('site theme', () => { + let theme; + + beforeEach(() => { + jest.resetModules(); + theme = require(themeModulePath); + }); + + test('uses System when no saved override exists', () => { + const storage = createStorage(); + const root = createRoot(); + + expect(theme.readStoredPreference(storage)).toBe('system'); + + theme.applyPreference(root, 'system'); + + expect(root.removeAttribute).toHaveBeenCalledWith('data-theme'); + expect(root.style.setProperty).not.toHaveBeenCalled(); + }); + + test.each(['light', 'dark'])('applies a saved %s override before controls initialize', preference => { + const storage = createStorage(preference); + const root = createRoot(); + + const storedPreference = theme.readStoredPreference(storage); + theme.applyPreference(root, storedPreference); + + expect(storedPreference).toBe(preference); + expect(root.setAttribute).toHaveBeenCalledWith('data-theme', preference); + // CSS owns color-scheme so the print stylesheet can force light controls. + expect(root.style.setProperty).not.toHaveBeenCalled(); + }); + + test('discards an invalid saved preference', () => { + const storage = createStorage('sepia'); + + expect(theme.readStoredPreference(storage)).toBe('system'); + expect(storage.removeItem).toHaveBeenCalledWith(theme.STORAGE_KEY); + }); + + test('adds a working archive switch to the existing banner without replacing its content', () => { + const fixture = createControllerFixture({ storedPreference: 'dark' }); + const banner = { appendChild: jest.fn(), textContent: 'Currently viewing ATT&CK v3.0' }; + fixture.document.querySelector = jest.fn(selector => ( + selector === '.version-banner' ? banner : null + )); + fixture.document.createElement = jest.fn(() => fixture.toggle); + fixture.document.readyState = 'complete'; + + theme.bootstrap({ ...fixture, archived: true }); + + expect(fixture.document.documentElement.setAttribute).toHaveBeenCalledWith('data-archive-theme', ''); + expect(banner.appendChild).toHaveBeenCalledWith(fixture.toggle); + expect(banner.textContent).toBe('Currently viewing ATT&CK v3.0'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Dark mode'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true'); + fixture.toggle.click(); + expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'light'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false'); + }); + + test('toggles from the system light theme to a saved dark override', () => { + const fixture = createControllerFixture({ systemDark: false }); + const controller = theme.createThemeController(fixture); + + controller.init(); + fixture.toggle.click(); + + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark'); + expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'dark'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to light mode'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true'); + }); + + test('toggles from the system dark theme to a saved light override', () => { + const fixture = createControllerFixture({ systemDark: true }); + const controller = theme.createThemeController(fixture); + + controller.init(); + fixture.toggle.click(); + + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'light'); + expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'light'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false'); + }); + + test.each([ + ['light', 'dark'], + ['dark', 'light'], + ])('toggles a saved %s override to %s', (storedPreference, expectedPreference) => { + const fixture = createControllerFixture({ storedPreference }); + const controller = theme.createThemeController(fixture); + + controller.init(); + fixture.toggle.click(); + + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith( + 'data-theme', + expectedPreference, + ); + expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, expectedPreference); + }); + + test('handles toggle clicks before DOMContentLoaded without double toggling after initialization', () => { + const fixture = createControllerFixture({ storedPreference: 'dark' }); + fixture.document.readyState = 'loading'; + + theme.bootstrap(fixture); + + // The stored preference is still applied synchronously to prevent a light-theme flash. + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark'); + + fixture.document.dispatchClick({ closest: jest.fn(() => fixture.toggle) }); + + expect(fixture.storage.setItem).toHaveBeenLastCalledWith(theme.STORAGE_KEY, 'light'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false'); + + fixture.document.dispatchDOMContentLoaded(); + fixture.toggle.click(); + + expect(fixture.storage.setItem).toHaveBeenLastCalledWith(theme.STORAGE_KEY, 'dark'); + expect(fixture.storage.setItem).toHaveBeenCalledTimes(2); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true'); + }); + + test('ignores delegated clicks outside the theme toggle', () => { + const fixture = createControllerFixture(); + const controller = theme.createThemeController(fixture); + + fixture.document.dispatchClick({ closest: jest.fn(() => null) }); + controller.init(); + + expect(fixture.storage.setItem).not.toHaveBeenCalled(); + }); + + test('continues applying a choice when storage access fails', () => { + const fixture = createControllerFixture(); + fixture.storage.setItem.mockImplementation(() => { + throw new Error('Storage disabled'); + }); + const controller = theme.createThemeController(fixture); + + expect(() => { + controller.init(); + fixture.toggle.click(); + }).not.toThrow(); + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark'); + }); + + test('updates the toggle when the system preference changes before an override', () => { + const fixture = createControllerFixture({ systemDark: false }); + const controller = theme.createThemeController(fixture); + + controller.init(); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode'); + + fixture.mediaQuery.matches = true; + fixture.mediaQuery.dispatchChange(); + + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to light mode'); + }); + + test('ignores OS preference changes while an explicit override is active', () => { + const fixture = createControllerFixture({ storedPreference: 'light', systemDark: false }); + const controller = theme.createThemeController(fixture); + + controller.init(); + fixture.mediaQuery.matches = true; + fixture.mediaQuery.dispatchChange(); + + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-label', 'Switch to dark mode'); + }); + + test('synchronizes a theme change from another tab without writing it back', () => { + const fixture = createControllerFixture({ storedPreference: 'light' }); + theme.createThemeController(fixture).init(); + + fixture.storage.getItem.mockReturnValue('dark'); + fixture.document.defaultView.dispatchStorage({ + key: theme.STORAGE_KEY, + storageArea: fixture.storage, + }); + + expect(fixture.document.documentElement.setAttribute).toHaveBeenLastCalledWith('data-theme', 'dark'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'true'); + expect(fixture.storage.setItem).not.toHaveBeenCalled(); + fixture.toggle.click(); + expect(fixture.storage.setItem).toHaveBeenCalledWith(theme.STORAGE_KEY, 'light'); + }); + + test.each([null, 'attack-website-theme'])('returns to the system theme after storage removal (%s)', key => { + const fixture = createControllerFixture({ storedPreference: 'dark', systemDark: false }); + theme.createThemeController(fixture).init(); + + fixture.storage.getItem.mockReturnValue(null); + fixture.document.defaultView.dispatchStorage({ key, storageArea: fixture.storage }); + + expect(fixture.document.documentElement.removeAttribute).toHaveBeenCalledWith('data-theme'); + expect(fixture.toggle.setAttribute).toHaveBeenCalledWith('aria-checked', 'false'); + fixture.mediaQuery.matches = true; + fixture.mediaQuery.dispatchChange(); + expect(fixture.toggle.setAttribute).toHaveBeenLastCalledWith('data-theme-effective', 'dark'); + }); + + test('ignores unrelated storage keys and storage areas', () => { + const fixture = createControllerFixture({ storedPreference: 'light' }); + theme.createThemeController(fixture).init(); + fixture.storage.getItem.mockClear(); + + fixture.document.defaultView.dispatchStorage({ key: 'other', storageArea: fixture.storage }); + fixture.document.defaultView.dispatchStorage({ key: theme.STORAGE_KEY, storageArea: createStorage('dark') }); + + expect(fixture.storage.getItem).not.toHaveBeenCalled(); + }); + + test('loads the early theme script before styles and renders one toggle before search', () => { + const template = fs.readFileSync( + path.join(__dirname, '../../attack-theme/templates/general/base-template.html'), + 'utf8', + ); + const navigation = fs.readFileSync( + path.join(__dirname, '../../attack-theme/templates/macros/navigation_menu.html'), + 'utf8', + ); + + expect(template).toContain(''); + expect(template.indexOf('/theme/scripts/theme.js')).toBeLessThan(template.indexOf('bootstrap.min.css')); + expect(navigation.match(/\bdata-theme-toggle(?=[\s>])/g)).toHaveLength(1); + expect(navigation.indexOf('id="theme-toggle"')).toBeLessThan(navigation.indexOf('id="search-button"')); + expect(navigation).toContain('role="switch"'); + expect(navigation).toContain('class="theme-toggle-track"'); + expect(navigation).toContain('class="theme-toggle-thumb"'); + expect(navigation).toContain('aria-checked="false"'); + expect(navigation).not.toContain('aria-pressed'); + expect(navigation).not.toContain('data-theme-option'); + expect(navigation).not.toContain('theme-menu'); + expect(navigation).not.toContain('dropdown-toggle" type="button" data-theme-toggle'); + }); + + test('uses theme-aware surfaces for the affected resource pages', () => { + const council = fs.readFileSync( + path.join(__dirname, '../../modules/resources/templates/attack-advisory-council-members.html'), + 'utf8', + ); + const dataTools = fs.readFileSync( + path.join(__dirname, '../../modules/resources/templates/attack-data-and-tools.html'), + 'utf8', + ); + const attackcon = fs.readFileSync( + path.join(__dirname, '../../modules/resources/templates/attackcon-overview.html'), + 'utf8', + ); + + expect(council).toContain('background: var(--attack-color-body-alternate);'); + expect(council).toContain('color: var(--attack-on-color-body);'); + expect(dataTools).toContain('class="tab-content card card-body p-3 attack-excel-files"'); + expect(dataTools).not.toContain('style="background: #f8f9fa;"'); + expect(attackcon).toContain('"ATT&CKcon 4.0", "ATT&CKcon 5.0", "ATT&CKcon 6.0", "ATT&CKcon 7.0"'); + expect(attackcon).toContain('attackcon-banner-image{% if con.title in light_banner_titles %} on-light{% endif %}'); + }); + + test('uses theme-aware home controls and announcement banner colors', () => { + const home = fs.readFileSync( + path.join(__dirname, '../../attack-theme/templates/general/attack-index.html'), + 'utf8', + ); + const colors = fs.readFileSync( + path.join(__dirname, '../../attack-style/themes/_palette.scss'), + 'utf8', + ); + + expect(home).toContain('fa-up-right-from-square external-link-icon'); + expect(home).toContain('dropdown-toggle-split random-page-toggle'); + expect(home).not.toContain('external-site-dark.jpeg'); + expect(home).not.toContain('style="color: #4f7cac; background-color: white;'); + expect(colors).toContain('--attack-color-banner: #e7f0f6;'); + expect(colors).toContain('--attack-color-banner: #263a49;'); + }); + + test('renders the matrix Navigator link with a theme-aware icon', () => { + const matrix = fs.readFileSync( + path.join(__dirname, '../../modules/matrices/templates/matrix.html'), + 'utf8', + ); + + expect(matrix).toContain('fa-up-right-from-square'); + expect(matrix).not.toContain('external-site-dark.jpeg'); + }); + + test('paints the initial toggle state from the root theme and suppresses the Bootstrap focus ring', () => { + const nav = fs.readFileSync( + path.join(__dirname, '../../attack-style/layout/_nav.scss'), + 'utf8', + ); + + expect(nav).toContain(':root[data-theme="dark"] &'); + expect(nav).toContain(':root:not([data-theme]) &'); + expect(nav).toMatch(/&:focus\s*\{\s*outline: 0;\s*box-shadow: none;/); + }); + + test('uses a warm metadata label color only in dark mode', () => { + const colors = fs.readFileSync( + path.join(__dirname, '../../attack-style/themes/_palette.scss'), + 'utf8', + ); + const layout = fs.readFileSync( + path.join(__dirname, '../../attack-style/layout/_layout.scss'), + 'utf8', + ); + + expect(colors).toContain('--attack-color-property-label: #1d2226;'); + expect(colors).toContain('--attack-color-property-label: #f2d2a4;'); + expect(layout).toMatch(/\.card-data \.card-title\s*\{\s*color: color-functions\.color\(property-label\);/); + }); +}); + +function createStorage(value = null) { + return { + getItem: jest.fn(() => value), + removeItem: jest.fn(), + setItem: jest.fn(), + }; +} + +function createRoot() { + return { + removeAttribute: jest.fn(), + setAttribute: jest.fn(), + style: { + removeProperty: jest.fn(), + setProperty: jest.fn(), + }, + }; +} + +function createElement() { + const listeners = {}; + let dispatchClick; + const element = { + classList: { toggle: jest.fn() }, + setAttribute: jest.fn(), + addEventListener: jest.fn((eventName, listener) => { + listeners[eventName] = listener; + }), + closest: jest.fn(selector => (selector === '[data-theme-toggle]' ? element : null)), + connectClickDispatcher: dispatcher => { + dispatchClick = dispatcher; + }, + click: () => { + const event = { preventDefault: jest.fn(), target: element }; + if (listeners.click) listeners.click(event); + if (dispatchClick) dispatchClick(event); + }, + }; + + return element; +} + +function createControllerFixture({ storedPreference = null, systemDark = false } = {}) { + const root = createRoot(); + const toggle = createElement(); + let changeListener; + let clickListener; + let domContentLoadedListener; + let storageListener; + const mediaQuery = { + matches: systemDark, + addEventListener: jest.fn((eventName, listener) => { + if (eventName === 'change') changeListener = listener; + }), + dispatchChange: () => changeListener({ matches: mediaQuery.matches }), + }; + const document = { + documentElement: root, + querySelector: jest.fn(selector => (selector === '[data-theme-toggle]' ? toggle : null)), + addEventListener: jest.fn((eventName, listener) => { + if (eventName === 'click') clickListener = listener; + if (eventName === 'DOMContentLoaded') domContentLoadedListener = listener; + }), + dispatchClick: target => clickListener({ preventDefault: jest.fn(), target }), + dispatchDOMContentLoaded: () => domContentLoadedListener(), + defaultView: { + addEventListener: jest.fn((eventName, listener) => { + if (eventName === 'storage') storageListener = listener; + }), + dispatchStorage: event => storageListener(event), + }, + }; + toggle.connectClickDispatcher(event => clickListener(event)); + + return { + document, + mediaQuery, + storage: createStorage(storedPreference), + toggle, + }; +} diff --git a/attack-search/src/index.js b/attack-search/src/index.js index d3aa49f3b30..54ad023b402 100644 --- a/attack-search/src/index.js +++ b/attack-search/src/index.js @@ -89,6 +89,40 @@ const closeSearch = function () { // Variable to check if search service is loaded let searchServiceIsLoaded = false; +// Set once the index cannot be built at all. Without it `search` waits for a flag that is +// never going to flip and the parsing spinner runs for as long as the page is open. +let searchServiceUnavailable = false; + +// Put the search controls into their unavailable state and explain why on hover. +function markSearchUnavailable(reason) { + searchServiceUnavailable = true; + searchServiceIsLoaded = false; + searchInput.prop('disabled', true); + searchButton.prop('disabled', true); + searchIcon.removeClass('search-icon'); + searchIcon.addClass('error-icon'); + searchButton.prop('title', reason); +} + +// Remove a failed cached index so the next page load rebuilds it instead of retrying +// the same restore path. Cache cleanup is best-effort and must not mask the original +// initialization failure or prevent the unavailable UI state from being shown. +async function invalidateSearchCache() { + try { + localStorage.removeItem(searchCacheKey); + } catch (error) { + console.error('Failed to remove the search cache marker:', error); + } + + try { + await searchService.db.indexeddb.delete(); + } catch (error) { + console.error('Failed to delete the cached search index:', error); + } +} + +const SEARCH_INDEX_FAILED_MESSAGE = 'The search index could not be built. Reload the page to try again.'; + // Initialize the search service async function initializeSearchService() { console.debug('Initializing search service...'); @@ -111,12 +145,13 @@ async function initializeSearchService() { await searchService.initializeAsync(null); // Passing null will instruct the search service to attempt // restoring itself from the IndexedDB console.debug('SearchService is initialized.'); + searchServiceIsLoaded = true; } catch (error) { console.error('Failed to initialize SearchService:', error); - searchServiceIsLoaded = false; + markSearchUnavailable(SEARCH_INDEX_FAILED_MESSAGE); + await invalidateSearchCache(); } finally { searchParsingIcon.hide(); - searchServiceIsLoaded = true; } } else { @@ -139,18 +174,14 @@ async function initializeSearchService() { .catch(error => { console.error('Failed to initialize SearchService:', error); searchParsingIcon.hide(); - searchServiceIsLoaded = false; + markSearchUnavailable(SEARCH_INDEX_FAILED_MESSAGE); }); } } else { // Disable the search button and display an error icon with a hover effect that displays a message/explanation console.error('Search is only available in browsers that support IndexedDB. Please try using Firefox, Chrome, Safari, or another browser that supports IndexedDB.'); - searchInput.prop('disabled', true); - searchButton.prop('disabled', true); - searchIcon.removeClass('search-icon'); - searchIcon.addClass('error-icon'); - searchButton.prop('title', 'To use the search feature, please make sure your browser supports IndexedDB. If not, consider upgrading your browser or switching to a supported browser such as Firefox, Chrome, or Safari.') + markSearchUnavailable('To use the search feature, please make sure your browser supports IndexedDB. If not, consider upgrading your browser or switching to a supported browser such as Firefox, Chrome, or Safari.'); } } @@ -158,13 +189,18 @@ async function initializeSearchService() { const search = async function (query) { console.debug(`search -> Received search query: ${query}`); - // Wait until the search service is loaded - while (!searchServiceIsLoaded) { + // Wait until the search service is loaded, or until we know it never will be. + while (!searchServiceIsLoaded && !searchServiceUnavailable) { console.debug('search -> search index is not loaded...'); searchParsingIcon.show(); await new Promise(resolve => setTimeout(resolve, 100)); } + if (searchServiceUnavailable) { + searchParsingIcon.hide(); + return; + } + console.debug(`Executing search: ${query}`); await searchService.query(query); searchParsingIcon.hide(); diff --git a/attack-search/src/indexed-db-wrapper.js b/attack-search/src/indexed-db-wrapper.js index df24f3ff08b..9ac1400b02a 100644 --- a/attack-search/src/indexed-db-wrapper.js +++ b/attack-search/src/indexed-db-wrapper.js @@ -23,7 +23,7 @@ class TableWrapper { */ async bulkPut(data, chunkSize = 100) { - return new Promise(async (resolve) => { + return new Promise((resolve, reject) => { /** * Schedules work using requestIdleCallback if supported, or setTimeout as a fallback. * @param {Function} callback - The function to be executed when the browser is idle or after the specified delay. @@ -44,23 +44,28 @@ class TableWrapper { * @param {number} start - The index of the first item in the data array to be included in the current chunk. */ const putChunk = async (start) => { - // If all data has been processed, resolve the promise - if (start >= data.length) { - resolve(); - return; + try { + // If all data has been processed, resolve the promise + if (start >= data.length) { + resolve(); + return; + } + + // Determine the end index for the current chunk + const end = Math.min(start + chunkSize, data.length); + + // Extract the chunk from the data array + const chunk = data.slice(start, end); + + // Insert the chunk into the IndexedDB table + await this.indexeddb[this.tableName].bulkPut(chunk); + + // Schedule the next chunk to be processed + scheduleWork(() => putChunk(end)); + } catch (error) { + // Nothing else settles this promise, so callers would wait forever. + reject(error); } - - // Determine the end index for the current chunk - const end = Math.min(start + chunkSize, data.length); - - // Extract the chunk from the data array - const chunk = data.slice(start, end); - - // Insert the chunk into the IndexedDB table - await this.indexeddb[this.tableName].bulkPut(chunk); - - // Schedule the next chunk to be processed - scheduleWork(() => putChunk(end)); }; // Start processing the data array by inserting the first chunk diff --git a/attack-search/src/search-service.js b/attack-search/src/search-service.js index 5a38f6ecfe5..bd03b763296 100644 --- a/attack-search/src/search-service.js +++ b/attack-search/src/search-service.js @@ -313,10 +313,84 @@ module.exports = class SearchService { * ] */ - this.allSearchResults = await this.#setSearchResults(results); + this.allSearchResults = this.#filterAndPromoteExactAttackIdMatches(await this.#setSearchResults(results)); this.#renderFilteredSearchResults(); } + /** + * Limits ATT&CK ID searches to matching objects, their sub-techniques, and genuine references. + * Non-ID and multi-token queries retain FlexSearch's existing ordering. + * + * @private + * @param {Array