107 lines
6.9 KiB
TypeScript
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();
|
|
});
|
|
});
|