Skip to content
Merged
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
37 changes: 0 additions & 37 deletions .towncrier.template.md

This file was deleted.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ Run commands from the repo root unless a subdirectory is called out.

- Pull requests should target the `develop` branch per `CONTRIBUTING.md`.
- The PR template expects a reviewer and a `CHANGELOG.md` update when appropriate.
- `pyproject.toml` configures Towncrier for `CHANGELOG.md`; prefer the repository's release-note fragment workflow when one is present instead of hand-editing generated changelog sections.
- The website version is configured in `modules/site_config.py`; keep it aligned with release tags and docs.
- Do not assume `master` is the integration branch just because GitHub Pages deploys from it.
- Use Conventional Commit style git messages.

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Website Changelog

## v4.4.3 (2026-05-12)

### Features

* Release ATT&CK content version 19.1.
See detailed changes [here](https://github.com/mitre/cti/releases/tag/ATT%26CK-v19.1).

## v4.4.2 (2026-04-28)

### Features
Expand Down
4 changes: 2 additions & 2 deletions attack-search/__tests__/attack-index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ describe('AttackIndex', () => {
// Search the title index for "The"
const results = await attackIndex.search('The', ['title']);

// Index 2 through 6 (inclusive) should have "The" in the title
const expectedResult = [{"field":"title","result":[2,3,4,5,6]}]
// Index 2 through 10 (inclusive) should have "The" in the title
const expectedResult = [{"field":"title","result":[2,3,4,5,6,7,8,9,10]}]

expect(results).toEqual(expectedResult);
});
Expand Down
119 changes: 119 additions & 0 deletions attack-search/__tests__/search-events.test.js
Original file line number Diff line number Diff line change
@@ -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());
}
73 changes: 64 additions & 9 deletions attack-search/__tests__/search-filters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
];
});

Expand All @@ -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']);

Expand All @@ -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', () => {
Expand All @@ -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,
Expand All @@ -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']);
Expand Down Expand Up @@ -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 exposes first last and five nearby pages for larger result sets', () => {
const manyDocuments = Array.from({ length: 183 }, (_, 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: 10,
pageSize: 10,
pages: [1, 2, 3, 4, 5, 'ellipsis', 19],
showPagination: true,
startResult: 1,
totalPages: 19,
totalResults: 183,
});

searchService.toggleFiltersPanel();
searchService.goToPage(12);

expect(searchService.getOpenFilterDropdown()).toBeNull();
expect(searchService.filtersExpanded).toBe(true);
expect(searchService.getPaginationState().pages).toEqual([1, 'ellipsis', 10, 11, 12, 13, 14, 'ellipsis', 19]);
expect(searchService.getPaginationState().startResult).toBe(111);
});
});
26 changes: 26 additions & 0 deletions attack-search/__tests__/search-loader.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
50 changes: 50 additions & 0 deletions attack-search/__tests__/search-style.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');

describe('search styles', () => {
test('marks page type and domain result badges with distinct classes', () => {
const searchService = fs.readFileSync(
path.join(__dirname, '../src/search-service.js'),
'utf8',
);

expect(searchService).toContain('search-result-badge-page-type');
expect(searchService).toContain('search-result-badge-domain');
});

test('renders result badges as subtle prominent chips', () => {
const styles = fs.readFileSync(
path.join(__dirname, '../../attack-style/components/_search.scss'),
'utf8',
);

const badgeStyle = styles.match(/\.search-result-badge\s*\{(?<body>[^}]+)\}/)?.groups?.body ?? '';

expect(badgeStyle).toContain('color: white;');
expect(badgeStyle).toContain('font-size: 0.8rem;');

expect(styles).toContain('.search-result-badge-page-type');
expect(styles).toContain('border-color: color-functions.color(active);');
expect(styles).toContain('background: color-functions.color-alternate(active, 1.5);');
expect(styles).toContain('.search-result-badge-domain');
expect(styles).toContain('border-color: color-functions.color(deemphasis);');
expect(styles).toContain('background: color-functions.color(deemphasis);');
});

test('renders search pagination controls as accessible icon buttons', () => {
const searchService = fs.readFileSync(
path.join(__dirname, '../src/search-service.js'),
'utf8',
);
const styles = fs.readFileSync(
path.join(__dirname, '../../attack-style/components/_search.scss'),
'utf8',
);

expect(searchService).toContain('aria-label="Previous page"');
expect(searchService).toContain('aria-label="Next page"');
expect(searchService).not.toContain('>Prev</button>');
expect(searchService).not.toContain('>Next</button>');
expect(styles).toContain('.search-pagination-icon');
});
});
Loading
Loading