Assessor_2.0/tests/shared/markdown.test.ts

369 lines
17 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { cleanLineBreaks, extractMarkdownTables, hasMarkdown, parseInline, parseInlineTable, parseMarkdownBlocks } from '../../src/shared/markdown';
import { inlineResultTableSheets } from '../../src/features/linked-results/inline-result-tables';
import type { FindingRecord } from '../../src/domain/finding';
import { buildRecord } from '../../src/analysis/record-builder';
import { cellText } from '../../src/shared/text';
function recordWith(fields: Array<[string, string]>): FindingRecord {
const built = fields.map(([label, value], index) => ({ label, value, index }));
return { id: 'record', index: 0, title: '', fields: built, filled: built, meta: [] };
}
/** Synthetische Nachbildung der Form, in der Werkzeuge ihre Texte liefern. */
const RESULT_DETAIL = [
'The following applications have credentials expiring soon:',
'',
'| App Name | Credential Type | Expires In |',
'| --- | --- | --- |',
'| Backup Service | Client Secret | 15 days |',
'| API Gateway | Certificate | 22 days |'
].join('\n');
const REMEDIATION = [
'#### Remediation action:',
'',
'1. Navigate to the admin center [Portal](https://example.invalid/admin).',
'2. Select **Active users**.',
'',
'#### Related links',
'',
'* [Benchmark](https://example.invalid/benchmark)'
].join('\n');
describe('Markdown-Erkennung', () => {
it('erkennt Ueberschriften, Auszeichnung, Verweise, Listen und Tabellen', () => {
expect(hasMarkdown('#### Remediation action:')).toBe(true);
expect(hasMarkdown('Select **Active users**.')).toBe(true);
expect(hasMarkdown('See [Portal](https://example.invalid/a).')).toBe(true);
expect(hasMarkdown('* erster Punkt')).toBe(true);
expect(hasMarkdown(RESULT_DETAIL)).toBe(true);
});
it('haelt gewoehnlichen Fliesstext unangetastet', () => {
expect(hasMarkdown('Die Richtlinie deckt nicht alle Benutzer ab.')).toBe(false);
expect(hasMarkdown('Gefunden: 5 Konten mit Kennwort ohne Ablauf.')).toBe(false);
expect(hasMarkdown('')).toBe(false);
});
});
describe('Bereinigung von Zeilenumbrüchen und Satzzeichen', () => {
it('verbindet frei stehende Satzzeichen mit der vorherigen Zeile', () => {
expect(cleanLineBreaks('ENTERPRISE DOMAIN CONTROLLERS\n\n.')).toBe('ENTERPRISE DOMAIN CONTROLLERS.');
expect(cleanLineBreaks('ENTERPRISE DOMAIN CONTROLLERS\n\n:')).toBe('ENTERPRISE DOMAIN CONTROLLERS:');
});
it('verbindet Umbrüche mitten im Satz vor Kleinbuchstaben', () => {
expect(cleanLineBreaks('The recommended state for this setting\n\non Domain Controllers')).toBe('The recommended state for this setting on Domain Controllers');
expect(cleanLineBreaks('Access this computer from the network\n\nuser right on Domain Controllers')).toBe('Access this computer from the network user right on Domain Controllers');
});
});
describe('Blockauswertung', () => {
it('trennt Tabelle und einleitenden Satz', () => {
const blocks = parseMarkdownBlocks(RESULT_DETAIL);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'table']);
const table = blocks[1] as Extract<(typeof blocks)[number], { kind: 'table' }>;
expect(table.table.headers).toEqual(['App Name', 'Credential Type', 'Expires In']);
expect(table.table.rows).toEqual([
['Backup Service', 'Client Secret', '15 days'],
['API Gateway', 'Certificate', '22 days']
]);
});
it('erkennt Ueberschriften sowie nummerierte und ungeordnete Listen getrennt', () => {
const blocks = parseMarkdownBlocks(REMEDIATION);
expect(blocks.map(block => block.kind)).toEqual(['heading', 'list', 'heading', 'list']);
const ordered = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(ordered.ordered).toBe(true);
expect(ordered.items).toHaveLength(2);
const unordered = blocks[3] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(unordered.ordered).toBe(false);
});
it('zerlegt Fliesstext nicht an Ziffern mitten im Satz', () => {
const blocks = parseMarkdownBlocks('Die Regel gilt seit 2019. Sie betrifft 3 Konten.');
expect(blocks.map(block => block.kind)).toEqual(['paragraph']);
});
});
describe('Inline-Auszeichnung', () => {
it('loest Fett, Kursiv, Code und Verweise auf', () => {
expect(parseInline('Select **Active users** now')).toEqual([
{ kind: 'text', text: 'Select ' },
{ kind: 'strong', children: [{ kind: 'text', text: 'Active users' }] },
{ kind: 'text', text: ' now' }
]);
expect(parseInline('[Portal](https://example.invalid/a)')).toEqual([
{ kind: 'link', href: 'https://example.invalid/a', children: [{ kind: 'text', text: 'Portal' }] }
]);
expect(parseInline('Wert `Get-Item` pruefen')[1]).toEqual({ kind: 'code', text: 'Get-Item' });
});
it('liest doppelte Sternchen als Fett, nicht als zwei kursive Teile', () => {
expect(parseInline('**Next**')).toEqual([{ kind: 'strong', children: [{ kind: 'text', text: 'Next' }] }]);
});
/**
* Regression: ohne Rekursion stand die innere Auszeichnung woertlich im
* fetten Text - in der laufenden Anwendung waren so 16 Markdown-Reste
* innerhalb bereits gerenderter strong-, em- und a-Elemente sichtbar.
*/
it('loest verschachtelte Auszeichnung vollstaendig auf', () => {
expect(parseInline('**[Portal](https://example.invalid/a)**')).toEqual([
{ kind: 'strong', children: [
{ kind: 'link', href: 'https://example.invalid/a', children: [{ kind: 'text', text: 'Portal' }] }
] }
]);
expect(parseInline('[**Portal**](https://example.invalid/a)')).toEqual([
{ kind: 'link', href: 'https://example.invalid/a', children: [
{ kind: 'strong', children: [{ kind: 'text', text: 'Portal' }] }
] }
]);
});
it('gibt unausgezeichneten Text unveraendert zurueck', () => {
expect(parseInline('nur Text')).toEqual([{ kind: 'text', text: 'nur Text' }]);
});
});
describe('Einzeilige Markdown-Tabellen', () => {
/**
* Maester exportiert seine Ergebnistabellen ohne Zeilenumbrueche. In der
* Datenbank enthaelt kein einziges Maester-Feld ein Zeilenende - die Tabelle
* steht als eine Pipe-Kette in der Zelle.
*/
const FLAT = 'Investigate. The following applications have credentials expiring soon: '
+ '| App Name | Credential Type | Expires In | | --- | --- | --- | '
+ '| Backup Service | Client Secret | 15 days | | API Gateway | Certificate | 22 days |';
it('erkennt die Tabelle trotz fehlender Zeilenumbrueche', () => {
expect(hasMarkdown(FLAT)).toBe(true);
const blocks = parseMarkdownBlocks(FLAT);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'table']);
const table = blocks[1] as Extract<(typeof blocks)[number], { kind: 'table' }>;
expect(table.table.headers).toEqual(['App Name', 'Credential Type', 'Expires In']);
expect(table.table.rows).toEqual([
['Backup Service', 'Client Secret', '15 days'],
['API Gateway', 'Certificate', '22 days']
]);
});
it('behauptet keine Tabelle, wenn die Zellenzahl nicht aufgeht', () => {
const ragged = '| A | B | | --- | --- | | 1 | 2 | 3 |';
expect(parseInlineTable(ragged)).toBeNull();
});
it('trennt eine Ueberschrift ab, die mitten in der Zeile steht', () => {
const flat = 'Konten sollen getrennt sein. #### Remediation action: Neues Konto anlegen.';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'paragraph']);
});
it('loest eine vollstaendig einzeilige Massnahmenbeschreibung auf', () => {
const flat = '1.1.1 (L1) Ensure accounts are cloud-only. #### Remediation action: '
+ 'Create separate accounts. #### Related links * [Admin Center](https://example.invalid/a) '
+ '* [Benchmark](https://example.invalid/b)';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'paragraph', 'heading', 'list']);
const links = blocks[4] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(links.items).toHaveLength(2);
expect(links.items[0][0]).toEqual({
kind: 'link',
href: 'https://example.invalid/a',
children: [{ kind: 'text', text: 'Admin Center' }]
});
});
it('zerschneidet keine Versionsnummer am Zeilenanfang', () => {
const blocks = parseMarkdownBlocks('1.1.1 (L1) Ensure administrative accounts are cloud-only.');
expect(blocks).toHaveLength(1);
expect(blocks[0].kind).toBe('paragraph');
});
/**
* Regression: Maester/CIS-CAT liefern nummerierte Massnahmenschritte
* einzeilig ohne echte Zeilenumbrueche. Ohne Erkennung einer echten,
* lueckenlos ab 1 aufsteigenden Folge blieb die gesamte Anleitung
* ununterscheidbar von Fliesstext.
*/
it('erkennt eine eingebettete, ab 1 aufsteigende nummerierte Liste ohne echte Zeilenumbrueche', () => {
const flat = 'To correct the number of global tenant administrators: '
+ '1. Navigate to the admin center [Portal](https://example.invalid/admin). '
+ '2. Select **Active users**. 3. Search for the user. 4. Save changes.';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']);
const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(list.ordered).toBe(true);
expect(list.items).toHaveLength(4);
});
it('behandelt eine einzelne Ziffer mit Punkt weiterhin als Fliesstext, wenn keine Folge vorliegt', () => {
const flat = 'Findings reference section 3. The report explains the rationale in detail.';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph']);
});
/**
* Regression: CISA ScubaGear verschachtelt eine ungeordnete Liste in einen
* nummerierten Schritt, ohne echten Zeilenumbruch. Ohne Erkennung
* verschwanden die Unterpunkte im Fliesstext des vorangehenden Schritts.
*/
it('erkennt eine verschachtelte Aufzaehlung ohne echten Zeilenumbruch', () => {
const flat = 'Configure the following policy settings, per the values below:'
+ ' * Users > Include > Select users and groups'
+ ' * Target resources > Cloud apps > All cloud apps'
+ ' * Access controls > Grant > Require MFA';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']);
const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(list.ordered).toBe(false);
expect(list.items).toHaveLength(3);
});
/**
* Regression: `cellText` faltet jeden Leerraum innerhalb einer Zeile zu einem
* einzigen Leerzeichen. Die urspruengliche Markdown-Einrueckung vor dem
* Aufzaehlungszeichen ist in der Anzeige damit verloren - eine Erkennung, die
* sich auf Einrueckung stuetzt, greift dort nie. Unterscheidungsmerkmal ist
* deshalb die Wiederholung, wie bei den nummerierten Listen.
*/
it('erkennt eine Aufzaehlung auch nach der Leerraumfaltung von cellText', () => {
const folded = cellText('It is recommended to use least privileged roles.'
+ ' - Damien Bowden (damien.bowden@example.invalid)'
+ ' - Ravi Kalwani (ravi.kalwani@example.invalid)'
+ ' - Gael Colas (gael@example.invalid)');
expect(folded).not.toMatch(/ {2}/);
const blocks = parseMarkdownBlocks(folded);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'list']);
const list = blocks[1] as Extract<(typeof blocks)[number], { kind: 'list' }>;
expect(list.items).toHaveLength(3);
});
it('haelt einen einzelnen Gedankenstrich im Fliesstext unangetastet', () => {
// Erst ab zwei Markierungen entsteht eine Liste; ein einzelner Strich ist
// in Ergebnistexten regelmaessig Teil eines Satzes ("Ratio >= 1 - True").
const blocks = parseMarkdownBlocks('Current Ratio: 3.6 = 18 / 5 Ratio >= 1 - True');
expect(blocks).toHaveLength(1);
expect(blocks[0].kind).toBe('paragraph');
});
it('zaehlt einen Gedankenstrich innerhalb eines Verweistitels nicht als Aufzaehlung', () => {
const flat = 'Open [Recommendation - Require MFA](https://example.invalid/a) '
+ 'and [Guidance - Review roles](https://example.invalid/b) in the admin portal.';
const blocks = parseMarkdownBlocks(flat);
expect(blocks).toHaveLength(1);
expect(blocks[0].kind).toBe('paragraph');
});
/**
* Regression: Hinter der letzten Datenzeile folgt bei Maester regelmaessig
* noch ein Hinweissatz. Weil die Zellenzahl dadurch kein Vielfaches der
* Spaltenzahl mehr war, wurde die gesamte Tabelle verworfen und landete als
* Pipe-Kette in der Oberflaeche.
*/
it('erkennt eine einzeilige Tabelle mit nachfolgendem Fliesstext', () => {
const flat = '| Status | Name | | --- | --- | | Fail | [tyler@example.invalid]() | '
+ '| Fail | [ann@example.invalid]() | Open [Recommendation](https://example.invalid/a) in the portal.';
const parsed = parseInlineTable(flat);
expect(parsed?.table.headers).toEqual(['Status', 'Name']);
expect(parsed?.table.rows).toHaveLength(2);
expect(parsed?.after).toContain('in the portal.');
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['table', 'paragraph']);
});
/**
* Regression: Eine Ueberschrift laeuft bis zum Zeilenende. Stand die Tabelle
* in derselben kollabierten Zeile, verschluckte die Ueberschrift den
* gesamten Ergebnisinhalt und er verschwand aus der Anzeige.
*/
it('trennt eine Tabelle von der Ueberschrift, die in derselben Zeile steht', () => {
const flat = 'You have 3 of 16 users without MFA. #### Impacted resources '
+ '| Status | Name | | --- | --- | | Fail | tyler@example.invalid | | Fail | ann@example.invalid |';
const blocks = parseMarkdownBlocks(flat);
expect(blocks.map(block => block.kind)).toEqual(['paragraph', 'heading', 'table']);
const heading = blocks[1] as Extract<(typeof blocks)[number], { kind: 'heading' }>;
// Die erste Kopfzelle darf nicht in der Ueberschrift landen.
expect(heading.content).toEqual([{ kind: 'text', text: 'Impacted resources' }]);
const table = blocks[2] as Extract<(typeof blocks)[number], { kind: 'table' }>;
expect(table.table.headers).toEqual(['Status', 'Name']);
expect(table.table.rows).toHaveLength(2);
});
it('trennt den Vortext vollstaendig von der Kopfzeile', () => {
const parsed = parseInlineTable('Some intro sentence: | App | Owner | | --- | --- | | A | B |');
expect(parsed?.before).toBe('Some intro sentence:');
expect(parsed?.table.headers).toEqual(['App', 'Owner']);
});
});
describe('Zeilenstruktur der Zellwerte', () => {
/**
* Regression: `buildRecord` hat den Anzeigewert mit `clean` gefaltet und dabei
* jeden Zeilenumbruch entfernt. Damit war eine Markdown-Tabelle nicht mehr von
* einem Satz zu unterscheiden, und `####` stand woertlich in der Oberflaeche.
*/
it('behaelt Zeilenumbrueche im Feldwert, faltet aber Leerraum innerhalb der Zeile', () => {
const record = buildRecord({
row: ['Ein Satz.\r\n\r\n| A | B |\r\n| --- | --- |\r\n| 1 | 2 |'],
headers: ['ResultDetail'],
index: 0,
sheetName: 'blatt'
});
expect(record.fields[0].value).toContain('\n');
expect(extractMarkdownTables(record.fields[0].value)).toHaveLength(1);
});
it('haelt den Titel einzeilig, damit Kachel und Kartenliste nicht umbrechen', () => {
const record = buildRecord({
row: ['Erste Zeile\nZweite Zeile'],
headers: ['Title'],
index: 0,
sheetName: 'blatt'
});
expect(record.title).toBe('Erste Zeile Zweite Zeile');
});
it('faltet mehrfachen Leerraum innerhalb einer Zeile', () => {
expect(cellText('a b\t\tc')).toBe('a b c');
expect(cellText(' a \n b ')).toBe('a\nb');
});
});
describe('Ergebnistabellen aus Textfeldern', () => {
it('macht eine Tabelle im Feld ResultDetail als Blatt verfuegbar', () => {
const sheets = inlineResultTableSheets(recordWith([['ResultDetail', RESULT_DETAIL]]));
expect(sheets).toHaveLength(1);
expect(sheets[0].name).toBe('ResultDetail');
expect(sheets[0].rows[0]).toEqual(['App Name', 'Credential Type', 'Expires In']);
expect(sheets[0].analysis.records).toHaveLength(2);
});
it('macht altes PurpleKnight-ResultDetail-JSON als Results-Tabelle verfuegbar', () => {
const value = JSON.stringify([
['DistinguishedName: CN=Synthetic,DC=example,DC=test', 'MinLength: 14', 'Ignored: False'],
['DistinguishedName: CN=Second,DC=example,DC=test', 'MinLength: 16', 'Ignored: True']
]);
const sheets = inlineResultTableSheets(recordWith([['ResultDetail', value]]));
expect(sheets).toHaveLength(1);
expect(sheets[0].rows).toEqual([
['DistinguishedName', 'MinLength', 'Ignored'],
['CN=Synthetic,DC=example,DC=test', '14', 'False'],
['CN=Second,DC=example,DC=test', '16', 'True']
]);
});
it('erzeugt kein Blatt fuer Felder ohne Tabelle', () => {
expect(inlineResultTableSheets(recordWith([['ResultDetail', 'Keine Auffaelligkeiten.']]))).toEqual([]);
expect(inlineResultTableSheets(recordWith([['Description', RESULT_DETAIL]]))).toEqual([]);
});
it('ignoriert eine Tabelle ohne Datenzeilen', () => {
const empty = '| A | B |\n| --- | --- |';
expect(extractMarkdownTables(empty)).toEqual([]);
});
});