Files
Webklar-Kundenbereich/test/buildLegalHtml.test.js
T
2026-10-07 14:23:21 +00:00

577 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import express, { Router } from 'express'
import { useTestEnv } from './support/testEnv.js'
// HTML-Generator der Rechtstexte (scripts/build-legal-html.mjs) und die ausgelieferten Dateien des Terminplaners (Task F6).
// Die Markdown-Quellen stehen unter docs/rechtliches/, die Seiten und PDFs unter public/rechtliches/<slug>-<Fassung>.html|pdf.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js')
const { render, parse, lauf, termineDokumente, shopDokumente, unterkunftDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const V = config.termine.avvVersion
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const DOKUMENTE = termineDokumente(V)
const S = config.shop.fassung
const SHOP = shopDokumente(S)
const U = config.unterkunft.fassung
const UK = unterkunftDokumente(U)
const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n'
/** HTML in Klartext: Tags weg, Entitäten zurück, Leerraum normalisiert. */
const klartext = (html) =>
html
.replace(/<style>[\s\S]*?<\/style>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
test('termineDokumente: lb, avv, widerruf mit Quelle, Ziel und Navigation auf die Fassung', () => {
assert.deepEqual(DOKUMENTE.map((d) => d.doc), ['lb', 'avv', 'widerruf'])
assert.deepEqual(
DOKUMENTE.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-terminplaner.md', `public/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`],
['docs/rechtliches/avv-terminplaner.md', `public/rechtliches/avv-terminplaner-${V}.html`],
['docs/rechtliches/widerrufsbelehrung-funktionen.md', `public/rechtliches/widerrufsbelehrung-funktionen-${V}.html`],
],
)
const adressen = Object.values(legalPaths('termine', V))
for (const d of DOKUMENTE) {
assert.equal(d.meta.fassung, V)
const hrefs = d.meta.nav.map((l) => l.href)
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
}
})
test('html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown) und werden bei zweitem Lauf gleich', () => {
for (const d of DOKUMENTE) {
const md = lies(d.md)
const html = render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs termine)`)
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
// keine Datumsangabe außer der Fassung
assert.doesNotMatch(html.replaceAll(V, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
}
// --check: Exit 0 bei gleichem Stand
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 3)
})
test('html_gleich_generator: --check meldet eine abweichende oder fehlende Seite mit Exit 1 und schreibt nichts; ohne --check wird geschrieben; falscher Aufruf: Exit 2', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of DOKUMENTE) cpSync(join(ROOT, d.md), join(dir, d.md))
const zeilen = []
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
// noch keine Seiten: --check meldet alle drei und schreibt nichts
assert.equal(ruf('termine', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 3)
assert.equal(existsSync(join(dir, DOKUMENTE[0].html)), false, '--check schreibt nichts')
// schreiben, dann stimmt --check
assert.equal(ruf('termine'), 0)
assert.equal(ruf('termine', '--check'), 0)
for (const d of DOKUMENTE) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
// eine Seite von Hand geändert (oder die Quelle geändert): Abweichung, Datei bleibt wie sie ist
const ziel = join(dir, DOKUMENTE[1].html)
const handgemacht = readFileSync(ziel, 'utf8').replace('<h2>7. Unterauftragsverarbeiter</h2>', '<h2>7. Unterauftragsverarbeiter (von Hand)</h2>')
writeFileSync(ziel, handgemacht)
zeilen.length = 0
assert.equal(ruf('termine', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${DOKUMENTE[1].html}`])
assert.equal(readFileSync(ziel, 'utf8'), handgemacht, '--check ändert die Datei nicht')
// Quelle geändert, Seite nicht nachgezogen
writeFileSync(ziel, lies(DOKUMENTE[1].html))
writeFileSync(join(dir, DOKUMENTE[1].md), `${lies(DOKUMENTE[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
assert.equal(ruf('termine', '--check'), 1)
// falscher Aufruf
assert.equal(ruf(), 2)
assert.equal(ruf('analyse'), 2)
assert.equal(ruf('termine', '--schreiben'), 2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
// als Programm: Exit 0 auf dem eingecheckten Stand, Exit 2 ohne Argumente
const prog = (...args) => spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', ...args], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(prog('termine', '--check').status, 0)
assert.equal(prog().status, 2)
})
test('huelle_gleich_analyse: dieselbe Seitenhülle wie die Seiten der Website-Analyse (bis auf die meta-Zeile mit der Fassung)', () => {
const analyse = termineDokumenteAnalyse()
for (const a of analyse) {
const neu = render(lies(a.md), a.meta).replace(/^ {2}<meta name="webklar-fassung"[^\n]*\n/m, '')
assert.equal(neu, lies(a.html), `${a.html}: Seitenhülle weicht ab`)
}
})
function termineDokumenteAnalyse() {
const nav = [
{ href: '/dashboard.html', text: '← Kundenportal' },
{ href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' },
{ href: '/rechtliches/avv-website-analyse-2026-10-02.html', text: 'AVV Website-Analyse' },
{ href: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', text: 'Leistungsbeschreibung Website-Analyse' },
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
]
return [
{ md: 'docs/rechtliches/avv-website-analyse.md', html: 'public/rechtliches/avv-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Auftragsverarbeitungsvertrag Website-Analyse', seitenkopf: 'AVV Website-Analyse', nav } },
{ md: 'docs/rechtliches/leistungsbeschreibung-website-analyse.md', html: 'public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Leistungsbeschreibung Website-Analyse', seitenkopf: 'Leistungsbeschreibung Website-Analyse', nav } },
]
}
test('html_text_gleich_markdown: der sichtbare Text jeder Seite ist genau der Text der Quelle (plus Navigation und Marke)', () => {
for (const d of DOKUMENTE) {
const md = lies(d.md)
// Text der Quelle in Lesereihenfolge: Markierungen weg, Tabellenzellen einzeln, Trennzeile weg
const aus = []
for (const zeile of md.replace(/\r/g, '').split('\n')) {
if (zeile.trim() === '' || /^\|[-| ]+\|$/.test(zeile)) continue
if (zeile.startsWith('|')) aus.push(...zeile.slice(1, -1).split('|'))
else aus.push(zeile.replace(/^#{1,3} /, '').replace(/^- /, ''))
}
const erwartet = aus.join(' ').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
const html = lies(d.html)
const main = html.slice(html.indexOf('<main>'), html.indexOf('</main>')).replace(/<nav class="top">[\s\S]*?<\/nav>/, '').replace('<div class="brand">WEBKLAR</div>', '')
assert.equal(klartext(main), erwartet, `${d.html}: sichtbarer Text weicht vom Markdown ab`)
// jeder Absatz einzeln (aussagekräftige Fehlermeldung)
const text = klartext(main)
for (const stueck of aus.map((z) => z.replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean)) {
assert.ok(text.includes(stueck), `${d.html}: fehlt im HTML: ${stueck.slice(0, 80)}`)
}
}
})
test('html_sauber: keine Kommentare, Entwurfsmarken, Skripte oder Ereignis-Attribute; Fassung im Kopf; Links nur relativ oder https', () => {
for (const d of DOKUMENTE) {
const html = lies(d.html)
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|Prüfpunkt|<script|<iframe|<img|<form|<link|\sstyle=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, `${d.html}: Ereignis-Attribut`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.ok(html.includes(`<strong>Fassung ${V}</strong>`), `${d.html}: "Fassung ${V}" fehlt im Kopf`)
assert.ok(html.includes(`<meta name="webklar-fassung" content="${V}">`))
assert.match(html, /<html lang="de">/)
assert.match(html, /<title>[^<]+ – WEBklar<\/title>/)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
assert.ok(hrefs.length >= 6)
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
assert.doesNotMatch(html, /\ssrc=/)
// @page-Kopf und Seitenzähler wie die Analyse-Seiten
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.ok(html.includes('content: "Seite " counter(page) " von " counter(pages)'))
// nur ein h1, jede Seite hat die Marke
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
assert.ok(html.includes('<div class="brand">WEBKLAR</div>'))
}
})
test('generator_lehnt_unbekannte_syntax_ab: Codeblock, Zitat, Bild, Link, rohes HTML, nummerierte Liste und mehr brechen laut ab', () => {
assert.doesNotThrow(() => render(`${KOPF}## Abschnitt\n\n(1) Text mit **fett** und \`code\`.\n- Punkt\n- Punkt zwei\n\n| A | B |\n|---|---|\n| 1 | 2 |\n`, META))
const schlecht = {
Codeblock: '```js\nx\n```',
'Codeblock (Tilde)': '~~~\nx\n~~~',
Zitat: '> zitiert',
Bild: '![Bild](a.png)',
Link: 'ein [Link](https://example.com) im Text',
'rohes HTML': '<div>x</div>',
'rohes HTML im Satz': 'Text <b>fett</b>',
Kommentar: '<!-- x -->',
'nummerierte Liste': '1. Eins\n2. Zwei',
'Liste mit Stern': '* Punkt',
'Liste mit Plus': '+ Punkt',
Trennlinie: '---',
'Überschrift ab Ebene 4': '#### Tief',
'Überschrift ohne Leerzeichen': '##Tief',
Einrückung: ' eingerückt',
'Einrückung der Liste': '- Punkt\n - Unterpunkt',
'offenes fett': 'ein **offenes fett',
'offener Code': 'ein `offener Code',
'leerer Listenpunkt': '- ',
'zweites h1': '# Noch ein Titel',
'Tabelle ohne Trennzeile': '| A | B |\n| 1 | 2 |',
'Tabelle mit anderer Spaltenzahl': '| A | B |\n|---|---|\n| 1 | 2 | 3 |',
'Tabelle mit Ausrichtung': '| A | B |\n|:--|---|\n| 1 | 2 |',
}
for (const [name, text] of Object.entries(schlecht)) {
assert.throws(() => render(`${KOPF}${text}\n`, META), MarkdownFehler, `${name} muss abbrechen`)
}
// Fehlermeldung nennt Zeile und Grund
assert.throws(() => render(`${KOPF}## A\n\n> Zitat\n`, META), /Zeile 7: nicht unterstützt \(Zitat\)/)
// Kopf: erste Zeile Titel, danach der Fassungsabsatz mit der Fassung der Meta
assert.throws(() => render('## ohne Titel\n', META), MarkdownFehler)
assert.throws(() => render('# Titel\n\nText ohne Fassung\n', META), /Fassung 2026-10-05/)
assert.throws(() => render('# Titel\n\n**Fassung 2026-10-06**\n', META), /Fassung 2026-10-05/)
// Meta: Fassung im Format, Adressen der Navigation nur relativ oder https
assert.throws(() => render(`${KOPF}Text\n`, { ...META, fassung: '5.10.2026' }), /fassung/)
for (const href of ['javascript:alert(1)', '//evil.example/x', 'http://example.com', '/x"onmouseover="y', 'data:text/html,x', '/\\evil.example/x', '/x\\y', 'https://example.com\\@evil.example/']) {
assert.throws(() => render(`${KOPF}Text\n`, { ...META, nav: [{ href, text: 'x' }] }), /Adresse in der Navigation/, href)
}
assert.throws(() => parse(42), TypeError)
})
test('generator_escaped: <, & und " in Text, Code, Tabellen, Titel und Attributen werden maskiert', () => {
const md = `${KOPF}## Wert < 5 & "zitiert" > 3\n\nEin Absatz mit a < b & c, "Anführung" und 'Apostroph'.\n\n- Punkt mit < und &\n- **fett & kühn** und \`a<b && c\`\n\n| Kopf & "Z" | B < 1 |\n|---|---|\n| x & y | "z" > 0 |\n`
const html = render(md, { ...META, titel: 'Titel & "Mehr" <b>', seitenkopf: 'Kopf "x" \\ y', nav: [{ href: '/x?a=1&b=2', text: 'Link <1> & "2"' }] })
assert.ok(html.includes('<h2>Wert &lt; 5 &amp; &quot;zitiert&quot; &gt; 3</h2>'))
assert.ok(html.includes('<p>Ein Absatz mit a &lt; b &amp; c, &quot;Anführung&quot; und &#39;Apostroph&#39;.</p>'))
assert.ok(html.includes('<li>Punkt mit &lt; und &amp;</li>'))
assert.ok(html.includes('<li><strong>fett &amp; kühn</strong> und <code>a&lt;b &amp;&amp; c</code></li>'))
assert.ok(html.includes('<th>Kopf &amp; &quot;Z&quot;</th><th>B &lt; 1</th>'))
assert.ok(html.includes('<td>x &amp; y</td><td>&quot;z&quot; &gt; 0</td>'))
// Attribute und Titel
assert.ok(html.includes('<a href="/x?a=1&amp;b=2">Link &lt;1&gt; &amp; &quot;2&quot;</a>'))
assert.ok(html.includes('<title>Titel &amp; &quot;Mehr&quot; &lt;b&gt; – WEBklar</title>'))
assert.ok(html.includes('content: "WEBklar · Kopf \\"x\\" \\\\ y"'), 'CSS-Zeichenkette escaped')
// kein ungeschütztes Tag aus dem Text im Ergebnis
assert.doesNotMatch(html, /<b>|<1>/)
})
test('pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles, größer als 20 KB', () => {
const dateien = legalPdfFiles('termine', V)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const [doc, f] of Object.entries(dateien)) {
assert.match(f.path, new RegExp(`/public/rechtliches/[a-z-]+-${V}\\.pdf$`))
assert.equal(f.filename.endsWith(`-${V}.pdf`), true)
const bytes = readFileSync(f.path)
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${doc}: kein PDF`)
assert.ok(bytes.length > 20 * 1024, `${doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${doc}: PDF ist abgeschnitten`)
// mehr als eine Seite bei LB und AVV, mindestens eine bei der Belehrung
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= (doc === 'widerruf' ? 1 : 2), `${doc}: ${seiten.length} Seiten`)
// das PDF gehört zur HTML-Seite derselben Fassung: gleicher Name bis auf die Endung
const d = DOKUMENTE.find((x) => x.doc === doc)
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
}
})
test('pdf_pruefsumme: neben jedem PDF liegt <PDF>.sha256 mit der SHA-256 der HTML-Seite, aus der es gedruckt wurde (HTML geändert ohne neues PDF fällt auf)', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
for (const d of DOKUMENTE) {
const pdf = d.html.replace(/\.html$/, '.pdf')
const summe = lies(`${pdf}.sha256`)
assert.match(summe, /^[0-9a-f]{64}\n$/, `${pdf}.sha256: nur die Prüfsumme und ein Zeilenende`)
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh termine)`)
}
// die Prüfung schlägt an: eine geänderte Seite ergibt eine andere Summe
const d = DOKUMENTE[1]
assert.notEqual(sha256(`${lies(d.html)} `), lies(`${d.html.replace(/\.html$/, '.pdf')}.sha256`).trim())
// das Skript schreibt die Summe und druckt ein PDF mit gültiger Summe nicht neu
const sh = lies('scripts/build-legal-pdfs.sh')
assert.ok(sh.includes('sha256sum "$quelle"') && sh.includes('echo "$summe" > "$ziel.sha256"'))
assert.ok(sh.includes('(unveraendert)') && sh.includes('FORCE'))
})
test('anhaenge_drei: legalPdfAttachments("termine", F) liefert lb, avv, widerruf als application/pdf', async () => {
const anhaenge = await legalPdfAttachments('termine', V)
assert.deepEqual(anhaenge.map((a) => a.filename), [
`Leistungsbeschreibung-Terminplaner-${V}.pdf`,
`AVV-Terminplaner-${V}.pdf`,
`Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`,
])
const dateien = legalPdfFiles('termine', V)
for (const [i, doc] of ['lb', 'avv', 'widerruf'].entries()) {
assert.equal(anhaenge[i].contentType, 'application/pdf')
assert.ok(Buffer.isBuffer(anhaenge[i].content))
assert.deepEqual(anhaenge[i].content, readFileSync(dateien[doc].path))
assert.equal(anhaenge[i].content.subarray(0, 5).toString('latin1'), '%PDF-')
}
})
// Router wie in server/index.js: erst die Adressen ohne Fassung (legalRouter), dann die Dateien selbst (express.static)
const rechtliches = Router()
rechtliches.use(legalRouter())
rechtliches.use(express.static(join(ROOT, 'public', 'rechtliches')))
const app = await startTestApp({ '/rechtliches': rechtliches })
after(() => app.close())
test('router_alias: /rechtliches/<slug>.html liefert die Bytes der Fassungsdatei; unbekannte Fassung und alte Adressen: 404', async () => {
for (const d of DOKUMENTE) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
const alias = await fetch(`${app.url}/rechtliches/${slug}.html`)
assert.equal(alias.status, 200)
assert.match(alias.headers.get('content-type'), /text\/html/)
assert.deepEqual(Buffer.from(await alias.arrayBuffer()), readFileSync(join(ROOT, d.html)), `${slug}: Alias liefert nicht die Datei der Fassung`)
const fest = await fetch(`${app.url}/rechtliches/${slug}-${V}.html`)
assert.equal(fest.status, 200)
assert.deepEqual(Buffer.from(await fest.arrayBuffer()), readFileSync(join(ROOT, d.html)))
const pdf = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf`)
assert.equal(pdf.status, 200)
assert.match(pdf.headers.get('content-type'), /application\/pdf/)
const alt = await fetch(`${app.url}/rechtliches/${slug}-1999-01-01.html`)
assert.equal(alt.status, 404, `${slug}-1999-01-01.html darf es nicht geben`)
// die Prüfsummen-Datei neben dem PDF wird einfach mit ausgeliefert, die Adressen der Seiten bleiben davon unberührt
const summe = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf.sha256`)
assert.equal(summe.status, 200)
assert.match(await summe.text(), /^[0-9a-f]{64}\n$/)
assert.equal((await fetch(`${app.url}/rechtliches/${slug}.pdf.sha256`)).status, 404)
}
// Fassung der Konstante ohne Datei: der Alias antwortet 404
const vorher = config.termine.avvVersion
config.termine.avvVersion = '1999-01-01'
try {
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 404)
} finally {
config.termine.avvVersion = vorher
}
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 200)
})
test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt die all-Zeile unverändert und begrenzt Chrome auf 120 Sekunden', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^termine\)$/m)
assert.match(sh, /^datenschutz\)$/m)
assert.match(sh, /^analyse\)$/m)
assert.match(sh, /^all\)$/m)
assert.ok(sh.includes('TERMINE_VERSION'))
assert.ok(sh.includes('config.termine.avvVersion'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
// die unveränderte Zeile von all (und derselbe Aufruf im Modus datenschutz)
const all = sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)'))
assert.ok(all.includes(' pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf\n'))
assert.ok(all.includes(' pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf\n'))
assert.ok(all.includes(' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf\n'))
const datenschutz = sh.slice(sh.indexOf('\ndatenschutz)'), sh.indexOf('\nall)'))
assert.ok(datenschutz.includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
// die drei Dokumente des Modus termine sind die Slugs der Registry
const termine = sh.slice(sh.indexOf('\ntermine)'), sh.indexOf('\ndatenschutz)'))
for (const f of Object.values(legalPdfFiles('termine', V))) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.pdf$/.exec(f.path)[1]
assert.ok(termine.includes(slug), `Modus termine baut ${slug}`)
}
// Chrome höchstens 120 Sekunden je Aufruf; Aufbau und Aufrufe nacheinander
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
assert.match(sh, /v="\$\(timeout 60 node --max-old-space-size=256 /, 'auch der Node-Aufruf für die Fassung hat Heap-Grenze und Timeout')
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
// bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]'))
})
// ---------------------------------------------------------------------------------------------------- Webshop (Task J1)
test('shopDokumente: lb und avv mit Quelle, Ziel und Navigation auf die Fassung; die Widerrufsbelehrung gehört zum Satz termine', () => {
assert.deepEqual(SHOP.map((d) => d.doc), ['lb', 'avv'])
assert.deepEqual(
SHOP.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-webshop.md', `public/rechtliches/leistungsbeschreibung-webshop-${S}.html`],
['docs/rechtliches/avv-webshop.md', `public/rechtliches/avv-webshop-${S}.html`],
],
)
const adressen = Object.values(legalPaths('shop', S))
assert.equal(adressen.length, 3)
for (const d of SHOP) {
assert.equal(d.meta.fassung, S)
const hrefs = d.meta.nav.map((l) => l.href)
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
}
// die Widerrufsbelehrung der Funktionen wird von shop nie erzeugt (sonst änderte shop die Datei des Satzes termine)
assert.ok(!SHOP.some((d) => d.html.includes('widerrufsbelehrung-funktionen')))
assert.equal(legalPaths('shop', S).widerruf, legalPaths('termine', S).widerruf)
// Standard der Fassung ist config.shop.fassung
assert.deepEqual(shopDokumente(), SHOP)
})
test('shop_html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown); deterministisch; --check Exit 0', () => {
for (const d of SHOP) {
const md = lies(d.md)
const html = render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs shop)`)
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
assert.doesNotMatch(html.replaceAll(S, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
// Kopf und Seitenhülle wie beim Terminplaner
assert.ok(html.includes(`<strong>Fassung ${S}</strong>`))
assert.ok(html.includes(`<meta name="webklar-fassung" content="${S}">`))
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\bF\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
}
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'shop', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 2)
// der Satz termine bleibt unverändert (drei Seiten)
const termine = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(termine.status, 0, termine.stdout + termine.stderr)
assert.equal([...termine.stdout.matchAll(/^ok /gm)].length, 3)
})
test('shop_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1, schreibt nur lb und avv; unbekannter Satz: Exit 2 mit Usage', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-shop-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of SHOP) cpSync(join(ROOT, d.md), join(dir, d.md))
const zeilen = []
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
assert.equal(ruf('shop', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 2)
assert.equal(existsSync(join(dir, SHOP[0].html)), false, '--check schreibt nichts')
assert.equal(ruf('shop'), 0)
assert.equal(ruf('shop', '--check'), 0)
for (const d of SHOP) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
assert.equal(existsSync(join(dir, `public/rechtliches/widerrufsbelehrung-funktionen-${S}.html`)), false, 'shop schreibt die Widerrufsbelehrung nicht')
// Quelle geändert, Seite nicht nachgezogen
writeFileSync(join(dir, SHOP[1].md), `${lies(SHOP[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
zeilen.length = 0
assert.equal(ruf('shop', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${SHOP[1].html}`])
// falscher Aufruf
zeilen.length = 0
assert.equal(ruf('analyse'), 2)
assert.equal(ruf('shop', '--schreiben'), 2)
assert.equal(ruf(), 2)
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop\|unterkunft \[--check\]/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('shop_pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles (mehr als eine Seite, größer als 20 KB), Prüfsumme der HTML-Seite daneben', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
const dateien = legalPdfFiles('shop', S)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const d of SHOP) {
const f = dateien[d.doc]
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
const bytes = readFileSync(f.path)
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${d.doc}: kein PDF`)
assert.ok(bytes.length > 20 * 1024, `${d.doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${d.doc}: PDF ist abgeschnitten`)
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= 2, `${d.doc}: ${seiten.length} Seiten`)
const summe = lies(d.html.replace(/\.html$/, '.pdf.sha256'))
assert.match(summe, /^[0-9a-f]{64}\n$/, 'nur die Prüfsumme und ein Zeilenende')
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh shop)`)
}
// die Widerrufsbelehrung ist die gemeinsame Datei (PDF und Prüfsumme bestehen)
assert.ok(readFileSync(dateien.widerruf.path).length > 5 * 1024)
assert.match(lies(`public/rechtliches/widerrufsbelehrung-funktionen-${S}.pdf.sha256`), /^[0-9a-f]{64}\n$/)
})
test('shop_script_modus: build-legal-pdfs.sh kennt den Modus shop (Slugs der Registry, Fassung aus config.shop.fassung), all druckt ihn mit, Usage nennt ihn', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^shop\)$/m)
assert.ok(sh.includes('config.shop.fassung'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
assert.ok(sh.includes('SHOP_VERSION'))
const shop = sh.slice(sh.indexOf('\nshop_pdfs() {'), sh.indexOf('\ncase "$MODE"'))
for (const d of SHOP) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
assert.ok(shop.includes(slug), `Modus shop baut ${slug}`)
}
assert.ok(!shop.includes('widerrufsbelehrung'), 'die Widerrufsbelehrung druckt der Modus termine')
assert.ok(shop.includes('sha256sum "$quelle"') && shop.includes('echo "$summe" > "$ziel.sha256"') && shop.includes('(unveraendert)'))
assert.match(sh, /^shop\)\n {2}shop_pdfs\n {2};;$/m)
assert.ok(sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)')).includes(' shop_pdfs\n'), 'all druckt auch die Webshop-PDFs')
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]'))
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
})
// ---------------------------------------------------------------------------------------------------- Buchungskalender (Task 21)
test('unterkunftDokumente: lb, avv und widerruf mit Quelle, Ziel und Navigation auf die Fassung des Buchungskalenders', () => {
assert.deepEqual(UK.map((d) => d.doc), ['lb', 'avv', 'widerruf'])
assert.deepEqual(
UK.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-unterkunft.md', `public/rechtliches/leistungsbeschreibung-unterkunft-${U}.html`],
['docs/rechtliches/avv-unterkunft.md', `public/rechtliches/avv-unterkunft-${U}.html`],
['docs/rechtliches/widerrufsbelehrung-funktionen.md', `public/rechtliches/widerrufsbelehrung-funktionen-${U}.html`],
],
)
assert.deepEqual(Object.values(legalPaths('unterkunft', U)), UK.map((d) => d.html.replace(/^public/, '')))
assert.deepEqual(unterkunftDokumente(), UK)
// nur die Widerrufsbelehrung trägt eine fremde Fassung in der Quelle (die des Terminplaners)
assert.deepEqual(UK.map((d) => d.stempel), [undefined, undefined, config.termine.avvVersion])
})
test('unterkunft_html_gleich_generator: eingecheckte Seiten sind genau render(Markdown), Entwurfskennzeichen in LB und AVV, --check Exit 0', () => {
for (const d of UK) {
const md = d.stempel ? lies(d.md).replace(`**Fassung ${d.stempel}**`, `**Fassung ${U}**`) : lies(d.md)
const html = render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs unterkunft)`)
assert.equal(render(md, d.meta), html, 'deterministisch')
assert.ok(html.includes(`<strong>Fassung ${U}</strong>`))
assert.ok(html.includes(`<meta name="webklar-fassung" content="${U}">`))
assert.doesNotMatch(html.replaceAll(U, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
assert.doesNotMatch(html, /<!--|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=|\{\{|\}\}|undefined|NaN/, `${d.html}: verbotener Inhalt`)
if (d.doc === 'lb' || d.doc === 'avv') assert.ok(html.includes('Entwurf – Prüfung durch Kanzlei offen'), `${d.doc}: Kennzeichnung fehlt`)
else assert.ok(!html.includes('Entwurf'), 'die Widerrufsbelehrung ist kein Entwurf')
}
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'unterkunft', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 3)
})
test('unterkunft_lauf: --check meldet fehlende Seiten mit Exit 1, schreibt drei Seiten; die Fassung der Quelle der Widerrufsbelehrung wird ersetzt', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-uk-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of UK) cpSync(join(ROOT, d.md), join(dir, d.md))
const zeilen = []
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
assert.equal(ruf('unterkunft', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 3)
assert.equal(existsSync(join(dir, UK[0].html)), false, '--check schreibt nichts')
assert.equal(ruf('unterkunft'), 0)
assert.equal(ruf('unterkunft', '--check'), 0)
for (const d of UK) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
// Fassung der Quelle fehlt: laut abbrechen statt eine Seite mit falscher Fassung zu schreiben
writeFileSync(join(dir, UK[2].md), lies(UK[2].md).replace(`**Fassung ${UK[2].stempel}**`, '**Fassung ohne**'))
assert.throws(() => ruf('unterkunft'), MarkdownFehler)
assert.equal(ruf('shop', '--schreiben'), 2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('unterkunft_pdf_dateien: je Dokument ein PDF mit passender Prüfsumme der HTML-Seite', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
const dateien = legalPdfFiles('unterkunft', U)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const d of UK) {
const f = dateien[d.doc]
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
const bytes = readFileSync(f.path)
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${d.doc}: kein PDF`)
assert.ok(bytes.length > 20 * 1024, `${d.doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${d.doc}: PDF ist abgeschnitten`)
const summe = lies(d.html.replace(/\.html$/, '.pdf.sha256'))
assert.match(summe, /^[0-9a-f]{64}\n$/)
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh unterkunft)`)
}
})
test('unterkunft_script_modus: build-legal-pdfs.sh kennt den Modus unterkunft (Slugs der Registry, Fassung aus config.unterkunft.fassung), Usage nennt ihn', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^unterkunft\)$/m)
assert.ok(sh.includes('config.unterkunft.fassung') && sh.includes('UNTERKUNFT_VERSION'))
const modus = sh.slice(sh.indexOf('\nunterkunft)'), sh.indexOf('\ndatenschutz)'))
for (const d of UK) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
assert.ok(modus.includes(slug), `Modus unterkunft baut ${slug}`)
}
assert.ok(modus.includes('sha256sum "$quelle"') && modus.includes('echo "$summe" > "$ziel.sha256"') && modus.includes('(unveraendert)'))
})