178 lines
13 KiB
TypeScript
178 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { FindingRecord, PolicySourceEvidence } from '../../src/domain/finding';
|
|
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
|
|
|
/** Synthetic Registry.pol contribution of one GPO backup. */
|
|
function source(backup: string, key: string): PolicySourceEvidence {
|
|
return { id: backup, backupKey: backup, backupInstanceId: `gpo-backup-${backup}`, sequence: 3,
|
|
sourceReference: `{${backup}}/DomainSysvol/GPO/Machine/registry.pol#records=3`, baselineRelation: 'conflicting', deviationFields: [['data']],
|
|
values: [{ kind: 'registry-policy', operation: 'set-value', key, valueName: 'SyntheticEnable', data: { kind: 'uint32', value: 0 }, typeCode: 4 }] };
|
|
}
|
|
|
|
function record(): FindingRecord {
|
|
const fields = [['Title', 'SyntheticEnable'], ['Status', 'Fail'], ['Scope', 'computer'], ['Conflict', 'Conflicting customer states; see original GPO source instructions.'],
|
|
['Technical Identity', 'synthetic-identity'], ['Registry path', 'HKEY_LOCAL_MACHINE\\Software\\Policies\\Synthetic'], ['Remediation', 'repeated per package']]
|
|
.map(([label, value], index) => ({ label, value, index }));
|
|
return { id: 'synthetic', index: 0, title: 'SyntheticEnable', fields, filled: fields, meta: [],
|
|
policySourceEvidence: [source('a', 'SOFTWARE\\Policies\\Synthetic'), source('b', 'Software\\Policies\\Synthetic'), source('c', 'Software\\Policies\\Synthetic')],
|
|
policyBaselineComparisons: [1, 2].map(index => ({ baseline: `Synthetic baseline ${index}`, status: 'Fail', summary: '',
|
|
context: 'Synthetic role context.', scopeNotice: 'Synthetic scope notice.', explanation: '',
|
|
remediation: 'Computer policy: HKEY_LOCAL_MACHINE\\Software\\Policies\\Synthetic\\SyntheticEnable\nExpected: 1',
|
|
recommendations: [{ template: `Synthetic template ${index}`, settingId: `setting-${index}`, comparison: 'Different', expected: '1', registryType: '4', additional: [], missing: [] }] })) };
|
|
}
|
|
|
|
function render(item = record()) {
|
|
const container = document.createElement('div');
|
|
container.innerHTML = renderToStaticMarkup(<FindingReader record={item} title={item.title} linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={1} toolId="group-policy" onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/>);
|
|
return container;
|
|
}
|
|
|
|
describe('GPO first-read problem summary', () => {
|
|
it('states configured versus expected, the contributing GPOs, the flag and the next step before the evidence', () => {
|
|
const container = render();
|
|
const summary = container.querySelector('.reader-summary')!;
|
|
const problem = summary.querySelector('.policy-problem')!;
|
|
// The problem is the first content block after the setting heading.
|
|
expect(summary.querySelector('h3 + .policy-problem')).toBe(problem);
|
|
expect(problem.getAttribute('data-outcome')).toBe('fail');
|
|
const values = Object.fromEntries([...problem.querySelectorAll('.policy-problem__values > div')]
|
|
.map(row => [row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent]));
|
|
expect(values).toEqual({ Configured: '0', Expected: '1, registry type 4 (REG_DWORD)' });
|
|
expect(problem.querySelector('.policy-problem__context')?.textContent)
|
|
.toBe('3 GPO backup sources with the same value · differs from all 2 assessed baseline variants in 2 baseline packages');
|
|
expect([...problem.querySelectorAll('.policy-problem__flag')].map(item => item.textContent)).toEqual([
|
|
'Conflict: Conflicting customer states; see original GPO source instructions.',
|
|
'The registry key letter case differs between sources; Windows treats registry key names case-insensitively.'
|
|
]);
|
|
// Problem and next step are separate blocks with their own marker.
|
|
const next = problem.nextElementSibling!;
|
|
expect(next.matches('section.policy-next-step')).toBe(true);
|
|
expect(next.querySelector('h4')?.textContent).toBe('Next step');
|
|
expect(problem.querySelector('.policy-problem__sources')).toBeNull();
|
|
expect(next.querySelector('.policy-problem__sources')?.textContent)
|
|
.toBe('In: GPO backup gpo-backup-a, GPO backup gpo-backup-b, GPO backup gpo-backup-c');
|
|
// Identical instructions of several backups are stated above, so their complete sections are on demand.
|
|
const evidence = next.nextElementSibling!;
|
|
expect(evidence.classList.contains('policy-source-evidence')).toBe(true);
|
|
const collapsed = evidence.querySelector('details.policy-source-collapse')!;
|
|
expect(collapsed.hasAttribute('open')).toBe(false);
|
|
expect(collapsed.querySelector('summary')?.textContent).toBe('Show the 3 identical source instructions');
|
|
expect(collapsed.querySelectorAll('.policy-source-group')).toHaveLength(3);
|
|
expect(container.querySelectorAll('.policy-source-deviation-value')).toHaveLength(3);
|
|
// Not repeated as separate report fields; technical provenance moves to Additional Information.
|
|
const headings = [...summary.querySelectorAll(':scope > div > h4')].map(item => item.textContent);
|
|
expect(headings).not.toContain('Conflict');
|
|
expect(headings).not.toContain('Remediation');
|
|
expect(headings).not.toContain('Technical Identity');
|
|
expect(headings).toContain('Scope');
|
|
expect(container.querySelector('.all-fields')?.textContent).toContain('synthetic-identity');
|
|
});
|
|
|
|
it('keeps work tracking available but collapsed below the finding', () => {
|
|
const container = render();
|
|
const tracking = container.querySelector('details.reader-tracking')!;
|
|
expect(tracking.hasAttribute('open')).toBe(false);
|
|
expect(tracking.querySelector('summary')?.textContent).toBe('Work trackingStatus: Open · Priority: Medium');
|
|
expect(tracking.querySelectorAll('.dashboard-grid select, .review-grid textarea')).toHaveLength(7);
|
|
expect(container.querySelector('.reader-summary')!.compareDocumentPosition(tracking) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
expect(container.querySelector('.finding-reader > .dashboard-grid')).toBeNull();
|
|
});
|
|
|
|
it('adds no problem block without current structured comparisons', () => {
|
|
const item = record();
|
|
delete item.policyBaselineComparisons;
|
|
expect(render(item).querySelector('.policy-problem')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('GPO source sections with differing instructions', () => {
|
|
it('summarizes differing values per backup and keeps their complete sections on demand', () => {
|
|
const item = record();
|
|
const differing = item.policySourceEvidence![1];
|
|
differing.values = [{ ...(differing.values[0] as object), data: { kind: 'uint32', value: 1 } }] as never;
|
|
const container = render(item);
|
|
const collapsed = container.querySelector('details.policy-source-collapse')!;
|
|
expect(collapsed.hasAttribute('open')).toBe(false);
|
|
expect(collapsed.querySelector('summary')?.textContent).toBe('Show the 3 source instructions');
|
|
expect(collapsed.querySelectorAll('.policy-source-group')).toHaveLength(3);
|
|
// The value-to-backup overview stays visible outside the disclosure.
|
|
const overview = container.querySelector('.policy-source-overview')!;
|
|
expect(overview.closest('details')).toBeNull();
|
|
expect(overview.querySelector('.policy-source-overview__summary')?.textContent).toBe('2 different instructions across 3 sources');
|
|
});
|
|
});
|
|
|
|
describe('GPO problem and next-step markers', () => {
|
|
it('use the shared marker width, the fail color for the problem and the orange next-step role', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const styles = readFileSync('src/styles/components.css', 'utf8');
|
|
const tokens = readFileSync('src/styles/tokens.css', 'utf8');
|
|
expect(styles).toMatch(/\.reader-summary > \.policy-problem, \.reader-summary > \.policy-next-step \{[^}]*border-left: var\(--marker-width\) solid/);
|
|
expect(styles).toMatch(/\.reader-summary > \.policy-problem\[data-outcome="fail"\] \{ border-left-color: var\(--outcome-fail\); \}/);
|
|
expect(styles).toMatch(/\.reader-summary > \.policy-next-step \{[^}]*border-left-color: var\(--next-step-accent\);/);
|
|
expect(tokens).toContain('--next-step-accent: var(--sev-high);');
|
|
});
|
|
});
|
|
|
|
describe('GPO problem summary with differing package results', () => {
|
|
it('explains the rolled-up verdict, names the templates that match and states the shared intro once', () => {
|
|
const principal = (key: string) => ({ id: key, backupKey: key, backupInstanceId: `gpo-backup-${key}`, sourceReference: `{${key}}/GptTmpl.inf#lines=1`,
|
|
baselineRelation: 'review' as const, values: [{ kind: 'principal-set', principals: ['*S-1-5-32-544'] }] });
|
|
const variant = (template: string, match: boolean) => ({ template, settingId: template, comparison: match ? 'Match' : 'Different',
|
|
expected: match ? 'Values: *S-1-5-32-544' : 'Empty account list', additional: match ? [] : ['*S-1-5-32-544'], missing: [] });
|
|
const comparison = (baseline: string, status: string, remediation: string, recommendations: ReturnType<typeof variant>[]) => ({
|
|
baseline, status, summary: '', context: 'Synthetic role context.', scopeNotice: 'Synthetic scope notice.', explanation: '', remediation, recommendations });
|
|
const mixed = 'Establish the applicable baseline template before changing this setting. Compare the individual recommendations below; do not combine their values into a new baseline.';
|
|
const item = record();
|
|
item.fields = [{ label: 'Title', value: 'SyntheticRight', index: 0 }, { label: 'Status', value: 'Fail', index: 1 }];
|
|
item.policySourceEvidence = [principal('a')];
|
|
item.policyBaselineComparisons = [
|
|
comparison('Synthetic client baseline', 'Fail', 'Computer policy: SyntheticRight\nExpected: Values: Not set', [variant('Synthetic client', false)]),
|
|
comparison('Synthetic server baseline 1', 'Review required', mixed, [variant('Synthetic server 1 - Member Server', false), variant('Synthetic server 1 - Domain Controller', true)]),
|
|
comparison('Synthetic server baseline 2', 'Review required', mixed, [variant('Synthetic server 2 - Domain Controller', true), variant('Synthetic server 2 - Member Server', false)])
|
|
];
|
|
const problem = render(item).querySelector('.policy-problem')!;
|
|
expect([...problem.querySelectorAll('.policy-problem__context')].map(line => line.textContent)).toEqual([
|
|
'1 GPO backup source',
|
|
'Verdict Fail: the highest result of the individual baseline packages. Fail in 1 of 3 packages, Review required in 2 of 3 packages; in 2 of them the templates of the same package disagree.'
|
|
]);
|
|
const expected = [...problem.querySelectorAll('.policy-problem__values > div')].find(row => row.querySelector('dt')?.textContent === 'Expected')!;
|
|
expect([...expected.querySelectorAll('.policy-problem__entry')].map(entry => entry.textContent)).toEqual([
|
|
'Empty account list — Different · 3 baseline templates',
|
|
'Values: *S-1-5-32-544 (Administrators) — Match · Synthetic server 1 - Domain Controller; Synthetic server 2 - Domain Controller'
|
|
]);
|
|
const next = problem.nextElementSibling!;
|
|
expect(next.textContent!.split('Establish the applicable baseline template').length - 1).toBe(1);
|
|
expect([...next.querySelectorAll('.policy-problem__step')].map(step => step.querySelector('.policy-problem__packages')?.textContent))
|
|
.toEqual(['Synthetic client baseline']);
|
|
});
|
|
});
|
|
|
|
describe('GPO backups without a link', () => {
|
|
it('states "No effect" in the problem summary and marks every unlinked source, without changing the verdict', () => {
|
|
const item = record();
|
|
item.policySourceEvidence = item.policySourceEvidence!.map(source => ({ ...source, linkStatus: 'not-linked' as const }));
|
|
const container = render(item);
|
|
const problem = container.querySelector('.policy-problem')!;
|
|
expect(container.querySelector('.outcome-mark')?.textContent).toBe('Fail');
|
|
expect([...problem.querySelectorAll('.policy-problem__flag')].map(flag => flag.textContent)).toContain(
|
|
'No effect: every contributing GPO is not linked in its backup report, so this value applies to no device.');
|
|
expect(container.querySelectorAll('.policy-source-group__badge.is-not-linked')).toHaveLength(3);
|
|
expect(container.querySelectorAll('.policy-source-group__link-note')).toHaveLength(3);
|
|
});
|
|
|
|
it('counts unlinked contributors when only some GPOs are unlinked and stays silent without retained link facts', () => {
|
|
const partial = record();
|
|
partial.policySourceEvidence = partial.policySourceEvidence!.map((source, index) => index === 0
|
|
? { ...source, linkStatus: 'not-linked' as const } : { ...source, linkStatus: 'linked' as const });
|
|
const flags = [...render(partial).querySelectorAll('.policy-problem__flag')].map(flag => flag.textContent);
|
|
expect(flags).toContain('1 of 3 contributing GPOs are not linked in their backup report and apply to no device.');
|
|
const unknown = render(record());
|
|
expect(unknown.querySelector('.policy-source-group__badge.is-not-linked')).toBeNull();
|
|
expect(unknown.textContent).not.toContain('not linked');
|
|
});
|
|
});
|