// Render the quick-diagnosis document from the real builder, in every // language, without a browser or an API. A short report that throws on // click is worse than a long one that prints. const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createRequire } = require('node:module'); const app = path.resolve(__dirname, '../AppImage'); const appRequire = createRequire(path.join(app, 'package.json')); const ts = appRequire('typescript'); function load(rel, imports = {}) { const source = fs.readFileSync(path.join(app, rel), 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, }}).outputText; const module = { exports: {} }; new Function('require', 'module', 'exports', compiled)( name => imports[name] || appRequire(name), module, module.exports); return module.exports; } global.window = { location: { origin: 'http://localhost:8008' } }; const shell = load('lib/report-shell.ts'); const evidence = load('lib/evidence-format.ts'); const diagrams = load('lib/report-diagrams.ts', { './report-shell': shell }); const presentation = load('lib/audit-presentation.ts', { './evidence-format': evidence }); const doc = load('lib/audit-document.ts', { './report-shell': shell, './report-diagrams': diagrams, './audit-presentation': presentation, './evidence-format': evidence, }); const finding = (check_id, classification, area, extra = {}) => ({ check_id, classification, area, incomplete: false, summary_key: 'attention', summary_params: { count: '3', total: '9' }, evidence: 'raw evidence', affected: Array.from({ length: 25 }, (_, i) => ({ name: `object-${i}`, classification, reason_key: 'hostBackupStale', })), ...extra, }); const FINDINGS = [ finding('backup.host_recovery', 'critical', 'backup'), finding('system.security_updates', 'warning', 'system'), finding('guests.autostart', 'observation', 'guests'), finding('storage.zfs_scrub_age', 'conformant', 'storage'), { ...finding('system.update_chain', 'unverified', 'system'), affected: [] }, ]; for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) { const messages = JSON.parse( fs.readFileSync(path.join(app, 'messages', locale, 'common.json'))); const t = (key, values = {}) => { let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key; for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v); return text; }; const input = { profile: 'diagnostic', findings: FINDINGS, t, locale, run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, status: 'partial', metadata: {} }, inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} }, }; const html = doc.buildAuditDocument(input); // What it must contain: the findings that ask for a decision. assert.ok(html.includes(t('audit.checks.backup.host_recovery.title')), `${locale}: the critical finding is missing`); assert.ok(html.includes(t('audit.checks.system.security_updates.title')), `${locale}: the warning is missing`); // And the blind spot it could not read. assert.ok(html.includes(t('audit.document.diagnosticUnread')), `${locale}: unread readings are not declared`); // What it must not: conformant results, observations, the annex. assert.ok(!html.includes(t('audit.checks.storage.zfs_scrub_age.title')), `${locale}: a conformant result reached the quick diagnosis`); assert.ok(!html.includes(t('audit.checks.guests.autostart.title')), `${locale}: an observation reached the quick diagnosis`); assert.ok(!html.includes('raw evidence'), `${locale}: the technical annex reached the quick diagnosis`); // Long tables are cut rather than printed whole. assert.ok(html.includes('object-7') && !html.includes('object-9'), `${locale}: affected rows are not capped at eight`); assert.ok(html.includes(t('audit.document.diagnosticMoreRows', { count: '17' })), `${locale}: the cut is not declared`); assert.ok(!html.includes('undefined') && !html.includes('audit.document.'), `${locale}: an untranslated key or an undefined value was rendered`); } // With nothing to decide it says so instead of printing an empty section. const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/en/common.json'))); const t = (key, values = {}) => { let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key; for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v); return text; }; const clear = doc.buildAuditDocument({ profile: 'diagnostic', findings: [FINDINGS[3]], t, locale: 'en', run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} }, inventory: { sections: { identity: { node: 'fixture' } }, unavailable: {} }, }); assert.ok(clear.includes(t('audit.document.diagnosticClear')), 'a host with nothing to decide is not told so'); assert.ok(!clear.includes(t('audit.document.diagnosticActions')), 'an empty findings section was printed'); // A disk finding shows what happened, not six rows repeating that // something did. The columns are the inventory's own, so the finding and // the observation table read as one account of the disk. { const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json'))); const t = (key, values = {}) => { let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key; for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v); return text; }; const groups = presentation.presentFinding({ check_id: 'hardware.disk_errors', classification: 'warning', area: 'hardware', evidence: null, affected: [ { name: 'sdh', type: 'io_error', severity: 'critical', count: 284364, first_seen: '2026-05-20T23:03:39', last_seen: '2026-09-07T19:31:47', message: 'ata8.00: error: { IDNF }', classification: 'warning', reason_key: 'diskErrorsActive' }, { name: 'sda', type: 'smart_error', severity: 'warning', count: 13, first_seen: 1788000000, last_seen: 1788600000, message: 'read failed', classification: 'warning', reason_key: 'diskWarningsActive' }, ], }, t, 'es', []); assert.equal(groups.length, 2, 'events are not grouped by device'); assert.deepEqual(groups.map(g => g.title), ['sdh', 'sda']); assert.deepEqual(groups[0].columns, [ t('audit.document.event'), t('audit.document.severity'), t('audit.document.occurrences'), t('audit.document.firstSeen'), t('audit.document.lastSeen'), t('audit.document.detail'), ], 'the finding does not use the inventory table columns'); const [type, severity, count, first, last, detail] = groups[0].rows[0].cells; assert.equal(type, 'io_error'); assert.equal(severity, t('audit.classifications.critical'), 'the stored English severity reached a translated view'); assert.equal(count, '284364'); assert.ok(first.includes('2026') && last.includes('2026'), 'ISO timestamps were not rendered as dates'); assert.equal(detail, 'ata8.00: error: { IDNF }'); assert.equal(first, new Date(2026, 4, 20, 23, 3, 39).toLocaleString('es'), 'a local SQLite timestamp was converted as UTC'); // The other Monitor tables store epoch seconds; both forms must render. const epochRow = groups[1].rows[0].cells; assert.ok(epochRow[3].includes('2026') && epochRow[4].includes('2026'), 'epoch timestamps were not rendered as dates'); console.log('Disk findings: inventory columns, translated severity, both date forms.'); } // "Could not be evaluated" describes the assessment, not the host. The // reason is recorded against each source; it used to sit two collapsed // panels below a line that explained nothing. { const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json'))); const t = (key, values = {}) => { let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key; for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v); return text; }; // Exactly what .55 recorded: a backup destination that refused the // connection, which is why the age of its copies is unverified. const line = presentation.unreadSources([ { source: 'cmd:["pvesm", "list", "local"]', collected_at: 1788728305 }, { source: 'cmd:["pvesm", "list", "pbs"]', collected_at: 1788728305, error: "exit 111: pbs: error fetching datastores - 500 Can't connect to\n192.168.0.72:8007 (Connection refused)" }, ], t); assert.ok(line.startsWith(t('audit.presentation.couldNotRead')), 'the line does not say that something could not be read'); assert.ok(line.includes('pvesm list pbs'), 'the command was left in its serialised form'); assert.ok(!line.includes('cmd:['), 'the raw source key leaked into the reader\'s view'); assert.ok(line.includes('Connection refused'), 'the reason was dropped'); assert.ok(!line.includes('\n'), 'a multi-line error was not flattened'); assert.ok(!line.includes('pvesm list local'), 'a source that was read fine was listed as unreadable'); assert.equal(presentation.unreadSources([{ source: 'x', collected_at: 1 }], t), '', 'a check whose sources all worked printed an empty notice'); assert.equal(presentation.unreadSources(undefined, t), ''); console.log('Unread sources: named, flattened, only the ones that failed.'); } // Lynis repeats a warning once per thing it applies to. Ten promiscuous // interfaces printed as ten rows saying "NETW-3015 · —" described none // of them; collapsed, each row carries a warning and how often it was // raised. { const messages = JSON.parse(fs.readFileSync(path.join(app, 'messages/es/common.json'))); const t = (key, values = {}) => { let text = key.split('.').reduce((o, k) => o?.[k], messages) ?? key; for (const [k, v] of Object.entries(values)) text = text.replaceAll(`{${k}}`, v); return text; }; const warn = (test, message, details = '') => ({ test, message, details, classification: 'observation', reason_key: 'lynisWarning' }); const finding = affected => ({ check_id: 'security.lynis_warnings', classification: 'observation', area: 'security', evidence: null, affected }); const plain = presentation.presentFinding(finding([ warn('PKGS-7392', 'Found one or more vulnerable packages.'), ...Array.from({ length: 10 }, () => warn('NETW-3015', 'Found promiscuous interface')), warn('MAIL-8818', 'SMTP banner discloses software'), ]), t, 'es', []); assert.equal(plain.length, 1, 'warnings are still split into a group each'); assert.equal(plain[0].rows.length, 3, '12 warnings did not collapse to 3 rows'); assert.deepEqual(plain[0].columns, [t('audit.presentation.lynisTest'), t('audit.presentation.lynisWarning'), t('audit.document.occurrences')], 'a detail column was printed with nothing to put in it'); const promiscuous = plain[0].rows.find(r => r.cells[0] === 'NETW-3015'); assert.equal(promiscuous.cells[2], '10', 'repetitions were not counted'); // Where Lynis names what it found, the names are kept and joined. const named = presentation.presentFinding(finding([ warn('NETW-3015', 'Found promiscuous interface', 'ens4f0'), warn('NETW-3015', 'Found promiscuous interface', 'eno1'), ]), t, 'es', []); assert.equal(named[0].columns.length, 4, 'the detail column is missing'); assert.equal(named[0].rows[0].cells[3], 'ens4f0, eno1'); console.log('Lynis warnings: one row per warning, repetitions counted, names kept.'); } // The inventory profile is the other short document: structure and // configuration, with nothing assessed. An assessment summary counting // nothing and a findings section listing nothing are two empty frames // around the only thing its reader opened it for. const structure = doc.buildAuditDocument({ profile: 'inventory', findings: [], t, locale: 'en', run: { run_id: 'r1', started_at: 1788700000, finished_at: 1788700100, metadata: {} }, inventory: { sections: { identity: { node: 'fixture', pve_version: '9.2.4' }, cluster: { member: false }, hardware: { cpu_model: 'Xeon', memory_total: 1, disks: [], memory_modules: [], controllers: [] }, network: { bridges: {}, adapters: [] }, }, unavailable: {} }, }); assert.ok(structure.includes(t('audit.document.structureTitle')), 'the structure report is still titled as an audit'); assert.ok(!structure.includes(t('audit.document.executiveSummary')), 'an assessment summary counting nothing was printed'); assert.ok(!structure.includes(t('audit.document.findings')), 'a findings section listing nothing was printed'); assert.ok(!structure.includes(t('audit.presentation.annex')), 'the technical annex was printed with no evidence to carry'); assert.ok(structure.includes(t('audit.document.scope')), 'the structure report does not say what it covers'); console.log('Structure report: no assessment frames, own title, scope kept.'); console.log('Quick diagnosis: eight languages, only what needs deciding, capped tables, declared blind spots and cuts.');