AVV Webshop, Vorbemerkung Abs. 2: Das Team darf den Webshop ohne Online-Bestellung
nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat. Die Textform-Aktivierung
gilt jetzt wie beim Terminplaner auch fuer 'shop' (Modus on, Pilotadressen ausgenommen,
shopTextformNoetig): Nachweis in featureConsents mit Fassung (config.shop.fassung), Datum,
Weg, Mitarbeiter und dem Wortlaut der Erklaerungen der Bestelltexte, K16 mit
Leistungsbeschreibung und AVV als PDF. GET /api/team/funktionen/:kunde liefert zusaetzlich
shop { textformNoetig, avvVersion, links }.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
830 lines
57 KiB
JavaScript
830 lines
57 KiB
JavaScript
import { test, after, mock } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
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')
|
|
const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.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).
|
|
|
|
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
|
|
const norm = (text) => text.replace(/\s+/g, ' ').trim()
|
|
const V = config.termine.avvVersion
|
|
|
|
// ----------------------------------------------------------------------------------------- Aufbau eines Dokuments
|
|
|
|
/** Zerlegt eine Vertragsunterlage: h1, Kopfzeile und Abschnitte (## N. Titel) mit ihren Absätzen "(n) …". */
|
|
function lese(md) {
|
|
const zeilen = md.split('\n')
|
|
const h1 = zeilen.filter((z) => /^# /.test(z))
|
|
const abschnitte = []
|
|
let aktuell = null
|
|
for (const zeile of zeilen) {
|
|
const h2 = /^## (.+)$/.exec(zeile)
|
|
if (h2) {
|
|
aktuell = { titel: h2[1], zeilen: [] }
|
|
abschnitte.push(aktuell)
|
|
} else if (aktuell) {
|
|
aktuell.zeilen.push(zeile)
|
|
}
|
|
}
|
|
for (const a of abschnitte) {
|
|
a.nummer = /^(\d+)\. /.exec(a.titel)?.[1] ?? null
|
|
a.text = a.zeilen.join('\n')
|
|
a.absaetze = [...a.text.matchAll(/^\((\d+)\) /gm)].map((m) => Number(m[1]))
|
|
}
|
|
return { h1, kopf: zeilen[2], abschnitte, md }
|
|
}
|
|
|
|
const LB_MD = lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md')
|
|
const LB = lese(LB_MD)
|
|
|
|
const abschnitt = (doc, nummer) => doc.abschnitte.find((a) => a.nummer === String(nummer))
|
|
|
|
// ----------------------------------------------------------------------------------------- Leistungsbeschreibung
|
|
|
|
test('lb_aufbau: Titel, Kopf mit Fassung und die elf Abschnitte in fester Nummerierung', () => {
|
|
assert.deepEqual(LB.h1, ['# Leistungsbeschreibung Terminplaner'])
|
|
assert.equal(LB_MD.split('\n')[0], '# Leistungsbeschreibung Terminplaner')
|
|
assert.equal(LB.kopf, `**Fassung ${V}** · Zusatzbedingungen zum Wartungs-Abo`)
|
|
assert.deepEqual(LB.abschnitte.map((a) => a.titel), [
|
|
'1. Geltung',
|
|
'2. Leistung',
|
|
'3. Nicht enthalten',
|
|
'4. Voraussetzungen und Pflichten des Kunden',
|
|
'5. Einrichtung und Preis',
|
|
'6. Verfügbarkeit und Störungen',
|
|
'7. Haftung für verpasste Termine',
|
|
'8. Ende der Leistung',
|
|
'9. Änderungen und Einstellung',
|
|
'10. Widerruf',
|
|
'11. Hinweise',
|
|
])
|
|
// Absätze sind lückenlos nummeriert: (1), (2), …
|
|
for (const a of LB.abschnitte) {
|
|
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`)
|
|
}
|
|
})
|
|
|
|
test('lb_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
|
|
assert.doesNotMatch(LB_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
|
|
assert.doesNotMatch(LB_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
|
|
assert.doesNotMatch(LB_MD, /undefined|null|NaN/)
|
|
assert.ok(!/```|^>|<[a-z]/im.test(LB_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
|
|
})
|
|
|
|
test('lb_festwerte: Preis, Umsatzsteuer-Hinweis, Voraussetzung Wartungs-Abo und die Sätze von Nr. 8 wörtlich', () => {
|
|
const text = norm(LB_MD)
|
|
assert.ok(text.includes('149 €'))
|
|
assert.ok(text.includes('§ 19 UStG'))
|
|
assert.ok(text.includes(config.ustHinweis), 'Nr. 5: Hinweis wörtlich aus config.ustHinweis')
|
|
assert.ok(text.includes('ein Hosting-Abo genügt nicht'))
|
|
assert.ok(text.includes('Zusatzbedingungen zum Wartungs-Abo'))
|
|
const nr8 = norm(abschnitt(LB, 8).text)
|
|
for (const satz of [
|
|
'Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.',
|
|
'WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.',
|
|
'Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).',
|
|
'Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.',
|
|
]) {
|
|
assert.ok(nr8.includes(satz), `Nr. 8: Satz fehlt oder ist verändert: ${satz}`)
|
|
}
|
|
// Absatz 1, 3, 4, 5 stehen an der festgelegten Stelle
|
|
const absatz = (n) => norm(abschnitt(LB, 8).text.split(/^\(\d+\) /m)[n])
|
|
assert.ok(absatz(1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'))
|
|
assert.ok(absatz(3).startsWith('WEBklar löscht alle Termindaten'))
|
|
assert.ok(absatz(4).startsWith('Beginnt innerhalb dieser 30 Tage'))
|
|
assert.ok(absatz(5).startsWith('Endet das Wartungs-Abo, bevor'))
|
|
// keine Einstellung ohne Grund
|
|
assert.ok(norm(abschnitt(LB, 9).text).includes('Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen.'))
|
|
})
|
|
|
|
test('lb_preis_gleich_register: Preis der LB = priceLabel des Registers (Nr. 5)', () => {
|
|
const nr5 = norm(abschnitt(LB, 5).text)
|
|
const preis = /kostet (\d+ €) einmalig/.exec(nr5)
|
|
assert.ok(preis, 'Nr. 5 nennt den Preis')
|
|
assert.equal(preis[1], featureConfig('termine').priceLabel)
|
|
assert.equal(config.termineSetup.amountCents, 14900)
|
|
assert.equal(`${config.termineSetup.amountCents / 100} €`, preis[1])
|
|
// der Preis steht nur einmal in der LB
|
|
assert.equal(norm(LB_MD).split('149 €').length - 1, 1)
|
|
})
|
|
|
|
test('lb_fristen_gleich_code: 30 Tage bis zur Löschung = LOESCHFRIST_TAGE (termine/frist.js)', () => {
|
|
assert.equal(LOESCHFRIST_TAGE, 30)
|
|
const nr8 = norm(abschnitt(LB, 8).text)
|
|
const loeschung = /löscht alle Termindaten und die Einstellungen (\d+) Tage nach dem Abschalten/.exec(nr8)
|
|
const rueckkehr = /Beginnt innerhalb dieser (\d+) Tage wieder/.exec(nr8)
|
|
assert.ok(loeschung && rueckkehr)
|
|
assert.equal(Number(loeschung[1]), LOESCHFRIST_TAGE)
|
|
assert.equal(Number(rueckkehr[1]), LOESCHFRIST_TAGE)
|
|
// die Abschalt-Frist "spätestens am folgenden Tag" steht nur in Nr. 8 Abs. 1
|
|
assert.equal(norm(LB_MD).split('spätestens am folgenden Tag').length - 1, 1)
|
|
})
|
|
|
|
/**
|
|
* Verweise: "Nr. N [Abs. M]" auf diese LB (ohne Zusatz), "Nummer N des AVV Terminplaner", "Leistungsbeschreibung B Nr. N der
|
|
* Abo-Bedingungen" und "§ N [Abs. M] der Abo-Bedingungen" müssen auf vorhandene Abschnitte zeigen.
|
|
*/
|
|
test('lb_verweise: jedes „Nr. N“ auf LB oder AVV und jedes „§ N der Abo-Bedingungen“ existiert', () => {
|
|
const text = norm(LB_MD)
|
|
|
|
// 1. Verweise auf diese Leistungsbeschreibung: "Nr. N", "Nr. N Abs. M", "nach Nr. N"
|
|
const eigene = []
|
|
for (const m of text.matchAll(/\bNr\. (\d+)(?: Abs\. (\d+))?(?! der Abo-Bedingungen)/g)) {
|
|
const davor = text.slice(Math.max(0, m.index - 40), m.index)
|
|
if (/Leistungsbeschreibung B $/.test(davor)) continue // Abo-Bedingungen, siehe unten
|
|
eigene.push({ nr: m[1], abs: m[2], zitat: m[0] })
|
|
}
|
|
assert.ok(eigene.length >= 8, `zu wenige interne Verweise gefunden: ${eigene.length}`)
|
|
for (const v of eigene) {
|
|
const a = abschnitt(LB, v.nr)
|
|
assert.ok(a, `„${v.zitat}“: Abschnitt ${v.nr} gibt es nicht`)
|
|
if (v.abs) assert.ok(a.absaetze.includes(Number(v.abs)), `„${v.zitat}“: Absatz ${v.abs} gibt es nicht in Nr. ${v.nr}`)
|
|
}
|
|
|
|
// 2. Verweise auf den AVV Terminplaner (Vorbemerkung, Nr. 1 bis 12, Anlage): Nummernbereich; ist der AVV schon geschrieben, auch seine Überschriften
|
|
const avvNummern = [...text.matchAll(/Nummer (\d+)(?: Abs\. (\d+))? (?:des )?AVV Terminplaner|AVV Terminplaner Nummer (\d+)(?: Abs\. (\d+))?/g)]
|
|
assert.ok(avvNummern.length >= 2)
|
|
const avvPfad = 'docs/rechtliches/avv-terminplaner.md'
|
|
const avv = existsSync(new URL(`../${avvPfad}`, import.meta.url)) ? lese(lies(avvPfad)) : null
|
|
for (const m of avvNummern) {
|
|
const n = Number(m[1] ?? m[3])
|
|
assert.ok(n >= 1 && n <= 12, `„${m[0]}“: der AVV hat Nr. 1 bis 12`)
|
|
if (avv) assert.ok(abschnitt(avv, n), `„${m[0]}“: Abschnitt ${n} fehlt im AVV`)
|
|
}
|
|
|
|
// 3. Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen: "Nicht enthalten" im Wartungs-Abo
|
|
const abo = lies('public/abo-bedingungen.html')
|
|
const wartung = abo.slice(abo.indexOf('<h2>Wartungs-Abo</h2>'), abo.indexOf('<h2 id="widerruf">'))
|
|
assert.ok(wartung.includes('<h3>3. Nicht enthalten</h3>'))
|
|
assert.ok(text.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“)'))
|
|
assert.ok(/Texte, Bilder und Rechtstexte \(Impressum, Datenschutzerklärung\)/.test(wartung.replace(/<[^>]+>/g, '')))
|
|
|
|
// 4. Paragrafen der Abo-Bedingungen: § N existiert, Absatz M existiert (Aufzählungspunkte bzw. Überschriften "(M) …" des Paragrafen)
|
|
const paragrafen = [...text.matchAll(/§§? (\d+)(?: Abs\. (\d+)(?: und (\d+))?)?(?: und (\d+))? der Abo-Bedingungen/g)]
|
|
assert.ok(paragrafen.length >= 7, `zu wenige Verweise auf die Abo-Bedingungen: ${paragrafen.length}`)
|
|
const nummern = new Set()
|
|
for (const m of paragrafen) {
|
|
for (const n of [m[1], m[4]].filter(Boolean)) nummern.add(n)
|
|
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 ende = abo.indexOf('<h2', start + 10)
|
|
const stueck = abo.slice(start, ende)
|
|
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
|
|
for (const abs of [m[2], m[3]].filter(Boolean)) {
|
|
assert.ok(Number(abs) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${abs}`)
|
|
}
|
|
}
|
|
// "§§ 12 und 13 der Abo-Bedingungen" nennt beide Paragrafen
|
|
assert.ok(nummern.has('12') && nummern.has('13'))
|
|
for (const n of ['3', '4', '5', '6', '12', '13']) assert.ok(nummern.has(n), `§ ${n} der Abo-Bedingungen wird zitiert`)
|
|
// § 327r BGB und § 3 BFSG sind Gesetzesverweise, keine Verweise in die Abo-Bedingungen
|
|
assert.ok(text.includes('§ 327r Abs. 3 BGB') && text.includes('§ 3 Abs. 3 BFSG'))
|
|
|
|
// 5. Die Anlage zur Widerrufsbelehrung heißt wie die Überschrift der Datei
|
|
const anlage = lies('docs/rechtliches/widerrufsbelehrung-funktionen.md').split('\n')[0].replace(/^# /, '')
|
|
assert.ok(text.includes(`„${anlage}“`), 'Nr. 10 nennt die Anlage mit dem Titel der Widerrufsbelehrung')
|
|
})
|
|
|
|
test('lb_ohne_du: Dritte Person und Sie-Form, keine Du-Form', () => {
|
|
assert.doesNotMatch(LB_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
|
|
assert.ok(/\bder Kunde\b/.test(LB_MD))
|
|
})
|
|
|
|
test('lb_bfsg_ohne_konformitaetszusage: keine Zusage, das Widget erfülle WCAG oder EN 301 549', () => {
|
|
const nr11 = norm(abschnitt(LB, 11).text)
|
|
assert.ok(nr11.includes('Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu.'))
|
|
assert.ok(nr11.includes('Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.'))
|
|
assert.ok(nr11.includes('Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region).'))
|
|
const alles = norm(LB_MD)
|
|
assert.doesNotMatch(alles, /WCAG[^.]*\b(entspricht|erfüllt|erfüllen|barrierefrei|konform ist|sind konform)\b/i)
|
|
assert.doesNotMatch(alles, /\b(entspricht|erfüllt|erfüllen)\b[^.]*(WCAG|EN 301 549|BFSG)/i)
|
|
assert.doesNotMatch(alles, /vollständig barrierefrei|barrierefrei nach/i)
|
|
})
|
|
|
|
test('lb_inhalt: Rangfolge, Gästetexte, Gesundheitsbereich, Datenschutz-Absatz, Zahlung nur vor Ort', () => {
|
|
const nr1 = norm(abschnitt(LB, 1).text)
|
|
assert.ok(nr1.includes('Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.'))
|
|
assert.ok(nr1.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz'))
|
|
const nr2 = norm(abschnitt(LB, 2).text)
|
|
assert.ok(nr2.includes('Bezahlt wird nur vor Ort.'))
|
|
assert.ok(nr2.includes('Mustertexte der Standardkonfiguration'))
|
|
for (const wort of ['geheimen Kalender-Link (ICS)', 'CSV-Datei', 'Missbrauchsschutz', 'Bestätigungslink', 'Erinnerung (nur mit seinem Haken)', 'Widerrufs']) {
|
|
assert.ok(nr2.includes(wort), `Nr. 2 nennt ${wort}`)
|
|
}
|
|
const nr3 = norm(abschnitt(LB, 3).text)
|
|
for (const wort of ['SMS', 'Online-Zahlung', 'Kalender-Abgleich', 'Konten für Gäste', 'Marketing-E-Mails', 'Gruppentermine', 'Mehrsprachigkeit', 'Gesundheitsbereich', 'zweites Widget', 'Sonderdesign', '§ 4 der Abo-Bedingungen']) {
|
|
assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
|
|
}
|
|
const nr4 = norm(abschnitt(LB, 4).text)
|
|
assert.ok(nr4.includes('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'))
|
|
assert.ok(nr4.includes('WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.'))
|
|
assert.ok(nr4.includes('Kinder unter 16 Jahren'))
|
|
assert.ok(nr4.includes('wird nicht auf das Stundenkontingent angerechnet'))
|
|
assert.ok(nr4.includes('Textbaustein'))
|
|
// Keine Gesundheitsangaben anfordern: der Text fordert nirgends welche an
|
|
assert.doesNotMatch(LB_MD, /Gesundheitsdaten (erfassen|speichern)|Diagnose|Patient/i)
|
|
const nr5 = norm(abschnitt(LB, 5).text)
|
|
for (const wort of ['Fragebogen', 'Einbau', 'Abnahme', 'Freischaltung', 'Stripe', 'Rechnung', 'keine verbindliche Zusage eines Termins', 'weder auf das Kontingent angerechnet noch damit bezahlt']) {
|
|
assert.ok(nr5.includes(wort), `Nr. 5 nennt ${wort}`)
|
|
}
|
|
const nr7 = norm(abschnitt(LB, 7).text)
|
|
assert.ok(nr7.includes('§ 13 Abs. 1 und 2 der Abo-Bedingungen'))
|
|
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(`zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten ${LOESCHFRIST_TAGE} Tage nach dem Abschalten gelöscht`))
|
|
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).'))
|
|
})
|
|
|
|
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')
|
|
// 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
|
|
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
|
|
}
|
|
})
|
|
|
|
/** 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)
|
|
|
|
// 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'))
|
|
// T1: In der Textform-Aktivierung trägt K16 nur Leistungsbeschreibung und AVV als PDF (ohne Widerrufsbelehrung, ohne Links): der Text verspricht nicht die E-Mail nach Absatz 1
|
|
assert.ok(vorbemerkung.includes('Der Kunde erhält anschließend eine E-Mail mit diesen beiden Texten als PDF.'))
|
|
assert.ok(!vorbemerkung.includes('E-Mail nach Absatz 1 anschließend'), 'der Satz verwies auf die Mail mit Belehrung und Links')
|
|
assert.match(lies('server/routes/admin/funktionenAdmin.js'), /featureLegalAttachments\(key, \{ ohne: \['widerruf'\] \}\)/, 'K16 der Textform-Aktivierung (Terminplaner und Webshop): nur Leistungsbeschreibung und AVV')
|
|
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',
|
|
'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.'))
|
|
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'))
|
|
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('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'))
|
|
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.'))
|
|
})
|
|
|
|
test('avv_ausnahmen_loeschung: Einrichtungs-Ticket bleibt (Nr. 9 Abs. 6, Anlage, LB Nr. 8 Abs. 3); fehlgeschlagene E-Mails behalten Adresse und Inhalt bis Nr. 9 Abs. 4 (Nr. 8 Abs. 2)', () => {
|
|
// T2: das Einrichtungs-Ticket (workorders.details = Kurzfassung der Einstellungen, ticketDetails.js) wird beim Löschlauf nicht angefasst
|
|
const ticket = 'Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen'
|
|
const nr9 = avvNr(9)
|
|
const abs6 = nr9.slice(nr9.indexOf('(6) '), nr9.indexOf('(7) '))
|
|
assert.ok(abs6.includes(ticket))
|
|
assert.ok(abs6.includes('(zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6).'))
|
|
// Reihenfolge: erst die Löschung nach 30 Tagen, dann die Ausnahme, dann der Export; „Bis dahin“ hätte sich auf die Datenschutzerklärung beziehen lassen
|
|
assert.ok(abs6.indexOf('30 Tage danach.') < abs6.indexOf(ticket) && abs6.indexOf(ticket) < abs6.indexOf('Bis zur Löschung kann der Kunde die Termine'))
|
|
assert.ok(!abs6.includes('Bis dahin'))
|
|
assert.ok(tomZeilen()['Löschung'].includes('das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6)'))
|
|
assert.ok(norm(abschnitt(LB, 8).text).includes('Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).'))
|
|
// die Zahl „acht“ ist die der Kurzfassung (einbauanleitung.js KURZ_NAMEN); die Kurzfassung hat keinen Zugriff auf Buchungen (ticketDetails.js liest nur den Stand der Einstellungen)
|
|
assert.match(lies('server/services/termine/einbauanleitung.js'), /const KURZ_NAMEN = 8\b/)
|
|
assert.doesNotMatch(lies('server/services/termine/ticketDetails.js'), /termineBookings|buchungen\.js|gastName|gastEmail/)
|
|
// Datenschutzerklärung Abschnitt 6 nennt die Arbeitsnachweise und die Aufbewahrung bis zur Verjährung
|
|
const ds = norm(lies('public/datenschutz.html').replace(/<[^>]+>/g, ' '))
|
|
assert.ok(ds.includes('unsere Arbeitsnachweise') && ds.includes('bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre)'))
|
|
|
|
// T3: „Gastdaten löschen“ löscht nur Einträge im Status wartet (verwerfeWartende); Einträge im Status fehler behalten Adresse und Inhalt bis 48 Stunden nach dem Fehlschlag
|
|
const nr8 = avvNr(8)
|
|
assert.ok(nr8.includes('Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig.'))
|
|
assert.ok(nr8.includes('E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag).'))
|
|
assert.ok(!nr8.includes('Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei'), 'der alte Satz versprach mehr, als verwerfeWartende löscht')
|
|
const verwerfe = /export async function verwerfeWartende[\s\S]*?\n\}\n/.exec(OUTBOX)?.[0]
|
|
assert.ok(verwerfe, 'verwerfeWartende')
|
|
assert.ok(verwerfe.includes("Query.equal('status', 'wartet')") && !verwerfe.includes("'fehler'"), 'verwerfeWartende löscht nur den Status wartet')
|
|
assert.match(OUTBOX, /const FEHLER_BEHALTEN_MS = 48 \* STUNDE_MS/)
|
|
assert.ok(avvNr(9).includes('löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag'))
|
|
})
|
|
|
|
/**
|
|
* 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\)/ },
|
|
// 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\)/ },
|
|
{ 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/ },
|
|
{ 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\)/ },
|
|
// das Einrichtungs-Ticket (Kurzfassung der Einstellungen, ohne Daten von Gästen) bleibt als Arbeitsnachweis
|
|
{ datei: 'server/services/termine/ticketDetails.js', hat: /const \{ kurz \} = einbauanleitung\(/ },
|
|
{ datei: 'server/services/termine/einbauanleitung.js', hat: /const KURZ_NAMEN = 8 / },
|
|
],
|
|
'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['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'))
|
|
})
|