import { describe, expect, it } from 'vitest'; import type { CanonicalOutcome } from '../../src/domain/canonical-model'; import { DEVIATION_PREDICATE_VERSION, isDeviation } from '../../src/domain/deviation-predicate'; import type { FindingRecord } from '../../src/domain/finding'; import { isDeviationRecord, normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../src/importers/status-normalization'; function recordWith(fields: Array<[string, string]>): FindingRecord { const built = fields.map(([label, value], index) => ({ label, value, index })); return { id: 'record', index: 0, title: '', fields: built, filled: built, meta: [] }; } describe('Abweichungspraedikat', () => { it('ist versioniert', () => { expect(DEVIATION_PREDICATE_VERSION).toBe('1.1.0'); }); it('enthaelt jede festgestellte Exposition unabhaengig vom Outcome', () => { expect(isDeviation(true, 'fail')).toBe(true); expect(isDeviation(true, 'warning')).toBe(true); expect(isDeviation(true, 'unknown')).toBe(true); }); it('nimmt Outcome warning auch ohne festgestellte Exposition auf', () => { expect(isDeviation(false, 'warning')).toBe(true); }); it('nimmt einen fehlgeschlagenen Compliance-Check auch ohne IOE auf', () => { expect(isDeviation(false, 'fail')).toBe(true); }); it('laesst bestandene und nicht ausgefuehrte Pruefungen unberuehrt', () => { const untouched: CanonicalOutcome[] = ['pass', 'not_run', 'not_applicable', 'not_evaluated', 'informational', 'unknown']; untouched.forEach(outcome => expect(isDeviation(false, outcome)).toBe(false)); }); it('zaehlt einen technischen Ausfuehrungsfehler nicht als fachliche Abweichung', () => { expect(isDeviation(false, 'execution_error')).toBe(false); }); }); describe('Maester-Statusvokabular', () => { /** * Der eigentliche Anlass des Praedikats: `Investigate` ist weder pass noch * fail und faellt daher ohne die Abweichungsachse aus jeder Arbeitsansicht. */ it('normalisiert Investigate auf Outcome warning und damit auf eine Abweichung', () => { const dimensions = normalizeStatusDimensionsFor('maester', 'Investigate'); expect(dimensions.outcome).toBe('warning'); expect(dimensions.technical).toBe('completed'); expect(isDeviation(false, dimensions.outcome)).toBe(true); }); it('laesst Pass und Fail unveraendert', () => { const passed = normalizeStatusDimensionsFor('maester', 'Passed'); expect(passed.legacy).toBe('passed'); expect(passed.outcome).toBe('pass'); expect(isDeviation(false, passed.outcome)).toBe(false); const failed = normalizeStatusDimensionsFor('maester', 'Failed'); expect(failed.legacy).toBe('failed'); expect(failed.outcome).toBe('fail'); expect(isDeviation(false, failed.outcome)).toBe(true); }); it('behandelt Skipped und NotRun weiterhin als nicht ausgefuehrt', () => { expect(normalizeStatusDimensionsFor('maester', 'Skipped').outcome).toBe('not_run'); expect(normalizeStatusDimensionsFor('maester', 'NotRun').outcome).toBe('not_run'); }); }); describe('Outcome eines rohen Statusfeldes fuer die Arbeitsansichten', () => { it('wendet in Work dasselbe Praedikat wie die Result DB an', () => { expect(isDeviationRecord(recordWith([['Severity', 'Critical']]), 'defender-for-iot-vulnerabilities')).toBe(true); expect(isDeviationRecord(recordWith([['Result', 'Failed']]), 'cis-cat')).toBe(true); expect(isDeviationRecord(recordWith([['Result', 'Passed']]), 'cis-cat')).toBe(false); }); it('unterscheidet die drei Maester-Ergebnisse voneinander', () => { expect(outcomeForStatusValue('maester', 'Passed')).toBe('pass'); expect(outcomeForStatusValue('maester', 'Failed')).toBe('fail'); expect(outcomeForStatusValue('maester', 'Investigate')).toBe('warning'); }); it('liefert fuer leere oder fehlende Statuswerte keinen Outcome', () => { expect(outcomeForStatusValue('maester', undefined)).toBeUndefined(); expect(outcomeForStatusValue('maester', ' ')).toBeUndefined(); }); it('normalisiert einen statuslosen Defender-CVE-Befund auf warning', () => { expect(normalizeStatusDimensionsFor('defender-for-iot-vulnerabilities', '')) .toEqual({ legacy: 'unknown', outcome: 'warning', technical: 'completed' }); }); it('bleibt ohne bekannte Werkzeug-ID ueber die generischen Regeln nutzbar', () => { expect(outcomeForStatusValue(undefined, 'Investigate')).toBe('warning'); expect(outcomeForStatusValue(undefined, 'Passed')).toBe('pass'); }); it('bildet auch das Vokabular der anderen Werkzeuge korrekt ab', () => { expect(outcomeForStatusValue('purple-knight', 'IOE Found')).toBe('fail'); expect(outcomeForStatusValue('purple-knight', 'Pass')).toBe('pass'); expect(outcomeForStatusValue('pingcastle', 'Matched')).toBe('fail'); }); }); describe('Werkzeugherkunft je Datensatz in der Projektaggregation', () => { /** * Regression: die Aggregation aller Scans eines Mandanten traegt die * Profil-ID `generic`. Wird sie als Werkzeug verwendet, liest die generische * Rueckfallregel PingCastles `Matched` als blossen Hinweis (`warning`) statt * als erkannte Exposition (`fail`) - Work zeigte dadurch 40 Warnungen, wo die * Result DB nur 3 kennt. */ it('loest das Werkzeug aus der Provenienzspalte statt aus der Profil-ID des Blattes', () => { const pingCastle = recordWith([['Source: Scan', 'PingCastle · 2026-07-25T16:58:46.398Z'], ['Result', 'Matched']]); expect(toolIdForRecord(pingCastle, 'generic')).toBe('pingcastle'); expect(outcomeForStatusValue(toolIdForRecord(pingCastle, 'generic'), 'Matched')).toBe('fail'); }); it('haelt Maester-Investigate auch in der gemischten Aggregation als warning', () => { const maester = recordWith([['Source: Scan', 'Maester · 2026-07-25T23:37:10.570Z'], ['Result', 'Investigate']]); expect(toolIdForRecord(maester, 'generic')).toBe('maester'); expect(outcomeForStatusValue(toolIdForRecord(maester, 'generic'), 'Investigate')).toBe('warning'); }); it('faellt ohne Provenienzspalte auf das Werkzeug des Blattes zurueck', () => { const plain = recordWith([['Result', 'Matched']]); expect(toolIdForRecord(plain, 'pingcastle')).toBe('pingcastle'); expect(toolIdForRecord(plain, undefined)).toBeUndefined(); }); it('ignoriert einen unbekannten Werkzeugnamen und behaelt den Rueckfallwert', () => { const unknown = recordWith([['Source: Scan', 'Fremdwerkzeug · 2026-07-25T00:00:00.000Z'], ['Result', 'Failed']]); expect(toolIdForRecord(unknown, 'generic')).toBe('generic'); }); });