Rechtstexte: AVV Terminplaner

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 02:57:05 +00:00
1 parent 6c474c46c3
commit 24cb549e78
2 files changed
+647 -4

No files matched your search

+502 -4
View File
@@ -1,9 +1,38 @@
import { test } from 'node:test'
import { test, after, mock } from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import { featureConfig } from '../server/services/featureRegistry.js'
import { LOESCHFRIST_TAGE } from '../server/services/termine/frist.js'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { TERMINE_COLLECTIONS, TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Nachbau von Appwrite fuer den Test avv_keine_ip_in_buchung (eine echte Buchung ueber halte); config erst danach importieren
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
termineConfig: schemaKeys('termineConfig'),
termineBookings: schemaKeys('termineBookings'),
termineClaims: schemaKeys('termineClaims'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: TERMINE_DATETIMES,
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { featureConfig } = await import('../server/services/featureRegistry.js')
const { LOESCHFRIST_TAGE } = await import('../server/services/termine/frist.js')
const { ENTWURF_TAGE } = await import('../server/services/termine/entwuerfe.js')
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
const { datenschutzAbsatz } = await import('../server/services/termine/rechtstexte.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { halte } = await import('../server/services/termine/ablauf.js')
after(() => setMailTransportForTests(null))
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
// Die Markdown-Dateien sind die Quelle der ausgelieferten Seiten (scripts/build-legal-html.mjs, Task F6).
@@ -236,3 +265,472 @@ test('lb_inhalt: Rangfolge, Gästetexte, Gesundheitsbereich, Datenschutz-Absatz,
const nr10 = norm(abschnitt(LB, 10).text)
assert.ok(nr10.includes('Mit dem Widerruf endet der Terminplaner.'))
})
// ============================================================================================ AVV Terminplaner (Task F5)
const AVV_PFAD = 'docs/rechtliches/avv-terminplaner.md'
const AVV_MD = lies(AVV_PFAD)
const AVV = lese(AVV_MD)
const ABO = lies('public/abo-bedingungen.html')
const JOBS = lies('server/services/termine/jobs.js')
const OUTBOX = lies('server/services/termine/outbox.js')
const avvNr = (n) => norm(abschnitt(AVV, n).text)
const absatzTexte = (a) => a.text.split(/^\(\d+\) /m).slice(1).map(norm)
test('avv_aufbau: Titel, Kopf mit Fassung, Parteien, Vorbemerkung, Nr. 1 bis 12 und Anlage', () => {
assert.deepEqual(AVV.h1, ['# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)'])
assert.equal(AVV_MD.split('\n')[0], '# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)')
assert.equal(AVV.kopf, `**Fassung ${V}**`)
assert.equal(
AVV_MD.split('\n')[4],
'zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).',
)
assert.deepEqual(AVV.abschnitte.map((a) => a.titel), [
'Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen',
'1. Gegenstand und Dauer',
'2. Art und Zweck der Verarbeitung',
'3. Datenarten und Betroffene',
'4. Weisungen',
'5. Vertraulichkeit',
'6. Technische und organisatorische Maßnahmen',
'7. Unterauftragsverarbeiter',
'8. Unterstützung des Kunden, Betroffenenrechte',
'9. Löschung und Rückgabe',
'10. Nachweise und Kontrollen',
'11. Pflichten des Kunden',
'12. Haftung, Änderungen, Schlussbestimmungen',
'Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)',
])
// Absätze sind lückenlos nummeriert; Nr. 5 und Nr. 10 bestehen aus einem Absatz ohne Nummer, die Anlage ist eine Tabelle
for (const a of AVV.abschnitte) {
if (['5', '10'].includes(a.nummer) || a.titel.startsWith('Anlage:')) {
assert.deepEqual(a.absaetze, [], `${a.titel}: kein nummerierter Absatz`)
} else {
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
}
}
// Vorbemerkung hat drei Absätze (Abschluss, Mitarbeiter, Verhältnis zu Anlage 1)
assert.deepEqual(AVV.abschnitte[0].absaetze, [1, 2, 3])
})
test('avv_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
assert.doesNotMatch(AVV_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
assert.doesNotMatch(AVV_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
assert.doesNotMatch(AVV_MD, /undefined|null|NaN/)
assert.ok(!/```|^>|<[a-z]/im.test(AVV_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
// keine Du-Form: der AVV spricht vom Kunden in der dritten Person
assert.doesNotMatch(AVV_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
assert.ok(/\bder Kunde\b/.test(AVV_MD))
// Tabelle: genau zwei Zellen je Zeile, kein weiteres | im Zelleninhalt
const zeilen = AVV_MD.split('\n').filter((z) => z.startsWith('|'))
assert.ok(zeilen.length >= 13, 'Kopf, Trennzeile und die Zeilen der Anlage')
for (const z of zeilen) assert.equal(z.split('|').length, 4, `Tabellenzeile mit mehr als zwei Zellen: ${z.slice(0, 40)}`)
assert.equal(zeilen[0], '| Bereich | Maßnahmen |')
assert.equal(zeilen[1], '|---|---|')
})
test('avv_unterauftragsverarbeiter: Nr. 7 nennt Hetzner mit Anschrift, Falkenstein und Nürnberg, kein Drittland', () => {
const nr7 = avvNr(7)
const abs1 = absatzTexte(abschnitt(AVV, 7))[0]
assert.ok(abs1.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
assert.ok(abs1.includes('Falkenstein') && abs1.includes('Nürnberg'))
assert.match(abs1, /Betrieb des Servers[\s\S]*Sicherung[\s\S]*Falkenstein[\s\S]*Versand der Buchungs-E-Mails[\s\S]*Webhostings[\s\S]*Nürnberg/)
assert.ok(abs1.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
assert.doesNotMatch(nr7, /\bUSA\b|Vereinigte(n)? Staaten|Standardvertragsklauseln|Angemessenheitsbeschluss|Data Privacy Framework|Drittland(?!länder)/)
// Muster Analyse-AVV Nr. 7: vier Wochen Vorlauf, zwei Wochen Widerspruch, Verpflichtung nach Art. 28 Abs. 4
assert.ok(nr7.includes('mindestens vier Wochen vorher in Textform'))
assert.ok(nr7.includes('innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen'))
assert.ok(nr7.includes('Art. 28 Abs. 4 DSGVO'))
// Stripe betrifft nur den Vertrag zwischen Kunde und WEBklar
assert.ok(nr7.includes('Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar.'))
assert.ok(nr7.includes('Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.'))
// dieselben Orte wie Anlage 3 der Abo-Bedingungen (Server: Falkenstein, E-Mail-Postfächer/Webhosting: Nürnberg)
assert.ok(ABO.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
assert.ok(ABO.includes('Deutschland (Rechenzentrum Falkenstein)') && ABO.includes('Deutschland (Rechenzentrum Nürnberg)'))
})
/** Die Konstante `const NAME = N * (STUNDE|TAG)_MS` aus einem Quelltext als { n, einheit }. */
function konstante(quelle, name) {
const m = new RegExp(`const ${name} = (\\d+) \\* (STUNDE|TAG)_MS`).exec(quelle)
assert.ok(m, `${name} fehlt im Quelltext`)
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : 'Tage' }
}
/** Eingabe einer gültigen Einstellung (wie test/termineAblaufGast.test.js, ohne aufbewahrungTage). */
const R1 = 'r_aaaaaaa1'
const S_SOFORT = 's_bbbbbbb1'
const eingabeEinstellung = (over = {}) => ({
betrieb: {
name: 'Salon Müller',
email: 'info@salon.de',
telefon: '06301 1234',
adresse: 'Hauptstr. 1, 67722 Winnweiler',
websiteUrl: 'https://salon.de',
impressumUrl: 'https://salon.de/impressum',
datenschutzUrl: 'https://salon.de/datenschutz',
},
anrede: 'sie',
bundesland: 'RP',
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1] }],
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
erinnerung: { anbieten: true, stundenVorher: 24 },
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
recht: { gaesteSind: 'verbraucher', freizeit: false },
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
...over,
})
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
const einstellungMit = (over = {}) => normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
test('avv_loeschfristen_gleich_code: Nr. 9 nennt genau die Fristen, die der Löschlauf anwendet', () => {
const nr9 = avvNr(9)
const zahlen = (re) => {
const m = re.exec(nr9)
assert.ok(m, `Nr. 9: Satz fehlt oder ist verändert: ${re}`)
return m.slice(1).map(Number)
}
// (1) nicht bestätigt: 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten (jobs.js VERFALLEN_BEHALTEN_MS, config.termine.holdMinutes)
const [verfall, haltezeit] = zahlen(/Nicht bestätigte Buchungen löscht WEBklar (\d+) Stunden nach Ablauf der Bestätigungsfrist von (\d+) Minuten\./)
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: verfall, einheit: 'Stunden' })
assert.equal(config.termine.holdMinutes, haltezeit)
// (2) abgesagt/abgelehnt: 30 Tage (jobs.js ABGESAGT_BEHALTEN_MS)
const [abgesagt] = zahlen(/Abgesagte und abgelehnte Termine löscht WEBklar (\d+) Tage nach der Absage oder Ablehnung\./)
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: abgesagt, einheit: 'Tage' })
// (3) bestätigt/erledigt: Vorgabe 90, einstellbar 14 bis 365 Tage nach Terminende (jobs.js AUFBEWAHRUNG_*, einstellungen.js)
const [vorgabe, minimum, maximum] = zahlen(/die Vorgabe sind (\d+) Tage, einstellbar sind (\d+) bis (\d+) Tage\./)
const konst = (name) => Number(new RegExp(`const ${name} = (\\d+)`).exec(JOBS)?.[1])
assert.deepEqual([konst('AUFBEWAHRUNG_VORGABE'), konst('AUFBEWAHRUNG_MIN'), konst('AUFBEWAHRUNG_MAX')], [vorgabe, minimum, maximum])
assert.equal(einstellungMit().einstellung.aufbewahrungTage, vorgabe, 'Vorgabe der Einstellung')
for (const tage of [minimum, maximum]) assert.equal(einstellungMit({ aufbewahrungTage: tage }).einstellung.aufbewahrungTage, tage)
for (const tage of [minimum - 1, maximum + 1]) assert.ok(einstellungMit({ aufbewahrungTage: tage }).fehler?.aufbewahrungTage, `${tage} Tage sind nicht erlaubt`)
assert.ok(nr9.includes('gerechnet ab dem Ende des Termins'))
// (4) Versandwarteschlange: gesendet 7 Tage, nicht zustellbar 48 Stunden (outbox.js GESENDET_BEHALTEN_MS, FEHLER_BEHALTEN_MS)
const [gesendet, fehler] = zahlen(/löscht den Eintrag (\d+) Tage nach dem Versand\. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt (\d+) Stunden nach dem endgültigen Fehlschlag\./)
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: gesendet, einheit: 'Tage' })
assert.deepEqual(konstante(OUTBOX, 'FEHLER_BEHALTEN_MS'), { n: fehler, einheit: 'Stunden' })
assert.ok(nr9.includes('leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand'))
// (6) Abschalten: LOESCHFRIST_TAGE (termine/frist.js), dieselbe Zahl wie in der Leistungsbeschreibung Nr. 8
const [abschalten] = zahlen(/löscht WEBklar alle Termindaten und die Einstellungen (\d+) Tage danach\./)
assert.equal(abschalten, LOESCHFRIST_TAGE)
assert.equal(LOESCHFRIST_TAGE, 30)
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'), 'jobs.js rechnet mit LOESCHFRIST_TAGE')
assert.ok(nr9.includes('Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung).'))
assert.ok(nr9.includes('kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren'))
// (7) Entwürfe: ENTWURF_TAGE (entwuerfe.js)
const [entwurf] = zahlen(/Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar (\d+) Tage nach der letzten Änderung\./)
assert.equal(ENTWURF_TAGE, entwurf)
// (8) Sicherung: bis zu sieben Tage (§ 3 Abs. 5 der Abo-Bedingungen)
assert.ok(nr9.includes('Gelöschte Daten bleiben dort bis zu sieben Tage erhalten'))
assert.ok(ABO.includes('Verfügbar sind jeweils die letzten sieben Tagessicherungen.'))
// der Löschlauf läuft täglich und löscht nie vor der Frist (Deckel: der Rest folgt am nächsten Tag)
assert.ok(nr9.includes('Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist.'))
assert.ok(lies('server/index.js').includes('setInterval(termineTagesJob, 24 * 60 * 60 * 1000)'))
})
test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschutz-Absatz überein, den der Kunde veröffentlicht', () => {
const { einstellung } = einstellungMit()
const absatz = norm(datenschutzAbsatz(einstellung, 'sie'))
assert.ok(absatz.includes(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`))
assert.ok(absatz.includes('löschen wir 48 Stunden nach Ablauf dieser Frist'))
assert.ok(absatz.includes(`Bestätigte Termine löschen wir ${einstellung.aufbewahrungTage} Tage nach dem Termin`))
assert.ok(absatz.includes('abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung'))
assert.ok(absatz.includes(`löschen wir alle Termindaten ${LOESCHFRIST_TAGE} Tage nach dem Abschalten`))
assert.ok(absatz.includes('nicht zustellbare E-Mails nach 48 Stunden'))
assert.ok(absatz.includes('Rechenzentren Falkenstein und Nürnberg') && absatz.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
assert.ok(absatz.includes('Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen'))
// der AVV nennt dieselben Zahlen
const nr3 = avvNr(3)
assert.ok(nr3.includes('In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).'))
})
test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene Buchung enthält IP-Adresse oder Browser-Kennzeichen', async () => {
const nameIp = /^(ip|ipAdresse|clientIp|userAgent)$/i
for (const [collection, def] of Object.entries(TERMINE_COLLECTIONS)) {
const treffer = def.attributes.map((a) => a.key).filter((k) => nameIp.test(k))
assert.deepEqual(treffer, [], `${collection}: Attribut für IP-Adresse oder Browser-Kennzeichen`)
}
// ein loses /ip/ träfe stripeSessionId und stripePaymentIntent: das Muster oben ist absichtlich genau
assert.ok(TERMINE_COLLECTIONS.termineBookings.attributes.some((a) => a.key === 'stripeSessionId'))
// eine echte gehaltene Buchung: der Nachweis trägt keinen Schlüssel ip oder userAgent (auch nicht verschachtelt)
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE = 'f_p_projekt01_termine'
const vorher = { mode: config.termine.mode, from: config.smtp.from, basis: config.publicBaseUrl }
const log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
try {
config.termine.mode = 'pilot'
config.smtp.from = 'noreply@webklar.com'
config.publicBaseUrl = 'https://project.webklar.com'
resetKontextForTests()
resetOutboxForTests()
const sink = mailSink()
setMailTransportForTests(sink)
const feature = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }
aw.seed('portalFeatures', feature)
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
const { einstellung, fehler } = einstellungMit()
assert.equal(fehler, undefined)
await speichereEinstellung(feature, einstellung, { version: 0, now: JETZT })
const ctx = { feature, project: { $id: PROJEKT, subdomain: 'salon' }, einstellung, version: 1 }
const erg = await halte(
ctx,
{
leistung: S_SOFORT, ressource: 'egal', tag: '2026-10-26', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com',
telefon: '', nachricht: '', zusatz: {}, erinnerung: true, zustimmungen: { storno: true, vorzeitig: false },
},
JETZT,
)
await warteAufVersand()
assert.equal(erg.status, 'gehalten')
const buchungen = [...aw.docs('termineBookings').values()]
assert.equal(buchungen.length, 1)
const nachweis = JSON.parse(buchungen[0].nachweis)
assert.ok(nachweis.fassung && nachweis.datenschutz, 'der Nachweis enthält Fassung und Hash des Datenschutz-Hinweises')
const schluessel = (wert) => (wert && typeof wert === 'object' ? Object.entries(wert).flatMap(([k, v]) => [k, ...schluessel(v)]) : [])
assert.deepEqual(schluessel(nachweis).filter((k) => nameIp.test(k)), [])
// der Nachweis hält Hashes, keine Texte: jeder Hash ist 64 Hexzeichen
for (const hash of [nachweis.datenschutz, nachweis.storno?.hash, nachweis.widerruf?.hash, nachweis.erinnerung?.hash]) assert.match(hash, /^[0-9a-f]{64}$/)
// auch sonst enthält kein Feld der Buchung eine Adresse der Form n.n.n.n
assert.doesNotMatch(JSON.stringify(buchungen[0]), /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/)
} finally {
log.error.mock.restore()
log.warn.mock.restore()
setMailTransportForTests(null)
config.termine.mode = vorher.mode
config.smtp.from = vorher.from
config.publicBaseUrl = vorher.basis
}
})
test('avv_verweise_auf_lb: jedes „Nr. N Abs. M der Leistungsbeschreibung“ und jedes „Nummer N“ im AVV zeigt auf Vorhandenes', () => {
const text = norm(AVV_MD)
// 1. Verweise auf die Leistungsbeschreibung Terminplaner
const lbVerweise = [...text.matchAll(/\b(?:Nr\.|Nummer) (\d+)(?: Abs\. (\d+))? der Leistungsbeschreibung/g)]
assert.ok(lbVerweise.length >= 8, `zu wenige Verweise auf die Leistungsbeschreibung: ${lbVerweise.length}`)
for (const m of lbVerweise) {
const a = abschnitt(LB, m[1])
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es in der Leistungsbeschreibung nicht`)
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nr. ${m[1]} nicht`)
}
// der Wortlaut der Verweise passt zum Inhalt der Stelle
const lb = (n, abs) => absatzTexte(abschnitt(LB, n))[abs - 1]
assert.ok(lb(8, 1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'), 'Nr. 1 Abs. 2: Nr. 8 Abs. 1')
assert.ok(lb(8, 4).startsWith('Beginnt innerhalb dieser 30 Tage'), 'Nr. 9 Abs. 6: Rückkehr nach Nr. 8 Abs. 4')
assert.ok(lb(8, 6).startsWith('Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab.'), 'Nr. 4 Abs. 2: Nr. 8 Abs. 6')
assert.ok(lb(4, 9).startsWith('Liegt die Datenschutzerklärung in der von WEBklar betreuten Website'), 'Nr. 11 Abs. 2: Datenschutz-Absatz')
assert.ok(lb(4, 7).startsWith('Er informiert seine Beschäftigten'), 'Nr. 11 Abs. 3: Beschäftigte')
assert.ok(lb(4, 3).startsWith('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'), 'Nr. 11 Abs. 4: Gesundheitsbereich')
assert.ok(lb(4, 6).startsWith('Er lässt die Texte der Haken und Hinweise im Widget unverändert.'), 'Nr. 11 Abs. 5: Haken unverändert')
assert.ok(lb(9, 2).startsWith('WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden'), 'Nr. 12 Abs. 3: Beenden')
assert.ok(lb(9, 3).startsWith('Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig'), 'Nr. 12 Abs. 3: Aussetzen')
// 2. Verweise innerhalb des AVV: „Nummer N [Abs. M]“ (ohne Zusatz „der Leistungsbeschreibung“)
const eigene = [...text.matchAll(/\bNummer (\d+)(?: Abs\. (\d+))?(?! der Leistungsbeschreibung)/g)]
assert.ok(eigene.length >= 6, `zu wenige interne Verweise: ${eigene.length}`)
for (const m of eigene) {
const a = abschnitt(AVV, m[1])
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es nicht`)
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nummer ${m[1]} nicht`)
}
// die Verweise der Leistungsbeschreibung auf den AVV zeigen auf Löschfristen (Nr. 9) und Änderungen (Nr. 12)
assert.match(avvNr(9), /Löschung und Rückgabe|Nicht bestätigte Buchungen/)
assert.match(avvNr(12), /neue Fassung/)
// 3. Paragrafen und Anlagen der Abo-Bedingungen
const paragrafen = [...text.matchAll(/§ (\d+)(?: Abs\. (\d+))? der Abo-Bedingungen/g)]
assert.ok(paragrafen.length >= 3)
for (const m of paragrafen) {
const start = ABO.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
const stueck = ABO.slice(start, ABO.indexOf('<h2', start + 10))
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
if (m[2]) assert.ok(Number(m[2]) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${m[2]}`)
}
assert.deepEqual([...new Set(paragrafen.map((m) => m[1]))].sort(), ['13', '16', '3'])
assert.ok(ABO.includes('id="avv"') && ABO.includes('id="tom"'), 'Anlage 1 und Anlage 2 der Abo-Bedingungen')
assert.ok(text.includes('Anlage 1 der Abo-Bedingungen') && text.includes('Anlage 2 der Abo-Bedingungen'))
})
test('avv_ohne_analyse_bezug: Analyse-Kunden müssen nicht neu zustimmen, der AVV der Analyse bleibt unverändert', () => {
// der Terminplaner-AVV ist ein eigenes Geschwister: kein Wort zur Analyse, kein Nachtrag
assert.doesNotMatch(AVV_MD, /Analyse|Nachtrag|Zusatz zum|Änderung des Auftragsverarbeitungsvertrags/i)
// der AVV der Analyse kennt den Terminplaner nicht und trägt weiter seine eigene Fassung
const analyse = lies('docs/rechtliches/avv-website-analyse.md')
assert.doesNotMatch(analyse, /Terminplaner|Termine/)
assert.equal(analyse.split('\n')[2], `**Fassung ${config.analyse.avvVersion}**`)
assert.notEqual(config.analyse.avvVersion, V, 'die Fassung der Analyse ändert sich nicht mit der des Terminplaners')
// die beiden Dokumentsätze sind getrennt
assert.notEqual(featureConfig('termine').avv, undefined)
assert.ok(!String(featureConfig('termine').avv).includes('website-analyse'))
})
test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Datenpanne, Änderungen', () => {
const vorbemerkung = norm(AVV.abschnitte[0].text)
assert.ok(vorbemerkung.includes('Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person.'))
assert.ok(vorbemerkung.includes('Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält'))
assert.ok(vorbemerkung.includes('Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform'))
assert.ok(vorbemerkung.includes('Der Kunde erhält die E-Mail nach Absatz 1 anschließend.'))
assert.ok(vorbemerkung.includes('Für den Terminplaner geht er Anlage 1 vor'))
assert.ok(vorbemerkung.includes('Anlage 1 der Abo-Bedingungen'))
const nr1 = avvNr(1)
assert.ok(nr1.includes('Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab'))
assert.ok(nr1.includes('Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.'))
const nr2 = avvNr(2)
assert.ok(nr2.includes('WEBklar verwendet die Daten für keine eigenen Zwecke'))
assert.ok(nr2.includes('WEBklar bildet keine Profile einzelner Gäste.'))
assert.ok(nr2.includes('Missbrauch'))
const nr3 = avvNr(3)
for (const wort of [
'Name und E-Mail-Adresse', 'Telefonnummer', 'Zusatzfelder', 'die gebuchte Leistung, die Person oder der Platz und der Termin', 'der Status der Buchung mit den Zeitpunkten',
'Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
'Namen oder Bezeichnungen der Personen oder Plätze', 'Arbeitszeiten und Abwesenheiten', 'geheimer Kalender-Link',
]) assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
assert.ok(nr3.includes('Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet.'))
assert.ok(nr3.includes('Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert.'))
assert.ok(nr3.includes('nur im Arbeitsspeicher des Servers'))
assert.ok(nr3.includes('Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.'))
const nr4 = avvNr(4)
assert.ok(nr4.includes('Mit der Bestellung weist der Kunde WEBklar an'))
assert.ok(nr4.includes('Sonstige Weisungen erteilt der Kunde in Textform'))
const nr8 = avvNr(8)
assert.ok(nr8.includes('Gastdaten löschen') && nr8.includes('CSV-Datei'))
assert.ok(nr8.includes('möglichst innerhalb von 24 Stunden nach Kenntnis'))
assert.ok(nr8.includes('bis drei Jahre nach dem Ende des Terminplaners'))
assert.ok(nr8.includes('nur den Zeitpunkt und die Hashes der Texte'))
const nr11 = avvNr(11)
assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten'))
assert.ok(nr11.includes('nicht im Gesundheitsbereich'))
assert.ok(nr11.includes('Haken für die Erinnerung'))
const nr12 = avvNr(12)
assert.ok(nr12.includes('§ 13 der Abo-Bedingungen') && nr12.includes('Art. 82 DSGVO'))
assert.ok(nr12.includes('mindestens sechs Wochen vor ihrem Wirksamwerden in Textform'))
assert.ok(nr12.includes('Im Übrigen gilt § 16 der Abo-Bedingungen.'))
})
/**
* Fundstellen der Anlage TOM: jede Zeile der Tabelle nennt hier, wo im Code, in den Tests oder in den Abo-Bedingungen ihre
* Aussage gesichert ist. { datei, hat } = die Datei enthält das Muster; { datei, nicht } = die Datei enthält es nicht.
*/
const TOM_FUNDSTELLEN = {
'Übertragung': [
{ datei: 'server/config.js', hat: /publicBaseUrl: \(process\.env\.PORTAL_PUBLIC_URL \|\| 'https:\/\/project\.webklar\.com'\)/ },
{ datei: 'server/services/termine/einbauanleitung.js', hat: /beginnen mit https:\/\// },
{ datei: 'test/termineEmbedStatic.test.js', hat: /keine festen Fremdadressen im Code/ },
],
'Speicher im Browser': [
{ datei: 'test/termineEmbedStatic.test.js', hat: /'sessionStorage', 'document\.cookie', 'indexedDB', 'document\.referrer'/ }, // termineEmbedStatic.test.js:24
{ datei: 'test/termineEmbedStatic.test.js', hat: /'resolvedOptions', 'getTimezoneOffset'/ },
{ datei: 'public/embed/termine.js', hat: /credentials: 'omit'/ },
{ datei: 'public/termin.js', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
{ datei: 'public/termin.html', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
{ datei: 'server/routes/publicTermine.js', nicht: /res\.cookie|Set-Cookie/i },
],
'Zugriffskontrolle': [
{ datei: 'server/routes/termine.js', hat: /assertOwnProject\(customerId, projectId\)/ },
{ datei: 'server/routes/termine.js', hat: /ctx\.feature\.customerId !== customerId/ },
{ datei: 'server/routes/publicTermine.js', hat: /timingSafeEqual\(sha256\(r\.feedToken\), gegeben\)/ },
{ datei: 'server/routes/publicTermine.js', hat: /POST und Preflight nur von Seiten des Betriebs/ },
{ datei: 'server/services/termine/token.js', hat: /timingSafeEqual\(gegeben, erwartet\)/ },
],
'Missbrauchsschutz': [
{ datei: 'server/routes/publicTermine.js', hat: /Honeypot kontakt_url und Formular-Token/ },
{ datei: 'server/routes/publicTermine.js', hat: /config\.termine\.minAntwortMs/ },
{ datei: 'server/services/termine/token.js', hat: /config\.termine\.formMinSekunden/ },
{ datei: 'server/services/termine/limits.js', hat: /const MAX_SCHLUESSEL = 10000/ },
{ datei: 'server/services/termine/limits.js', hat: /Ein Neustart des Prozesses leert alles/ },
{ datei: 'server/services/termine/outbox.js', hat: /Stundenbudget \(config\.termine\.mailsProStunde\)/ },
{ datei: 'server/services/termine/outbox.js', hat: /config\.termine\.mailsProFunktion/ },
],
'Datenminimierung': [
{ datei: 'test/termineVertragsunterlagen.test.js', hat: /avv_keine_ip_in_buchung/ },
{ datei: 'server/services/termine/ablauf.js', hat: /datenschutz: sha256\(formularHinweis\(e\)\)/ },
{ datei: 'server/services/termine/einstellungen.js', hat: /wahl\(fehl, 'gastFelder\.telefon'/ },
{ datei: 'server/services/termine/outbox.js', hat: /status: 'gesendet',[\s\S]*?an: '',\s*html: '',\s*text: '',\s*ics: ''/ },
],
'Trennung': [
{ datei: 'server/services/termine/schema.js', hat: /Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments/ },
{ datei: 'server/services/termine/schema.js', hat: /str\('featureId', 64\)/ },
{ datei: 'server/services/termine/buchungen.js', hat: /Query\.equal\('featureId', featureId\)/ },
],
'Löschung': [
{ datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ },
{ datei: 'server/services/termine/jobs.js', hat: /Status und Bezugszeit noch einmal frisch geprueft/ },
{ datei: 'server/services/termine/jobs.js', hat: /im Zweifel wird nichts geloescht/ },
{ datei: 'server/services/termine/jobs.js', hat: /ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE \* TAG_MS/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /export const ENTWURF_TAGE = 90/ },
],
'Protokolle': [
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
{ datei: 'test/termineBetrieb.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
{ datei: 'test/termineStatus.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
{ datei: 'public/abo-bedingungen.html', hat: /Server-Protokolle werden nach 14 Tagen gelöscht/ },
],
'Datensicherung': [
{ datei: 'public/abo-bedingungen.html', hat: /Verfügbar sind jeweils die letzten sieben Tagessicherungen/ },
],
'Rechenzentrum': [
{ datei: 'public/abo-bedingungen.html', hat: /Hetzner ist nach ISO\/IEC 27001 zertifiziert/ },
{ datei: 'public/abo-bedingungen.html', hat: /E-Mail-Postfächer \(Webhosting\)<\/td><td>Deutschland \(Rechenzentrum Nürnberg\)/ },
],
'Organisation': [
{ datei: 'public/abo-bedingungen.html', hat: /Verpflichtung aller Beteiligten auf Vertraulichkeit/ },
{ datei: 'public/abo-bedingungen.html', hat: /Die Maßnahmen werden mindestens einmal im Jahr überprüft/ },
],
}
/** Die Zeilen der Anlage-Tabelle als { bereich: text }. */
function tomZeilen() {
const anlage = AVV.abschnitte.find((a) => a.nummer === null && a.titel.startsWith('Anlage:'))
assert.ok(anlage)
const zeilen = anlage.zeilen.filter((z) => z.startsWith('|')).slice(2)
return Object.fromEntries(zeilen.map((z) => {
const [, bereich, text] = z.split('|').map((t) => t.trim())
return [bereich, text]
}))
}
test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, in den Tests oder in den Abo-Bedingungen', () => {
const zeilen = tomZeilen()
assert.deepEqual(Object.keys(zeilen), Object.keys(TOM_FUNDSTELLEN), 'Zeilen der Anlage und Fundstellen-Tabelle stimmen überein (Reihenfolge eingeschlossen)')
for (const [bereich, stellen] of Object.entries(TOM_FUNDSTELLEN)) {
assert.ok(stellen.length >= 1, `${bereich}: keine Fundstelle`)
assert.ok(zeilen[bereich].length > 40, `${bereich}: Text der Zeile fehlt`)
for (const stelle of stellen) {
const pfad = stelle.datei
assert.ok(existsSync(new URL(`../${pfad}`, import.meta.url)), `${bereich}: ${pfad} gibt es nicht`)
const quelle = lies(pfad)
if (stelle.hat) assert.match(quelle, stelle.hat, `${bereich}: Fundstelle in ${pfad} fehlt (${stelle.hat})`)
if (stelle.nicht) assert.doesNotMatch(quelle, stelle.nicht, `${bereich}: ${pfad} darf ${stelle.nicht} nicht enthalten`)
}
}
// Aussagen mit Zahlen stehen in der Zeile und in der Fundstelle gleich
assert.ok(zeilen['Protokolle'].includes('nach 14 Tagen gelöscht'))
assert.ok(zeilen['Datensicherung'].includes('die letzten sieben Tagessicherungen'))
assert.ok(zeilen['Rechenzentrum'].includes('ISO/IEC 27001') && zeilen['Rechenzentrum'].includes('Falkenstein und Nürnberg'))
assert.ok(zeilen['Löschung'].includes('30 Tage nach dem Abschalten'))
assert.ok(zeilen['Speicher im Browser'].includes('keine Cookies'))
assert.ok(zeilen['Zugriffskontrolle'].includes('konstanter Zeit'))
assert.ok(zeilen['Organisation'].includes('Nummer 8 Abs. 3') && zeilen['Organisation'].includes('Nummer 8 Abs. 5'))
})