730 lines
57 KiB
TypeScript
730 lines
57 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act, useState } from 'react';
|
||
import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow';
|
||
import { readFileSync } from 'node:fs';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||
import { useApp } from '../../src/app/AppProvider';
|
||
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||
import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar';
|
||
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
|
||
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
||
import { RelatedFindingsProvider } from '../../src/features/related-findings/RelatedFindings';
|
||
import { ActionlistControls } from '../../src/features/actionlist/ActionlistControls';
|
||
import { SidebarSecurityDomains } from '../../src/features/sidebar/SidebarSecurityDomains';
|
||
import { useContextClusterIndex } from '../../src/features/context-layer/use-context-cluster-index';
|
||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
||
import { buildWorkPackages, scannerFindingKey, UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID, type WorkPackage } from '../../src/remediation/remediation-engine';
|
||
import { resetWorkPackageRequestsForTests } from '../../src/remediation/use-work-packages';
|
||
import { workPackageOverview } from '../../src/remediation/work-package-overview';
|
||
import { workPackageIndex } from '../../src/remediation/work-package-index';
|
||
import { ContextHeatmap } from '../../src/features/context-layer/ContextHeatmap';
|
||
import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet';
|
||
import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture';
|
||
|
||
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
|
||
vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: vi.fn(() => undefined) }));
|
||
let host: HTMLDivElement; let root: Root; let app: ReturnType<typeof useApp>; let result: ReturnType<typeof buildWorkPackages>;
|
||
|
||
it('keeps only the table head sticky when scrolling package row headings', async () => {
|
||
const style = document.createElement('style');
|
||
style.textContent = ['base.css', 'components.css'].map(file => readFileSync(`src/styles/${file}`, 'utf8')).join('\n');
|
||
document.head.append(style);
|
||
try {
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const scroll = document.querySelector<HTMLElement>('.work-packages__scroll')!;
|
||
scroll.scrollTop = 300;
|
||
await act(async () => scroll.dispatchEvent(new Event('scroll')));
|
||
expect(getComputedStyle(scroll.querySelector('thead')!).position).toBe('sticky');
|
||
for (const heading of scroll.querySelectorAll('tbody th')) {
|
||
expect(getComputedStyle(heading).position).toBe('static');
|
||
expect(getComputedStyle(heading).zIndex).toBe('auto');
|
||
}
|
||
} finally { style.remove(); }
|
||
});
|
||
function View() {
|
||
const records = useFilteredRecords();
|
||
// Work shows the selected record in the Reader, otherwise the first visible one.
|
||
const selected = records.find(record => record.id === useApp().selectedRecordId) ?? records[0];
|
||
return <><SearchFilterBar/><output data-record-count>{records.length}</output>{selected && <FindingReader
|
||
record={selected} title={selected.title} linkedSheets={[]} resultReferences={[]} toolId="generic" search="" position={0}
|
||
recordCount={records.length} onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/>}</>;
|
||
}
|
||
it.each(['light', 'dark'])('places yellow-outlined Work Packages before CSV and keeps its outline when opened (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const style = document.createElement('style');
|
||
// JSDOM does not resolve custom colour properties; resolve this shared token for its CSS cascade.
|
||
const accent = readFileSync('src/styles/ui-tokens.css', 'utf8').match(/--button-accent:\s*([^;]+);/)![1];
|
||
style.textContent = ['base.css', 'components.css'].map(file => readFileSync(`src/styles/${file}`, 'utf8')).join('\n')
|
||
.replaceAll('var(--button-accent)', accent);
|
||
const expected = document.createElement('span'); expected.style.borderColor = accent;
|
||
document.head.append(style);
|
||
try {
|
||
await act(async () => root.render(<ActionlistControls/>));
|
||
const row = host.querySelector('.header-export-row')!;
|
||
expect([...row.children].filter(node => node.tagName === 'BUTTON').map(node => node.textContent))
|
||
.toEqual(['Work Packages', 'CSV', 'MD', 'HTML']);
|
||
const button = row.querySelector<HTMLButtonElement>('[data-work-packages-open]')!;
|
||
expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor);
|
||
await act(async () => button.click());
|
||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||
expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor);
|
||
await act(async () => button.click());
|
||
expect(document.querySelector('[data-work-packages-window]')).toBeNull();
|
||
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||
await act(async () => button.click());
|
||
expect(document.querySelector('[data-work-packages-window]')).not.toBeNull();
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||
expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor);
|
||
} finally { style.remove(); delete document.documentElement.dataset.theme; }
|
||
});
|
||
beforeEach(() => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests();
|
||
setEngineEnabled('context', false);
|
||
vi.mocked(useContextClusterIndex).mockReturnValue(undefined);
|
||
const sheet = makeSheet('Synthetic', [['Title','Status','Severity','Source Rule ID','Result Message','Source: Scan'],
|
||
['Admin sign-in A','Failed','High','MT.1014','Message A','Synthetic'], ['Admin sign-in B','Failed','High','CISA.MS.AAD.3.6','Message B','Synthetic'],
|
||
['Azure hierarchy','Failed','High','MT.1056','Message C','Synthetic']], '', 'synthetic.csv', { isProjectSheet: true });
|
||
sheet.analysis.records.forEach((r, i) => { r.findingOccurrenceId = `occ-${i}`; });
|
||
const fs = sheet.analysis.records.map(r => ({ key: scannerFindingKey(r.findingOccurrenceId!), occurrenceId: r.findingOccurrenceId, tool: 'maester',
|
||
ruleId: r.fields[3].value, title: r.title, outcome: 'fail' }));
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs,
|
||
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
||
clusters: [{ clusterId: 'cloud-privileged-access-exposure', label: { en: 'Cloud Privileged Access Exposure' } },
|
||
{ clusterId: 'cloud-external-access-exposure', label: { en: 'Cloud External Access Exposure' } }],
|
||
// The third finding belongs to another cluster, so the package boundary yields one two-finding package.
|
||
findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'cloud-privileged-access-exposure' : 'cloud-external-access-exposure'], conditionIds: [] })), settings: [] } });
|
||
app = { activeSheet: sheet, activeProfile: genericProfile, dataset: { fileName: 'Synthetic', sheets: [sheet] }, targetProjectId: 'synthetic',
|
||
activeAssessmentIds: ['a'], activeScanRunIds: ['s'], reviews: {}, search: '', statusFilter: 'all', facetFilters: {}, sortColumn: '', sortDirection: 'asc',
|
||
pathFindingIds: [], actionOnly: false, selectedRecordId: '', setFacetFilter: (id: string, filter: unknown) => {
|
||
app = { ...app, facetFilters: { ...app.facetFilters } }; if (filter) app.facetFilters[id] = filter as never; else delete app.facetFilters[id];
|
||
vi.mocked(useApp).mockReturnValue(app); root.render(<><ActionlistControls/><View/></>);
|
||
}, setSelectedRecord: (id: string) => { app = { ...app, selectedRecordId: id }; vi.mocked(useApp).mockReturnValue(app); root.render(<><ActionlistControls/><View/></>); },
|
||
setStatusFilter: vi.fn(), setActiveSheet: vi.fn(), resetFilters: vi.fn(), setActionOnly: vi.fn() } as unknown as ReturnType<typeof useApp>;
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
|
||
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||
});
|
||
it.each(['light', 'dark'])('minimizes below Source: Scan and restores the retained Work Packages table (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const originalWidth = window.innerWidth;
|
||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 });
|
||
try {
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
const anchor = host.querySelector<HTMLElement>('[aria-label="Source: Scan"]')!;
|
||
expect(anchor).not.toBeNull();
|
||
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({ bottom: 96 } as DOMRect);
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const panel = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
const filter = panel.querySelector<HTMLSelectElement>('[aria-label="Filter Package"]')!;
|
||
await act(async () => { filter.value = filter.options[1].value; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[data-sort-column="findings"]')!.click());
|
||
const before = panel.querySelector('tbody')!.innerHTML;
|
||
const selectedFilter = filter.value;
|
||
const minimize = () => panel.querySelector<HTMLButtonElement>('[aria-label="Minimize Work packages window"]')!.click();
|
||
await act(async () => minimize());
|
||
expect(panel.classList.contains('is-minimized')).toBe(true);
|
||
expect(panel.style.left).toBe('1064px'); expect(panel.style.top).toBe('104px'); expect(panel.style.width).toBe('320px');
|
||
expect(panel.querySelector<HTMLElement>('.floating-data-window-body')!.hidden).toBe(true);
|
||
expect(panel.querySelector('.results-window-resize')).toBeNull();
|
||
expect(panel.querySelector('.floating-data-window-actions')!.lastElementChild!.getAttribute('aria-label')).toBe('Close Work packages window');
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Open Work packages"]')!.click());
|
||
expect(panel.classList.contains('is-minimized')).toBe(false);
|
||
expect(panel.style.left).toBe('554px'); expect(panel.style.width).toBe('830px');
|
||
expect(panel.querySelector<HTMLElement>('.floating-data-window-body')!.hidden).toBe(false);
|
||
expect(filter.value).toBe(selectedFilter); expect(panel.querySelector('tbody')!.innerHTML).toBe(before);
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Maximize Work packages window"]')!.click());
|
||
await act(async () => minimize());
|
||
expect(panel.classList.contains('is-maximized')).toBe(false);
|
||
expect(panel.classList.contains('is-minimized')).toBe(true);
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Restore Work packages window"]')!.click());
|
||
expect(panel.style.left).toBe('554px'); expect(filter.value).toBe(selectedFilter);
|
||
await act(async () => minimize());
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
expect(document.querySelector('.floating-data-window')).toBeNull();
|
||
} finally { Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth }); delete document.documentElement.dataset.theme; }
|
||
});
|
||
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests(); });
|
||
|
||
it.each(['light', 'dark'])('opens Results above Work Packages and brings either clicked window forward while Filters stays above both (%s)', async theme => {
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
|
||
document.documentElement.dataset.theme = theme;
|
||
function Windows() {
|
||
const [shown, setShown] = useState(false);
|
||
return <><ActionlistControls/><SidebarSecurityDomains sheet={app.activeSheet}/>
|
||
<button onClick={() => setShown(true)}>Results</button>
|
||
{shown && <FloatingResultsWindow id="synthetic" title="Results – Synthetic" sheets={[]} color="" zIndex={2100}
|
||
offset={0} search="" onActivate={() => {}} onClose={() => setShown(false)}/>}</>;
|
||
}
|
||
await act(async () => root.render(<Windows/>));
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const packages = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
await act(async () => [...host.querySelectorAll('button')].find(b => b.textContent === 'Results')!.click());
|
||
const results = host.querySelector<HTMLElement>('.results-window')!;
|
||
expect(Number(results.style.zIndex)).toBeGreaterThan(Number(packages.style.zIndex));
|
||
const activate = async (element: Element) => act(async () => element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })));
|
||
for (let index = 0; index < 3; index++) {
|
||
await activate(packages);
|
||
expect(Number(packages.style.zIndex)).toBeGreaterThan(Number(results.style.zIndex));
|
||
await activate(results);
|
||
expect(Number(results.style.zIndex)).toBeGreaterThan(Number(packages.style.zIndex));
|
||
}
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Detach filters"]')!.click());
|
||
const filterLayer = Number(readFileSync('src/styles/ui-tokens.css', 'utf8').match(/--z-floating-filters:\s*(\d+)/)![1]);
|
||
expect(filterLayer).toBeGreaterThan(Number(results.style.zIndex));
|
||
expect(filterLayer).toBeGreaterThan(Number(packages.style.zIndex));
|
||
expect(document.querySelector('.work-filter-panel.is-floating')).not.toBeNull();
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it.each(['light', 'dark'])('shows legacy AP snapshots as WP in every package control and preserves exact selection IDs (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
result.packages.forEach(p => { p.code = p.code.replace(/^WP-/, 'AP-'); });
|
||
const before = JSON.stringify(result);
|
||
await act(async () => root.render(<><ActionlistControls/><View/><SidebarSecurityDomains sheet={app.activeSheet}/></>));
|
||
expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('WP-01');
|
||
const dropdown = host.querySelector<HTMLSelectElement>('[aria-label="Work Package"]')!;
|
||
expect(dropdown.textContent).toContain('WP-01');
|
||
expect(dropdown.textContent).not.toContain('AP-');
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const first = document.querySelector<HTMLButtonElement>('[data-work-package-select]')!;
|
||
expect(first.textContent).toBe('WP-01');
|
||
expect(document.querySelector('[data-work-packages-window]')!.textContent).not.toContain('AP-');
|
||
await act(async () => first.click());
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [result.packages[0].id] });
|
||
expect(JSON.stringify(result)).toBe(before);
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it.each(['light', 'dark'])('uses the actual heatmap positions in groups, both filters and the Reader without renumbering filtered groups (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
setEngineEnabled('context', true);
|
||
let context = emptyHeatmapContext();
|
||
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
|
||
context.clusters.forEach(cluster => { cluster.satisfied = 1; });
|
||
const sheet = makeSheet('Synthetic heatmap order', [['Title', 'Status', 'Severity', 'Source Rule ID'],
|
||
...Array.from({ length: 10 }, (_, i) => [`Synthetic ${i}`, 'Failed', 'High', `order-${i}`])], '', 'order.csv', { isProjectSheet: true });
|
||
const findings = sheet.analysis.records.map((record, i) => {
|
||
record.findingOccurrenceId = `order-${i}`;
|
||
const clusterId = i < 9 ? 'cloud-privileged-access-exposure' : 'credential-relay-exposure';
|
||
const conditionId = context.clusters.find(cluster => cluster.clusterId === clusterId)!.conditions[0].conditionId;
|
||
context.observations.push({ id: record.id, occurrenceId: record.findingOccurrenceId, resultProjectionId: record.id,
|
||
conditionId, tool: 'maester', sourceRuleId: `order-${i}`, ruleText: record.title, outcome: 'fail',
|
||
matchedBy: 'rule-id', assertion: 'observed-by-scanner' } as never);
|
||
context.findings.push({ occurrenceId: record.findingOccurrenceId, resultProjectionId: record.id, tool: 'maester', sourceRuleId: `order-${i}`,
|
||
observationIds: [record.id], conditionIds: [], clusterIds: [clusterId], declaredReferenceIds: [], cisControlAreaIds: [] });
|
||
return { key: scannerFindingKey(record.findingOccurrenceId), occurrenceId: record.findingOccurrenceId, tool: 'maester', ruleId: `order-${i}`, title: record.title, outcome: 'fail' };
|
||
});
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings, context });
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
vi.mocked(useContextClusterIndex).mockReturnValue(contextClusterIndex(context));
|
||
const render = () => root.render(<><ContextHeatmap context={context} onOpenCluster={vi.fn()}/><SidebarSecurityDomains sheet={sheet}/><ActionlistControls/><View/></>);
|
||
app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic order', sheets: [sheet] }, setFacetFilter: (id, filter) => {
|
||
app = { ...app, facetFilters: { ...app.facetFilters } };
|
||
if (filter) app.facetFilters[id] = filter; else delete app.facetFilters[id];
|
||
vi.mocked(useApp).mockReturnValue(app); render();
|
||
} };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
const original = JSON.stringify({ context, result, records: sheet.analysis.records });
|
||
await act(async () => render());
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const heatmapLabels = new Map([...host.querySelectorAll<HTMLElement>('[data-cluster]')].map(cell => [cell.dataset.cluster!, `${cell.querySelector('.context-heatmap__number')!.textContent} · ${cell.querySelector('strong')!.textContent}`]));
|
||
expect(heatmapLabels.get('cloud-privileged-access-exposure')).toBe('01 · Cloud Privileged Access Exposure');
|
||
expect(heatmapLabels.get('credential-relay-exposure')).toBe('05 · Credential Relay Exposure');
|
||
const groups = [...document.querySelectorAll<HTMLElement>('[data-work-package-cluster]')];
|
||
expect(groups.map(group => group.textContent)).toEqual(['01 · Cloud Privileged Access Exposure', '05 · Credential Relay Exposure']);
|
||
for (const group of groups) expect(group.textContent).toBe(heatmapLabels.get(group.dataset.workPackageCluster!));
|
||
expect([...document.querySelectorAll('[data-work-package-select]')].map(button => button.textContent)).toEqual(['WP-01', 'WP-02', 'WP-01']);
|
||
expect([...host.querySelectorAll('.work-package-cluster-head > span:first-child')].map(node => node.textContent)).toEqual(groups.map(group => group.textContent));
|
||
for (const facetLabel of ['Context Cluster', 'Work Package']) {
|
||
const labels = [...host.querySelector<HTMLSelectElement>(`select[aria-label="${facetLabel}"]`)!.options].slice(1).map(option => option.textContent);
|
||
expect(labels[0]).toMatch(/^01 · Cloud Privileged Access Exposure/);
|
||
expect(labels.some(label => label?.startsWith('05 · Credential Relay Exposure'))).toBe(true);
|
||
}
|
||
expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('01 · Cloud Privileged Access Exposure → WP-01');
|
||
const relay = result.packages.find(p => p.clusterId === 'credential-relay-exposure')!;
|
||
await act(async () => document.querySelector<HTMLButtonElement>(`[data-work-package-select="${relay.id}"]`)!.click());
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [relay.id] });
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('1');
|
||
expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('05 · Credential Relay Exposure → WP-01');
|
||
expect([...document.querySelectorAll('[data-work-package-cluster]')].map(group => group.textContent)).toEqual(groups.map(group => group.textContent));
|
||
const tableFilter = document.querySelector<HTMLSelectElement>('[aria-label="Filter Package"]')!;
|
||
await act(async () => { tableFilter.value = relay.id; tableFilter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect([...document.querySelectorAll('[data-work-package-cluster]')].map(group => group.textContent)).toEqual(['05 · Credential Relay Exposure']);
|
||
expect(JSON.stringify({ context, result, records: sheet.analysis.records })).toBe(original);
|
||
// A different published tie order must update every visible position, retaining the selected package ID.
|
||
context = { ...context, clusters: [...context.clusters].reverse() };
|
||
vi.mocked(useContextClusterIndex).mockReturnValue(contextClusterIndex(context));
|
||
await act(async () => render());
|
||
const relayTile = host.querySelector('[data-cluster="credential-relay-exposure"]')!;
|
||
const relayHeatmapLabel = `${relayTile.querySelector('.context-heatmap__number')!.textContent} · ${relayTile.querySelector('strong')!.textContent}`;
|
||
expect(relayHeatmapLabel).not.toBe('05 · Credential Relay Exposure');
|
||
expect(document.querySelector('[data-work-package-cluster]')!.textContent).toBe(relayHeatmapLabel);
|
||
expect(tableFilter.value).toBe(relay.id);
|
||
expect(host.querySelector('[data-work-package-id]')!.textContent).toContain(`${relayHeatmapLabel} → WP-01`);
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it.each(['dark', 'light'])('selects the real Work Package filter, displays the flag beside Source ID and removes Result Message in %s', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
await act(async () => { root.render(<><ActionlistControls/><View/></>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('3');
|
||
expect(host.querySelector('select[aria-label="Result Message"]')).toBeNull();
|
||
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Work Package"]')!;
|
||
const selected = result.packages.find(p => p.findingKeys.length === 2)!;
|
||
expect(select.options.namedItem(selected.id)).toBeNull();
|
||
expect([...select.options].find(o => o.value === selected.id)?.textContent).toContain('· 2');
|
||
await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
const source = host.querySelector('[data-source-rule-id]')!;
|
||
expect(source.closest('.reader-summary')).not.toBeNull();
|
||
expect(source.querySelector('h4')?.textContent).toBe('Source Rule ID');
|
||
expect(source.querySelector('code.mono')).not.toBeNull();
|
||
expect(host.querySelector('.reader-heading [data-source-rule-id]')).toBeNull();
|
||
expect(host.querySelector('.finding-badges [data-work-package-id]')).toBeNull();
|
||
const link = source.querySelector<HTMLAnchorElement>('a[data-work-package-id]')!;
|
||
expect(link.textContent).toBe(`Cloud Privileged Access Exposure → ${selected.code}`);
|
||
expect(link.classList.contains('status-badge')).toBe(false);
|
||
const badges = host.querySelector('.finding-badges')!;
|
||
expect(badges.firstElementChild?.className).toBe('outcome-mark');
|
||
expect(badges.querySelector('.outcome-mark')?.getAttribute('data-outcome')).toBe('fail');
|
||
expect(badges.querySelector('.severity-badge.severity-high')?.textContent).toBe('High');
|
||
await act(async () => app.setFacetFilter(WORK_PACKAGE_FACET_ID, null));
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('3');
|
||
await act(async () => host.querySelector<HTMLAnchorElement>('a[data-work-package-id]')!.click());
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [selected.id] });
|
||
expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(1);
|
||
await act(async () => setEngineEnabled('remediation', false));
|
||
expect(host.querySelector('select[aria-label="Work Package"]')).toBeNull();
|
||
expect(host.querySelector('[data-work-package-id]')).toBeNull();
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toBeUndefined();
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('3');
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
it('suppresses late output after disabling the engine and makes no new request', async () => {
|
||
let resolve!: (value: { status: 'ready'; result: typeof result }) => void;
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockReturnValue(new Promise(r => { resolve = r; }));
|
||
await act(async () => root.render(<View/>));
|
||
await act(async () => setEngineEnabled('remediation', false));
|
||
await act(async () => resolve({ status: 'ready', result }));
|
||
expect(host.querySelector('[data-work-package-id]')).toBeNull();
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('3');
|
||
expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('offers a shared non-Cloud package and selects every associated finding through the real Work filter', async () => {
|
||
const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!),
|
||
occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: `synthetic-${i}`,
|
||
title: r.title, outcome: 'fail' }));
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs,
|
||
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
||
clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } },
|
||
{ clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }],
|
||
findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'],
|
||
conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'schema-admins-not-empty'] })), settings: [] } });
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
await act(async () => root.render(<View/>));
|
||
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Work Package"]')!;
|
||
const selected = result.packages.find(p => p.findingKeys.length === 2)!;
|
||
expect([...select.options].find(o => o.value === selected.id)?.textContent).toContain('· 2');
|
||
await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
expect(host.querySelector('[data-work-package-id]')?.textContent).toBe(`AD Privileged Account Exposure → ${selected.code}`);
|
||
});
|
||
|
||
it('keeps a selected package from showing unrelated findings while a changed scope loads', async () => {
|
||
await act(async () => root.render(<View/>));
|
||
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Work Package"]')!;
|
||
const selected = result.packages.find(p => p.findingKeys.length === 2)!;
|
||
await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockReturnValue(new Promise(() => {}));
|
||
app = { ...app, dataset: { ...app.dataset! } };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => root.render(<View/>));
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('0');
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toBeDefined();
|
||
});
|
||
|
||
it.each(['dark', 'light'])('opens the sortable package list from the Work toolbar, selects a package and shows its redundant task in the Reader in %s', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!),
|
||
occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: r.fields[3].value,
|
||
title: r.title, outcome: 'fail', severity: i === 0 ? 'high' : 'critical' }));
|
||
// Both sources report the same specific condition once: one redundant task. The third finding sits in another cluster.
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs,
|
||
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
||
clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } },
|
||
{ clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }],
|
||
findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'],
|
||
conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'ad-stale-admincount'] })), settings: [] } });
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
await act(async () => { root.render(<><ActionlistControls/><View/></>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||
const redundant = result.packages.find(p => p.facts.redundantFindings === 2)!;
|
||
expect(redundant.tasks).toHaveLength(1);
|
||
expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('false');
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('true');
|
||
expect(document.querySelector('.floating-data-window-head.is-marked')?.textContent).toContain('Work packages');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('false');
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const window = document.querySelector('[data-work-packages-window]')!;
|
||
expect(window.querySelector('th[aria-sort="ascending"] [data-sort-column="package"]')).not.toBeNull();
|
||
expect(window.querySelector(`[data-work-package-select="${redundant.id}"]`)?.textContent).toBe(redundant.code);
|
||
expect(window.querySelector(`[data-work-package-select="${redundant.id}"]`)?.getAttribute('aria-label')).toBe(`AD Privileged Account Exposure → ${redundant.code}`);
|
||
expect(window.querySelectorAll('[data-work-package-row]')).toHaveLength(2);
|
||
expect(window.querySelectorAll('[data-work-package-unassigned]')).toHaveLength(1);
|
||
expect(window.querySelector<HTMLButtonElement>('[data-work-package-select-unassigned]')!.disabled).toBe(true);
|
||
await act(async () => window.querySelector<HTMLButtonElement>('[data-sort-column="findings"]')!.click());
|
||
expect(window.querySelector('th[aria-sort="descending"] [data-sort-column="findings"]')).not.toBeNull();
|
||
expect(window.querySelector('[data-work-package-row]')?.getAttribute('data-work-package-row')).toBe(result.packages.find(p => p.clusterId === 'ad-persistence-indicators')!.id);
|
||
expect(window.querySelector('[data-sort-column="severity"]')).toBeNull();
|
||
await act(async () => window.querySelector<HTMLButtonElement>(`[data-work-package-select="${redundant.id}"]`)!.click());
|
||
expect(document.querySelector('[data-work-packages-window]')).not.toBeNull();
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [redundant.id] });
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('true');
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
const detail = host.querySelector('[data-work-package-task]')!;
|
||
expect(detail.textContent).toContain('One of 1 task');
|
||
expect(detail.textContent).not.toMatch(/Task \d+ of/);
|
||
expect(host.querySelector('[data-work-package-redundant]')?.textContent).toBe(' · Same change as Synthetic CISA.MS.AAD.3.6');
|
||
expect(host.querySelector('a[data-work-package-id]')?.textContent).toBe(`AD Privileged Account Exposure → ${redundant.code}`);
|
||
await act(async () => setEngineEnabled('remediation', false));
|
||
expect(host.querySelector('[data-work-packages-open]')).toBeNull();
|
||
expect(host.querySelector('[data-work-package-task]')).toBeNull();
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it.each(['dark', 'light'])('offers selectable values without Measure, finding-severity or Critical/High filters (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const panel = document.querySelector('[data-work-packages-window]')!;
|
||
const before = JSON.stringify(result);
|
||
const filter = async (label: string, value: string) => act(async () => {
|
||
const input = panel.querySelector<HTMLSelectElement>(`select[aria-label="Filter ${label}"]`)!;
|
||
value = [...input.options].find(option => option.textContent === value)?.value ?? value;
|
||
expect([...input.options].some(option => option.value === value)).toBe(true);
|
||
input.value = value;
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
expect(panel.querySelector('input')).toBeNull();
|
||
expect(panel.querySelector('[aria-label="Filter Measure"]')).toBeNull();
|
||
expect(panel.querySelector('[aria-label="Filter Highest finding severity"]')).toBeNull();
|
||
expect(panel.querySelector('[aria-label="Filter Critical/High"]')).toBeNull();
|
||
expect(panel.querySelector('[data-sort-column="severity"]')).toBeNull();
|
||
expect(panel.querySelector('[data-sort-column="criticalHigh"]')).toBeNull();
|
||
const inputs = [...panel.querySelectorAll<HTMLSelectElement>('.work-packages__filter')];
|
||
expect(inputs).toHaveLength(8);
|
||
for (const input of inputs) {
|
||
const label = input.getAttribute('aria-label')!.replace('Filter ', '');
|
||
await filter(label, input.options[1].value);
|
||
expect(panel.querySelector('tbody tr')).not.toBeNull();
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('3');
|
||
await filter(label, '');
|
||
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(2);
|
||
}
|
||
await filter('Tasks', '2');
|
||
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1);
|
||
await filter('Package', 'Cloud External Access Exposure → WP-01');
|
||
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(0);
|
||
await filter('Tasks', '');
|
||
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1);
|
||
expect(panel.querySelector('[data-work-package-cluster]')?.textContent).toContain('Cloud External Access Exposure');
|
||
await act(async () => [...panel.querySelectorAll<HTMLButtonElement>('button')].find(b => b.textContent === 'Clear column filters')!.click());
|
||
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(2);
|
||
expect(panel.querySelector('[data-work-package-unassigned]')).not.toBeNull();
|
||
expect(panel.querySelector('[data-work-package-filter-count]')).toBeNull();
|
||
expect(JSON.stringify(result)).toBe(before);
|
||
expect(app.facetFilters).toEqual({});
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it.each(['dark', 'light'])('keeps numbered groups in published heatmap order through filtering and column sorting (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const clusters = [
|
||
['credential-relay-exposure', 'Credential Relay Exposure', '01'],
|
||
['cloud-privileged-access-exposure', 'Cloud Privileged Access Exposure', '02'],
|
||
['cloud-sign-in-protection-gaps', 'Cloud Sign-in Protection Gaps', '03'],
|
||
['malicious-code-execution-exposure', 'Malicious Code Execution Exposure', '12']
|
||
];
|
||
const sheet = makeSheet('Synthetic order', [['Title', 'Status', 'Severity', 'Source Rule ID'],
|
||
...Array.from({ length: 12 }, (_, i) => [`Finding ${i}`, 'Failed', i === 11 ? 'Critical' : 'High', `synthetic-${i}`])], '', 'order.csv', { isProjectSheet: true });
|
||
sheet.analysis.records.forEach((r, i) => { r.findingOccurrenceId = `order-${i}`; });
|
||
const findings = sheet.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!), occurrenceId: r.findingOccurrenceId,
|
||
tool: 'maester', ruleId: `synthetic-${i}`, title: r.title, outcome: 'fail', severity: i === 11 ? 'critical' : 'high' }));
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings,
|
||
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
||
clusters: clusters.map(([clusterId, label]) => ({ clusterId, label: { en: label } })),
|
||
findings: findings.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [clusters[i < 9 ? 0 : i - 8][0]], conditionIds: [] })), settings: [] } });
|
||
app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic order', sheets: [sheet] } };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
vi.mocked(useContextClusterIndex).mockReturnValue({ publication: { clusters: [clusters[1], clusters[3], clusters[0], clusters[2]].map(([id, label], i) => ({ id, label, hasEvidence: true, severity: i < 2 ? 'critical' : 'high' })) },
|
||
clusters: [], facet: { id: 'context:cluster', label: 'Context Cluster', kind: 'category', source: 'derived', values: [], valuesFor: () => [] } } as never);
|
||
await act(async () => root.render(<ActionlistControls/>));
|
||
const open = async () => act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const labels = () => [...document.querySelectorAll('[data-work-package-select]')].map(el => el.getAttribute('aria-label'));
|
||
const expected = [
|
||
'01 · Cloud Privileged Access Exposure → WP-01', '02 · Malicious Code Execution Exposure → WP-01',
|
||
'03 · Credential Relay Exposure → WP-01', '03 · Credential Relay Exposure → WP-02',
|
||
'04 · Cloud Sign-in Protection Gaps → WP-01'
|
||
];
|
||
await open();
|
||
expect(labels()).toEqual(expected);
|
||
expect([...document.querySelectorAll('[data-work-package-cluster]')].map(el => el.textContent)).toEqual([
|
||
'01 · Cloud Privileged Access Exposure', '02 · Malicious Code Execution Exposure', '03 · Credential Relay Exposure', '04 · Cloud Sign-in Protection Gaps']);
|
||
expect([...document.querySelectorAll('[data-work-package-select]')].map(el => el.textContent)).toEqual(['WP-01', 'WP-01', 'WP-01', 'WP-02', 'WP-01']);
|
||
const filter = document.querySelector<HTMLSelectElement>('[aria-label="Filter Package"]')!;
|
||
await act(async () => { filter.value = [...filter.options].find(option => option.textContent === expected[1])!.value; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(labels()).toEqual([expected[1]]);
|
||
await act(async () => { filter.value = ''; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(labels()).toEqual(expected);
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[data-sort-column="contextSeverity"]')!.click());
|
||
expect(labels()).toEqual(expected);
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[data-sort-column="package"]')!.click());
|
||
expect(labels()).toEqual(expected);
|
||
expect(document.querySelector('.work-packages__table tbody tr:last-child')?.hasAttribute('data-work-package-unassigned')).toBe(true);
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
await open();
|
||
expect(labels()).toEqual(expected);
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it('updates an open package overview after Context preparation and dataset refresh, suppressing an older late response', async () => {
|
||
const contextRequest = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue({
|
||
findings: [], settings: [], clusters: [], coverage: { uncataloguedWithoutRecognition: 0, scannerFindings: 0 },
|
||
engine: { version: 'synthetic' }, catalog: { version: 'synthetic' }, repositoryRevision: 2
|
||
} as never);
|
||
await act(async () => setEngineEnabled('context', true));
|
||
const render = () => root.render(<><ActionlistControls/><View/></>);
|
||
await act(async () => render());
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
expect(document.querySelectorAll('[data-work-package-row]')).toHaveLength(2);
|
||
let resolveOld!: (response: { status: 'ready'; result: typeof result }) => void;
|
||
let resolveNew!: typeof resolveOld;
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages)
|
||
.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
|
||
.mockReturnValueOnce(new Promise(resolve => { resolveNew = resolve; }));
|
||
app = { ...app, dataset: { ...app.dataset! } }; vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => render());
|
||
expect(document.querySelector('[data-work-packages-window]')).toBeNull();
|
||
app = { ...app, dataset: { ...app.dataset! } }; vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => render());
|
||
await act(async () => resolveOld({ status: 'ready', result }));
|
||
expect(document.querySelector('[data-work-packages-window]')).toBeNull();
|
||
const p = result.packages.find(p => p.findingKeys.length === 1)!;
|
||
const next = { ...result, repositoryRevision: 2, packages: [{ ...p, title: 'Refreshed published measure' }],
|
||
unassignedFindingKeys: result.packages.filter(other => other.id !== p.id).flatMap(other => other.findingKeys) };
|
||
await act(async () => resolveNew({ status: 'ready', result: next }));
|
||
expect(document.querySelectorAll('[data-work-package-row]')).toHaveLength(1);
|
||
expect(document.querySelector('[data-work-packages-window]')?.textContent).toContain('Refreshed published measure');
|
||
expect(document.querySelector('[data-work-package-unassigned-summary]')?.textContent).toBe('2');
|
||
expect(contextRequest).toHaveBeenCalledTimes(3);
|
||
expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(3);
|
||
await act(async () => setEngineEnabled('remediation', false));
|
||
expect(document.querySelector('[data-work-packages-window]')).toBeNull();
|
||
expect(host.querySelector('[data-work-packages-open]')).toBeNull();
|
||
});
|
||
|
||
it.each(['dark', 'light'])('keeps the full published package contents and compact totals across Work search, status, package filters and reopening (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const sheet = makeSheet('Synthetic reconciliation', [['Title', 'Status', 'Severity', 'Source Rule ID', 'Source: Scan'],
|
||
...Array.from({ length: 274 }, (_, i) => [i === 0 ? 'Only matched declaration' : i < 258 ? `Assigned ${i}` : `Unassigned ${i - 258}`,
|
||
'Failed', 'High', `synthetic-${i}`, 'Synthetic'])], '', 'synthetic.csv', { isProjectSheet: true });
|
||
sheet.analysis.records.forEach((record, i) => { record.findingOccurrenceId = `reconcile-${i}`; });
|
||
const keys = sheet.analysis.records.map(record => scannerFindingKey(record.findingOccurrenceId!));
|
||
let nextFinding = 0;
|
||
const packages: WorkPackage[] = Array.from({ length: 49 }, (_, packageIndex) => {
|
||
const tasks = Array.from({ length: packageIndex < 7 ? 6 : 5 }, (_, taskIndex) => {
|
||
const findingKeys = keys.slice(nextFinding, nextFinding + (packageIndex === 0 ? 2 : 1));
|
||
nextFinding += findingKeys.length;
|
||
return { id: `task-${packageIndex}-${taskIndex}`, findingKeys, redundant: findingKeys.length > 1 };
|
||
});
|
||
const findingKeys = tasks.flatMap(task => task.findingKeys);
|
||
return { id: `package-${packageIndex}`, code: `WP-${String(packageIndex + 1).padStart(2, '0')}`,
|
||
clusterId: 'ad-privileged-account-exposure', clusterLabel: 'Synthetic cluster', platform: 'active-directory',
|
||
title: 'Synthetic published change', tasks, findingKeys, measures: [], method: 'individual', reason: 'Synthetic published task identities',
|
||
facts: { tasks: tasks.length, findings: findingKeys.length, redundantFindings: packageIndex === 0 ? 12 : 0,
|
||
highestSeverity: 'high', criticalOrHighFindings: findingKeys.length, conditionsClosed: 0, conditionsTouched: 0, sources: ['Synthetic'] } };
|
||
});
|
||
expect(nextFinding).toBe(258);
|
||
result = { ...result, packages, unassignedFindingKeys: keys.slice(258) };
|
||
app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic reconciliation', sheets: [sheet] }, statusFilter: 'deviation',
|
||
facetFilters: {}, search: '', reviews: {} };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
const originalRecords = JSON.stringify(sheet.analysis.records), originalPackages = JSON.stringify(result);
|
||
const openOverview = async () => {
|
||
await act(async () => { root.render(<><ActionlistControls/><View/></>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||
if (!document.querySelector('[data-work-packages-window]'))
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
return document.querySelector('[data-work-packages-window]')!;
|
||
};
|
||
let window = await openOverview();
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('274');
|
||
const facts = () => Object.fromEntries([...window.querySelectorAll('.work-packages__facts > div')].map(cell =>
|
||
[cell.querySelector('dt')!.textContent, cell.querySelector('dd')!.textContent]));
|
||
const expectedFacts = { Findings: '274', Assigned: '258', Unassigned: '16', Packages: '49', Tasks: '252',
|
||
'Redundant findings': '12' };
|
||
const originalTable = window.querySelector('tbody')!.innerHTML;
|
||
const unchanged = () => {
|
||
expect(facts()).toEqual(expectedFacts);
|
||
expect(window.querySelectorAll('[data-work-package-row]')).toHaveLength(49);
|
||
expect(window.querySelector('tbody')!.innerHTML).toBe(originalTable);
|
||
};
|
||
unchanged();
|
||
app = { ...app, search: 'Unassigned' }; vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('16');
|
||
unchanged();
|
||
await act(async () => window.querySelector<HTMLButtonElement>(theme === 'dark' ? '[data-work-package-select-unassigned]' : '[data-work-package-unassigned-summary]')!.click());
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [UNASSIGNED_WORK_PACKAGE] });
|
||
expect(app.statusFilter).toBe('deviation');
|
||
expect(app.search).toBe('Unassigned');
|
||
expect(document.querySelector('[data-work-packages-window]')).not.toBeNull();
|
||
window = await openOverview();
|
||
unchanged();
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
app = { ...app, facetFilters: {}, search: 'Only matched declaration' }; vi.mocked(useApp).mockReturnValue(app);
|
||
window = await openOverview();
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('1');
|
||
unchanged();
|
||
app = { ...app, statusFilter: 'passed', search: 'no matching finding' }; vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => root.render(<><ActionlistControls/><View/></>));
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('0');
|
||
unchanged();
|
||
expect(JSON.stringify(sheet.analysis.records)).toBe(originalRecords);
|
||
expect(JSON.stringify(result)).toBe(originalPackages);
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
it('keeps records outside the published package snapshot separately visible in the reconciliation', () => {
|
||
const records = app.activeSheet!.analysis.records;
|
||
const outside = { ...records[0], id: 'outside-snapshot', findingOccurrenceId: 'outside-snapshot' };
|
||
const before = JSON.stringify(result);
|
||
const overview = workPackageOverview([...records, outside], workPackageIndex(result));
|
||
expect(overview.records).toBe(4);
|
||
expect(overview.assigned).toBe(3); expect(overview.unassigned).toBe(0); expect(overview.outsideSnapshot).toBe(1);
|
||
expect(overview.assigned + overview.unassigned + overview.outsideSnapshot).toBe(overview.records);
|
||
expect(JSON.stringify(result)).toBe(before);
|
||
});
|
||
|
||
it('opens the redundant finding of the same task with only the package filter changed, and reports a finding hidden by other Work filters', async () => {
|
||
const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!),
|
||
occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: r.fields[3].value, title: r.title, outcome: 'fail' }));
|
||
result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs,
|
||
context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1,
|
||
clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } },
|
||
{ clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }],
|
||
findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'],
|
||
conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'ad-stale-admincount'] })), settings: [] } });
|
||
vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result });
|
||
const [first, second] = app.activeSheet!.analysis.records;
|
||
const redundant = result.packages.find(p => p.facts.redundantFindings === 2)!;
|
||
await act(async () => { root.render(<><ActionlistControls/><View/></>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||
// Visible case: no package filter yet; the click sets it and selects the other finding.
|
||
const link = host.querySelector<HTMLAnchorElement>(`[data-work-package-finding="${second.id}"]`)!;
|
||
expect(link.textContent).toBe('Synthetic CISA.MS.AAD.3.6');
|
||
await act(async () => link.click());
|
||
expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [redundant.id] });
|
||
expect(app.selectedRecordId).toBe(second.id);
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
||
expect(host.querySelector(`[data-work-package-finding="${first.id}"]`)?.textContent).toBe('Synthetic MT.1014');
|
||
// Hidden case: a search keeps only the current finding; the click reports it and changes nothing.
|
||
app = { ...app, search: 'Admin sign-in B', facetFilters: {}, selectedRecordId: '' };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
await act(async () => root.render(<View/>));
|
||
expect(host.querySelector('[data-record-count]')?.textContent).toBe('1');
|
||
await act(async () => host.querySelector<HTMLAnchorElement>(`[data-work-package-finding="${first.id}"]`)!.click());
|
||
expect(host.querySelector('[data-work-package-hidden]')?.textContent).toBe(' · Synthetic MT.1014 is hidden by the current Work filters.');
|
||
expect(app.facetFilters).toEqual({});
|
||
expect(app.search).toBe('Admin sign-in B');
|
||
expect(app.selectedRecordId).toBe('');
|
||
});
|
||
|
||
it.each(['dark', 'light'])('matches Context severity between Filters and Packages, including GPO and live updates (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
const p = result.packages.find(p => p.findingKeys.length === 2)!;
|
||
p.platform = 'windows-host'; p.facts.highestSeverity = undefined;
|
||
const cluster = { id: p.clusterId, severity: 'critical', hasEvidence: true };
|
||
const index = { publication: { clusters: [cluster] }, clusters: [], facet: {
|
||
id: 'context:cluster', label: 'Context Cluster', kind: 'category', source: 'derived', values: [], valuesFor: () => []
|
||
} } as unknown as NonNullable<ReturnType<typeof useContextClusterIndex>>;
|
||
vi.mocked(useContextClusterIndex).mockReturnValue(index);
|
||
const render = () => root.render(<><SidebarSecurityDomains sheet={app.activeSheet}/><ActionlistControls/></>);
|
||
await act(async () => render());
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const row = () => document.querySelector('[data-work-package-row="' + p.id + '"]')!;
|
||
expect(document.querySelector('[data-work-package-row]')?.getAttribute('data-work-package-row')).toBe(p.id);
|
||
expect(row().querySelector('[data-context-severity="critical"]')?.textContent).toBe('Critical');
|
||
expect(document.querySelector('.work-package-cluster-colour.severity-critical')).not.toBeNull();
|
||
expect(document.querySelector('[data-sort-column="severity"]')).toBeNull();
|
||
cluster.severity = 'medium';
|
||
vi.mocked(useContextClusterIndex).mockReturnValue({ ...index });
|
||
await act(async () => render());
|
||
expect(row().querySelector('[data-context-severity="medium"]')?.textContent).toBe('Medium');
|
||
expect(document.querySelector('.work-package-cluster-colour.severity-medium')).not.toBeNull();
|
||
cluster.hasEvidence = false;
|
||
await act(async () => render());
|
||
expect(row().querySelector('[data-context-severity="undetermined"]')?.textContent).toBe('Undetermined');
|
||
expect(document.querySelector('.work-package-cluster-colour.severity-undetermined')).not.toBeNull();
|
||
expect(p.facts.highestSeverity).toBeUndefined();
|
||
delete document.documentElement.dataset.theme;
|
||
});
|
||
|
||
|
||
it.each(['light', 'dark'])('opens all same-change evidence in Work from the summary and parks Packages with matching cluster squares (%s)', async theme => {
|
||
document.documentElement.dataset.theme = theme;
|
||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 });
|
||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1200 });
|
||
const p = result.packages.find(p => p.findingKeys.length === 2)!;
|
||
p.tasks = [{ ...p.tasks[0], findingKeys: p.findingKeys, redundant: true }];
|
||
p.facts.tasks = 1; p.facts.redundantFindings = 2;
|
||
app = { ...app, activeSheetId: app.activeSheet!.id, workScopeId: 'synthetic', search: 'Azure hierarchy', actionOnly: false,
|
||
applyViewBookmark: saved => { app = { ...app, ...saved }; vi.mocked(useApp).mockReturnValue(app); } };
|
||
vi.mocked(useApp).mockReturnValue(app);
|
||
const render = () => root.render(<RelatedFindingsProvider><ActionlistControls/><View/></RelatedFindingsProvider>);
|
||
await act(async () => { render(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('1');
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
const panel = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
expect(panel.style.width).toBe('830px'); expect(panel.style.height).toBe('1080px');
|
||
expect(panel.style.left).toBe('554px'); expect(panel.style.top).toBe('60px');
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Maximize Work packages window"]')!.click());
|
||
expect(panel.classList.contains('is-maximized')).toBe(true);
|
||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1600 });
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Restore Work packages window"]')!.click());
|
||
expect(panel.style.left).toBe('754px'); expect(panel.style.width).toBe('830px');
|
||
await act(async () => panel.querySelector('.floating-data-window-head')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
||
expect(panel.classList.contains('is-maximized')).toBe(true);
|
||
await act(async () => panel.querySelector('.floating-data-window-head')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
||
expect(panel.style.left).toBe('754px');
|
||
expect(panel.querySelectorAll('[data-work-package-colour]')).toHaveLength(0);
|
||
for (const row of panel.querySelectorAll('[data-work-package-row]'))
|
||
expect(row.querySelector('.work-package-cluster-colour')).toBeNull();
|
||
expect(panel.querySelectorAll('.work-packages__cluster .work-package-cluster-colour')).toHaveLength(new Set(result.packages.map(p => p.clusterId)).size);
|
||
const before = JSON.stringify(result);
|
||
const link = panel.querySelector<HTMLButtonElement>('[data-work-package-related-summary]')!;
|
||
expect(link.textContent).toBe('2'); expect(link.disabled).toBe(false);
|
||
await act(async () => { link.click(); render(); });
|
||
expect(document.querySelector('[data-work-packages-window]')).not.toBeNull();
|
||
expect(panel.style.left).toBe('754px');
|
||
expect(app.pathFindingIds).toHaveLength(2); expect(app.search).toBe(''); expect(app.facetFilters).toEqual({});
|
||
expect(app.statusFilter).toBe('all'); expect(app.view).toBe('records'); expect(app.actionOnly).toBe(false);
|
||
expect(host.querySelector('[data-record-count]')!.textContent).toBe('2');
|
||
expect(JSON.stringify(result)).toBe(before);
|
||
await act(async () => panel.querySelector<HTMLButtonElement>('[aria-label="Close Work packages window"]')!.click());
|
||
expect(document.querySelector('[data-work-packages-window]')).toBeNull();
|
||
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-packages-open]')!.click());
|
||
expect(document.querySelector<HTMLElement>('.floating-data-window')!.style.left).toBe('754px');
|
||
delete document.documentElement.dataset.theme;
|
||
});
|