Assessor_2.0/tests/comparison/comparison-open-finding.tes...

105 lines
7.2 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
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> }));
// Compare tab → Work (mockup B): a selected change opens its exact finding in Work, in the open timeslot B or,
// after opening it, in timeslot A. Synthetic data only.
const at = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z' };
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'synthetic-client', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
const run = (id: string, assessmentId: string, time: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: time });
const file = (runId: string, time: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
importedAt: time, lastModifiedAt: time, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
const report = {
version: '1.0.0', projectId: 'synthetic-client', baselineRunIds: ['run-a'], targetRunIds: ['run-b'],
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }],
changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], title: 'Synthetic rule', ruleId: '1.1', reasons: [] }],
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 },
score: { sources: [], excludedSources: [] }, scanner: {}
} as unknown as TimeslotComparisonReport;
function timeslotDataset(occurrence: string) {
const sheet = makeSheet('Synthetic', [['Title', 'Status'], ['Other rule', 'Failed'], ['Synthetic rule', 'Passed']], '', 'synthetic.csv', { isProjectSheet: true });
sheet.analysis.records[0].findingOccurrenceId = `${occurrence}-other`;
sheet.analysis.records[1].findingOccurrenceId = occurrence;
return { sheet, dataset: { fileName: 'Synthetic client', sheets: [sheet], scanTool: 'Generic' } as ImportedDataset };
}
let host: HTMLDivElement;
let root: Root;
let app: Record<string, unknown>;
function mockApp(occurrence: string, assessmentId: string) {
const { sheet, dataset } = timeslotDataset(occurrence);
app = { ...app, dataset, activeSheet: sheet, activeAssessmentIds: [assessmentId] };
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
return sheet;
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests();
for (const engine of ['context', 'compliance', 'remediation', 'report'] as const) setEngineEnabled(engine, false);
app = { activeProfile: genericProfile, reviews: {}, targetProjectId: 'synthetic-client', activeScanRunIds: [], facetFilters: {}, search: '',
statusFilter: 'all', actionOnly: false, backendStatus: 'connected', status: '',
setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(),
setActiveSheet: vi.fn(), setSelectedRecord: vi.fn(), openProject: vi.fn(async () => true) };
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ observations: [], sections: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', at.a), file('run-b', at.b)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', at.a), assessment('assessment-b', at.b)],
scanRuns: [run('run-a', 'assessment-a', at.a), run('run-b', 'assessment-b', at.b)]
} as never);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotDomainComparison').mockResolvedValue({ version: '1.0.0', sources: ['cis-cat'], 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(); vi.unstubAllGlobals(); resetEnginePreferencesForTests();
});
const settle = () => act(async () => { for (let index = 0; index < 8; index++) await Promise.resolve(); });
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === label);
it('opens the selected change in Work for timeslot B and, after opening it, for timeslot A', async () => {
const sheetB = mockApp('b-1', 'assessment-b');
const openWork = vi.fn();
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => button('Compare')!.click());
await settle();
expect(host.querySelector('.comparison-item.is-selected')?.textContent).toContain('Synthetic rule');
await act(async () => button('Open in Work')!.click());
// The resolved finding is Passed in B: Work opens exactly that record in the Reports view, whatever its status.
expect(app.setStatusFilter).toHaveBeenLastCalledWith('all');
expect(app.setPathFindingIds).toHaveBeenLastCalledWith([sheetB.analysis.records[1].id]);
expect(app.setSelectedRecord).toHaveBeenLastCalledWith(sheetB.analysis.records[1].id);
expect(app.setView).toHaveBeenLastCalledWith('records');
expect(openWork).toHaveBeenCalledTimes(1);
await act(async () => button('Open in timeslot A')!.click());
expect(app.openProject).toHaveBeenCalledWith('synthetic-client', ['run-a'], ['assessment-a']);
expect(openWork).toHaveBeenCalledTimes(1);
// Once timeslot A is loaded, its record of the same finding opens in Work.
const sheetA = mockApp('a-1', 'assessment-a');
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await settle();
expect(app.setPathFindingIds).toHaveBeenLastCalledWith([sheetA.analysis.records[1].id]);
expect(openWork).toHaveBeenCalledTimes(2);
});