// @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; import { AssessorApiClient } from '../../src/api/assessor-api-client'; import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar'; import { makeSheet } from '../../src/importers/sheet-factory'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; import type { ImportedDataset } from '../../src/domain/dataset'; import type { EnvironmentSnapshot } from '../../src/domain/environment-context'; import type { DynamicFacetFilter } from '../../src/features/search-filter/dynamic-facets'; vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function fixture() { const sheet = makeSheet('Synthetic', [ ['Name', 'Status', 'Severity', 'Source: Scan'], ['Synthetic A', 'Passed', 'Low', 'Scanner A'], ['Synthetic B', 'Failed', 'High', 'Scanner B'], ['Synthetic C', 'Failed', 'Critical', 'Scanner B'] ], '', 'synthetic.csv', { isProjectSheet: true }); const dataset = { fileName: 'Synthetic', sheets: [sheet], scanTool: 'Generic' } as ImportedDataset; const state = { dataset, activeSheet: sheet, activeSheetId: sheet.id, activeProfile: genericProfile, reviews: {}, facetFilters: {}, search: '', statusFilter: 'all', pathFindingIds: [], actionOnly: false, view: 'table', setView: vi.fn(), resetFilters: vi.fn(), setActionOnly: vi.fn(), setStatusFilter: vi.fn(), setPathFindingIds: vi.fn(), setActiveSheet: vi.fn(), setFacetFilter: vi.fn((id: string, filter: DynamicFacetFilter | null) => { state.facetFilters = { ...state.facetFilters }; if (filter) state.facetFilters[id] = filter; else delete state.facetFilters[id]; }) } as unknown as ReturnType; vi.mocked(useApp).mockImplementation(() => state); return state; } describe('source selection preserves the current view', () => { it('opens the real Results action with retained native rows that are not on the Environment tiles', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; }); const state = fixture(); state.targetProjectId = 'dummy-project'; const tool = { id: 'dummy', name: 'Dummy scanner' }; const makeSection = (id: string, tableName: string, extra: string) => ({ version: 1 as const, id, scanRunId: 'dummy-run', importFileId: 'dummy-file', tool, sectionKey: id, label: tableName, ordinal: 1, state: 'observed' as const, tables: [{ name: tableName, headers: ['Name', 'Value', 'Native detail'], rows: [['Synthetic fact', '1', extra]] }], source: { rowIndex: 5 }, extractorVersion: 'dummy-1', attributes: {} }); const snapshot: EnvironmentSnapshot = { version: 1, projectId: 'dummy-project', scanRunIds: ['dummy-run'], generatedAt: '2026-01-01T00:00:00Z', coverage: { observed: 3, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, sections: [ makeSection('sectionPasswordPolicies', 'Password policies', 'Native password detail'), makeSection('sectionComputerInformation', 'Operating systems', 'Native OS detail'), { ...makeSection('sectionUserInformation', 'Honey Pot', 'unused'), label: 'Honey Pot', tables: [ { name: 'Account list', sourceTableIndex: 38, headers: ['Name'], rows: [['Dummy honeypot']] }, { name: 'Tabelle 40', sourceTableIndex: 39, headers: ['Nb User Accounts'], rows: [[590]] } ] }, makeSection('other-section', 'Unclassified native table', 'Native unmapped detail') ], observations: [ { version: 1, id: 'dummy-honeypot', scanRunId: 'dummy-run', importFileId: 'dummy-file', tool, subject: { type: 'user-information.honey-pot' }, property: 'pingcastle.honeypot.name', label: 'Name', originalValue: 'Dummy honeypot', valueType: 'string', state: 'observed', sectionKey: 'sectionUserInformation', source: { rowIndex: 1 }, mappingVersion: 'dummy-1', attributes: { sourceTableIndex: 38, subsectionLabel: 'Honey Pot' } }, { version: 1, id: 'dummy-account-analysis', scanRunId: 'dummy-run', importFileId: 'dummy-file', tool, subject: { type: 'user-information.account-analysis' }, property: 'pingcastle.account.count', label: 'Nb User Accounts', originalValue: 590, valueType: 'number', state: 'observed', sectionKey: 'sectionUserInformation', source: { rowIndex: 1 }, mappingVersion: 'dummy-1', attributes: { sourceTableIndex: 39, subsectionLabel: 'Account analysis' } } ] }; vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot); vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); const open = async (title: string) => { const tile = title === 'Overall Score' ? container.querySelector('.client-overall') : [...container.querySelectorAll('.client-environment-grid > section')] .find(item => item.querySelector('h3')?.textContent?.includes(title)); const trigger = tile?.querySelector('button[aria-haspopup="dialog"]'); expect(trigger).toBeTruthy(); await act(async () => trigger?.click()); return document.body.querySelector('.floating-data-window')!; }; const close = async (title: string) => { await act(async () => document.body.querySelector(`button[aria-label="Close ${title} window"]`)?.click()); }; const nativeTable = (window: HTMLElement, name: string) => { const section = [...window.querySelectorAll('.client-env-evidence-table')] .find(item => item.querySelector('h5')?.textContent?.includes(name)); expect(section).toBeTruthy(); return section!; }; try { await act(async () => root.render()); const password = await open('Password policy'); expect(nativeTable(password, 'Password policies').textContent).toContain('Native password detail'); expect(password.textContent).not.toContain('Native OS detail'); expect(document.body.querySelector('.client-env-table-dialog')).toBeNull(); expect(password.isConnected).toBe(true); await close('Password policy'); const inventory = await open('Inventory'); expect([...inventory.querySelectorAll('h4')].map(item => item.textContent).join(' | ')).toContain('User Information'); expect([...inventory.querySelectorAll('h5')].map(item => item.textContent)).toEqual(expect.arrayContaining([ expect.stringContaining('Honey Pot'), expect.stringContaining('Account analysis') ])); expect(inventory.textContent).not.toContain('Tabelle 40'); expect(inventory.textContent).not.toContain('Source table index'); expect(inventory.textContent).not.toContain('sourceTableIndex'); expect(nativeTable(inventory, 'Account analysis').textContent).toContain('590'); expect(nativeTable(inventory, 'Account analysis').textContent).not.toContain('Dummy honeypot'); await close('Inventory'); const os = await open('Operating systems'); expect(nativeTable(os, 'Operating systems').textContent).toContain('Native OS detail'); await close('Operating systems'); const overview = await open('Overall Score'); expect(overview.textContent).toContain('Source result contributions'); expect(overview.textContent).toContain('Source finding records'); expect(overview.textContent).toContain('Synthetic A'); for (const [name, detail] of [ ['Password policies', 'Native password detail'], ['Operating systems', 'Native OS detail'], ['Unclassified native table', 'Native unmapped detail'] ]) { expect(nativeTable(overview, name).textContent).toContain(detail); } await close('Overall Score'); } finally { await act(async () => root.unmount()); container.remove(); } }); it('keeps square environment tiles visible and opens their complete details through Results', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; }); fixture(); const container = document.createElement('div'); document.body.append(container); const root = createRoot(container); const titles = ['Directory', 'Resilience', 'Inventory', 'Password policy', 'Certificates & PKI', 'Source metrics', 'Operating systems']; try { await act(async () => root.render()); const tiles = [...container.querySelectorAll('.client-environment-grid > section')]; expect(tiles).toHaveLength(titles.length); expect(container.querySelector('.client-environment-detail')).toBeNull(); // Facts already shown in Domain Information or Data stay in Results only (2026-09-26). expect(tiles[0].querySelectorAll('.client-env-facts > div')).toHaveLength(4); expect(tiles[0].textContent).toContain('NetBIOS name'); const inventoryFacts = [...tiles[2].querySelectorAll('.client-env-facts > div')]; expect(inventoryFacts).toHaveLength(5); expect(inventoryFacts.map(fact => fact.querySelector('dt')?.textContent)).toEqual([ 'Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host' ]); for (const [index, title] of titles.entries()) { const trigger = tiles[index].querySelector('button[aria-haspopup="dialog"]'); expect(trigger?.textContent).toBe('Results'); expect(trigger?.getAttribute('aria-label')).toBe(`Open ${title} results`); expect(trigger?.classList.contains('results-btn')).toBe(true); await act(async () => trigger?.click()); const floating = document.body.querySelector('.floating-data-window'); expect(floating?.querySelector('h2')?.textContent).toBe(title); expect(floating?.querySelector('.floating-data-window-body')?.textContent?.trim()).not.toBe(''); if (title === 'Directory') { expect(floating?.textContent).toContain('NetBIOS name'); expect(floating?.textContent).toContain('No retained scanner context available'); } if (title === 'Operating systems') expect(floating?.textContent).toContain('No classified inventory available'); const close = [...(floating?.querySelectorAll('.floating-data-window-actions button') ?? [])] .find(button => button.getAttribute('aria-label') === `Close ${title} window`); expect(close).toBeTruthy(); await act(async () => close?.click()); expect(document.body.querySelector('.floating-data-window')).toBeNull(); expect(document.activeElement).toBe(trigger); } } finally { await act(async () => root.unmount()); container.remove(); } }); it('shows the Data tile first and opens its deviation count in Work', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const state = fixture(); const openWork = vi.fn(); const container = document.createElement('div'); const root = createRoot(container); try { await act(async () => root.render()); const header = container.querySelector('.client-priority-findings > header'); expect(header?.querySelector('h2')?.textContent).toBe('Data'); expect(header?.textContent).toContain('Deviations'); expect(header?.querySelector('.client-priority-total')?.getAttribute('aria-label')).toBe('Deviations: 2, 67%'); expect(header?.textContent).not.toContain('Critical'); expect(header?.textContent).not.toContain('High'); const priorityTiles = container.querySelectorAll('.client-priority > section'); expect(priorityTiles[0]?.classList.contains('client-priority-findings')).toBe(true); expect(priorityTiles[1]?.classList.contains('client-domain-information')).toBe(true); const dataFacts = priorityTiles[0]?.querySelector('.client-data-facts'); expect(dataFacts?.querySelectorAll('.client-data-fact')).toHaveLength(6); expect(dataFacts?.querySelectorAll('.client-glyph')).toHaveLength(6); expect(container.querySelector('.client-overview-facts')).toBeNull(); await act(async () => container.querySelector('.client-priority-total')!.click()); expect(state.setStatusFilter).toHaveBeenCalledWith('deviation'); expect(openWork).toHaveBeenCalledOnce(); } finally { await act(async () => root.unmount()); } }); it('shows clickable Gaps in the Data status row and opens the counted records in Work', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const state = fixture(); const sheet = makeSheet('Synthetic with gap', [ ['Name', 'Status', 'Severity', 'Source: Scan'], ['Assessed', 'Passed', 'Low', 'Scanner A'], ['Unassessed', 'Not Run', 'Low', 'Scanner A'] ], '', 'synthetic.csv', { isProjectSheet: true }); state.dataset = { ...state.dataset!, sheets: [sheet] }; state.activeSheet = sheet; state.activeSheetId = sheet.id; const openWork = vi.fn(); const container = document.createElement('div'); const root = createRoot(container); try { await act(async () => root.render()); const buttons = [...container.querySelectorAll('.client-priority-context button')]; expect(buttons.map(button => button.querySelector('span')?.textContent)).toEqual([ 'Passed', 'Not configured', 'Duplicates', 'Conflicts', 'Gaps' ]); expect(buttons[4].querySelector('strong')?.textContent).toBe('1'); expect(buttons[4].disabled).toBe(false); await act(async () => buttons[4].click()); expect(state.resetFilters).toHaveBeenCalledOnce(); expect(state.setStatusFilter).toHaveBeenLastCalledWith('all'); expect(state.setPathFindingIds).toHaveBeenCalledWith([sheet.analysis.records[1].id]); expect(openWork).toHaveBeenCalledOnce(); } finally { await act(async () => root.unmount()); } }); it('omits the generic Tabular Data row from Source Overview Results', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const state = fixture(); const sheet = makeSheet('Synthetic with generic source', [ ['Name', 'Status', 'Severity', 'Source: Scan'], ['Recognized result', 'Passed', 'Low', 'Scanner A'], ['Generic row', 'Failed', 'High', 'Tabular Data'] ], '', 'synthetic.csv', { isProjectSheet: true }); state.dataset = { ...state.dataset!, sheets: [sheet] }; state.activeSheet = sheet; state.activeSheetId = sheet.id; const openWork = vi.fn(); const container = document.createElement('div'); const root = createRoot(container); try { await act(async () => root.render()); await act(async () => container.querySelector('.client-overall button[aria-haspopup="dialog"]')!.click()); const sourceRows = [...document.body.querySelectorAll('.floating-data-window .client-environment-sources li')] .map(row => row.textContent ?? ''); expect(sourceRows).toHaveLength(1); expect(sourceRows[0]).toContain('Scanner A'); expect(sourceRows.join(' ')).not.toContain('Tabular Data'); await act(async () => document.body.querySelector('.floating-data-window .client-environment-sources li button')!.click()); expect(openWork).toHaveBeenCalledOnce(); expect(document.body.querySelector('.floating-data-window')).toBeNull(); } finally { await act(async () => root.unmount()); } }); it('updates Client Info in place, retains every source option, resets to All and reuses unchanged analysis', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const state = fixture(); const openWork = vi.fn(); const container = document.createElement('div'); const root = createRoot(container); const render = () => root.render(); const select = () => container.querySelector('.client-overview-source select')!; const deviations = () => container.querySelector('.client-priority-findings header strong')?.textContent; const passed = () => container.querySelector('.client-priority-context strong')?.textContent; const change = async (value: string) => { await act(async () => { select().value = value; select().dispatchEvent(new Event('change', { bubbles: true })); }); await act(async () => render()); }; try { await act(async () => render()); expect(deviations()).toBe('2'); await change('scanner a'); expect(select().value).toBe('scanner a'); expect(select().options).toHaveLength(3); expect(deviations()).toBe('0'); expect(passed()).toBe('1'); expect(container.querySelectorAll('.client-overall button[aria-haspopup="dialog"]')).toHaveLength(1); // An unchanged source must reuse the dataset-owned analysis, including after remount. let reads = 0; for (const record of state.activeSheet!.analysis.records) { const fields = record.fields; Object.defineProperty(record, 'fields', { get: () => { reads++; return fields; }, configurable: true }); } await act(async () => root.render(null)); await act(async () => render()); expect(reads).toBe(0); expect(select().value).toBe('scanner a'); await change('scanner b'); expect(deviations()).toBe('2'); expect(passed()).toBe('0'); await change('all'); expect(deviations()).toBe('2'); expect(passed()).toBe('1'); expect(state.facetFilters['provenance:source-scan']).toBeUndefined(); expect(openWork).not.toHaveBeenCalled(); expect(state.setView).not.toHaveBeenCalled(); expect(state.resetFilters).not.toHaveBeenCalled(); expect(state.view).toBe('table'); // Replacing the loaded timeslot cannot reuse the previous source totals. const replacement = makeSheet('Next synthetic timeslot', [ ['Name', 'Status', 'Source: Scan'], ['Synthetic next', 'Passed', 'Scanner C'] ], '', 'synthetic.csv', { isProjectSheet: true }); state.dataset = { ...state.dataset!, sheets: [replacement] }; state.activeSheet = replacement; await act(async () => render()); expect(select().value).toBe('all'); expect([...select().options].map(option => option.value)).toEqual(['all', 'scanner c']); expect(deviations()).toBe('0'); expect(passed()).toBe('1'); } finally { await act(async () => root.unmount()); } }); it.each(['columns', 'table', 'records', 'raw'] as const)('keeps the %s Work view when changing Source', async view => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const state = fixture(); state.view = view; const container = document.createElement('div'); const root = createRoot(container); try { await act(async () => root.render()); const select = container.querySelector('select[aria-label="Source: Scan"]')!; await act(async () => { select.value = 'scanner b'; select.dispatchEvent(new Event('change', { bubbles: true })); }); expect(state.facetFilters['provenance:source-scan']?.values).toEqual(['scanner b']); expect(state.setView).not.toHaveBeenCalled(); expect(state.view).toBe(view); } finally { await act(async () => root.unmount()); } }); });