275 lines
19 KiB
TypeScript
275 lines
19 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { buildReportProjection } from '../../src/domain/report-projection';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import { buildTechnicalReportHtml } from '../../src/exports/technical-report';
|
|
|
|
describe('Technical Report', () => {
|
|
it('renders source-backed actual/expected values, exact GPO instructions and all policy states without scripts', () => {
|
|
const result = {
|
|
resultProjectionId: 'dummy-result', sourceKind: 'policy-evaluation', sourceResultId: 'dummy-source',
|
|
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'GPOs',
|
|
title: '<Dummy Registry Rule>', sourceRuleId: 'registry:dummy', isDeviation: true,
|
|
severityNormalized: 'unknown', outcomeNormalized: 'fail', policyActualValue: 0, policyBaselineValue: 1,
|
|
policyComparisonRelation: 'different', policyDiagnosticCodes: ['dummy-conflict'],
|
|
frameworkReferences: [{ namespace: 'GPOs', value: 'MITRE T1110' }]
|
|
} as unknown as UnifiedResultProjection;
|
|
const projection = buildReportProjection({
|
|
generatedAt: '2026-09-23T10:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' },
|
|
results: [result], domains: [{ id: 'gpo', label: 'GPO & Baseline' }], domainOf: () => 'gpo',
|
|
policyEntries: [{ id: 'dummy-setting', verdict: 'Fail', conflict: true, duplicate: false,
|
|
title: 'Dummy Registry Rule', scope: 'machine', identity: 'dummy-key', backupInstanceIds: ['dummy-backup'] }],
|
|
technicalPolicySources: [{ settingId: 'dummy-setting', sources: [{
|
|
id: 'dummy-source', backupInstanceId: 'dummy-backup', gpoId: '{DUMMY-GUID}',
|
|
nativeBackupId: '{DUMMY-BACKUP}', sourceReference: 'dummy/source', sequence: 2,
|
|
values: [{ kind: 'registry-policy', operation: 'set-value', data: 0, typeCode: 'REG_DWORD' }],
|
|
deviationFields: [['data']]
|
|
}] }]
|
|
});
|
|
const html = buildTechnicalReportHtml(projection);
|
|
expect(html).toContain('<html lang="de">');
|
|
expect(html).toContain('<Dummy Registry Rule>');
|
|
expect(html).not.toContain('<Dummy Registry Rule>');
|
|
expect(html).toContain('Konfiguriert');
|
|
expect(html).toContain('Erwartet');
|
|
expect(html).toContain('dummy/source');
|
|
expect(html).toContain('dummy-backup');
|
|
expect(html).toContain('MITRE T1110');
|
|
expect(html).toContain('<dd class="different">0</dd>');
|
|
for (const verdict of ['Deviation', 'Passed', 'Review required', 'Conflicts', 'Duplicates']) {
|
|
expect(html).toContain(verdict);
|
|
}
|
|
// No explanatory filler text in the Technical Report.
|
|
for (const filler of ['effektive Endpunktzustand', 'betroffene GPO Backups', 'kein eigenes Verdict', 'importierten GPO-Backup-Satz', 'Ein Klick zeigt']) {
|
|
expect(html).not.toContain(filler);
|
|
}
|
|
expect(html.match(/class="(?:note|section__lead)"/g)).toBeNull();
|
|
expect(html).not.toMatch(/<script\b[^>]*\bsrc\s*=|<link\b|https?:\/\//i);
|
|
expect(html).not.toMatch(/prefers-color-scheme\s*:\s*dark/i);
|
|
});
|
|
|
|
const scanner = (overrides: Record<string, unknown> = {}) => ({
|
|
resultProjectionId: 'dummy-scan', sourceKind: 'scanner-finding', sourceResultId: 'dummy-scan-source',
|
|
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'purple-knight',
|
|
title: 'Dummy indicator', sourceRuleId: 'DUMMY-1', isDeviation: true, severityNormalized: 'high',
|
|
outcomeNormalized: 'fail', description: 'Dummy description.', frameworkReferences: [], ...overrides
|
|
}) as unknown as UnifiedResultProjection;
|
|
|
|
const render = async (input: Partial<Parameters<typeof buildReportProjection>[0]>) => {
|
|
const { JSDOM } = await import('jsdom');
|
|
const html = buildTechnicalReportHtml(buildReportProjection({
|
|
generatedAt: '2026-09-24T10:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' },
|
|
results: [], domains: [{ id: 'identity', label: 'Identity & Account Hygiene' }, { id: 'gpo', label: 'GPO & Baseline' }],
|
|
domainOf: result => result.sourceKind === 'policy-evaluation' ? 'gpo' : 'identity', ...input
|
|
}));
|
|
return { html, document: new JSDOM(html).window.document };
|
|
};
|
|
|
|
it('shows a finding as description, result and solution and leaves empty source fields out', async () => {
|
|
const { document } = await render({
|
|
results: [scanner({ recommendation: '<p>Restrict the <b>dummy</b> right.</p><img src=x onerror=alert(1)>' })],
|
|
technicalFindingFields: [{ resultProjectionId: 'dummy-scan', fields: [
|
|
{ label: 'Result message', value: 'Found 2 dummy objects.', index: 0 },
|
|
{ label: 'MITRE D3FEND_1', value: '', index: 1 },
|
|
{ label: 'MITRE ATT&CK_3', value: '—', index: 2 },
|
|
{ label: 'Description', value: 'Dummy description.', index: 3 },
|
|
{ label: 'Weight', value: '10', index: 4 }
|
|
] }]
|
|
});
|
|
const row = document.querySelector('section[aria-labelledby="findings"] details.row')!;
|
|
const labels = [...row.querySelectorAll('.block__label')].map(node => node.textContent);
|
|
expect(labels.slice(0, 3)).toEqual(['Beschreibung', 'Befund', 'Lösung']);
|
|
expect(row.querySelector('.solution strong')!.textContent).toBe('dummy');
|
|
expect(row.querySelector('img')).toBeNull();
|
|
expect(row.querySelector('.solution')!.textContent).toContain('<img src=x onerror=alert(1)>');
|
|
const original = [...row.querySelectorAll('.more .kv dt')].map(node => node.textContent);
|
|
expect(original).toContain('Weight');
|
|
expect(original).not.toContain('MITRE D3FEND_1');
|
|
expect(original).not.toContain('MITRE ATT&CK_3');
|
|
// Already shown as description, so not repeated in the technical details.
|
|
expect(original).not.toContain('Description');
|
|
// Severity is supporting text, not a filled status badge.
|
|
expect(row.querySelector('summary .sev')!.textContent).toBe('High');
|
|
expect(document.querySelectorAll('.badge, .pill')).toHaveLength(0);
|
|
});
|
|
|
|
it('shows each Security Domain as a collapsible group that reopens for a matching search', async () => {
|
|
const { JSDOM } = await import('jsdom');
|
|
const { html } = await render({
|
|
results: [scanner(), scanner({ resultProjectionId: 'dummy-scan-2', title: 'Dummy second indicator' })]
|
|
});
|
|
const { document, Event } = new JSDOM(html, { runScripts: 'dangerously' }).window;
|
|
const group = document.querySelector<HTMLDetailsElement>('section[aria-labelledby="findings"] details.group')!;
|
|
expect(group.open).toBe(true);
|
|
expect(group.querySelector('summary')!.textContent).toContain('Identity & Account Hygiene');
|
|
expect(group.querySelectorAll('details.row')).toHaveLength(2);
|
|
group.open = false;
|
|
const search = document.querySelector<HTMLInputElement>('section[aria-labelledby="findings"] [data-filter-search]')!;
|
|
search.value = 'second';
|
|
search.dispatchEvent(new Event('input'));
|
|
expect(group.open).toBe(true);
|
|
expect([...group.querySelectorAll<HTMLElement>('details.row')].filter(row => !row.hidden)).toHaveLength(1);
|
|
});
|
|
|
|
it('lists scanner and GPO deviations in one Findings list grouped by Security Domain', async () => {
|
|
const { JSDOM } = await import('jsdom');
|
|
const failing = (id: string, title: string) => ({ id, verdict: 'Fail' as const, conflict: false, duplicate: id === 'dummy-b',
|
|
title, scope: 'computer', identity: id, backupInstanceIds: [] });
|
|
const { html } = await render({
|
|
results: [scanner(), scanner({ resultProjectionId: 'dummy-scan-gpo', title: 'Dummy scanner GPO check', severityNormalized: 'unknown' })],
|
|
domainOf: result => result.resultProjectionId === 'dummy-scan-gpo' || result.sourceKind === 'policy-evaluation' ? 'gpo' : 'identity',
|
|
policyEntries: [failing('dummy-a', 'System Access · DummyLength'), failing('dummy-b', 'Privilege Rights · SeDummyPrivilege')]
|
|
});
|
|
const { document, Event } = new JSDOM(html, { runScripts: 'dangerously' }).window;
|
|
expect(document.querySelector('section[aria-labelledby="scan"], section[aria-labelledby="gpo"]')).toBeNull();
|
|
const section = document.querySelector('section[aria-labelledby="findings"]')!;
|
|
const groups = [...section.querySelectorAll<HTMLDetailsElement>('details.group')];
|
|
const titles = (group: Element) => [...group.querySelectorAll('.row__title')].map(node => node.textContent);
|
|
expect(groups.map(group => group.querySelector('summary')!.textContent!.replace(/\s+/g, ' ').trim()))
|
|
.toEqual(['GPO & Baseline 3', 'Identity & Account Hygiene 1']);
|
|
expect(titles(groups[0])).toEqual(['Dummy scanner GPO check', 'Privilege Rights · SeDummyPrivilege', 'System Access · DummyLength']);
|
|
// One toolbar filters the whole list, by source and by GPO diagnostics.
|
|
expect(section.querySelectorAll('[data-filter-toolbar]')).toHaveLength(1);
|
|
section.querySelector<HTMLButtonElement>('[data-filter-key="tool"][data-filter-value="group-policy"]')!.click();
|
|
expect([...section.querySelectorAll<HTMLElement>('details.row')].filter(row => !row.hidden).map(row => row.querySelector('.row__title')!.textContent))
|
|
.toEqual(['Privilege Rights · SeDummyPrivilege', 'System Access · DummyLength']);
|
|
section.querySelector<HTMLButtonElement>('[data-filter-key="flags"][data-filter-value="duplicate"]')!.click();
|
|
expect([...section.querySelectorAll<HTMLElement>('details.row')].filter(row => !row.hidden)).toHaveLength(1);
|
|
const search = section.querySelector<HTMLInputElement>('[data-filter-search]')!;
|
|
search.value = 'nothing matches';
|
|
search.dispatchEvent(new Event('input'));
|
|
expect(groups.every(group => group.hidden)).toBe(true);
|
|
});
|
|
|
|
it('shows environment counts and operating systems in the overview without turning unknown values into zero', async () => {
|
|
const { document } = await render({
|
|
environment: [{ key: 'Domain', value: 'dummy.example' }, { key: 'Domain Controller', value: '2' }],
|
|
technicalEnvironment: {
|
|
counts: [{ key: 'Domain Controller', value: 2 }, { key: 'Computer objects', value: 1241 }, { key: 'User accounts', value: undefined }],
|
|
operatingSystems: [{ label: 'Servers', total: 7, entries: [{ name: 'Windows Server 2022', count: 7 }] },
|
|
{ label: 'Other / unclassified', entries: [{ name: 'Dummy OS' }] }]
|
|
}
|
|
});
|
|
const section = document.querySelector('section[aria-labelledby="overview"]')!;
|
|
const facts = [...section.querySelectorAll('.env')].map(card => [...card.querySelectorAll('dl div')]
|
|
.map(row => [row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent]));
|
|
expect(facts[0]).toEqual([['Domain', 'dummy.example'], ['Domain Controller', '2'], ['Computer objects', '1.241']]);
|
|
expect(facts[1]).toEqual([['Windows Server 2022', '7'], ['Dummy OS', '—']]);
|
|
expect([...section.querySelectorAll('.os__group')].map(node => node.textContent!.replace(/\s+/g, ' ').trim())).toEqual(['Servers 7', 'Other / unclassified']);
|
|
const empty = (await render({})).document.querySelector('section[aria-labelledby="overview"]')!.textContent;
|
|
expect(empty).toContain('Keine Betriebssystemdaten übermittelt.');
|
|
});
|
|
|
|
it('shows the GPO card with Deviation, Passed, Review required, Conflicts and Duplicates only', async () => {
|
|
const entry = (id: string, verdict: string, extra: Record<string, boolean> = {}) => ({ id, verdict, conflict: false, duplicate: false,
|
|
title: id, scope: 'computer', identity: id, backupInstanceIds: [], ...extra }) as never;
|
|
const { document } = await render({ policyEntries: [
|
|
...Array.from({ length: 25 }, (_, index) => entry('fail-' + index, 'Fail', index < 2 ? { duplicate: true } : {})),
|
|
...Array.from({ length: 25 }, (_, index) => entry('pass-' + index, 'Pass')),
|
|
...Array.from({ length: 30 }, (_, index) => entry('absent-' + index, 'Not configured')),
|
|
entry('no-rule', 'No baseline rule'), entry('preference', 'Not comparable')
|
|
] });
|
|
const card = [...document.querySelectorAll('section[aria-labelledby="overview"] .card')]
|
|
.find(item => item.querySelector('.card__title')?.textContent === 'GPO Policy Settings')!;
|
|
const bars = [...card.querySelectorAll('.bars li')].map(row => [row.querySelector('.bars__label')!.textContent, row.querySelector('.bars__value')!.textContent]);
|
|
expect(bars).toEqual([['Deviation', '25'], ['Passed', '25'], ['Review required', '0'], ['Conflicts', '0'], ['Duplicates', '2']]);
|
|
expect(card.querySelector('.card__meta')!.textContent).toBe('50 bewertete Settings');
|
|
expect(card.textContent).not.toMatch(/Not configured|No baseline rule|Not comparable/);
|
|
});
|
|
|
|
it('keeps severity on the finding rows only, not in the overview', async () => {
|
|
const { document } = await render({ results: [scanner()] });
|
|
expect(document.querySelector('#overview')!.closest('section')!.textContent).not.toMatch(/severity|critical|\bhigh\b/i);
|
|
// Result inventory, convergence, scope limits and appendix are removed from the Technical Report.
|
|
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual(['Überblick', 'Findings']);
|
|
const row = document.querySelector('section[aria-labelledby="findings"] details.row')!;
|
|
expect(row.querySelector('summary .sev')!.textContent).toBe('High');
|
|
expect(document.querySelector('section[aria-labelledby="overview"] .kpi__figure')!.textContent).toBe('1');
|
|
});
|
|
|
|
it('renders pipe-delimited source rows as a table and keeps irregular rows as text', async () => {
|
|
const details = [
|
|
'The detail can be found in Dummy Groups',
|
|
'DN',
|
|
'CN=dummy.one,OU=Dummy,DC=dummy,DC=example',
|
|
'CN=dummy.two,CN=Users,DC=dummy,DC=example',
|
|
'This section explains the dummy groups.',
|
|
'Group Name | Nb Admins | Nb Enabled',
|
|
'Dummy Operators | 0 | 0',
|
|
'Dummy Admins | 3 | <b>2</b>',
|
|
'',
|
|
'Dummy Admins Close',
|
|
'',
|
|
'User',
|
|
'dummy.admin',
|
|
'dummy-service',
|
|
'Administrator',
|
|
'Accounts are listed above.',
|
|
'',
|
|
'Name | State',
|
|
'dummy.user | YES | extra'
|
|
].join('\n');
|
|
const { document } = await render({
|
|
results: [scanner()],
|
|
technicalFindingFields: [{ resultProjectionId: 'dummy-scan', fields: [{ label: 'Details', value: details, index: 0 }] }]
|
|
});
|
|
const found = [...document.querySelectorAll('section[aria-labelledby="findings"] .block')]
|
|
.find(block => block.querySelector('.block__label')!.textContent === 'Befund')!;
|
|
const tables = found.querySelectorAll('table');
|
|
expect(tables).toHaveLength(3);
|
|
// A flattened one-column list of account names under its label.
|
|
expect([...tables[2].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['User']);
|
|
expect([...tables[2].querySelectorAll('td')].map(cell => cell.textContent)).toEqual(['dummy.admin', 'dummy-service', 'Administrator']);
|
|
expect([...found.querySelectorAll('p')].some(node => node.textContent!.includes('Accounts are listed above.'))).toBe(true);
|
|
// A flattened one-column table of distinguished names keeps its header.
|
|
expect([...tables[0].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['DN']);
|
|
expect([...tables[0].querySelectorAll('td')].map(cell => cell.textContent))
|
|
.toEqual(['CN=dummy.one,OU=Dummy,DC=dummy,DC=example', 'CN=dummy.two,CN=Users,DC=dummy,DC=example']);
|
|
expect(found.textContent).toContain('This section explains the dummy groups.');
|
|
expect([...tables[1].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['Group Name', 'Nb Admins', 'Nb Enabled']);
|
|
expect([...tables[1].querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent)))
|
|
.toEqual([['Dummy Operators', '0', '0'], ['Dummy Admins', '3', '2']]);
|
|
expect(found.textContent).toContain('The detail can be found in Dummy Groups');
|
|
expect(found.textContent).toContain('Dummy Admins Close');
|
|
// Differing column counts are not forced into a table.
|
|
expect(found.textContent).toContain('Name | State');
|
|
expect(found.textContent).toContain('dummy.user | YES | extra');
|
|
});
|
|
|
|
it('rolls GPO baseline comparisons up to one finding per policy setting with expected and configured values', async () => {
|
|
const comparison = (variant: number) => ({
|
|
resultProjectionId: `dummy-gpo-${variant}`, sourceKind: 'policy-evaluation', sourceResultId: `dummy-gpo-source-${variant}`,
|
|
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'GPOs', title: 'Dummy Rights · SeDummyPrivilege',
|
|
sourceRuleId: 'dummy-identity', policyIdentityKey: 'dummy-identity', isDeviation: true, severityNormalized: 'unknown',
|
|
outcomeNormalized: 'fail', policyBaselineLabel: `Dummy Baseline ${variant}`, policyComparisonRelation: 'different', frameworkReferences: [],
|
|
policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration', decision: 'all-fail', diagnosticCodes: [],
|
|
actualValues: [{ kind: 'principal-set', principals: ['*S-1-5-19', '*S-1-5-99'] }],
|
|
variants: [{ settingId: `dummy-baseline-${variant}`, baselineValues: [{ kind: 'principal-set', principals: ['*S-1-5-19'] }],
|
|
comparator: { version: 1, comparator: { kind: 'equality' }, relation: 'different', diagnosticCodes: [] } }] }
|
|
}) as unknown as UnifiedResultProjection;
|
|
const { document } = await render({
|
|
results: [comparison(1), comparison(2), comparison(3)],
|
|
policyEntries: [
|
|
{ id: 'dummy-setting', verdict: 'Fail', conflict: false, duplicate: false, title: 'Dummy Rights · SeDummyPrivilege',
|
|
scope: 'computer', identity: 'dummy-identity', backupInstanceIds: ['dummy-backup'] },
|
|
...Array.from({ length: 3 }, (_, index) => ({ id: `dummy-nc-${index}`, verdict: 'Not configured' as const, conflict: false,
|
|
duplicate: false, title: `Dummy absent ${index}`, scope: 'user', identity: `dummy-nc-${index}`, backupInstanceIds: [] }))
|
|
],
|
|
technicalPolicySources: [{ settingId: 'dummy-setting', sources: [{ id: 'dummy-source', backupInstanceId: 'dummy-backup',
|
|
gpoId: '{DUMMY-GUID}', sourceReference: 'dummy/GptTmpl.inf', values: [{ kind: 'principal-set', principals: ['*S-1-5-19', '*S-1-5-99'] }],
|
|
deviationItems: [['*S-1-5-99']] }] }]
|
|
});
|
|
const rows = document.querySelectorAll('section[aria-labelledby="findings"] details.row');
|
|
expect(rows).toHaveLength(1);
|
|
const [expected, configured] = [...rows[0].querySelectorAll('.compare__col')];
|
|
expect([...expected.querySelectorAll('li')].map(node => node.textContent)).toEqual(['*S-1-5-19']);
|
|
expect(expected.textContent).toContain('Dummy Baseline 1 · Dummy Baseline 2 · Dummy Baseline 3');
|
|
// Only the configured principal absent from every baseline variant is marked.
|
|
expect([...configured.querySelectorAll('li.different')].map(node => node.textContent)).toEqual(['*S-1-5-99']);
|
|
expect(rows[0].querySelector('.compare')!.textContent).not.toContain('"kind"');
|
|
expect(rows[0].querySelector('.solution')!.textContent).toContain('auf den erwarteten Baseline-Wert setzen');
|
|
// Score-neutral settings are not listed anywhere: the GPO Policy Register is removed.
|
|
expect(document.querySelector('section[aria-labelledby="register"]')).toBeNull();
|
|
expect(document.body.textContent).not.toContain('Dummy absent');
|
|
});
|
|
});
|