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:
1 parent
2c059cb3d6
commit
90098726b9
13 files changed
+345
-63
No files matched your search
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user