Datenschutz Weg B nach Prüfung: Zusagen auf den Dienst begrenzt, [OFFEN]-Markierungen, Upload-Sperren

Abschnitt 11: Sperre und Kürzung ab dem Lesen unserer Antwort, nur je Beitrag und E-Mail-Adresse (neues Gesuch = neuer Beitrag);
aussortierte Anfragen 30 Tage; feste Ausschlussliste der Websuche samt robots.txt; Kriterium für die Dauer der Reste; Kopie der
Standardvertragsklauseln; Hinweis in der ersten Nachricht nennt den Widerspruch; Abschnitt 11 und 14: die KI sortiert aus, ein
Mitarbeiter entscheidet über jede Antwort. Alarm-Postfach-Anbieter und Aufbewahrung der Server-Sicherungen stehen als sichtbare
[OFFEN]-Markierung im Text (auch in Abschnitt 12).

Tests: Pins angepasst, Markierungen nur die zwei erlaubten, neuer Test für Zusagen in Reichweite des Dienstes. Upload-Skript bricht ab,
solange eine [OFFEN]-Markierung steht oder die Prüfsumme neben dem PDF nicht zur HTML passt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 13:57:18 +00:00
1 parent b3ca84a8e5
commit 23ab43f9fe
4 files changed
+100 -23

No files matched your search

