577 lines
35 KiB
JavaScript
577 lines
35 KiB
JavaScript
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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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: '',
|
||
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 < 5 & "zitiert" > 3</h2>'))
|
||
assert.ok(html.includes('<p>Ein Absatz mit a < b & c, "Anführung" und 'Apostroph'.</p>'))
|
||
assert.ok(html.includes('<li>Punkt mit < und &</li>'))
|
||
assert.ok(html.includes('<li><strong>fett & kühn</strong> und <code>a<b && c</code></li>'))
|
||
assert.ok(html.includes('<th>Kopf & "Z"</th><th>B < 1</th>'))
|
||
assert.ok(html.includes('<td>x & y</td><td>"z" > 0</td>'))
|
||
// Attribute und Titel
|
||
assert.ok(html.includes('<a href="/x?a=1&b=2">Link <1> & "2"</a>'))
|
||
assert.ok(html.includes('<title>Titel & "Mehr" <b> – 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)'))
|
||
})
|