Files
ProxMenux/tests/test_audit_presentation.cjs
T
MacRimiandClaude Opus 5 da8a480eff Add audit and reports page, and a change journal
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer.

The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded.

The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:06:04 +02:00

160 lines
12 KiB
JavaScript

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');
const cache = new Map();
function load(file) {
file = path.resolve(file);
if (cache.has(file)) return cache.get(file).exports;
const mod = {exports:{}}; cache.set(file,mod);
const js = ts.transpileModule(fs.readFileSync(file,'utf8'), {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText;
new Function('require','module','exports',js)(name => {
if (!name.startsWith('.')) return appRequire(name);
const base=path.resolve(path.dirname(file),name);
return load(fs.existsSync(base+'.ts') ? base+'.ts' : base+'.tsx');
},mod,mod.exports);
return mod.exports;
}
function translate(locale) {
const messages=JSON.parse(fs.readFileSync(path.join(app,'messages',locale,'common.json')));
return (key,params={}) => {
const text=key.split('.').reduce((o,k)=>o?.[k],messages);
return typeof text==='string' ? text.replace(/\{(\w+)\}/g,(m,k)=>params[k] ?? m) : key;
};
}
global.window={location:{origin:'http://localhost'}};
const presentation=load(path.join(app,'lib/audit-presentation.ts'));
const {buildAuditDocument}=load(path.join(app,'lib/audit-document.ts'));
const {storageDiagram}=load(path.join(app,'lib/report-diagrams.ts'));
const base=(id,classification,affected=[])=>({check_id:id,area:id.split('.')[0],severity:'INFO',classification,summary_key:null,summary_params:{},affected,evidence:null});
const coverage=base('backup.guest_coverage','observation',[
...[109,111,112,114,9510].map(vmid=>({vmid,classification:'observation',reason_key:'noJobSelectsGuest'})),
...['sata0','sata1','sata2','sata3','scsi1'].map(volume=>({vmid:106,volume,reason_key:'dataExcludedFromBackup',classification:'observation'})),
{vmid:110,volume:'scsi1',reason_key:'dataExcludedFromBackup',classification:'observation'}]);
const lynis=base('security.lynis_warnings','observation',[...['enp3s0','tap106i0','tap105i0'].map(details=>({test:'NETW-3015',message:'Found promiscuous interface',details,solution:'Do not show this advice',classification:'observation'}))]);
const age=base('backup.last_backup_age','warning',[{vmid:101,storage:'PBS-Cloud',classification:'warning',reason_key:'olderThanSchedule'},{vmid:109,storage:'any',classification:'observation',reason_key:'noStoredBackupUnscheduled'}]);
age.evidence=JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252}]);
for(const locale of ['en','es','de','fr','it','pt','sk','sv']) {
const t=translate(locale);
assert(!t('audit.checks.backup.last_backup_age.rationale').includes('ProxMenux'));
assert(!t('audit.presentation.limitReference').includes('ProxMenux'));
const groups=presentation.presentFinding(coverage,t,locale);
assert.equal(groups[0].rows.length,5); assert.equal(groups[1].rows.length,2);
assert(presentation.affectedDescription(coverage,t).includes('6'));
assert(!presentation.affectedDescription(coverage,t).includes('11'));
const lxcExcluded=base('backup.guest_coverage','observation',[{vmid:120,name:'container',type:'lxc',volume:'mp0',reason_key:'dataExcludedFromBackup',classification:'observation'}]);
assert.equal(presentation.presentFinding(lxcExcluded,t,locale)[0].rows[0].cells[0],'container · LXC 120');
const unavailable=base('backup.last_backup_age','unverified',[{vmid:120,storage:'offline',classification:'unverified',reason_key:'destinationUnavailable'}]);
const unavailableText=JSON.stringify(presentation.presentFinding(unavailable,t,locale));
assert(!unavailableText.includes(t('audit.presentation.notFound')));
assert(unavailableText.includes(t('audit.classifications.unverified')));
const text=JSON.stringify(presentation.presentFinding(lynis,t,locale));
assert(!text.includes('Do not show this advice'));
assert(text.includes('tap106i0'));
assert.equal(presentation.presentFinding(lynis,t,locale).length,1);
assert.equal(presentation.presentFinding(age,t,locale).length,2);
assert.notEqual(presentation.auditDuration(259.1,locale),presentation.auditDuration(252,locale));
for(const [policy,labelKey] of [['schedule and grace','limitSchedule'],['declared recovery objective','limitDeclared'],['fallback; no recovery objective declared and schedule not read','limitReference']]) {
const data={...age,affected:[age.affected[0]],evidence:JSON.stringify([{vmid:101,expected_storage:'PBS-Cloud',last_backup:1787763643,age_hours:259.1,max_age_hours:252,age_policy:policy}])};
const group=presentation.presentFinding(data,t,locale)[0];
assert(group.columns.includes(t('audit.presentation.backupAge')));
assert(group.columns.includes(t('audit.presentation.backupLimit')));
assert(!group.columns.includes(t('audit.presentation.ageLimit')));
assert(group.rows[0].cells.includes(presentation.auditDuration(259.1,locale)));
assert(group.rows[0].cells.some(cell=>cell.includes(t('audit.presentation.'+labelKey)) && cell.includes(presentation.auditDuration(252,locale))));
}
const legacyText=JSON.stringify(presentation.presentFinding(age,t,locale));
assert(!legacyText.includes(t('audit.presentation.limitSchedule')));
const implicitDestination={...age,affected:[{vmid:112,storage:'local',classification:'warning',reason_key:'olderThanFallback'}],evidence:JSON.stringify([{vmid:112,expected_storage:'any visible destination (no explicit target)',storage:'local',last_backup:1787763643,age_hours:800,max_age_hours:720,age_policy:'fallback; no recovery objective declared and schedule not read'}])};
const implicitText=JSON.stringify(presentation.presentFinding(implicitDestination,t,locale));
assert(implicitText.includes(presentation.auditDuration(720,locale)),
`${locale}: a backup without an explicit job destination lost its limit`);
const failedRuns=base('backup.job_results','warning',[
{vmid:106,status:'job errors',when:1787760000,upid:'UPID:first',classification:'warning',reason_key:'backupRunFailed'},
{vmid:106,status:'job errors',when:1787763600,upid:'UPID:last',classification:'warning',reason_key:'backupRunFailed'},
{vmid:110,status:'storage unavailable',when:1787767200,upid:'UPID:other',classification:'warning',reason_key:'backupRunFailed'},
]);
const failedGroups=presentation.presentFinding(failedRuns,t,locale);
assert.equal(failedGroups.length,1);
assert.equal(failedGroups[0].rows.length,2,
`${locale}: repeated backup failures were not grouped`);
assert.equal(failedGroups[0].rows.find(row=>row.cells[0].includes('106')).cells[1],'2');
assert(failedGroups[0].rows.some(row=>row.cells.includes('UPID:last')),
`${locale}: the latest backup task reference was not retained`);
const connected={...base('storage.connected_storage','conformant'),evidence:JSON.stringify({storages:[
{storage:'store-fixture',type:'pbs',status:'active',dependencies:[{vmid:101}],jobs:['backup-1'],capacity_known:true,used_percent:42.5},
],scope:'PVE-side observations only'})};
const connectedGroups=presentation.presentFinding(connected,t,locale);
assert.equal(connectedGroups.length,1);
assert.deepEqual(connectedGroups[0].columns,[t('audit.document.storage'),t('audit.document.type'),
t('audit.document.state'),t('audit.presentation.capacity'),t('audit.presentation.fact')]);
assert(connectedGroups[0].rows[0].cells.some(cell=>cell.includes('42')),
`${locale}: connected storage capacity was not presented`);
const thin={...base('storage.thin_pool_overprovisioning','warning',[
{pool:'pve/data',metric:'metadata',classification:'warning',reason_key:'thinMetadataPressure'},
]),evidence:JSON.stringify([{pool:'pve/data',allocated_bytes:214748364800,
pool_bytes:107374182400,allocation_percent:200,data_percent:81.2,metadata_percent:92.4}])};
const thinGroups=presentation.presentFinding(thin,t,locale);
assert.equal(thinGroups.length,1);
assert.deepEqual(thinGroups[0].columns,[t('audit.presentation.resource'),
t('audit.presentation.capacity'),t('audit.presentation.data'),
t('audit.presentation.metadata'),t('audit.presentation.fact')]);
assert(thinGroups[0].rows[0].cells[1].includes('GiB'),
`${locale}: thin-pool byte values were not made readable`);
const passing={...base('system.time_synchronisation','conformant'),evidence:'NTP: yes\nNTPSynchronized: yes'};
const input={profile:'full',run:null,findings:[coverage,age,lynis,passing,base('system.security_updates','unverified')],inventory:null,t,locale};
const html=buildAuditDocument(input);
const header=html.split('<div class="exec-box">')[1].split('<div class="audit-counters">')[0];
assert(header.includes('<strong>4/5</strong>'));
assert(header.includes('stroke-dasharray="80 100"'));
assert(header.includes(t('audit.presentation.verified')));
assert(!header.includes('health-ring'));
assert(!header.includes('health-lbl'));
assert(!header.includes('15 de 26'));
assert(header.includes('audit-result-heading'));
assert(header.includes('stroke="currentColor"'));
const checkedHtml=html.split('id="verified-checks"')[1].split('id="unverified-checks"')[0];
assert.equal((checkedHtml.match(/href="#finding-/g)||[]).length,4);
assert(checkedHtml.includes(t('audit.presentation.verifiedChecks')+' · 4'));
assert(checkedHtml.includes(t('audit.checks.system.time_synchronisation.title')));
assert(!checkedHtml.includes('href="#finding-system.security_updates"'));
assert(html.indexOf('id="verified-checks"') < html.indexOf(t('audit.presentation.overview')));
assert(!html.includes('audit.document.area'));
for(const [findings,expected] of [
[[base('x','critical'),base('y','observation'),base('z','not_applicable')],'2/2'],
[[{...base('x','warning'),incomplete:true,decision:'accepted'},base('y','conformant')],'1/2'],
[[{...base('x','unverified'),decision:'accepted'}],'0/1'],
[[base('x','not_applicable')],'—'],
[[],'—']]) {
const doc=buildAuditDocument({...input,findings});
assert(doc.includes(`<strong>${expected}</strong>`));
const verifiedSection=doc.split('id="verified-checks"')[1]?.split('</table>')[0] || '';
const expectedCount=expected==='—'?0:Number(expected.split('/')[0]);
assert.equal((verifiedSection.match(/href="#finding-/g)||[]).length,expectedCount);
if(expected==='—') assert(doc.includes('stroke-dasharray="0 100"'));
}
assert(!html.includes('health-icon" style="font-size:26px'));
assert(html.includes(t('audit.presentation.incomplete')));
assert(!html.includes('{count}'));
assert(!html.includes('audit.presentation.'));
const main=html.split('id="evidence-')[0];
assert(!main.includes('noJobSelectsGuest'));
assert(!main.includes('Do not show this advice'));
assert(html.includes(t('audit.presentation.evidenceObserved')),
`${locale}: conformant findings have no visible evidence`);
assert(!html.includes('id="evidence-system.time_synchronisation"'),
`${locale}: conformant raw evidence still bloats the appendix`);
const structuredPassing=buildAuditDocument({...input,findings:[connected]});
assert.equal((structuredPassing.match(/store-fixture/g)||[]).length,1,
`${locale}: structured conformant evidence was printed twice`);
const dangerous={...coverage,affected:[{vmid:109,name:'<img src=x onerror=alert(1)>',classification:'observation'}]};
assert(!buildAuditDocument({...input,findings:[dangerous]}).includes('<img src=x'));
}
const diagram=storageDiagram([{vmid:1,name:'one',disks:[{storage:'local'},{storage:'local'}],backups:[]}],{guests:'Guests',storage:'Storage',backup:'Jobs',unprotected:'No job'});
assert(!diagram.includes('>2</text>'));
console.log('Audit presentation: eight languages, truthful counts, calendar age, Lynis grouping, no advice, escaping and incomplete results passed.');
module.exports={load,translate,base,coverage,age,lynis,buildAuditDocument};