128 lines
7.9 KiB
TypeScript
128 lines
7.9 KiB
TypeScript
import { JSDOM } from 'jsdom';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { CanonicalOutcome } from '../../src/domain/canonical-model';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import { buildManagementOverviewHtml } from '../../src/reporting/management-overview-report';
|
|
import { buildReportProjection, type ReportProjection } from '../../src/reporting/report-projection';
|
|
import { statementText } from '../../src/reporting/report-statement-text';
|
|
import type { ReportStatement } from '../../src/reporting/report-narrative';
|
|
import { buildTechnicalReportHtml } from '../../src/reporting/technical-report';
|
|
|
|
const domains = [
|
|
{ id: 'identity', label: 'Identity & Access' },
|
|
{ id: 'legacy', label: 'Legacy Protocols' },
|
|
{ id: 'cloud', label: 'Cloud & Applications' }
|
|
];
|
|
|
|
let sequence = 0;
|
|
function result(domain: string, outcome: CanonicalOutcome, deviation = outcome === 'fail'): UnifiedResultProjection {
|
|
sequence += 1;
|
|
return {
|
|
resultProjectionId: `dummy-${domain}-${sequence}`, sourceKind: 'scanner-finding', sourceResultId: `source-${sequence}`,
|
|
projectId: 'dummy-project', title: `Synthetic check ${sequence}`, sourceTool: 'Dummy Scanner', sourceRuleId: `DUMMY-${sequence}`,
|
|
severityNormalized: 'unknown', outcomeNormalized: outcome, isDeviation: deviation, categories: [], frameworkReferences: [],
|
|
description: domain
|
|
} as unknown as UnifiedResultProjection;
|
|
}
|
|
|
|
function many(count: number, domain: string, outcome: CanonicalOutcome): UnifiedResultProjection[] {
|
|
return Array.from({ length: count }, () => result(domain, outcome));
|
|
}
|
|
|
|
function project(results: UnifiedResultProjection[]): ReportProjection {
|
|
return buildReportProjection({
|
|
generatedAt: '2026-09-30T08:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' },
|
|
results, domains, domainOf: item => item.description ?? 'unclassified'
|
|
});
|
|
}
|
|
|
|
function statement<K extends ReportStatement['id']>(projection: ReportProjection, id: K): Extract<ReportStatement, { id: K }> {
|
|
return projection.statements.find((item): item is Extract<ReportStatement, { id: K }> => item.id === id)!;
|
|
}
|
|
|
|
describe('Report Engine key statements', () => {
|
|
it('states overall, focus and limits only from the aggregates and traces every number to result IDs', () => {
|
|
const projection = project([
|
|
...many(2, 'identity', 'fail'), ...many(8, 'identity', 'pass'),
|
|
...many(3, 'legacy', 'fail'), ...many(1, 'legacy', 'pass'),
|
|
...many(2, 'identity', 'not_run'), ...many(1, 'legacy', 'not_applicable'),
|
|
...many(1, 'legacy', 'execution_error'), ...many(1, 'cloud', 'informational')
|
|
]);
|
|
expect(projection.version).toBe('1.3.0');
|
|
expect(projection.statements.map(item => item.id)).toEqual(['overall', 'focus', 'limits']);
|
|
expect(statementText(statement(projection, 'overall')).text)
|
|
.toBe('Im Assessment wurden 14 Ergebnisse bewertet. 5 davon weichen vom Soll ab (36 %).');
|
|
expect(statementText(statement(projection, 'focus'))).toEqual({
|
|
text: 'Die meisten Abweichungen und den höchsten Abweichungsanteil zeigt Legacy Protocols (3 von 4, 75 %).',
|
|
note: 'Anteil und Häufigkeit sagen nichts über Schweregrad oder Geschäftsrisiko aus.'
|
|
});
|
|
expect(statementText(statement(projection, 'limits')).text)
|
|
.toBe('2 Prüfungen wurden nicht ausgeführt, 1 war nicht anwendbar, 1 endete mit einem Fehler und 1 lieferte kein bewertbares Ergebnis. Sie sind weder bestanden noch abweichend und fließen in keine Quote ein.');
|
|
|
|
const overall = statement(projection, 'overall');
|
|
expect(overall.evidence.find(item => item.role === 'deviations')!.ids).toHaveLength(overall.deviations);
|
|
const limits = statement(projection, 'limits');
|
|
const unassessed = limits.evidence.flatMap(item => item.ids);
|
|
expect(new Set(unassessed).size).toBe(projection.coverage.total - projection.coverage.assessed);
|
|
const legacy = projection.domains.find(item => item.domainId === 'legacy')!;
|
|
expect(legacy.evidence.deviations).toHaveLength(3);
|
|
expect(legacy.evidence.passed).toHaveLength(1);
|
|
});
|
|
|
|
it('names every tied domain instead of choosing one and separates count and share', () => {
|
|
const projection = project([
|
|
...many(3, 'identity', 'fail'), ...many(7, 'identity', 'pass'),
|
|
...many(3, 'legacy', 'fail'), ...many(3, 'legacy', 'pass'),
|
|
...many(1, 'cloud', 'fail'), ...many(1, 'cloud', 'pass')
|
|
]);
|
|
expect(statementText(statement(projection, 'focus')).text).toBe(
|
|
'Die meisten Abweichungen liegen in Identity & Access (3 von 10 bewerteten) und Legacy Protocols (3 von 6 bewerteten). '
|
|
+ 'Den höchsten Abweichungsanteil zeigen Legacy Protocols (3 von 6, 50 %) und Cloud & Applications (1 von 2, 50 %).');
|
|
const evidenceDomains = new Set(statement(projection, 'focus').evidence.map(item => item.domainId));
|
|
expect(evidenceDomains).toEqual(new Set(['identity', 'legacy', 'cloud']));
|
|
});
|
|
|
|
it('never rounds a positive share to zero and omits statements without a factual basis', () => {
|
|
const tiny = project([...many(1, 'identity', 'fail'), ...many(399, 'identity', 'pass')]);
|
|
expect(statementText(statement(tiny, 'overall')).text)
|
|
.toBe('Im Assessment wurden 400 Ergebnisse bewertet. 1 davon weicht vom Soll ab (unter 1 %).');
|
|
expect(tiny.statements.some(item => item.id === 'limits')).toBe(false);
|
|
|
|
const clean = project([...many(1, 'identity', 'pass')]);
|
|
expect(statementText(statement(clean, 'overall')).text).toBe('Im Assessment wurde 1 Ergebnis bewertet. Keines davon weicht vom Soll ab.');
|
|
expect(clean.statements.map(item => item.id)).toEqual(['overall']);
|
|
|
|
const empty = project([...many(1, 'cloud', 'not_run')]);
|
|
expect(statementText(statement(empty, 'overall')).text).toBe('Im gewählten Stand liegen keine bewerteten Ergebnisse vor.');
|
|
expect(statementText(statement(empty, 'limits')).text)
|
|
.toBe('1 Prüfung wurde nicht ausgeführt. Sie ist weder bestanden noch abweichend und fließt in keine Quote ein.');
|
|
});
|
|
|
|
it('places the statements under the key figures of the Management Report without any result ID', () => {
|
|
const projection = project([...many(2, 'identity', 'fail'), ...many(2, 'identity', 'pass'), ...many(1, 'legacy', 'not_run')]);
|
|
const html = buildManagementOverviewHtml(projection);
|
|
const document = new JSDOM(html).window.document;
|
|
const blocks = [...document.querySelectorAll('.sheet > *')].map(element => element.className);
|
|
expect(blocks.indexOf('statements')).toBe(blocks.indexOf('facts') + 1);
|
|
expect(document.querySelector('.statements h2')?.textContent).toBe('Kernaussagen');
|
|
expect(document.querySelectorAll('.statements li')).toHaveLength(3);
|
|
for (const item of projection.statements) expect(document.querySelector('.statements')?.textContent).toContain(statementText(item).text);
|
|
expect(html).not.toMatch(/dummy-(?:identity|legacy)-\d+|DUMMY-\d+/);
|
|
expect(html).not.toMatch(/<script\b|https?:\/\//i);
|
|
});
|
|
|
|
it('proves each statement in the Technical Report with the same sentence and its result IDs', () => {
|
|
const projection = project([...many(2, 'identity', 'fail'), ...many(1, 'identity', 'pass'), ...many(1, 'legacy', 'not_run')]);
|
|
const html = buildTechnicalReportHtml(projection);
|
|
const document = new JSDOM(html).window.document;
|
|
const section = [...document.querySelectorAll('section.block')].find(item => item.querySelector('h2')?.textContent === 'Belege der Kernaussagen')!;
|
|
expect(section).toBeTruthy();
|
|
for (const item of projection.statements) expect(section.textContent).toContain(statementText(item).text);
|
|
for (const id of projection.coverage.evidence.deviations) expect(section.textContent).toContain(id);
|
|
expect(section.textContent).toContain(projection.coverage.evidence.notRun[0]);
|
|
const index = [...document.querySelectorAll('section.block')].at(-1)!;
|
|
expect(index.querySelector('th')?.textContent).toBe('Result-ID');
|
|
expect(index.textContent).toContain(projection.coverage.evidence.notRun[0]);
|
|
});
|
|
});
|