261 lines
10 KiB
TypeScript
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 });
|
|
});
|
|
});
|