340 lines
20 KiB
TypeScript
340 lines
20 KiB
TypeScript
// @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', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
|
|
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
|
|
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<typeof useApp>;
|
|
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<HTMLElement>('.client-overall')
|
|
: [...container.querySelectorAll<HTMLElement>('.client-environment-grid > section')]
|
|
.find(item => item.querySelector('h3')?.textContent?.includes(title));
|
|
const trigger = tile?.querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]');
|
|
expect(trigger).toBeTruthy();
|
|
await act(async () => trigger?.click());
|
|
return document.body.querySelector<HTMLElement>('.floating-data-window')!;
|
|
};
|
|
const close = async (title: string) => {
|
|
await act(async () => document.body.querySelector<HTMLButtonElement>(`button[aria-label="Close ${title} window"]`)?.click());
|
|
};
|
|
const nativeTable = (window: HTMLElement, name: string) => {
|
|
const section = [...window.querySelectorAll<HTMLElement>('.client-env-evidence-table')]
|
|
.find(item => item.querySelector('h5')?.textContent?.includes(name));
|
|
expect(section).toBeTruthy();
|
|
return section!;
|
|
};
|
|
try {
|
|
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
|
|
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 = ['Resilience', 'Inventory', 'Password policy',
|
|
'Certificates & PKI', 'Operating systems'];
|
|
try {
|
|
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
|
|
const tiles = [...container.querySelectorAll<HTMLElement>('.client-environment-grid > section')];
|
|
expect(tiles).toHaveLength(titles.length + 1);
|
|
expect(tiles.at(-1)?.getAttribute('aria-label')).toBe('Cloud Information');
|
|
expect(tiles.at(-1)?.querySelectorAll('dd')).toHaveLength(4);
|
|
expect(container.querySelector('.client-environment-detail')).toBeNull();
|
|
// Nutzerentscheidung 2026-10-01: jede Umgebungskachel zeigt hoechstens vier Fakten;
|
|
// alle weiteren bleiben ueber Results erreichbar.
|
|
for (const tile of tiles) expect(tile.querySelectorAll('.client-env-facts > div').length).toBeLessThanOrEqual(4);
|
|
expect(container.querySelector('.client-domain-information')?.textContent).toContain('NetBIOS name');
|
|
const inventoryFacts = [...tiles[1].querySelectorAll('.client-env-facts > div')];
|
|
// User accounts, Computer objects und Domain controllers stehen schon gleichlautend in Data (2026-09-26).
|
|
expect(inventoryFacts.map(fact => fact.querySelector('dt')?.textContent)).toEqual([
|
|
'Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts'
|
|
]);
|
|
for (const [index, title] of titles.entries()) {
|
|
const trigger = tiles[index].querySelector<HTMLButtonElement>('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<HTMLElement>('.floating-data-window');
|
|
expect(floating?.querySelector('h2')?.textContent).toBe(title);
|
|
expect(floating?.querySelector('.floating-data-window-body')?.textContent?.trim()).not.toBe('');
|
|
if (title === 'Operating systems') expect(floating?.textContent).toContain('No classified inventory available');
|
|
const close = [...(floating?.querySelectorAll<HTMLButtonElement>('.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(<DashboardPanel onOpenWork={openWork}/>));
|
|
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%, Of assessed checks');
|
|
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(container.querySelector('.client-overview-main > .client-domain-information')).not.toBeNull();
|
|
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<HTMLButtonElement>('.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(<DashboardPanel onOpenWork={openWork}/>));
|
|
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.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(<DashboardPanel onOpenWork={openWork}/>));
|
|
await act(async () => container.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.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(<DashboardPanel onOpenWork={openWork}/>);
|
|
const select = () => container.querySelector<HTMLSelectElement>('.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(<SearchFilterBar/>));
|
|
const select = container.querySelector<HTMLSelectElement>('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()); }
|
|
});
|
|
});
|