215 lines
14 KiB
TypeScript
215 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, createElement } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../src/domain/environment-context';
|
|
import { operatingSystemGroup, operatingSystemInventory, operatingSystemEolCount } from '../../src/features/dashboard/operating-system-inventory';
|
|
import { OperatingSystemInventory } from '../../src/features/dashboard/OperatingSystemInventory';
|
|
|
|
function os(name: string, count: unknown, row: number, run = 'run'): EnvironmentObservation[] {
|
|
return [['operating_system.name', name], ['operating_system.count', count]].map(([property, raw], column) => ({
|
|
version: 1, id: `${run}:${row}:${column}`, scanRunId: run, importFileId: `${run}:file`, tool: { id: 'pingcastle', name: 'PingCastle' },
|
|
subject: { type: 'computer-information.operating-systems', identity: name }, property: String(property),
|
|
label: column ? 'Nb OS' : 'Operating system', originalValue: raw, normalizedValue: raw, valueType: 'string', state: 'observed',
|
|
sectionKey: 'sectionComputerInformation', source: { rowIndex: row, columnIndex: column },
|
|
attributes: { tableName: 'Operating systems', sourceTableIndex: 0 }, mappingVersion: '1'
|
|
})) as EnvironmentObservation[];
|
|
}
|
|
function osStates(row: number, values: Record<'Active' | 'Inactive' | 'Enabled' | 'Disabled', unknown>, run = 'run'): EnvironmentObservation[] {
|
|
return Object.entries(values).map(([label, raw], column) => ({
|
|
version: 1, id: `${run}:${row}:state:${column}`, scanRunId: run, importFileId: `${run}:file`, tool: { id: 'pingcastle', name: 'PingCastle' },
|
|
subject: { type: 'computer-information.operating-systems', identity: `row-${row}` },
|
|
property: `pingcastle.sectioncomputerinformation.operating.system.list.nb.${label.toLowerCase()}`,
|
|
label: `Nb ${label}`, originalValue: raw, normalizedValue: raw, valueType: 'string', state: 'observed',
|
|
sectionKey: 'sectionComputerInformation', source: { rowIndex: row, columnIndex: column + 2 },
|
|
attributes: { tableName: 'Operating systems', sourceTableIndex: 0 }, mappingVersion: '1'
|
|
})) as EnvironmentObservation[];
|
|
}
|
|
function snapshot(observations: EnvironmentObservation[]): EnvironmentSnapshot {
|
|
return { version: 1, projectId: 'synthetic', scanRunIds: ['run'], generatedAt: '', sections: [], observations,
|
|
coverage: { observed: observations.length, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 } };
|
|
}
|
|
|
|
describe('operating system inventory', () => {
|
|
it.each(['dark', 'light'])('omits EOL OS from the tile while preserving original Results evidence in %s', theme => {
|
|
document.documentElement.dataset.theme = theme;
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
|
|
const eol: EnvironmentObservation = { ...os('Windows 11', 2, 1)[1], id: 'eol', label: 'EOL OS',
|
|
property: 'pingcastle.sectioncomputerinformation.account.analysis.eol.os', originalValue: '2', normalizedValue: 2,
|
|
source: { objectPath: 'synthetic/account-analysis/eol', rowIndex: 1, columnIndex: 0 }, attributes: { tableName: 'Account analysis', sourceTableIndex: 1 } };
|
|
const source = snapshot([...os('Windows Server 2019', 1, 1), eol]);
|
|
source.sections = [{ version: 1, id: 'computer', scanRunId: 'run', importFileId: 'run:file',
|
|
tool: eol.tool, sectionKey: 'sectionComputerInformation', label: 'Computer Information', ordinal: 1,
|
|
state: 'observed', tables: [{ name: 'Account analysis', sourceTableIndex: 1, headers: ['EOL OS'], rows: [['2']] }],
|
|
source: { objectPath: 'synthetic/account-analysis' }, extractorVersion: '2.2.1', attributes: {} }];
|
|
const before = JSON.stringify(source);
|
|
const container = document.createElement('div'); document.body.append(container);
|
|
const root = createRoot(container);
|
|
try {
|
|
act(() => root.render(createElement(OperatingSystemInventory, { groups: operatingSystemInventory(source), snapshot: source })));
|
|
expect(container.textContent).not.toContain('EOL OS');
|
|
expect([...container.querySelectorAll('.client-env-facts dt')].map(node => node.textContent))
|
|
.toEqual(['Servers', 'Windows Server 2019']);
|
|
expect(container.querySelector('.client-env-facts dd')?.textContent).toBe('1');
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Open Operating systems results"]')!.click());
|
|
expect(document.body.querySelector('.floating-data-window')?.textContent).toContain('EOL OS');
|
|
expect(document.body.querySelector('.floating-data-window')?.textContent).toContain('Account analysis');
|
|
expect(eol.source.objectPath).toBe('synthetic/account-analysis/eol');
|
|
expect(JSON.stringify(source)).toBe(before);
|
|
} finally { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }
|
|
});
|
|
|
|
it.each(['dark', 'light'])('keeps the empty tile free of an EOL placeholder in %s', theme => {
|
|
document.documentElement.dataset.theme = theme;
|
|
const markup = renderToStaticMarkup(createElement(OperatingSystemInventory, { groups: operatingSystemInventory() }));
|
|
expect(markup).toContain('Operating systems');
|
|
expect(markup).toContain('Results');
|
|
expect(markup).toContain('<dt>Inventory</dt><dd>—</dd>');
|
|
expect(markup).not.toContain('EOL OS');
|
|
});
|
|
|
|
it('keeps missing, invalid and multiple EOL totals unknown and preserves explicit zero', () => {
|
|
const eol = { ...os('Windows 11', 0, 1)[1], label: 'Nb EOL OS' };
|
|
expect(operatingSystemEolCount(snapshot([eol]))).toBe(0);
|
|
expect(operatingSystemEolCount(snapshot(os('Windows XP', 99, 1)))).toBeUndefined();
|
|
for (const raw of ['', null, -1, 'invalid', true]) {
|
|
expect(operatingSystemEolCount(snapshot([{ ...eol, originalValue: raw, normalizedValue: raw }]))).toBeUndefined();
|
|
}
|
|
expect(operatingSystemEolCount(snapshot([eol, { ...eol, scanRunId: 'other' }]))).toBeUndefined();
|
|
expect(operatingSystemEolCount(snapshot([eol, { ...eol, originalValue: 1, normalizedValue: 1 }]))).toBeUndefined();
|
|
expect(operatingSystemEolCount(snapshot([{ ...eol, state: 'not-assessed' }]))).toBeUndefined();
|
|
expect(operatingSystemEolCount(snapshot([{ ...eol, sectionKey: 'sectionUserInformation' }]))).toBeUndefined();
|
|
});
|
|
it('splits server/client products, retains other systems and pairs exact native counts including zero', () => {
|
|
const source = snapshot([...os('Windows Server 2022', 12, 1), ...os('Windows 11 24H2', '1,200', 2),
|
|
...os('Windows 10 22H2', 0, 3), ...os('Debian', 4, 4), ...os('OperatingSystem not set', 3, 5)]);
|
|
const before = JSON.stringify(source);
|
|
const groups = operatingSystemInventory(source);
|
|
expect(groups.map(group => [group.label, group.total])).toEqual([['Servers', 12], ['Clients', 1200], ['Other / unclassified', 7]]);
|
|
expect(groups[1].entries.map(entry => entry.count)).toEqual([1200, 0]);
|
|
expect(renderToStaticMarkup(createElement(OperatingSystemInventory, { groups }))).toContain('1,200');
|
|
expect(JSON.stringify(source)).toBe(before);
|
|
});
|
|
it('does not infer Linux roles, supports explicit roles and preserves conflicting classifications', () => {
|
|
expect(operatingSystemGroup('Debian')).toBe('Other / unclassified');
|
|
expect(operatingSystemGroup('Debian', ['Server'])).toBe('Servers');
|
|
expect(operatingSystemGroup('Windows 11', ['Server', 'Workstation'])).toBe('Other / unclassified');
|
|
expect(operatingSystemGroup('Windows 2019')).toBe('Servers');
|
|
expect(operatingSystemGroup('Windows 2000')).toBe('Other / unclassified');
|
|
});
|
|
it('keeps missing, null, negative, boolean and malformed counts unknown rather than zero', () => {
|
|
for (const raw of [undefined, null, '', -1, true, '1.5', '1,2', 'unknown']) {
|
|
const group = operatingSystemInventory(snapshot(os('Windows 11', raw, 1)))[1];
|
|
expect(group.entries[0].count).toBeUndefined();
|
|
expect(group.total).toBeUndefined();
|
|
}
|
|
expect(operatingSystemInventory().every(group => group.total === undefined)).toBe(true);
|
|
});
|
|
it('never borrows counts from another report, table or source row', () => {
|
|
const fields = os('Windows 11', 2, 1);
|
|
fields[1].source.rowIndex = 2;
|
|
const foreign = os('Windows Server 2022', 99, 1, 'foreign');
|
|
const groups = operatingSystemInventory(snapshot([...fields, ...foreign]));
|
|
expect(groups[1].entries[0].count).toBeUndefined();
|
|
expect(groups[0].total).toBe(99);
|
|
fields[1].source.rowIndex = 1;
|
|
fields[1].attributes.sourceTableIndex = 2;
|
|
expect(operatingSystemInventory(snapshot(fields))[1].total).toBeUndefined();
|
|
});
|
|
it('does not sum duplicate OS rows or overlapping inventories and invalidates on changed inputs', () => {
|
|
const rows = [...os('Windows 11', 4, 1), ...os('Windows 11', 4, 2)];
|
|
expect(operatingSystemInventory(snapshot(rows))[1].total).toBeUndefined();
|
|
const groups = operatingSystemInventory(snapshot([...os('Windows 11', 4, 1), ...os('Windows 11', 7, 1, 'other')]));
|
|
expect(groups[1].total).toBeUndefined();
|
|
expect(groups[1].entries.map(entry => entry.count)).toEqual([7, 4]);
|
|
expect(renderToStaticMarkup(createElement(OperatingSystemInventory, { groups }))).not.toContain('overlapping counts are not added');
|
|
expect(operatingSystemInventory(snapshot(os('Windows 11', 8, 1)))[1].total).toBe(8);
|
|
});
|
|
|
|
it('shows the complete quantified inventory total when another inventory reports names without counts', () => {
|
|
const groups = operatingSystemInventory(snapshot([
|
|
...os('Windows Server 2022', 40, 1), ...os('Windows Server 2025', 19, 2),
|
|
...os('Windows Server 2016', 10, 3), ...os('Windows Server 2019', 3, 4),
|
|
...os('Windows 2022', undefined, 1, 'other')
|
|
]));
|
|
|
|
expect(groups[0].total).toBe(72);
|
|
expect(groups[0].entries.find(entry => entry.name === 'Windows 2022')?.count).toBeUndefined();
|
|
});
|
|
|
|
it('shows PingCastle active, inactive, enabled and disabled counts from the exact OS source row', () => {
|
|
const groups = operatingSystemInventory(snapshot([
|
|
...os('Windows Server 2022', 12, 1),
|
|
...osStates(1, { Active: 10, Inactive: 2, Enabled: 9, Disabled: 3 }),
|
|
...os('Windows 11', 8, 2)
|
|
]));
|
|
|
|
expect(groups[0].entries[0]).toMatchObject({ count: 12, active: 10, inactive: 2, enabled: 9, disabled: 3 });
|
|
expect(groups[1].entries[0]).toMatchObject({ count: 8, active: undefined, inactive: undefined, enabled: undefined, disabled: undefined });
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
|
|
const container = document.createElement('div');
|
|
document.body.append(container);
|
|
const root = createRoot(container);
|
|
act(() => root.render(createElement(OperatingSystemInventory, { groups })));
|
|
expect(container.querySelectorAll('.client-env-facts > div')).toHaveLength(4);
|
|
act(() => container.querySelector<HTMLButtonElement>('[aria-label="Open Operating systems results"]')?.click());
|
|
const table = document.body.querySelector('.floating-data-window .client-os-group table');
|
|
['Active', 'Inactive', 'Enabled', 'Disabled'].forEach(label => expect(table?.textContent).toContain(label));
|
|
expect(table?.textContent).toContain('10');
|
|
expect(table?.textContent).toContain('3');
|
|
expect(document.body.querySelector('.floating-data-window')?.textContent).toContain('—');
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it('does not borrow or guess PingCastle state counts across source rows', () => {
|
|
const groups = operatingSystemInventory(snapshot([
|
|
...os('Windows Server 2022', 12, 1),
|
|
...osStates(2, { Active: 10, Inactive: 2, Enabled: 9, Disabled: 3 })
|
|
]));
|
|
expect(groups[0].entries[0]).toMatchObject({
|
|
count: 12, active: undefined, inactive: undefined, enabled: undefined, disabled: undefined
|
|
});
|
|
});
|
|
|
|
it('opens the table in a movable window, maximizes it and restores focus after closing', () => {
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
|
|
const container = document.createElement('div');
|
|
document.body.append(container);
|
|
const root = createRoot(container);
|
|
const groups = operatingSystemInventory(snapshot([
|
|
...os('Windows Server 2022', 12, 1),
|
|
...os('Windows Server 2025', 4, 1, 'other')
|
|
]));
|
|
|
|
act(() => root.render(createElement(OperatingSystemInventory, { groups })));
|
|
const trigger = container.querySelector<HTMLButtonElement>('[aria-label="Open Operating systems results"]');
|
|
expect(trigger).not.toBeNull();
|
|
expect(trigger?.textContent).toBe('Results');
|
|
expect(trigger?.classList.contains('results-btn')).toBe(true);
|
|
act(() => trigger?.click());
|
|
expect(trigger?.classList.contains('is-open')).toBe(true);
|
|
const floating = document.body.querySelector<HTMLElement>('.floating-data-window');
|
|
expect(floating?.getAttribute('aria-modal')).toBe('false');
|
|
expect(floating?.textContent).toContain('Windows Server 2022');
|
|
expect(floating?.querySelector('.client-os-source')?.textContent).toBe('Inventory');
|
|
|
|
act(() => floating?.querySelector('.floating-data-window-head')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));
|
|
expect(floating?.classList.contains('is-maximized')).toBe(true);
|
|
expect(floating?.querySelector('[aria-label="Restore Operating systems window"]')).not.toBeNull();
|
|
|
|
act(() => floating?.querySelector<HTMLButtonElement>('[aria-label="Close Operating systems window"]')?.click());
|
|
expect(document.body.querySelector('.floating-data-window')).toBeNull();
|
|
expect(document.activeElement).toBe(trigger);
|
|
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
});
|