Assessor_2.0/tests/exports/work-view-xlsx.test.ts

160 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import type { ImportedDataset } from '../../src/domain/dataset';
import { defaultReview, type Review } from '../../src/domain/review';
import { buildWorkViewExport } from '../../src/exports/work-view-export';
import { renderWorkViewXlsx } from '../../src/exports/work-view-xlsx';
import { makeSheet } from '../../src/importers/sheet-factory';
/** Synthetic mixed Work sheet; `Source: Scan` assigns each record to its tool as in the project sheet. */
function mixed(rows: Array<Record<string, string>>, extraSheets: ImportedDataset['sheets'] = []) {
const labels = [...new Set(rows.flatMap(row => Object.keys(row)))];
const sheet = makeSheet('All Records', [labels, ...rows.map(row => labels.map(label => row[label] ?? ''))], '', 'synthetic.html', { isProjectSheet: true, toolId: 'generic' });
const dataset = { fileName: 'Synthetic', profileId: 'generic', sheets: [sheet, ...extraSheets] } as ImportedDataset;
return { dataset, sheet, records: sheet.analysis.records };
}
function workbook(input: ReturnType<typeof mixed>, options: { isDeviation?: (title: string) => boolean; reviews?: Record<string, Review> } = {}) {
const report = buildWorkViewExport({ ...input, generatedAt: '2026-09-24T12:00:00.000Z', reviews: options.reviews,
isDeviation: options.isDeviation && (record => options.isDeviation!(record.title)) });
const bytes = renderWorkViewXlsx(report);
const book = XLSX.read(bytes, { type: 'array', cellFormula: true });
const rows = (name: string) => XLSX.utils.sheet_to_json<string[]>(book.Sheets[name], { header: 1, raw: true, defval: '' });
/** Finding rows of a tool sheet as header → value objects. */
const findings = (name: string) => { const [header, ...body] = rows(name); return body.map(row => Object.fromEntries(header.map((key, index) => [key, row[index]]))); };
const zip = XLSX.CFB.read(bytes, { type: 'array' });
const part = (path: string) => new TextDecoder().decode(XLSX.CFB.find(zip, path).content);
return { report, book, rows, findings, part };
}
describe('Work Excel finding list', () => {
it('writes an overview and one sheet per tool whose columns follow the finding reader', () => {
const input = mixed([
{ Title: 'Synthetic PC finding', Status: 'Matched', Severity: 'High', Category: 'Anomalies', Description: 'Synthetic description',
'Technical explanation': 'Synthetic explanation', 'Advised solution': 'Synthetic fix', 'Source Rule ID': 'A-Synthetic', 'Source: Scan': 'PingCastle · 2026-09-01' },
{ Title: 'Synthetic Maester test', Status: 'Failed', Severity: 'Medium', Description: 'Synthetic Maester description', 'Source: Scan': 'Maester · 2026-09-01' },
{ Title: 'Synthetic PC second', Status: 'Matched', Severity: 'Low', Description: 'Second description', 'Source: Scan': 'PingCastle · 2026-09-01' }
]);
const { book, rows, findings } = workbook(input, { isDeviation: title => title !== 'Synthetic PC second' });
expect(book.SheetNames).toEqual(['Übersicht', 'PingCastle', 'Maester']);
const header = rows('PingCastle')[0];
expect(header.slice(0, 6)).toEqual(['Nr', 'Finding', 'Status', 'Severity', 'Badges', 'Source Rule ID']);
// Reader order for PingCastle: Description, Technical explanation, Advised solution.
expect(header.indexOf('Description')).toBeLessThan(header.indexOf('Technical explanation'));
expect(header.indexOf('Technical explanation')).toBeLessThan(header.indexOf('Advised solution'));
expect(header.slice(-3)).toEqual(['Results', 'Additional Information', 'Record ID']);
expect(header).not.toContain('Soll-Zustand');
expect(header).not.toContain('Ist-Zustand / Abweichung');
const [first, second] = findings('PingCastle');
expect(first).toMatchObject({ Nr: '1', Finding: 'Synthetic PC finding', Status: 'Matched', Severity: 'High', Badges: 'Category: Anomalies',
'Source Rule ID': 'A-Synthetic', Description: 'Synthetic description', 'Technical explanation': 'Synthetic explanation', 'Advised solution': 'Synthetic fix' });
expect(second).toMatchObject({ Nr: '2', Finding: 'Synthetic PC second', Description: 'Second description', 'Advised solution': '' });
expect(findings('Maester').map(row => row.Finding)).toEqual(['Synthetic Maester test']);
const overview = rows('Übersicht').map(row => row.join('|')).join('\n');
expect(overview).toContain('Treffer|3');
expect(overview).toContain('PingCastle|2|1');
expect(overview).toContain('Maester|1|1');
});
it('puts the Results window content and the review into their own columns', () => {
const evidence = makeSheet('Synthetic evidence', [['Host', 'State'], ['Synthetic host A', '0'], ['Synthetic host B', 'false']], '', 'synthetic.html',
{ sourceFile: 'synthetic.html', sourceTableIndex: 42, toolId: 'cis-cat' });
const input = mixed([{ Title: 'Synthetic rule', Status: 'Fail', Description: 'Synthetic CIS description', Results: 'source-table:42 "Synthetic evidence"', 'Source: Scan': 'CIS-CAT Pro · 2026-09-01' }], [evidence]);
input.records[0].sourceFile = 'synthetic.html';
const { findings } = workbook(input, { reviews: { [input.records[0].id]: { ...defaultReview(), status: 'Done', owner: 'Synthetic team', markerQuickwin: true } } });
const [finding] = findings('CIS-CAT Pro');
expect(finding.Results).toContain('Synthetic evidence');
expect(finding.Results).toContain('Host: Synthetic host A\nState: 0');
expect(finding.Results).toContain('State: false');
expect(finding.Review).toBe('Status: Done\nPriority: Medium\nOwner / Team: Synthetic team\nMarkers: Quick Win');
expect(finding.Status).toBe('Fail'); // a done review never changes the result status
});
it('shows GPO findings like the reader: customer and baseline values, sources, neutral note and scope boundary', () => {
const input = mixed([
{ Title: 'Synthetic policy', Status: 'Deviation', Category: 'GPOs', 'Configured Value': '1', 'Baseline value': '0', Description: 'Synthetic policy description', 'Source: Scan': 'GPOs · 2026-09-01' },
{ Title: 'Synthetic absent policy', Status: 'Not configured', Category: 'GPOs', 'Baseline value': '1', 'Source: Scan': 'GPOs · 2026-09-01' }
]);
input.records[0].policySourceEvidence = [{ id: 'synthetic-observation', backupKey: 'synthetic-backup', sourceReference: 'Synthetic/registry.pol', sequence: 1,
values: [{ kind: 'registry-policy', operation: 'set-value', key: 'Software\\Synthetic', valueName: 'Value', data: 1, typeCode: 4 }] }];
const { book, findings, rows } = workbook(input);
expect(book.SheetNames).toEqual(['Übersicht', 'GPOs']);
const [configured, absent] = findings('GPOs');
expect(configured['GPO sources']).toContain('Synthetic/registry.pol');
expect(configured['Expected baseline value']).toBeTruthy();
expect(configured.Description).toBe('Synthetic policy description');
expect(configured.Hinweise).toBe('');
expect(absent.Hinweise).toContain('Score-neutral');
expect(rows('Übersicht').flat().join('\n')).toContain('kein Nachweis der wirksamen Endpoint-Konfiguration');
});
it('formats headers, deviations, wrapping, filters and frozen identity columns', () => {
const { part, rows } = workbook(mixed([
{ Title: 'Synthetic deviation', Status: 'Matched', Description: 'x', 'Source: Scan': 'PingCastle · 2026-09-01' },
{ Title: 'Synthetic passed', Status: 'Not matched', Description: 'y', 'Source: Scan': 'PingCastle · 2026-09-01' }
]), { isDeviation: title => title === 'Synthetic deviation' });
const last = String.fromCharCode(64 + rows('PingCastle')[0].length);
const sheet = part('/xl/worksheets/sheet2.xml');
expect(part('/xl/styles.xml')).toContain('<alignment vertical="top" wrapText="1"/>');
expect(sheet).toContain('<pane xSplit="2" ySplit="1" topLeftCell="C2" activePane="bottomRight" state="frozen"/>');
expect(sheet).toContain(`<autoFilter ref="A1:${last}3"/>`);
expect(sheet).toMatch(/<c r="A1" s="1"/); // header style
expect(sheet).toMatch(/<c r="C2" s="2"/); // deviation status highlighted
expect(sheet).toMatch(/<c r="C3" s="0"/); // non-deviation status neutral
expect(part('/xl/workbook.xml')).toContain(`<definedName name="_xlnm._FilterDatabase" localSheetId="1" hidden="1">'PingCastle'!$A$1:$${last}$3</definedName>`);
});
it('keeps formula-like and control-character source text literal and exact', () => {
const input = mixed([{ Title: '=HYPERLINK("https://example.invalid","x")', Status: 'Fail', Description: '=1+1', Target: 'a_x0041_b' }]);
input.records[0].fields.find(field => field.label === 'Target')!.value = 'a_x0041_b\u0001c';
const { book, findings, part } = workbook(input);
const sheet = book.Sheets[book.SheetNames[1]];
const cells = Object.keys(sheet).filter(key => !key.startsWith('!')).map(key => sheet[key] as XLSX.CellObject);
expect(cells.every(cell => cell.t === 's' && cell.f === undefined)).toBe(true);
const [finding] = findings(book.SheetNames[1]);
expect(finding.Finding).toBe('=HYPERLINK("https://example.invalid","x")');
expect(finding.Description).toBe('=1+1');
expect(finding['Additional Information']).toContain('Target: a_x0041_b\u0001c'); // exact source identifier
expect(part('/xl/worksheets/sheet2.xml')).not.toContain('<f>');
});
it('continues oversized cells in marked rows of the same finding without losing data', () => {
const long = 'Synthetic target =1; "value" | \\path 😀\n'.repeat(2300);
const input = mixed([{ Title: 'Synthetic long', Status: 'Fail', Target: 'x', 'Source: Scan': 'PingCastle · 2026-09-01' }]);
input.records[0].fields.find(field => field.label === 'Target')!.value = long;
const { findings } = workbook(input);
const list = findings('PingCastle');
expect(list.length).toBeGreaterThan(2);
expect(list.every(row => row.Nr === '1' && row.Status === 'Fail' && row['Record ID'] === list[0]['Record ID'])).toBe(true);
expect(list.every((row, index) => row.Finding === `Synthetic long\nTeil ${index + 1}/${list.length} · Fortsetzungszeile desselben Findings`)).toBe(true);
expect(list.flatMap(row => Object.values(row)).every(cell => cell.length < 32767)).toBe(true);
expect(list.map(row => row['Additional Information']).join('')).toContain(long); // exact, no Markdown rendering
});
it('names the sheet of an unrecognized tool after its profile', () => {
const { book } = workbook(mixed([{ Title: 'Synthetic', Status: 'Fail' }]));
expect(book.SheetNames).toEqual(['Übersicht', 'Tabular Data']);
});
it('names Purple Knight runs of the mixed project sheet after their tool, not the generic profile', () => {
const { book } = workbook(mixed([
{ Title: 'Synthetic AD indicator', Status: 'IOE Found', 'Source: Scan': 'Purple Knight for Active Directory · 2026-09-01' },
{ Title: 'Synthetic Entra indicator', Status: 'IOE Found', 'Source: Scan': 'Purple Knight for Entra ID · 2026-09-01' }
]));
expect(book.SheetNames).toEqual(['Übersicht', 'PurpleKnight']);
});
it('keeps a source field named like a fixed column in its own column', () => {
const { findings } = workbook(mixed([{ Title: 'Synthetic', Status: 'Fail', Nr: '17', Review: 'Synthetic source review', 'Source: Scan': 'PingCastle · 2026-09-01' }]));
const [finding] = findings('PingCastle');
expect(finding).toMatchObject({ Nr: '1', 'Nr (Source field)': '17', 'Review (Source field)': 'Synthetic source review' });
expect(finding.Review ?? '').not.toContain('Synthetic source review');
});
it('exports a large single-tool sheet', () => {
const rows = Array.from({ length: 250_000 }, (_, index) => ({ Title: `Synthetic ${index}`, Status: 'Matched', 'Source: Scan': 'PingCastle · 2026-09-01' }));
const input = mixed(rows);
const report = buildWorkViewExport({ ...input, generatedAt: '2026-09-24T12:00:00.000Z' });
expect(() => renderWorkViewXlsx(report)).not.toThrow();
}, 120_000);
});