// @vitest-environment jsdom import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import type { DatasetSheet } from '../../src/domain/dataset'; import type { FindingRecord } from '../../src/domain/finding'; import { FindingReader } from '../../src/features/finding-reader/FindingReader'; import { makeSheet } from '../../src/importers/sheet-factory'; import { toolIdForRecord } from '../../src/importers/status-normalization'; import { powerShellDisplayLines } from '../../src/features/finding-reader/first-read'; import { cisSectionHeading } from '../../src/profiles/cis-cat/cis-benchmark-section-titles'; /** Synthetic scanner finding with the given retained fields. */ it.each(['\n', '
'])('renders a Maester description table with a cell break (%s)', (breakValue) => { const description = 'Synthetic introduction. | Application permission | Path type | Known shortest path | |---|---|---| ' + '| [Synthetic.Read.All](https://example.invalid/read) | Indirect | First explanation.' + breakValue + 'Note: retain this complete explanation. | | Synthetic.Write.All | Direct | Last explanation. |'; const item = record([['Title', 'Synthetic permissions check'], ['Status', 'Failed'], ['Description', description]]); const { container } = render(item, 'maester'); const table = container.querySelector('.rich-text-table-wrap table')!; expect(table).not.toBeNull(); expect([...table.querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['Application permission', 'Path type', 'Known shortest path']); expect(table.querySelectorAll('tbody tr')).toHaveLength(2); expect(table.querySelectorAll('tbody tr')[0].textContent).toContain('Note: retain this complete explanation.'); expect(table.querySelector('a')?.getAttribute('href')).toBe('https://example.invalid/read'); expect(item.fields.find(field => field.label === 'Description')?.value).toBe(description); }); function record(fields: Array<[string, string]>): FindingRecord { const values = fields.map(([label, value], index) => ({ label, value, index })); return { id: 'synthetic', index: 0, title: 'Synthetic finding', fields: values, filled: values, meta: [] }; } function sheet(rows: string[][]): DatasetSheet { return makeSheet('Synthetic results', rows, '', 'synthetic.html'); } function render(item: FindingRecord, toolId: string, linkedSheets: DatasetSheet[] = []) { const container = document.createElement('div'); container.innerHTML = renderToStaticMarkup( {}} onNext={() => {}} onReview={() => {}}/>); const values = (selector: string) => Object.fromEntries([...container.querySelectorAll(`${selector} .policy-problem__values > div`)] .map(row => [row.querySelector('dt')!.textContent, row.querySelector('dd')!.textContent])); return { container, values }; } const headings = (container: HTMLElement) => [...container.querySelectorAll('.reader-summary > div > h4')].map(item => item.textContent); describe('scanner first read', () => { it('PingCastle: evidence and result rows as the problem, the advised solution as the next step, background below', () => { const item = record([['Rule ID', 'S-Synthetic'], ['Title', 'Synthetic finding'], ['Status', 'Matched'], ['Evidence', 'Synthetic accounts found: 3'], ['Description', 'Synthetic description.'], ['Technical explanation', 'Synthetic explanation.'], ['Advised solution', 'Change the synthetic setting.\n\n$Synthetic = Get-Synthetic'], ['Documentation', 'Synthetic documentation']]); const { container, values } = render(item, 'pingcastle', [sheet([['Account'], ['synthetic-a'], ['synthetic-b'], ['synthetic-c']])]); const problem = container.querySelector('.finding-first-read.policy-problem')!; expect(problem.getAttribute('data-outcome')).toBe(container.querySelector('.outcome-mark')?.getAttribute('data-outcome')); expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic accounts found: 3', Results: '3 rows in 1 table' }); const next = problem.nextElementSibling!; expect(next.matches('section.policy-next-step')).toBe(true); expect(next.textContent).toContain('Change the synthetic setting.'); // The remaining remediation text is complete, only behind a disclosure. expect(next.querySelector('details summary')?.textContent).toBe('Full remediation'); expect(next.querySelector('details')?.textContent).toContain('$Synthetic = Get-Synthetic'); expect(headings(container)).toEqual(['Description', 'Technical explanation', 'Documentation']); }); it('keeps a PingCastle PowerShell command complete and readable outside the prose', () => { const command = 'Get-ADComputer -Filter {(Enabled -eq $true) -and (OperatingSystem -Like "*Windows 7*")} -Property Name,OperatingSystem | format-table Name,OperatingSystem -Wrap -Auto'; const advice = `Upgrade the workstations. You can check the OS with the following PowerShell command: ${command}`; const item = record([['Advised solution', `${advice}\n\nConfirm the resulting inventory.`]]); const { container } = render(item, 'pingcastle'); const step = container.querySelector('.finding-first-read.policy-next-step')!; expect(step.querySelector('p')?.textContent).toBe('Upgrade the workstations. You can check the OS with the following PowerShell command:'); expect(step.querySelector('pre.finding-first-read__command code')?.textContent).toBe( 'Get-ADComputer -Filter {(Enabled -eq $true) -and (OperatingSystem -Like "*Windows 7*")} -Property Name,OperatingSystem |\n' + ' format-table Name,OperatingSystem -Wrap -Auto'); expect(step.querySelector('details')?.textContent).toContain('Confirm the resulting inventory.'); expect(item.fields[0].value).toBe(`${advice}\n\nConfirm the resulting inventory.`); }); // Regression 2026-09-26: a script introduced by "the following script to ...:" and starting with a // variable assignment stayed inside the prose instead of its own command block. it('separates an introduced PowerShell script that starts with a variable assignment', () => { const script = '$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID) ' + 'Get-ADUser -Filter * -Properties PrimaryGroup | Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } | Select-Object UserPrincipalName,PrimaryGroup'; const lead = 'Restore the synthetic default. You can use the following script to list users with a synthetic primary group:'; const item = record([['Advised solution', `${lead} ${script}`]]); const { container } = render(item, 'pingcastle'); const step = container.querySelector('.finding-first-read.policy-next-step')!; expect(step.querySelector('p')?.textContent).toBe(lead); const shown = step.querySelector('pre.finding-first-read__command code')?.textContent ?? ''; expect(shown.split('\n')).toEqual([ '$SyntheticSid = New-Object System.Security.Principal.SecurityIdentifier ([System.Security.Principal.WellKnownSidType]::AccountDomainUsersSid,(Get-ADDomain).DomainSID)', 'Get-ADUser -Filter * -Properties PrimaryGroup |', ' Where-Object { $_.PrimaryGroup -ne (Get-ADGroup -Filter {SID -eq $SyntheticSid}).DistinguishedName } |', ' Select-Object UserPrincipalName,PrimaryGroup']); // Only whitespace changes; every other character is retained. expect(shown.replace(/\s+/g, ' ')).toBe(script); expect(item.fields[0].value).toBe(`${lead} ${script}`); }); it('lays out a flattened command only at unambiguous structure boundaries', () => { expect(powerShellDisplayLines('Get-Help Get-ADUser -Full')).toEqual(['Get-Help Get-ADUser -Full']); expect(powerShellDisplayLines('Write-Output "a | b" | Out-Null')).toEqual(['Write-Output "a | b" |', ' Out-Null']); expect(powerShellDisplayLines('Test-Path $a || Write-Output x')).toEqual(['Test-Path $a || Write-Output x']); expect(powerShellDisplayLines('Get-A |\n Get-B')).toEqual(['Get-A |', ' Get-B']); }); it('keeps prose with a colon but no command in the text', () => { const advice = 'Review the script policy: re-run only signed scripts.'; const { container } = render(record([['Advised solution', advice]]), 'pingcastle'); expect(container.querySelector('pre.finding-first-read__command')).toBeNull(); expect(container.querySelector('.policy-next-step')?.textContent).toContain(advice); }); it('Purple Knight: result message, affected count and likelihood as the problem, remediation as the next step', () => { const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'], ['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'], ['Remediation', 'Remove the synthetic permission.']]); const { container, values } = render(item, 'purple-knight'); expect(values('.finding-first-read')).toEqual({ Result: 'Found 2 synthetic objects.', Affected: '2', Impact: 'Synthetic likelihood.' }); expect(container.querySelector('.policy-next-step')?.textContent).toContain('Remove the synthetic permission.'); expect(container.querySelector('.policy-next-step details')).toBeNull(); expect(headings(container)).toEqual(['Description']); }); it('CIS-CAT: the report criterion and evaluated row as expected and found, colored by the finding outcome', () => { const item = record([['Benchmark Item', 'Synthetic finding'], ['Result', 'Fail'], ['Description', 'Synthetic description.'], ['Rationale', 'Synthetic rationale.'], ['Impact', 'Synthetic impact.'], ['Remediation', 'Set the synthetic value to 1.']]); const evidence = sheet([['Item', 'Value'], ['Criterion: synthetic value', 'equals 1'], ['Synthetic value', '0']]); evidence.analysis.records[1].highlighted = true; const { container, values } = render(item, 'cis-cat', [evidence]); expect(values('.finding-first-read')).toEqual({ Expected: 'Criterion: synthetic value · equals 1', Found: 'Synthetic value · 0', Impact: 'Synthetic impact.' }); expect(container.querySelector('.finding-first-read [data-state="deviating"] .policy-value-mark.is-deviating')?.textContent).toBe('Synthetic value · 0'); expect(headings(container)).toEqual(['Description', 'Rationale']); }); it('Maester: the test result lead as the problem, source-marked row counts and the https documentation link', () => { const item = record([['ID', 'MT.9001'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['ResultDetail', 'Synthetic native test result'], ['Description', 'Synthetic description.'], ['HelpUrl', 'https://maester.dev/docs/tests/MT.9001']]); const results = sheet([['Policy', 'Status'], ['Synthetic A', '❌ Disabled'], ['Synthetic B', '✅ Enabled'], ['Synthetic C', 'Report-only']]); const { container, values } = render(item, 'maester', [results]); expect(values('.finding-first-read')).toEqual({ Result: 'Synthetic native test result', Results: '1 of 3 rows marked failed · 1 marked passed' }); const link = container.querySelector('.policy-next-step a')!; expect(link.getAttribute('href')).toBe('https://maester.dev/docs/tests/MT.9001'); expect(link.getAttribute('rel')).toBe('noopener noreferrer'); }); // Regression 2026-09-28 (user decision): the Maester test result statement was missing from the reader; // only its first prose paragraph is shown, verbatim, while headings and the impacted-resources table stay in Results. it('Maester: opening a finding shows the first paragraph of the native test result as the problem', () => { const detail = '## Test result\n\nYou have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.\n\n' + '#### Impacted resources\n\n| Status | Name | First detected |\n| :--- | :--- | :--- |\n| ❌ Fail | synthetic-a@example.test | 04/17/2025 |'; const item = record([['ID', 'MT.1024.adminMFAV2'], ['Title', 'Require multifactor authentication for administrative roles.'], ['Result', 'Failed'], ['ResultDetail', detail], ['Description', 'Synthetic description.\n\n#### Remediation action:\n\n1. Enable MFA.']]); const { container, values } = render(item, 'maester'); expect(values('.finding-first-read')).toEqual({ Result: 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.' }); const reader = container.querySelector('.reader-summary')?.textContent ?? ''; expect(reader).not.toContain('synthetic-a@example.test'); expect(reader).not.toContain('Impacted resources'); expect(item.fields.find(field => field.label === 'ResultDetail')?.value).toBe(detail); }); // Regression 2026-09-28: the Maester Excel export keeps the test result in `Result` next to a separate // `Status` column, with its line breaks flattened; the statement must still become the problem. it.each([ ['CIS.M365.1.1.1', ' Your tenant has 1 or more hybrid Global Administrators: | Display Name | Cloud Only | | --- | --- | | Synthetic A | ❌ Fail |', 'Your tenant has 1 or more hybrid Global Administrators:'], ['MT.1024.adminMFAV2', 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA. #### Impacted resources | Status | Name |', 'You have 3 of 16 users with administrative roles that aren’t registered and protected with MFA.'] ])('Maester export %s: the Result statement next to a Status column is the problem', (id, result, expected) => { const item = record([['ID', id], ['Title', 'Synthetic finding'], ['Severity', 'High'], ['Status', 'Failed'], ['Category', 'CIS'], ['Description', 'Synthetic description.'], ['Result', result]]); const { values } = render(item, 'maester'); expect(values('.finding-first-read')).toEqual({ Result: expected }); expect(item.fields.find(field => field.label === 'Result')?.value).toBe(result); }); it('Maester export without a Status column keeps Result as the status, not as a problem statement', () => { const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['Description', 'Synthetic description.']]); expect(render(item, 'maester').container.querySelector('.finding-first-read.policy-problem')).toBeNull(); }); // Regression 2026-09-26: the mixed Work sheet carries the generic profile and names each record's scan in // `Source: Scan`. Purple Knight runs are named by scope, which did not resolve to the profile, so the // report showed the fields as plain sections instead of Problem and Next step. it.each(['Purple Knight for Active Directory', 'Purple Knight for Entra ID', 'Purple Knight for Active Directory and Entra ID'])( 'Purple Knight in mixed Work (%s): opening the report shows Problem and Next step', runName => { const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'], ['Likelihood of compromise', 'Synthetic likelihood.'], ['Result message', 'Found 2 synthetic objects.'], ['Number of results', '2'], ['Remediation', 'Remove the synthetic permission.'], ['Source: Scan', `${runName} · 2026-01-01T00:00:00.000Z`]]); expect(toolIdForRecord(item, 'generic')).toBe('purple-knight'); const { container, values } = render(item, 'generic'); expect(values('.finding-first-read')).toEqual({ Result: 'Found 2 synthetic objects.', Affected: '2', Impact: 'Synthetic likelihood.' }); expect(container.querySelector('.policy-next-step')?.textContent).toContain('Remove the synthetic permission.'); expect(headings(container)).not.toContain('Remediation'); expect(headings(container)).not.toContain('Result message'); }); // Regression 2026-09-26: Purple Knight exports append the reference titles to the likelihood text and lose // the paragraph breaks, so the Impact row ended in ">ReferencesNIST.SP.800-63-3...". it('Purple Knight: impact ends before the appended references, which move to the references position', () => { const likelihood = 'Synthetic weak passwords are easier to crack.The risk is higher for synthetic privileged accounts.' + '>ReferencesNIST.SP.800-63-3Synthetic weak password policy | ANSSI'; const item = record([['Name', 'Synthetic finding'], ['Status', 'IOE Found'], ['Description', 'Synthetic description.'], ['Likelihood of compromise', likelihood], ['Result message', 'Found 5 synthetic users.'], ['Number of results', '5'], ['Remediation', 'Apply the synthetic policy.'], ['Source: Scan', 'Purple Knight for Active Directory · 2026-01-01T00:00:00.000Z']]); const { container } = render(item, 'generic'); const impact = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')] .find(row => row.querySelector('dt')?.textContent === 'Impact')!.querySelector('dd')!; expect(impact.textContent).not.toContain('References'); // The reader paragraph keeps line breaks (white-space: pre-wrap), so the restored break shows as a new paragraph. expect(impact.textContent!.split('\n\n')).toEqual([ 'Synthetic weak passwords are easier to crack.', 'The risk is higher for synthetic privileged accounts.']); expect(headings(container)).toEqual(['Description', 'References']); expect(container.querySelector('.reader-summary')!.textContent).toContain('NIST.SP.800-63-3Synthetic weak password policy | ANSSI'); expect(container.querySelector('.reader-summary')!.textContent!.split('Synthetic weak password policy').length).toBe(2); expect(item.fields.find(field => field.label === 'Likelihood of compromise')?.value).toBe(likelihood); }); it.each([ ['a spaced heading', 'Synthetic impact text.References Synthetic reference title', 'Synthetic impact text.', 'Synthetic reference title'], ['a heading glued to the last word', 'Synthetic impact is resolvedReferencesSynthetic reference title', 'Synthetic impact is resolved', 'Synthetic reference title'], ['an ordinary mention', 'Open the References tab in the synthetic console.', 'Open the References tab in the synthetic console.', undefined] ])('Purple Knight impact with %s', (_case, likelihood, impact, references) => { const item = record([['Name', 'Synthetic finding'], ['Likelihood of compromise', likelihood]]); const { container } = render(item, 'purple-knight'); const row = [...container.querySelectorAll('.finding-first-read .policy-problem__values > div')].find(item => item.querySelector('dt')?.textContent === 'Impact'); expect(row?.querySelector('dd')?.textContent).toBe(impact); expect(headings(container).includes('References')).toBe(Boolean(references)); if (references) expect(container.querySelector('.reader-summary')!.textContent).toContain(references); }); it('Maester without a remediation field: the native Remediation action section becomes the next step, shown once', () => { const description = 'Synthetic background paragraph.\n\n#### Remediation action:\n\nOpen the synthetic portal.\n\n#### Related links\n\n* Synthetic link'; const item = record([['ID', 'MT.9003'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', description], ['Source: Scan', 'Maester · 2026-01-01T00:00:00.000Z']]); const { container } = render(item, 'generic'); const step = container.querySelector('.finding-first-read.policy-next-step')!; expect(step.querySelector('p')?.textContent).toBe('Open the synthetic portal.'); expect(step.querySelector('details')?.textContent).toContain('Synthetic link'); const summary = container.querySelector('.reader-summary')!.textContent!; expect(summary).toContain('Synthetic background paragraph.'); expect(summary.split('Open the synthetic portal.').length).toBe(2); expect(summary).not.toContain('Remediation action'); expect(item.fields.find(field => field.label === 'Description')?.value).toBe(description); }); it('Maester export with flattened line breaks still separates the native section at its heading', () => { const item = record([['ID', 'MT.9004'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', 'Synthetic background. #### Remediation action: 1. Open the synthetic portal. 2. Save.']]); const { container } = render(item, 'maester'); const step = container.querySelector('.policy-next-step')!.textContent!; expect(step).toContain('Open the synthetic portal.'); expect(step).toContain('Save.'); const summary = container.querySelector('.reader-summary')!.textContent!; expect(summary).toContain('Synthetic background.'); expect(summary.split('Open the synthetic portal.').length).toBe(2); }); it('Maester without a native remediation section keeps the full description and shows no next step', () => { const item = record([['ID', 'MT.9005'], ['Title', 'Synthetic finding'], ['Status', 'Failed'], ['Description', 'Only synthetic background.']]); const { container } = render(item, 'maester'); expect(container.querySelector('.policy-next-step')).toBeNull(); expect(container.querySelector('.reader-summary')!.textContent).toContain('Only synthetic background.'); }); it('CIS-CAT report: each number badge names its report section on hover', () => { const item: FindingRecord = { ...record([['Rule ID', '2.2.2'], ['Benchmark Item', '2.2.2 Ensure a synthetic user right'], ['Result', 'Fail']]), benchmarkSectionTitles: new Map([['2', 'Synthetic Local Policies'], ['2.2', 'Synthetic User Rights Assignment']]) }; const badges = [...render(item, 'cis-cat').container.querySelectorAll('[data-derived-category="cis"]')]; expect(badges.map(badge => badge.textContent)).toEqual(['CIS 2', 'CIS 2.2']); expect(badges.map(badge => badge.getAttribute('title'))).toEqual(['Synthetic Local Policies', 'Synthetic User Rights Assignment']); }); it.each([ ['2.2.2', '2.2.2 Ensure a synthetic user right', 'Synthetic User Rights Assignment: Ensure a synthetic user right'], ['9.9.9', '9.9.9 Ensure a synthetic unknown section', '9.9.9 Ensure a synthetic unknown section'], ['2.2.2', 'Ensure a heading without number', 'Ensure a heading without number'] ])('CIS heading for rule %s: %s', (ruleId, heading, expected) => { const titles = { benchmarkSectionTitles: new Map([['2.2', 'Synthetic User Rights Assignment']]) }; expect(cisSectionHeading(titles, ruleId, heading)).toBe(expected); expect(cisSectionHeading({}, ruleId, heading)).toBe(heading); }); it('never offers a non-https documentation link from imported content', () => { const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic finding'], ['Result', 'Failed'], ['HelpUrl', 'javascript:alert(1)']]); expect(render(item, 'maester').container.querySelector('.policy-next-step')).toBeNull(); }); }); it('Maester opens with its native title and collapsed complete test code, and discloses absent evidence', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const script='$EntraPremiumRecommendations = @(\n"insiderRiskPolicy"\n)'; const item=record([['Source Rule ID','MT.1024'],['Title','Synthetic recommendation check'],['Result','Failed'],['TestScript',script],['HelpUrl','https://maester.dev/docs/tests/MT.1024/']]); const container=document.createElement('div');document.body.append(container);const root=createRoot(container); const show=(value:FindingRecord)=>{}} onNext={()=>{}} onReview={()=>{}}/>; try { await act(async()=>root.render(show(item))); expect(container.querySelector('.reader-summary > h3')?.textContent).toBe('Synthetic recommendation check'); expect(container.querySelector('.maester-missing-result')).not.toBeNull(); expect(container.querySelector('.policy-problem')).toBeNull(); const details=[...container.querySelectorAll('details')].find(d=>d.querySelector('summary')?.textContent==='Test details')!; expect(details.open).toBe(false);expect(details.querySelector('code')?.textContent).toBe(script); await act(async()=>details.querySelector('summary')!.click());expect(details.open).toBe(true); await act(async()=>details.querySelector('summary')!.click());expect(details.open).toBe(false); const complete=record([...item.fields.map(f=>[f.label,f.value] as [string,string]),['ResultDetail','Synthetic native result statement.']]); await act(async()=>root.render(show(complete))); expect(container.querySelector('.maester-missing-result')).toBeNull(); expect(container.querySelector('.policy-problem')?.textContent).toContain('Synthetic native result statement.'); expect(item.fields.find(f=>f.label==='TestScript')?.value).toBe(script); } finally {await act(async()=>root.unmount());container.remove();vi.unstubAllGlobals();} });