137 lines
6.5 KiB
TypeScript
137 lines
6.5 KiB
TypeScript
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');
|
|
});
|
|
});
|