waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Der Rückmeldeweg: eine Adresse, keine Rufnummer, keine Anschrift. |
| 4 | * |
| 5 | * Anlass ist die Auslieferung über den Microsoft Store. Bis dahin standen |
| 6 | * Telefonnummer und Wohnort des Betreibers in der Anwendung; beide sind |
| 7 | * private Angaben und für die Prüfschritte 9.2.2 und 9.2.3 des |
| 8 | * Barrierefreiheits-Prüfplans nicht gefordert. 9.2.3 verlangt einen Kanal, |
| 9 | * der „mindestens E-Mail oder ein barrierefreies Formular, nicht |
| 10 | * ausschließlich Telefon“ ist – E-Mail allein erfüllt ihn also. |
| 11 | * |
| 12 | * Ein Test, der bloß die neue Adresse prüft, wäre zu schwach: Er bliebe grün, |
| 13 | * wenn jemand die Rufnummer irgendwo wieder ergänzt. Deshalb prüft diese |
| 14 | * Datei beide Richtungen – dass die eine Adresse dasteht **und** dass keine |
| 15 | * Rufnummer und keine Anschrift zurückgekehrt ist. Gesucht wird nach Mustern, |
| 16 | * nicht nach den entfernten Zeichenketten: Eine andere Nummer wäre derselbe |
| 17 | * Rückschritt. |
| 18 | * |
| 19 | * Gegen den Quelltext, nicht nur gegen die Konstanten: Die Angaben ließen |
| 20 | * sich auch als Festtext an einer Ansicht vorbeischmuggeln, ohne `KONTAKT` je |
| 21 | * anzufassen. Gelesen wird, was `git ls-files` unter `app/src` führt – nur |
| 22 | * das wird ausgeliefert. |
| 23 | * |
| 24 | * Und seit dem 28.08.2026 gegen den **ganzen** Arbeitsbaum. Der Grund ist ein |
| 25 | * anderer Verbreitungsweg: Für den Store genügt `app/src`, denn nur das geht |
| 26 | * an die Anwender. Für ein öffentliches Quelltextarchiv nicht – dort wandert |
| 27 | * alles mit, und die privaten Angaben stünden nicht in der Anwendung, sondern |
| 28 | * in den Unterlagen: `docs/stand.md`, `PLAN.md`, der Änderungsverlauf. Genau |
| 29 | * dorthin hat vorher niemand gesehen. |
| 30 | * |
| 31 | * Was die Muster nicht erreichen, steht bei `TEXTENDUNGEN` und `AUSGENOMMEN` |
| 32 | * ausgeschrieben. Und eines erreichen sie grundsätzlich nicht: die |
| 33 | * Autorenangabe der Commits. Sie steht nicht im Text, sondern in den |
| 34 | * Kopfdaten – dagegen hilft kein Test, sondern nur `git config user.email` |
| 35 | * vor dem ersten Commit eines öffentlichen Archivs (docs/stand.md, |
| 36 | * Abschnitt 8). |
| 37 | */ |
| 38 | |
| 39 | import { execFileSync } from 'node:child_process'; |
| 40 | import { readFileSync } from 'node:fs'; |
| 41 | import { join } from 'node:path'; |
| 42 | import { fileURLToPath } from 'node:url'; |
| 43 | |
| 44 | import { describe, expect, it } from 'vitest'; |
| 45 | |
| 46 | import { HILFE_KAPITEL, type Hilfeblock } from '../src/shared/hilfe'; |
| 47 | import { KONTAKT, KONTAKT_ANLIEGEN } from '../src/shared/kontakt'; |
| 48 | import { meldetextBauen } from '../src/shared/meldetext'; |
| 49 | |
| 50 | const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); |
| 51 | |
| 52 | /** Die einzige Anschrift, über die zurückgemeldet wird. */ |
| 53 | const ADRESSE = 'Olaf@olaf-willerding.de'; |
| 54 | |
| 55 | /** |
| 56 | * Eine deutsche Rufnummer im Fließtext. |
| 57 | * |
| 58 | * Verlangt Vorwahl, ein Trennzeichen und mindestens fünf weitere Ziffern – |
| 59 | * genau die Form, in der die entfernte Nummer dastand. Enger gefasst als |
| 60 | * „irgendeine Ziffernfolge“, damit Fassungsnummern, Prüfsummen und CSS-Werte |
| 61 | * nicht mitgefangen werden; am Stand des Umbaus lief das Muster über alle 155 |
| 62 | * Quelldateien unter `app/src` und fand nichts. |
| 63 | */ |
| 64 | const RUFNUMMER = /(?:\b0\d{3,4}[ /-]\d{5,9}\b|\+49[\d\s/-]{6,}\d)/u; |
| 65 | |
| 66 | /** Postleitzahl und Ortsname – die Form der entfernten Ortsangabe. */ |
| 67 | const ANSCHRIFT = /\b\d{5}\s+[A-ZÄÖÜ][a-zäöüß]/u; |
| 68 | |
| 69 | /** Die aufgegebene Adresse des Betreibers, in jeder Schreibweise. */ |
| 70 | const ALTE_ADRESSE = /t-online\.de/iu; |
| 71 | |
| 72 | /** Alle Textstellen eines Handbuchblocks, gleich welcher Art. */ |
| 73 | function texte(block: Hilfeblock): readonly string[] { |
| 74 | switch (block.art) { |
| 75 | case 'absatz': |
| 76 | return [block.text]; |
| 77 | case 'liste': |
| 78 | case 'schritte': |
| 79 | return block.punkte; |
| 80 | case 'tasten': |
| 81 | return block.zeilen.map((zeile) => zeile.wirkung); |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /** Erfundene Eingaben für den Meldetext – hier zählt nur sein Kopf. */ |
| 86 | const MELDETEXT = meldetextBauen( |
| 87 | { id: 'I.1-01', amtliche_nummer: '1.01', seite: 6 }, |
| 88 | { |
| 89 | herausgeber: 'Bundesverwaltungsamt', |
| 90 | stand: '2024-12-16', |
| 91 | quelldatei_sha256: '7e5e41faee0a9198b6ba3e2fe3b46ef313448b06753516579596f98212b1d108', |
| 92 | }, |
| 93 | { fassung: '0.21.1', baukennung: '0218791', baustand: '2026-08-22' }, |
| 94 | ); |
| 95 | |
| 96 | /** Was der Anwendung an Betreiberangaben aus dem Mund kommt. */ |
| 97 | const SICHTBARER_TEXT = [ |
| 98 | HILFE_KAPITEL.flatMap((kapitel) => kapitel.bloecke.flatMap(texte)).join('\n'), |
| 99 | Object.values(KONTAKT).join('\n'), |
| 100 | KONTAKT_ANLIEGEN.join('\n'), |
| 101 | MELDETEXT, |
| 102 | ].join('\n'); |
| 103 | |
| 104 | /** Die von Git geführten Dateien unterhalb von `pfad`, oder `null` ohne Git. */ |
| 105 | function versionierteDateien(pfad: string): readonly string[] | null { |
| 106 | try { |
| 107 | return execFileSync('git', ['ls-files', pfad], { |
| 108 | cwd: wurzel, |
| 109 | encoding: 'utf8', |
| 110 | stdio: ['ignore', 'pipe', 'ignore'], |
| 111 | }) |
| 112 | .split('\n') |
| 113 | .map((zeile) => zeile.trim()) |
| 114 | .filter((zeile) => zeile.length > 0); |
| 115 | } catch { |
| 116 | return null; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | /** Der ausgelieferte Quelltext, oder `null` ohne Git. */ |
| 121 | function quelltexte(): readonly string[] | null { |
| 122 | const dateien = versionierteDateien('app/src'); |
| 123 | if (dateien === null) { |
| 124 | return null; |
| 125 | } |
| 126 | return dateien |
| 127 | .filter((datei) => datei.endsWith('.ts') || datei.endsWith('.tsx')) |
| 128 | .map((datei) => readFileSync(join(wurzel, datei), 'utf8')); |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Dateiendungen, hinter denen Text steht, den ein Mensch geschrieben hat. |
| 133 | * |
| 134 | * Bilder und PDF stehen nicht dabei: In ihnen findet ein Textmuster nichts, |
| 135 | * auch wenn die Angabe als Pixel darinsteht. Für sie trägt die Reihenfolge |
| 136 | * den Beweis – alle Aufnahmen entstehen aus einem Bau der Anwendung, und in |
| 137 | * der Anwendung hält der Test darüber die Angaben draußen. |
| 138 | */ |
| 139 | const TEXTENDUNGEN = [ |
| 140 | '.md', |
| 141 | '.ts', |
| 142 | '.tsx', |
| 143 | '.mjs', |
| 144 | '.js', |
| 145 | '.json', |
| 146 | '.txt', |
| 147 | '.yml', |
| 148 | '.yaml', |
| 149 | '.css', |
| 150 | '.html', |
| 151 | '.py', |
| 152 | '.toml', |
| 153 | ] as const; |
| 154 | |
| 155 | /** |
| 156 | * Dateien, die das Muster nicht sinnvoll prüfen kann – jede mit ihrem Grund. |
| 157 | * |
| 158 | * Kurz halten. Jeder Eintrag ist ein Stück Arbeitsbaum, über das diese Wache |
| 159 | * nichts mehr sagt; eine Ausnahme ohne Grund ist eine Lücke mit Absegnung. |
| 160 | */ |
| 161 | const AUSGENOMMEN: readonly { readonly datei: string; readonly grund: string }[] = [ |
| 162 | { |
| 163 | datei: 'app/tests/kontakt.test.ts', |
| 164 | grund: |
| 165 | 'Diese Datei selbst. Sie führt erfundene Proben in der Form der ' + |
| 166 | 'entfernten Angaben, damit die Gegenprobe oben nicht ins Leere läuft.', |
| 167 | }, |
| 168 | { |
| 169 | datei: 'app/package-lock.json', |
| 170 | grund: |
| 171 | 'Von npm erzeugt. Die Integritätsprüfsummen sind Base64 und enthalten ' + |
| 172 | 'Schrägstriche, an denen das Rufnummernmuster zufällig anschlägt; ' + |
| 173 | 'einen Wohnort schreibt dort niemand hinein.', |
| 174 | }, |
| 175 | { |
| 176 | datei: 'docs/katalog-pruefbericht.html', |
| 177 | grund: |
| 178 | 'Erzeugter Prüfbericht mit eingebetteten Bildern als Base64 – dieselbe ' + |
| 179 | 'Zufallstrefferlage. Sein Inhalt stammt vollständig aus dem amtlichen ' + |
| 180 | 'Katalog, nicht aus Betreiberangaben.', |
| 181 | }, |
| 182 | ]; |
| 183 | |
| 184 | /** |
| 185 | * Anschriften, die dastehen **müssen** – jede einzeln und mit Grund. |
| 186 | * |
| 187 | * Diese Wache hält die **privaten** Angaben des Betreibers draußen. Fremde |
| 188 | * Geschäftsanschriften sind etwas anderes: Art. 13 Abs. 1 lit. e DSGVO |
| 189 | * verlangt, dass Empfänger personenbezogener Daten benannt werden, und wer |
| 190 | * eine Webseite ausliefert, verarbeitet die IP-Adresse jedes Lesers. Eine |
| 191 | * Datenschutzerklärung, die ihren Auftragsverarbeiter verschweigt, wäre |
| 192 | * unvollständig. |
| 193 | * |
| 194 | * Erlaubt wird deshalb der **Wortlaut**, nicht die Datei: `docs/datenschutz.md` |
| 195 | * bleibt vollständig bewacht, und jede andere Anschrift darin schlägt weiter |
| 196 | * an. Die Alternative – die Datei freistellen – hätte ausgerechnet das |
| 197 | * Dokument aus der Prüfung genommen, in dem eine Anschrift am ehesten |
| 198 | * versehentlich landet. |
| 199 | */ |
| 200 | const ERLAUBTE_ANSCHRIFTEN: readonly { readonly text: string; readonly grund: string }[] = [ |
| 201 | { |
| 202 | text: 'Hauptstraße 68, 02742 Friedersdorf', |
| 203 | grund: |
| 204 | 'Sitz von ALL-INKL.COM – Neue Medien Münnich, dem Anbieter des ' + |
| 205 | 'Webspace, auf dem die Datenschutzerklärung liegt (Abschnitt 10.5). ' + |
| 206 | 'Öffentliche Geschäftsanschrift aus dem Impressum des Anbieters.', |
| 207 | }, |
| 208 | ]; |
| 209 | |
| 210 | describe('Rückmeldeweg – die eine Adresse', () => { |
| 211 | it('nennt genau die Adresse, die gelten soll', () => { |
| 212 | expect(KONTAKT.epost).toBe(ADRESSE); |
| 213 | }); |
| 214 | |
| 215 | it('behält den Namen als Urheberbenennung', () => { |
| 216 | /* Der Name ist keine private Kontaktangabe, sondern steht so in der |
| 217 | EUPL-Auszeichnung, in REUSE.toml und als Herausgeber im Store. Er darf |
| 218 | beim Entfernen der Kontaktdaten nicht mit verschwinden. */ |
| 219 | expect(KONTAKT.name).toBe('Olaf Willerding'); |
| 220 | }); |
| 221 | |
| 222 | it('führt kein Feld für Telefon oder Ort mehr', () => { |
| 223 | /* Ein übrig gebliebenes leeres Feld wäre die stille Einladung, es wieder |
| 224 | zu füllen. Deshalb hat der Vertrag die Felder gar nicht erst. */ |
| 225 | expect(Object.keys(KONTAKT).sort()).toEqual(['epost', 'frist', 'name']); |
| 226 | }); |
| 227 | |
| 228 | it('zeigt die Adresse im Handbuch und im Meldetext', () => { |
| 229 | /* Zwei Stellen mit derselben Adresse laufen auseinander; beide lesen |
| 230 | deshalb aus KONTAKT. Dieser Test merkt, wenn eine davon wegfällt. */ |
| 231 | expect(SICHTBARER_TEXT).toContain(ADRESSE); |
| 232 | expect(MELDETEXT).toContain(ADRESSE); |
| 233 | }); |
| 234 | }); |
| 235 | |
| 236 | describe('Rückmeldeweg – was nicht zurückkehren darf', () => { |
| 237 | it('erkennt Angaben dieser Art überhaupt', () => { |
| 238 | /* Gegenprobe. Ohne sie wären die Tests unten auch dann grün, wenn die |
| 239 | Muster an einer echten Rufnummer vorbeiliefen – und damit wertlos. |
| 240 | |
| 241 | Die Proben sind erfunden, haben aber genau die Form der entfernten |
| 242 | Angaben. Die echten hier einzusetzen hieße, sie im Arbeitsbaum zu |
| 243 | behalten – ausgerechnet in der Datei, die sie draußen halten soll. */ |
| 244 | expect(RUFNUMMER.test('Telefon: 09999 111222')).toBe(true); |
| 245 | expect(RUFNUMMER.test('+49 9999 111222')).toBe(true); |
| 246 | expect(ANSCHRIFT.test('99999 Musterstadt')).toBe(true); |
| 247 | expect(ALTE_ADRESSE.test('irgendwer@t-online.de')).toBe(true); |
| 248 | /* Und die Gegenrichtung: Was dastehen darf, darf nicht anschlagen. */ |
| 249 | expect(RUFNUMMER.test('Fassung 0.21.1, Bau 0218791 vom 22.08.2026')).toBe(false); |
| 250 | expect(ANSCHRIFT.test(ADRESSE)).toBe(false); |
| 251 | }); |
| 252 | |
| 253 | it('nennt in keinem sichtbaren Text eine Rufnummer oder eine Anschrift', () => { |
| 254 | expect(SICHTBARER_TEXT).not.toMatch(RUFNUMMER); |
| 255 | expect(SICHTBARER_TEXT).not.toMatch(ANSCHRIFT); |
| 256 | expect(SICHTBARER_TEXT).not.toMatch(ALTE_ADRESSE); |
| 257 | }); |
| 258 | |
| 259 | it('nennt auch im Handbuch das Wort „Telefon“ nicht mehr', () => { |
| 260 | /* Das Handbuch führte den Kanal als Aufzählung „Verantwortlich – |
| 261 | E-Mail – Telefon“. Fällt die Zeile weg, aber das Wort bleibt, steht |
| 262 | dort eine Beschriftung ohne Inhalt. */ |
| 263 | expect(SICHTBARER_TEXT).not.toContain('Telefon'); |
| 264 | }); |
| 265 | |
| 266 | it('nennt im ausgelieferten Quelltext keine Rufnummer und keine Anschrift', () => { |
| 267 | const quellen = quelltexte(); |
| 268 | if (quellen === null) { |
| 269 | /* Kein Git – dann ist nicht feststellbar, was ausgeliefert würde. */ |
| 270 | return; |
| 271 | } |
| 272 | |
| 273 | expect(quellen.length).toBeGreaterThan(50); |
| 274 | for (const quelle of quellen) { |
| 275 | expect(quelle).not.toMatch(RUFNUMMER); |
| 276 | expect(quelle).not.toMatch(ANSCHRIFT); |
| 277 | expect(quelle).not.toMatch(ALTE_ADRESSE); |
| 278 | } |
| 279 | }); |
| 280 | |
| 281 | it('nennt sie auch in keinem anderen versionierten Text – Unterlagen eingeschlossen', () => { |
| 282 | /* Der Test darüber sieht `app/src`, weil nur das ausgeliefert wird. Für |
| 283 | den Store genügt das. Für ein öffentliches Archiv nicht: Dort wandert |
| 284 | der ganze Arbeitsbaum mit, und die privaten Angaben stünden nicht in |
| 285 | der Anwendung, sondern in den Unterlagen – `docs/stand.md`, `PLAN.md`, |
| 286 | der Änderungsverlauf. Genau dort hat bisher niemand hingesehen. |
| 287 | |
| 288 | Deshalb dieselben Muster über alles, was Git führt und ein Mensch |
| 289 | geschrieben hat. Ausgenommen ist, was oben mit Grund dasteht. */ |
| 290 | const dateien = versionierteDateien('.'); |
| 291 | if (dateien === null) { |
| 292 | /* Kein Git – dann ist nicht feststellbar, was mitwanderte. */ |
| 293 | return; |
| 294 | } |
| 295 | |
| 296 | const ausgenommen = new Set(AUSGENOMMEN.map((eintrag) => eintrag.datei)); |
| 297 | const zuPruefen = dateien.filter( |
| 298 | (datei) => TEXTENDUNGEN.some((endung) => datei.endsWith(endung)) && !ausgenommen.has(datei), |
| 299 | ); |
| 300 | |
| 301 | /* Ohne diese Schranke wäre der Test auch dann grün, wenn `git ls-files` |
| 302 | nichts mehr liefert oder die Endungsliste ins Leere greift. */ |
| 303 | expect(zuPruefen.length).toBeGreaterThan(200); |
| 304 | |
| 305 | const beanstandet: string[] = []; |
| 306 | for (const datei of zuPruefen) { |
| 307 | let inhalt = readFileSync(join(wurzel, datei), 'utf8'); |
| 308 | /* Die begründeten Anschriften vor dem Prüfen herausnehmen – und zwar |
| 309 | als Wortlaut, damit jede andere Anschrift in derselben Datei weiter |
| 310 | anschlägt. Ein `replaceAll` statt einer Ausnahmeliste für Dateien. */ |
| 311 | for (const erlaubt of ERLAUBTE_ANSCHRIFTEN) { |
| 312 | inhalt = inhalt.split(erlaubt.text).join(''); |
| 313 | } |
| 314 | for (const [was, muster] of [ |
| 315 | ['Rufnummer', RUFNUMMER], |
| 316 | ['Anschrift', ANSCHRIFT], |
| 317 | ['aufgegebene Adresse', ALTE_ADRESSE], |
| 318 | ] as const) { |
| 319 | const treffer = muster.exec(inhalt); |
| 320 | if (treffer !== null) { |
| 321 | /* Die Fundstelle wird benannt, der Fund nicht abgedruckt: Eine |
| 322 | Fehlermeldung landet in Protokollen und Berichten, und dort soll |
| 323 | die Angabe genauso wenig stehen wie in der Datei. */ |
| 324 | const zeile = inhalt.slice(0, treffer.index).split('\n').length; |
| 325 | beanstandet.push(`${datei}:${String(zeile)} – ${was}`); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | expect(beanstandet).toEqual([]); |
| 331 | }); |
| 332 | }); |