Tests / test (push) Successful in 1m1s
Approved by Daniel Haefliger for develop and beta. Author dh_Agent, authenticated account dh. Preserve published battery, charging and overall Energy Pie changes. No deployment or plant control authorization.
105 lines
8.9 KiB
JavaScript
105 lines
8.9 KiB
JavaScript
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('explicit other-channel links retain their Git target', () => {
|
|
for (const branch of ['main', 'beta']) {
|
|
const href = `https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/${branch}/docs/module/Manager/README.md#variablen`;
|
|
assert.equal(resolveLink(href, { ...page, branch:'develop' }, targets), href);
|
|
}
|
|
});
|
|
test('same-channel links map locally and respect the manifest branch', () => {
|
|
const href = 'https://git.belevo.ch/ENELIX/Enelix-EMS/src/branch/beta/docs/module/Manager/README.md';
|
|
assert.equal(resolveLink(href, { ...page, branch:'beta' }, targets), '/docs/ems/Manager.html');
|
|
const html = renderPage({ ...page, markdown:`# Channels\n\n[Beta](${href})` }, targets, { branch:'beta', checkedAt:'2026-10-07T00:00:00Z' }, '123456');
|
|
assert.match(html, /href="\/docs\/ems\/Manager.html"/);
|
|
});
|
|
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'); });
|