342 lines
27 KiB
TypeScript
342 lines
27 KiB
TypeScript
// @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', '<br>'])('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(<FindingReader record={item} title={item.title} linkedSheets={linkedSheets} resultReferences={[]}
|
||
search="" position={0} recordCount={1} toolId={toolId} onPrevious={() => {}} 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<HTMLAnchorElement>('.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);
|
||
});
|
||
|
||
// Regression 2026-10-13: a list following the result statement (one item per line, or flattened behind the
|
||
// introducing colon) appeared completely in the Problem block; it belongs to Results like tables and headings.
|
||
it.each([
|
||
['flattened export (Result next to Status)', [['Status', 'Failed'], ['Result',
|
||
'Synthetic emergency access * user: Synthetic A (00000000-0000-0000-0000-000000000001) These synthetic policies lack the exclusion: '
|
||
+ '- [Synthetic policy - one](https://entra.microsoft.com/#synthetic-1) - [Synthetic policy two](https://entra.microsoft.com/#synthetic-2)']]],
|
||
['native ResultDetail with line breaks', [['Result', 'Failed'], ['ResultDetail',
|
||
'## Test result\n\nSynthetic emergency access * user: Synthetic A (00000000-0000-0000-0000-000000000001) These synthetic policies lack the exclusion:\n'
|
||
+ ' - [Synthetic policy - one](https://entra.microsoft.com/#synthetic-1)\n - [Synthetic policy two](https://entra.microsoft.com/#synthetic-2)']]]
|
||
] as Array<[string, Array<[string, string]>]>)('Maester %s: the Problem ends before the listed items', (_variant, fields) => {
|
||
const item = record([['ID', 'MT.9002'], ['Title', 'Synthetic break glass finding'], ['Description', 'Synthetic description.'], ...fields]);
|
||
const { container, values } = render(item, 'maester');
|
||
expect(values('.finding-first-read')).toEqual({ Result:
|
||
'Synthetic emergency access * user: Synthetic A (00000000-0000-0000-0000-000000000001) These synthetic policies lack the exclusion:' });
|
||
const problem = container.querySelector('.finding-first-read')?.textContent ?? '';
|
||
expect(problem).not.toContain('Synthetic policy');
|
||
expect(container.querySelector('.finding-first-read li, .finding-first-read a')).toBeNull();
|
||
});
|
||
|
||
it('Maester: a test result that opens with a list states no problem paragraph from later text', () => {
|
||
const item = record([['ID', 'MT.9003'], ['Title', 'Synthetic finding'], ['Result', 'Failed'],
|
||
['ResultDetail', '- [Synthetic one](https://entra.microsoft.com/#1)\n- [Synthetic two](https://entra.microsoft.com/#2)\n\nSynthetic trailing note.']]);
|
||
expect(render(item, 'maester').values('.finding-first-read').Result).toBeUndefined();
|
||
});
|
||
|
||
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)=><FindingReader record={value} title={value.title} linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1} toolId="maester" onPrevious={()=>{}} 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<HTMLDetailsElement>('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();}
|
||
});
|