Assessor_2.0/tests/importers/import-compatibility.test.ts

107 lines
6.9 KiB
TypeScript

// @vitest-environment jsdom
import { readFileSync, readdirSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import contracts from './import-contracts.json';
import { importFile } from '../../src/importers/importer';
import { importSource } from '../../src/importers/engine';
import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters';
import { importProfiles } from '../../src/profiles/profile-catalog';
import { previewEntryFromDataset, confirmEntryMappings } from '../../src/features/import-workflow/import-workflow';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
import { canonicalSecurityScores } from '../../src/scoring/canonical-security-score';
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
// Keep all source data intact. Only generated record links, processing versions and
// import timestamps are excluded: a compatible implementation may change these.
function semanticRecord(record: object) {
const excluded = new Set(['id', 'definitionId', 'occurrenceId', 'scanRunId', 'importFileId',
'sourceTableId', 'createdAt', 'updatedAt', 'mappingRuleVersion', 'mappingVersion', 'extractorVersion']);
return Object.fromEntries(Object.entries(record).filter(([key]) => !excluded.has(key)));
}
function mapped(value: { originalValue: unknown; normalizedValue: unknown } | undefined) {
return value && { originalValue: value.originalValue, normalizedValue: value.normalizedValue };
}
function result(model: CanonicalAssessmentModel) {
return {
definitions: model.findingDefinitions.map(semanticRecord),
findings: model.findingOccurrences.map(item => ({ ...semanticRecord(item),
status: mapped(item.status), severity: mapped(item.severity), outcome: mapped(item.outcome),
technicalExecutionState: mapped(item.technicalExecutionState), score: mapped(item.score),
affectedAsset: mapped(item.affectedAsset),
fieldMappings: item.fieldMappings.map(({ provenance, ...mapping }) => ({ ...mapping,
decisionSource: provenance.decisionSource })),
})),
evidence: model.evidence.map(item => ({ ...semanticRecord(item),
finding: model.findingOccurrences.find(finding => finding.id === item.occurrenceId)?.sourceIdentity })),
observations: model.environmentObservations?.map(semanticRecord),
reportSections: model.reportSections?.map(semanticRecord),
scores: canonicalSecurityScores([model]).map(semanticRecord),
tables: model.sourceTables.map(table => ({ name: table.name, index: table.index,
hidden: table.hidden, rows: table.rows, rowCount: table.rowCount, columnCount: table.columnCount })),
};
}
function input(contract: typeof contracts[number]) {
const text = readFileSync(resolve(contract.fixture), 'utf8').replaceAll('\r\n', '\n');
expect(createHash('sha256').update(text).digest('hex'), `${contract.id}: protected fixture changed`).toBe(contract.sha256);
if (contract.variant === 'xlsx') {
const sheet = XLSX.read(text, { type: 'string', raw: true }).Sheets.Sheet1;
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, 'Tests');
return new Uint8Array(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }));
}
const transformed = contract.variant === 'bare-json'
? text.replace(/const testResults\s*=\s*/, '')
: contract.variant === 'let' || contract.variant === 'var'
? text.replace('const testResults=', `${contract.variant} testResults=`) : text;
return new TextEncoder().encode(transformed);
}
describe('protected scanner import contracts (synthetic data only)', () => {
it('covers every registered tool and keeps contract identifiers unique', () => {
expect(new Set(contracts.map(item => item.id)).size).toBe(contracts.length);
expect([...new Set(contracts.map(item => item.tool))].sort()).toEqual(importProfiles.map(item => item.id).sort());
expect(contracts.every(item => item.fixture.includes('.synthetic.'))).toBe(true);
const originalContracts = ['pingcastle-html', 'pingcastle-xml', 'cis-cat-html', 'cis-cat-future-controls',
'cis-cat-summary', 'maester-csv', 'maester-legacy-html', 'maester-literal-html', 'maester-current-html',
'purple-knight-html', 'purple-knight-json', 'purple-knight-summary', 'generic-csv',
'maester-bare-json', 'maester-let', 'maester-var', 'maester-xlsx', 'maester-multi-tenant'];
expect(contracts.map(item => item.id)).toEqual(expect.arrayContaining(originalContracts));
const fixtures = readdirSync('fixtures', { recursive: true }).map(String)
.filter(path => path.includes('.synthetic.')).map(path => `fixtures/${path.replaceAll('\\', '/')}`);
expect([...new Set(contracts.map(item => item.fixture))].sort()).toEqual(fixtures.sort());
});
it('requires the fail-closed compatibility gate before normal tests and production builds', () => {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
expect(pkg.scripts.test).toMatch(/^npm run test:imports && /);
expect(pkg.scripts.prebuild).toBe('npm run test:imports');
expect(pkg.scripts['test:imports']).toBe('node scripts/check-import-contracts.mjs');
});
it.each(contracts)('$id retains recognition, preview and approved canonical results in browser and backend', async contract => {
const bytes = input(contract);
const metadata = { name: contract.fileName, sizeBytes: bytes.length, lastModified: 0, mediaType: contract.mediaType };
const browser = await importFile(new File([bytes], contract.fileName, { type: contract.mediaType }), { profiles: importProfiles });
const backend = await importSource({ bytes, metadata }, { profiles: importProfiles }, nodeImportRuntimeAdapters);
const outputs = [];
for (const dataset of [browser, backend]) {
expect(dataset.profileId).toBe(contract.tool);
expect(dataset.format).toBe(contract.format);
const preview = previewEntryFromDataset(dataset, { ...metadata, fileName: contract.fileName,
sha256: createHash('sha256').update(bytes).digest('hex') });
expect(preview.status).not.toBe('error');
const approved = confirmEntryMappings(preview, importProfiles);
const model = createCanonicalImportBatch('protected-dummy-import', [approved], '2026-10-09T00:00:00Z').model;
assertCanonicalAssessmentModel(model);
expect(model.findingOccurrences).toHaveLength(contract.findings);
outputs.push({ recognition: dataset.recognition?.source.selected.toolId,
preview: { selected: preview.selected, recordCount: preview.recordCount, mappingsToConfirm: preview.mappingsToConfirm },
approved: { selected: approved.selected, recordCount: approved.recordCount }, ...result(model) });
}
expect(outputs[1]).toEqual(outputs[0]);
expect(outputs[0]).toMatchSnapshot();
});
});