Assessor_2.0/tests/features/client-security-panel.test.tsx

347 lines
28 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { readFileSync } from 'node:fs';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useApp } from '../../src/app/AppProvider';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { ClientSecurityPanel } from '../../src/features/dashboard/ClientSecurityPanel';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture';
import { reportHeaderFacts } from '../../src/reporting/report-header-facts';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); });
describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
it('dims unrelated domain flows, opens exactly the related categories and toggles all categories without changing findings', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ...Array.from({ length: 4 }, (_, index) => [`Synthetic ${index}`, 'Failed', 'High'])], '', 'synthetic.csv', { isProjectSheet: true });
const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'generic', scanTool: 'Generic', sheets: [sheet] };
const first = { id: 'identity', label: 'Identity & Authentication', count: 2 };
const second = { id: 'hardening', label: 'System Hardening', count: 2 };
const groups = [
{ id: 'one', label: 'First category', severity: 'high', records: sheet.analysis.records.slice(0, 1), domains: [{ ...first, count: 1 }] },
{ id: 'shared', label: 'Shared category', severity: 'high', records: sheet.analysis.records.slice(1, 3), domains: [{ ...first, count: 1 }, { ...second, count: 1 }] },
{ id: 'other', label: 'Other category', severity: 'high', records: sheet.analysis.records.slice(3), domains: [{ ...second, count: 1 }] },
{ id: 'empty', label: 'Empty category', severity: 'unknown', records: [], domains: [{ ...first, count: 0 }] }
];
const original = JSON.stringify({ dataset, groups });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const style = document.createElement('style'); style.textContent = readFileSync('src/styles/client-security.css', 'utf8'); document.head.append(style);
try {
await act(async () => root.render(<ClientSecurityPanel dataset={dataset} sheetId={sheet.id} groups={groups} domains={[first, second]} total={4} contextStatus="ready"/>));
const domain = host.querySelector<SVGGElement>('.client-security-map-domain[data-domain="identity"]')!;
const expanded = () => [...host.querySelectorAll('.client-security-group')].filter(group => group.querySelector('button')!.getAttribute('aria-expanded') === 'true').map(group => group.getAttribute('data-cluster'));
expect(expanded()).toEqual([]);
await act(async () => domain.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
expect([...host.querySelectorAll('.client-security-flow.is-muted')].every(flow => flow.getAttribute('data-domain') === 'hardening')).toBe(true);
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
expect(getComputedStyle(host.querySelector('.client-security-flow.is-muted')!).opacity).toBe('0.08');
expect(getComputedStyle(host.querySelector('.client-security-flow[data-domain="identity"]')!).opacity).toBe('0.5');
expect(host.querySelector('.client-security-map-domain[data-domain="hardening"]')!.classList.contains('is-muted')).toBe(true);
expect([...host.querySelectorAll('.client-security-map-category.is-muted')].map(e => e.getAttribute('data-category'))).toEqual(['other', 'empty']);
expect(host.querySelector('.client-security-map-category[data-category="shared"]')!.classList.contains('is-muted')).toBe(false);
await act(async () => domain.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(0);
const sharedCategory = host.querySelector('.client-security-map-category[data-category="shared"]')!;
await act(async () => sharedCategory.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
expect([...host.querySelectorAll('.client-security-flow:not(.is-muted)')].map(e => e.getAttribute('data-category'))).toEqual(['shared', 'shared']);
expect(host.querySelectorAll('.client-security-map-domain.is-muted')).toHaveLength(0);
expect([...host.querySelectorAll('.client-security-map-category.is-muted')].map(e => e.getAttribute('data-category'))).toEqual(['one', 'other', 'empty']);
expect(getComputedStyle(host.querySelector('.client-security-map-category.is-muted')!).opacity).toBe('0.18');
await act(async () => sharedCategory.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })));
await act(async () => sharedCategory.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
await act(async () => sharedCategory.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-map-category.is-muted')).toHaveLength(0);
await act(async () => domain.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(expanded()).toEqual(['one', 'shared']);
const bulk = host.querySelector<HTMLButtonElement>('.client-security-toggle-all')!;
expect(getComputedStyle(bulk.querySelector('svg')!).flexShrink).toBe('0');
expect(bulk.closest('.client-security-category-heading')!.textContent).toBe('Category');
expect(bulk.getAttribute('aria-label')).toBe('Expand all categories');
await act(async () => bulk.click()); expect(expanded()).toEqual(['one', 'shared', 'other', 'empty']);
expect(bulk.getAttribute('aria-label')).toBe('Collapse all categories');
await act(async () => bulk.click()); expect(expanded()).toEqual([]);
for (const key of ['Enter', ' ']) {
await act(async () => domain.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })));
expect(expanded()).toEqual(['one', 'shared']);
await act(async () => bulk.click()); await act(async () => bulk.click()); expect(expanded()).toEqual([]);
}
await act(async () => domain.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
await act(async () => domain.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(0);
expect(JSON.stringify({ dataset, groups })).toBe(original);
} finally { await act(async () => root.unmount()); style.remove(); }
});
it('opens a bold finding without scanner metadata and places Description directly after Affected', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Result Message', 'Number of Results', 'Description', 'Impact', 'Remediation', 'Source Rule ID'],
['DC Sync rights', 'Failed', 'Critical', 'Retained result.', '3', 'Retained result.', 'Retained impact.', 'Retained fix.', 'SYNTHETIC-001']
], '', 'synthetic.csv', { isProjectSheet: true });
const record = sheet.analysis.records[0];
const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'purple-knight', scanTool: 'Purple Knight', sheets: [sheet] };
const original = JSON.stringify(record);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const style = document.createElement('style');
style.textContent = readFileSync('src/styles/ui-tokens.css', 'utf8') + readFileSync('src/styles/client-security.css', 'utf8');
document.head.append(style);
try {
await act(async () => root.render(<ClientSecurityPanel dataset={dataset} sheetId={sheet.id} toolId="purple-knight" contextStatus="off" total={1}
domains={[]} groups={[{ id: 'all', label: 'All findings', severity: 'critical', records: [record], domains: [] }]}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.client-security-group-toggle')!.click());
const toggle = host.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
expect(toggle.textContent).toBe('DC Sync rights');
expect(toggle.querySelector('small')).toBeNull();
expect(toggle.querySelector('strong')!.textContent).toBe('DC Sync rights');
expect(getComputedStyle(toggle.querySelector('strong')!).fontWeight).toBe('var(--fw-bold)');
await act(async () => toggle.click());
const labels = [...host.querySelectorAll('.client-security-finding-detail > div:first-child .client-security-source > span')].map(item => item.textContent);
expect(labels).toContain('Affected');
expect(labels[labels.indexOf('Affected') + 1]).toBe('Description');
expect(labels.indexOf('Description')).toBeLessThan(labels.indexOf('Impact'));
expect(host.textContent).toContain('Retained fix.');
expect(JSON.stringify(record)).toBe(original);
} finally { await act(async () => root.unmount()); style.remove(); }
});
it('keeps all 105 deviations in heatmap order, reconciles domains and opens the original explanation and remediation without changing Work', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const resizeCallbacks = new Map<Element, () => void>();
vi.stubGlobal('ResizeObserver', class {
constructor(private callback: () => void) {}
observe(element: Element) { resizeCallbacks.set(element, this.callback); }
disconnect() {}
});
document.documentElement.dataset.theme = theme;
resetEnginePreferencesForTests();
const state = clientFactsState();
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Description', 'Remediation'],
...Array.from({ length: 105 }, (_, index) => [`Control ${String(index).padStart(3, '0')}`, 'Failed', 'High', '<p>Synthetic retained explanation.</p>', 'Synthetic exact remediation.']),
['Passed control', 'Passed', 'Critical', 'Not a deviation', ''], ['Unassessed control', 'Not configured', '', '', '']
], '', 'synthetic.csv', { isProjectSheet: true });
const context = emptyHeatmapContext();
sheet.analysis.records.slice(0, 105).forEach((record, index) => {
const occurrenceId = `synthetic-occurrence-${index}`; record.findingOccurrenceId = occurrenceId;
const clusterId = index < 104 ? 'cloud-privileged-access-exposure' : 'credential-relay-exposure';
const conditionId = context.clusters.find(cluster => cluster.clusterId === clusterId)!.conditions[0].conditionId;
context.observations.push({ id: occurrenceId, conditionId, occurrenceId, resultProjectionId: occurrenceId,
tool: 'purple-knight', sourceRuleId: 'SYNTHETIC', ruleText: record.title, matchedBy: 'rule-id', outcome: 'fail', assertion: 'observed-by-scanner' });
context.findings.push({ occurrenceId, resultProjectionId: occurrenceId, tool: 'purple-knight', sourceRuleId: 'SYNTHETIC',
observationIds: [occurrenceId], conditionIds: [conditionId], clusterIds: [clusterId], declaredReferenceIds: [], cisControlAreaIds: [] });
});
const openWork = vi.fn(), setFilter = vi.fn();
vi.mocked(useApp).mockReturnValue({ ...state, dataset: { ...state.dataset, sheets: [sheet] }, activeSheet: sheet,
activeProfile: genericProfile, setFacetFilter: setFilter } as unknown as ReturnType<typeof useApp>);
const original = JSON.stringify(useApp().dataset);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ ...clientFactsSnapshot,
sections: [...clientFactsSnapshot.sections, { ...clientFactsSnapshot.sections[0],
id: 'synthetic-restricted', sectionKey: 'syntheticRestricted', label: 'Synthetic restricted section', state: 'license-restricted', tables: [] }]
});
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const style = document.createElement('style');
style.textContent = readFileSync('src/styles/base.css', 'utf8') + readFileSync('src/styles/client-security.css', 'utf8');
document.head.append(style);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
const tabs = host.querySelectorAll<HTMLButtonElement>('[role="tab"]');
await act(async () => tabs[1].click());
const panel = host.querySelector<HTMLElement>('[role="tabpanel"]:not([hidden])')!;
expect(panel.textContent).toContain('105 deviations');
const groups = [...panel.querySelectorAll<HTMLElement>('.client-security-group')];
expect(groups[0].dataset.cluster).toBe('cloud-privileged-access-exposure');
expect(groups.map(group => group.dataset.cluster)).toEqual([...host.querySelectorAll<HTMLElement>('.context-heatmap__cell')].map(cell => cell.dataset.cluster));
expect(groups.map(group => Number(group.querySelector('.client-security-group-toggle > strong')!.textContent)).reduce((sum, count) => sum + count, 0)).toBe(105);
expect(panel.querySelector('[data-cluster="unassigned"]')).toBeNull();
const disclosure = panel.querySelector<HTMLDetailsElement>('.client-security-overview-disclosure')!;
const map = panel.querySelector<HTMLDivElement>('.client-security-map')!;
expect(disclosure.open).toBe(true);
expect(disclosure.style.getPropertyValue('--security-map-height')).toBe(`${groups.length * 28 + 24}px`);
expect(disclosure.querySelector('.client-security-hardfacts')).not.toBeNull();
expect(disclosure.querySelector('.client-security-table')).toBeNull();
expect(panel.querySelectorAll('details.client-security-map')).toHaveLength(0);
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(false);
expect(panel.querySelector('.client-security-table')).not.toBeNull();
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(true);
const overview = panel.querySelector('.client-security-hardfacts')!;
expect(getComputedStyle(overview).paddingInlineStart).toBe('var(--space-8)');
for (const row of overview.querySelectorAll('.client-security-facts > div')) {
expect(getComputedStyle(row).paddingBlock).toBe('calc(var(--space-1) / 2)');
}
expect(disclosure.querySelector('.client-security-graph-zone .client-security-score')).not.toBeNull();
expect(disclosure.querySelector('.client-security-graph-zone .client-security-remediation')).not.toBeNull();
expect(overview.textContent).not.toContain('Overall Score');
expect(overview.textContent).not.toContain('Remediation');
expect(overview.textContent).toContain('Password policy');
expect(overview.textContent).toContain('Cloud Information');
expect(overview.textContent).not.toContain('Certificates & PKI');
expect(overview.textContent).not.toContain('Source security metrics');
expect(overview.textContent).not.toContain('Security Domains');
expect(overview.textContent).not.toContain('Restricted by the source tool');
expect(getComputedStyle(overview).backgroundColor).toBe('rgba(0, 0, 0, 0)');
const boundedFacts = overview.querySelector('.client-security-hardfacts-content')!;
expect(getComputedStyle(boundedFacts).position).not.toBe('absolute');
expect(getComputedStyle(boundedFacts).overflow).not.toBe('auto');
expect(getComputedStyle(panel.querySelector('.client-security-overview')!).gridTemplateColumns).toBe('minmax(0, 2fr) minmax(0, 1fr)');
const columns = [...overview.querySelectorAll('.client-security-fact-column')];
expect(columns).toHaveLength(2);
expect(columns.map(column => [...column.querySelectorAll('h3')].map(h => h.textContent))).toEqual([
['Domain Information', 'Resilience', 'Inventory'],
['Password policy', 'Operating systems', 'Cloud Information']
]);
const report = reportHeaderFacts(clientFactsSnapshot, {})!;
const reportGroups = [{ title: 'Domain Information', facts: report.domainInformation }, ...report.groups]
.filter(group => group.title !== 'Certificates & PKI');
for (const group of reportGroups) {
const section = [...overview.querySelectorAll('.client-security-fact-column > section')]
.find(item => item.querySelector('h3')?.textContent === group.title)!;
expect([...section.querySelectorAll('.client-security-facts > div')].map(row => ({
label: row.querySelector('dt')!.textContent, value: row.querySelector('dd')!.textContent
}))).toEqual(group.facts);
}
const domainRows = [...map.querySelectorAll('.client-security-map-domain')];
expect(domainRows).toHaveLength(6);
const domainPositions = domainRows.map(row => Number(row.querySelector('.client-security-domain-count')!.getAttribute('y')));
expect(domainPositions.slice(1).map((y, index) => y - domainPositions[index])).toEqual([28, 28, 28, 28, 28]);
expect((domainPositions[0] + domainPositions[5]) / 2 - 5).toBe((groups.length * 28 + 24) / 2 + 32);
for (const surface of [disclosure, panel.querySelector('.client-security-overview')!, panel.querySelector('.client-security-table')!]) {
expect(getComputedStyle(surface).width).toBe('100%');
expect(getComputedStyle(surface).maxWidth).toBe('100%');
expect(getComputedStyle(surface).minWidth).toBe('0px');
}
expect(domainRows.reduce((sum, row) => sum + Number(row.querySelector('.client-security-domain-count')!.textContent), 0)).toBe(105);
for (const row of domainRows) {
const count = Number(row.querySelector('.client-security-domain-count')!.textContent);
const bar = row.querySelector('.client-security-domain-bar')!;
expect(bar.getAttribute('aria-label')).toBe(`${count} of 105 deviations`);
expect(Number(bar.querySelector('.client-security-domain-track')!.getAttribute('width'))).toBe(80);
expect(Number(bar.querySelector('.client-security-domain-fill')!.getAttribute('width'))).toBeCloseTo(count / 105 * 80);
}
const domainCountsBefore = domainRows.map(row => row.querySelector('.client-security-domain-count')!.textContent);
const categoryRows = [...map.querySelectorAll('.client-security-map-category')];
expect(categoryRows).toHaveLength(groups.length);
expect(categoryRows.reduce((sum, row) => sum + Number(row.querySelector('.client-security-category-count')!.textContent), 0)).toBe(105);
for (const row of categoryRows) {
const count = Number(row.querySelector('.client-security-category-count')!.textContent);
expect(row.querySelector('.client-security-category-bar')!.getAttribute('aria-label')).toBe(`${count} of 105 deviations`);
expect(Number(row.querySelector('.client-security-domain-track')!.getAttribute('width'))).toBe(80);
expect(Number(row.querySelector('.client-security-domain-fill')!.getAttribute('width'))).toBeCloseTo(count / 105 * 80);
}
expect(disclosure.querySelector('.client-security-remediation > strong')!.textContent).toBe('105');
const originalOverview = host.querySelector('.client-command-center')!;
expect(domainCountsBefore).toEqual([...originalOverview.querySelectorAll('.client-domains > ul b')].map(item => item.textContent));
expect(disclosure.querySelector('.client-security-score > strong')!.textContent).toBe(originalOverview.querySelector('.client-overall-ring-number')!.textContent);
for (const value of disclosure.querySelectorAll('.client-security-metrics section > strong')) {
expect(getComputedStyle(value).justifySelf).toBe('end');
expect(getComputedStyle(value).textAlign).toBe('right');
}
expect(disclosure.querySelector('.client-security-totals')).toBeNull();
await act(async () => disclosure.querySelector<HTMLButtonElement>('[aria-label="Open Assessment facts results"]')!.click());
const totalFacts = [...document.querySelectorAll('.client-security-totals tbody > tr')];
expect(getComputedStyle(totalFacts[0].querySelector('th')!).position).toBe('static');
expect(totalFacts).toHaveLength(9);
expect([...document.querySelectorAll('.client-security-totals thead th')].map(cell => cell.textContent)).toEqual(['Metric', 'Value']);
const factValue = (label: string) => totalFacts.find(fact => fact.querySelector('th[scope="row"]')!.textContent === label)?.querySelector('td')!.textContent;
expect(factValue('Assessed')).toBe('106');
expect(factValue('Passed')).toBe('1');
expect(factValue('Gaps')).toBe('1');
expect(factValue('Categories with deviations')).toBe('2');
const assessmentDiagram = document.querySelector('.client-assessment-diagram')!;
const diagramValue = (metric: string) => assessmentDiagram.querySelector(`[data-metric="${metric}"] b`)!.textContent;
expect(diagramValue('Assessed')).toBe(factValue('Assessed'));
expect(diagramValue('Passed')).toBe(factValue('Passed'));
expect(diagramValue('Gaps')).toBe(factValue('Gaps'));
expect(diagramValue('Deviations')).toBe('105');
expect(document.querySelector('.client-assessment-explanation')!.textContent).toContain('No baseline rule');
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Assessment facts window"]')!.click());
await act(async () => new Promise<void>(resolve => requestAnimationFrame(() => resolve())));
const securityFacts = [...overview.querySelectorAll('.client-security-inventory strong')].map(item => item.textContent);
expect(overview.querySelectorAll('.client-security-inventory .client-data-fact')).toHaveLength(6);
expect(securityFacts).toEqual([...originalOverview.querySelectorAll('.client-data-fact strong')].map(item => item.textContent));
const diagram = map.querySelector('.client-security-map-scroll > svg')!;
expect(getComputedStyle(diagram).width).toBe('100%');
expect(getComputedStyle(map.querySelector('.client-security-map-scroll')!).overflow).toBe('visible');
vi.spyOn(map, 'getBoundingClientRect').mockReturnValue({ width: 1400 } as DOMRect);
await act(async () => resizeCallbacks.get(map)!());
expect(diagram.getAttribute('viewBox')).toBe(`0 0 1400 ${groups.length * 28 + 24}`);
expect(map.querySelector('.client-security-map-category .client-security-map-hit')!.getAttribute('x')).toBe('900');
expect(map.querySelector('.client-security-domain-track')!.getAttribute('width')).toBe('80');
expect(getComputedStyle(disclosure.querySelector('summary > svg')!).width).not.toBe('1040px');
expect(panel.querySelectorAll('.client-security-flow')).not.toHaveLength(0);
expect(panel.querySelector('svg[role="group"]')!.textContent).toContain('104');
const category = groups[0].querySelector<HTMLButtonElement>('.client-security-group-toggle')!;
expect(category.getAttribute('aria-expanded')).toBe('false');
const scroll = vi.fn(); groups[0].scrollIntoView = scroll;
const mapCategory = map.querySelector<SVGGElement>('.client-security-map-category[role="button"]')!;
await act(async () => mapCategory.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(category);
expect(scroll).toHaveBeenCalled();
await act(async () => category.click());
expect(category.getAttribute('aria-expanded')).toBe('false');
await act(async () => mapCategory.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
await act(async () => category.click());
await act(async () => mapCategory.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
expect(groups[0].textContent).toContain('Technical approach');
expect(groups[0].querySelectorAll('.client-security-finding')).toHaveLength(104);
expect(groups[0].textContent).not.toContain('Passed control');
const finding = groups[0].querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
await act(async () => finding.click());
expect(finding.getAttribute('aria-expanded')).toBe('true');
expect(groups[0].textContent).toContain('Synthetic retained explanation.');
expect(groups[0].textContent).toContain('Synthetic exact remediation.');
await act(async () => tabs[0].click()); await act(async () => tabs[1].click());
expect(category.getAttribute('aria-expanded')).toBe('true');
expect(finding.getAttribute('aria-expanded')).toBe('true');
await act(async () => finding.click());
expect(groups[0].querySelector('.client-security-finding-detail')).toBeNull();
expect(setFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled();
expect(JSON.stringify(useApp().dataset)).toBe(original);
await act(async () => setEngineEnabled('report', false));
expect(panel.textContent).not.toContain('Technical approach');
expect(panel.textContent).toContain('105 deviations');
await act(async () => setEngineEnabled('context', false));
expect(panel.querySelector('.client-security-map')).toBeNull();
expect(panel.textContent).toContain('105 deviations');
expect(panel.textContent).toContain('All findings');
expect([...originalOverview.querySelectorAll('.client-domains > ul b')].map(row => row.textContent)).toEqual(domainCountsBefore);
} finally { await act(async () => root.unmount()); style.remove(); }
});
it('counts a rolled-up GPO once and keeps distinct stored baseline fixes separate', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic GPO', [['Name', 'Status', 'Category'], ['Stored setting', 'Fail', 'GPOs']], '', 'synthetic.csv', { isProjectSheet: true });
const record = sheet.analysis.records[0];
record.policyBaselineComparisons = ['A', 'B'].map(name => ({ baseline: `Synthetic baseline ${name}`, status: 'Fail', summary: 'Stored comparison', context: '', scopeNotice: '', explanation: '',
remediation: `Retained fix ${name}`, recommendations: [{ template: `Template ${name}`, settingId: 'Synthetic exact setting', comparison: 'Fail', expected: name, additional: [], missing: [] }] }));
const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'generic', scanTool: 'Generic', sheets: [sheet] };
const original = JSON.stringify(record);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<ClientSecurityPanel dataset={dataset} sheetId={sheet.id} toolId="group-policy" contextStatus="off" total={1}
domains={[]} groups={[{ id: 'all', label: 'All findings', severity: 'unknown', records: [record], domains: [] }]}/>));
await act(async () => host.querySelector<HTMLButtonElement>('.client-security-group-toggle')!.click());
await act(async () => host.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!.click());
expect(host.querySelectorAll('.client-security-finding')).toHaveLength(1);
expect(host.textContent).toContain('Retained fix A'); expect(host.textContent).toContain('Retained fix B');
expect(JSON.stringify(record)).toBe(original);
} finally { await act(async () => root.unmount()); }
});
});