610 lines
45 KiB
TypeScript
610 lines
45 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection } from '../../src/domain/environment-context';
|
||
import { EnvironmentEvidenceTables } from '../../src/features/dashboard/EnvironmentEvidenceTables';
|
||
import { EnvironmentResultsButton } from '../../src/features/dashboard/EnvironmentResultsButton';
|
||
import type { EnvironmentEvidenceGroup } from '../../src/features/dashboard/environment-result-evidence';
|
||
import { environmentEvidenceForGroup, environmentSectionTitle, environmentTableSubsection, environmentTableTitle, partitionEnvironmentObservations } from '../../src/features/dashboard/environment-result-evidence';
|
||
|
||
const tool = { id: 'dummy', name: 'Dummy scanner' };
|
||
const section = (key: string, name: string, rows: (string | number)[][]): ScannerReportSection => ({
|
||
version: 1, id: key, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
|
||
sectionKey: key, label: name, ordinal: 1, state: 'observed',
|
||
tables: [{ name, headers: ['Technical setting', 'Configured value', 'Extra evidence'], rows }],
|
||
prose: `${name} source prose`, source: { rowIndex: 4 }, extractorVersion: 'dummy-1', attributes: { source: 'dummy' }
|
||
});
|
||
const observation = (property: string, originalValue: string | number | boolean, normalizedValue?: string | number | boolean): EnvironmentObservation => ({
|
||
version: 1, id: property, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool,
|
||
subject: { type: 'domain', label: 'DUMMY' }, property, label: property,
|
||
originalValue, normalizedValue, valueType: 'string', state: 'observed',
|
||
sectionKey: 'report-overview', source: { objectPath: '$.dummy.value' }, mappingVersion: 'dummy-2',
|
||
attributes: { role: 'test' }
|
||
});
|
||
const snapshot: EnvironmentSnapshot = {
|
||
version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-01-01T00:00:00Z',
|
||
coverage: { observed: 1, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
|
||
sections: [
|
||
section('sectionPasswordPolicies', 'Password policies', [['Minimum length', 14, 'GPO A'], ['History', 24, 'GPO B']]),
|
||
section('sectionPKI', 'Certificate templates', [['Template A', 'Enabled', 'Enrollment enabled']]),
|
||
section('sectionComputerInformation', 'Operating systems', [['Windows Server', 7, 'Unsupported count']]),
|
||
section('unknown-section', 'Unmapped report evidence', [['Unknown fact', 'retained', 'source-only']])
|
||
],
|
||
observations: [
|
||
observation('domain.count', 2, 3), observation('password_policy.minimum_length', 14),
|
||
observation('pki.template_count', 1), observation('operating_system.name', 'Windows Server'),
|
||
observation('domain.controller_count', 2), observation('entra.tenant_name', 'dummy tenant'),
|
||
observation('unmapped.custom_property', 'keep me')
|
||
]
|
||
};
|
||
|
||
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
|
||
|
||
describe.each(['light', 'dark'])('Complete scanner table pagination (%s)', theme => {
|
||
it.each([100, 800, 1000])('keeps %i real rows reachable in inline and linked tables when changing the page size', async count => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
document.documentElement.dataset.theme = theme;
|
||
const notice = 'Output limited to 100 items - go to the advanced menu before running the report or add "--no-enum-limit" to remove that limit';
|
||
const rows = Array.from({ length: count }, (_, index) => [`Synthetic-${index + 1}`, '2026-01-01']);
|
||
if (count === 100) rows.unshift([notice, notice]);
|
||
const data: EnvironmentSnapshot = { ...snapshot, sections: [{ ...section('sectionUserInformation', 'User Information', []), tables: [
|
||
{ name: 'Account analysis', presentation: 'inline', headers: ['Name', 'Creation'], rows,
|
||
cellLinks: [{ rowIndex: rows.length - 1, columnIndex: 0, targetId: 'accounts' }] },
|
||
{ name: 'Account details', title: 'Account details', presentation: 'modal', targetId: 'accounts', headers: ['Name', 'Creation'], rows }
|
||
] }] };
|
||
const original = JSON.stringify(data);
|
||
const container = document.createElement('div'); document.body.append(container); const root = createRoot(container);
|
||
const changeSize = async (scope: Element, size: number) => {
|
||
const select = scope.querySelector<HTMLSelectElement>('select')!;
|
||
await act(async () => { select.value = String(size); select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
};
|
||
const checkPages = async (scope: Element) => {
|
||
await changeSize(scope, 100);
|
||
expect(scope.querySelectorAll('tbody tr')).toHaveLength(100);
|
||
const pages = Math.ceil(count / 100);
|
||
for (let page = 1; page < pages; page++) await act(async () => [...scope.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Next')!.click());
|
||
expect(scope.querySelector('tbody')!.textContent).toContain(`Synthetic-${count}`);
|
||
expect([...scope.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Next')!.disabled).toBe(true);
|
||
await changeSize(scope, 1000);
|
||
expect(scope.querySelectorAll('tbody tr')).toHaveLength(count);
|
||
expect(scope.querySelector('.client-env-table-pagination')!.textContent).toContain(`Showing 1–${count} of ${count} rows`);
|
||
expect(scope.querySelector('tbody')!.textContent).not.toContain('Output limited');
|
||
expect(scope.querySelectorAll('[role="note"]')).toHaveLength(count === 100 ? 1 : 0);
|
||
if (count === 100) expect(scope.querySelector('[role="note"]')!.textContent).toContain(notice);
|
||
};
|
||
try {
|
||
await act(async () => root.render(<EnvironmentResultsButton title="User Accounts"><EnvironmentEvidenceTables snapshot={data} group="user-accounts"/></EnvironmentResultsButton>));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||
const inline = document.querySelector('.client-env-evidence-table')!;
|
||
await checkPages(inline);
|
||
await act(async () => inline.querySelector<HTMLButtonElement>('.client-env-table-cell-link')!.click());
|
||
const dialog = document.querySelector('.client-env-table-dialog')!;
|
||
await checkPages(dialog);
|
||
const search = dialog.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||
await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, `Synthetic-${count}`); search.dispatchEvent(new Event('input', { bubbles: true })); });
|
||
expect(dialog.querySelectorAll('tbody tr')).toHaveLength(1);
|
||
expect(dialog.querySelector('tbody')!.textContent).toContain(`Synthetic-${count}`);
|
||
expect(JSON.stringify(data)).toBe(original);
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
});
|
||
|
||
describe.each(['light', 'dark'])('Inventory detail layering (%s)', theme => {
|
||
it('opens an activated Inventory row and its nested table above the Inventory window, including prose details', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
document.documentElement.dataset.theme = theme;
|
||
const adminSection: ScannerReportSection = { ...section('sectionAdminGroups', 'Admin Groups', []),
|
||
tables: [
|
||
{ name: 'Admin groups', presentation: 'inline', headers: ['Group'], rows: [['Schema Administrators'], ['Operator graph']],
|
||
cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'schema' }, { rowIndex: 1, columnIndex: 0, targetId: 'graph' }] },
|
||
{ name: 'Schema Administrators', title: 'Schema Administrators', presentation: 'modal', targetId: 'schema',
|
||
headers: ['Account'], rows: [['Synthetic member']], cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'member' }] },
|
||
{ name: 'Member', title: 'Member', presentation: 'modal', targetId: 'member', headers: ['Account'], rows: [['Retained member evidence']] }
|
||
], modalDetails: [{ targetId: 'graph', title: 'Operator graph', prose: 'Retained graph detail.', interactiveGraph: true }] };
|
||
const data = { ...snapshot, sections: [adminSection] };
|
||
const original = JSON.stringify(data);
|
||
const container = document.createElement('div'); document.body.append(container); const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentResultsButton title="Inventory"><EnvironmentEvidenceTables snapshot={data} group="inventory"/></EnvironmentResultsButton>));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||
const inventory = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
await act(async () => inventory.dispatchEvent(new Event('pointerdown', { bubbles: true })));
|
||
expect(Number(inventory.style.zIndex)).toBeGreaterThan(2700);
|
||
await act(async () => inventory.querySelector<HTMLButtonElement>('.client-env-table-cell-link')!.click());
|
||
const aboveInventory = () => {
|
||
const backdrop = document.querySelector<HTMLElement>('.client-env-table-backdrop')!;
|
||
expect(Number(backdrop.style.zIndex)).toBeGreaterThan(Number(inventory.style.zIndex));
|
||
expect(backdrop.contains(document.activeElement)).toBe(true);
|
||
};
|
||
aboveInventory();
|
||
expect(document.querySelector('.client-env-table-dialog h2')!.textContent).toBe('Schema Administrators');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('.client-env-table-dialog .client-env-table-cell-link')!.click());
|
||
expect(document.querySelector('.client-env-table-dialog h2')!.textContent).toBe('Member');
|
||
await act(async () => document.querySelector('.client-env-table-dialog')!.dispatchEvent(new Event('pointerdown', { bubbles: true })));
|
||
aboveInventory();
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Member table"]')!.click());
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Schema Administrators table"]')!.click());
|
||
expect(document.querySelector('.floating-data-window')).toBe(inventory);
|
||
await act(async () => inventory.querySelectorAll<HTMLButtonElement>('.client-env-table-cell-link')[1].click());
|
||
aboveInventory();
|
||
expect(document.querySelector('.client-env-table-dialog')!.textContent).toContain('Retained graph detail.');
|
||
expect(JSON.stringify(data)).toBe(original);
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
});
|
||
|
||
describe('Environment Results source evidence', () => {
|
||
it('opens Directory Results and native details without generated labels or legacy source-text controls', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const domain: ScannerReportSection = { ...section('sectionDomainInformation', 'Domain Information', []),
|
||
tool: { id: 'pingcastle', name: 'PingCastle' }, prose: 'Domain Information legacy flattened text Close Close',
|
||
detailLinks: [{ targetId: 'domain-extra', label: 'Additional domain details' }],
|
||
tables: [
|
||
{ name: 'Tabelle 35', title: 'Tabelle 35', sourceTableIndex: 34, headers: ['Domain', 'NetBIOS'],
|
||
rows: [['synthetic.example', 'SYNTHETIC']], cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'domain-popup' }] },
|
||
{ name: 'Tabelle 36', title: 'Table 36 of 40', presentation: 'modal', targetId: 'domain-popup',
|
||
headers: ['Property', 'Value'], rows: [['Synthetic domain property', 'Retained popup value']] },
|
||
{ name: 'Properties', title: 'Tabelle 37', presentation: 'linked-inline', targetId: 'domain-extra',
|
||
headers: ['Property', 'Value'], rows: [['Synthetic extra property', 'Retained inline value']] }
|
||
] };
|
||
const infrastructure: ScannerReportSection = { ...section('sectionInfrastructure', 'Infrastructure', []),
|
||
tables: [{ name: 'Azure AD Connect settings', title: 'Azure AD Connect settings · Tabelle 2',
|
||
headers: ['Setting', 'Value'], rows: [['Synthetic configuration', 'Enabled']] }] };
|
||
const rowCount = { ...observation('domain.row_count', 1), sectionKey: domain.sectionKey,
|
||
subject: { type: 'table', label: 'Tabelle 35' }, attributes: { sourceTableIndex: 34, subsectionLabel: 'Tabelle 35' } };
|
||
const data = { ...snapshot, sections: [domain, infrastructure], observations: [rowCount] };
|
||
const retained = JSON.stringify(data);
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
const expectCleanHeadings = (element: Element) => {
|
||
expect(element.textContent).not.toMatch(/Source text|Tabelle \d+|Table \d+|Close Close|legacy flattened text/);
|
||
};
|
||
try {
|
||
await act(async () => root.render(<EnvironmentResultsButton title="Directory">
|
||
<EnvironmentEvidenceTables snapshot={data} group="directory"/>
|
||
</EnvironmentResultsButton>));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||
const results = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
expectCleanHeadings(results);
|
||
expect([...results.querySelectorAll('h4, h5')].map(node => node.textContent))
|
||
.toEqual(['Domain Information', 'Infrastructure', 'Azure AD Connect settings']);
|
||
expect(results.querySelectorAll('table')).toHaveLength(2);
|
||
expect(results.textContent).toContain('synthetic.example');
|
||
await act(async () => results.querySelector<HTMLButtonElement>('.client-env-table-cell-link')!.click());
|
||
const popup = document.querySelector<HTMLElement>('.client-env-table-dialog')!;
|
||
expectCleanHeadings(popup);
|
||
expect(popup.querySelector('h2')?.textContent).toBe('Domain Information');
|
||
expect(popup.textContent).toContain('Retained popup value');
|
||
await act(async () => popup.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
||
await act(async () => results.querySelector<HTMLButtonElement>('.client-env-table-detail-link')!.click());
|
||
expectCleanHeadings(results);
|
||
expect(results.textContent).toContain('Retained inline value');
|
||
expect(results.querySelectorAll('table')).toHaveLength(3);
|
||
expect(JSON.stringify(data)).toBe(retained);
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('filters generated names from every title source while keeping native headings', () => {
|
||
const original = section('sectionDomainInformation', 'Domain Information', []);
|
||
const table = { name: 'Tabelle 35', title: 'Table 35', sourceTableIndex: 34, headers: [], rows: [] };
|
||
const rowCount = { ...observation('domain.row_count', 1), sectionKey: original.sectionKey,
|
||
subject: { type: 'table', label: 'Table 35' }, attributes: { sourceTableIndex: 34 } };
|
||
const labelSources: EnvironmentObservation['attributes'][] = [{ sourceTableIndex: 34 }, { sourceTableIndex: 34, subsectionLabel: 'Tabelle 35' }];
|
||
for (const attributes of labelSources) {
|
||
const data = { ...snapshot, observations: [{ ...rowCount, attributes }] };
|
||
expect(environmentTableTitle(data, original, table)).toBe('');
|
||
expect(environmentTableTitle(data, original, { ...table, name: 'Domain controllers' })).toBe('Domain controllers');
|
||
expect(environmentTableTitle(data, original, { ...table, title: 'Native details' })).toBe('Native details');
|
||
}
|
||
});
|
||
|
||
it.each([
|
||
['Directory', 'directory', 'sectionDomainInformation', 'Domain information'],
|
||
['Resilience', 'resilience', 'sectionAnomalies', 'Backup'],
|
||
['Inventory', 'inventory', 'sectionAdminGroups', 'Admin groups list'],
|
||
['Password policy', 'password-policy', 'sectionPasswordPolicies', 'Password policies'],
|
||
['Certificates & PKI', 'certificates', 'sectionPKI', 'Certificate Templates'],
|
||
['Operating systems', 'operating-systems', 'sectionComputerInformation', 'Operating systems'],
|
||
['Overall Score', 'all', 'report-overview', 'Scanner summary']
|
||
])('opens %s Results with every original table directly readable', async (title, group, sectionKey, tableName) => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const original = section(sectionKey, tableName, [['Synthetic first row', 14, 'Retained evidence']]);
|
||
original.tables.push({ ...original.tables[0], rows: [['Synthetic second table', 24, 'Other evidence']] });
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentResultsButton title={title}>
|
||
<EnvironmentEvidenceTables snapshot={{ ...snapshot, sections: [original], observations: [] }} group={group as EnvironmentEvidenceGroup}/>
|
||
</EnvironmentResultsButton>));
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
const trigger = container.querySelector<HTMLButtonElement>('button')!;
|
||
await act(async () => trigger.click());
|
||
const results = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
expect(results.querySelectorAll('table')).toHaveLength(2);
|
||
expect(results.querySelectorAll('tbody')[0].textContent).toContain('Synthetic first row');
|
||
expect(results.querySelectorAll('tbody')[1].textContent).toContain('Synthetic second table');
|
||
expect(results.textContent).not.toMatch(/Open table|Source text|Table \d+ of \d+/);
|
||
expect(results.querySelector('details[open]')).toBeNull();
|
||
expect(Number.parseFloat(results.style.left) + Number.parseFloat(results.style.width)).toBeLessThanOrEqual(window.innerWidth - 8);
|
||
await act(async () => [...results.querySelectorAll<HTMLButtonElement>('button')]
|
||
.find(button => button.getAttribute('aria-label') === `Close ${title} window`)!.click());
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('keeps all PKI child tables in the correct report for both native link paths', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const makeSource = (file: string, value: string): ScannerReportSection[] => [
|
||
{ ...section('sectionPKI', 'PKI', []), id: `${file}-parent`, importFileId: file,
|
||
detailLinks: [{ targetId: 'native-child', label: `${value} details` }], tables: [
|
||
{ name: 'Certificates', presentation: 'inline', headers: ['Certificate', 'Count'], rows: [[value, 2]],
|
||
cellLinks: [{ rowIndex: 0, columnIndex: 1, targetId: 'native-child' }] }
|
||
] },
|
||
{ ...section('native-child', 'Certificate detail', []), id: `${file}-child`, importFileId: file,
|
||
tables: [{ name: 'Properties', presentation: 'inline', headers: ['Subject'], rows: [[`${value} subject`]] },
|
||
{ name: 'Delegations', presentation: 'inline', headers: ['Principal'], rows: [[`${value} principal`]] }] }
|
||
];
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentResultsButton title="Certificates & PKI">
|
||
<EnvironmentEvidenceTables snapshot={{ ...snapshot, sections: [...makeSource('first-file', 'First'), ...makeSource('second-file', 'Second')], observations: [] }} group="certificates"/>
|
||
</EnvironmentResultsButton>));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
|
||
const results = document.querySelector<HTMLElement>('.floating-data-window')!;
|
||
expect(results.querySelectorAll('table')).toHaveLength(2);
|
||
await act(async () => results.querySelectorAll<HTMLButtonElement>('.client-env-table-cell-link')[1].click());
|
||
const popup = document.querySelector<HTMLElement>('.client-env-table-dialog')!;
|
||
expect(popup.querySelectorAll('table')).toHaveLength(2);
|
||
expect(popup.textContent).toContain('Second subject');
|
||
expect(popup.textContent).toContain('Second principal');
|
||
expect(popup.textContent).not.toContain('First subject');
|
||
await act(async () => popup.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
||
expect(document.querySelector('.client-env-table-dialog')).toBeNull();
|
||
expect(document.querySelector('.floating-data-window')).toBe(results);
|
||
await act(async () => results.querySelectorAll<HTMLButtonElement>('.client-env-table-detail-link')[0].click());
|
||
expect(results.querySelectorAll('table')).toHaveLength(4);
|
||
expect(results.textContent).toContain('First subject');
|
||
expect(results.textContent).toContain('First principal');
|
||
expect(results.textContent).not.toContain('Second subject');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
it('shows PingCastle base tables immediately, opens cell links as dialogs and outside links inline', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const adminSection: ScannerReportSection = { ...section('sectionAdminGroups', 'Admin Groups', []),
|
||
tables: [
|
||
{ name: 'Admin groups list', presentation: 'inline', headers: ['Group Name', 'Nb Admins'],
|
||
rows: [['Enterprise Administrators', 1], ['Operator graph', 1], ['2', 2]],
|
||
cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'modalEnterprise-Administrators' },
|
||
{ rowIndex: 1, columnIndex: 0, targetId: 'mcg-Operators' },
|
||
{ rowIndex: 2, columnIndex: 0, targetId: 'sectionAdminGroupsChild' }] },
|
||
{ name: 'Admin groups detail', presentation: 'modal', targetId: 'modalEnterprise-Administrators',
|
||
title: 'Enterprise Administrators', headers: ['SamAccountName'], rows: [['Administrator']],
|
||
cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'modalMember' }] },
|
||
{ name: 'Member detail', presentation: 'modal', targetId: 'modalMember',
|
||
title: 'Administrator', headers: ['Account'], rows: [['Administrator detail']] },
|
||
{ name: 'Admin groups detail', presentation: 'linked-inline', targetId: 'allprivileged',
|
||
headers: ['SamAccountName'], rows: [['Administrator']] }
|
||
], detailLinks: [{ targetId: 'allprivileged', label: 'All users in Admins groups' },
|
||
{ targetId: 'sectionAdminGroupsChild', label: 'Extra accounts' }],
|
||
modalDetails: [{ targetId: 'mcg-Operators', title: 'Operator graph', prose: 'Graph legend.', interactiveGraph: true }] };
|
||
const childSection: ScannerReportSection = { ...section('sectionAdminGroupsChild', 'Extra accounts', []),
|
||
parentSectionKey: 'sectionAdminGroups', tables: [{ name: 'Extra accounts', presentation: 'linked-inline',
|
||
targetId: 'sectionAdminGroupsChild', headers: ['Account'], rows: [['Second account']] }] };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot,
|
||
sections: [adminSection, childSection], observations: [] }} group="all"/>));
|
||
expect(container.querySelectorAll('.client-env-evidence-table')).toHaveLength(1);
|
||
expect([...container.querySelectorAll('.client-env-evidence h3, .client-env-evidence h4, .client-env-evidence h5')]
|
||
.map(heading => heading.textContent)).toEqual(['Admin Groups', 'Admin groups list']);
|
||
expect(container.textContent).not.toContain('Original report sections');
|
||
expect(container.textContent).not.toContain('Original report table');
|
||
expect(container.textContent).not.toContain('Second account');
|
||
expect(container.textContent).toContain('Enterprise Administrators');
|
||
expect([...container.querySelectorAll('tbody td')].map(cell => cell.textContent)).not.toContain('Administrator');
|
||
await act(async () => container.querySelector<HTMLButtonElement>('.client-env-table-cell-link')?.click());
|
||
const dialog = document.querySelector<HTMLElement>('.client-env-table-dialog')!;
|
||
expect(dialog.querySelector('h2')?.textContent).toBe('Enterprise Administrators');
|
||
expect(dialog.textContent).toContain('Administrator');
|
||
await act(async () => dialog.querySelector<HTMLButtonElement>('.client-env-table-cell-link')?.click());
|
||
expect(document.querySelector('.client-env-table-dialog h2')?.textContent).toBe('Administrator');
|
||
expect(document.querySelector('.client-env-table-dialog')?.textContent).toContain('Administrator detail');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Administrator table"]')?.click());
|
||
expect(document.querySelector('.client-env-table-dialog h2')?.textContent).toBe('Enterprise Administrators');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Enterprise Administrators table"]')?.click());
|
||
expect(document.querySelector('.client-env-table-dialog')).toBeNull();
|
||
await act(async () => container.querySelectorAll<HTMLButtonElement>('.client-env-table-cell-link')[1]?.click());
|
||
expect(document.querySelector('.client-env-table-dialog')?.textContent).toContain('Graph legend.');
|
||
expect(document.querySelector('.client-env-table-dialog')?.textContent).toContain('original PingCastle report');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Operator graph detail"]')?.click());
|
||
await act(async () => container.querySelectorAll<HTMLButtonElement>('.client-env-table-cell-link')[2]?.click());
|
||
expect(document.querySelector('.client-env-table-dialog h2')?.textContent).toBe('Extra accounts');
|
||
expect(document.querySelector('.client-env-table-dialog')?.textContent).toContain('Second account');
|
||
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Extra accounts detail"]')?.click());
|
||
expect(container.textContent).not.toContain('Second account');
|
||
await act(async () => container.querySelector<HTMLButtonElement>('.client-env-table-detail-link')?.click());
|
||
expect(container.querySelectorAll('.client-env-evidence-table')).toHaveLength(2);
|
||
expect(container.textContent).toContain('Administrator');
|
||
expect(document.querySelector('.client-env-table-dialog')).toBeNull();
|
||
await act(async () => [...container.querySelectorAll<HTMLButtonElement>('.client-env-table-detail-link')]
|
||
.find(button => button.textContent === 'Extra accounts')?.click());
|
||
expect(container.textContent).toContain('Second account');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
it('uses only the matching retained subsection heading and fails closed on conflicting evidence', () => {
|
||
const userSection = { ...section('sectionUserInformation', 'Honey Pot', []), tables: [
|
||
{ name: 'Account list', sourceTableIndex: 38, headers: ['Name'], rows: [['Dummy']] },
|
||
{ name: 'Tabelle 40', sourceTableIndex: 39, headers: ['Nb User Accounts'], rows: [[590]] }
|
||
] };
|
||
const honeyPot = { ...observation('pingcastle.honeypot.name', 'Dummy'), sectionKey: userSection.sectionKey,
|
||
attributes: { sourceTableIndex: 38, subsectionLabel: 'Honey Pot' } };
|
||
const accountAnalysis = { ...observation('pingcastle.account.count', 590), sectionKey: userSection.sectionKey,
|
||
attributes: { sourceTableIndex: 39, subsectionLabel: 'Account analysis' } };
|
||
const matchingSnapshot = { ...snapshot, sections: [userSection], observations: [honeyPot, accountAnalysis] };
|
||
expect(environmentSectionTitle(matchingSnapshot, userSection)).toBe('User Information');
|
||
expect(environmentTableSubsection(matchingSnapshot, userSection, userSection.tables[0])).toBe('Honey Pot');
|
||
expect(environmentTableSubsection(matchingSnapshot, userSection, userSection.tables[1])).toBe('Account analysis');
|
||
expect(environmentTableSubsection({ ...matchingSnapshot, observations: [honeyPot,
|
||
{ ...accountAnalysis, importFileId: 'other-file' }] }, userSection, userSection.tables[1])).toBeUndefined();
|
||
expect(environmentTableSubsection({ ...matchingSnapshot, observations: [honeyPot, accountAnalysis,
|
||
{ ...accountAnalysis, id: 'conflict', attributes: { sourceTableIndex: 39, subsectionLabel: 'Different heading' } }] },
|
||
userSection, userSection.tables[1])).toBeUndefined();
|
||
});
|
||
|
||
it('uses the retained PingCastle heading instead of a technical section key', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const trusts = { ...section('sectionTrustsdetails', 'Discovered Domains', []),
|
||
prose: 'Trusts details This section focuses on the relations that this domain has with other domains',
|
||
tables: [{ name: 'Tabelle 1', presentation: 'inline' as const, headers: ['Trust Partner'], rows: [['example.local']] }] };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot,
|
||
sections: [trusts], observations: [] }} group="inventory"/>));
|
||
expect(container.querySelector('.client-env-evidence-section h4')?.textContent).toBe('Trusts details');
|
||
expect(container.querySelector('.client-env-evidence-prose')?.textContent).toBe('This section focuses on the relations that this domain has with other domains');
|
||
expect([...container.querySelectorAll('h4, h5')].some(heading => heading.textContent?.includes('Trustsdetails'))).toBe(false);
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('keeps category evidence separate while Source Overview retains every original source section and observation', () => {
|
||
expect(environmentEvidenceForGroup(snapshot, 'password-policy').sections.map(item => item.sectionKey)).toEqual(['sectionPasswordPolicies']);
|
||
expect(environmentEvidenceForGroup(snapshot, 'password-policy').observations.map(item => item.property)).toEqual(['password_policy.minimum_length']);
|
||
expect(environmentEvidenceForGroup(snapshot, 'certificates').sections.map(item => item.sectionKey)).toEqual(['sectionPKI']);
|
||
expect(environmentEvidenceForGroup(snapshot, 'inventory').observations.map(item => item.property)).toContain('domain.controller_count');
|
||
expect(environmentEvidenceForGroup(snapshot, 'source-metrics').observations.map(item => item.property)).toContain('entra.tenant_name');
|
||
expect(environmentEvidenceForGroup(snapshot, 'operating-systems').sections[0].tables[0].rows).toEqual([['Windows Server', 7, 'Unsupported count']]);
|
||
expect(environmentEvidenceForGroup(snapshot, 'all').sections).toHaveLength(snapshot.sections.length);
|
||
expect(environmentEvidenceForGroup(snapshot, 'all').observations).toHaveLength(snapshot.observations.length);
|
||
});
|
||
|
||
it('shows only original report values while retaining observation mapping internally', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const userSection = { ...section('sectionUserInformation', 'Honey Pot', []), tables: [
|
||
{ name: 'Account list', sourceTableIndex: 38, headers: ['Name'], rows: [['Decoy']] },
|
||
{ name: 'Tabelle 40', sourceTableIndex: 39, headers: ['Nb User Accounts'], rows: [[590]] }
|
||
] };
|
||
const linked = { ...observation('account.count', 590), sectionKey: userSection.sectionKey,
|
||
attributes: { sourceTableIndex: 39, subsectionLabel: 'Account analysis' } };
|
||
const decoy = { ...observation('honeypot.name', 'Decoy'), sectionKey: userSection.sectionKey,
|
||
attributes: { sourceTableIndex: 38, subsectionLabel: 'Honey Pot' } };
|
||
const unrelated = { ...observation('other.count', 10), sectionKey: userSection.sectionKey,
|
||
importFileId: 'other-file', attributes: { sourceTableIndex: 39 } };
|
||
const ambiguous = { ...observation('section.count', 2), sectionKey: userSection.sectionKey };
|
||
const data = { ...snapshot, sections: [userSection], observations: [decoy, linked, unrelated, ambiguous] };
|
||
const partition = partitionEnvironmentObservations(data.sections, data.observations);
|
||
expect(partition.bySection.get(userSection.id)?.tables[0]).toEqual([decoy]);
|
||
expect(partition.bySection.get(userSection.id)?.tables[1]).toEqual([linked]);
|
||
expect(partition.bySection.get(userSection.id)?.sectionObservations).toEqual([ambiguous]);
|
||
expect(partition.unassigned).toEqual([unrelated]);
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={data} group="all"/>));
|
||
const tables = [...container.querySelectorAll('.client-env-evidence-table')];
|
||
expect(tables).toHaveLength(2);
|
||
expect(tables[0].querySelector('h5')?.textContent).toBe('Honey Pot');
|
||
expect(tables[1].querySelector('h5')?.textContent).toBe('Account analysis');
|
||
expect(tables[1].textContent).not.toContain('Original report table');
|
||
expect(tables[1].textContent).not.toContain('mapped observations');
|
||
expect(tables[0].textContent).not.toContain('account.count');
|
||
expect(tables[1].querySelector('table')?.textContent).toContain('590');
|
||
expect(tables[1].textContent).not.toContain('Source observations');
|
||
expect(tables[1].textContent).not.toContain('account.count');
|
||
expect(container.querySelector('.client-env-table-open')).toBeNull();
|
||
expect(container.querySelector('.client-env-evidence-section h4')?.textContent).toBe('User Information');
|
||
expect(container.textContent).not.toContain('Source observations');
|
||
expect(container.textContent).not.toContain('Source tool: Dummy scanner');
|
||
expect(container.textContent).not.toContain('other.count');
|
||
expect(container.textContent).not.toContain('section.count');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('shows original table rows without technical source metadata', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={snapshot} group="password-policy"/>));
|
||
expect(container.querySelector('table')?.textContent).toContain('Minimum length');
|
||
expect(container.querySelector('table')?.textContent).toContain('History');
|
||
expect(container.querySelector('table')?.textContent).toContain('GPO B');
|
||
expect(container.querySelector('.client-env-evidence-prose')).toBeNull();
|
||
expect(snapshot.sections[0].prose).toBe('Password policies source prose');
|
||
expect(container.textContent).not.toContain('dummy-run');
|
||
expect(container.textContent).not.toContain('dummy-1');
|
||
expect(container.textContent).not.toContain('Enrollment enabled');
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={snapshot} group="all"/>));
|
||
expect(container.textContent).toContain('Unmapped report evidence');
|
||
expect(container.textContent).not.toContain('keep me');
|
||
expect(container.textContent).not.toContain('domain.count');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('retains repeated native titles and complete tables without inventing ordinals or group names', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const repeated = { ...section('sectionAdminGroups', 'Admin Groups', []), tables: [
|
||
{ name: 'Admin groups detail', headers: ['SamAccountName'], rows: [['Administrator']] },
|
||
{ name: 'Admin groups detail', headers: ['SamAccountName'], rows: [['Other member']] }
|
||
] };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot, sections: [repeated], observations: [] }} group="inventory"/>));
|
||
const tables = [...container.querySelectorAll<HTMLElement>('.client-env-evidence-table')];
|
||
expect(tables.map(table => table.querySelector('h5')?.textContent)).toEqual([
|
||
'Admin groups detail',
|
||
'Admin groups detail'
|
||
]);
|
||
expect(tables[1].querySelector('table')?.textContent).toContain('Other member');
|
||
expect(tables[1].textContent).not.toContain('Administrator');
|
||
expect(tables[0].querySelector('table')?.textContent).toContain('Administrator');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('renders historical PingCastle Admin Groups as a readable overview with retained source tables', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const pingcastle = { id: 'pingcastle', name: 'PingCastle' };
|
||
const adminSection: ScannerReportSection = { ...section('sectionAdminGroups', 'Groups', []), tool: pingcastle,
|
||
prose: 'Admin Groups This section is focused on groups critical for admin activities. Close Close All users in Admins groups [33] This feature is reserved for licensed customers. All delegations [209]',
|
||
tables: [
|
||
{ name: 'Admin groups list', sourceTableIndex: 54, headers: ['Group Name', 'Nb Admins'], rows: [['Domain Admins', 33]] },
|
||
{ name: 'Admin groups detail', sourceTableIndex: 55, headers: ['SamAccountName'], rows: [['Administrator']] },
|
||
{ name: 'Admin groups detail', sourceTableIndex: 56, headers: ['SamAccountName'], rows: [['Second administrator']] },
|
||
{ name: 'Delegations list', sourceTableIndex: 62, headers: ['OU'], rows: [['Servers']] }
|
||
] };
|
||
const linked = [54, 55, 56].map(index => ({ ...observation(`pingcastle.admin.${index}`, 1),
|
||
tool: pingcastle, sectionKey: 'sectionAdminGroups',
|
||
attributes: { sourceTableIndex: index, subsectionLabel: 'Groups' } }));
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot,
|
||
sections: [adminSection], observations: linked }} group="inventory"/>));
|
||
expect(container.querySelector('.client-env-evidence-section h4')?.textContent).toBe('Admin Groups');
|
||
expect(container.querySelector('.client-env-evidence-section > .client-env-evidence-prose')).toBeNull();
|
||
expect(container.querySelector('.client-env-evidence-source-text')).toBeNull();
|
||
expect([...container.querySelectorAll('.client-env-evidence-table h5')].map(node => node.textContent)).toEqual([
|
||
'Admin groups list', 'Admin groups detail', 'Admin groups detail', 'Delegations list'
|
||
]);
|
||
expect(container.querySelector('.client-env-table-inline-scroll')?.textContent).toContain('Domain Admins');
|
||
expect(container.querySelectorAll('.client-env-table-inline-scroll')).toHaveLength(4);
|
||
expect(container.querySelectorAll('table')[2].textContent).toContain('Second administrator');
|
||
expect(container.textContent).not.toMatch(/Source text|Close Close/);
|
||
expect(adminSection.prose).toContain('Close Close');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('keeps repeated legacy PingCastle graph controls out of Inventory prose while retaining the source', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const graphSection: ScannerReportSection = { ...section('sectionControlPathsAnalysis', 'Control Paths Analysis', []),
|
||
tool: { id: 'pingcastle', name: 'PingCastle' },
|
||
prose: 'Control Paths Analysis This section focuses on permissions issues. 0% Legend: u user m computer Close Close 0% Legend: u user m computer Close Close' };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot,
|
||
sections: [graphSection], observations: [] }} group="inventory"/>));
|
||
expect(container.querySelector('.client-env-evidence-section > .client-env-evidence-prose')).toBeNull();
|
||
expect(container.querySelector('.client-env-evidence-source-text')).toBeNull();
|
||
expect(container.textContent).not.toMatch(/Source text|Close Close|Legend:/);
|
||
expect(graphSection.prose).toContain('Close Close');
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('shows a named source table directly with search and complete pagination while preserving the original columns', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const longSnapshot = { ...snapshot, sections: [section('sectionPasswordPolicies', 'Long policies',
|
||
Array.from({ length: 101 }, (_, index) => [`Rule ${index + 1}`, index, `Evidence ${index + 1}`]))], observations: [] };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={longSnapshot} group="password-policy"/>));
|
||
const dialog = container.querySelector<HTMLElement>('.client-env-evidence-table')!;
|
||
expect(dialog.querySelector('h5')?.textContent).toBe('Long policies');
|
||
expect([...dialog.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual([
|
||
'Technical setting', 'Configured value', 'Extra evidence'
|
||
]);
|
||
expect(dialog.textContent).toContain('Rule 25');
|
||
expect(dialog.textContent).not.toContain('Rule 26');
|
||
const next = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Next')!;
|
||
for (let page = 0; page < 4; page++) await act(async () => next.click());
|
||
expect(dialog.textContent).toContain('Rule 101');
|
||
expect(dialog.textContent).toContain('Showing 101–101 of 101 rows');
|
||
const search = dialog.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||
await act(async () => {
|
||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, 'Rule 42');
|
||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||
});
|
||
expect(dialog.textContent).toContain('Rule 42');
|
||
expect(dialog.textContent).not.toContain('Rule 101');
|
||
expect(document.querySelector('.client-env-table-dialog')).toBeNull();
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
|
||
it('resizes a linked Admin Groups table popup from corners and keeps it inside the viewport', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const adminSection: ScannerReportSection = { ...section('sectionAdminGroups', 'Admin Groups', []), tables: [
|
||
{ name: 'Admin groups list', presentation: 'inline', headers: ['Group Name'], rows: [['Domain Administrators']],
|
||
cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'modalDomain-Administrators' }] },
|
||
{ name: 'Admin groups detail', presentation: 'modal', targetId: 'modalDomain-Administrators',
|
||
title: 'Domain Administrators', headers: ['SamAccountName', 'Enabled'], rows: [['Administrator', 'YES']] }
|
||
] };
|
||
const container = document.createElement('div'); document.body.append(container);
|
||
const root = createRoot(container);
|
||
const pointer = (type: string, x: number, y: number) => {
|
||
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y });
|
||
Object.defineProperty(event, 'pointerId', { value: 1 });
|
||
return event;
|
||
};
|
||
try {
|
||
await act(async () => root.render(<EnvironmentEvidenceTables snapshot={{ ...snapshot,
|
||
sections: [adminSection], observations: [] }} group="all"/>));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('.client-env-table-cell-link')?.click());
|
||
const dialog = document.querySelector<HTMLElement>('.client-env-table-dialog')!;
|
||
expect(dialog.querySelector('h2')?.textContent).toBe('Domain Administrators');
|
||
expect(dialog.querySelectorAll('.results-window-resize')).toHaveLength(8);
|
||
vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({ left: 100, top: 100, width: 700, height: 400,
|
||
right: 800, bottom: 500, x: 100, y: 100, toJSON: () => ({}) });
|
||
const corner = dialog.querySelector<HTMLElement>('.results-window-resize-se')!;
|
||
corner.setPointerCapture = vi.fn();
|
||
corner.hasPointerCapture = vi.fn().mockReturnValue(true);
|
||
corner.releasePointerCapture = vi.fn();
|
||
await act(async () => {
|
||
corner.dispatchEvent(pointer('pointerdown', 800, 500));
|
||
corner.dispatchEvent(pointer('pointermove', 920, 600));
|
||
corner.dispatchEvent(pointer('pointerup', 920, 600));
|
||
});
|
||
expect(dialog.style.width).toBe('820px');
|
||
expect(dialog.style.height).toBe('500px');
|
||
expect(dialog.style.left).toBe('100px');
|
||
expect(dialog.style.top).toBe('100px');
|
||
const oppositeCorner = dialog.querySelector<HTMLElement>('.results-window-resize-nw')!;
|
||
oppositeCorner.setPointerCapture = vi.fn();
|
||
oppositeCorner.hasPointerCapture = vi.fn().mockReturnValue(true);
|
||
oppositeCorner.releasePointerCapture = vi.fn();
|
||
await act(async () => {
|
||
oppositeCorner.dispatchEvent(pointer('pointerdown', 100, 100));
|
||
oppositeCorner.dispatchEvent(pointer('pointermove', 50, 60));
|
||
oppositeCorner.dispatchEvent(pointer('pointerup', 50, 60));
|
||
});
|
||
expect(dialog.style.left).toBe('50px');
|
||
expect(dialog.style.top).toBe('60px');
|
||
expect(dialog.style.width).toBe('750px');
|
||
expect(dialog.style.height).toBe('440px');
|
||
await act(async () => {
|
||
corner.dispatchEvent(pointer('pointerdown', 800, 500));
|
||
corner.dispatchEvent(pointer('pointermove', 3000, 3000));
|
||
corner.dispatchEvent(pointer('pointerup', 3000, 3000));
|
||
});
|
||
expect(Number.parseInt(dialog.style.left) + Number.parseInt(dialog.style.width)).toBeLessThanOrEqual(window.innerWidth - 8);
|
||
expect(Number.parseInt(dialog.style.top) + Number.parseInt(dialog.style.height)).toBeLessThanOrEqual(window.innerHeight - 8);
|
||
expect([...dialog.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['SamAccountName', 'Enabled']);
|
||
} finally { await act(async () => root.unmount()); container.remove(); }
|
||
});
|
||
});
|