+33 -3
View File
@@ -1,5 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
// Hochladen der Datenschutzerklärung nach WOMS "Rechtliches" (scripts/datenschutz-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps
// auf, nie mit dem echten Schlüssel oder dem Netz. Das globale fetch wirft und zählt, damit ein Weg am Nachbau vorbei auffällt.
@@ -13,7 +14,8 @@ const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import lie
const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
// stand = das Datum, das im Nachbau der HTML-Datei hinter "Stand" steht (die PDF-Datei ist beliebiger Inhalt)
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '', stand = '20.10.2026', vorhanden = () => true } = {}) {
// zusatz = zusätzlicher Inhalt der HTML (zum Beispiel eine offene Markierung); summe = Inhalt der .sha256 (null = passend zur HTML, 'fehlt' = nicht lesbar)
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '', stand = '20.10.2026', vorhanden = () => true, zusatz = '', summe = null } = {}) {
const aufrufe = [], log = []
const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
const fetch = async (url, opt = {}) => {
@@ -27,8 +29,12 @@ function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datensch
if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
throw new Error(`unerwartet: ${methode} ${pfad}`)
}
const html = () => Buffer.from(stand === null ? '<p class="meta">ohne Datum</p>' : `<p class="meta">Stand ${stand}</p>`)
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: vorhanden, readFileSync: (rel) => (rel.endsWith('.html') ? html() : Buffer.from('x')) } } }
const html = () => Buffer.from((stand === null ? '<p class="meta">ohne Datum</p>' : `<p class="meta">Stand ${stand}</p>`) + zusatz)
const pruefsumme = () => {
if (summe === 'fehlt') throw new Error('ENOENT')
return Buffer.from(`${summe ?? createHash('sha256').update(html()).digest('hex')}\n`)
}
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: vorhanden, readFileSync: (rel) => (rel.endsWith('.html') ? html() : rel.endsWith('.sha256') ? pruefsumme() : Buffer.from('x')) } } }
}
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')
@@ -91,6 +97,30 @@ test('Stand in der HTML muss der Freigabe entsprechen, sonst Abbruch vor jedem S
const richtig = backend({ stand: '21.10.2026' })
assert.equal(await m.main(['--freigabe=2026-10-21'], richtig.deps), 0, 'mit passender Freigabe geht derselbe Stand durch')
})
test('offene Markierung im Text: Abbruch vor jedem Schreiben, mit Nennung der Markierung; Kommentare zählen nicht', async () => {
const offen = backend({ zusatz: '<p>bei <span class="offen">[OFFEN: Anbieter Alarm-Postfach]</span></p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], offen.deps), 1)
assert.ok(offen.log.some((z) => z.includes('offene Markierung') && z.includes('[OFFEN: Anbieter Alarm-Postfach]')), offen.log.join(' | '))
const zweiMal = backend({ zusatz: '<p>[OFFEN: Aufbewahrung der Server-Sicherungen] und noch einmal [OFFEN: Aufbewahrung der Server-Sicherungen]</p>' })
assert.equal(await m.main(['--freigabe=2026-10-20'], zweiMal.deps), 1)
assert.equal(zweiMal.log.filter((z) => z.includes('offene Markierung'))[0].match(/Aufbewahrung der Server-Sicherungen/g).length, 1, 'jede Markierung nur einmal genannt')
for (const b of [offen, zweiMal]) assert.equal(schreibend(b).length, 0)
const kommentar = backend({ zusatz: '<!-- [OFFEN: nur eine Notiz im Quelltext] -->' })
assert.equal(await m.main(['--freigabe=2026-10-20'], kommentar.deps), 0, 'eine Markierung im HTML-Kommentar ist nicht sichtbar')
})
test('Prüfsumme neben dem PDF muss zur HTML passen, sonst Abbruch vor jedem Schreiben (veraltetes PDF)', async () => {
const veraltet = backend({ summe: 'a'.repeat(64) })
assert.equal(await m.main(['--freigabe=2026-10-20'], veraltet.deps), 1)
assert.ok(veraltet.log.some((z) => z.includes('das PDF ist nicht aus dieser HTML gedruckt') && z.includes('build-legal-pdfs.sh datenschutz')), veraltet.log.join(' | '))
const fehlt = backend({ summe: 'fehlt' })
assert.equal(await m.main(['--freigabe=2026-10-20'], fehlt.deps), 1)
assert.ok(fehlt.log.some((z) => z.includes('WEBklar-Datenschutzerklaerung.pdf.sha256') && z.includes('fehlt')), fehlt.log.join(' | '))
for (const b of [veraltet, fehlt]) assert.equal(schreibend(b).length, 0)
// passende Prüfsumme (Standard im Nachbau): der Upload geht durch
const passt = backend()
assert.equal(await m.main(['--freigabe=2026-10-20'], passt.deps), 0)
assert.equal(schreibend(passt).length, 4)
})
test('--probe liest nur: Bucket, Karte und Versionen, meldet eine fehlende Karte als Stopp', async () => {
const b = backend()
assert.equal(await m.main(['--probe'], b.deps), 0)
+39 -11
View File
@@ -745,28 +745,37 @@ test('legal_datenschutz_anfragen: Abschnitt 11 beschreibt nur den Anfrage-Weg (K
const s = between(strip(html), '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
for (const t of ['Art. 14 DSGVO', 'KI-Assistent', 'Claude von Anthropic', 'von Hand', 'Automatische E-Mails verschicken wir nicht', 'Für E-Mails gilt Abschnitt 8',
// Quellen: Alarm-E-Mails und Websuche; Plattformen mit Bot-Verbot ausgenommen
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus', 'Facebook, Instagram, LinkedIn, Xing, Reddit',
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus',
// feste Ausschlussliste (so wie BOT_VERBOT_DOMAINS im Dienst) statt eines Kriteriums
'Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren robots.txt den Abruf sperrt',
'nicht nach bestimmten Personen',
// Daten
'Titel des Beitrags', 'Beitragstext (höchstens 4000 Zeichen)', 'den Link', 'Ort und Postleitzahl', 'die Plattform', 'Punktzahl mit kurzer Begründung', 'E-Mail-Adresse aus dem Beitrag',
'Ein eigenes Feld für Ihren Namen gibt es nicht', 'Telefonnummern entfernen wir beim Speichern automatisch', 'Faustregel',
// Rechtsgrundlage, Empfänger
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO)',
// keine automatisierte Einzelentscheidung
'entscheidet ein Mitarbeiter (Abschnitt 14)',
// Fristen und Reste
'spätestens 90 Tage nach dem Fund', 'innerhalb von 30 Tagen', 'sperren und kürzen wir sie sofort', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com)',
// keine automatisierte Einzelentscheidung: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort
'Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht', 'Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14)',
// Hinweis in der ersten Nachricht (der Dienst setzt ihn in Claras Entwurf; Wortlaut in text-weg-b-aenderungen.md)
'In unserer ersten Nachricht steht, dass eine KI-Assistentin von WEBklar antwortet', 'in unserer Datenbank speichern, dass Sie widersprechen können',
// Fristen und Reste: aussortierte Anfragen 29/30 Tage (Regel B), Absage und Widerspruch erst ab dem Lesen unserer Seite
'spätestens 90 Tage nach dem Fund', 'Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten innerhalb von 30 Tagen nach dem Fund',
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben', 'sperren und kürzen wir sie sofort, sobald wir Ihren Widerspruch lesen', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
'jedoch ohne Titel, Text, Link und Kontaktdaten', 'bereinigen die zugehörigen Einträge im Protokoll', 'Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9',
// Dauer der Reste (Art. 14 Abs. 2 lit. a: Kriterium)
'so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse erneut ansprechen',
// Kopien
'spätestens nach 90 Tagen', 'Datensicherungen unseres Servers', 'Geschäftsbrief',
// Widerspruch
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com']) assert.ok(s.includes(t), t)
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com',
'Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse',
'Wir erkennen Sie dabei nur am Beitrag und an der E-Mail-Adresse', 'Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag; sagen Sie uns dann bitte noch einmal Bescheid']) assert.ok(s.includes(t), t)
// der Widerspruch steht im Kasten (box widerspruch) im Abschnitt 11
const kasten = html.slice(html.indexOf('<a id="interessenten">'), html.indexOf('<h2>12. Empfänger im Überblick</h2>'))
assert.ok(kasten.includes('<div class="box widerspruch">'))
})
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2) und keine Zusage, die der Dienst heute nicht einhält', () => {
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2); offene Tatsachen stehen als sichtbare [OFFEN]-Markierung im Text', () => {
const html = read(DS)
const ds = strip(html)
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
@@ -775,8 +784,25 @@ test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2) und kein
'Postfach bei der Hetzner', 'In allen Fällen bleiben nur']) assert.ok(!s.includes(t), `noch da (Stufe 2 oder zu viel versprochen): ${t}`)
// Resend, Cloudflare und Overpass stehen nirgends in der Erklärung
for (const t of ['Resend', 'Cloudflare', 'Overpass']) assert.ok(!ds.includes(t), `${t} gehört nicht in die Weg-B-Fassung`)
// keine sichtbaren Platzhalter für offene Punkte (die stehen in der Notiz text-offen.md, nicht im Text)
assert.ok(!/\[OFFEN|\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(html.replace(/<!--[\s\S]*?-->/g, ''))), 'keine offenen Markierungen im sichtbaren Text')
// Offene Tatsachen (Faktenblatt) stehen als sichtbare Markierung im Text, solange sie ungeklärt sind; der Upload (scripts/datenschutz-hochladen.mjs)
// bricht ab, solange eine Markierung steht. Erlaubt sind nur diese beiden; jede steht im Span class="offen" (gelb hinterlegt). Sind sie geklärt, fallen sie weg.
const ERLAUBT = new Set(['[OFFEN: Anbieter Alarm-Postfach]', '[OFFEN: Aufbewahrung der Server-Sicherungen]'])
const sichtbar = html.replace(/<!--[\s\S]*?-->/g, '')
const marken = [...sichtbar.matchAll(/\[OFFEN[^\]]*\]/g)].map((m) => m[0])
for (const m of marken) assert.ok(ERLAUBT.has(m), `unerlaubte offene Markierung: ${m}`)
assert.equal(marken.length, (sichtbar.match(/<span class="offen">\[OFFEN: [^\]<]*\]<\/span>/g) ?? []).length, 'jede Markierung steht im Span class="offen"')
assert.ok([0, 2].includes(marken.filter((m) => m === '[OFFEN: Anbieter Alarm-Postfach]').length), 'Alarm-Postfach: im Absatz über Kopien und in Abschnitt 12 (oder in beiden geklärt)')
// keine anderen Platzhalter
assert.ok(!/\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(sichtbar.replace(/\[OFFEN[^\]]*\]/g, ''))), 'keine anderen Platzhalter im sichtbaren Text')
})
test('legal_datenschutz_anfragen: Zusagen nur in der Reichweite des Dienstes (Sperre nur je Beitrag und E-Mail-Adresse, Fristen ab dem Lesen), keine zu weiten Aussagen', () => {
const ds = strip(read(DS))
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
// der Dienst sperrt nur denselben Beitrag oder dieselbe Adresse und erreicht keine Antwort auf einer Plattform: keine Zusage "nicht mehr schreiben" ohne Einschränkung
for (const t of ['schreiben Ihnen nicht mehr', 'melden uns nicht wieder', 'sperren Ihre Daten dann sofort', 'derzeit unter anderem Facebook', 'Plattformen, die automatisierte Zugriffe untersagen', 'entscheidet nicht allein', 'kurze Zeit enthalten'])
assert.ok(!ds.includes(t), `zu weit: ${t}`)
assert.ok(s.includes('schreiben Ihnen dazu nicht mehr'), 'die Sperre gilt "dazu", also für den Beitrag und die Adresse')
})
test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11 und es gibt keinen Widerspruch zu 8', () => {
@@ -786,10 +812,12 @@ test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
assert.ok(emp.includes('Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)'))
assert.ok(emp.includes('Plattformen, auf denen wir Ihre Anfrage beantworten'))
// der Anbieter des Alarm-Postfachs steht in der Liste, solange er offen ist als Markierung (danach als Name)
assert.ok(emp.includes('E-Mail-Postfach für die Benachrichtigungen der Plattformen (Abschnitt 11)'))
assert.ok(!emp.includes('Betrieben') && !emp.includes('Website-Texte'), 'der Anthropic-Zweck nennt keine Website-Texte von Betrieben mehr')
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
assert.ok(s14.includes('keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO'))
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) bereitet nur eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie nur eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
assert.ok(!ds.includes('keine automatisierten Entscheidungen'), 'die zu weite alte Überschrift ist weg')
// Einleitung nennt die Antwort auf öffentliche Anfragen
assert.ok(ds.includes('oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten'))