Assessor_2.0/tests/features/load-canonical-project-work...

226 lines
13 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import pingCastleXml from '../../fixtures/pingcastle/healthcheck.synthetic.xml?raw';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import {
currentPolicyEvaluationsForWork,
loadCanonicalProjectWorkspace
} from '../../src/features/project-import/load-canonical-project-workspace';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { importFile } from '../../src/importers/importer';
import { pingCastleProfile } from '../../src/profiles/pingcastle/pingcastle-profile';
import { importProfiles } from '../../src/profiles/profile-catalog';
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../src/domain/policy-evaluation';
import { selectCurrentPolicyEvaluations } from '../../src/domain/unified-result';
describe('canonical project workspace loading', () => {
it('uses one current evaluation per aggregation even when baseline coverage references more backups', () => {
const evaluation = (
id: string,
version: string,
baselineCount: number,
completedAt: string,
observationId = 'observation-one'
) => ({
run: {
id,
aggregationRunId: 'aggregation-one',
completedAt,
baselineAnalysisIds: Array.from({ length: baselineCount }, (_, index) => `baseline-${index}`),
evaluator: { ...POLICY_EVALUATION_ENGINE, version }
},
results: [{ observationIds: [observationId], backupInstanceIds: ['backup-one'] }]
}) as unknown as StoredPolicyEvaluation;
const complete = evaluation('complete', POLICY_EVALUATION_ENGINE.version, 11, '2026-09-06T10:00:00Z');
complete.results[0].backupInstanceIds = ['backup-one', 'backup-two', 'backup-three'];
const earlier = evaluation('earlier', POLICY_EVALUATION_ENGINE.version, 11, '2026-09-06T09:00:00Z');
earlier.results[0].backupInstanceIds = ['backup-one', 'backup-two'];
const laterPartial = evaluation('later-partial', POLICY_EVALUATION_ENGINE.version, 1, '2026-09-06T11:00:00Z');
const obsolete = evaluation('obsolete', '1.3.0', 12, '2026-09-06T12:00:00Z');
const unrelated = evaluation('unrelated', POLICY_EVALUATION_ENGINE.version, 12, '2026-09-06T13:00:00Z', 'other');
expect(currentPolicyEvaluationsForWork(
[laterPartial, obsolete, unrelated, earlier, complete],
new Set(['observation-one'])
)).toEqual([complete]);
const previous = evaluation('previous-version', '1.3.3', 12, '2026-09-06T12:00:00Z');
expect(currentPolicyEvaluationsForWork([previous], new Set(['observation-one']), new Set([previous.run.id])))
.toEqual([previous]);
expect(currentPolicyEvaluationsForWork([previous, complete], new Set(['observation-one']), new Set([previous.run.id])))
.toEqual([complete]);
expect(currentPolicyEvaluationsForWork([complete, previous], new Set(['observation-one']), new Set([previous.run.id])))
.toEqual([complete]);
expect(currentPolicyEvaluationsForWork([previous], new Set(['observation-one']), new Set())).toEqual([]);
expect(selectCurrentPolicyEvaluations([previous])).toEqual([]);
});
it('honors authoritative supersession, preserves separate imports and excludes mixed timeslots', () => {
const evaluation = (id: string, aggregationRunId: string, observationIds: string[]) => ({
run: { id, aggregationRunId, completedAt: '2026-09-06T10:00:00Z', baselineAnalysisIds: ['baseline'], evaluator: POLICY_EVALUATION_ENGINE },
results: [{ observationIds, backupInstanceIds: ['same-backup'] }]
}) as StoredPolicyEvaluation;
const history = evaluation('history', 'superseded-aggregation', ['one']);
const current = evaluation('current', 'current-aggregation', ['one']);
const separateImport = evaluation('separate', 'separate-aggregation', ['two']);
const otherSlot = evaluation('other-slot', 'other-aggregation', ['elsewhere']);
const mixed = evaluation('mixed', 'mixed-aggregation', ['one', 'elsewhere']);
const records = [history, current, separateImport, otherSlot, mixed];
const before = JSON.stringify(records);
expect(currentPolicyEvaluationsForWork(records, new Set(['one', 'two']),
new Set(['current', 'separate', 'other-slot', 'mixed']))).toEqual([current, separateImport]);
expect(currentPolicyEvaluationsForWork(records, new Set(['one']), new Set())).toEqual([]);
expect(JSON.stringify(records)).toBe(before);
});
it('opens a GPO-only assessment without loading another timeslot or requiring scanner results', async () => {
const stamp = '2026-09-06T00:00:00Z';
const model: CanonicalAssessmentModel = {
schemaVersion: 1,
project: { id: 'synthetic-client', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp },
assessment: { id: 'gpo-slot', projectId: 'synthetic-client', name: 'Synthetic GPO slot', createdAt: stamp, updatedAt: stamp },
scanRuns: [], importFiles: [], sourceTables: [], findingDefinitions: [], findingOccurrences: [], evidence: [], reviews: [], diagnostics: []
};
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([model.assessment, { ...model.assessment, id: 'other-slot' }]),
loadAssessmentModel: vi.fn().mockResolvedValue(model),
queryUnifiedResults: vi.fn().mockResolvedValue({ items: [], total: 0, limit: 500, offset: 0 })
};
const workspace = await loadCanonicalProjectWorkspace(reader, model.project.id, [], ['gpo-slot']);
expect(reader.loadAssessmentModel).toHaveBeenCalledTimes(1);
expect(reader.loadAssessmentModel).toHaveBeenCalledWith('gpo-slot');
expect(workspace.assessmentIds).toEqual(['gpo-slot']);
expect(workspace.models).toEqual([model]);
expect(workspace.dataset).toBeNull();
expect(reader.queryUnifiedResults).toHaveBeenCalledWith(expect.objectContaining({ assessmentIds: ['gpo-slot'] }));
});
it('retains GPO results from an explicitly selected mixed timeslot when scanner runs live in another assessment', async () => {
const stamp = '2026-09-06T00:00:00Z';
const base: CanonicalAssessmentModel = {
schemaVersion: 1,
project: { id: 'synthetic-client', name: 'Synthetic', description: '', attributes: {}, createdAt: stamp, updatedAt: stamp },
assessment: { id: 'scanner-slot', projectId: 'synthetic-client', name: 'Scanner slot', createdAt: stamp, updatedAt: stamp },
scanRuns: [{ id: 'scanner-run', assessmentId: 'scanner-slot', sequence: 1, label: 'Synthetic scanner',
tool: { id: 'synthetic', name: 'Synthetic' }, importedAt: stamp }],
importFiles: [], sourceTables: [], findingDefinitions: [], findingOccurrences: [], evidence: [], reviews: [], diagnostics: []
};
const gpoModel = { ...base, assessment: { ...base.assessment, id: 'gpo-slot', name: 'GPO slot' }, scanRuns: [] };
const observation = {
sourceKind: 'policy-observation', sourceResultId: 'policy-one', resultProjectionId: 'policy-observation:policy-one',
projectId: base.project.id, assessmentId: 'gpo-slot', importArtifactId: 'gpo-artifact', title: 'Synthetic policy', importedAt: stamp,
policyScope: 'computer', policyDeliveryMechanism: 'registry-policy', policyState: 'configured', policyIdentityStatus: 'resolved',
policyIdentityKey: 'synthetic-policy', policyBackupInstanceId: 'backup-one', policyValues: [{ kind: 'registry-policy',
key: 'Software\\Policies\\Synthetic', valueName: 'Mode', operation: 'set-value', typeCode: 4,
data: { kind: 'uint32', value: 1 } }]
};
const secondObservation = { ...observation, sourceResultId: 'policy-two',
resultProjectionId: 'policy-observation:policy-two', policyBackupInstanceId: 'backup-two' };
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([base.assessment, gpoModel.assessment]),
loadAssessmentModel: vi.fn(async (id: string) => id === 'gpo-slot' ? gpoModel : base),
queryUnifiedResults: vi.fn().mockResolvedValue({ items: [observation, secondObservation], total: 2, limit: 500, offset: 0 }),
listPolicyEvaluations: vi.fn().mockResolvedValue({ records: [] })
};
const workspace = await loadCanonicalProjectWorkspace(reader, base.project.id, ['scanner-run'], ['scanner-slot', 'gpo-slot']);
expect(reader.queryUnifiedResults).toHaveBeenCalledWith(expect.objectContaining({
assessmentIds: ['scanner-slot', 'gpo-slot'], sourceKind: ['policy-observation']
}));
expect(workspace.assessmentIds).toEqual(['scanner-slot', 'gpo-slot']);
expect(workspace.dataset?.sheets[0].analysis.records.some(record =>
record.fields.some(field => field.label === 'Source: Scan' && field.value.startsWith('GPOs')))).toBe(true);
});
it('projects persisted PingCastle XML RiskRules instead of generic XML nodes', async () => {
const imported = await importFile(
new File([pingCastleXml], 'ad_hc_synthetic.example.xml', { type: 'application/xml' }),
{ profiles: importProfiles }
);
const preview = previewEntryFromDataset(imported, {
sha256: 'c'.repeat(64),
fileName: imported.fileName,
sizeBytes: pingCastleXml.length,
lastModified: 0
});
const entry = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch(
'pingcastle-workspace',
[entry],
'2026-07-22T12:00:00.000Z'
).model;
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([model.assessment]),
loadAssessmentModel: vi.fn().mockResolvedValue(model)
};
const workspace = await loadCanonicalProjectWorkspace(reader, model.project.id);
expect(reader.listProjectAssessments).toHaveBeenCalledWith(model.project.id);
expect(reader.loadAssessmentModel).toHaveBeenCalledWith(model.assessment.id);
expect(workspace.dataset?.profileId).toBe('pingcastle');
expect(workspace.dataset?.sheets[1].analysis.headers).toEqual(expect.arrayContaining(['Source Rule ID', 'Title', 'Status']));
expect(workspace.dataset?.sheets[1].analysis.headers).not.toContain('DomainSid');
expect(workspace.dataset?.sheets[1].analysis.records[0].title).toBe('Synthetic inactive privileged account');
expect(pingCastleProfile.defaults(workspace.dataset?.sheets[1]).titleColumn).toBe('Title');
});
it('opens a selected PingCastle HTML finding with the Result DB title from its paired XML report', async () => {
const imported = await importFile(
new File([pingCastleXml], 'ad_hc_synthetic.example.xml', { type: 'application/xml' }),
{ profiles: importProfiles }
);
const preview = previewEntryFromDataset(imported, {
sha256: 'd'.repeat(64), fileName: imported.fileName, sizeBytes: pingCastleXml.length, lastModified: 0
});
const source = createCanonicalImportBatch('pingcastle-paired-report', [
confirmEntryMappings(preview, importProfiles)
], '2026-09-23T12:00:00.000Z').model;
const resultTitle = 'The synthetic LAN Manager Authentication Level allows NTLMv1 or LM.';
const checkTitle = 'Ensure that synthetic NTLMv1 and LM protocols are banned';
const xml = structuredClone(source);
xml.assessment.id = 'assessment-xml';
xml.scanRuns[0].assessmentId = 'assessment-xml';
xml.findingDefinitions[0].title = resultTitle;
xml.findingOccurrences[0].sourceIdentity.originalRuleId = 'S-oldNtlm';
xml.findingOccurrences[0].sourceIdentity.canonicalRuleId = 'S-oldNtlm';
xml.findingOccurrences[0].sourceIdentity.originalTitle = resultTitle;
const html = structuredClone(xml);
html.assessment.id = 'assessment-html';
html.scanRuns[0].id = 'run-html';
html.scanRuns[0].assessmentId = 'assessment-html';
html.importFiles[0].id = 'file-html';
html.importFiles[0].scanRunId = 'run-html';
html.importFiles[0].fileName = 'ad_hc_synthetic.example.html';
html.importFiles[0].format = 'HTML';
html.findingDefinitions[0].title = checkTitle;
html.findingOccurrences[0].id = 'occurrence-html';
html.findingOccurrences[0].scanRunId = 'run-html';
html.findingOccurrences[0].importFileId = 'file-html';
html.findingOccurrences[0].sourceIdentity.originalTitle = checkTitle;
html.findingOccurrences[0].originalFields = [
{ key: 'risk-id', label: 'Rule ID', value: 'S-oldNtlm' },
{ key: 'title', label: 'Title', value: checkTitle },
{ key: 'description', label: 'Description', value: 'Synthetic scanner explanation.' }
];
const models = new Map([[html.assessment.id, html], [xml.assessment.id, xml]]);
const reader = {
listProjectAssessments: vi.fn().mockResolvedValue([html.assessment, xml.assessment]),
loadAssessmentModel: vi.fn(async (id: string) => models.get(id)!)
};
const workspace = await loadCanonicalProjectWorkspace(reader, html.project.id,
['run-html'], ['assessment-html', 'assessment-xml']);
const records = workspace.dataset?.sheets[0].analysis.records ?? [];
expect(workspace.models).toHaveLength(1);
expect(records).toHaveLength(1);
expect(records[0].title).toBe(resultTitle);
expect(records[0].fields.find(field => field.label === 'Source Rule ID')?.value).toBe('S-oldNtlm');
expect(records[0].fields.find(field => field.label === 'Native source heading')?.value).toBe(checkTitle);
expect(html.findingOccurrences[0].sourceIdentity.originalTitle).toBe(checkTitle);
});
});