220 lines
11 KiB
TypeScript
220 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { profileForSheet } from '../../src/app/AppProvider';
|
|
import cisCatFixture from '../../fixtures/cis-cat/assessment.synthetic.html?raw';
|
|
import maesterFixture from '../../fixtures/maester/test-results.synthetic.csv?raw';
|
|
import pingCastleFixture from '../../fixtures/pingcastle/healthcheck.synthetic.html?raw';
|
|
import { importFile } from '../../src/importers/importer';
|
|
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
|
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
|
import { toolProfileDefinitions } from '../../src/profiles/profile-definition-catalog';
|
|
|
|
const options = { profiles: importProfiles };
|
|
|
|
describe('declarative tool profiles', () => {
|
|
it('requires an explicit native-score decision and fallback population for every registered profile', () => {
|
|
for (const { securityScoring: scoring } of toolProfileDefinitions) {
|
|
expect(scoring.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
expect(['complete', 'findings-only', 'unknown']).toContain(scoring.resultPopulation);
|
|
if (scoring.native.kind === 'mapped') {
|
|
expect(scoring.native.properties.length).toBeGreaterThan(0);
|
|
expect(scoring.native.properties.every(property => property.trim().length > 0)).toBe(true);
|
|
expect(scoring.native.scope).toBe('overall');
|
|
expect(scoring.native.scale).toBe('0-100');
|
|
expect(['higher-is-better', 'lower-is-better']).toContain(scoring.native.direction);
|
|
} else {
|
|
expect(scoring.native.kind).toBe('unavailable');
|
|
expect(scoring.native.reason.trim().length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('requires an explicit, versioned remediation-comparison decision for every registered profile', () => {
|
|
for (const { timeslotComparison: comparison } of toolProfileDefinitions) {
|
|
expect(comparison.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
if (comparison.kind === 'unavailable') {
|
|
expect(comparison.reason.trim().length).toBeGreaterThan(0);
|
|
continue;
|
|
}
|
|
expect(['scope', 'asset', 'aggregate']).toContain(comparison.granularity);
|
|
expect(comparison.scope.kind.trim().length).toBeGreaterThan(0);
|
|
expect(comparison.scope.properties.length).toBeGreaterThan(0);
|
|
expect(comparison.scope.properties.every(property => property.trim().length > 0)).toBe(true);
|
|
expect(comparison.observationTimeProperties.length).toBeGreaterThan(0);
|
|
expect(comparison.observationTimeProperties.every(property => property.trim().length > 0)).toBe(true);
|
|
expect(['explicit-results', 'findings-only', 'unknown']).toContain(comparison.resultEvidence);
|
|
expect(['unsupported', 'complete-explicit-negative']).toContain(comparison.negativeEvidence);
|
|
expect(comparison.diagnosticPolicy).toBe('fail-closed-by-impact');
|
|
}
|
|
});
|
|
|
|
it('requires an explicit, versioned priority basis for every registered profile', () => {
|
|
for (const { priorityBasis } of toolProfileDefinitions) {
|
|
expect(priorityBasis.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
const { basis } = priorityBasis;
|
|
if (basis.kind === 'severity' || basis.kind === 'numeric') {
|
|
expect(basis.properties.length).toBeGreaterThan(0);
|
|
expect(basis.properties.every(property => property.trim().length > 0)).toBe(true);
|
|
}
|
|
if (basis.kind === 'numeric') {
|
|
expect(basis.scale.trim().length).toBeGreaterThan(0);
|
|
expect(['higher-is-worse', 'lower-is-worse']).toContain(basis.direction);
|
|
}
|
|
if (basis.kind === 'unavailable') expect(basis.reason.trim().length).toBeGreaterThan(0);
|
|
for (const excluded of priorityBasis.excludedProperties ?? []) {
|
|
expect(excluded.property.trim().length).toBeGreaterThan(0);
|
|
expect(excluded.reason.trim().length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('never promotes a CIS profile tier into a criticality', () => {
|
|
const cisCat = toolProfileDefinitions.find(definition => definition.id === 'cis-cat');
|
|
expect(cisCat?.priorityBasis.basis.kind).toBe('unavailable');
|
|
expect(cisCat?.priorityBasis.excludedProperties?.map(entry => entry.property))
|
|
.toContain('recommendation.level');
|
|
});
|
|
|
|
it('declares PingCastle on points because its severity is derived and capped', () => {
|
|
const pingCastle = toolProfileDefinitions.find(definition => definition.id === 'pingcastle');
|
|
expect(pingCastle?.priorityBasis.basis).toMatchObject({
|
|
kind: 'numeric', direction: 'higher-is-worse'
|
|
});
|
|
});
|
|
|
|
it('provides a unique, versioned definition for every runtime profile', () => {
|
|
expect(new Set(toolProfileDefinitions.map(definition => definition.id)).size).toBe(toolProfileDefinitions.length);
|
|
expect(importProfiles.map(profile => profile.id)).toEqual(toolProfileDefinitions.map(definition => definition.id));
|
|
for (const profile of importProfiles) {
|
|
expect(profile.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
expect(profile.definition).toMatchObject({
|
|
schemaVersion: 1,
|
|
id: profile.id,
|
|
name: profile.name,
|
|
version: profile.version
|
|
});
|
|
expect(profile.definition.fieldRules.length).toBeGreaterThan(0);
|
|
expect(profile.definition.comparisonKeys.length).toBeGreaterThan(0);
|
|
expect(profile.definition.comparisonKeys.every(key => key.includes('rule-id'))).toBe(true);
|
|
expect(profile.defaults(undefined).statusFilter).toBe('deviation');
|
|
expect(profile.presentation.statusFilters.map(filter => filter.value)).toContain('deviation');
|
|
}
|
|
expect(genericProfile.definition.fallback).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'PingCastle',
|
|
fileName: 'ad_hc_synthetic.example.html',
|
|
type: 'text/html',
|
|
content: pingCastleFixture,
|
|
profileId: 'pingcastle',
|
|
firstSheet: 'Risk Scores'
|
|
},
|
|
{
|
|
name: 'CIS-CAT',
|
|
fileName: 'CIS-CAT-SYNTHETIC.html',
|
|
type: 'text/html',
|
|
content: cisCatFixture,
|
|
profileId: 'cis-cat',
|
|
firstSheet: 'assessmentResultTable'
|
|
},
|
|
{
|
|
name: 'Maester',
|
|
fileName: 'TestResults-synthetic.csv',
|
|
type: 'text/csv',
|
|
content: maesterFixture,
|
|
profileId: 'maester',
|
|
firstSheet: 'CSV'
|
|
}
|
|
])('selects and applies the $name profile', async ({ fileName, type, content, profileId, firstSheet }) => {
|
|
const imported = await importFile(new File([content], fileName, { type }), options);
|
|
|
|
expect(imported.profileId).toBe(profileId);
|
|
// Gegen den Katalog pruefen statt gegen eine feste Zahl: die Zusage ist,
|
|
// dass die tatsaechliche Profilversion im Importergebnis landet - ein
|
|
// fachlich begruendeter Versionssprung darf diesen Test nicht brechen.
|
|
expect(imported.profileVersion).toBe(importProfiles.find(profile => profile.id === profileId)?.version);
|
|
expect(imported.sheets[0].name).toBe(firstSheet);
|
|
expect(imported.scanTool).toBe(importProfiles.find(profile => profile.id === profileId)?.name);
|
|
});
|
|
|
|
it('uses the sheet tool profile inside a mixed project workspace', async () => {
|
|
const imported = await importFile(new File([cisCatFixture], 'CIS-CAT-SYNTHETIC.html', { type: 'text/html' }), options);
|
|
const sheet = { ...imported.sheets[0], toolId: imported.profileId };
|
|
const mixedDataset = { ...imported, sheets: [sheet], profileId: 'generic', profileVersion: 'gemischt' };
|
|
|
|
expect(sheet.toolId).toBe('cis-cat');
|
|
expect(profileForSheet(mixedDataset, sheet).id).toBe('cis-cat');
|
|
expect(profileForSheet(mixedDataset, sheet).defaults(sheet).titleColumn).toBe('Benchmark Item');
|
|
});
|
|
|
|
it('combines the status filters of every tool in a mixed timeslot aggregate', async () => {
|
|
const imported = await importFile(new File([cisCatFixture], 'CIS-CAT-SYNTHETIC.html', { type: 'text/html' }), options);
|
|
const aggregate = { ...imported.sheets[0], id: 'aggregate', toolId: undefined, isProjectSheet: true };
|
|
const maesterSheet = { ...imported.sheets[0], id: 'maester-sheet', toolId: 'maester' };
|
|
const purpleKnightSheet = { ...imported.sheets[0], id: 'purple-sheet', toolId: 'purple-knight' };
|
|
const mixedDataset = { ...imported, sheets: [aggregate, maesterSheet, purpleKnightSheet], profileId: 'generic', profileVersion: 'gemischt' };
|
|
|
|
const aggregateProfile = profileForSheet(mixedDataset, aggregate);
|
|
const filterValues = aggregateProfile.presentation.statusFilters.map(filter => filter.value);
|
|
expect(aggregateProfile.id).toBe('multiple');
|
|
expect(filterValues).toContain('investigate');
|
|
expect(filterValues).toContain('ioes-found');
|
|
expect(profileForSheet(mixedDataset, maesterSheet).id).toBe('maester');
|
|
expect(profileForSheet(mixedDataset, purpleKnightSheet).id).toBe('purple-knight');
|
|
});
|
|
|
|
it('selects Purple Knight through the same profile factory and keeps status filters', async () => {
|
|
const text = [
|
|
'Name;Short Name;SI Version;Severity;Weight;Category;Result',
|
|
'Synthetic indicator;SYN-001;1;High;10;Identity;IOEs found'
|
|
].join('\n');
|
|
|
|
const imported = await importFile(new File([text], 'PurpleKnight-results.csv', { type: 'text/csv' }), options);
|
|
const profile = importProfiles.find(candidate => candidate.id === imported.profileId);
|
|
|
|
expect(imported.profileId).toBe('purple-knight');
|
|
// 1.4.0 additionally covers the Entra-ID summary and scan scope;
|
|
// canonical status normalization stays at 1.1.0.
|
|
expect(imported.profileVersion).toBe('1.4.0');
|
|
expect(profile?.presentation.statusFilters.map(filter => filter.value)).toContain('ioes-found');
|
|
expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'ioes-found')).toBe(true);
|
|
expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'deviation')).toBe(true);
|
|
});
|
|
|
|
it('requires confirmation and keeps the generic profile when only the file name identifies a tool', async () => {
|
|
const imported = await importFile(new File(['Alpha;Beta\none;two'], 'CIS-CAT-SYNTHETIC.csv', { type: 'text/csv' }), options);
|
|
|
|
expect(imported.recognition?.source).toMatchObject({
|
|
selected: { toolId: 'cis-cat' },
|
|
requiresConfirmation: true
|
|
});
|
|
expect(imported.profileId).toBe('generic');
|
|
expect(imported.mapping?.mappingProfile.id).toBe('generic');
|
|
|
|
const preview = previewEntryFromDataset(imported, {
|
|
sha256: 'c'.repeat(64), fileName: imported.fileName, sizeBytes: imported.fileSize, lastModified: imported.lastModified
|
|
});
|
|
const confirmed = confirmEntryMappings(preview, importProfiles);
|
|
expect(confirmed.dataset).toMatchObject({ profileId: 'cis-cat', scanTool: 'CIS-CAT Pro' });
|
|
// Die Mappingversion ist die deklarierte Abbildungsversion des Profils und
|
|
// nicht dessen Gesamtversion: ein reiner Kontextzuwachs hebt die
|
|
// Profilversion, laesst die Feldabbildung aber unveraendert.
|
|
expect(confirmed.dataset?.mapping?.mappingProfile).toMatchObject({
|
|
kind: 'mapping-profile', id: 'cis-cat',
|
|
version: importProfiles.find(profile => profile.id === 'cis-cat')?.definition.processing.mappingVersion
|
|
});
|
|
expect(confirmed.dataset?.recognition?.source.decisionSource).toBe('user-confirmed');
|
|
});
|
|
it('keeps an unknown table on the generic fallback profile', async () => {
|
|
const imported = await importFile(new File(['Alpha;Beta\none;two'], 'unknown.csv', { type: 'text/csv' }), options);
|
|
|
|
expect(imported.profileId).toBe('generic');
|
|
expect(imported.profileVersion).toBe('1.0.0');
|
|
expect(imported.sheets[0].rows[1]).toEqual(['one', 'two']);
|
|
});
|
|
});
|