171 lines
11 KiB
TypeScript
171 lines
11 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 { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
|
||
import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer';
|
||
import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar';
|
||
import { appendPolicyObservationsToDataset } from '../../src/features/project-import/policy-observations-to-dataset';
|
||
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
||
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||
import type { StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
|
||
import type { ViewMode } from '../../src/app/app-state';
|
||
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: () => <span>Synthetic timeslot</span> }));
|
||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); });
|
||
|
||
function fixture(version: '1.3.3' | '1.4.0' | '1.9.0') {
|
||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), configurable: true });
|
||
const observation = { sourceKind: 'policy-observation', sourceResultId: 'observation', projectId: 'synthetic',
|
||
assessmentId: 'synthetic-slot', title: 'Synthetic setting', importedAt: '2026-09-01T00:00:00Z',
|
||
policyScope: 'computer', policyIdentityKey: 'identity', policyIdentityStatus: 'resolved',
|
||
policyBackupInstanceId: 'backup', policyDeliveryMechanism: 'registry-policy',
|
||
policyValues: [{ kind: 'registry-policy', key: 'Software\\Policies\\Synthetic', valueName: 'Mode',
|
||
operation: 'set-value', typeCode: 4, data: { kind: 'uint32', value: 0 } }]
|
||
} as unknown as UnifiedResultProjection;
|
||
const evaluation = { run: { evaluator: { id: 'policy-baseline-evaluation', version }, completedAt: '2026-09-02T00:00:00Z' },
|
||
results: [{ id: 'comparison', observationIds: ['observation'], baselineId: 'baseline', baselineLabel: 'Synthetic baseline',
|
||
baselineState: 'explicit-recommendation', classification: 'confirmed_configured_deviation', customerState: 'configured',
|
||
identity: { id: 'identity', scope: 'computer', target: { kind: 'registry-value', hive: 'HKEY_LOCAL_MACHINE',
|
||
key: 'Software\\Policies\\Synthetic', valueName: 'Mode' } }, diagnosticCodes: [],
|
||
comparator: { version: 1, comparator: { kind: 'equality' }, relation: 'different',
|
||
actual: { kind: 'integer', value: '0' }, baseline: { kind: 'integer', value: '1' }, diagnosticCodes: [] }
|
||
}]
|
||
} as unknown as StoredPolicyEvaluation;
|
||
const dataset = appendPolicyObservationsToDataset(null, 'Synthetic', [observation], [evaluation], new Map())!;
|
||
const state = { dataset, activeSheet: dataset.sheets[0], activeProfile: genericProfile, reviews: {},
|
||
facetFilters: {}, statusFilter: 'deviation', search: '', actionOnly: false, pathFindingIds: [],
|
||
sortColumn: '', sortDirection: 'asc', titleColumn: 'Title', selectedRecordId: '', view: 'table',
|
||
setActiveSheet: vi.fn(), setSelectedRecord: vi.fn(), updateReview: vi.fn(),
|
||
setView: vi.fn((value: ViewMode) => { state.view = value; }),
|
||
setStatusFilter: vi.fn((value: string) => { state.statusFilter = value; }),
|
||
setSearch: vi.fn((value: string) => { state.search = value; }),
|
||
setActionOnly: vi.fn((value: boolean) => { state.actionOnly = value; }),
|
||
setPathFindingIds: vi.fn((value: string[]) => { state.pathFindingIds = value; }),
|
||
resetFilters: vi.fn(() => { state.facetFilters = {}; state.statusFilter = 'deviation'; }),
|
||
setFacetFilter: vi.fn((id: string, value: DynamicFacetFilter) => {
|
||
state.facetFilters = { ...state.facetFilters, [id]: value };
|
||
})
|
||
} as unknown as ReturnType<typeof useApp>;
|
||
vi.mocked(useApp).mockImplementation(() => state);
|
||
return { state, evaluation };
|
||
}
|
||
|
||
describe('saved GPO evaluation visibility after an evaluator update', () => {
|
||
it('shows a current backup-scope absence when the user selects Not configured', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const { state, evaluation } = fixture('1.9.0');
|
||
evaluation.results[0].customerState = 'missing';
|
||
evaluation.results[0].observationIds = [];
|
||
evaluation.results[0].comparator.actual = { kind: 'none' };
|
||
evaluation.results[0].baselineVariantAssessment = {
|
||
version: 1, scope: 'backup-configuration', decision: 'all-fail', actualValues: [], diagnosticCodes: [], variants: []
|
||
};
|
||
evaluation.results[0].diagnosticCodes = ['policy-evaluation-customer-absence-scope-limited'];
|
||
const dataset = appendPolicyObservationsToDataset(null, 'Synthetic', [], [evaluation], new Map())!;
|
||
state.dataset = dataset;
|
||
state.activeSheet = dataset.sheets[0];
|
||
state.statusFilter = 'all';
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
const status = container.querySelector<HTMLSelectElement>('select[aria-label="Result Status"]')!;
|
||
expect([...status.options].map(option => option.value)).toContain('not-configured');
|
||
await act(async () => {
|
||
status.value = 'not-configured';
|
||
status.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
expect(state.statusFilter).toBe('not-configured');
|
||
expect(filterAndSortRecords(state.activeSheet, state)).toHaveLength(1);
|
||
expect(container.querySelector('.work-filter-empty')).toBeNull();
|
||
expect(container.textContent).toContain('Not configured');
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it('distinguishes a current unresolved comparison from an outdated evaluation and missing evidence', async () => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const { state } = fixture('1.4.0');
|
||
const sheet = makeSheet('GPOs', [['Title', 'Category', 'Status', 'Source: Scan'],
|
||
['Synthetic mixed variants', 'GPOs', 'Review required', 'GPOs']], '', 'Synthetic', { toolId: 'group-policy' });
|
||
state.activeSheet = sheet;
|
||
state.dataset = { ...state.dataset!, sheets: [sheet] };
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<DashboardPanel onOpenWork={vi.fn()}/>));
|
||
expect(container.querySelector('.client-overall-status')).toBeNull();
|
||
expect(container.querySelector('.client-gpo-readiness')).toBeNull();
|
||
const rows = [...container.querySelectorAll('.client-assessment-gaps dl > div')];
|
||
expect(rows.find(row => row.textContent?.includes('Manual review'))?.querySelector('dd')?.textContent).toBe('1');
|
||
expect(rows.find(row => row.textContent?.includes('Missing evidence'))?.querySelector('dd')?.textContent).toBe('0');
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
it.each(['1.3.3', '1.4.0'] as const)('opens the saved %s comparison from Client Info instead of an empty Deviation view', async version => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const { state, evaluation } = fixture(version);
|
||
const original = JSON.stringify(evaluation);
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
const openWork = vi.fn();
|
||
try {
|
||
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
|
||
expect(container.querySelector('.client-overall-status')).toBeNull();
|
||
const gaps = container.querySelector('.client-assessment-gaps')!;
|
||
expect(gaps.textContent).toContain('1 saved comparisons await re-evaluation');
|
||
expect([...gaps.querySelectorAll('dl > div')].find(row => row.textContent?.includes('Missing evidence'))?.querySelector('dd')?.textContent).toBe('0');
|
||
const source = container.querySelector<HTMLButtonElement>('.client-source-gpo')!;
|
||
expect(source.textContent).toContain('Re-evaluation required');
|
||
expect(source.textContent).not.toContain('0 baseline deviations');
|
||
await act(async () => source.click());
|
||
expect(openWork).toHaveBeenCalledOnce();
|
||
expect(state.statusFilter).toBe('all');
|
||
expect(state.facetFilters['provenance:source-scan']?.values).toHaveLength(1);
|
||
state.view = 'table';
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
expect(container.textContent).toContain('Review required');
|
||
expect(container.textContent).toContain(`Fail · Engine ${version}`);
|
||
const status = container.querySelector<HTMLSelectElement>('select[aria-label="Result Status"]')!;
|
||
expect([...status.options].map(option => option.value)).toEqual(['all', 'deviation', 'passed', 'not-configured', 'no-baseline-rule', 'not-comparable', 'conflicts', 'duplicates']);
|
||
expect(filterAndSortRecords(state.activeSheet, { ...state, statusFilter: 'passed' })).toHaveLength(0);
|
||
expect(filterAndSortRecords(state.activeSheet, { ...state, statusFilter: 'deviation' })).toHaveLength(0);
|
||
expect(JSON.stringify(evaluation)).toBe(original);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(['records', 'columns', 'table', 'raw'] as const)('recovers the empty %s Work filter without losing the selected GPO source', async view => {
|
||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||
const { state } = fixture('1.4.0');
|
||
state.view = view;
|
||
const container = document.createElement('div');
|
||
const root = createRoot(container);
|
||
try {
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
const source = container.querySelector<HTMLSelectElement>('select[aria-label="Source: Scan"]')!;
|
||
await act(async () => {
|
||
source.value = source.options[1].value;
|
||
source.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
const selectedSource = state.facetFilters['provenance:source-scan'];
|
||
expect(container.querySelector('[aria-label="ASSESSOR – No dataset open"]')).toBeNull();
|
||
expect(container.textContent).toContain('No results match the current filters');
|
||
await act(async () => container.querySelector<HTMLButtonElement>('.work-filter-empty button')!.click());
|
||
await act(async () => root.render(<><SearchFilterBar/><DatasetViewer/></>));
|
||
expect(state.statusFilter).toBe('all');
|
||
expect(state.facetFilters['provenance:source-scan']).toEqual(selectedSource);
|
||
expect(container.querySelector('.work-filter-empty')).toBeNull();
|
||
expect(filterAndSortRecords(state.activeSheet, state)).toHaveLength(1);
|
||
expect(container.textContent).toContain('Mode');
|
||
expect(state.view).toBe(view);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
});
|