// @vitest-environment jsdom import { describe, expect, it } from 'vitest'; import * as XLSX from 'xlsx'; import cisCatFixture from '../../fixtures/cis-cat/assessment.synthetic.html?raw'; import genericFixture from '../../fixtures/generic/findings.synthetic.csv?raw'; import maesterFixture from '../../fixtures/maester/test-results.synthetic.csv?raw'; import pingCastleFixture from '../../fixtures/pingcastle/healthcheck.synthetic.html?raw'; import purpleKnightFixture from '../../fixtures/purple-knight/indicator-results.synthetic.json?raw'; import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; import { createCanonicalImportBatch } from '../../src/importers/canonical-import'; import { importFile } from '../../src/importers/importer'; import { importProfiles } from '../../src/profiles/profile-catalog'; const fixtures = [ { id: 'purple-knight', name: 'Indicator-results.synthetic.json', type: 'application/json', content: purpleKnightFixture }, { id: 'pingcastle', name: 'ad_hc_synthetic.example.html', type: 'text/html', content: pingCastleFixture }, { id: 'cis-cat', name: 'CIS-CAT-SYNTHETIC.html', type: 'text/html', content: cisCatFixture }, { id: 'maester', name: 'TestResults-synthetic.csv', type: 'text/csv', content: maesterFixture }, { id: 'generic', name: 'findings.synthetic.csv', type: 'text/csv', content: genericFixture } ] as const; describe('canonical candidate migration', () => { it.each(fixtures)('persists $id through the confirmed candidate mapping', async fixture => { const dataset = await importFile(new File([fixture.content], fixture.name, { type: fixture.type }), { profiles: importProfiles }); expect(dataset.mapping).toBeDefined(); expect(dataset.profileId).toBe(fixture.id); const previewEntry = previewEntryFromDataset(dataset, { sha256: fixture.id.padEnd(64, '0').slice(0, 64), fileName: fixture.name, sizeBytes: fixture.content.length, lastModified: 0 }); if (previewEntry.mappingsToConfirm > 0) expect(() => createCanonicalImportBatch( `unconfirmed-${fixture.id}`, [previewEntry], '2026-07-20T14:00:00.000Z' )).toThrow(/unbestätigten/); const entry = confirmEntryMappings(previewEntry, importProfiles); const confirmedDataset = entry.dataset!; const selectedTables = new Set(confirmedDataset.recognition?.tables .filter(table => table.selected) .map(table => table.tableIndex)); const expectedCandidates = confirmedDataset.mapping!.candidates.filter(candidate => candidate.source.tableIndex === undefined || selectedTables.has(candidate.source.tableIndex)); expect(expectedCandidates.length).toBeGreaterThan(0); expect(entry.mappingsToConfirm).toBe(0); expect(entry.recordCount).toBe(expectedCandidates.length); if (fixture.id === 'purple-knight') { expect(entry.dataset?.mapping?.fieldDecisions.some(decision => decision.sourceScope.tableIndex === undefined && decision.decisionSource === 'user-confirmed')).toBe(true); } const model = createCanonicalImportBatch(`migration-${fixture.id}`, [entry], '2026-07-20T14:00:00.000Z').model; assertCanonicalAssessmentModel(model); expect(model.findingOccurrences).toHaveLength(expectedCandidates.length); const excludedCandidateCount = confirmedDataset.mapping!.candidates.length - expectedCandidates.length; if (excludedCandidateCount > 0) expect(model.diagnostics).toContainEqual(expect.objectContaining({ code: 'candidate-table-not-selected', details: { excludedCandidateCount } })); expect(model.importFiles[0].processedWith).toMatchObject({ parserId: `${dataset.format.toLocaleLowerCase('en-US')}-format-adapter`, profileId: dataset.mapping!.mappingProfile.id, profileVersion: dataset.profileVersion, mappingVersion: dataset.mapping!.mappingProfile.version }); expect(model.findingOccurrences.every(occurrence => occurrence.sourceIdentity.tool.id === fixture.id)).toBe(true); expect(model.findingOccurrences[0].originalFields.map(field => ({ key: field.key, value: field.value }))) .toEqual(expectedCandidates[0].fields.map(field => ({ key: field.key, value: field.originalValue }))); if (fixture.id === 'cis-cat') { const firstCandidateTitle = expectedCandidates[0].fields.find(field => field.role === 'title')?.decodedValue; expect(firstCandidateTitle).toBeTruthy(); expect(model.findingDefinitions[0].title).toBe(firstCandidateTitle); expect(model.findingDefinitions[0].title).not.toBe('Source: File'); expect(model.findingDefinitions[0].title).not.toBe(fixture.name); } }); it('persists non-finding workbook tables for linked PurpleKnight results', async () => { const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ ['ShortName', 'Name', 'Status', 'Result'], ['SI000055', 'Synthetic permission finding', 'IOE Found', 'SI000055'] ]), 'Indicators results'); XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([ ['Attribute', 'DistinguishedName', 'Ignored'], ['nTSecurityDescriptor', 'CN=Synthetic', false] ]), 'SI000055'); const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); const source = new File([bytes], 'Security_Assessment_Report_synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const dataset = await importFile(source, { profiles: importProfiles }); const preview = previewEntryFromDataset(dataset, { sha256: 'purple-knight-linked-results'.padEnd(64, '0').slice(0, 64), fileName: source.name, sizeBytes: source.size, lastModified: 0 }); const entry = confirmEntryMappings(preview, importProfiles); const model = createCanonicalImportBatch('purple-knight-linked-results', [entry], '2026-07-22T16:00:00.000Z').model; const resultTable = model.sourceTables.find(table => table.name === 'SI000055'); expect(model.findingOccurrences).toHaveLength(1); expect(resultTable?.rows).toHaveLength(1); expect(resultTable?.rows?.[0].fields.map(field => field.value)).toEqual([ 'nTSecurityDescriptor', 'CN=Synthetic', 'FALSE' ]); assertCanonicalAssessmentModel(model); }); it('produces the same canonical meaning for equivalent CSV and native JSON candidates', async () => { const sources = [ { name: 'equivalent.csv', type: 'text/csv', content: 'Rule ID;Title;Severity;Status\nFORMAT-1;Equivalent finding;High;Open' }, { name: 'equivalent.json', type: 'application/json', content: JSON.stringify([{ 'Rule ID': 'FORMAT-1', Title: 'Equivalent finding', Severity: 'High', Status: 'Open' }]) } ]; const models = await Promise.all(sources.map(async (source, index) => { const dataset = await importFile(new File([source.content], source.name, { type: source.type }), { profiles: importProfiles }); const preview = previewEntryFromDataset(dataset, { sha256: `equivalent-${index}`.padEnd(64, '0').slice(0, 64), fileName: source.name, sizeBytes: source.content.length, lastModified: 0 }); const entry = confirmEntryMappings(preview, importProfiles); return createCanonicalImportBatch(`equivalent-${index}`, [entry], '2026-07-20T14:00:00.000Z').model; })); expect(models.map(model => ({ definition: { canonicalKey: model.findingDefinitions[0].canonicalKey, title: model.findingDefinitions[0].title }, occurrence: { severity: model.findingOccurrences[0].severity.normalizedValue, status: model.findingOccurrences[0].status.normalizedValue, ruleId: model.findingOccurrences[0].sourceIdentity.canonicalRuleId } }))).toEqual([ { definition: { canonicalKey: 'generic:format-1', title: 'Equivalent finding' }, occurrence: { severity: 'high', status: 'open', ruleId: 'FORMAT-1' } }, { definition: { canonicalKey: 'generic:format-1', title: 'Equivalent finding' }, occurrence: { severity: 'high', status: 'open', ruleId: 'FORMAT-1' } } ]); }); it('preserves native object paths instead of persisting the JSON compatibility table as provenance', async () => { const dataset = await importFile(new File([purpleKnightFixture], 'Indicator-results.synthetic.json', { type: 'application/json' }), { profiles: importProfiles }); const previewEntry = previewEntryFromDataset(dataset, { sha256: 'native-json'.padEnd(64, '0').slice(0, 64), fileName: dataset.fileName, sizeBytes: dataset.fileSize, lastModified: dataset.lastModified }); if (previewEntry.mappingsToConfirm > 0) expect(() => createCanonicalImportBatch( 'unconfirmed-native-json', [previewEntry], '2026-07-20T14:00:00.000Z' )).toThrow(/unbestätigten/); const entry = confirmEntryMappings(previewEntry, importProfiles); const model = createCanonicalImportBatch('native-json', [entry], '2026-07-20T14:00:00.000Z').model; expect(dataset.mapping?.extractor.id).toBe('native-object-source-extractor'); expect(model.findingOccurrences[0]).toMatchObject({ sourceTableId: undefined, sourcePosition: { objectPath: '$' } }); expect(model.findingOccurrences[0].originalFields.every(field => field.path?.startsWith('$.'))).toBe(true); }); it('keeps repeated HTML table names uniquely addressable through canonical persistence', async () => { const repeatedTables = Array.from({ length: 12 }, (_, index) => `
| Rule ID | Title | Severity |
|---|---|---|
| SYN-${index + 1} | Synthetic finding ${index + 1} | Medium |