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

248 lines
17 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);
const technical = buildTechnicalReportHtml(source);
expect(technical).toContain('secret-result-one');
expect(technical).toContain('Context-Belege des Management Reports');
});
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');
});