Assessor_2.0/tests/features/environment-evidence-result...

610 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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(); }
});
});