Rechtstexte: AVV Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c474c46c3
commit
24cb549e78
2 files changed
+647
-4
No files matched your search
@@ -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'))
|
||||
})
|
||||
Reference in new issue
Block a user