315 lines
15 KiB
TypeScript
315 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report';
|
||
import type { ReportProjection } from '../../src/domain/report-projection';
|
||
|
||
function projection(overrides: Partial<ReportProjection> = {}): ReportProjection {
|
||
return {
|
||
version: '1.1.0',
|
||
generatedAt: '2026-09-18T10:00:00.000Z',
|
||
project: { id: 'synthetic-project', name: 'Synthetic GmbH' },
|
||
assessmentId: 'synthetic-assessment',
|
||
environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: undefined }],
|
||
overallScore: 62,
|
||
scoreSources: [],
|
||
domains: [],
|
||
priorities: [],
|
||
coverage: {
|
||
total: 0, assessed: 0, deviations: 0, passed: 0, notRun: 0,
|
||
notApplicable: 0, executionErrors: 0, unknown: 0, perSource: []
|
||
},
|
||
inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} },
|
||
findings: [],
|
||
policyEntries: [],
|
||
workItems: [],
|
||
sourcePriorities: [],
|
||
convergence: [],
|
||
bundles: [],
|
||
singleSourcePolicyActions: [],
|
||
diagnostics: [],
|
||
provenance: [],
|
||
...overrides
|
||
} as ReportProjection;
|
||
}
|
||
|
||
const finding = (overrides = {}) => ({
|
||
resultProjectionId: 'synthetic-1',
|
||
domainId: 'identity',
|
||
toolId: 'purple-knight',
|
||
title: 'Synthetic indicator',
|
||
severity: 'critical' as const,
|
||
outcome: 'fail' as const,
|
||
rankBasis: { kind: 'severity' as const },
|
||
ranked: true,
|
||
...overrides
|
||
});
|
||
|
||
describe('management overview renderer', () => {
|
||
it('renders a self-contained document with only inline script and no remote resources', () => {
|
||
const html = buildManagementOverviewHtml(projection());
|
||
expect(html.startsWith('<!doctype html>')).toBe(true);
|
||
expect(html).not.toMatch(/<script\b[^>]*\bsrc\s*=/i);
|
||
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
|
||
expect(html).not.toMatch(/https?:\/\//i);
|
||
expect(html).not.toMatch(/<link\b/i);
|
||
// The only url() is the embedded font; nothing is fetched when opened.
|
||
expect(html.match(/url\(/gi)).toHaveLength(1);
|
||
expect(html).toContain("url('data:font/woff2;base64,");
|
||
});
|
||
|
||
it('renders for customers in light mode only', () => {
|
||
const html = buildManagementOverviewHtml(projection());
|
||
expect(html).toContain('color-scheme:light');
|
||
expect(html).not.toMatch(/prefers-color-scheme\s*:\s*dark/i);
|
||
});
|
||
|
||
it('declares German as the document language', () => {
|
||
expect(buildManagementOverviewHtml(projection())).toContain('<html lang="de">');
|
||
});
|
||
|
||
it('keeps the binding score wording and never claims baseline compliance', () => {
|
||
const html = buildManagementOverviewHtml(projection());
|
||
expect(html).toContain('Konformität der konfigurierten, vergleichbaren');
|
||
expect(html).not.toMatch(/Baseline-?Compliance/i);
|
||
expect(html).not.toMatch(/Baseline-?Abdeckung/i);
|
||
});
|
||
|
||
it('keeps established English technical terms untranslated', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
domains: [{ domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 1, deviations: 1, assessed: 2, passRate: 0.5 }],
|
||
priorities: [{
|
||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
|
||
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }]
|
||
}]
|
||
}));
|
||
expect(html).toContain('Priority Findings');
|
||
expect(html).toContain('Overall Score');
|
||
expect(html).toContain('Security Domains');
|
||
expect(html).toContain('Deviations');
|
||
expect(html).toContain('>Critical<');
|
||
expect(html).toContain('GPO & Baseline');
|
||
// German carries the prose around the English terms.
|
||
expect(html).toContain('Gruppiert nach Security Domain');
|
||
});
|
||
|
||
it('renders an unknown value as an em dash and never as a zero', () => {
|
||
const html = buildManagementOverviewHtml(projection({ overallScore: undefined }));
|
||
expect(html).toContain('—');
|
||
// No zero-length arc is drawn, and the readout shows no fabricated value.
|
||
expect(html).not.toContain('<circle class="donut__value"');
|
||
expect(html).not.toContain('<span class="donut__unit">');
|
||
expect(html).toContain('nicht verfügbar');
|
||
expect(html).toContain('<span class="donut__number">—</span>');
|
||
});
|
||
|
||
it('draws the donut as a full circle starting at twelve o\'clock with flat caps', () => {
|
||
const html = buildManagementOverviewHtml(projection({ overallScore: 50 }));
|
||
expect(html).toContain('rotate(-90 64 64)');
|
||
expect(html).toContain('stroke-linecap:butt');
|
||
expect(html).toContain('donut__track');
|
||
});
|
||
|
||
it('names the ordering basis of every source and never merges them', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'hardening', domainLabel: 'System Hardening', deviations: 2,
|
||
sources: [
|
||
{ toolId: 'cis-cat', basis: { kind: 'equal-weight', reason: 'CIS benchmarks state no criticality.' }, ranked: false, shown: [finding({ severity: 'unknown', toolId: 'cis-cat' })], total: 1 },
|
||
{ toolId: 'pingcastle', basis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' }, ranked: true, shown: [finding({ toolId: 'pingcastle', severity: 'medium', rankValue: 35, rankBasis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' } })], total: 1 }
|
||
]
|
||
}]
|
||
}));
|
||
expect(html).toContain('Ohne Rangfolge: CIS benchmarks state no criticality.');
|
||
expect(html).toContain('Sortiert nach rule.points (0-50+, höher ist kritischer)');
|
||
expect(html).toContain('quellenübergreifende Rangfolge');
|
||
});
|
||
|
||
it('points at the technical report instead of silently truncating a domain', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 9,
|
||
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 9 }]
|
||
}]
|
||
}));
|
||
expect(html).toContain('8 weitere Deviations dieser Quelle stehen im Technical Report.');
|
||
});
|
||
|
||
it('keeps GPO equal-weight and marks the score-neutral verdicts as such', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
gpo: {
|
||
basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' },
|
||
counts: {
|
||
Fail: 3, Pass: 10, 'Not configured': 40, 'No baseline rule': 5,
|
||
'Not comparable': 2, 'Review required': 1
|
||
},
|
||
conflicts: 1, duplicates: 2, affectedSettings: 3, affectedBackups: 2, scopeLimited: true
|
||
}
|
||
}));
|
||
expect(html).toContain('A baseline deviation carries no native criticality.');
|
||
expect(html).toContain('gleichgewichtig');
|
||
expect(html).toContain('sind score-neutral und begründen keine Maßnahme');
|
||
expect(html).toContain('trifft keine Aussage über die gesamte Domäne');
|
||
});
|
||
|
||
it('shows GPO deviations once in the GPO block instead of per comparison in Priority Findings', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'gpo', domainLabel: 'GPO & Baseline', deviations: 4,
|
||
sources: [
|
||
{ toolId: 'pingcastle', basis: { kind: 'severity' }, ranked: true, shown: [finding({ toolId: 'pingcastle', title: 'Synthetic scanner item' })], total: 1 },
|
||
{ toolId: 'GPOs', basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' }, ranked: false,
|
||
shown: [1, 2, 3].map(index => finding({ toolId: 'GPOs', severity: 'unknown', title: `Synthetic comparison ${index}` })), total: 3 }
|
||
]
|
||
}],
|
||
findings: [finding({ toolId: 'pingcastle' }), ...[1, 2, 3].map(() => finding({ toolId: 'GPOs', severity: 'unknown' }))],
|
||
gpo: {
|
||
basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' },
|
||
counts: { Fail: 1, Pass: 0, 'Not configured': 0, 'No baseline rule': 0, 'Not comparable': 0, 'Review required': 0 },
|
||
conflicts: 0, duplicates: 0, affectedSettings: 1, affectedBackups: 1, scopeLimited: false
|
||
}
|
||
}));
|
||
expect(html).toContain('Synthetic scanner item');
|
||
expect(html).not.toContain('Synthetic comparison');
|
||
expect(html).toContain('1 Deviations</span>');
|
||
// One scanner finding plus one GPO policy setting, not three comparisons.
|
||
expect(html).toContain('1 aus Scans · 1 GPO Policy Settings');
|
||
});
|
||
|
||
it('omits the remediation section entirely when no reviews exist', () => {
|
||
expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status');
|
||
});
|
||
|
||
it('marks remediation as self reported and not as technical proof', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
remediation: {
|
||
selfReported: true, items: 4, byStatus: { Actionable: 4 },
|
||
withoutOwner: 1, withoutDue: 2, riskAccepted: 0
|
||
}
|
||
}));
|
||
expect(html).toContain('Remediation-Status');
|
||
expect(html).toContain('Selbstauskunft');
|
||
expect(html).toContain('kein technischer Nachweis');
|
||
expect(html).toContain('Rescan');
|
||
});
|
||
|
||
it('names the score method per source and explains an inverted native risk', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
scoreSources: [{
|
||
toolId: 'pingcastle', score: 55, method: 'inverted-native', weight: 1,
|
||
passed: 0, deviations: 20, assessed: 20, observedAt: '2026-07-22T00:00:00.000Z', issues: []
|
||
}]
|
||
}));
|
||
expect(html).toContain('Nativer Risk, invertiert als 100 − Risk');
|
||
expect(html).toContain('100 − Risk</code> in den Index ein');
|
||
expect(html).toContain('22.07.2026');
|
||
});
|
||
|
||
it('escapes hostile content from a finding title', () => {
|
||
const html = buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
|
||
sources: [{
|
||
toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 1,
|
||
shown: [finding({ title: '<img src=x onerror=alert(1)>' })]
|
||
}]
|
||
}]
|
||
}));
|
||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||
expect(html).not.toMatch(/<img\b/i);
|
||
});
|
||
});
|
||
|
||
describe('management overview structure', () => {
|
||
it('keeps the agreed section order and heading levels', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const html = buildManagementOverviewHtml(projection({
|
||
remediation: {
|
||
selfReported: true, items: 1, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 0
|
||
}
|
||
}));
|
||
const { document } = new JSDOM(html).window;
|
||
expect([...document.querySelectorAll('h1')].map(node => node.textContent))
|
||
.toEqual(['Synthetic GmbH']);
|
||
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
|
||
'Overall Posture', 'Priority Findings', 'Remediation-Status', 'Abdeckung und Lücken',
|
||
'Konvergenz der Quellen', 'Maßnahmenbündel', 'Source Overview'
|
||
]);
|
||
// Every section is labelled for assistive technology.
|
||
for (const section of document.querySelectorAll('section.segment')) {
|
||
expect(section.getAttribute('aria-labelledby')).toBeTruthy();
|
||
}
|
||
});
|
||
|
||
it('never nests tables', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({
|
||
coverage: {
|
||
total: 1, assessed: 1, deviations: 1, passed: 0, notRun: 0, notApplicable: 0,
|
||
executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }]
|
||
}
|
||
}))).window;
|
||
expect(document.querySelectorAll('table table')).toHaveLength(0);
|
||
});
|
||
|
||
it('draws the value arc proportional to the score on a full circle', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 25 }))).window;
|
||
const arc = document.querySelector('circle.donut__value')!;
|
||
const [drawn, gap] = arc.getAttribute('stroke-dasharray')!.split(' ').map(Number);
|
||
const circumference = 2 * Math.PI * 54;
|
||
expect(drawn + gap).toBeCloseTo(circumference, 2);
|
||
expect(drawn / circumference).toBeCloseTo(0.25, 4);
|
||
});
|
||
|
||
it('filters Priority Findings by severity chip and search through the inline script', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const html = buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 2,
|
||
sources: [{
|
||
toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 2,
|
||
shown: [finding({ title: 'Synthetic critical' }), finding({ resultProjectionId: 'synthetic-2', title: 'Synthetic high', severity: 'high' })]
|
||
}]
|
||
}],
|
||
findings: [finding(), finding({ resultProjectionId: 'synthetic-2', severity: 'high' })]
|
||
}));
|
||
const { document } = new JSDOM(html, { runScripts: 'dangerously' }).window;
|
||
const rows = [...document.querySelectorAll<HTMLElement>('[data-filter-item]')];
|
||
const visible = () => rows.filter(row => !row.hidden).map(row => row.querySelector('.finding__title')!.textContent);
|
||
expect(document.querySelector<HTMLElement>('[data-filter-toolbar]')!.hidden).toBe(false);
|
||
expect(visible()).toEqual(['Synthetic critical', 'Synthetic high']);
|
||
|
||
document.querySelector<HTMLButtonElement>('[data-filter-value="critical"]')!.click();
|
||
expect(visible()).toEqual(['Synthetic critical']);
|
||
expect(document.querySelector('[data-filter-reset]')!.getAttribute('aria-pressed')).toBe('false');
|
||
|
||
document.querySelector<HTMLButtonElement>('[data-filter-reset]')!.click();
|
||
const search = document.querySelector<HTMLInputElement>('[data-filter-search]')!;
|
||
search.value = 'high';
|
||
search.dispatchEvent(new document.defaultView!.Event('input'));
|
||
expect(visible()).toEqual(['Synthetic high']);
|
||
expect(document.querySelector('[data-filter-count]')!.textContent).toBe('1 / 2');
|
||
});
|
||
|
||
it('keeps every finding readable when the script does not run', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({
|
||
priorities: [{
|
||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
|
||
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }]
|
||
}],
|
||
findings: [finding(), finding({ resultProjectionId: 'synthetic-2', severity: 'high' })]
|
||
}))).window;
|
||
expect(document.querySelector<HTMLElement>('[data-filter-toolbar]')!.hidden).toBe(true);
|
||
expect([...document.querySelectorAll<HTMLElement>('[data-filter-item]')].every(row => !row.hidden)).toBe(true);
|
||
});
|
||
|
||
it('clamps an out-of-range score instead of drawing past the ring', async () => {
|
||
const { JSDOM } = await import('jsdom');
|
||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window;
|
||
const [drawn, gap] = document.querySelector('circle.donut__value')!
|
||
.getAttribute('stroke-dasharray')!.split(' ').map(Number);
|
||
expect(gap).toBeCloseTo(0, 3);
|
||
expect(drawn).toBeCloseTo(2 * Math.PI * 54, 2);
|
||
});
|
||
});
|