377 lines
16 KiB
TypeScript
377 lines
16 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, 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('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 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('<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');
|
|
});
|
|
});
|