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

+145
View File
@@ -0,0 +1,145 @@
# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)
**Fassung 2026-10-05**
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“).
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1 anschließend.
(3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.
## 1. Gegenstand und Dauer
(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Verwaltung im Kundenportal („Terminplaner“) und verarbeitet dabei personenbezogene Daten der Gäste und der Beschäftigten des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 8 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
## 2. Art und Zweck der Verarbeitung
(1) Art: Erheben über das Widget auf der Website des Kunden, Übermitteln an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen eines Kalender-Links und einer CSV-Datei und Löschen.
(2) Zwecke: Der Kunde soll Termine seiner Gäste verwalten. Dazu nimmt der Terminplaner Buchungen und Terminanfragen entgegen, verhindert Doppelbuchungen, bestätigt, ändert und sagt Termine ab, erinnert Gäste auf deren Wunsch und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Terminplaner für den Kunden vor Missbrauch (Mengenbegrenzung, Erkennen automatisierter Buchungen).
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der gelöschten Buchungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.
## 3. Datenarten und Betroffene
(1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); der Nachweis der Zustimmungen als Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte; der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.
(2) Daten der Beschäftigten des Kunden: Namen oder Bezeichnungen der Personen oder Plätze, deren Arbeitszeiten und Abwesenheiten sowie ein geheimer Kalender-Link je Person oder Platz.
(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt; der Kunde fordert von seinen Gästen keine Gesundheitsangaben (Nummer 11 Abs. 4).
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse nur im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).
(5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.
(6) Betroffene: Gäste, die über die Website des Kunden Termine buchen oder anfragen, und Beschäftigte des Kunden, die im Terminplaner geführt werden.
## 4. Weisungen
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
(2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung).
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
## 5. Vertraulichkeit
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
## 6. Technische und organisatorische Maßnahmen
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
## 7. Unterauftragsverarbeiter
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Verlangen des Kunden sofort.
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
(4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.
## 8. Unterstützung des Kunden, Betroffenenrechte
(1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei, und die Links des Gastes werden ungültig. Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).
(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.
(5) Die Buchung speichert als Nachweis der Zustimmungen nur den Zeitpunkt und die Hashes der Texte, die der Gast gesehen hat, nicht die Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung.
(6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).
## 9. Löschung und Rückgabe
(1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.
(2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.
(3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage.
(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.
(5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.
(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Bis dahin kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. 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). Auf Weisung des Kunden löscht WEBklar die Daten früher.
(7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.
(8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
(9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
## 10. Nachweise und Kontrollen
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
## 11. Pflichten des Kunden
(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner.
(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für die Online-Terminbuchung in seine Datenschutzerklärung auf (Nr. 4 Abs. 9 der Leistungsbeschreibung) und lässt den Datenschutz-Hinweis am Buchungsformular unverändert.
(3) Der Kunde informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt (Nr. 4 Abs. 7 der Leistungsbeschreibung).
(4) Der Kunde setzt den Terminplaner nicht im Gesundheitsbereich ein und fordert von seinen Gästen keine Angaben zu ihrer Gesundheit, auch nicht über Zusatzfelder oder das Nachrichtenfeld (Nr. 4 Abs. 3 der Leistungsbeschreibung).
(5) Der Kunde verändert den Haken für die Erinnerung und die Texte der übrigen Haken nicht (Nr. 4 Abs. 6 der Leistungsbeschreibung). Erinnerungen versendet der Terminplaner nur, wenn der Gast den Haken gesetzt hat.
## 12. Haftung, Änderungen, Schlussbestimmungen
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Terminplaner für seine Website nach Nr. 9 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 9 Abs. 3 der Leistungsbeschreibung aussetzen. Das Wartungs-Abo bleibt davon unberührt.
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
## Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)
| Bereich | Maßnahmen |
|---|---|
| Übertragung | Das Widget, die Seiten der Gäste und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. |
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Terminplaner speichert nichts im Browser des Gastes (kein lokaler Speicher, kein Sitzungsspeicher). Das Widget sendet seine Anfragen ohne Cookies, liest weder den Referrer noch die Zeitzone des Browsers und ruft nur die Schnittstelle des Terminplaners auf. |
| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen eingeschalteten Terminplaner Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Buchungen nimmt sie nur von Seiten des Kunden und des Portals an. Der Kalender-Link enthält ein geheimes Token, das mit einem Vergleich in konstanter Zeit geprüft wird. |
| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie. |
| Datenminimierung | Pflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus dem Zeitpunkt und Hashes der gezeigten Texte, nicht aus den Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. |
| Trennung | Alle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); Abfragen erfolgen nur je Funktion. Die Einstellungen liegen je Funktion in einem eigenen Dokument. |
| Löschung | Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten. Vor jeder Löschung wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. |
| Protokolle | Die Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). |
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). |
| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Archiv der Fassungen der Gästetexte (Nummer 8 Abs. 5), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
+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'))
})