230 lines
18 KiB
TypeScript
230 lines
18 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from 'react';
|
||
import { createRoot, type Root } from 'react-dom/client';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||
import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model';
|
||
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
|
||
import type { TimeslotDomainComparison } from '../../src/comparison/timeslot-domain-comparison';
|
||
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
|
||
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
|
||
|
||
// Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a
|
||
// labelled delta, change facts that open Work and a source table. Synthetic data only.
|
||
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
|
||
const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at });
|
||
const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
|
||
importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
|
||
const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' };
|
||
|
||
const report: TimeslotComparisonReport = {
|
||
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'],
|
||
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }],
|
||
changes: [
|
||
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] },
|
||
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] },
|
||
{ sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] }
|
||
],
|
||
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 },
|
||
score: {
|
||
baselineScore: 52.4, targetScore: 55.2, change: 2.8,
|
||
sources: [
|
||
{ sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 },
|
||
{ sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] }
|
||
],
|
||
excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }]
|
||
},
|
||
scanner: {} as TimeslotComparisonReport['scanner']
|
||
};
|
||
|
||
const side = (score: number | undefined, passed: number, deviations: number) => ({ ...(score === undefined ? {} : { score }), passed, deviations });
|
||
const domains: TimeslotDomainComparison = { version: '1.0.0', sources: ['cis-cat'], domains: [
|
||
{ id: 'identity', label: 'Identity & Authentication', baseline: side(48, 12, 13), target: side(55.2, 16, 13), onlyInSources: [], resolved: 4, worse: 1 },
|
||
{ id: 'cloud', label: 'Cloud & Collaboration', baseline: side(undefined, 0, 0), target: side(46, 6, 7), onlyIn: 'target', onlyInSources: ['maester'], resolved: 0, worse: 0 },
|
||
{ id: 'monitoring', label: 'Monitoring & Recovery', baseline: side(61, 11, 7), target: side(58, 11, 8), onlyInSources: [], resolved: 0, worse: 2 }
|
||
] };
|
||
|
||
let host: HTMLDivElement;
|
||
let root: Root;
|
||
beforeEach(() => {
|
||
resetTimeslotComparisonRequestsForTests();
|
||
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]);
|
||
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
|
||
assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)],
|
||
scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)]
|
||
} as never);
|
||
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotDomainComparison').mockResolvedValue(domains as never);
|
||
host = document.createElement('div');
|
||
document.body.append(host);
|
||
root = createRoot(host);
|
||
});
|
||
afterEach(async () => {
|
||
await act(async () => root.unmount());
|
||
host.remove();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
|
||
|
||
describe('Comparison Engine Compare tab', () => {
|
||
it('compares the open timeslot with the one before it and opens Work from a change', async () => {
|
||
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||
const onOpenChange = vi.fn();
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']} onOpenChange={onOpenChange}/>));
|
||
await settle();
|
||
expect(request).toHaveBeenCalledWith('dummy-project',
|
||
expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
|
||
const selects = [...host.querySelectorAll('.comparison-tab__timeslots select')] as HTMLSelectElement[];
|
||
expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']);
|
||
expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']);
|
||
expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better');
|
||
const facts = [...host.querySelectorAll<HTMLButtonElement>('.comparison-fact')];
|
||
const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!;
|
||
expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse');
|
||
expect(fact('Not comparable').textContent).toContain('1');
|
||
expect(fact('New').disabled).toBe(true);
|
||
await act(async () => fact('Resolved').click());
|
||
expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' }));
|
||
const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester'));
|
||
expect(maester?.textContent).toContain('Not in timeslot A');
|
||
});
|
||
|
||
it('lists the changed findings under the source table and filters them by change', async () => {
|
||
const titled: TimeslotComparisonReport = { ...report, changes: [
|
||
{ sourceId: 'cis-cat', change: 'unchanged', side: 'baseline', recordIds: ['b-2'], counterpartIds: ['c-2'], title: 'Synthetic unchanged rule', ruleId: '1.2', reasons: [] },
|
||
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-4'], counterpartIds: [], title: 'Synthetic low rule', ruleId: '5.2', severity: 'low', reasons: [] },
|
||
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], title: 'Synthetic regressed rule', ruleId: '5.1', severity: 'critical', reasons: [] },
|
||
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], title: 'Synthetic resolved rule', ruleId: '1.1', reasons: [] },
|
||
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['g-1'], counterpartIds: ['g-2'], title: 'Synthetic setting', reasons: [],
|
||
values: { baseline: ['7'], target: ['10'] } }
|
||
] };
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(titled);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
const rows = () => [...host.querySelectorAll('.comparison-item')].map(row => ({
|
||
severity: row.className.replace('comparison-item ', '').replace(' is-selected', ''), title: row.querySelector('.comparison-item__title')?.textContent,
|
||
badges: [...row.querySelectorAll('.comparison-item__meta > span')].map(cell => cell.textContent),
|
||
badge: row.querySelector(':scope > .reader-severity')?.textContent
|
||
}));
|
||
// `All changes` leaves out the unchanged ones; better first, then worse by severity, then neutral.
|
||
expect(rows()).toEqual([
|
||
{ severity: 'severity-unknown', title: 'Synthetic resolved rule', badges: ['▼ Resolved', 'CIS-CAT Pro', '1.1 · Deviation in A · passed in B'], badge: 'Unknown' },
|
||
{ severity: 'severity-critical', title: 'Synthetic regressed rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.1 · Passed in A'], badge: 'Critical' },
|
||
{ severity: 'severity-low', title: 'Synthetic low rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.2 · Passed in A'], badge: 'Low' },
|
||
{ severity: 'severity-unknown', title: 'Synthetic setting', badges: ['≠ Value changed', 'GPOs', 'Deviation in A and B · 7 → 10'], badge: 'Unknown' }
|
||
]);
|
||
expect(host.querySelector('.comparison-badge.is-worse')?.textContent).toBe('▲ Regressed');
|
||
// Rows are clickable like the Work list: the first is selected, a click or arrow key selects another and
|
||
// the detail beside the list shows that change with A and B side by side.
|
||
const items = () => [...host.querySelectorAll<HTMLButtonElement>('.comparison-item')];
|
||
const detailTitle = () => host.querySelector('.comparison-detail__title')?.textContent;
|
||
expect(items()[0].getAttribute('aria-pressed')).toBe('true');
|
||
expect(detailTitle()).toBe('Synthetic resolved rule');
|
||
await act(async () => items()[3].click());
|
||
expect(items()[3].className).toContain('is-selected');
|
||
expect(detailTitle()).toBe('Synthetic setting');
|
||
const valueRow = [...host.querySelectorAll('.comparison-detail tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
|
||
expect([...valueRow!.querySelectorAll('td')].slice(1).map(cell => cell.textContent)).toEqual(['7', '10']);
|
||
await act(async () => host.querySelector('.comparison-list')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })));
|
||
expect(detailTitle()).toBe('Synthetic low rule');
|
||
expect(document.activeElement).toBe(items()[2]);
|
||
const filter = host.querySelector<HTMLSelectElement>('.comparison-tile__filter select')!;
|
||
await act(async () => { filter.value = 'unchanged'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(rows().map(row => row.title)).toEqual(['Synthetic unchanged rule']);
|
||
await act(async () => { filter.value = 'new'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
expect(host.textContent).toContain('No finding has this change.');
|
||
});
|
||
|
||
it('shows Security Domains A → B with a labelled change, resolved against worse bars and a domain only in B', async () => {
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
const rows = [...host.querySelectorAll('.comparison-domain:not(.is-header)')];
|
||
const cells = (row: Element) => [...row.children].map(cell => cell.textContent);
|
||
expect(cells(rows[0]).slice(0, 5)).toEqual(['Identity & Authentication', '48', '→', '55', '▲ +7']);
|
||
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-better')?.style.width).toBe('100%');
|
||
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-worse')?.style.width).toBe('25%');
|
||
expect(cells(rows[1])).toEqual(['Cloud & Collaboration', '—', '→', '46', '○ Only in B', 'Maester only in B · not comparable']);
|
||
expect(cells(rows[2])[4]).toBe('▼ −3');
|
||
expect(rows[2].querySelector('.comparison-delta')?.className).toContain('is-worse');
|
||
});
|
||
|
||
it('shows a scanner value change with both Problem values side by side', async () => {
|
||
const changed: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'purple-knight', change: 'value-changed', side: 'baseline',
|
||
recordIds: ['b-5'], counterpartIds: ['c-5'], title: 'Synthetic templates', ruleId: 'SI-1', reasons: [],
|
||
problem: { baseline: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '1' }],
|
||
target: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '3' }] } }] };
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(changed);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('SI-1 · Deviation in A and B · Affected 1 → 3');
|
||
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
|
||
// Only what differs between A and B; the identical Result line stays in the Problem block.
|
||
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Affected', '1', '3']]);
|
||
// Like GPO configured values: red where the timeslot deviates (here both), green where it passes.
|
||
expect([...host.querySelectorAll('.comparison-detail .finding-comparison .policy-value-mark')].map(item => [item.textContent, item.className])).toEqual([
|
||
['1', 'policy-value-mark is-deviating'], ['3', 'policy-value-mark is-deviating']]);
|
||
});
|
||
|
||
it('marks a resolved finding red in A and green in B and keeps the expected value neutral', async () => {
|
||
const resolved: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline',
|
||
recordIds: ['b-6'], counterpartIds: ['c-6'], title: 'Synthetic rule', ruleId: '1.1.1', reasons: [],
|
||
problem: { baseline: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '7' }],
|
||
target: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '14' }] } }] };
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(resolved);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
const row = (label: string) => [...host.querySelectorAll('.comparison-detail tbody tr')].find(item => item.firstElementChild?.textContent === label)!;
|
||
expect([...row('Found').querySelectorAll('.policy-value-mark')].map(item => item.className)).toEqual(['policy-value-mark is-deviating', 'policy-value-mark is-aligned']);
|
||
expect(row('Expected')).toBeUndefined();
|
||
});
|
||
|
||
it('shows a CIS-CAT evidence row that changed only in its value by that value, and hides identical rows', async () => {
|
||
const found = (value: string) => [{ label: 'Expected', value: 'Criterion: synthetic' },
|
||
{ label: 'Found', value: 'Type · String · Exists · reg_dword' }, { label: 'Found', value: `Value · Int · Exists · ${value}` }];
|
||
const cis: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'value-changed', side: 'baseline',
|
||
recordIds: ['b-7'], counterpartIds: ['c-7'], title: 'Synthetic registry rule', ruleId: '2.3.17.2', reasons: [],
|
||
problem: { baseline: found('5'), target: found('0') } }] };
|
||
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(cis);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('2.3.17.2 · Deviation in A and B · Found 5 → 0');
|
||
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
|
||
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Found · Value', '5', '0']]);
|
||
});
|
||
|
||
it('explains that the oldest timeslot has nothing before it', async () => {
|
||
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
|
||
await settle();
|
||
expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10');
|
||
expect(host.textContent).toContain('no earlier timeslot');
|
||
expect(request).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('offers only earlier timeslots as A and requests the new pair on change', async () => {
|
||
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||
await settle();
|
||
const [baseline] = [...host.querySelectorAll('select')];
|
||
expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']);
|
||
await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); });
|
||
await settle();
|
||
expect(request).toHaveBeenLastCalledWith('dummy-project',
|
||
expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
|
||
});
|
||
|
||
it('explains a client with a single timeslot instead of comparing', async () => {
|
||
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]);
|
||
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
|
||
assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)]
|
||
} as never);
|
||
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
|
||
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
|
||
await settle();
|
||
expect(host.textContent).toContain('only one timeslot');
|
||
expect(request).not.toHaveBeenCalled();
|
||
});
|
||
});
|