221 lines
14 KiB
TypeScript
221 lines
14 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', 'Not configured', 'No baseline rule', 'Not comparable', 'Conflicts', 'Duplicates']) {
|
|
expect(html).toContain(verdict);
|
|
}
|
|
expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert');
|
|
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="scan"] 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="scan"] 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="scan"] [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('groups GPO Findings into collapsible policy sections', async () => {
|
|
const { JSDOM } = await import('jsdom');
|
|
const failing = (id: string, title: string) => ({ id, verdict: 'Fail' as const, conflict: false, duplicate: false,
|
|
title, scope: 'computer', identity: id, backupInstanceIds: [] });
|
|
const { html } = await render({ policyEntries: [
|
|
failing('dummy-a', 'System Access · DummyLength'),
|
|
failing('dummy-b', 'Privilege Rights · SeDummyPrivilege'),
|
|
failing('dummy-c', 'System Access · DummyLockout'),
|
|
failing('dummy-d', 'Software\\Policies\\Dummy\\Key')
|
|
] });
|
|
const { document, Event } = new JSDOM(html, { runScripts: 'dangerously' }).window;
|
|
const groups = [...document.querySelectorAll<HTMLDetailsElement>('section[aria-labelledby="gpo"] details.group')];
|
|
expect(groups.map(group => group.querySelector('summary')!.textContent!.replace(/\s+/g, ' ').trim()))
|
|
.toEqual(['Privilege Rights 1', 'Registry 1', 'System Access 2']);
|
|
expect(groups.every(group => group.open)).toBe(true);
|
|
groups[2].open = false;
|
|
const search = document.querySelector<HTMLInputElement>('section[aria-labelledby="gpo"] [data-filter-search]')!;
|
|
search.value = 'lockout';
|
|
search.dispatchEvent(new Event('input'));
|
|
expect(groups[2].open).toBe(true);
|
|
expect(groups.filter(group => !group.hidden)).toHaveLength(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="scan"] .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="gpo"] 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');
|
|
// The register keeps every other verdict; long score-neutral groups start collapsed and stay out of print.
|
|
const group = document.querySelector<HTMLDetailsElement>('section[aria-labelledby="register"] details.group')!;
|
|
expect(group.querySelector('summary')!.textContent).toContain('Not configured');
|
|
expect(group.open).toBe(false);
|
|
expect(group.hasAttribute('data-print-closed')).toBe(true);
|
|
expect(group.querySelectorAll('[data-filter-item]')).toHaveLength(3);
|
|
});
|
|
});
|