docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s

This commit is contained in:
dh
2026-10-06 14:32:05 +00:00
parent 9fbc52ee47
commit faa905ab09
29 changed files with 1973 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
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<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n[bad](javascript:alert(1))\n\n[bad](data:text/html,test)\n\n**bold**\n\n```html\n<script>literal</script>\n```' }, targets);
assert.doesNotMatch(html, /<script|onerror=|href="javascript:|href="data:/i);
assert.match(html, /<strong>bold<\/strong>/);
assert.match(html, /&lt;script&gt;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, /<table>/); 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, /<summary>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, /<img src="\/docs\/images\/symcon-manager-1234567890abcdef.png"/);
assert.match(html, /width="1000" height="600" loading="lazy"/);
assert.match(html, /Test &amp; Ansicht/);
assert.match(html, /Bild in voller Größe öffnen/);
});
test('external, unapproved, traversal and raw HTML images stay blocked', () => {
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:'<img src="images/symcon-manager.png" onerror="alert(1)">\n\n![Remote](https://example.com/pixel.png)' }, targets, [screenshot]);
assert.doesNotMatch(html, /<img|onerror/);
});
test('screenshots are fingerprinted and reject non-PNG or unreasonable dimensions', () => {
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('<svg></svg>')));
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'); });