// @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('
Kein Summary
')).toBeNull(); }); /** * CIS-CAT rendert die Manual-Zeile als `Manual`. * 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 }); }); });