Assessor_2.0/tests/comparison/comparison-tab.test.tsx

230 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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();
});
});