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 ' }, 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(/ { 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'); });