Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Website Changelog

## Unreleased

### Bug Fixes

* 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. A failed restore from the cached index is no longer reported as a successful load.

## v5.0.0 (2026-08-06)

* Release ATT&CK content version 19.2.
Expand Down
25 changes: 25 additions & 0 deletions attack-search/__tests__/indexed-db-wrapper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
72 changes: 72 additions & 0 deletions attack-search/__tests__/search-events.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,80 @@ 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 () => {
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');
});

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}`;

global.window = { indexedDB: {} };
global.localStorage.getItem.mockReturnValue(`${global.build_uuid}-search-${version}`);

jest.doMock('../src/search-service.js', () => class {
initializeAsync() {
return Promise.reject(new Error('cached index is unreadable'));
}
});

require('../src/index');
await new Promise(resolve => setImmediate(resolve));
}

function eventsForSelector(selector) {
return mockJqueryCalls
.filter(call => call.selector === selector || call.delegatedSelector === selector)
Expand Down
38 changes: 28 additions & 10 deletions attack-search/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ 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);
}

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...');
Expand All @@ -111,12 +128,12 @@ 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);
} finally {
searchParsingIcon.hide();
searchServiceIsLoaded = true;
}
}
else {
Expand All @@ -139,32 +156,33 @@ 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.');
}
}

// Perform a search using the search service
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();
Expand Down
39 changes: 22 additions & 17 deletions attack-search/src/indexed-db-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down