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, selectFindingFields } from '../../src/features/finding-reader/FindingReader'; 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('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('renders NVD Affected Configurations last in the normal Work finding summary', () => { const enrichedRecord: FindingRecord = { ...record, fields: [ ...record.fields, { index: 11, label: 'NVD Affected Configurations', value: '| CPE | Vulnerable |\n| --- | --- |\n| synthetic | Yes |' }, { index: 12, label: 'NVD Metrics', value: '| Score |\n| --- |\n| 4.3 |' } ] }; const selected = selectFindingFields(enrichedRecord, record.title); expect(selected.summary.at(-1)?.label).toBe('NVD Affected Configurations'); expect(selected.remainingFields.map(field => field.label)).not.toContain('NVD Affected Configurations'); const html = renderToStaticMarkup(createElement(FindingReader, { record: enrichedRecord, title: enrichedRecord.title, linkedSheets: [], resultReferences: [], search: '', position: 0, recordCount: 1, onPrevious: () => undefined, onNext: () => undefined, onReview: () => undefined })); expect(html.indexOf('NVD Affected Configurations')).toBeGreaterThan(html.indexOf('NVD Metrics')); }); 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\nfail\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('fail'); 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(''); expect(html).toContain('data-derived-category="cis">CIS 1'); expect(html).toContain('data-derived-category="cis">CIS 1.1'); 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\nfail\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('fail'); 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(''); 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('Failed'); expect(html).not.toContain('data-outcome="unknown">SI000067'); }); 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'); expect(selected.summary.map(field => field.label)).toEqual([ 'Description', 'Technical explanation', '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('Details'); 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'); }); });