Assessor_2.0/tests/importers/cis-cat-summary.test.ts

261 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import cisCatSummaryFixture from '../../fixtures/cis-cat/summary-and-results.synthetic.html?raw';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { ImportRecognitionResult } from '../../src/domain/import-recognition';
import type { ImportPreviewEntry } from '../../src/domain/import-workflow';
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import {
cisCatResultCategory,
compareCisCatSummary,
extractCisCatSummary
} from '../../src/importers/cis-cat-summary';
import { importFile } from '../../src/importers/importer';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
const fileName = 'WIN-TEST-CIS_Microsoft_Windows_Server_2025_Benchmark.html';
function recognitionFor(sheetId: string, statusColumnIndex: number): ImportRecognitionResult {
const confidence = { level: 'high' as const, score: 1, reasons: [] };
return {
version: 1,
source: {
selected: { toolId: 'cis-cat', toolName: 'CIS-CAT Pro', confidence, evidence: [] },
candidates: [],
decisionSource: 'automatic',
requiresConfirmation: false
},
tables: [],
fields: [{
tableId: sheetId,
tableIndex: 0,
columnIndex: statusColumnIndex,
sourceHeader: 'Result',
proposedRole: 'status',
appliedRole: 'status',
alternatives: [],
confidence,
evidence: [],
decisionSource: 'automatic',
requiresConfirmation: false
}]
};
}
/**
* Baut einen Importeintrag, dessen Ergebniszeilen frei waehlbar sind. Der
* Summary-Block stammt unveraendert aus dem Fixture und erklaert dort 3 Pass,
* 2 Fail und 1 Manual - also sechs Ergebniszeilen.
*/
function entryWithResults(results: string[]): ImportPreviewEntry {
const rows = [['Benchmark Item', 'Result'], ...results.map((result, index) => [`1.1.${index + 1} Synthetische Empfehlung`, result])];
const sheet = makeSheet('Assessment Results', rows, '', fileName);
const dataset: ImportedDataset = {
fileName,
fileSize: cisCatSummaryFixture.length,
lastModified: 0,
format: 'HTML',
rawText: cisCatSummaryFixture,
sheets: [sheet],
profileId: 'cis-cat',
profileVersion: '1.0.0',
scanTool: 'CIS-CAT Pro',
recognition: recognitionFor(sheet.id, 1)
};
return {
id: fileName,
fileName,
sizeBytes: dataset.fileSize,
lastModified: 0,
sha256: 'a'.repeat(64),
status: 'ready',
selected: true,
format: 'HTML',
toolId: 'cis-cat',
toolName: 'CIS-CAT Pro',
confidence: 'high',
confidenceScore: 0.9,
tableCount: 1,
recordCount: results.length,
rowCount: results.length,
warningMessages: [],
errorMessages: [],
unassignedFields: [],
mappingsToConfirm: 0,
dataset
};
}
function diagnosticCodes(entry: ImportPreviewEntry): string[] {
return createCanonicalImportBatch('batch-cis', [entry], '2026-07-18T08:00:00.000Z')
.model.diagnostics.map(item => item.code);
}
describe('CIS-CAT Summary-Gegenprobe', () => {
it('liest die sechs Ergebniszahlen aus dem Summary-Block', () => {
const summary = extractCisCatSummary(cisCatSummaryFixture);
expect(summary).not.toBeNull();
expect(summary!.counts).toEqual({ pass: 3, fail: 2, error: 0, unknown: 0, manual: 1, excepted: 0 });
});
/**
* Der Nenner der Kopfzeile zaehlt nur die bewerteten Empfehlungen. Im echten
* Windows-Server-2025-Bericht steht dort 410, waehrend der Bericht 411
* Ergebniszeilen enthaelt - die eine Manual-Zeile fehlt darin. Eine Pruefung
* gegen diesen Nenner wuerde jeden vollstaendigen Import als fehlerhaft melden.
*/
it('nutzt die Summe der Kategorien als Sollgroesse, nicht den Nenner der Kopfzeile', () => {
const summary = extractCisCatSummary(cisCatSummaryFixture)!;
expect(summary.scoredTotal).toBe(5);
expect(summary.declaredRowCount).toBe(6);
});
it('meldet keine Summary, wenn der Bericht keinen auswertbaren Block traegt', () => {
expect(extractCisCatSummary('<html><body><p>Kein Summary</p></body></html>')).toBeNull();
});
/**
* CIS-CAT rendert die Manual-Zeile als `<span class="informational">Manual</span>`.
* Die Zuordnung muss deshalb ueber den Text laufen, nicht ueber die CSS-Klasse.
*/
it('ordnet den Ergebnistext den Kategorien zu, auch bei Manual', () => {
expect(cisCatResultCategory('Pass')).toBe('pass');
expect(cisCatResultCategory(' fail ')).toBe('fail');
expect(cisCatResultCategory('Manual')).toBe('manual');
expect(cisCatResultCategory('Exists')).toBeUndefined();
});
it('bestaetigt einen Import, dessen Ergebniszeilen der Summary entsprechen', () => {
const summary = extractCisCatSummary(cisCatSummaryFixture)!;
const comparison = compareCisCatSummary(summary, ['Pass', 'Pass', 'Pass', 'Fail', 'Fail', 'Manual']);
expect(comparison.matches).toBe(true);
expect(comparison.differences).toEqual([]);
expect(comparison.importedRowCount).toBe(6);
});
/**
* Der reale Fehlerfall: Gruppen-Kopfzeilen der Ergebnistabelle und Zeilen der
* je Regel eingebetteten Nachweistabellen werden als eigenstaendige Funde
* uebernommen. Sie tragen keinen der sechs Ergebniswerte und muessen deshalb
* getrennt ausgewiesen werden.
*/
it('weist Zeilen ohne Ergebniswert getrennt aus', () => {
const summary = extractCisCatSummary(cisCatSummaryFixture)!;
const comparison = compareCisCatSummary(summary, [
'Pass', 'Pass', 'Pass', 'Fail', 'Fail', 'Manual',
'', '', 'Exists', 'Exists'
]);
expect(comparison.matches).toBe(false);
expect(comparison.uncategorizedCount).toBe(4);
expect(comparison.importedRowCount).toBe(10);
expect(comparison.differences).toEqual([]);
});
it('benennt fehlende Datensaetze je Kategorie', () => {
const summary = extractCisCatSummary(cisCatSummaryFixture)!;
const comparison = compareCisCatSummary(summary, ['Pass', 'Fail', 'Manual']);
expect(comparison.matches).toBe(false);
expect(comparison.differences).toEqual([
{ category: 'pass', declared: 3, imported: 1 },
{ category: 'fail', declared: 2, imported: 1 }
]);
});
});
describe('CIS-CAT Summary-Gegenprobe im Importlauf', () => {
it('bestaetigt den Import, wenn die Anzahl stimmt', () => {
const codes = diagnosticCodes(entryWithResults(['Pass', 'Pass', 'Pass', 'Fail', 'Fail', 'Manual']));
expect(codes).toContain('cis-cat-summary-verified');
expect(codes).not.toContain('cis-cat-summary-mismatch');
});
/**
* Der reale Fall: neben den sechs Ergebniszeilen kommen Gruppen-Kopfzeilen und
* Zeilen der Nachweistabellen an. Sie tragen keinen Ergebniswert, die
* verbleibende Menge trifft die Summary exakt - also duerfen sie entfallen.
*/
it('verwirft Zeilen ohne Ergebniswert, wenn der Rest exakt zur Summary passt', () => {
const batch = createCanonicalImportBatch(
'batch-cis',
[entryWithResults(['Pass', 'Pass', 'Pass', 'Fail', 'Fail', 'Manual', '1 Account Policies', 'Exists', 'Not collected'])],
'2026-07-18T08:00:00.000Z'
);
const dropped = batch.model.diagnostics.find(item => item.code === 'cis-cat-non-result-rows-dropped');
expect(dropped?.details).toMatchObject({ droppedCount: 3, keptCount: 6 });
expect(batch.model.diagnostics.map(item => item.code)).toContain('cis-cat-summary-verified');
expect(batch.model.findingOccurrences).toHaveLength(6);
expect(batch.model.findingOccurrences.map(occurrence => occurrence.status.originalValue).sort())
.toEqual(['Fail', 'Fail', 'Manual', 'Pass', 'Pass', 'Pass']);
});
it('laesst keine Finding-Definition ohne Vorkommen zurueck', () => {
const batch = createCanonicalImportBatch(
'batch-cis',
[entryWithResults(['Pass', 'Pass', 'Pass', 'Fail', 'Fail', 'Manual', '1 Account Policies', 'Exists', 'Not collected'])],
'2026-07-18T08:00:00.000Z'
);
const referenced = new Set(batch.model.findingOccurrences.map(occurrence => occurrence.definitionId));
expect(batch.model.findingDefinitions.every(definition => referenced.has(definition.id))).toBe(true);
});
/**
* Sicherung gegen stillschweigenden Datenverlust: passt auch die auf
* Ergebniszeilen beschraenkte Menge nicht zur Summary, ist die Berichtsform
* unverstanden - dann darf nichts verworfen werden.
*/
it('verwirft nichts, wenn auch die gefilterte Menge nicht zur Summary passt', () => {
const batch = createCanonicalImportBatch(
'batch-cis',
[entryWithResults(['Pass', 'Fail', 'Manual', 'Exists'])],
'2026-07-18T08:00:00.000Z'
);
const diagnostic = batch.model.diagnostics.find(item => item.code === 'cis-cat-summary-mismatch');
expect(diagnostic).toBeDefined();
expect(diagnostic!.severity).toBe('warning');
expect(batch.model.findingOccurrences).toHaveLength(4);
expect(batch.model.diagnostics.map(item => item.code)).not.toContain('cis-cat-non-result-rows-dropped');
});
it('prueft nur CIS-CAT-Dateien', () => {
const entry = entryWithResults(['Pass']);
const codes = diagnosticCodes({
...entry,
toolId: 'purple-knight',
toolName: 'PurpleKnight',
dataset: { ...entry.dataset!, profileId: 'purple-knight', scanTool: 'PurpleKnight' }
});
expect(codes.some(code => code.startsWith('cis-cat-summary'))).toBe(false);
});
});
describe('Verfuegbarkeit des Summary-Blocks nach dem Parsen', () => {
/**
* Die Gegenprobe liest den Rohtext des geparsten Dokuments. Dieser Test
* sichert, dass der Summary-Block dort ueberhaupt ankommt - andernfalls
* liefe die Pruefung ins Leere, ohne dass es auffiele.
*/
it('behaelt den Summary-Block im Rohtext des importierten Dokuments', async () => {
const file = new File([cisCatSummaryFixture], fileName, { type: 'text/html' });
const imported = await importFile(file, { profiles: [genericProfile] });
const summary = extractCisCatSummary(imported.intermediate?.rawText ?? imported.rawText);
expect(summary?.counts).toEqual({ pass: 3, fail: 2, error: 0, unknown: 0, manual: 1, excepted: 0 });
});
});