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

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

No files matched your search

+24
View File
@@ -1,5 +1,6 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -256,6 +257,23 @@ test('pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles, größ
}
})
test('pdf_pruefsumme: neben jedem PDF liegt <PDF>.sha256 mit der SHA-256 der HTML-Seite, aus der es gedruckt wurde (HTML geändert ohne neues PDF fällt auf)', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
for (const d of DOKUMENTE) {
const pdf = d.html.replace(/\.html$/, '.pdf')
const summe = lies(`${pdf}.sha256`)
assert.match(summe, /^[0-9a-f]{64}\n$/, `${pdf}.sha256: nur die Prüfsumme und ein Zeilenende`)
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh termine)`)
}
// die Prüfung schlägt an: eine geänderte Seite ergibt eine andere Summe
const d = DOKUMENTE[1]
assert.notEqual(sha256(`${lies(d.html)} `), lies(`${d.html.replace(/\.html$/, '.pdf')}.sha256`).trim())
// das Skript schreibt die Summe und druckt ein PDF mit gültiger Summe nicht neu
const sh = lies('scripts/build-legal-pdfs.sh')
assert.ok(sh.includes('sha256sum "$quelle"') && sh.includes('echo "$summe" > "$ziel.sha256"'))
assert.ok(sh.includes('(unveraendert)') && sh.includes('FORCE'))
})
test('anhaenge_drei: legalPdfAttachments("termine", F) liefert lb, avv, widerruf als application/pdf', async () => {
const anhaenge = await legalPdfAttachments('termine', V)
assert.deepEqual(anhaenge.map((a) => a.filename), [
@@ -294,6 +312,11 @@ test('router_alias: /rechtliches/<slug>.html liefert die Bytes der Fassungsdatei
assert.match(pdf.headers.get('content-type'), /application\/pdf/)
const alt = await fetch(`${app.url}/rechtliches/${slug}-1999-01-01.html`)
assert.equal(alt.status, 404, `${slug}-1999-01-01.html darf es nicht geben`)
// die Prüfsummen-Datei neben dem PDF wird einfach mit ausgeliefert, die Adressen der Seiten bleiben davon unberührt
const summe = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf.sha256`)
assert.equal(summe.status, 200)
assert.match(await summe.text(), /^[0-9a-f]{64}\n$/)
assert.equal((await fetch(`${app.url}/rechtliches/${slug}.pdf.sha256`)).status, 404)
}
// Fassung der Konstante ohne Datei: der Alias antwortet 404
const vorher = config.termine.avvVersion
@@ -329,6 +352,7 @@ test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt di
}
// Chrome höchstens 120 Sekunden je Aufruf; Aufbau und Aufrufe nacheinander
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
assert.match(sh, /v="\$\(timeout 60 node --max-old-space-size=256 /, 'auch der Node-Aufruf für die Fassung hat Heap-Grenze und Timeout')
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
// bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
+147 -5
View File
@@ -1,4 +1,4 @@
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, copyFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -376,11 +376,153 @@ test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Bl
} finally {
log2.mock.restore()
}
// Kunde ohne E-Mail-Adresse: keine K15, aber T16 sagt es dem Team
// Kunde ohne E-Mail-Adresse: keine K15, T16 sagt es dem Team, und das Log nennt den Grund (T16: "Der Grund steht im Log des Portals")
aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' })
const ohneAdresse = await bezahleTermine({ customerId: 'c9' })
assert.equal(ohneAdresse.k15Anzahl, 0)
assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden'))
const log3 = stumm()
try {
const ohneAdresse = await bezahleTermine({ customerId: 'c9' })
assert.equal(ohneAdresse.k15Anzahl, 0)
assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden'))
const zeilen = log3.mock.calls.map((c) => c.arguments.join(' '))
assert.ok(zeilen.some((z) => z.includes(`K15 (Funktion ${ohneAdresse.doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt`)), zeilen.join('\n'))
assert.doesNotMatch(zeilen.join('\n'), /Ohne Adresse/, 'keine Personendaten im Log')
} finally {
log3.mock.restore()
}
})
/** Verzeichnis mit Kopien der ausgelieferten Dateien unter dem Namen einer anderen Fassung (HTML und PDF je Dokument). */
function dateienDerFassung(fassung) {
const dir = mkdtempSync(join(tmpdir(), 'k15-fassung-'))
mkdirSync(join(dir, 'rechtliches'))
const echt = legalPdfFiles('termine', V)
for (const f of Object.values(echt)) {
const slug = f.path.split('/').at(-1).replace(`-${V}.pdf`, '')
copyFileSync(f.path, join(dir, 'rechtliches', `${slug}-${fassung}.pdf`))
copyFileSync(f.path.replace(/\.pdf$/, '.html'), join(dir, 'rechtliches', `${slug}-${fassung}.html`))
}
return dir
}
test('k15_vereinbarte_fassung: wurde die Fassung zwischen Klick und Zahlung angehoben, gehen PDFs und Links der vereinbarten Fassung raus, wenn es sie gibt', async () => {
const alt = '2026-10-01'
const dir = dateienDerFassung(alt)
const log = stumm()
try {
setLegalDirForTests(dir)
const vereinbart = Object.fromEntries(Object.keys(consents.zustimmungsfassungen(T)).map((k) => [k, alt]))
const { k15, t16, doc } = await bezahleTermine({ fassungen: vereinbart })
assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${alt}.pdf`, `AVV-Terminplaner-${alt}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${alt}.pdf`])
for (const a of k15.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 5).toString('latin1'), '%PDF-')
for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) {
assert.ok(k15.text.includes(`${BASE}/rechtliches/${slug}-${alt}.html`), `${slug}: Link der vereinbarten Fassung`)
assert.ok(!k15.text.includes(`${slug}-${V}.html`), `${slug}: kein Link der aktuellen Fassung`)
}
assert.ok(k15.text.includes(`Fassung ${alt}: Leistungsbeschreibung`))
assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./)
assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/)
assert.equal(doc.state, 'setup')
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes(`zugestimmt, aktuell ist ${V}`)), 'das Log nennt den Fassungswechsel')
} finally {
setLegalDirForTests(null)
log.mock.restore()
rmSync(dir, { recursive: true, force: true })
}
// gibt es die Dateien der vereinbarten Fassung nicht, geht K15 ohne PDFs und ohne Links raus (Dateien der aktuellen Fassung bleiben ungenutzt)
const log2 = stumm()
try {
const fehlend = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-09-30' } })
assert.ok(!fehlend.k15.attachments || fehlend.k15.attachments.length === 0)
assert.doesNotMatch(fehlend.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
assert.match(fehlend.k15.text, /Deine Zustimmungen/)
assert.ok(fehlend.t16.text.includes('Unterlagen von Hand nachsenden'))
} finally {
log2.mock.restore()
}
})
test('k15_nachweis_nicht_lesbar: ohne lesbaren Nachweis ist die vereinbarte Fassung unbekannt: keine PDFs, keine Links, T16 anhang_fehlt, Log', async () => {
// (a) Lesefehler beim Nachweis
zaehler += 1
const projectId = `ptermine${zaehler}`
const sessionId = `cs_ptermine${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
await consents.speichereZustimmung({
customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: consents.zustimmungsfassungen(T),
texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), at: KLICK,
})
const log = stumm()
try {
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' })
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const neu = mails.sent.slice(vor)
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt'))
assert.ok(k15, 'die Bestätigung geht trotzdem raus')
assert.ok(!k15.attachments || k15.attachments.length === 0, 'nie die aktuelle Fassung ungeprüft anhängen')
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|Deine Zustimmungen|\/rechtliches\//)
assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'))
assert.equal((await sf.getFeatureDoc('c1', projectId, 'termine')).state, 'setup')
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('die vereinbarte Fassung ist unbekannt')), 'das Log nennt den Grund')
} finally {
log.mock.restore()
}
})
test('k15_nachweis_ohne_fassung: ein Nachweis ohne lesbare Fassung zählt wie ein unlesbarer', async () => {
zaehler += 1
const projectId = `ptermine${zaehler}`
const sessionId = `cs_ptermine${zaehler}`
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
aw.seed('featureConsents', {
$id: `fc_kaputt_${zaehler}`, customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, acceptedAt: KLICK,
fassungen: 'kein json', texte: JSON.stringify(consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() })), textHash: 'x',
})
const log = stumm()
try {
const vor = mails.sent.length
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
const neu = mails.sent.slice(vor)
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
assert.ok(!k15.attachments || k15.attachments.length === 0)
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
assert.match(k15.text, /Deine Zustimmungen/, 'der Wortlaut der Zustimmungen steht trotzdem in der Mail')
assert.ok(neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')).text.includes('Unterlagen von Hand nachsenden'))
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Fassung im Zustimmungsnachweis nicht bestimmbar')))
} finally {
log.mock.restore()
}
})
test('k15_frisches_array: der Mailer bekommt für attachments immer ein eigenes, nicht eingefrorenes Array', async () => {
const roh = []
setMailTransportForTests({
async sendMail(mail) {
roh.push(mail)
return mails.sendMail(mail)
},
})
try {
aw.seed('websiteProjects', { $id: 'pnews12', customerId: 'c1', projectName: 'Frisch' })
await sf.markOrdered({ customerId: 'c1', projectId: 'pnews12', key: 'news', sessionId: 'cs_news12', consent: {} })
await sf.handleFeatureCheckoutEvent(paidEvent('cs_news12', 'pnews12'))
await bezahleTermine()
} finally {
setMailTransportForTests(mails)
}
const k15 = roh.filter((m) => String(m.subject).startsWith('Deine Bestellung'))
assert.equal(k15.length, 2)
for (const mail of k15) {
assert.ok(Array.isArray(mail.attachments))
assert.equal(Object.isFrozen(mail.attachments), false)
}
assert.notEqual(k15[0].attachments, k15[1].attachments)
k15[0].attachments.push('x') // ein Mailer darf das Array verändern, ohne das gemeinsame Muster zu treffen
const nochmal = await bezahleTermine()
assert.equal(nochmal.k15.attachments.length, 3)
})
test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => {
+20 -1
View File
@@ -11,7 +11,7 @@ import { useTestEnv } from './support/testEnv.js'
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { startTestApp } = await import('./support/testApp.js')
const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter, setLegalDirForTests } = await import(
const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, featureLegalPaths, legalRouter, setLegalDirForTests } = await import(
'../server/services/legalDocs.js'
)
const analyse = await import('../server/services/analyseLegal.js')
@@ -200,6 +200,25 @@ test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inha
assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit')
})
// Fassung wählbar (Task F8, Fix-Runde 1): K15 schickt die Unterlagen der Fassung, der der Kunde zugestimmt hat
test('featureLegalAttachments / featureLegalReady / featureLegalPaths mit opts.fassung', async () => {
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V))
assert.deepEqual(featureLegalPaths('termine', { fassung: '2026-10-01' }), legalPaths('termine', '2026-10-01'))
assert.equal(featureLegalPaths('news'), null)
assert.throws(() => featureLegalPaths('gibtsnicht'), /Funktion/)
assert.throws(() => featureLegalPaths('termine', { fassung: '2026-10-5' }), /Fassung/)
// ausdrücklich die aktuelle Fassung = ohne Angabe
assert.deepEqual((await featureLegalAttachments('termine', { fassung: V })).map((a) => a.filename), (await featureLegalAttachments('termine')).map((a) => a.filename))
assert.equal(await featureLegalReady('termine', { fassung: V }), true)
// andere Fassung ohne Dateien: Wurf bei den Anhängen, false bei der Prüfung, news weiter []
await assert.rejects(featureLegalAttachments('termine', { fassung: '1999-01-01' }))
assert.equal(await featureLegalReady('termine', { fassung: '1999-01-01' }), false)
assert.equal(await featureLegalReady('termine', { fassung: 'kaputt' }), false)
assert.deepEqual(await featureLegalAttachments('news', { fassung: '1999-01-01' }), [])
// opts.ohne wirkt weiter neben fassung
assert.deepEqual((await featureLegalAttachments('termine', { fassung: V, ohne: ['avv'] })).map((a) => a.filename.split('-')[0]), ['Leistungsbeschreibung', 'Widerrufsbelehrung'])
})
test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => {
assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`)
+58 -2
View File
@@ -32,6 +32,7 @@ const { setMailTransportForTests } = await import('../server/services/mailer.js'
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
const { halte } = await import('../server/services/termine/ablauf.js')
const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.js')
after(() => setMailTransportForTests(null))
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
@@ -450,6 +451,14 @@ test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschut
assert.ok(absatz.includes('nicht zustellbare E-Mails nach 48 Stunden'))
assert.ok(absatz.includes('Rechenzentren Falkenstein und Nürnberg') && absatz.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
assert.ok(absatz.includes('Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen'))
// unbeantwortete Terminanfragen: derselbe Satz im Datenschutz-Absatz des Kunden und im AVV (Nr. 9 Abs. 2), und der Code tut es
assert.ok(absatz.includes('Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt.'))
const nr9abs2 = absatzTexte(abschnitt(AVV, 9))[1]
assert.ok(nr9abs2.startsWith('Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.'))
assert.ok(nr9abs2.includes('Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.'))
assert.ok(JOBS.includes("angefragt mit Beginn in der Vergangenheit => abgelehnt vom System"), 'jobs.js lehnt verstrichene Anfragen ab')
assert.equal(loeschBezugsfeld('angefragt'), undefined, 'eine Anfrage wird nie unmittelbar gelöscht, erst als abgelehnte nach 30 Tagen')
assert.equal(loeschBezugsfeld('abgelehnt'), 'updatedAt', 'die 30 Tage laufen ab der letzten Änderung, also ab der Ablehnung durch das System')
// der AVV nennt dieselben Zahlen
const nr3 = avvNr(3)
assert.ok(nr3.includes('In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).'))
@@ -500,6 +509,12 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene
assert.equal(buchungen.length, 1)
const nachweis = JSON.parse(buchungen[0].nachweis)
assert.ok(nachweis.fassung && nachweis.datenschutz, 'der Nachweis enthält Fassung und Hash des Datenschutz-Hinweises')
// genau die Bestandteile, die AVV Nr. 3 Abs. 1 und Nr. 8 Abs. 5 nennen: Fassung, Zeitpunkt, Knopftext, Anrede, Hashes (keine weiteren Klartexte)
const erlaubt = new Set(['fassung', 'am', 'knopf', 'anrede', 'datenschutz', 'storno', 'widerruf', 'vorzeitig', 'erinnerung'])
assert.deepEqual(Object.keys(nachweis).filter((k) => !erlaubt.has(k)), [])
for (const k of ['fassung', 'am', 'knopf', 'anrede']) assert.equal(typeof nachweis[k], 'string', k)
assert.deepEqual(Object.keys(nachweis.storno), ['version', 'hash', 'haken', 'am'])
assert.deepEqual(Object.keys(nachweis.erinnerung), ['stunden', 'hash', 'am'])
const schluessel = (wert) => (wert && typeof wert === 'object' ? Object.entries(wert).flatMap(([k, v]) => [k, ...schluessel(v)]) : [])
assert.deepEqual(schluessel(nachweis).filter((k) => nameIp.test(k)), [])
// der Nachweis hält Hashes, keine Texte: jeder Hash ist 64 Hexzeichen
@@ -516,6 +531,32 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene
}
})
/** Jedes Attribut einer Buchung ist in AVV Nr. 3 Abs. 1 genannt (Wortlaut) oder eine technische Kennung/Eigenschaft des Dienstes. */
const BUCHUNG_IM_AVV = {
gastName: 'Name und E-Mail-Adresse', gastEmail: 'Name und E-Mail-Adresse', gastTelefon: 'Telefonnummer', gastNachricht: 'eine Nachricht', gastZusatz: 'Zusatzfelder',
leistungName: 'die gebuchte Leistung', ressourceName: 'die Person oder der Platz', startsAt: 'der Termin', endsAt: 'der Termin', blockEndsAt: 'der Termin',
status: 'der Status der Buchung mit den Zeitpunkten', heldUntil: 'der Status der Buchung mit den Zeitpunkten', bestaetigtAt: 'der Status der Buchung mit den Zeitpunkten',
angenommenAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtVon: 'der Status der Buchung mit den Zeitpunkten',
widerrufenAt: 'der Status der Buchung mit den Zeitpunkten', createdAt: 'der Status der Buchung mit den Zeitpunkten', updatedAt: 'der Status der Buchung mit den Zeitpunkten',
absageGrund: 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', nachweis: 'der Nachweis der Zustimmungen',
preisCent: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', zahlart: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
zahlStatus: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', verbraucher: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
erinnerung: 'der Wunsch nach einer Erinnerung', erinnerungGesendetAt: 'der Wunsch nach einer Erinnerung',
}
const BUCHUNG_TECHNISCH = ['featureId', 'customerId', 'projectId', 'leistungId', 'ressourceId', 'preisArt', 'buchungsart', 'quelle', 'verifyHash', 'manageHash', 'stripeSessionId', 'stripePaymentIntent', 'icsSequenz']
test('avv_datenarten_decken_schema: jedes Attribut einer Buchung steht in Nr. 3 Abs. 1 oder ist eine technische Kennung', () => {
const nr3abs1 = norm(absatzTexte(abschnitt(AVV, 3))[0])
const attribute = TERMINE_COLLECTIONS.termineBookings.attributes.map((a) => a.key)
const unbekannt = attribute.filter((k) => !Object.hasOwn(BUCHUNG_IM_AVV, k) && !BUCHUNG_TECHNISCH.includes(k))
assert.deepEqual(unbekannt, [], 'neues Attribut der Buchung: in AVV Nr. 3 Abs. 1 aufnehmen oder als technisch einstufen')
for (const [k, wort] of Object.entries(BUCHUNG_IM_AVV)) {
assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
assert.ok(nr3abs1.includes(wort), `${k}: „${wort}“ fehlt in Nr. 3 Abs. 1`)
}
for (const k of BUCHUNG_TECHNISCH) assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
})
test('avv_verweise_auf_lb: jedes „Nr. N Abs. M der Leistungsbeschreibung“ und jedes „Nummer N“ im AVV zeigt auf Vorhandenes', () => {
const text = norm(AVV_MD)
@@ -600,7 +641,8 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
const nr3 = avvNr(3)
for (const wort of [
'Name und E-Mail-Adresse', 'Telefonnummer', 'Zusatzfelder', 'die gebuchte Leistung, die Person oder der Platz und der Termin', 'der Status der Buchung mit den Zeitpunkten',
'Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
'Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', 'auch mit dem Namen aus einer Widerrufserklärung', 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
'Namen oder Bezeichnungen der Personen oder Plätze', 'Arbeitszeiten und Abwesenheiten', 'geheimer Kalender-Link',
]) assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
assert.ok(nr3.includes('Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet.'))
@@ -611,12 +653,14 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten
const nr4 = avvNr(4)
assert.ok(nr4.includes('Mit der Bestellung weist der Kunde WEBklar an'))
assert.ok(nr4.includes('Sonstige Weisungen erteilt der Kunde in Textform'))
assert.ok(nr4.includes('Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.'))
const nr8 = avvNr(8)
assert.ok(nr8.includes('Gastdaten löschen') && nr8.includes('CSV-Datei'))
assert.ok(nr8.includes('möglichst innerhalb von 24 Stunden nach Kenntnis'))
assert.ok(nr8.includes('bis drei Jahre nach dem Ende des Terminplaners'))
assert.ok(nr8.includes('nur den Zeitpunkt und die Hashes der Texte'))
assert.ok(nr8.includes('die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte'))
assert.doesNotMatch(nr8, /nur den Zeitpunkt/)
const nr11 = avvNr(11)
assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten'))
@@ -673,6 +717,9 @@ const TOM_FUNDSTELLEN = {
{ datei: 'server/services/termine/schema.js', hat: /Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments/ },
{ datei: 'server/services/termine/schema.js', hat: /str\('featureId', 64\)/ },
{ datei: 'server/services/termine/buchungen.js', hat: /Query\.equal\('featureId', featureId\)/ },
// die Hintergrundläufe arbeiten über alle Funktionen (Löschlauf ohne Funktionsfilter, Aufräumen der Warteschlange nach Status) und löschen Dokument für Dokument
{ datei: 'server/services/termine/buchungen.js', hat: /featureId === null \? \[\] : \[Query\.equal\('featureId', featureId\)\]/ },
{ datei: 'server/services/termine/outbox.js', hat: /Query\.equal\('status', \['gesendet', 'fehler', 'verworfen'\]\)/ },
],
'Löschung': [
{ datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ },
@@ -680,6 +727,9 @@ const TOM_FUNDSTELLEN = {
{ datei: 'server/services/termine/jobs.js', hat: /im Zweifel wird nichts geloescht/ },
{ datei: 'server/services/termine/jobs.js', hat: /ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE \* TAG_MS/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /export const ENTWURF_TAGE = 90/ },
{ datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ },
// die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch)
{ datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ },
],
'Protokolle': [
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
@@ -730,6 +780,12 @@ test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, i
assert.ok(zeilen['Datensicherung'].includes('die letzten sieben Tagessicherungen'))
assert.ok(zeilen['Rechenzentrum'].includes('ISO/IEC 27001') && zeilen['Rechenzentrum'].includes('Falkenstein und Nürnberg'))
assert.ok(zeilen['Löschung'].includes('30 Tage nach dem Abschalten'))
assert.ok(zeilen['Löschung'].includes('Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen'))
assert.doesNotMatch(zeilen['Löschung'], /Vor jeder Löschung/)
assert.ok(zeilen['Trennung'].includes('Das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion.'))
assert.ok(zeilen['Trennung'].includes('Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht.'))
assert.doesNotMatch(zeilen['Trennung'], /Abfragen erfolgen nur je Funktion/)
assert.ok(zeilen['Datenminimierung'].includes('der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte'))
assert.ok(zeilen['Speicher im Browser'].includes('keine Cookies'))
assert.ok(zeilen['Zugriffskontrolle'].includes('konstanter Zeit'))
assert.ok(zeilen['Organisation'].includes('Nummer 8 Abs. 3') && zeilen['Organisation'].includes('Nummer 8 Abs. 5'))