// @vitest-environment node /** * Der Rückmeldeweg: eine Adresse, keine Rufnummer, keine Anschrift. * * Anlass ist die Auslieferung über den Microsoft Store. Bis dahin standen * Telefonnummer und Wohnort des Betreibers in der Anwendung; beide sind * private Angaben und für die Prüfschritte 9.2.2 und 9.2.3 des * Barrierefreiheits-Prüfplans nicht gefordert. 9.2.3 verlangt einen Kanal, * der „mindestens E-Mail oder ein barrierefreies Formular, nicht * ausschließlich Telefon“ ist – E-Mail allein erfüllt ihn also. * * Ein Test, der bloß die neue Adresse prüft, wäre zu schwach: Er bliebe grün, * wenn jemand die Rufnummer irgendwo wieder ergänzt. Deshalb prüft diese * Datei beide Richtungen – dass die eine Adresse dasteht **und** dass keine * Rufnummer und keine Anschrift zurückgekehrt ist. Gesucht wird nach Mustern, * nicht nach den entfernten Zeichenketten: Eine andere Nummer wäre derselbe * Rückschritt. * * Gegen den Quelltext, nicht nur gegen die Konstanten: Die Angaben ließen * sich auch als Festtext an einer Ansicht vorbeischmuggeln, ohne `KONTAKT` je * anzufassen. Gelesen wird, was `git ls-files` unter `app/src` führt – nur * das wird ausgeliefert. * * Und seit dem 28.08.2026 gegen den **ganzen** Arbeitsbaum. Der Grund ist ein * anderer Verbreitungsweg: Für den Store genügt `app/src`, denn nur das geht * an die Anwender. Für ein öffentliches Quelltextarchiv nicht – dort wandert * alles mit, und die privaten Angaben stünden nicht in der Anwendung, sondern * in den Unterlagen: `docs/stand.md`, `PLAN.md`, der Änderungsverlauf. Genau * dorthin hat vorher niemand gesehen. * * Was die Muster nicht erreichen, steht bei `TEXTENDUNGEN` und `AUSGENOMMEN` * ausgeschrieben. Und eines erreichen sie grundsätzlich nicht: die * Autorenangabe der Commits. Sie steht nicht im Text, sondern in den * Kopfdaten – dagegen hilft kein Test, sondern nur `git config user.email` * vor dem ersten Commit eines öffentlichen Archivs (docs/stand.md, * Abschnitt 8). */ import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { HILFE_KAPITEL, type Hilfeblock } from '../src/shared/hilfe'; import { KONTAKT, KONTAKT_ANLIEGEN } from '../src/shared/kontakt'; import { meldetextBauen } from '../src/shared/meldetext'; const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); /** Die einzige Anschrift, über die zurückgemeldet wird. */ const ADRESSE = 'Olaf@olaf-willerding.de'; /** * Eine deutsche Rufnummer im Fließtext. * * Verlangt Vorwahl, ein Trennzeichen und mindestens fünf weitere Ziffern – * genau die Form, in der die entfernte Nummer dastand. Enger gefasst als * „irgendeine Ziffernfolge“, damit Fassungsnummern, Prüfsummen und CSS-Werte * nicht mitgefangen werden; am Stand des Umbaus lief das Muster über alle 155 * Quelldateien unter `app/src` und fand nichts. */ const RUFNUMMER = /(?:\b0\d{3,4}[ /-]\d{5,9}\b|\+49[\d\s/-]{6,}\d)/u; /** Postleitzahl und Ortsname – die Form der entfernten Ortsangabe. */ const ANSCHRIFT = /\b\d{5}\s+[A-ZÄÖÜ][a-zäöüß]/u; /** Die aufgegebene Adresse des Betreibers, in jeder Schreibweise. */ const ALTE_ADRESSE = /t-online\.de/iu; /** Alle Textstellen eines Handbuchblocks, gleich welcher Art. */ function texte(block: Hilfeblock): readonly string[] { switch (block.art) { case 'absatz': return [block.text]; case 'liste': case 'schritte': return block.punkte; case 'tasten': return block.zeilen.map((zeile) => zeile.wirkung); } } /** Erfundene Eingaben für den Meldetext – hier zählt nur sein Kopf. */ const MELDETEXT = meldetextBauen( { id: 'I.1-01', amtliche_nummer: '1.01', seite: 6 }, { herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quelldatei_sha256: '7e5e41faee0a9198b6ba3e2fe3b46ef313448b06753516579596f98212b1d108', }, { fassung: '0.21.1', baukennung: '0218791', baustand: '2026-08-22' }, ); /** Was der Anwendung an Betreiberangaben aus dem Mund kommt. */ const SICHTBARER_TEXT = [ HILFE_KAPITEL.flatMap((kapitel) => kapitel.bloecke.flatMap(texte)).join('\n'), Object.values(KONTAKT).join('\n'), KONTAKT_ANLIEGEN.join('\n'), MELDETEXT, ].join('\n'); /** Die von Git geführten Dateien unterhalb von `pfad`, oder `null` ohne Git. */ function versionierteDateien(pfad: string): readonly string[] | null { try { return execFileSync('git', ['ls-files', pfad], { cwd: wurzel, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) .split('\n') .map((zeile) => zeile.trim()) .filter((zeile) => zeile.length > 0); } catch { return null; } } /** Der ausgelieferte Quelltext, oder `null` ohne Git. */ function quelltexte(): readonly string[] | null { const dateien = versionierteDateien('app/src'); if (dateien === null) { return null; } return dateien .filter((datei) => datei.endsWith('.ts') || datei.endsWith('.tsx')) .map((datei) => readFileSync(join(wurzel, datei), 'utf8')); } /** * Dateiendungen, hinter denen Text steht, den ein Mensch geschrieben hat. * * Bilder und PDF stehen nicht dabei: In ihnen findet ein Textmuster nichts, * auch wenn die Angabe als Pixel darinsteht. Für sie trägt die Reihenfolge * den Beweis – alle Aufnahmen entstehen aus einem Bau der Anwendung, und in * der Anwendung hält der Test darüber die Angaben draußen. */ const TEXTENDUNGEN = [ '.md', '.ts', '.tsx', '.mjs', '.js', '.json', '.txt', '.yml', '.yaml', '.css', '.html', '.py', '.toml', ] as const; /** * Dateien, die das Muster nicht sinnvoll prüfen kann – jede mit ihrem Grund. * * Kurz halten. Jeder Eintrag ist ein Stück Arbeitsbaum, über das diese Wache * nichts mehr sagt; eine Ausnahme ohne Grund ist eine Lücke mit Absegnung. */ const AUSGENOMMEN: readonly { readonly datei: string; readonly grund: string }[] = [ { datei: 'app/tests/kontakt.test.ts', grund: 'Diese Datei selbst. Sie führt erfundene Proben in der Form der ' + 'entfernten Angaben, damit die Gegenprobe oben nicht ins Leere läuft.', }, { datei: 'app/package-lock.json', grund: 'Von npm erzeugt. Die Integritätsprüfsummen sind Base64 und enthalten ' + 'Schrägstriche, an denen das Rufnummernmuster zufällig anschlägt; ' + 'einen Wohnort schreibt dort niemand hinein.', }, { datei: 'docs/katalog-pruefbericht.html', grund: 'Erzeugter Prüfbericht mit eingebetteten Bildern als Base64 – dieselbe ' + 'Zufallstrefferlage. Sein Inhalt stammt vollständig aus dem amtlichen ' + 'Katalog, nicht aus Betreiberangaben.', }, ]; /** * Anschriften, die dastehen **müssen** – jede einzeln und mit Grund. * * Diese Wache hält die **privaten** Angaben des Betreibers draußen. Fremde * Geschäftsanschriften sind etwas anderes: Art. 13 Abs. 1 lit. e DSGVO * verlangt, dass Empfänger personenbezogener Daten benannt werden, und wer * eine Webseite ausliefert, verarbeitet die IP-Adresse jedes Lesers. Eine * Datenschutzerklärung, die ihren Auftragsverarbeiter verschweigt, wäre * unvollständig. * * Erlaubt wird deshalb der **Wortlaut**, nicht die Datei: `docs/datenschutz.md` * bleibt vollständig bewacht, und jede andere Anschrift darin schlägt weiter * an. Die Alternative – die Datei freistellen – hätte ausgerechnet das * Dokument aus der Prüfung genommen, in dem eine Anschrift am ehesten * versehentlich landet. */ const ERLAUBTE_ANSCHRIFTEN: readonly { readonly text: string; readonly grund: string }[] = [ { text: 'Hauptstraße 68, 02742 Friedersdorf', grund: 'Sitz von ALL-INKL.COM – Neue Medien Münnich, dem Anbieter des ' + 'Webspace, auf dem die Datenschutzerklärung liegt (Abschnitt 10.5). ' + 'Öffentliche Geschäftsanschrift aus dem Impressum des Anbieters.', }, ]; describe('Rückmeldeweg – die eine Adresse', () => { it('nennt genau die Adresse, die gelten soll', () => { expect(KONTAKT.epost).toBe(ADRESSE); }); it('behält den Namen als Urheberbenennung', () => { /* Der Name ist keine private Kontaktangabe, sondern steht so in der EUPL-Auszeichnung, in REUSE.toml und als Herausgeber im Store. Er darf beim Entfernen der Kontaktdaten nicht mit verschwinden. */ expect(KONTAKT.name).toBe('Olaf Willerding'); }); it('führt kein Feld für Telefon oder Ort mehr', () => { /* Ein übrig gebliebenes leeres Feld wäre die stille Einladung, es wieder zu füllen. Deshalb hat der Vertrag die Felder gar nicht erst. */ expect(Object.keys(KONTAKT).sort()).toEqual(['epost', 'frist', 'name']); }); it('zeigt die Adresse im Handbuch und im Meldetext', () => { /* Zwei Stellen mit derselben Adresse laufen auseinander; beide lesen deshalb aus KONTAKT. Dieser Test merkt, wenn eine davon wegfällt. */ expect(SICHTBARER_TEXT).toContain(ADRESSE); expect(MELDETEXT).toContain(ADRESSE); }); }); describe('Rückmeldeweg – was nicht zurückkehren darf', () => { it('erkennt Angaben dieser Art überhaupt', () => { /* Gegenprobe. Ohne sie wären die Tests unten auch dann grün, wenn die Muster an einer echten Rufnummer vorbeiliefen – und damit wertlos. Die Proben sind erfunden, haben aber genau die Form der entfernten Angaben. Die echten hier einzusetzen hieße, sie im Arbeitsbaum zu behalten – ausgerechnet in der Datei, die sie draußen halten soll. */ expect(RUFNUMMER.test('Telefon: 09999 111222')).toBe(true); expect(RUFNUMMER.test('+49 9999 111222')).toBe(true); expect(ANSCHRIFT.test('99999 Musterstadt')).toBe(true); expect(ALTE_ADRESSE.test('irgendwer@t-online.de')).toBe(true); /* Und die Gegenrichtung: Was dastehen darf, darf nicht anschlagen. */ expect(RUFNUMMER.test('Fassung 0.21.1, Bau 0218791 vom 22.08.2026')).toBe(false); expect(ANSCHRIFT.test(ADRESSE)).toBe(false); }); it('nennt in keinem sichtbaren Text eine Rufnummer oder eine Anschrift', () => { expect(SICHTBARER_TEXT).not.toMatch(RUFNUMMER); expect(SICHTBARER_TEXT).not.toMatch(ANSCHRIFT); expect(SICHTBARER_TEXT).not.toMatch(ALTE_ADRESSE); }); it('nennt auch im Handbuch das Wort „Telefon“ nicht mehr', () => { /* Das Handbuch führte den Kanal als Aufzählung „Verantwortlich – E-Mail – Telefon“. Fällt die Zeile weg, aber das Wort bleibt, steht dort eine Beschriftung ohne Inhalt. */ expect(SICHTBARER_TEXT).not.toContain('Telefon'); }); it('nennt im ausgelieferten Quelltext keine Rufnummer und keine Anschrift', () => { const quellen = quelltexte(); if (quellen === null) { /* Kein Git – dann ist nicht feststellbar, was ausgeliefert würde. */ return; } expect(quellen.length).toBeGreaterThan(50); for (const quelle of quellen) { expect(quelle).not.toMatch(RUFNUMMER); expect(quelle).not.toMatch(ANSCHRIFT); expect(quelle).not.toMatch(ALTE_ADRESSE); } }); it('nennt sie auch in keinem anderen versionierten Text – Unterlagen eingeschlossen', () => { /* Der Test darüber sieht `app/src`, weil nur das ausgeliefert wird. Für den Store genügt das. Für ein öffentliches Archiv nicht: Dort wandert der ganze Arbeitsbaum mit, und die privaten Angaben stünden nicht in der Anwendung, sondern in den Unterlagen – `docs/stand.md`, `PLAN.md`, der Änderungsverlauf. Genau dort hat bisher niemand hingesehen. Deshalb dieselben Muster über alles, was Git führt und ein Mensch geschrieben hat. Ausgenommen ist, was oben mit Grund dasteht. */ const dateien = versionierteDateien('.'); if (dateien === null) { /* Kein Git – dann ist nicht feststellbar, was mitwanderte. */ return; } const ausgenommen = new Set(AUSGENOMMEN.map((eintrag) => eintrag.datei)); const zuPruefen = dateien.filter( (datei) => TEXTENDUNGEN.some((endung) => datei.endsWith(endung)) && !ausgenommen.has(datei), ); /* Ohne diese Schranke wäre der Test auch dann grün, wenn `git ls-files` nichts mehr liefert oder die Endungsliste ins Leere greift. */ expect(zuPruefen.length).toBeGreaterThan(200); const beanstandet: string[] = []; for (const datei of zuPruefen) { let inhalt = readFileSync(join(wurzel, datei), 'utf8'); /* Die begründeten Anschriften vor dem Prüfen herausnehmen – und zwar als Wortlaut, damit jede andere Anschrift in derselben Datei weiter anschlägt. Ein `replaceAll` statt einer Ausnahmeliste für Dateien. */ for (const erlaubt of ERLAUBTE_ANSCHRIFTEN) { inhalt = inhalt.split(erlaubt.text).join(''); } for (const [was, muster] of [ ['Rufnummer', RUFNUMMER], ['Anschrift', ANSCHRIFT], ['aufgegebene Adresse', ALTE_ADRESSE], ] as const) { const treffer = muster.exec(inhalt); if (treffer !== null) { /* Die Fundstelle wird benannt, der Fund nicht abgedruckt: Eine Fehlermeldung landet in Protokollen und Berichten, und dort soll die Angabe genauso wenig stehen wie in der Datei. */ const zeile = inhalt.slice(0, treffer.index).split('\n').length; beanstandet.push(`${datei}:${String(zeile)} – ${was}`); } } } expect(beanstandet).toEqual([]); }); });