docs: publish public guides, screenshots and Git-synced portal documentation
Tests / test (push) Failing after 50s
Tests / test (push) Failing after 50s
This commit is contained in:
@@ -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, /<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, /<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:'' }, 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 & 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' }, 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'); });
|
||||
Reference in New Issue
Block a user