Rechtstexte und K15: Prüfbefunde F5–F8 (AVV genauer, PDF-Prüfsumme, Unterlagen der vereinbarten Fassung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 03:42:27 +00:00
1 parent 2c059cb3d6
commit 90098726b9
13 files changed
+345 -63

No files matched your search

+58 -2
View File
@@ -32,6 +32,7 @@ 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')
const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.js')
after(() => setMailTransportForTests(null))
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
@@ -450,6 +451,14 @@ test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschut
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'))
// unbeantwortete Terminanfragen: derselbe Satz im Datenschutz-Absatz des Kunden und im AVV (Nr. 9 Abs. 2), und der Code tut es
assert.ok(absatz.includes('Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt.'))
const nr9abs2 = absatzTexte(abschnitt(AVV, 9))[1]
assert.ok(nr9abs2.startsWith('Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.'))
assert.ok(nr9abs2.includes('Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.'))
assert.ok(JOBS.includes("angefragt mit Beginn in der Vergangenheit => abgelehnt vom System"), 'jobs.js lehnt verstrichene Anfragen ab')
assert.equal(loeschBezugsfeld('angefragt'), undefined, 'eine Anfrage wird nie unmittelbar gelöscht, erst als abgelehnte nach 30 Tagen')
assert.equal(loeschBezugsfeld('abgelehnt'), 'updatedAt', 'die 30 Tage laufen ab der letzten Änderung, also ab der Ablehnung durch das System')
// 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).'))
@@ -500,6 +509,12 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene
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')
// genau die Bestandteile, die AVV Nr. 3 Abs. 1 und Nr. 8 Abs. 5 nennen: Fassung, Zeitpunkt, Knopftext, Anrede, Hashes (keine weiteren Klartexte)
const erlaubt = new Set(['fassung', 'am', 'knopf', 'anrede', 'datenschutz', 'storno', 'widerruf', 'vorzeitig', 'erinnerung'])
assert.deepEqual(Object.keys(nachweis).filter((k) => !erlaubt.has(k)), [])
for (const k of ['fassung', 'am', 'knopf', 'anrede']) assert.equal(typeof nachweis[k], 'string', k)
assert.deepEqual(Object.keys(nachweis.storno), ['version', 'hash', 'haken', 'am'])
assert.deepEqual(Object.keys(nachweis.erinnerung), ['stunden', 'hash', 'am'])
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
@@ -516,6 +531,32 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene
}
})
/** Jedes Attribut einer Buchung ist in AVV Nr. 3 Abs. 1 genannt (Wortlaut) oder eine technische Kennung/Eigenschaft des Dienstes. */
const BUCHUNG_IM_AVV = {
gastName: 'Name und E-Mail-Adresse', gastEmail: 'Name und E-Mail-Adresse', gastTelefon: 'Telefonnummer', gastNachricht: 'eine Nachricht', gastZusatz: 'Zusatzfelder',
leistungName: 'die gebuchte Leistung', ressourceName: 'die Person oder der Platz', startsAt: 'der Termin', endsAt: 'der Termin', blockEndsAt: 'der Termin',
status: 'der Status der Buchung mit den Zeitpunkten', heldUntil: 'der Status der Buchung mit den Zeitpunkten', bestaetigtAt: 'der Status der Buchung mit den Zeitpunkten',
angenommenAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtVon: 'der Status der Buchung mit den Zeitpunkten',
widerrufenAt: 'der Status der Buchung mit den Zeitpunkten', createdAt: 'der Status der Buchung mit den Zeitpunkten', updatedAt: 'der Status der Buchung mit den Zeitpunkten',
absageGrund: 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', nachweis: 'der Nachweis der Zustimmungen',
preisCent: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', zahlart: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
zahlStatus: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', verbraucher: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
erinnerung: 'der Wunsch nach einer Erinnerung', erinnerungGesendetAt: 'der Wunsch nach einer Erinnerung',
}
const BUCHUNG_TECHNISCH = ['featureId', 'customerId', 'projectId', 'leistungId', 'ressourceId', 'preisArt', 'buchungsart', 'quelle', 'verifyHash', 'manageHash', 'stripeSessionId', 'stripePaymentIntent', 'icsSequenz']
test('avv_datenarten_decken_schema: jedes Attribut einer Buchung steht in Nr. 3 Abs. 1 oder ist eine technische Kennung', () => {
const nr3abs1 = norm(absatzTexte(abschnitt(AVV, 3))[0])
const attribute = TERMINE_COLLECTIONS.termineBookings.attributes.map((a) => a.key)
const unbekannt = attribute.filter((k) => !Object.hasOwn(BUCHUNG_IM_AVV, k) && !BUCHUNG_TECHNISCH.includes(k))
assert.deepEqual(unbekannt, [], 'neues Attribut der Buchung: in AVV Nr. 3 Abs. 1 aufnehmen oder als technisch einstufen')
for (const [k, wort] of Object.entries(BUCHUNG_IM_AVV)) {
assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
assert.ok(nr3abs1.includes(wort), `${k}: „${wort}“ fehlt in Nr. 3 Abs. 1`)
}
for (const k of BUCHUNG_TECHNISCH) assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
})
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)
@@ -600,7 +641,8 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
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',
'Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', 'auch mit dem Namen aus einer Widerrufserklärung', 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
'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.'))
@@ -611,12 +653,14 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
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'))
assert.ok(nr4.includes('Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.'))
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'))
assert.ok(nr8.includes('die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte'))
assert.doesNotMatch(nr8, /nur den Zeitpunkt/)
const nr11 = avvNr(11)
assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten'))
@@ -673,6 +717,9 @@ const TOM_FUNDSTELLEN = {
{ 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\)/ },
// die Hintergrundläufe arbeiten über alle Funktionen (Löschlauf ohne Funktionsfilter, Aufräumen der Warteschlange nach Status) und löschen Dokument für Dokument
{ datei: 'server/services/termine/buchungen.js', hat: /featureId === null \? \[\] : \[Query\.equal\('featureId', featureId\)\]/ },
{ datei: 'server/services/termine/outbox.js', hat: /Query\.equal\('status', \['gesendet', 'fehler', 'verworfen'\]\)/ },
],
'Löschung': [
{ datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ },
@@ -680,6 +727,9 @@ const TOM_FUNDSTELLEN = {
{ 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/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ },
// die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch)
{ datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ },
],
'Protokolle': [
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
@@ -730,6 +780,12 @@ test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, i
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['Löschung'].includes('Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen'))
assert.doesNotMatch(zeilen['Löschung'], /Vor jeder Löschung/)
assert.ok(zeilen['Trennung'].includes('Das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion.'))
assert.ok(zeilen['Trennung'].includes('Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht.'))
assert.doesNotMatch(zeilen['Trennung'], /Abfragen erfolgen nur je Funktion/)
assert.ok(zeilen['Datenminimierung'].includes('der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte'))
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'))