import test from 'node:test'; import assert from 'node:assert/strict'; import { renderMarkdown, renderPage, renderNavigation, resolveLink, resolveImage } from '../render.mjs'; import { screenshotInfo, screenshotSources } from '../images.mjs'; import { validateCatalog, grossAmount, termLabel } from '../assets/prices.js'; import { repositories } from '../config.mjs'; const page = { repo:'Enelix-EMS', source:'docs/public/manager.md', output:'manager.html', title:'Manager', group:'Anleitungen', markdown:'# Manager\n\n## Einrichten\n\nInhalt.', commit:'a'.repeat(40) }; const targets = [page, { repo:'Enelix-EMS', source:'docs/module/Manager/README.md', output:'ems/Manager.html' }, { repo:'Enelix-Utils', source:'docs/module/Energiediagramm/README.md', output:'utils/Energiediagramm.html' }]; test('relative README links remain public', () => assert.equal(resolveLink('../module/Manager/README.md#variablen', page, targets), '/docs/ems/Manager.html#variablen')); test('cross-repository links remain public', () => assert.equal(resolveLink('../../../Enelix-Utils/docs/module/Energiediagramm/README.md', page, targets), '/docs/utils/Energiediagramm.html')); test('private and unsafe targets are not published', () => { for (const href of ['../NETPLAN_V4_AGENT_HANDOFF.md', '../Offene-Punkte.md', 'javascript:alert(1)', '//evil.example', 'data:text/html,hello', 'https://user:pass@example.com', '/srv/agent/.netrc']) assert.equal(resolveLink(href, page, targets), null); }); test('markdown escapes scripts, event handlers and injected attributes', () => { const { html } = renderMarkdown({ ...page, markdown:'# Test\n\n\n\n\n\n[bad](javascript:alert(1))\n\n[bad](data:text/html,test)\n\n**bold**\n\n```html\n\n```' }, targets); assert.doesNotMatch(html, /bold<\/strong>/); assert.match(html, /<script>literal/); }); test('tables, duplicate headings and contents are rendered', () => { const result = renderMarkdown({ ...page, markdown:'# Name\n\n## Gleich\n\n## Gleich\n\n| Name | Wert |\n| --- | --- |\n| Test | 12 |' }, targets); assert.match(result.html, /table-scroll/); assert.match(result.html, //); assert.deepEqual(result.toc.map(x => x.id), ['gleich', 'gleich-1']); }); test('HTML has public navigation and no login dependencies', () => { const html = renderPage(page, [page], { branch:'develop', checkedAt:'2026-10-06T12:00:00Z' }, '123456'); assert.match(html, /lang="de"/); assert.match(html, /Testing/); assert.match(html, /href="\/docs\/preise.html"/); assert.doesNotMatch(html, /api\/session|app\.js/); }); test('publication allowlist excludes internal operations', () => { for (const repo of repositories) for (const [source] of repo.pages) assert.doesNotMatch(source, /HANDOFF|AGENT|Offene-Punkte|\.env|services|data\//); }); const navigationPages = repositories.flatMap(repo => repo.pages.map(([source,title,output]) => ({ repo:repo.name, source, title, output, markdown:`# ${title}`, group:repo.id === 'ems' ? 'Enelix EMS' : 'Enelix Utils' }))); test('navigation groups all consumers and interfaces without duplicating or changing URLs', () => { const html = renderNavigation(page, navigationPages); for (const item of navigationPages) assert.equal(html.split(`href="/docs/${item.output}"`).length - 1, 1); const consumers = html.match(/data-folder="verbraucher"[^>]*>(.*?)<\/details>/s)[1]; assert.equal((consumers.match(/data-search=/g) || []).length, 8); assert.match(consumers, /ems\/Batterie.html/); assert.match(consumers, /ems\/Easee-Gateway.html/); assert.doesNotMatch(consumers, /ems\/Manager.html|ems\/schnittstelle.html/); assert.match(html, /Schnittstellen<\/summary>/); assert.doesNotMatch(html, /data-folder="[^"]+" open/); }); test('active reference opens only its own folder in server-rendered HTML', () => { const html = renderNavigation({ output:'ems/Waermepumpe.html' }, navigationPages); assert.match(html, /data-folder="verbraucher" open data-active="true"/); assert.match(html, /href="\/docs\/ems\/Waermepumpe.html" aria-current="page"/); assert.doesNotMatch(html, /data-folder="schnittstellen" open/); assert.equal((html.match(/aria-current="page"/g) || []).length, 1); }); test('unclassified future references remain directly accessible', () => { const extra = { ...page, group:'Enelix EMS', output:'ems/NewModule.html', title:'New module' }; assert.match(renderNavigation(extra, [...navigationPages,extra]), /href="\/docs\/ems\/NewModule.html" aria-current="page"/); }); const screenshot = { repo:'Enelix-EMS', source:'docs/public/images/symcon-manager.png', output:'images/symcon-manager-1234567890abcdef.png', width:1000, height:600 }; test('only approved local screenshots render with dimensions and escaped captions', () => { const { html } = renderMarkdown({ ...page, markdown:'![Manager](images/symcon-manager.png "Test & Ansicht")' }, targets, [screenshot]); assert.match(html, / { for (const href of ['https://example.com/pixel.png','data:image/png;base64,aaaa','images/private.png','../public/images/symcon-manager.png','images/../images/symcon-manager.png','images/%2e%2e/symcon-manager.png','/docs/images/symcon-manager.png','images\\symcon-manager.png']) { assert.equal(resolveImage(href, page, [screenshot]), null); } assert.equal(resolveImage('images/symcon-manager.png', { ...page, repo:'Enelix-Utils' }, [screenshot]), null); const { html } = renderMarkdown({ ...page, markdown:'\n\n![Remote](https://example.com/pixel.png)' }, targets, [screenshot]); assert.doesNotMatch(html, / { const data = Buffer.alloc(45); Buffer.from('89504e470d0a1a0a','hex').copy(data); data.writeUInt32BE(13,8); data.write('IHDR',12); data.writeUInt32BE(950,16); data.writeUInt32BE(550,20); data.write('IEND',37); const info = screenshotInfo(screenshotSources[0], data); assert.equal(info.width, 950); assert.match(info.output, /-[a-f0-9]{16}\.png$/); assert.throws(() => screenshotInfo('docs/private.png', data)); assert.throws(() => screenshotInfo(screenshotSources[0], Buffer.from(''))); data.writeUInt32BE(100000, 16); assert.throws(() => screenshotInfo(screenshotSources[0], data)); }); const catalog = { currency:'CHF', prices:'net', vatRate:8.1, items:{ sample:{ name:'Sample', sku:'SKU', unitAmount:10000, setupAmount:0, termMonths:12 } } }; test('catalog reflects arbitrary valid backend products', () => assert.equal(validateCatalog(catalog).items.sample.unitAmount, 10000)); test('zero prices are valid and distinct from missing prices', () => { assert.equal(validateCatalog({ ...catalog, items:{ zero:{ ...catalog.items.sample, unitAmount:0 } } }).items.zero.unitAmount, 0); assert.throws(() => validateCatalog({ ...catalog, items:{ broken:{ ...catalog.items.sample, unitAmount:undefined } } })); }); test('invalid prices, currencies and periods fail closed', () => { for (const patch of [{ currency:'USD' }, { vatRate:NaN }, { vatRate:101 }, { items:{} }, { prices:'gross' }]) assert.throws(() => validateCatalog({ ...catalog, ...patch })); for (const patch of [{ unitAmount:-1 }, { unitAmount:1.5 }, { termMonths:0 }, { setupAmount:null }]) assert.throws(() => validateCatalog({ ...catalog, items:{ item:{ ...catalog.items.sample, ...patch } } })); }); test('VAT follows backend rate and rounding contract', () => { assert.equal(grossAmount(10000, 8.1), 10810); assert.equal(grossAmount(4195, 8.1), 4535); assert.equal(grossAmount(100, 0), 100); }); test('period labels distinguish recurring from one-time', () => { assert.equal(termLabel(null), 'Einmalig'); assert.equal(termLabel(12), 'Pro Jahr'); assert.equal(termLabel(6), 'Pro 6 Monate'); });