608 lines
30 KiB
TypeScript
608 lines
30 KiB
TypeScript
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { FindingRecord } from '../../src/domain/finding';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { FindingReader, policyAnalyzerSearchKeys, selectFindingFields } from '../../src/features/finding-reader/FindingReader';
|
|
import { FindingCard } from '../../src/components/FindingCard/FindingCard';
|
|
import { resultSectionsFor, resultStatusFieldFor } from '../../src/features/linked-results/result-message';
|
|
import { LinkedResults } from '../../src/features/linked-results/LinkedResults';
|
|
import { resultReferenceValues } from '../../src/profiles/purple-knight/result-linker';
|
|
import { maesterFindingTitle } from '../../src/profiles/maester/presentation';
|
|
|
|
const record: FindingRecord = {
|
|
id: 'finding-1',
|
|
index: 0,
|
|
title: 'Non-admin users can register custom applications',
|
|
fields: [
|
|
{ index: 0, label: 'Name', value: 'Non-admin users can register custom applications' },
|
|
{ index: 1, label: 'Result', value: 'IOE Found' },
|
|
{ index: 2, label: 'Severity', value: 'High' },
|
|
{ index: 3, label: 'Result Message', value: 'Every user can register an application.' },
|
|
{ index: 4, label: 'Description', value: 'Description text' },
|
|
{ index: 5, label: 'ShortName', value: 'SI000160' },
|
|
{ index: 6, label: 'SI Version', value: '1.290.0' },
|
|
{ index: 7, label: 'Target', value: 'ME-ID' },
|
|
{ index: 8, label: 'Runtime', value: '00:00:14:20' },
|
|
{ index: 9, label: 'Duplicate description', value: 'Description text' },
|
|
{ index: 10, label: 'Internal note', value: 'Only shown as additional information' }
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
describe('selectFindingFields', () => {
|
|
it('hides a Purple Knight Results filename from the Work card and reader outcome while retaining its link', () => {
|
|
const reference = 'Security_Assessment_Report_sevenkingdoms.local_2026_07_22_08_23_22_(SI000055)';
|
|
const finding: FindingRecord = { ...record, fields: [
|
|
{ index: 0, label: 'Name', value: 'Permission changes on AdminSDHolder object' },
|
|
{ index: 1, label: 'Result', value: reference },
|
|
{ index: 2, label: 'Severity', value: 'Critical' },
|
|
{ index: 3, label: 'Result', value: 'IOE Found' }
|
|
] };
|
|
const card = renderToStaticMarkup(createElement(FindingCard, {
|
|
record: finding, title: finding.fields[0].value, bookmarked: false, toolId: 'generic', onOpen: () => undefined
|
|
}));
|
|
const reader = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: finding, title: finding.fields[0].value, linkedSheets: [], resultReferences: [reference],
|
|
search: '', position: 0, recordCount: 1, toolId: 'generic',
|
|
onPrevious: () => undefined, onNext: () => undefined, onReview: () => undefined
|
|
}));
|
|
expect(card).not.toContain(reference);
|
|
expect(reader).not.toContain(`<span class="outcome-mark" data-outcome="unknown">${reference}</span>`);
|
|
expect(card).toContain('IOE Found');
|
|
expect(reader).toContain('IOE Found');
|
|
expect(resultStatusFieldFor(finding, 'purple-knight')?.value).toBe('Failed');
|
|
expect(resultStatusFieldFor(finding, 'generic')?.value).toBe('IOE Found');
|
|
expect(resultReferenceValues(finding)).toContain(reference);
|
|
});
|
|
|
|
it('keeps technical metadata out of the finding text and in additional information', () => {
|
|
const selected = selectFindingFields(record, record.title);
|
|
|
|
expect(selected.summary.map(field => field.label)).toEqual(['Result Message', 'Description', 'Internal note']);
|
|
expect(selected.remainingFields.map(field => field.label)).toEqual([
|
|
'ShortName',
|
|
'SI Version',
|
|
'Target',
|
|
'Runtime'
|
|
]);
|
|
});
|
|
|
|
it('does not repeat title, badges, status, or values already displayed in the finding text', () => {
|
|
const selected = selectFindingFields(record, record.title);
|
|
const additionalLabels = selected.remainingFields.map(field => field.label);
|
|
|
|
expect(additionalLabels).not.toContain('Name');
|
|
expect(additionalLabels).not.toContain('Result');
|
|
expect(additionalLabels).not.toContain('Severity');
|
|
expect(additionalLabels).not.toContain('Duplicate description');
|
|
});
|
|
|
|
it('keeps generated CIS-CAT Results references out of the finding text', () => {
|
|
const cisCatRecord: FindingRecord = {
|
|
...record,
|
|
fields: [
|
|
...record.fields,
|
|
{ index: 11, label: 'Results', value: 'source-table:3 "Assessment Evidence · Synthetic Item"' }
|
|
]
|
|
};
|
|
|
|
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title);
|
|
expect(selected.summary.map(field => field.label)).not.toContain('Results');
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('Results');
|
|
});
|
|
it('keeps legacy CIS-CAT XML in a closed optional Finding section without repeating the aggregate text', () => {
|
|
const cisCatRecord: FindingRecord = {
|
|
id: 'cis-cat-finding',
|
|
index: 0,
|
|
title: '1.1.1 Ensure the synthetic setting is enabled',
|
|
fields: [
|
|
{ index: 0, label: 'Benchmark Item', value: '1.1.1 Ensure the synthetic setting is enabled' },
|
|
{ index: 1, label: 'Result', value: 'Fail' },
|
|
{ index: 2, label: 'Description', value: 'Synthetic description.' },
|
|
{ index: 3, label: 'Rationale', value: 'Synthetic rationale.' },
|
|
{ index: 4, label: 'Results', value: 'source-table:3 "Assessment Evidence - Synthetic Item"' },
|
|
{
|
|
index: 5, label: 'CIS-CAT Details',
|
|
value: '## Description\n\nSynthetic description.\n\n## Rule Result XML\n\n```xml\n<result>fail</result>\n```\n\n## References\n\nSYN-000001'
|
|
}
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title, true, 'cis-cat');
|
|
expect(selected.derivedCategories).toEqual(['CIS 1', 'CIS 1.1']);
|
|
expect(selected.summary.map(field => field.label)).toEqual(['Description', 'Rationale']);
|
|
expect(selected.optionalFields).toMatchObject([{ label: 'Rule Result XML' }]);
|
|
expect(selected.optionalFields[0].value).toContain('<result>fail</result>');
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('CIS-CAT Details');
|
|
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: cisCatRecord,
|
|
title: cisCatRecord.title,
|
|
linkedSheets: [],
|
|
resultReferences: [],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
toolId: 'cis-cat',
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(html).toContain('<details class="finding-optional-detail"><summary><svg class="triangle-icon triangle-icon-right"');
|
|
expect(html).toContain('Rule Result XML</summary>');
|
|
expect(html).toContain('data-derived-category="cis">CIS 1</span>');
|
|
expect(html).toContain('data-derived-category="cis">CIS 1.1</span>');
|
|
expect(html).not.toContain('CIS-CAT Details');
|
|
expect((html.match(/Synthetic description\./g) ?? [])).toHaveLength(1);
|
|
});
|
|
|
|
/**
|
|
* Nutzerkorrektur vom 2026-07-31: Das Rule Result XML des aktuellen Imports
|
|
* bleibt ein geschlossenes Detail im Finding und wandert nicht in das
|
|
* Results-Fenster. Die Rahmenwerk-Zuordnungen gehen umgekehrt dorthin und
|
|
* duerfen deshalb hier weder als Fliesstext noch unter den
|
|
* Zusatzinformationen erscheinen.
|
|
*/
|
|
it('keeps the current CIS-CAT XML expandable in the finding while control mappings move to the results window', () => {
|
|
const cisCatRecord: FindingRecord = {
|
|
id: 'cis-cat-current',
|
|
index: 0,
|
|
title: '1.1.1 Ensure the synthetic setting is enabled',
|
|
fields: [
|
|
{ index: 0, label: 'Benchmark Item', value: '1.1.1 Ensure the synthetic setting is enabled' },
|
|
{ index: 1, label: 'Result', value: 'Fail' },
|
|
{ index: 2, label: 'Description', value: 'Synthetic description.' },
|
|
{ index: 3, label: 'Control Mappings', value: '### CIS Controls V7.0\n\n- Control 1: Synthetic Inventory' },
|
|
{ index: 4, label: 'Results', value: 'source-table:3 "Assessment Evidence · Synthetic Item"' },
|
|
{ index: 5, label: 'Rule Result XML', value: '```xml\n<result>fail</result>\n```' }
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
const selected = selectFindingFields(cisCatRecord, cisCatRecord.title, true, 'cis-cat');
|
|
expect(selected.optionalFields).toMatchObject([{ label: 'Rule Result XML' }]);
|
|
expect(selected.optionalFields[0].value).toBe('<result>fail</result>');
|
|
expect(selected.summary.map(field => field.label)).not.toContain('Control Mappings');
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('Control Mappings');
|
|
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: cisCatRecord,
|
|
title: cisCatRecord.title,
|
|
linkedSheets: [],
|
|
resultReferences: [],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
toolId: 'cis-cat',
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(html).toContain('<details class="finding-optional-detail"><summary><svg class="triangle-icon triangle-icon-right"');
|
|
expect(html).toContain('Rule Result XML</summary>');
|
|
expect(html).not.toContain('Control 1: Synthetic Inventory');
|
|
});
|
|
|
|
it('zeigt im Purple-Knight-Reader Passed oder Failed statt des SI-Shortnames', () => {
|
|
const projected: FindingRecord = {
|
|
...record,
|
|
id: 'purple-knight-projected',
|
|
fields: [
|
|
{ index: 0, label: 'Name', value: 'Privileged accounts with a password that never expires' },
|
|
{ index: 1, label: 'Result', value: 'SI000067' },
|
|
{ index: 2, label: 'Status', value: 'IOE Found' },
|
|
{ index: 3, label: 'ShortName', value: 'SI000067' },
|
|
{ index: 4, label: 'Severity', value: 'High' }
|
|
]
|
|
};
|
|
const selected = selectFindingFields(projected, projected.title, false, 'purple-knight');
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: projected,
|
|
title: projected.title,
|
|
linkedSheets: [],
|
|
resultReferences: [],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
toolId: 'purple-knight',
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(selected.statusField).toMatchObject({ label: 'Status', value: 'Failed' });
|
|
expect(html).toContain('<div class="finding-badges"><span class="outcome-mark" data-outcome="fail">Failed</span>');
|
|
expect(html).not.toContain('data-outcome="unknown">SI000067</span>');
|
|
});
|
|
|
|
it('shows GPO customer and baseline values as one readable comparison', () => {
|
|
const policyRecord: FindingRecord = {
|
|
id: 'gpo-comparison', index: 0, title: 'Synthetic policy setting', filled: [], meta: [],
|
|
fields: [
|
|
{ index: 0, label: 'Title', value: 'Synthetic policy setting' },
|
|
{ index: 1, label: 'Status', value: 'Failed' },
|
|
{ index: 2, label: 'Configured Value', value: '[{"kind":"registry-policy","valueName":"SyntheticSetting","data":{"kind":"number","value":12},"operation":"set-value","typeCode":4}]' },
|
|
{ index: 3, label: 'Baseline value', value: '{"kind":"number","value":14}' },
|
|
{ index: 4, label: 'Baseline', value: 'Synthetic Microsoft Baseline' }
|
|
]
|
|
};
|
|
const selected = selectFindingFields(policyRecord, policyRecord.title, false, 'group-policy');
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: policyRecord, title: policyRecord.title, linkedSheets: [], resultReferences: [], search: '',
|
|
position: 0, recordCount: 1, toolId: 'group-policy', onPrevious: () => undefined,
|
|
onNext: () => undefined, onReview: () => undefined
|
|
}));
|
|
|
|
expect(selected.policyComparisonFields.map(field => field.label)).toEqual(['Configured Value', 'Baseline value']);
|
|
expect(selected.summary.map(field => field.label)).not.toEqual(expect.arrayContaining(['Configured Value', 'Baseline value']));
|
|
expect(html).toContain('class="policy-value-comparison" data-outcome="fail"');
|
|
expect(html).toContain('Configured customer value');
|
|
expect(html).toContain('Expected baseline value');
|
|
// User decision 2026-09-30: the Policy Analyzer search key is no longer shown in the reader heading (derivation kept).
|
|
expect(html).not.toContain('Policy Analyzer search');
|
|
expect(policyAnalyzerSearchKeys(policyRecord, policyRecord.title, 'group-policy')).toEqual(['SyntheticSetting']);
|
|
expect(html).toContain('<h3>SyntheticSetting</h3>');
|
|
expect(html).toContain('<strong class="policy-value-emphasis">Data: 12</strong>');
|
|
expect(html).toContain('<strong class="policy-value-emphasis">Operation: set-value</strong>');
|
|
expect(html).toContain('\nType Code: 4<span class="windows-code-meaning"> (REG_DWORD)</span></p>');
|
|
expect(html).toContain('<p class="policy-guidance-text">14</p>');
|
|
expect(html).not.toContain('Value Name: SyntheticSetting');
|
|
expect(html).not.toContain('"kind"');
|
|
});
|
|
|
|
it('derives a Policy Analyzer key from a technical GPO title without changing the title and omits preferences', () => {
|
|
const technical: FindingRecord = {
|
|
id: 'gpo-technical', index: 0,
|
|
title: 'Registry Values · MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\InactivityTimeoutSecs',
|
|
filled: [], meta: [], fields: [
|
|
{ index: 0, label: 'Title', value: 'Registry Values · MACHINE\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\InactivityTimeoutSecs' },
|
|
{ index: 1, label: 'Status', value: 'Fail' },
|
|
{ index: 2, label: 'Policy type', value: 'security-template' }
|
|
]
|
|
};
|
|
expect(policyAnalyzerSearchKeys(technical, technical.title, 'group-policy')).toEqual(['InactivityTimeoutSecs']);
|
|
|
|
const preference: FindingRecord = {
|
|
...technical,
|
|
id: 'gpo-preference',
|
|
title: 'registry · HKEY_CURRENT_USER\\Software\\Microsoft\\Office\\16.0\\Outlook\\AutoDiscover\\ExcludeScpLookup',
|
|
fields: [
|
|
{ index: 0, label: 'Title', value: 'registry · HKEY_CURRENT_USER\\Software\\Microsoft\\Office\\16.0\\Outlook\\AutoDiscover\\ExcludeScpLookup' },
|
|
{ index: 1, label: 'Status', value: 'Not comparable' },
|
|
{ index: 2, label: 'Policy type', value: 'group-policy-preference' },
|
|
{ index: 3, label: 'Configured Value', value: '[{"kind":"registry-preference","key":"Software\\\\Microsoft\\\\Office\\\\16.0\\\\Outlook\\\\AutoDiscover","valueName":"ExcludeScpLookup"}]' }
|
|
]
|
|
};
|
|
expect(policyAnalyzerSearchKeys(preference, preference.title, 'group-policy')).toEqual(['AutoDiscover', 'ExcludeScpLookup']);
|
|
});
|
|
|
|
it('ordnet auch die alte PurpleKnight-Schreibweise Short Name den Zusatzinformationen zu', () => {
|
|
const legacyShortName: FindingRecord = {
|
|
...record,
|
|
fields: record.fields.map(field => field.label === 'ShortName' ? { ...field, label: 'Short Name' } : field)
|
|
};
|
|
const selected = selectFindingFields(legacyShortName, legacyShortName.title, false, 'purple-knight');
|
|
|
|
expect(selected.summary.map(field => field.label)).not.toContain('Short Name');
|
|
expect(selected.remainingFields.map(field => field.label)).toContain('Short Name');
|
|
});
|
|
|
|
it('verschiebt alte PurpleKnight-HTML-Details in Results und loest Framework-JSON auf', () => {
|
|
const legacy: FindingRecord = {
|
|
...record,
|
|
id: 'purple-knight-legacy-html',
|
|
fields: [
|
|
{ index: 0, label: 'Name', value: 'Synthetic legacy indicator' },
|
|
{ index: 1, label: 'Status', value: 'IOE Found' },
|
|
{ index: 2, label: 'ResultDetail', value: '[["Object: Synthetic","Ignored: False"]]' },
|
|
{ index: 3, label: 'Framework', value: JSON.stringify([
|
|
{ Name: 'MITRE ATT&CK', Tags: ['Discovery'] },
|
|
{ Name: 'MITRE D3FEND', Tags: ['Harden'] },
|
|
{ Name: 'ANSSI', Tags: ['vuln_synthetic'] }
|
|
]) },
|
|
{ index: 4, label: 'Grade', value: 'F' },
|
|
{ index: 5, label: 'Exposure Type', value: 'IOE' }
|
|
]
|
|
};
|
|
const selected = selectFindingFields(legacy, legacy.title, true, 'purple-knight');
|
|
|
|
expect(selected.mitre.map(field => [field.label, field.value])).toEqual([
|
|
['MITRE ATT&CK_1', 'Discovery'], ['MITRE D3FEND_1', 'Harden']
|
|
]);
|
|
expect(selected.remainingFields.map(field => [field.label, field.value])).toContainEqual(['ANSSI_1', 'vuln_synthetic']);
|
|
expect([...selected.summary, ...selected.remainingFields].map(field => field.label)).not.toEqual(
|
|
expect.arrayContaining(['ResultDetail', 'Framework', 'Grade', 'Exposure Type'])
|
|
);
|
|
});
|
|
it('shows PingCastle rule points and report scores as badges while leaving score formulas readable', () => {
|
|
const scoringRecord: FindingRecord = {
|
|
...record,
|
|
fields: [
|
|
...record.fields,
|
|
{ index: 11, label: 'Points', value: '20' },
|
|
{ index: 12, label: 'Category Score', value: '85' },
|
|
{ index: 13, label: 'Domain Risk Level', value: '100' },
|
|
{ index: 14, label: 'Scoring rule', value: '20 points if present' }
|
|
]
|
|
};
|
|
|
|
const selected = selectFindingFields(scoringRecord, scoringRecord.title);
|
|
|
|
expect(selected.badges.map(field => field.label)).toEqual([
|
|
'Severity',
|
|
'Points',
|
|
'Category Score',
|
|
'Domain Risk Level'
|
|
]);
|
|
expect(selected.summary.map(field => field.label)).toContain('Scoring rule');
|
|
});
|
|
it('hides PingCastle Details from reader text and Additional Information when structured Results exist', () => {
|
|
const pingCastleRecord: FindingRecord = {
|
|
id: 'pingcastle-detail-layout',
|
|
index: 0,
|
|
title: 'Synthetic PingCastle finding',
|
|
fields: [
|
|
{ index: 0, label: 'Rule ID', value: 'S-Synthetic' },
|
|
{ index: 1, label: 'Title', value: 'Synthetic PingCastle finding' },
|
|
{ index: 2, label: 'Status', value: 'Matched' },
|
|
{ index: 3, label: 'Description', value: 'Synthetic description.' },
|
|
{ index: 4, label: 'Technical explanation', value: 'Synthetic technical explanation.' },
|
|
{ index: 5, label: 'Advised solution', value: 'Synthetic advised solution.' },
|
|
{ index: 6, label: 'Details', value: 'Readable synthetic detail.\nSecond detail line.' },
|
|
{ index: 7, label: 'Points Scale', value: '10 points' },
|
|
{ index: 8, label: 'Verwandte Daten', value: 'file-synthetic::rule-details-S-Synthetic' }
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
const selected = selectFindingFields(pingCastleRecord, pingCastleRecord.title, true, 'pingcastle');
|
|
// User decision 2026-09-25: the advised solution is the first-read Next step, not a repeated report field.
|
|
expect(selected.summary.map(field => field.label)).toEqual([
|
|
'Description',
|
|
'Technical explanation'
|
|
]);
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('Advised solution');
|
|
expect(selected.remainingFields.map(field => field.label)).toEqual(['Rule ID', 'Points Scale']);
|
|
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: pingCastleRecord,
|
|
title: pingCastleRecord.title,
|
|
linkedSheets: [makeSheet('Synthetic evidence', [['Field'], ['Value']], '', 'synthetic.html')],
|
|
resultReferences: [],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
toolId: 'pingcastle',
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(html).not.toContain('Readable synthetic detail.');
|
|
expect(html).not.toContain('<th>Details</th>');
|
|
expect(html).not.toContain('Verwandte Daten');
|
|
expect(html).not.toContain('file-synthetic::rule-details-S-Synthetic');
|
|
});
|
|
|
|
it('keeps PingCastle Details readable when no structured Results exist', () => {
|
|
const pingCastleRecord: FindingRecord = {
|
|
id: 'pingcastle-detail-fallback',
|
|
index: 0,
|
|
title: 'Synthetic PingCastle finding',
|
|
fields: [
|
|
{ index: 0, label: 'Title', value: 'Synthetic PingCastle finding' },
|
|
{ index: 1, label: 'Technical explanation', value: 'Synthetic technical explanation.' },
|
|
{ index: 2, label: 'Details', value: 'Readable fallback detail.' },
|
|
{ index: 3, label: 'Verwandte Daten', value: 'file-synthetic::rule-details-S-Synthetic' }
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
const selected = selectFindingFields(pingCastleRecord, pingCastleRecord.title, false, 'pingcastle');
|
|
expect(selected.summary.map(field => field.label)).toEqual(['Technical explanation', 'Details']);
|
|
});
|
|
// Ergebnistabellen gehoeren ausschliesslich in das schwebende Fenster. Inline
|
|
// standen sie ein zweites Mal in der Lesespalte; ausserdem bleibt so
|
|
// ausgeschlossen, dass der Reader grosse Ergebnismengen mitrendert.
|
|
it('never renders linked result tables in the reader itself', () => {
|
|
const linkedSheet = makeSheet('Results', [
|
|
['Evidence'],
|
|
['EXPENSIVE-LINKED-RESULT-CONTENT']
|
|
], '', 'results.csv');
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record,
|
|
title: record.title,
|
|
linkedSheets: [linkedSheet],
|
|
resultReferences: ['results.csv'],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(html).not.toContain('EXPENSIVE-LINKED-RESULT-CONTENT');
|
|
expect(html).not.toContain('Verknüpfte Results');
|
|
});
|
|
|
|
// Ohne geladene Ergebnisdatei ist die Results-Schaltflaeche abgeschaltet -
|
|
// dann bleibt der Hinweis die einzige Erklaerung fuer die Referenz.
|
|
it('uses the matching Result-Fensterfarbe for an open Results button', () => {
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record,
|
|
title: record.title,
|
|
linkedSheets: [],
|
|
resultReferences: [],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined,
|
|
onResults: () => undefined,
|
|
resultsOpen: true,
|
|
resultColor: '#059669'
|
|
}));
|
|
expect(html).toContain('results-btn is-open');
|
|
expect(html).toContain('--result-color:#059669');
|
|
});
|
|
|
|
it('keeps the hint when a reference exists but its result file is missing', () => {
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record,
|
|
title: record.title,
|
|
linkedSheets: [],
|
|
resultReferences: ['results.csv'],
|
|
search: '',
|
|
position: 0,
|
|
recordCount: 1,
|
|
onPrevious: () => undefined,
|
|
onNext: () => undefined,
|
|
onReview: () => undefined
|
|
}));
|
|
|
|
expect(html).toContain('Results (reference found)');
|
|
});
|
|
|
|
// Maester legt seine Testergebnismeldung selbst in die Spalte "Result".
|
|
// Anders als bei Purple Knight (kurzes Statuswort) ist das hier bereits der
|
|
// vollstaendige Befund - er gehoert deshalb in das Results-Fenster (siehe
|
|
// `resultMessageFor`/`FloatingResultsWindow`), nicht ein zweites Mal in den
|
|
// Finding-Fliesstext. "Result" bleibt im Reader deshalb fuer jedes Werkzeug
|
|
// einheitlich ausgeblendet.
|
|
const maesterRecord: FindingRecord = {
|
|
id: 'finding-2',
|
|
index: 0,
|
|
title: 'At least one Conditional Access policy is configured to secure security info registration only from a trusted location.',
|
|
fields: [
|
|
{ index: 0, label: 'ID', value: 'MT.1011' },
|
|
{ index: 1, label: 'Title', value: 'At least one Conditional Access policy is configured to secure security info registration only from a trusted location.' },
|
|
{ index: 2, label: 'Severity', value: 'High' },
|
|
{ index: 3, label: 'Status', value: 'Failed' },
|
|
{ index: 4, label: 'Category', value: 'Maester/Entra' },
|
|
{ index: 5, label: 'Description', value: 'Checks if the tenant has at least one conditional access policy securing security info registration.' },
|
|
{ index: 6, label: 'Result', value: 'No conditional access policy securing security info registration.' },
|
|
{ index: 7, label: 'Tags', value: 'Maester, CA, Security, MT.1011' }
|
|
],
|
|
filled: [],
|
|
meta: []
|
|
};
|
|
|
|
it('keeps the Maester "Result" field out of the finding text and additional information', () => {
|
|
const selected = selectFindingFields(maesterRecord, maesterRecord.title);
|
|
|
|
expect(selected.summary.map(field => field.label)).not.toContain('Result');
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('Result');
|
|
});
|
|
|
|
it('shows a Maester ResultDetail table only after opening Results', () => {
|
|
const resultDetail = 'Your tenant does not cover all recommended file attachment extensions:\n\n| Extension name | Result |\n| --- | --- |\n| 7z | Fail |';
|
|
const attachmentRecord: FindingRecord = {
|
|
...maesterRecord,
|
|
title: 'Ensure comprehensive attachment filtering is applied',
|
|
fields: [
|
|
{ index: 0, label: 'Title', value: 'Ensure comprehensive attachment filtering is applied' },
|
|
{ index: 1, label: 'Status', value: 'Failed' },
|
|
{ index: 2, label: 'ResultDetail', value: resultDetail },
|
|
{ index: 3, label: 'Source Rule ID', value: 'CIS.M365.2.1.11' }
|
|
]
|
|
};
|
|
const reader = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: attachmentRecord, title: attachmentRecord.title, toolId: 'maester', linkedSheets: [], resultReferences: [], search: '',
|
|
position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {}, onResults() {}
|
|
}));
|
|
const results = renderToStaticMarkup(createElement(LinkedResults, {
|
|
sheets: [], sections: resultSectionsFor(attachmentRecord, 'maester')
|
|
}));
|
|
|
|
// User decision 2026-09-28: the statement is the problem; the table stays in Results only.
|
|
expect(reader).toContain('<dt>Result</dt><dd><p>Your tenant does not cover all recommended file attachment extensions:</p></dd>');
|
|
expect(reader).not.toContain('<h4>Test result</h4>');
|
|
expect(reader).not.toContain('Extension name');
|
|
expect(reader).toContain('CIS.M365.2.1.11');
|
|
expect(results).toContain('Your tenant does not cover all recommended');
|
|
expect(results).toContain('Extension name');
|
|
expect(results).toContain('<table');
|
|
});
|
|
|
|
it('shows the canonical Source Rule ID once in the Work reader', () => {
|
|
const record: FindingRecord = {
|
|
...maesterRecord,
|
|
fields: maesterRecord.fields.map(field => field.label === 'ID'
|
|
? { ...field, label: 'Source Rule ID' }
|
|
: field)
|
|
};
|
|
const selected = selectFindingFields(record, record.title, false, 'maester');
|
|
expect(selected.sourceRuleId?.value).toBe('MT.1011');
|
|
expect(selected.summary.map(field => field.label)).not.toContain('ID');
|
|
expect(selected.remainingFields.map(field => field.label)).not.toContain('Source Rule ID');
|
|
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record, title: record.title, toolId: 'maester', linkedSheets: [], resultReferences: [], search: '',
|
|
position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {}
|
|
}));
|
|
expect(html).toContain('data-source-rule-id="true"');
|
|
expect(html).toContain('<h4>Source Rule ID</h4><p><code class="mono">MT.1011</code>');
|
|
});
|
|
|
|
it('presents the retained Maester fields in the native report hierarchy without duplicating the ID in the title', () => {
|
|
const sourcePath = 'C:\\Repo\\Customer\\Maester\\tests\\cisa\\exchange\\Test-MtCisaAuditLogRetention.Tests.ps1';
|
|
const cisaRecord: FindingRecord = {
|
|
id: 'maester-cisa-exo-17-3', index: 0, filled: [], meta: [],
|
|
title: 'CISA.MS.EXO.17.3: Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).',
|
|
fields: [
|
|
{ index: 0, label: 'ID', value: 'CISA.MS.EXO.17.3' },
|
|
{ index: 1, label: 'Title', value: 'Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).' },
|
|
{ index: 2, label: 'Result', value: 'Failed' },
|
|
{ index: 3, label: 'Severity', value: 'Medium' },
|
|
{ index: 4, label: 'Tag', value: '["MS.EXO","MS.EXO.17.3","CISA.MS.EXO.17.3","CISA","Security","All"]' },
|
|
{ index: 5, label: 'Block', value: 'CISA' },
|
|
{ index: 6, label: 'Duration', value: '00:00:00' },
|
|
{ index: 7, label: 'ErrorMessage', value: '["Synthetic execution detail"]' },
|
|
{ index: 8, label: 'Name', value: 'CISA.MS.EXO.17.3: Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).' },
|
|
{ index: 9, label: 'TestScriptFile', value: sourcePath },
|
|
{ index: 10, label: 'TestScript', value: '$result = Test-MtCisaAuditLogRetention\n\nif ($null -ne $result) {\n $result | Should -Be $true -Because "enabled."\n}' },
|
|
{ index: 11, label: 'Index', value: '63' }
|
|
]
|
|
};
|
|
const title = maesterFindingTitle(cisaRecord, cisaRecord.title);
|
|
const selected = selectFindingFields(cisaRecord, title, false, 'maester');
|
|
|
|
expect(title).toBe('Audit logs SHALL be maintained for at least the minimum duration dictated by OMB M-21-31 (Appendix C).');
|
|
expect(selected.summary.map(field => field.label)).toEqual(['ID', 'Category', 'Tags', 'Source']);
|
|
expect(selected.summary.find(field => field.label === 'ID')?.value).toBe('CISA.MS.EXO.17.3');
|
|
expect(selected.summary.find(field => field.label === 'Tags')?.value).toBe('MS.EXO · MS.EXO.17.3 · CISA.MS.EXO.17.3 · CISA · Security · All');
|
|
expect(selected.optionalFields).toContainEqual(expect.objectContaining({ label: 'Technical execution detail', value: 'Synthetic execution detail' }));
|
|
expect(selected.remainingFields.map(field => field.label)).not.toEqual(expect.arrayContaining([
|
|
'ID', 'Block', 'Duration', 'ErrorMessage', 'Name', 'TestScriptFile', 'TestScript', 'Index'
|
|
]));
|
|
|
|
const html = renderToStaticMarkup(createElement(FindingReader, {
|
|
record: cisaRecord, title, toolId: 'maester', linkedSheets: [], resultReferences: ['Failed'], search: '',
|
|
position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {}
|
|
}));
|
|
expect(html).toContain('<h2>Audit logs SHALL be maintained');
|
|
expect(html).not.toContain('<h2>CISA.MS.EXO.17.3:');
|
|
expect(html).toContain('<h4>ID</h4><p><code class="mono">CISA.MS.EXO.17.3</code></p>');
|
|
expect(html).not.toContain('<h4>Test result</h4>');
|
|
expect(html).toContain('Test details</summary>');
|
|
expect(html).not.toContain('<h4>Test details</h4>');
|
|
expect(html).toContain('class="rich-text-code"');
|
|
expect(html).toContain('<h4>Category</h4><p>CISA</p>');
|
|
expect(html).toContain('<h4>Tags</h4>');
|
|
expect(html).toContain('<h4>Source</h4>');
|
|
expect(html).toContain('Technical execution detail</summary>');
|
|
expect(html).not.toContain('Results (reference found)');
|
|
});
|
|
});
|