Assessor_2.0/tests/reporting/context-management-report.t...

312 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildContextManagementHtml } from '../../src/reporting/context-management-report';
import { buildReportProjection } from '../../src/reporting/report-projection';
import { buildTechnicalReportHtml } from '../../src/reporting/technical-report';
import { ReportContextRequiredError } from '../../src/reporting/report-context';
import { reportContext } from './context-report-fixture';
import { managementBusinessImpacts, managementFindingDistribution, managementThemes } from '../../src/reporting/management-business-impact';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG } from '../../src/assessment-context/context-catalog';
import { managementCategoryTexts } from '../../src/reporting/management-report-texts';
function projection() {
const projection = buildReportProjection({ project: { id: 'dummy-project', name: 'Synthetic <Customer>' },
generatedAt: '2026-10-02T12:00:00Z', results: [], domains: [], domainOf: () => 'unknown' });
projection.context = reportContext();
return projection;
}
it('counts 477 unique deviations once per theme even when their technical evidence has 482 rows', () => {
const source = projection();
const cluster = source.context!.clusters[0];
cluster.id = 'data-sharing-exposure';
cluster.scanner = 476; cluster.gpo = 1; cluster.hasEvidence = true;
cluster.resultIds = Array.from({ length: 482 }, (_, index) => `technical-${index}`);
cluster.findingKeys = Array.from({ length: 477 }, (_, index) => `finding-${index}`);
source.context!.findingCount = 477;
source.coverage.deviations = 560;
source.workspaceCounts = { deviations: 477, assessed: 507 };
source.scoreSources = [
{ toolId: 'synthetic-scanner', method: 'pass-rate', weight: 1, passed: 10, deviations: 452, assessed: 462, issues: [] },
{ toolId: 'group-policy', method: 'pass-rate', weight: 1, passed: 20, deviations: 25, assessed: 45, issues: [] }
];
source.context!.clusters = [cluster, { ...cluster, id: 'cloud-external-access-exposure' }];
const before = JSON.stringify(source);
const themes = managementBusinessImpacts(source.context!);
expect(themes.find(item => item.id === 'information')!.findingCount).toBe(477);
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect(document.querySelector('.two-column p')?.textContent).toContain('Von 507 bewerteten Ergebnissen weichen 477');
const row = [...document.querySelectorAll('.business-overview tbody tr')].find(item => item.firstElementChild?.textContent === 'Informationen & Daten')!;
expect(row.children[2]?.textContent).toBe('477');
const bar = [...document.querySelectorAll('.bar-row')].find(item => item.firstElementChild?.textContent === 'Informationen & Daten')!;
expect(bar.querySelector('strong')?.textContent).toBe('477');
expect(JSON.stringify(source)).toBe(before);
});
it('renders the offline management cut with confidence, limitations and no hidden technical evidence', () => {
const source = projection();
const before = JSON.stringify(source);
const html = buildContextManagementHtml(source);
const document = new JSDOM(html).window.document;
for (const title of ['Summary', 'Context-Heatmap', 'Möglicher Business Impact',
'Bearbeitungsstand und Entscheidungen', 'Untersuchungsumfang und Datenbasis', 'Technische Domain-Auswertung']) expect(document.body.textContent).toContain(title);
expect(document.querySelectorAll('.heatmap li')).toHaveLength(2);
expect(document.querySelector('.title-meta span')?.textContent).toBe('Summary');
expect(document.querySelector('.two-column h2')?.textContent).toBe('Summary');
expect(document.body.textContent).not.toContain('Zusammenfassung');
expect(document.body.textContent).toContain('Die folgenden Angaben beschreiben je Thema den möglichen Business Impact');
expect(document.body.textContent).not.toContain('Die folgenden Blätter');
expect(document.body.textContent).not.toContain('Findings bleiben ohne fachliche Cluster-Zuordnung');
expect(document.querySelector('.bar-chart')?.parentElement?.querySelector('.note')).toBeNull();
expect(document.body.textContent).not.toContain('Anzahl zugeordneter Findings je Thema. Mehrfachzuordnungen');
expect(document.querySelector('.action-fields h3')?.textContent).toBe('Handlungsfelder');
// Evidenced themes with their exclusive counts, largest first.
expect([...document.querySelectorAll('.action-fields li')].map(item => item.textContent?.split(' – ')[0])).toEqual(
managementBusinessImpacts(source.context!).filter(item => item.clusters.length).map(item => item.label));
expect(document.querySelector('.action-fields li')?.textContent).toMatch(/ – \d+ Abweichung(en)?$/);
expect(document.querySelectorAll('.report-sheet')).toHaveLength(8);
expect(document.querySelectorAll('.masthead img[alt="ASSESSOR Logo"]')).toHaveLength(8);
expect(document.querySelector('.masthead img')?.getAttribute('src')).toMatch(/^data:image\/png;base64,/);
expect(document.querySelectorAll('.business-impact')).toHaveLength(4);
expect(document.querySelectorAll('footer')[7].textContent).toContain('Berichtsblatt 8 von 8');
expect(document.querySelector('.heatmap .critical')!.textContent).toContain('2 Abweichungen');
expect(document.querySelector('.heatmap .undetermined')!.textContent).toContain('2 Abweichungen');
expect(document.querySelector('.business-impact tbody tr')?.lastElementChild?.textContent).toBe('2');
expect(document.querySelector('.business-impact')?.textContent).not.toMatch(/Critical|High|Medium|Base Severity|\bhoch\b|\bmittel\b|\bniedrig\b/);
// Uncatalogued deviations are no open business mapping; they appear as Ohne Problemkategorie.
expect(document.body.textContent).not.toContain('Keine Business-Impact-Zuordnung; fachlich prüfen.');
expect(document.body.textContent).toContain('Kein belegter Assessment-Vergleich');
expect(html).not.toMatch(/<script\b|<link\b|https?:\/\/|secret-result|secret-gpo|secret-other/i);
expect(html).toContain('Synthetic &lt;Customer&gt;');
expect(JSON.stringify(source)).toBe(before);
// The Technical Report deepens the same problem fields without exposing Result IDs.
const technical = buildTechnicalReportHtml(source);
expect(technical).toContain('Cloud Privileged Access Exposure');
expect(technical).not.toMatch(/secret-result|secret-gpo|secret-other/);
});
it('requires Context, preserves neutral empty areas and distinguishes reviews from proven progress', () => {
const source = projection();
const context = source.context!;
delete source.context;
expect(() => buildContextManagementHtml(source)).toThrow(ReportContextRequiredError);
source.context = context;
context.clusters[0].hasEvidence = false; context.clusters[0].scanner = 0;
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect(document.querySelector('.heatmap li')!.className).toBe('neutral');
expect(document.body.textContent).toContain('Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung');
expect(document.body.textContent).toContain('Neutral bedeutet nicht sicher');
source.remediation = { selfReported: true, items: 0, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 2 };
expect(buildContextManagementHtml(source)).toContain('Für 2 Abweichungen ist eine Risikoakzeptanz dokumentiert.');
});
it('shows only counts in evidence tables while preserving underlying confidence data', () => {
const source = projection();
source.context!.clusters[0].confidence = { catalogued: 7, recognized: 3, estimated: 2 };
const before = JSON.stringify(source);
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
const table = document.querySelector('.business-impact table')!;
expect([...table.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['Problemfeld', 'Anteil', 'Anzahl']);
expect(table.querySelector('tbody tr')?.lastElementChild?.textContent).toBe('2');
expect(document.body.textContent).not.toMatch(/Nach Katalog|Nach Befundmerkmalen|Thematisch eingeordnet|7 \/ 3 \/ 2/);
expect(document.body.textContent).not.toContain('Automatische Einordnungen und mögliche Folgen sind als fachlich zu prüfender Ansatz zu lesen.');
expect(JSON.stringify(source)).toBe(before);
});
it('deduplicates overlapping evidence, leaves unknown categories unassigned and never treats absent evidence as safe', () => {
const source = projection();
const context = source.context!;
context.clusters.push({ ...context.clusters[0], id: 'ad-privileged-account-exposure', label: 'Synthetic AD control',
resultIds: ['secret-result-two', 'secret-result-three'] });
const themes = managementBusinessImpacts(context);
expect(themes.find(item => item.id === 'control')?.findingCount).toBe(3);
expect(themes.find(item => item.id === 'control')?.clusters).toHaveLength(2);
expect(themes.find(item => item.id === 'resilience')?.clusters).toHaveLength(0);
expect(themes.every(item => item.clusters.every(cluster => cluster.id !== 'other-deviations'))).toBe(true);
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
const resilience = [...document.querySelectorAll('.report-sheet')].find(sheet => sheet.getAttribute('aria-label') === 'Widerstandsfähigkeit & Wiederanlauf')!;
expect(resilience.textContent).toContain('keine Aussage ableitbar');
expect(resilience.textContent).not.toContain('Lücken bei Erkennung, Aktualität oder Wiederherstellung');
expect(document.body.textContent).not.toMatch(/Dasselbe Finding|Fehlende Belege bedeuten/);
context.clusters.forEach(cluster => { cluster.hasEvidence = false; });
expect(managementBusinessImpacts(context).every(item => item.findingCount === 0 && item.clusters.length === 0)).toBe(true);
});
it('creates the management report locally with deterministic text and no network call', () => {
const network = vi.fn(() => { throw new Error('Network is forbidden'); });
vi.stubGlobal('fetch', network);
try {
const source = projection();
expect(buildContextManagementHtml(source)).toBe(buildContextManagementHtml(source));
expect(network).not.toHaveBeenCalled();
expect(buildContextManagementHtml(source)).toContain('Offline erzeugt, ohne LLM und ohne Internetabruf.');
} finally { vi.unstubAllGlobals(); }
});
it('uses ASSESSOR branding only, marks every sheet confidential and limits Management to its header', () => {
const source = projection();
source.context!.clusters[0].tools = ['maester', 'pingcastle', 'purple-knight', 'cis-cat'];
source.context!.clusters[0].sources = [{ publisher: 'Maester', title: 'Maester scan evidence', url: 'https://example.invalid' }];
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect(document.body.textContent).not.toMatch(/maester|pingcastle|purple.?knight|cis.?cat|Scanner|Scanner-Quellen|Ergebnisse nach Quelle/i);
for (const sheet of document.querySelectorAll('.report-sheet')) {
expect(sheet.querySelector('header')?.textContent).toContain('Management Report');
expect(sheet.querySelector('header')?.textContent).toContain('Streng vertraulich');
expect(sheet.querySelector('footer')?.textContent).toContain('Streng vertraulich');
}
document.querySelectorAll('header').forEach(header => header.remove());
expect(document.body.textContent).not.toMatch(/Management/i);
});
it.each([0, 62.4, 100])('draws the existing ASSESSOR Overall Score without recalculation (%s)', score => {
const source = projection(); source.overallScore = score;
const before = JSON.stringify(source);
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect(document.querySelector('.overall-score svg')?.getAttribute('aria-label')).toContain(score.toLocaleString('de-DE'));
expect(document.querySelector('.score-value')?.getAttribute('stroke-dasharray')).toBe(`${score} ${100 - score}`);
expect(JSON.stringify(source)).toBe(before);
});
it.each([undefined, NaN, -1, 101])('does not fabricate a score when unavailable or invalid (%s)', score => {
const source = projection(); source.overallScore = score;
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect(document.querySelector('.overall-score svg')).toBeNull();
expect(document.querySelector('.overall-score')?.textContent).toContain('Kein Overall Score');
});
it('renders an exclusive partition in table and chart whose sum matches the finding total', () => {
const source = projection();
const distribution = managementFindingDistribution(source.context!, source.context!.findingCount);
const counts = [...distribution.themes.map(theme => String(theme.count)), String(distribution.unassigned)];
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
expect([...document.querySelectorAll('.business-overview tbody tr')].map(row => row.children[2]?.textContent)).toEqual(counts);
const sheet = document.querySelector('.business-overview')!.parentElement!;
expect([...sheet.querySelectorAll('.bar-row strong')].map(value => value.textContent)).toEqual(counts);
expect(sheet.querySelector('figcaption')?.textContent).toBe('Abweichungen nach Hauptthema');
expect(counts.reduce((sum, count) => sum + Number(count), 0)).toBe(source.context!.findingCount);
});
it('keeps conflicting main themes, missing assignments and out-of-scope findings out of named bars', () => {
const source = projection();
const cluster = source.context!.clusters[0];
source.context!.clusters = [
{ ...cluster, id: 'adcs-escalation', findingKeys: ['one', 'conflict', 'stale'] },
{ ...cluster, id: 'data-sharing-exposure', findingKeys: ['two', 'conflict'] },
{ ...cluster, id: 'detection-blind-spots', findingKeys: ['three'] }
];
const before = JSON.stringify(source);
const distribution = managementFindingDistribution(source.context!, 5, ['one', 'two', 'three', 'conflict', 'missing']);
expect(distribution.themes.map(theme => theme.count)).toEqual([1, 1, 0, 1]);
expect(distribution.unassigned).toBe(2);
const reversed = { ...source.context!, clusters: [...source.context!.clusters].reverse() };
expect(managementFindingDistribution(reversed, 5, ['missing', 'conflict', 'three', 'two', 'one'])).toEqual(distribution);
expect(JSON.stringify(source)).toBe(before);
});
it('assigns every catalogued category to exactly one of four themes and counts downstream consequences nowhere', () => {
// User decision 2026-10-04: four disjoint themes; financial and compliance consequences are prose only.
expect(managementThemes.map(theme => theme.id)).toEqual(['control', 'information', 'continuity', 'resilience']);
const categories = RELEASED_ASSESSMENT_CONTEXT_CATALOG.clusters.map(cluster => cluster.id).filter(id => id !== 'other-deviations');
for (const id of categories) expect(managementThemes.filter(theme => theme.clusterIds.includes(id)).map(theme => theme.id), id).toHaveLength(1);
expect(managementThemes.flatMap(theme => theme.clusterIds).every(id => categories.includes(id))).toBe(true);
const document = new JSDOM(buildContextManagementHtml(projection())).window.document;
expect([...document.querySelectorAll('.report-sheet')].map(sheet => sheet.getAttribute('aria-label'))).not.toEqual(
expect.arrayContaining(['Finanzielle Auswirkungen']));
expect([...document.querySelectorAll('.report-sheet')].some(sheet => /Compliance & Haftung/.test(sheet.getAttribute('aria-label') ?? ''))).toBe(false);
expect(document.body.textContent).toContain('Sie werden nicht als eigenes Thema gezählt.');
});
it('gives every catalogued category considered text and states each reading note only once', () => {
// User request 2026-10-04: considered text blocks instead of generic phrases repeated in every row.
const categories = RELEASED_ASSESSMENT_CONTEXT_CATALOG.clusters.map(cluster => cluster.id).filter(id => id !== 'other-deviations');
for (const id of categories) {
const text = managementCategoryTexts[id];
expect(text, id).toBeDefined();
for (const value of [text.name, text.situation, text.significance, text.lever]) expect(value.trim().length, id).toBeGreaterThan(0);
}
const all = Object.values(managementCategoryTexts).flatMap(text => [text.name, text.situation, text.significance, text.lever]).join(' ');
expect(all).not.toMatch(/pingcastle|purple.?knight|cis.?cat|maester|scuba/i);
expect(all).not.toMatch(/kein Nachweis|Mehrere beobachtete Befunde|Mehrere deklarierte Einstellungen/);
const source = projection();
source.context!.clusters.push({ ...source.context!.clusters[0], id: 'credential-relay-exposure', label: 'Credential Relay Exposure', severity: 'high' });
const html = new JSDOM(buildContextManagementHtml(source)).window.document.body.textContent ?? '';
expect(html.match(/kein Nachweis/g)).toHaveLength(1);
expect(html).toContain('Weiterleitung von Anmeldungen');
expect(html).toContain('Sachverhalt.');
});
it('places theme evidence tables at full width with narrow numeric columns', () => {
// User correction 2026-10-04: tables outside Summary span the sheet; counts and shares never wrap.
const document = new JSDOM(buildContextManagementHtml(projection())).window.document;
const table = document.querySelector('.business-impact table')!;
expect(table.closest('.two-column')).toBeNull();
expect([...table.querySelectorAll('thead th')].map(cell => cell.className)).toEqual(['', 'num', 'num']);
expect(document.querySelector('.business-overview table')?.closest('.two-column')).toBeNull();
const css = document.querySelector('style')!.textContent!;
expect(css).toContain('th.num,td.num{width:1%;white-space:nowrap;text-align:right');
expect(css).not.toContain('overflow-wrap:anywhere');
});
it('lists the themes in one count order in summary, overview, chart and theme sheets', () => {
const source = projection();
const [first, second] = source.context!.clusters;
Object.assign(first, { id: 'data-sharing-exposure', scanner: 3, gpo: 0, hasEvidence: true,
findingKeys: ['k1', 'k2', 'k3'], resultIds: ['k1', 'k2', 'k3'] });
Object.assign(second, { id: 'lateral-movement-exposure', scanner: 5, gpo: 0, hasEvidence: true,
findingKeys: ['k4', 'k5', 'k6', 'k7', 'k8'], resultIds: ['k4', 'k5', 'k6', 'k7', 'k8'] });
source.context!.findingCount = 8;
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
const themes = managementBusinessImpacts(source.context!);
const counts = new Map(themes.map(item => [item.label, item.findingCount]));
const expected = [...themes].sort((left, right) => right.findingCount - left.findingCount).map(item => item.label);
expect(counts.get(expected[0])).toBe(5);
const overview = [...document.querySelectorAll('.business-overview tbody tr')].map(item => item.firstElementChild?.textContent);
const chart = [...document.querySelectorAll('.bar-row')].map(item => item.firstElementChild?.textContent);
const sheets = [...document.querySelectorAll('.report-sheet')].map(item => item.getAttribute('aria-label')).filter(label => counts.has(label!));
const actions = [...document.querySelectorAll('.action-fields li')].map(item => item.textContent?.split(' – ')[0]);
expect(overview.slice(0, 4)).toEqual(expected);
expect(chart.slice(0, 4)).toEqual(expected);
expect(sheets).toEqual(expected);
expect(actions).toEqual(expected.filter(label => counts.get(label)! > 0));
expect(document.querySelector('.two-column p:nth-of-type(2)')?.textContent).toContain(`Den größten Anteil hat ${expected[0]}`);
});
it('renders Work-level domain rows that reconcile to the headline counts when the projection provides them', () => {
const source = projection();
source.workspaceCounts = { deviations: 4, assessed: 7 };
source.workspaceDomains = [
{ domainId: 'identity', domainLabel: 'Identity & Authentication', passed: 2, deviations: 1, assessed: 3 },
{ domainId: 'hardening', domainLabel: 'System Hardening', passed: 1, deviations: 3, assessed: 4 }
];
source.domains = [{ domainId: 'hardening', domainLabel: 'System Hardening', passed: 9, deviations: 9, assessed: 18, evidence: { passed: [], deviations: [] } }];
const document = new JSDOM(buildContextManagementHtml(source)).window.document;
const heading = [...document.querySelectorAll('h2')].find(item => item.textContent === 'Technische Domain-Auswertung')!;
const rows = [...heading.nextElementSibling!.querySelectorAll('tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
expect(rows).toEqual([['Identity & Authentication', '3', '1', '2', '33 %'], ['System Hardening', '4', '3', '1', '75 %']]);
expect(document.body.textContent).toContain('System Hardening die höchste Abweichungsquote (3 von 4, 75 %)');
expect(document.body.textContent).toContain('die Summen entsprechen den bewerteten Ergebnissen und Abweichungen der Summary');
});
it('takes unassessed states, collections and the risk register from the Client Info counting rules', () => {
// Synthetic regression (Dummy 2026-10-05): result rows gave 170 not run and 0 unknown, Work shows 171 and 19;
// processing steps were counted as 38 collections; a draft risk acceptance read as documented acceptance.
const source = projection();
source.coverage.notRun = 170; source.coverage.unknown = 0; source.coverage.executionErrors = 2; source.coverage.notApplicable = 3;
source.workspaceCounts = { deviations: 4, assessed: 7, notRun: 171, executionErrors: 2, notApplicable: 3, notConfigured: 879, unknown: 19 };
source.riskRegister = { recorded: 1, current: 0 };
source.remediation = { selfReported: true, items: 0, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 1 };
source.provenance = [
{ kind: 'ScanRun', id: 'scan-1', observedAt: '2026-09-08T12:00:00Z' },
{ kind: 'ScanRun', id: 'gpo-collection', observedAt: '2026-09-03T14:00:00Z' },
{ kind: 'ImportFile: synthetic.xml', id: 'file-1', observedAt: '2026-10-04T10:00:00Z', sourceDate: '2026-07-22T08:00:00Z' },
{ kind: 'EvaluationRun: completed', id: 'evaluation-1', observedAt: '2026-10-04T11:00:00Z' }
];
const text = new JSDOM(buildContextManagementHtml(source)).window.document.body.textContent!;
expect(text).toContain('171 Prüfungen wurden nicht ausgeführt, 2 endeten mit einem Ausführungsfehler');
expect(text).toMatch(/Nicht ausgeführte Prüfungen\s*171/);
expect(text).toContain('Nicht anwendbar: 3 · nicht konfiguriert: 879 · sonstige unbewertete Ergebnisse: 19.');
expect(text).toContain('Grundlage sind 2 Erhebungen im Timeslot vom 22.7.2026.');
expect(text).toContain('Im Risikoregister ist eine Risikoakzeptanz erfasst; keine davon ist aktuell gültig.');
expect(text).not.toContain('Risikoakzeptanz dokumentiert');
});