Abschnitt 11: Sperre und Kürzung ab dem Lesen unserer Antwort, nur je Beitrag und E-Mail-Adresse (neues Gesuch = neuer Beitrag); aussortierte Anfragen 30 Tage; feste Ausschlussliste der Websuche samt robots.txt; Kriterium für die Dauer der Reste; Kopie der Standardvertragsklauseln; Hinweis in der ersten Nachricht nennt den Widerspruch; Abschnitt 11 und 14: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort. Alarm-Postfach-Anbieter und Aufbewahrung der Server-Sicherungen stehen als sichtbare [OFFEN]-Markierung im Text (auch in Abschnitt 12). Tests: Pins angepasst, Markierungen nur die zwei erlaubten, neuer Test für Zusagen in Reichweite des Dienstes. Upload-Skript bricht ab, solange eine [OFFEN]-Markierung steht oder die Prüfsumme neben dem PDF nicht zur HTML passt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
827 lines
64 KiB
JavaScript
827 lines
64 KiB
JavaScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import { createHash } from 'node:crypto'
|
||
import { spawnSync } from 'node:child_process'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const { config } = await import('../server/config.js')
|
||
|
||
// = `git show 18c3cdf:public/abo-bedingungen.html | sha256sum` (Fassung 3, Stand 25.09.2026).
|
||
// Fest eingetragen, weil die Tests auch ohne git laufen muessen (node:20-alpine).
|
||
const V3_SHA256 = '1c9e4b002dcd5d7896bbda24c573e56544b7123e3a88d154e5db77bd2855bc47'
|
||
|
||
// = sha256 des Blocks ab `const TAKEOVER_CONDITIONS` bis vor das schliessende ] in public/app.js, je conditionsVersion
|
||
// (Nachweis der Zustimmung). Aendert sich der Text der Bedingungen, muss config.takeover.conditionsVersion hochgezogen
|
||
// und der neue Hash hier eingetragen werden; die Fehlermeldung des Tests nennt ihn.
|
||
const TAKEOVER_CONDITIONS_SHA256 = { '2026-09-30': '2b7a745cb599c0ab28f20615a745bd86da6faa8d120391ca56beac35a0473e62' }
|
||
|
||
const file = (rel) => new URL(`../${rel}`, import.meta.url)
|
||
const read = (rel) => readFileSync(file(rel), 'utf8')
|
||
const sha256 = (rel) => createHash('sha256').update(readFileSync(file(rel))).digest('hex')
|
||
const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
|
||
|
||
// HTML-Ausschnitt von einem Anker bis zum naechsten, danach ohne Tags. Die Inhaltsuebersicht wiederholt
|
||
// die Ueberschriften, deshalb wird nicht nach dem ersten Text-Treffer, sondern nach id="..." geschnitten.
|
||
const section = (html, fromId, toId) => {
|
||
const a = html.indexOf(`id="${fromId}"`)
|
||
assert.ok(a >= 0, `Anker #${fromId} fehlt`)
|
||
const b = html.indexOf(`id="${toId}"`, a + 1)
|
||
assert.ok(b > a, `Anker #${toId} fehlt nach #${fromId}`)
|
||
return strip(html.slice(a, b))
|
||
}
|
||
const between = (s, a, b) => {
|
||
const i = s.indexOf(a)
|
||
assert.ok(i >= 0, `Text fehlt: ${a}`)
|
||
const j = s.indexOf(b, i)
|
||
assert.ok(j > i, `Text fehlt: ${b}`)
|
||
return s.slice(i, j)
|
||
}
|
||
|
||
const V3 = 'public/vertragsunterlagen/v3/abo-bedingungen.html'
|
||
// Aktuelle Fassung (5) und archivierte Fassung 4
|
||
const CUR = 'public/abo-bedingungen.html'
|
||
const V4 = 'public/vertragsunterlagen/v4/abo-bedingungen.html'
|
||
// = `git show c4fab31:public/abo-bedingungen.html | sha256sum` bzw. des PDFs (Fassung 4, Stand 30.09.2026)
|
||
const V4_SHA256 = '4c7fa07cfd8ea04672c2c1a33f7584959108f459ebdacf18dec56303b44096e9'
|
||
const V4_PDF_SHA256 = '1461137e033c1d4cafd4866851d64e0784c1335591918b622d198cd25d2adfbf'
|
||
const V4_PDF_LINK = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||
const V4_ARCHIVE_PDF_LINK = 'href="/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||
|
||
test('Fassung 3 unverändert archiviert (HTML und PDF)', () => {
|
||
assert.equal(sha256(V3), V3_SHA256)
|
||
// Das bisherige PDF bleibt die Fassung 3 und liegt identisch im Archiv
|
||
assert.equal(
|
||
sha256('public/vertragsunterlagen/v3/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'),
|
||
sha256('public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
||
)
|
||
assert.ok(read(V3).includes('Version 3 · Stand 25.09.2026'))
|
||
})
|
||
|
||
test('Widerrufsbelehrung und Muster-Widerrufsformular wortgleich mit Fassung 3', () => {
|
||
const v3 = read(V3), v4 = read(CUR)
|
||
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
|
||
const muster3 = muster(section(v3, 'widerruf', 'formular'))
|
||
assert.ok(muster3.length > 1500)
|
||
assert.equal(muster(section(v4, 'widerruf', 'formular')), muster3)
|
||
// Muster-Widerrufsformular (Anlage 2 zu Art. 246a EGBGB) ebenfalls unveraendert
|
||
const formular = (s) => between(s, 'Muster-Widerrufsformular', '(*) Unzutreffendes streichen.')
|
||
assert.equal(formular(section(v4, 'formular', 'widerruf-hinweise')), formular(section(v3, 'formular', 'avv')))
|
||
})
|
||
|
||
test('Fassung 4: beide Abos, Postfächer, Zusatzspeicher, Wechsel', () => {
|
||
const v4html = read(CUR), v4 = strip(v4html)
|
||
for (const s of ['20,00 €', '50,00 €', 'beliebig viele Postfächer', 'zusammen 5 GB', '1,00 €', 'Zusatzspeicher',
|
||
'12 Monate', 'statische', 'Hetzner', '§ 19 UStG', 'Leistungsbeschreibung A', 'Leistungsbeschreibung B',
|
||
'„Zahlungspflichtig bestellen“', '„Verträge hier kündigen“', '„Nur den E-Mail-Zusatzspeicher“', '„Speichern“',
|
||
'Vertragsbedingungen für die WEBklar-Abos']) assert.ok(v4.includes(s), s)
|
||
assert.ok(!v4.includes('Version 3'))
|
||
const hosting = section(v4html, 'hosting', 'wartung')
|
||
assert.ok(hosting.includes('1 E-Mail-Postfach'))
|
||
assert.ok(hosting.includes('statische'))
|
||
const wartung = section(v4html, 'wartung', 'widerruf')
|
||
assert.ok(wartung.includes('beliebig viele Postfächer'))
|
||
assert.ok(wartung.includes('zusammen 5 GB'))
|
||
assert.ok(!wartung.includes('1 E-Mail-Postfach'))
|
||
// Hinweise zum Widerruf bei Wechsel und Zusatzspeicher stehen ausserhalb der Musterbelehrung
|
||
const hinweise = section(v4html, 'widerruf-hinweise', 'avv')
|
||
assert.ok(hinweise.includes('Wechsel'))
|
||
assert.ok(hinweise.includes('Zusatzspeicher'))
|
||
assert.ok(hinweise.includes('Wertersatz'))
|
||
})
|
||
|
||
test('Anker der Fassung 4', () => {
|
||
const v4 = read(CUR)
|
||
for (const id of ['widerruf', 'hosting', 'wartung', 'zusatzspeicher', 'wechsel', 'avv', 'widerruf-hinweise', 'formular', 'tom', 'sub']) {
|
||
assert.equal(v4.split(`id="${id}"`).length, 2, `Anker #${id} genau einmal`)
|
||
}
|
||
// Reihenfolge: allgemeiner Teil, Leistungsbeschreibung A, B, Widerruf, Hinweise, Anlagen 1-3
|
||
const order = ['id="wechsel"', 'id="zusatzspeicher"', 'id="hosting"', 'id="wartung"', 'id="widerruf"', 'id="formular"',
|
||
'id="widerruf-hinweise"', 'id="avv"', 'id="tom"', 'id="sub"'].map((m) => v4.indexOf(m))
|
||
assert.deepEqual([...order].sort((a, b) => a - b), order)
|
||
})
|
||
|
||
test('Version und Stand passen zu termsVersion', () => {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})-v5$/.exec(config.abo.termsVersion)
|
||
assert.ok(m, `termsVersion ${config.abo.termsVersion}`)
|
||
const [, y, mo, d] = m
|
||
assert.ok(strip(read(CUR)).includes(`Version 5 · Stand ${d}.${mo}.${y}`))
|
||
assert.ok(read(CUR).includes(`content: "Version 5 · Stand ${d}.${mo}.${y}"`), 'Kopfzeile im Druck')
|
||
assert.ok(!strip(read(CUR)).includes('Version 4'))
|
||
})
|
||
|
||
test('PDF der Fassung 4 verlinkt, erzeugt und im Build-Skript', () => {
|
||
const v4 = read(CUR)
|
||
assert.ok(v4.includes('href="/WEBklar-Abo-Vertragsunterlagen.pdf"'))
|
||
assert.ok(!v4.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'))
|
||
const pdf = readFileSync(file('public/WEBklar-Abo-Vertragsunterlagen.pdf'))
|
||
assert.ok(pdf.length > 50_000 && pdf.subarray(0, 5).toString() === '%PDF-')
|
||
const build = read('scripts/build-legal-pdfs.sh')
|
||
assert.ok(build.includes('pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf'))
|
||
const erzeugt = build.split('\n').filter((l) => l.startsWith('pdf '))
|
||
assert.ok(!erzeugt.some((l) => l.includes('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')), 'Fassung 3 wird nicht neu erzeugt')
|
||
})
|
||
|
||
// --- Korrekturen aus der Prüfung (Review 1) ---
|
||
|
||
const v4Section = (fromId, toId) => section(read(CUR), fromId, toId)
|
||
|
||
test('Widerrufsfrist beginnt mit dem Vertragsschluss, nicht mit der Bestellung', () => {
|
||
const hinweise = v4Section('widerruf-hinweise', 'avv')
|
||
assert.ok(hinweise.includes('mit dem Vertragsschluss (§ 2 Abs. 3 bzw. Abs. 5)'))
|
||
assert.ok(!hinweise.includes('mit der Bestellung'))
|
||
// § 2 Abs. 3 = Vertragsschluss beim Abo, Abs. 5 = Vertragsschluss bei Wechsel und Zusatzspeicher
|
||
const html = read(CUR)
|
||
const p2 = html.slice(html.indexOf('id="p2"'), html.indexOf('id="p3"'))
|
||
// Absaetze auf oberster Ebene stehen mit genau vier Leerzeichen am Zeilenanfang (die Liste a-e in Abs. 2 mit acht)
|
||
const absaetze = [...p2.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
|
||
assert.equal(absaetze.length, 6)
|
||
assert.ok(absaetze[2].startsWith('Der Vertrag kommt zustande, sobald der Kunde den Vorgang bei Stripe abgeschlossen hat'))
|
||
assert.ok(absaetze[4].includes('Mit dem Klick auf „Zahlungspflichtig bestellen“ kommt der Vertrag zustande'))
|
||
assert.ok(hinweise.includes('gehören zu diesen Bedingungen, nicht zur gesetzlichen Widerrufsbelehrung'))
|
||
assert.ok(hinweise.includes('Mehrbetrag ist, was der Kunde seit dem Wechsel mehr gezahlt hat'))
|
||
assert.ok(hinweise.includes('Wertersatz verlangen wir dafür nicht (Nr. 4)'))
|
||
})
|
||
|
||
// Absaetze eines Paragraphen auf oberster Ebene: genau vier Leerzeichen vor <li> (verschachtelte Listen haben acht)
|
||
const absaetzeVon = (fromId, toId) => {
|
||
const html = read(CUR)
|
||
const slice = html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`))
|
||
return [...slice.matchAll(/^ <li>([\s\S]*?)<\/li>$/gm)].map((m) => strip(m[1]))
|
||
}
|
||
// Eine Zeile der Übersicht "Das Wichtigste auf einen Blick"
|
||
const uebersichtZeile = (name) => strip(between(read(CUR), `<tr><td>${name}</td><td>`, '</td></tr>'))
|
||
|
||
test('Wechsel, Plus und Minus im Portal nur ohne Kündigung und ohne offene Zahlung', () => {
|
||
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher'), speicher = absaetzeVon('zusatzspeicher', 'p11')
|
||
assert.equal(wechsel.length, 5)
|
||
assert.equal(speicher.length, 6)
|
||
// Jeder Absatz einzeln, damit ein fehlendes "nicht gekündigt" in Abs. 1 ODER Abs. 5 auffällt
|
||
assert.ok(wechsel[0].includes('nicht gekündigt'), '§ 9 Abs. 1')
|
||
assert.ok(speicher[0].includes('nicht gekündigt'), '§ 10 Abs. 1 (Bestellen und Ändern im Portal)')
|
||
assert.ok(speicher[4].includes('nicht gekündigt'), '§ 10 Abs. 5 (Verringern mit „Speichern“)')
|
||
assert.ok(speicher[4].includes('„Nur den E-Mail-Zusatzspeicher“'))
|
||
assert.ok(speicher[4].includes('kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern'))
|
||
assert.ok(!speicher[4].includes('auch eine Verringerung'))
|
||
// Übersicht: dieselben Bedingungen wie § 9 Abs. 1 und § 10 Abs. 1, kein unbedingtes "jederzeit"
|
||
for (const name of ['E-Mail-Zusatzspeicher', 'Wechsel']) {
|
||
const zeile = uebersichtZeile(name)
|
||
for (const s of ['nach Leistungsbeginn', 'nicht gekündigt', 'keine Zahlung offen']) assert.ok(zeile.includes(s), `${name}: ${s}`)
|
||
assert.ok(!/jederzeit im Kundenportal|Jederzeit zum Ende/.test(zeile), name)
|
||
}
|
||
})
|
||
|
||
test('Bestellübersichten nennen Laufzeit und Kündigungsmöglichkeit (§ 312j Abs. 2 BGB)', () => {
|
||
assert.ok(v4Section('p2', 'p3').includes('sowie Laufzeit und Kündigungsmöglichkeit'))
|
||
assert.ok(v4Section('wechsel', 'zusatzspeicher').includes('die Kündigungsmöglichkeit nach Ablauf der neuen Mindestlaufzeit'))
|
||
assert.ok(v4Section('zusatzspeicher', 'p11').includes('zum Ende jedes Abrechnungsmonats kündbar'))
|
||
})
|
||
|
||
test('Faire Nutzung der Postfächer, Hetzner benannt, Formulare, AVV-Zusatzleistungen', () => {
|
||
const leistungen = v4Section('p3', 'p4')
|
||
assert.ok(leistungen.includes('auch unentgeltlich'))
|
||
assert.ok(leistungen.includes('Versandgrenzen von Hetzner'))
|
||
assert.ok(leistungen.includes('(im Folgenden „Hetzner“)'))
|
||
assert.ok(!/\bder Anbieter\b/.test(leistungen))
|
||
assert.ok(v4Section('p6', 'p7').includes('für ihn tätige Personen'))
|
||
const hosting = v4Section('hosting', 'wartung')
|
||
assert.ok(hosting.includes('sind im Hosting-Abo nicht enthalten; eine Lösung dafür bieten wir auf Anfrage gesondert an'))
|
||
assert.ok(!hosting.includes('Formulardienst'))
|
||
const avv = v4Section('avv', 'tom')
|
||
assert.ok(avv.includes('sowie für gesondert vereinbarte Zusatzleistungen zum Abo, etwa eine Formularlösung,'))
|
||
assert.ok(avv.includes('sowie gesondert vereinbarter Zusatzleistungen'))
|
||
assert.ok(avv.includes('zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll'))
|
||
assert.ok(strip(read(CUR)).includes('über „Abo verwalten & Rechnungen“'))
|
||
})
|
||
|
||
// Text je <li> und <p>, jeweils dem innersten offenen Element zugeordnet. Text hinter einer verschachtelten
|
||
// Liste zaehlt so zum aeusseren <li> (eine Regex bis zum ersten </li> wuerde ihn uebersehen).
|
||
const textJeElement = (html) => {
|
||
const out = [], stack = []
|
||
for (const [, close, tag, text] of html.matchAll(/<(\/?)([a-zA-Z0-9]+)[^>]*>|([^<]+)/g)) {
|
||
if (text !== undefined) { if (stack.length) stack[stack.length - 1].text += text; continue }
|
||
const t = tag.toLowerCase()
|
||
if (t !== 'li' && t !== 'p') continue
|
||
if (!close) stack.push({ tag: t, text: '' })
|
||
else { const el = stack.pop(); assert.equal(el?.tag, t, `Tag-Verschachtelung bei </${t}>`); out.push(strip(el.text)) }
|
||
}
|
||
assert.equal(stack.length, 0, 'offene <li>/<p>')
|
||
return out
|
||
}
|
||
|
||
test('Jeder Preis in Absätzen und Listenpunkten mit Hinweis auf § 19 UStG', () => {
|
||
// Die Übersichtstabelle ist ausgenommen (<td>); ihre Zeile "Preise" trägt den Hinweis
|
||
assert.ok(uebersichtZeile('Preise').includes('Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.'))
|
||
const mitPreis = textJeElement(read(CUR)).filter((t) => /\d+(,\d\d)? €/.test(t))
|
||
assert.ok(mitPreis.length >= 7, `Preisstellen: ${mitPreis.length}`)
|
||
// Ein Wortlaut je Stelle: Fliesstext in Klammern, Kopfzeilen von A und B mit Punkt, § 7 Abs. 1 mit vollem Satz
|
||
const fliesstext = '(Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'
|
||
const kopfzeile = '· Endpreis, gemäß § 19 UStG ohne Umsatzsteuer'
|
||
const p7 = 'Alle Preise sind Endpreise. Als Kleinunternehmer im Sinne von § 19 UStG berechnen wir keine Umsatzsteuer.'
|
||
for (const t of mitPreis) {
|
||
assert.ok(t.includes(fliesstext) || t.includes(kopfzeile) || t.includes(p7), `ohne einheitlichen § 19-Hinweis: ${t.slice(0, 90)}`)
|
||
}
|
||
assert.ok(!strip(read(CUR)).includes('(gemäß § 19 UStG ohne Umsatzsteuer)'), 'alter Wortlaut ohne „Endpreis“')
|
||
})
|
||
|
||
test('Alle internen Links und Querverweise treffen', () => {
|
||
const html = read(CUR)
|
||
const ids = [...html.matchAll(/ id="([^"]+)"/g)].map((m) => m[1])
|
||
for (const [, ref] of html.matchAll(/href="#([^"]+)"/g)) assert.equal(ids.filter((i) => i === ref).length, 1, `#${ref}`)
|
||
// Absätze je Paragraph: <li> der ol.abs auf oberster Ebene; bei § 3 die Überschriften "(M)"
|
||
const absaetze = {}
|
||
for (const [, n, body] of html.matchAll(/<h2 id="[^"]+">§ (\d+) [^<]*<\/h2>([\s\S]*?)(?=<h2 |<p class="anlage-kopf)/g)) {
|
||
const h3 = [...body.matchAll(/<h3>\((\d+)\)/g)].map((x) => Number(x[1]))
|
||
if (h3.length) { absaetze[n] = Math.max(...h3); continue }
|
||
const start = body.indexOf('<ol class="abs">')
|
||
let depth = 0, count = 0
|
||
if (start >= 0) for (const [, close, tag] of body.slice(start).matchAll(/<(\/?)(ol|ul|li)\b[^>]*>/g)) {
|
||
if (tag === 'li') { if (!close && depth === 1) count++; continue }
|
||
depth += close ? -1 : 1
|
||
if (depth === 0) break
|
||
}
|
||
absaetze[n] = count
|
||
}
|
||
assert.equal(Object.keys(absaetze).length, 16)
|
||
const text = strip(html)
|
||
const refs = [...text.matchAll(/§ (\d+) Abs\. (\d+)/g)]
|
||
assert.ok(refs.length >= 15, `Querverweise: ${refs.length}`)
|
||
for (const [ref, n, a] of refs) assert.ok(Number(a) <= (absaetze[n] ?? 0), `${ref}: § ${n} hat ${absaetze[n] ?? 0} Absätze`)
|
||
// Leistungsbeschreibung A/B Nr. N: nummerierte <h3> der jeweiligen Beschreibung
|
||
const nummern = (fromId, toId) => [...html.slice(html.indexOf(`id="${fromId}"`), html.indexOf(`id="${toId}"`)).matchAll(/<h3>(\d+)\. /g)].map((x) => Number(x[1]))
|
||
const maxA = Math.max(...nummern('hosting', 'wartung')), maxB = Math.max(...nummern('wartung', 'widerruf'))
|
||
for (const [ref, ab, nr] of text.matchAll(/Leistungsbeschreibung ([AB]) Nr\. (\d+)/g)) assert.ok(Number(nr) <= (ab === 'A' ? maxA : maxB), ref)
|
||
for (const [ref, nr] of v4Section('hosting', 'wartung').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxA, `A: ${ref}`)
|
||
for (const [ref, nr] of v4Section('wartung', 'widerruf').matchAll(/\bNr\. (\d+)/g)) assert.ok(Number(nr) <= maxB, `B: ${ref}`)
|
||
})
|
||
|
||
// --- Follow-up vor der Rechtsrunde (Task 1, Fix 2) ---
|
||
|
||
test('Follow-up: Webmail, Überschrift der Hinweise, § 11 Abs. 3, § 4 Preiswortlaut', () => {
|
||
const html = read(CUR), text = strip(html)
|
||
// Webmail als Zugang zu den Postfaechern, einmal genannt (§ 3 Abs. 4); Übersicht und A/B nennen keine Zugangswege
|
||
const p3 = html.slice(html.indexOf('id="p3"'), html.indexOf('id="p4"'))
|
||
const abs4 = strip(between(p3, '<h3>(4) E-Mail-Postfächer</h3>', '<h3>(5)'))
|
||
assert.ok(abs4.includes('Webmail'))
|
||
assert.equal(text.split('Webmail').length - 1, 1, 'Webmail genau einmal')
|
||
// Die Hinweise betreffen auch den Widerruf des Abos selbst: kurze Überschrift, Anker bleibt
|
||
assert.ok(html.includes('<h2 id="widerruf-hinweise">Hinweise zum Widerruf</h2>'))
|
||
assert.ok(html.includes('<a href="#widerruf-hinweise">Hinweise zum Widerruf</a>'))
|
||
assert.ok(!text.includes('Hinweise zum Widerruf bei Wechsel und Zusatzspeicher'))
|
||
// § 11 Abs. 3: nur die fuer den Kunden gespeicherten Daten werden geloescht; Vertrags- und Abrechnungsdaten
|
||
// bleiben nach der Datenschutzerklaerung (Nachweis drei Jahre, Rechnungen nach § 147 AO)
|
||
const p11 = absaetzeVon('p11', 'p12')
|
||
assert.equal(p11.length, 3)
|
||
assert.ok(p11[2].includes('löschen wir die Daten, die wir für den Kunden gespeichert haben (Website bzw. Anwendungsdaten und alle Postfächer)'))
|
||
assert.ok(p11[2].includes('Vertrags- und Abrechnungsdaten bewahren wir auf, wie es die Datenschutzerklärung beschreibt (project.webklar.com/datenschutz.html)'))
|
||
assert.ok(p11[2].includes('Sicherungskopien werden spätestens mit Ablauf ihrer Aufbewahrungszeit (§ 3 Abs. 5) überschrieben'))
|
||
assert.ok(!p11[2].includes('soweit keine gesetzliche Aufbewahrungspflicht besteht'))
|
||
// § 4 Abs. 6 und 7: derselbe Wortlaut wie an den anderen Fliesstext-Preisen
|
||
const p4 = absaetzeVon('p4', 'p5')
|
||
assert.equal(p4.length, 7)
|
||
assert.ok(p4[5].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 6')
|
||
assert.ok(p4[6].includes('100 € pro Stunde (Endpreis, gemäß § 19 UStG ohne Umsatzsteuer)'), '§ 4 Abs. 7')
|
||
})
|
||
|
||
// --- Datenschutzerklärung (Plan 2, Task 2) ---
|
||
|
||
const DS = 'public/datenschutz.html'
|
||
// Ein Satz ab seinem Anfang bis zum nächsten Satzende (in den geprüften Sätzen kommt kein "Art. 6" o. ä. vor)
|
||
const sentence = (s, start) => between(s, start, '. ') + '.'
|
||
|
||
test('Datenschutz: Abos, produktneutral, Stand wie termsVersion', () => {
|
||
const ds = strip(read(DS))
|
||
for (const s of ['Abos und Zahlung über Stripe', 'UptimeRobot s. r. o.', 'Obchodná 507/2', '811 06 Bratislava',
|
||
'Standardvertragsklauseln', 'im Rahmen unserer Abos']) assert.ok(ds.includes(s), s)
|
||
// Produktneutral; keine Zusage einer Ereignisansicht (Plan 2 baut keine); UptimeRobot ist kein Unterauftragsverarbeiter
|
||
for (const s of ['insbesondere das Wartungs-Abo', 'Schließen Sie das Wartungs-Abo', 'im Rahmen des Wartungs-Abos',
|
||
'für das Wartungs-Abo über Stripe', 'Sie sehen diese Ereignisse', 'Unterauftragsverarbeiter']) assert.ok(!ds.includes(s), `noch da: ${s}`)
|
||
// Stand nicht vor dem Datum aus termsVersion (JJJJ-MM-TT-vN). Bis 30.09.2026 waren beide gleich; seit der
|
||
// Kundenansicht (01.10.2026) wird die Erklärung auch unabhängig von den Bedingungen geändert.
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
|
||
assert.ok(m, `termsVersion nicht im Format JJJJ-MM-TT-vN: ${config.abo.termsVersion}`)
|
||
const [, y, mo, d] = m
|
||
const stand = /Stand (\d{2})\.(\d{2})\.(\d{4})/.exec(ds)
|
||
assert.ok(stand, 'Stand fehlt')
|
||
assert.ok(`${stand[3]}-${stand[2]}-${stand[1]}` >= `${y}-${mo}-${d}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
|
||
assert.ok(read('scripts/build-legal-pdfs.sh').includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
|
||
})
|
||
|
||
test('Datenschutz Abschnitt 3 und 12: UptimeRobot, Drittland USA, SCC im AVV mit Kopie', () => {
|
||
const ds = strip(read(DS))
|
||
// Nur die UptimeRobot-Absätze (nach dem Hetzner-Absatz), damit "Auftragsverarbeitung" wirklich UptimeRobot meint
|
||
const robot = between(ds, 'Ob unsere Websites', '4. Cookies')
|
||
for (const s of ['UptimeRobot s. r. o., Obchodná 507/2, 811 06 Bratislava (Slowakei)', 'Adressen (URLs)', 'Daten der Besucher',
|
||
'Auftragsverarbeitung', 'außerhalb der EU', 'insbesondere in den USA', 'Standardvertragsklauseln', 'Art. 46 Abs. 2 lit. c DSGVO',
|
||
'uptimerobot.com/dpa', 'Kopie', 'lit. b', 'lit. f', 'entfernen wir']) assert.ok(robot.includes(s), `UptimeRobot: ${s}`)
|
||
const empfaenger = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
|
||
assert.ok(empfaenger.includes('UptimeRobot s. r. o.'))
|
||
assert.ok(empfaenger.includes('(insbesondere USA)'))
|
||
})
|
||
|
||
test('Datenschutz Abschnitt 7: Nachweisdaten je Bestellung, Vertragsereignisse, Kündigungs- und Widerrufsseite', () => {
|
||
const abos = between(strip(read(DS)), '7. Abos und Zahlung über Stripe', '8. E-Mail')
|
||
// Nachweisdaten (mit IP und Browser) für Abschluss, Wechsel und Zusatzspeicher-Bestellung, im Portal und bei Stripe
|
||
const nachweis = between(abos, 'Um Ihre Bestellungen nachweisen', 'Außerdem')
|
||
for (const s of ['Version der Vertragsbedingungen', 'gegebenenfalls Ihre Angaben zum Leistungsbeginn', 'IP-Adresse', 'Browserkennung',
|
||
'bei Stripe', 'Abschluss eines Abos', 'Wechsel', 'Zusatzspeicher']) assert.ok(nachweis.includes(s), `Nachweis: ${s}`)
|
||
// Ereignis-Satz nennt Kündigung und Widerruf als Ereignisse (eine "Kündigungsbestätigung" genügt nicht)
|
||
const ereignisse = sentence(abos, 'Außerdem halten wir')
|
||
for (const re of [/[ :,]Abschluss[ ,.]/, /[ :,]Wechsel[ ,.]/, /[ :,]Zusatzspeicher[ ,.]/, /[ :,]Verringerungen[ ,.]/,
|
||
/[ :,]Kündigung[ ,.]/, /[ :,]Widerruf[ ,.]/]) assert.match(ereignisse, re)
|
||
// Verringerung, Kündigung, Widerruf: nur Zeitpunkt und Status (Task 8 speichert beim Minus keine IP/Browserkennung)
|
||
assert.ok(abos.includes('nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung'))
|
||
assert.ok(abos.includes('Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“.'))
|
||
assert.ok(abos.includes('Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen'))
|
||
assert.ok(sentence(abos, 'Diese Angaben löschen wir').includes('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet'))
|
||
// Öffentliche Kündigungs- und Widerrufsseite (§§ 312k, 356a BGB): Daten, Zweck, Rechtsgrundlage, IP nur zur Begrenzung
|
||
const formulare = between(abos, 'kuendigen.html', 'Abschnitt 9')
|
||
for (const s of ['widerrufen.html', 'ohne Anmeldung', 'Namen', 'E-Mail-Adresse', 'Kundennummer', 'Eingang', 'Bestätigung', 'Team',
|
||
'Art. 6 Abs. 1 lit. c DSGVO', '§§ 312k, 356a BGB', 'lit. b', 'IP-Adresse', 'Arbeitsspeicher', 'zehn',
|
||
'drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet', 'Zahl der Pakete']) assert.ok(formulare.includes(s), `Formulare: ${s}`)
|
||
})
|
||
|
||
// --- Rechtsrunde 28.09.2026 (Spec § 14: Wechsel erst mit Zahlung, Zusatzspeicher teilweise kündbar, Aufbewahrung) ---
|
||
|
||
test('Rechtsrunde: Wechsel steht unter der Bedingung der Zahlung (§ 9 Abs. 3, § 2 Abs. 5, § 7 Abs. 4, Übersicht)', () => {
|
||
const wechsel = absaetzeVon('wechsel', 'zusatzspeicher')
|
||
assert.equal(wechsel.length, 5)
|
||
for (const s of ['Der Wechsel steht unter der Bedingung, dass die Wechselrechnung bezahlt wird.',
|
||
'Bei Zahlung per Kreditkarte wird er erst mit der erfolgreichen Zahlung wirksam.',
|
||
'Gelingt die Zahlung nicht, etwa weil der Kunde eine von seiner Bank verlangte Freigabe nicht bestätigt, wird er nicht wirksam; spätestens 23 Stunden nach der Bestellung verfällt die Wechselrechnung.',
|
||
'Bei SEPA-Lastschrift beginnt das Wartungs-Abo schon mit der Bestellung; wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt.',
|
||
'läuft das Hosting-Abo mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter; bei SEPA-Lastschrift bleibt der Abbuchungstag der Wechseltag; wir informieren den Kunden per E-Mail.',
|
||
'Das Wartungs-Abo beginnt, sobald der Wechsel wirksam ist',
|
||
// Leistung erst ab dem wirksamen Wechsel; Abbuchungstag und Mindestlaufzeit ab dem Bestelltag
|
||
'Ab Beginn des Wartungs-Abos gilt Leistungsbeschreibung B. Wechseltag ist der Tag der Bestellung; er ist der neue Abbuchungstag (§ 7 Abs. 4), und mit ihm beginnt eine neue Mindestlaufzeit von zwölf Monaten (§ 8 Abs. 1).',
|
||
'ab Beginn des Wartungs-Abos kann der Kunde weitere Postfächer beauftragen; wir richten sie ein, sobald die Wechselrechnung bezahlt ist.']) assert.ok(wechsel[2].includes(s), `§ 9 Abs. 3: ${s}`)
|
||
const p9 = v4Section('wechsel', 'zusatzspeicher')
|
||
for (const s of ['beginnt sofort mit dem Wechsel', 'Ab dem Wechseltag gilt Leistungsbeschreibung B', 'kommt er nicht zustande']) assert.ok(!p9.includes(s), `noch da: ${s}`)
|
||
// Wertersatz beim Widerruf des Wechsels erst ab Beginn des Wartungs-Abos, nicht ab der Bestellung
|
||
const hinweise = v4Section('widerruf-hinweise', 'avv')
|
||
assert.ok(hinweise.includes('für die Tage vom Beginn des Wartungs-Abos bis zum Widerruf'))
|
||
assert.ok(!hinweise.includes('vom Wechsel bis zum Widerruf'))
|
||
assert.ok(wechsel[1].includes('Wird der Wechsel wirksam, endet das Hosting-Abo im gegenseitigen Einvernehmen.'))
|
||
assert.ok(absaetzeVon('p2', 'p3')[4].includes('beim Wechsel steht er unter der Bedingung des § 9 Abs. 3 (Zahlung der Wechselrechnung)'))
|
||
assert.ok(absaetzeVon('p7', 'p8')[3].includes('Wird die Wechselrechnung nicht bezahlt, gilt § 9 Abs. 3.'))
|
||
assert.ok(absaetzeVon('p4', 'p5')[0].includes('mit der Zahlung der Wechselrechnung (§ 9 Abs. 3)'))
|
||
assert.ok(uebersichtZeile('Wechsel').includes('unter der Bedingung, dass die Wechselrechnung bezahlt wird'))
|
||
})
|
||
|
||
test('Rechtsrunde: Zusatzspeicher ganz oder teilweise kündbar, auch über „Verträge hier kündigen“ (§ 8 Abs. 7 und 8, § 10 Abs. 5, Übersicht)', () => {
|
||
const p8 = absaetzeVon('p8', 'wechsel')
|
||
assert.equal(p8.length, 8)
|
||
assert.ok(p8[6].includes('ganz oder teilweise (mit einer geringeren Zahl von Paketen) kündigen'))
|
||
assert.ok(p8[7].includes('ganz oder mit der Zahl der Pakete, die er kündigt'))
|
||
assert.ok(p8[7].includes('auch, wenn das Abo schon gekündigt ist'))
|
||
assert.ok(p8[7].includes('Den Zeitpunkt, zu dem die Kündigung wirkt, bestätigen wir'), 'passt auch zur teilweisen Kündigung des Zusatzspeichers')
|
||
const abs5 = absaetzeVon('zusatzspeicher', 'p11')[4]
|
||
assert.ok(abs5.includes('Den Zusatzspeicher ganz oder teilweise kündigen kann er jederzeit über „Verträge hier kündigen“ mit der Auswahl „Nur den E-Mail-Zusatzspeicher“ und der Zahl der Pakete oder in Textform (§ 8 Abs. 8)'))
|
||
assert.ok(abs5.includes('nach einer Kündigung des Abos kann der Kunde die Zahl der Pakete nur noch über „Verträge hier kündigen“ oder in Textform verringern'))
|
||
assert.ok(uebersichtZeile('Kündigen').includes('ganz oder mit der Zahl der Pakete'))
|
||
assert.ok(uebersichtZeile('E-Mail-Zusatzspeicher').includes('ganz oder teilweise kündbar'))
|
||
})
|
||
|
||
test('Rechtsrunde Datenschutz: Aufbewahrung nach dem Ende des letzten Abos, Zahl der Pakete, kurzer UptimeRobot-Eintrag', () => {
|
||
const html = read(DS), ds = strip(html)
|
||
assert.equal(ds.split('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet').length - 1, 3, 'Abschnitt 7 (Bestellungen, Funktionsbestellung) und Kündigungs-/Widerrufsseite')
|
||
for (const s of ['drei Jahre nach Vertragsende', 'nach dem Ende Ihres letzten Abos']) assert.ok(!ds.includes(s), `noch da: ${s}`)
|
||
// Abschnitt 6 (Kundenkonto) verweist für Abos und Bestellungen auf die Fristen in Abschnitt 7
|
||
assert.ok(between(ds, '6. Kundenportal', '7. Abos').includes('Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7.'))
|
||
// Übernahmegutschrift und Abschnitt 9 behalten ihre eigenen Fristen
|
||
assert.ok(ds.includes('sechs Monate nach der Entscheidung'))
|
||
assert.ok(ds.includes('acht Jahre auf (§ 147 AO, § 14b UStG), Handels- und Geschäftsbriefe sechs Jahre'))
|
||
assert.ok(between(ds, 'kuendigen.html', 'Abschnitt 9').includes('beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen'))
|
||
// Abschnitt 12: der UptimeRobot-Eintrag etwa so lang wie seine Nachbarn, mit Drittland und Rolle
|
||
const empfaenger = [...html.slice(html.indexOf('12. Empfänger'), html.indexOf('13. Ihre Rechte')).matchAll(/<li>([\s\S]*?)<\/li>/g)].map((m) => strip(m[1]))
|
||
const robot = empfaenger.find((e) => e.startsWith('UptimeRobot'))
|
||
assert.ok(robot.includes('Daten auch außerhalb der EU (insbesondere USA)') && robot.includes('Auftragsverarbeiter') && robot.includes('Abschnitt 3'), robot)
|
||
assert.ok(robot.length <= 135, `UptimeRobot-Eintrag zu lang: ${robot.length} Zeichen`)
|
||
})
|
||
|
||
// --- Übernahmegutschrift, Prüftexte, Leitfaden (Plan 2, Task 3) ---
|
||
|
||
test('Übernahmegutschrift und Leitfaden: produktneutral', () => {
|
||
const app = read('public/app.js')
|
||
const cond = between(app, 'const TAKEOVER_CONDITIONS', ']')
|
||
assert.ok(!cond.includes('Wartungs-Abo') && cond.includes('Ihr Abo'), 'Bedingungen nennen „Ihr Abo“ statt des Wartungs-Abos')
|
||
assert.notEqual(config.takeover.conditionsVersion, '2026-09-25', 'conditionsVersion hochgezogen')
|
||
const tc = read('server/services/takeoverCredits.js')
|
||
assert.ok(!/Wartungs-Abo (läuft|ist beendet)|kein (laufendes )?Wartungs-Abo/.test(tc), 'Prüf-Labels und Fehlermeldungen sagen „Abo“')
|
||
const lf = read('docs/leitfaden-empfehlungsgeber.html')
|
||
assert.ok(lf.includes('Hosting-Abo') && lf.includes('beliebig viele Postfächer'), 'Leitfaden nennt beide Abos')
|
||
// Festlegungen zur Umsetzung: Postfächer im Plural (Wartungs-Abo: beliebig viele), Entwurfsdatum als Version,
|
||
// Karte im Portal und Checkliste ohne Produktnamen (Preisvergleich je Produkt: test/takeoverCredits.test.js)
|
||
assert.ok(cond.includes('E-Mail-Postfächer'))
|
||
assert.match(config.takeover.conditionsVersion, /^\d{4}-\d{2}-\d{2}$/)
|
||
const condHash = createHash('sha256').update(cond).digest('hex')
|
||
assert.equal(condHash, TAKEOVER_CONDITIONS_SHA256[config.takeover.conditionsVersion],
|
||
`Bedingungen der Übernahmegutschrift geändert: conditionsVersion hochziehen und Hash eintragen (${condHash})`)
|
||
assert.ok(app.includes('sobald Ihr Abo läuft') && !app.includes('sobald Ihr Wartungs-Abo läuft'))
|
||
assert.ok(!tc.includes('durch das Wartungs-Abo ersetzt'))
|
||
// Leitfaden: beide Preise mit § 19 UStG, Zusatzspeicher, Wechsel, Gutschrift für beide Abos, Bestellweg „Verträge“
|
||
const lfText = strip(lf)
|
||
for (const s of ['zusammen 5 GB', 'Zusatzspeicher', 'Wechsel', 'Übernahmegutschrift', 'beiden Abos', '„Verträge“']) assert.ok(lfText.includes(s), s)
|
||
for (const preis of ['20,00 €', '50,00 €', '1,00 €']) {
|
||
const i = lfText.indexOf(preis)
|
||
assert.ok(i >= 0, preis)
|
||
assert.ok(lfText.slice(i, i + 80).includes('§ 19 UStG'), `${preis} ohne § 19 UStG`)
|
||
}
|
||
assert.ok(!lfText.includes('1 E-Mail-Postfach und 1 Stunde'), 'Wartungs-Abo nicht mehr mit einem Postfach')
|
||
assert.ok(!lfText.includes('Startseite'), 'kein veralteter Bestellweg')
|
||
})
|
||
|
||
test('§ 13 Abs. 3: Datenverlust-Klausel mit Ausnahme für Absatz 1 (Vorsatz, grobe Fahrlässigkeit)', () => {
|
||
const html = readFileSync(new URL('../public/abo-bedingungen.html', import.meta.url), 'utf8')
|
||
assert.ok(html.includes('Außer in den Fällen von Absatz 1 haften wir für den Verlust von Daten'))
|
||
})
|
||
|
||
/* ------------------------------------------------ Fassung 5: Unterstützung --- */
|
||
|
||
test('Fassung 4 archiviert: HTML bis auf den PDF-Link bytegleich, PDF bytegleich', () => {
|
||
const archived = read(V4)
|
||
assert.ok(archived.includes(V4_ARCHIVE_PDF_LINK), 'Archiv verlinkt das archivierte PDF')
|
||
assert.ok(!archived.includes(V4_PDF_LINK))
|
||
const original = archived.split(V4_ARCHIVE_PDF_LINK).join(V4_PDF_LINK)
|
||
assert.equal(createHash('sha256').update(original).digest('hex'), V4_SHA256)
|
||
assert.equal(sha256('public/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf'), V4_PDF_SHA256)
|
||
assert.ok(archived.includes('Version 4 · Stand 30.09.2026'))
|
||
})
|
||
|
||
// = `git show 7ae0ffd:public/abo-bedingungen.html | sha256sum` bzw. des PDFs (Fassung 5, Stand 30.09.2026)
|
||
const V5 = 'public/vertragsunterlagen/v5/abo-bedingungen.html'
|
||
const V5_SHA256 = '3b570eb2daed76d20fc5b8b9aea36a447f0481b21ac950452a82e454db45291e'
|
||
const V5_PDF_SHA256 = 'a8344e83c4d8f5ed2610048a5ea9ca6b8d2082b5648e706ea1ef01c10087e37b'
|
||
const V5_PDF_LINK = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||
const V5_ARCHIVE_PDF_LINK = 'href="/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf"'
|
||
test('Fassung 5 archiviert (vorbereitet für P10): HTML bis auf den PDF-Link bytegleich, PDF bytegleich', () => {
|
||
const archived = read(V5)
|
||
assert.equal(archived.split(V5_ARCHIVE_PDF_LINK).length, 2, 'genau ein Archiv-Link')
|
||
assert.ok(!archived.includes(V5_PDF_LINK))
|
||
assert.equal(createHash('sha256').update(archived.split(V5_ARCHIVE_PDF_LINK).join(V5_PDF_LINK)).digest('hex'), V5_SHA256)
|
||
assert.equal(sha256('public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'), V5_PDF_SHA256)
|
||
assert.ok(archived.includes('Version 5 · Stand 30.09.2026'))
|
||
})
|
||
|
||
// = `git show 3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html | sha256sum` (News-Leistungsbeschreibung, Stand 30.09.2026).
|
||
// Bestellungen seit dem 30.09.2026 haben diesem Text zugestimmt; die geltende Datei (Stand 05.10.2026) ersetzt ihn nur für künftige Bestellungen.
|
||
const NEWS_LB = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||
const NEWS_LB_ARCHIV = 'public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html'
|
||
const NEWS_LB_ARCHIV_SHA256 = '88a34b43362f0e403c5d0d64c2503d34de47860cacdab498f3cd72cee6df5bbc'
|
||
|
||
test('News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert; die geltende Datei verweist darauf', () => {
|
||
assert.equal(sha256(NEWS_LB_ARCHIV), NEWS_LB_ARCHIV_SHA256, 'das Archiv darf nie verändert werden (Bestellungen seit 2026-09-30 beruhen darauf)')
|
||
const alt = read(NEWS_LB_ARCHIV)
|
||
assert.ok(alt.includes('<p class="meta">Stand 30.09.2026</p>'))
|
||
assert.ok(alt.includes('§ 356 Abs. 4 BGB') && alt.includes('30,00 € einmalig, inklusive Umsatzsteuer'), 'der alte Wortlaut, nicht der berichtigte')
|
||
assert.ok(!alt.includes('Frühere Fassung'), 'das Archiv trägt keinen Verweis (byte-identisch mit dem Stand 3a0b008)')
|
||
// die geltende Datei bleibt die berichtigte Fassung und nennt die frühere mit Link auf das Archiv
|
||
const neu = read(NEWS_LB)
|
||
assert.notEqual(sha256(NEWS_LB), NEWS_LB_ARCHIV_SHA256)
|
||
assert.ok(neu.includes('<p class="meta">Stand 05.10.2026</p>'))
|
||
assert.ok(neu.includes('<p class="meta">Frühere Fassung vom 30.09.2026: <a href="/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html">Leistungsbeschreibung (Stand 30.09.2026)</a></p>'))
|
||
assert.equal(neu.split('funktionen-leistungsbeschreibung-2026-09-30.html').length - 1, 1, 'genau ein Verweis')
|
||
// Rest der geltenden Datei unverändert gegenüber der Fassung ohne die neue Zeile: nur diese eine Zeile kam hinzu
|
||
const ohneZeile = neu.split('\n').filter((z) => !z.includes('Frühere Fassung vom 30.09.2026')).join('\n')
|
||
assert.ok(ohneZeile.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).') && !ohneZeile.includes('§ 356 Abs. 4'))
|
||
})
|
||
|
||
test('News-Leistungsbeschreibung vom 30.09.2026: das Archiv ist bytegleich mit dem Stand 3a0b008 (nur mit Git-Verlauf)', (t) => {
|
||
const r = spawnSync('git', ['show', '3a0b008:public/vertragsunterlagen/funktionen-leistungsbeschreibung.html'], {
|
||
cwd: fileURLToPath(new URL('..', import.meta.url)),
|
||
timeout: 20000,
|
||
maxBuffer: 1 << 20,
|
||
})
|
||
if (r.status !== 0) return t.skip('Git-Verlauf mit 3a0b008 nicht vorhanden')
|
||
assert.ok(Buffer.compare(r.stdout, readFileSync(file(NEWS_LB_ARCHIV))) === 0, 'das Archiv weicht vom Stand 3a0b008 ab')
|
||
})
|
||
|
||
test('Fassung 5: Unterstützung am Ende von § 4 Abs. 4, Abs. 7 verweist auf Satz 1, 2 und 4', () => {
|
||
const text = strip(read(CUR))
|
||
// "Dazu zählen" bleibt direkt hinter Satz 1 und bezieht sich weiter auf die aufgewendete Zeit
|
||
assert.ok(text.includes('Angerechnet wird die tatsächlich aufgewendete Zeit je Auftrag, aufgerundet auf volle 15 Minuten. Dazu zählen die Umsetzung und die Abstimmung zum Auftrag'))
|
||
assert.ok(text.includes('sowie die Behebung von Fehlern, die wir zu vertreten haben. Wirkt an einem Arbeitsschritt eine weitere Person unterstützend mit, etwa bei Gestaltung, Abstimmung oder Prüfung, rechnen wir für sie die Hälfte der Arbeitszeit dieses Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede von ihnen. Im Kundenportal weisen wir jeden Arbeitsschritt mit seiner Arbeitszeit und jede Unterstützung mit Person, Tätigkeit und angerechneter Zeit einzeln aus.'))
|
||
// Hosting-Abo: Rundung (Satz 1), Abstimmung (Satz 2) und Unterstützung (Satz 4) gelten entsprechend
|
||
assert.ok(text.includes('Absatz 4 Satz 1, 2 und 4 gelten entsprechend'))
|
||
assert.ok(!text.includes('Absatz 4 Satz 1 bis 3'))
|
||
assert.ok(!text.includes('Absatz 4 Satz 1 und 2 gelten'))
|
||
})
|
||
|
||
test('Fassung 5: Widerrufsbelehrung und Formular wortgleich mit Fassung 4', () => {
|
||
const cur = read(CUR), v4 = read(V4)
|
||
const muster = (belehrung) => between(belehrung, 'Widerrufsrecht Sie haben das Recht', 'Ende der Widerrufsbelehrung')
|
||
assert.equal(muster(section(cur, 'widerruf', 'formular')), muster(section(v4, 'widerruf', 'formular')))
|
||
})
|
||
|
||
test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', () => {
|
||
const html = read('public/datenschutz.html')
|
||
const a = html.indexOf('id="news"')
|
||
assert.ok(a > html.indexOf('6. Kundenportal'), 'Abschnitt 6a steht nach Abschnitt 6')
|
||
const b = html.indexOf('<h2>7. ', a)
|
||
assert.ok(b > a)
|
||
const news = strip(html.slice(a, b))
|
||
for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s)
|
||
assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||
assert.ok(html.includes('<h2>7. Abos und Zahlung über Stripe</h2>'), 'keine Umnummerierung')
|
||
assert.ok(news.includes('nach etwa 24 Stunden') && !news.includes('spätestens nach 24 Stunden'))
|
||
assert.ok(news.includes('nach den Fristen in Abschnitt 7') && !news.includes('Abschnitt 6 und 7'))
|
||
assert.ok(!news.includes('Endet die Zusammenarbeit, schalten wir'))
|
||
|
||
const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
||
assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend'))
|
||
const text = strip(lb)
|
||
assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir'))
|
||
assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß'))
|
||
for (const s of ['30,00 €', '§ 356 Abs. 5 Nr. 2 BGB', '§ 357a Abs. 2 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
|
||
assert.ok(!text.includes('§ 356 Abs. 4') && !/inklusive Umsatzsteuer/.test(text), 'altes Zitat und alter Preistext sind weg')
|
||
|
||
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
|
||
// 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt
|
||
assert.ok(!muster.includes('nach 14 Tagen gelöscht') || strip(html).includes('Die Protokolle löschen wir nach 14 Tagen'))
|
||
assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
|
||
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
|
||
})
|
||
|
||
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
|
||
const html = read(DS)
|
||
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
|
||
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.'))
|
||
assert.ok(!html.includes('News-Beiträge anlegen, ändern oder löschen. Bestellungen'), 'der alte Satz endet nicht mehr nach den News')
|
||
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
|
||
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
|
||
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
|
||
// Stand: Tag der Änderung (TP2 F12a), nicht vor der Kundenansicht (01.10.2026) und nicht vor dem Datum aus termsVersion
|
||
const stand = /Stand (\d{2})\.(\d{2})\.(2026)/.exec(html)
|
||
assert.ok(stand, 'Stand JJ.MM.2026 fehlt')
|
||
const standTag = `${stand[3]}-${stand[2]}-${stand[1]}`
|
||
assert.ok(standTag >= '2026-10-01', `Stand ${stand[0]} liegt vor der Kundenansicht (01.10.2026)`)
|
||
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
|
||
assert.ok(terms)
|
||
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
|
||
// Stand = Tag der letzten Änderung der Erklärung; seit dem 17.10.2026 (Abschnitt 4a Reichweitenmessung auf webklar.com) nicht davor (der genaue Tag ist in legal_datenschutz_anfragen gepinnt)
|
||
assert.ok(html.includes(`<p class="meta">${stand[0]}</p>`) && standTag >= '2026-10-17', `Stand ${stand[0]} liegt vor der Reichweitenmessung (17.10.2026)`)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
|
||
|
||
/** Der Block von <h2 id="termine"> bis vor die nächste Überschrift (6d Webshop, danach 6f Buchungskalender) in public/datenschutz.html (roh, mit Tags). */
|
||
const block6c = (html = read(DS)) => {
|
||
const a = html.indexOf('<h2 id="termine">')
|
||
assert.ok(a >= 0, '<h2 id="termine"> fehlt')
|
||
const b = html.indexOf('<h2', a + 5)
|
||
assert.ok(b > a)
|
||
return html.slice(a, b)
|
||
}
|
||
|
||
test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt Rolle, Orte, Fristen und Rückkehr', () => {
|
||
const html = read(DS)
|
||
const analyse = html.indexOf('id="analyse"')
|
||
const termine = html.indexOf('id="termine"')
|
||
const sieben = html.indexOf('<h2>7. ')
|
||
assert.ok(analyse > 0 && termine > analyse && sieben > termine, 'Reihenfolge 6b < 6c < 7')
|
||
assert.equal(html.split('id="termine"').length - 1, 1)
|
||
assert.ok(html.includes('<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>'))
|
||
const roh = block6c(html)
|
||
assert.ok(!roh.includes('<!--'), 'kein Kommentar im Block')
|
||
const text = strip(roh)
|
||
for (const s of ['Auftragsverarbeiter', 'Falkenstein', 'Nürnberg', '48 Stunden', '30 Tage', '90 Tage', 'sieben Tage', 'nicht dauerhaft']) assert.ok(text.includes(s), `6c: ${s}`)
|
||
// Rolle: Auftragsverarbeiter für Gäste, Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung
|
||
assert.ok(text.includes('ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden'))
|
||
assert.ok(text.includes('Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal, für den Entwurf seines Fragebogens und für seine Bestellung'))
|
||
// Entwurf, E-Mail über Hetzner (Nürnberg, Abschnitt 8), Aufruf je Modus, keine IP in der Buchung
|
||
assert.ok(text.includes('Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung'))
|
||
assert.ok(text.includes('(Rechenzentrum Nürnberg, Abschnitt 8)'))
|
||
assert.ok(text.includes('mit der Buchung speichern wir die IP-Adresse nicht'))
|
||
assert.ok(text.includes('schon beim Laden der Seite') && text.includes('Konfiguration mit den Texten und Leistungen'))
|
||
// Ende des Abos (Wortlaut der Vorgabe)
|
||
assert.ok(text.includes('Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein.'))
|
||
// Ausnahme: das Einrichtungs-Ticket (Kurzfassung der Einstellungen, Arbeitsnachweis) bleibt, ohne Daten von Gästen (AVV Nr. 9 Abs. 6)
|
||
assert.ok(text.includes('wieder ein. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. In unseren Datensicherungen'))
|
||
assert.ok(text.includes('bleiben gelöschte Daten noch bis zu sieben Tage erhalten'))
|
||
// die Verbote der Nachbartests gelten auch hier
|
||
for (const verboten of ['Abschnitt 6 und 7', 'spätestens nach 24 Stunden', 'Endet die Zusammenarbeit, schalten wir', 'Unterauftragsverarbeiter', 'im Rahmen des Wartungs-Abos']) assert.ok(!text.includes(verboten), `noch da: ${verboten}`)
|
||
// Nummerierung 7 bis 15 unverändert
|
||
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
|
||
assert.deepEqual(titel, [
|
||
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
|
||
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick', '13. Ihre Rechte',
|
||
'14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO', '15. Änderungen',
|
||
])
|
||
})
|
||
|
||
test('legal_datenschutz_webshop: Abschnitt 6d steht zwischen 6c und 7 und nennt Rolle, Stripe Connect, Orte und Fristen', () => {
|
||
const html = read(DS)
|
||
const termine = html.indexOf('id="termine"')
|
||
const webshop = html.indexOf('id="webshop"')
|
||
const sieben = html.indexOf('<h2 id="buchungskalender">') // 6f folgt auf 6d (Buchungskalender)
|
||
assert.ok(termine > 0 && webshop > termine && sieben > webshop && html.indexOf('<h2>7. ') > sieben, 'Reihenfolge 6c < 6d < 6f < 7')
|
||
assert.equal(html.split('id="webshop"').length - 1, 1)
|
||
assert.ok(html.includes('<h2 id="webshop">6d. Webshop auf Websites unserer Kunden</h2>'))
|
||
const roh = html.slice(webshop, sieben)
|
||
assert.ok(!roh.includes('<!--'), 'kein Kommentar im Block')
|
||
const text = strip(roh)
|
||
assert.ok(text.includes('ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden'))
|
||
assert.ok(text.includes('Auftragsverarbeitungsvertrag Webshop'))
|
||
// Stripe Connect: Weitergabe von E-Mail, Website und Projektname auf Klick, Konto-ID und Stand, Rechtsgrundlage, Hinweise von Stripe
|
||
assert.ok(text.includes('Stripe Payments Europe, Limited') && text.includes('Dublin'))
|
||
assert.ok(text.includes('seine E-Mail-Adresse, die Adresse seiner Website und den Namen seines Projekts'))
|
||
assert.ok(text.includes('Wir speichern die Kennung des Stripe-Kontos und seinen Stand'))
|
||
assert.ok(text.includes('Art. 6 Abs. 1 lit. b DSGVO') && text.includes('stripe.com/de/privacy'))
|
||
assert.ok(text.includes('wir erhalten und verwahren kein Geld der Käufer'))
|
||
// Orte und Fristen wie im AVV Webshop
|
||
for (const s of ['Falkenstein', 'Nürnberg', 'nach 30 Tagen', 'drei Jahre nach dem Ende des Jahres der Bestellung', '90 Tage', 'bis zu sieben Tage', 'Sitzungsspeicher', 'nicht dauerhaft']) assert.ok(text.includes(s), `6d: ${s}`)
|
||
assert.ok(text.includes('Eine Ausnahme ist das Einrichtungs-Ticket'))
|
||
// kein Abo-Bezug: der Webshop endet mit dem Vertrag über den Betrieb der Website (Entscheidung W5)
|
||
assert.ok(text.includes('endet der Vertrag über den Betrieb seiner Website'))
|
||
assert.ok(!text.includes('Wartungs-Abo') && !text.includes('Hosting-Abo'), '6d nennt kein bestimmtes Abo')
|
||
// Empfängerliste nennt den Webshop bei Stripe
|
||
assert.ok(strip(html).includes('Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)'))
|
||
})
|
||
|
||
test('datenschutz_6c_gleich_markdown: der Block in public/datenschutz.html ist die Markdown-Quelle, die Quelle ist ohne Entwurfs- und Prüflisten-Reste', () => {
|
||
const md = read('docs/rechtliches/termine-datenschutz-6c.md').replace(/\r/g, '')
|
||
const fence = /```html\n([\s\S]*?)\n```/.exec(md)
|
||
assert.ok(fence, 'Markdown-Quelle ohne html-Block')
|
||
const zeilen = (t) => t.split('\n').map((z) => z.trim()).filter((z) => z !== '')
|
||
assert.deepEqual(zeilen(block6c()), zeilen(fence[1]), 'HTML-Block und Markdown-Quelle weichen ab')
|
||
assert.equal(md.split('```').length - 1, 2, 'genau ein Codeblock')
|
||
for (const rest of ['Einbau in TP2', 'nicht eingebaut', 'nicht veröffentlicht', 'Für die Rechtsprüfung offen', 'Entwurf für', 'anwaltlich prüfen lassen', '<!--']) assert.ok(!md.includes(rest), `noch da: ${rest}`)
|
||
assert.ok(md.startsWith('# Datenschutzerklärung von WEBklar: Abschnitt 6c'))
|
||
assert.ok(md.includes('Auftragsverarbeiter') && md.includes('6c. Online-Terminbuchung'))
|
||
})
|
||
|
||
test('legal_datenschutz_funktionsbestellung: Abschnitt 7 nennt den Nachweis der Funktionsbestellung, die Rechtsgrundlagen, die Löschfrist und die Entwurfsdaten', () => {
|
||
const abos = between(strip(read(DS)), '7. Abos und Zahlung über Stripe', '8. E-Mail')
|
||
const absatz = 'Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. '
|
||
+ 'Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. '
|
||
+ 'Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. '
|
||
+ 'Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. '
|
||
+ 'Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.'
|
||
assert.ok(abos.includes(absatz), 'Absatz zur Funktionsbestellung (wörtlich) fehlt')
|
||
assert.equal(abos.split('Bestellen Sie eine Funktion').length - 1, 1)
|
||
// der Absatz steht im HTML als eigener Absatz von Abschnitt 7
|
||
assert.ok(read(DS).includes(`<p>${absatz}</p>`))
|
||
// 6c nennt die Entwurfsdaten und die Frist ebenfalls (Spec 3.7)
|
||
assert.ok(strip(block6c()).includes('Entwurf seines Fragebogens'))
|
||
})
|
||
|
||
test('wiederkehr_satz_gleich_lb: Rückkehr des Abos innerhalb von 30 Tagen steht in 6c, Leistungsbeschreibung, AVV und K22 mit „30 Tage“ und „wieder ein“', async () => {
|
||
const { readdirSync } = await import('node:fs')
|
||
const k22 = readdirSync(new URL('../server/mail-templates/', import.meta.url)).filter((f) => /^K22 .*\.html$/.test(f))
|
||
assert.equal(k22.length, 1, 'genau eine Vorlage K22')
|
||
const quellen = {
|
||
'6c': block6c(),
|
||
Leistungsbeschreibung: read('docs/rechtliches/leistungsbeschreibung-terminplaner.md'),
|
||
AVV: read('docs/rechtliches/avv-terminplaner.md'),
|
||
K22: read(`server/mail-templates/${k22[0]}`),
|
||
}
|
||
for (const [name, quelle] of Object.entries(quellen)) {
|
||
// Absatz = Markdown-Absatz, <p> oder Tabellenzelle der Mail
|
||
const absaetze = quelle.replace(/\r/g, '').split(/\n\s*\n|<\/p>|<\/td>/).map(strip)
|
||
const treffer = absaetze.filter((a) => a.includes('wieder ein') && a.includes('Wartungs-Abo'))
|
||
assert.equal(treffer.length, 1, `${name}: genau ein Absatz zur Rückkehr`)
|
||
assert.ok(treffer[0].includes('30 Tage'), `${name}: Rückkehr ohne 30 Tage`)
|
||
// die Rückkehr steht im Zusammenhang mit der Löschung nach 30 Tagen (nicht nur irgendwo im Absatz)
|
||
const i = treffer[0].indexOf('wieder ein')
|
||
assert.ok(treffer[0].lastIndexOf('30 Tage', i) >= 0, `${name}: die 30 Tage stehen vor der Rückkehr`)
|
||
}
|
||
})
|
||
|
||
test('pdf_pruefsumme: neben dem PDF der Datenschutzerklärung liegt .sha256 mit der SHA-256 von public/datenschutz.html (HTML geändert ohne neues PDF fällt auf)', () => {
|
||
const summe = read('docs/WEBklar-Datenschutzerklaerung.pdf.sha256')
|
||
assert.match(summe, /^[0-9a-f]{64}\n$/, 'nur die Prüfsumme und ein Zeilenende')
|
||
assert.equal(summe.trim(), sha256(DS), 'public/datenschutz.html wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh datenschutz)')
|
||
// eine Änderung am HTML ergäbe eine andere Prüfsumme
|
||
assert.notEqual(createHash('sha256').update(`${read(DS)} `).digest('hex'), summe.trim())
|
||
// das Skript schreibt die Prüfsumme und druckt bei passender Prüfsumme nicht neu (FORCE=1 druckt neu)
|
||
const sh = read('scripts/build-legal-pdfs.sh')
|
||
const modus = sh.slice(sh.indexOf('\ndatenschutz)'), sh.indexOf('\nall)'))
|
||
assert.ok(modus.includes('sha256sum "$quelle"') && modus.includes('echo "$summe" > "$ziel.sha256"'))
|
||
assert.ok(modus.includes('${FORCE:-}') && modus.includes('(unveraendert)'))
|
||
assert.ok(modus.includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
|
||
})
|
||
|
||
test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 KB, %PDF) und das Skript baut es im Modus datenschutz', () => {
|
||
const pdf = readFileSync(file('docs/WEBklar-Datenschutzerklaerung.pdf'))
|
||
assert.ok(pdf.length > 20 * 1024, `PDF zu klein: ${pdf.length} Bytes`)
|
||
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
|
||
assert.ok(pdf.subarray(-2048).toString('latin1').includes('%%EOF'), 'PDF vollständig geschrieben')
|
||
const skript = read('scripts/build-legal-pdfs.sh')
|
||
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Reichweitenmessung auf webklar.com (eigene Website-Analyse)
|
||
|
||
test('legal_datenschutz_4a: eigene Website-Analyse auf webklar.com mit Rolle, Rechtsgrundlagen, Fristen und Zusatzseite', () => {
|
||
const html = read(DS)
|
||
const ds = strip(html)
|
||
// Der frühere Satz "keine Analyse-, Tracking- oder Marketing-Werkzeuge" gilt nur noch mit Ausnahme der eigenen Messung
|
||
assert.ok(ds.includes('Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten'))
|
||
assert.ok(!ds.includes('Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.'))
|
||
assert.ok(ds.includes('Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).'))
|
||
assert.ok(html.includes('<tr><td>wk-consent (lokaler Speicher, webklar.com)</td>'))
|
||
// 4a steht zwischen 4 und 5
|
||
const i4 = html.indexOf('<h2>4. Cookies und Speicher im Browser</h2>')
|
||
const i4a = html.indexOf('<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>')
|
||
const i5 = html.indexOf('<h2>5. Kontaktaufnahme</h2>')
|
||
assert.ok(i4 > 0 && i4 < i4a && i4a < i5, 'Reihenfolge 4, 4a, 5')
|
||
const block = strip(html.slice(i4a, i5))
|
||
for (const s of ['für webklar.com sind wir selbst Verantwortlicher', 'Hetzner Online GmbH in Deutschland', 'ohne Cookies',
|
||
'Art. 6 Abs. 1 lit. f DSGVO', '§ 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO', 'Global Privacy Control', 'Keks-Symbol',
|
||
'nach 30 Tagen', 'nach 25 Monaten', 'täglich wechselt', 'webklar.com/datenschutz-analyse.html']) assert.ok(block.includes(s), s)
|
||
assert.ok(html.includes('<a href="https://webklar.com/datenschutz-analyse.html">'))
|
||
// Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach
|
||
assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)'))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Abschnitt 11 Interessenten, Weg B (nur der heute laufende Weg: öffentliche Anfragen)
|
||
|
||
test('legal_datenschutz_anfragen: Abschnitt 11 beschreibt nur den Anfrage-Weg (KI bewertet, Mitarbeiter sendet von Hand), mit Anker, Quelle, Daten, Rechtsgrundlage, Fristen und Widerspruch', () => {
|
||
const html = read(DS)
|
||
// Stand-Pin: der Tag der Fassung (das endgültige Datum setzt der Push, dann auch hier)
|
||
assert.ok(html.includes('<p class="meta">Stand 22.10.2026</p>'), 'Stand 22.10.2026')
|
||
assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten: Antworten auf öffentliche Anfragen<\/h2>/)
|
||
assert.equal(html.split('id="interessenten"').length - 1, 1, 'der Anker steht genau einmal da')
|
||
const s = between(strip(html), '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||
for (const t of ['Art. 14 DSGVO', 'KI-Assistent', 'Claude von Anthropic', 'von Hand', 'Automatische E-Mails verschicken wir nicht', 'Für E-Mails gilt Abschnitt 8',
|
||
// Quellen: Alarm-E-Mails und Websuche; Plattformen mit Bot-Verbot ausgenommen
|
||
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus',
|
||
// feste Ausschlussliste (so wie BOT_VERBOT_DOMAINS im Dienst) statt eines Kriteriums
|
||
'Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren robots.txt den Abruf sperrt',
|
||
'nicht nach bestimmten Personen',
|
||
// Daten
|
||
'Titel des Beitrags', 'Beitragstext (höchstens 4000 Zeichen)', 'den Link', 'Ort und Postleitzahl', 'die Plattform', 'Punktzahl mit kurzer Begründung', 'E-Mail-Adresse aus dem Beitrag',
|
||
'Ein eigenes Feld für Ihren Namen gibt es nicht', 'Telefonnummern entfernen wir beim Speichern automatisch', 'Faustregel',
|
||
// Rechtsgrundlage, Empfänger
|
||
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com)',
|
||
// keine automatisierte Einzelentscheidung: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort
|
||
'Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht', 'Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14)',
|
||
// Hinweis in der ersten Nachricht (der Dienst setzt ihn in Claras Entwurf; Wortlaut in text-weg-b-aenderungen.md)
|
||
'In unserer ersten Nachricht steht, dass eine KI-Assistentin von WEBklar antwortet', 'in unserer Datenbank speichern, dass Sie widersprechen können',
|
||
// Fristen und Reste: aussortierte Anfragen 29/30 Tage (Regel B), Absage und Widerspruch erst ab dem Lesen unserer Seite
|
||
'spätestens 90 Tage nach dem Fund', 'Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten innerhalb von 30 Tagen nach dem Fund',
|
||
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben', 'sperren und kürzen wir sie sofort, sobald wir Ihren Widerspruch lesen', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
|
||
'jedoch ohne Titel, Text, Link und Kontaktdaten', 'bereinigen die zugehörigen Einträge im Protokoll', 'Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9',
|
||
// Dauer der Reste (Art. 14 Abs. 2 lit. a: Kriterium)
|
||
'so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse erneut ansprechen',
|
||
// Kopien
|
||
'spätestens nach 90 Tagen', 'Datensicherungen unseres Servers', 'Geschäftsbrief',
|
||
// Widerspruch
|
||
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com',
|
||
'Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse',
|
||
'Wir erkennen Sie dabei nur am Beitrag und an der E-Mail-Adresse', 'Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag; sagen Sie uns dann bitte noch einmal Bescheid']) assert.ok(s.includes(t), t)
|
||
// der Widerspruch steht im Kasten (box widerspruch) im Abschnitt 11
|
||
const kasten = html.slice(html.indexOf('<a id="interessenten">'), html.indexOf('<h2>12. Empfänger im Überblick</h2>'))
|
||
assert.ok(kasten.includes('<div class="box widerspruch">'))
|
||
})
|
||
|
||
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2); offene Tatsachen stehen als sichtbare [OFFEN]-Markierung im Text', () => {
|
||
const html = read(DS)
|
||
const ds = strip(html)
|
||
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||
for (const t of ['Gratis-Vorschau', 'höchstens zwei E-Mails', 'höchstens drei Betriebe', 'schreibt und versendet', 'Antworten sortiert', 'Place-ID', 'Impressum', 'OpenStreetMap', 'Overpass', 'Resend', 'Cloudflare',
|
||
'Abweichend von Abschnitt 8', 'Abmelde-Link', 'bei Heilberufen', 'Geschäftsdaten in geringem Umfang', 'sechs Monate nach der letzten Nachricht', 'zwölf Monaten', 'Branchenverzeichnisse', 'nur für diese Antwort',
|
||
'Postfach bei der Hetzner', 'In allen Fällen bleiben nur']) assert.ok(!s.includes(t), `noch da (Stufe 2 oder zu viel versprochen): ${t}`)
|
||
// Resend, Cloudflare und Overpass stehen nirgends in der Erklärung
|
||
for (const t of ['Resend', 'Cloudflare', 'Overpass']) assert.ok(!ds.includes(t), `${t} gehört nicht in die Weg-B-Fassung`)
|
||
// Offene Tatsachen (Faktenblatt) stehen als sichtbare Markierung im Text, solange sie ungeklärt sind; der Upload (scripts/datenschutz-hochladen.mjs)
|
||
// bricht ab, solange eine Markierung steht. Erlaubt sind nur diese beiden; jede steht im Span class="offen" (gelb hinterlegt). Sind sie geklärt, fallen sie weg.
|
||
const ERLAUBT = new Set(['[OFFEN: Anbieter Alarm-Postfach]', '[OFFEN: Aufbewahrung der Server-Sicherungen]'])
|
||
const sichtbar = html.replace(/<!--[\s\S]*?-->/g, '')
|
||
const marken = [...sichtbar.matchAll(/\[OFFEN[^\]]*\]/g)].map((m) => m[0])
|
||
for (const m of marken) assert.ok(ERLAUBT.has(m), `unerlaubte offene Markierung: ${m}`)
|
||
assert.equal(marken.length, (sichtbar.match(/<span class="offen">\[OFFEN: [^\]<]*\]<\/span>/g) ?? []).length, 'jede Markierung steht im Span class="offen"')
|
||
assert.ok([0, 2].includes(marken.filter((m) => m === '[OFFEN: Anbieter Alarm-Postfach]').length), 'Alarm-Postfach: im Absatz über Kopien und in Abschnitt 12 (oder in beiden geklärt)')
|
||
// keine anderen Platzhalter
|
||
assert.ok(!/\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(sichtbar.replace(/\[OFFEN[^\]]*\]/g, ''))), 'keine anderen Platzhalter im sichtbaren Text')
|
||
})
|
||
|
||
test('legal_datenschutz_anfragen: Zusagen nur in der Reichweite des Dienstes (Sperre nur je Beitrag und E-Mail-Adresse, Fristen ab dem Lesen), keine zu weiten Aussagen', () => {
|
||
const ds = strip(read(DS))
|
||
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||
// der Dienst sperrt nur denselben Beitrag oder dieselbe Adresse und erreicht keine Antwort auf einer Plattform: keine Zusage "nicht mehr schreiben" ohne Einschränkung
|
||
for (const t of ['schreiben Ihnen nicht mehr', 'melden uns nicht wieder', 'sperren Ihre Daten dann sofort', 'derzeit unter anderem Facebook', 'Plattformen, die automatisierte Zugriffe untersagen', 'entscheidet nicht allein', 'kurze Zeit enthalten'])
|
||
assert.ok(!ds.includes(t), `zu weit: ${t}`)
|
||
assert.ok(s.includes('schreiben Ihnen dazu nicht mehr'), 'die Sperre gilt "dazu", also für den Beitrag und die Adresse')
|
||
})
|
||
|
||
test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11 und es gibt keinen Widerspruch zu 8', () => {
|
||
const ds = strip(read(DS))
|
||
const s2 = between(ds, '2. Geltungsbereich', '3. Hosting')
|
||
assert.ok(s2.includes('Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen') && s2.includes('Privatpersonen') && s2.includes('Frage-Foren') && s2.includes('(Abschnitt 11)'))
|
||
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
|
||
assert.ok(emp.includes('Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)'))
|
||
assert.ok(emp.includes('Plattformen, auf denen wir Ihre Anfrage beantworten'))
|
||
// der Anbieter des Alarm-Postfachs steht in der Liste, solange er offen ist als Markierung (danach als Name)
|
||
assert.ok(emp.includes('E-Mail-Postfach für die Benachrichtigungen der Plattformen (Abschnitt 11)'))
|
||
assert.ok(!emp.includes('Betrieben') && !emp.includes('Website-Texte'), 'der Anthropic-Zweck nennt keine Website-Texte von Betrieben mehr')
|
||
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
|
||
assert.ok(s14.includes('keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO'))
|
||
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie nur eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
|
||
assert.ok(!ds.includes('keine automatisierten Entscheidungen'), 'die zu weite alte Überschrift ist weg')
|
||
// Einleitung nennt die Antwort auf öffentliche Anfragen
|
||
assert.ok(ds.includes('oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten'))
|
||
// Abschnitt 8 (E-Mail über Hetzner) bleibt unverändert gültig, Abschnitt 11 verweist darauf, statt davon abzuweichen
|
||
assert.ok(between(ds, '8. E-Mail', '9. Buchhaltung').includes('versenden und empfangen wir über den E-Mail-Dienst der Hetzner Online GmbH'))
|
||
})
|