waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests datenschutz.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Die Datenschutzerklärung in der Anwendung. |
| 4 | * |
| 5 | * ## Warum diese Datei die wichtigste Wache dieses Bereichs trägt |
| 6 | * |
| 7 | * Der Fehler, der hier droht, ist nicht „die Erklärung fehlt“. Er ist: **es |
| 8 | * gibt sie zweimal, und die zweite ist die falsche.** Der Fall ist bereits |
| 9 | * eingetreten, ohne dass jemand es bemerkt hätte: Am 30.08.2026 stand die |
| 10 | * veröffentlichte Seite auf Fassung 1.1, während `docs/datenschutz.md` bei |
| 11 | * 1.5 war — vier Revisionen Abstand in vier Tagen, bei vorhandenem Erzeuger |
| 12 | * und vorhandener Prüfliste in `docs/veroeffentlichen.md`. Der Erzeuger hatte |
| 13 | * gehalten, was er sollte; ausgelaufen ist der Schritt, den nur ein Mensch |
| 14 | * tut. |
| 15 | * |
| 16 | * Daraus folgt die Regel, die dieser Datei zugrunde liegt und die |
| 17 | * `tests/dokumentation.test.ts` für den Updatebericht schon einmal |
| 18 | * aufgeschrieben hat: **Ein Erzeuger allein reicht nicht. Was zählt, ist der |
| 19 | * Test, der rot wird, wenn er nicht gelaufen ist.** |
| 20 | * |
| 21 | * Deshalb steht die Prüfsumme des Veröffentlichungsteils in der erzeugten |
| 22 | * Datei, und deshalb wird sie hier nachgerechnet. |
| 23 | */ |
| 24 | |
| 25 | import { createHash } from 'node:crypto'; |
| 26 | import { readdirSync, readFileSync } from 'node:fs'; |
| 27 | import { join } from 'node:path'; |
| 28 | import { fileURLToPath } from 'node:url'; |
| 29 | |
| 30 | import { describe, expect, it } from 'vitest'; |
| 31 | |
| 32 | import { |
| 33 | abschnitte, |
| 34 | type Datenschutzblock, |
| 35 | type Datenschutzerklaerung, |
| 36 | } from '../src/shared/datenschutz'; |
| 37 | |
| 38 | const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); |
| 39 | |
| 40 | const TRENNER = '---\n---\n'; |
| 41 | |
| 42 | function lies(pfad: string): string { |
| 43 | /* Zeilenenden vereinheitlicht: `.gitattributes` erzwingt LF, aber ein |
| 44 | Arbeitsbaum, der einmal mit CRLF ausgecheckt wird, machte diese Wache |
| 45 | sonst dauerhaft und grundlos rot — und eine grundlos rote Wache wird |
| 46 | abgeschaltet. */ |
| 47 | return readFileSync(join(wurzel, pfad), 'utf8').replace(/\r\n/gu, '\n'); |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Derselbe Schnitt wie in `tools/datenschutz_html.py`. |
| 52 | * |
| 53 | * Einschliesslich des abschliessenden Abschneidens der Leerzeilen dort |
| 54 | * (`strip`) – ohne das käme eine andere Prüfsumme heraus, und die Wache wäre |
| 55 | * dauerhaft rot, ohne dass etwas falsch wäre. |
| 56 | */ |
| 57 | function veroeffentlichungsteil(markdown: string): string { |
| 58 | const stelle = markdown.indexOf(TRENNER); |
| 59 | const teil = stelle === -1 ? markdown : markdown.slice(stelle + TRENNER.length); |
| 60 | return teil.replace(/^\n+/u, '').replace(/\n+$/u, ''); |
| 61 | } |
| 62 | |
| 63 | const quelle = lies('docs/datenschutz.md'); |
| 64 | const erzeugt = JSON.parse(lies('content/datenschutz.json')) as Datenschutzerklaerung; |
| 65 | const html = lies('docs/datenschutz.html'); |
| 66 | |
| 67 | describe('Datenschutzerklärung – die erzeugte Fassung', () => { |
| 68 | it('stammt aus der heutigen Quelle', () => { |
| 69 | const erwartet = createHash('sha256') |
| 70 | .update(veroeffentlichungsteil(quelle), 'utf8') |
| 71 | .digest('hex'); |
| 72 | |
| 73 | expect( |
| 74 | erzeugt.quellpruefsumme, |
| 75 | 'docs/datenschutz.md hat sich geändert, content/datenschutz.json nicht. ' + |
| 76 | 'Neu erzeugen mit: python tools/datenschutz_anwendung.py', |
| 77 | ).toBe(erwartet); |
| 78 | }); |
| 79 | |
| 80 | it('nennt dieselbe Fassung und denselben Stand wie die Quelle', () => { |
| 81 | /* Beide werden aus der Standtabelle gelesen und nicht eingetragen. Eine |
| 82 | Fassungsnummer an zwei Orten ist genau die zweite Wahrheit, um die es |
| 83 | hier geht. */ |
| 84 | const fassung = /^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 85 | const stand = /^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle); |
| 86 | |
| 87 | expect(fassung?.[1]).toBeDefined(); |
| 88 | expect(stand?.[1]).toBeDefined(); |
| 89 | expect(erzeugt.fassung).toBe(fassung?.[1]); |
| 90 | expect(erzeugt.stand).toBe(stand?.[1]); |
| 91 | }); |
| 92 | |
| 93 | it('enthält den ganzen Veröffentlichungsteil und nicht nur den Anfang', () => { |
| 94 | /* Gegenprobe gegen eine stillschweigend abgeschnittene Umsetzung: Der |
| 95 | Erzeuger bricht bei unbekannten Auszeichnungen ab, aber ein Fehler in |
| 96 | der Blockbildung könnte hinten etwas verlieren, ohne dass jemand es |
| 97 | sähe. Verglichen wird die Zahl der Überschriften. */ |
| 98 | const ueberschriften = veroeffentlichungsteil(quelle) |
| 99 | .split('\n') |
| 100 | .filter((zeile) => /^#{1,3} /u.test(zeile)).length; |
| 101 | |
| 102 | const gebaut = erzeugt.bloecke.filter((block) => block.art === 'ueberschrift').length; |
| 103 | |
| 104 | expect(gebaut, 'Es fehlen Überschriften in der erzeugten Fassung.').toBe(ueberschriften); |
| 105 | expect(gebaut).toBeGreaterThan(20); |
| 106 | }); |
| 107 | |
| 108 | it('führt für jeden Abschnitt ein eindeutiges Sprungziel', () => { |
| 109 | const ziele = abschnitte(erzeugt); |
| 110 | expect(ziele.length).toBeGreaterThan(10); |
| 111 | expect(new Set(ziele.map((ziel) => ziel.kennung)).size).toBe(ziele.length); |
| 112 | for (const ziel of ziele) { |
| 113 | expect(ziel.kennung, `Leeres Sprungziel bei „${ziel.text}“`).not.toBe(''); |
| 114 | } |
| 115 | }); |
| 116 | |
| 117 | it('enthält kein Markup in den Texten', () => { |
| 118 | /* Dieselbe Zusage wie beim Handbuch: Die Blöcke werden als reiner Text |
| 119 | gerendert. Stünde hier eine spitze Klammer oder ein Sternchenpaar, |
| 120 | erschiene es wörtlich auf dem Bildschirm. */ |
| 121 | /* |
| 122 | Kennzeichnungen bleiben ausgenommen. Sie sind der Ort, an dem das |
| 123 | Dokument über Auszeichnung **spricht**: Abschnitt 8 erklärt, die Adresse |
| 124 | stehe „technisch in einem `<code>`-Element, nicht in einem Link“. Der |
| 125 | Satz ist richtig und soll genau so dastehen; ihn als Markup-Rückstand zu |
| 126 | werten wäre der erste Fehlalarm, nach dem eine Wache abgeschaltet wird. |
| 127 | */ |
| 128 | const sichtbar = (teile: readonly { art: string; text: string }[]): string[] => |
| 129 | teile.filter((teil) => teil.art !== 'kennzeichnung').map((teil) => teil.text); |
| 130 | |
| 131 | const texte: string[] = []; |
| 132 | for (const block of erzeugt.bloecke) { |
| 133 | if (block.art === 'ueberschrift' || block.art === 'code') { |
| 134 | texte.push(block.text); |
| 135 | } else if (block.art === 'absatz') { |
| 136 | texte.push(...sichtbar(block.teile)); |
| 137 | } else if (block.art === 'tabelle') { |
| 138 | texte.push(...block.kopf); |
| 139 | texte.push(...sichtbar(block.zeilen.flat(2))); |
| 140 | } else { |
| 141 | texte.push(...sichtbar(block.punkte.flat())); |
| 142 | } |
| 143 | } |
| 144 | const gesamt = texte.join('\n'); |
| 145 | |
| 146 | /* |
| 147 | Gesucht werden die Elemente, die der Umsetzer erzeugt – nicht jede |
| 148 | spitze Klammer. Das Dokument enthält echte Platzhalter in spitzen |
| 149 | Klammern („C:\\Users\\<Ihr Name>\\AppData“, |
| 150 | „Lernstand-selbsttaetig-<Zeitstempel>.wsklernstand“), und die sollen |
| 151 | genau so dastehen. Eine Wache, die daran anschlägt, wird nach dem |
| 152 | zweiten Fehlalarm abgeschaltet. |
| 153 | */ |
| 154 | const tags = /<\/?(?:p|ul|li|h[1-6]|strong|code|pre|a|em|br|div|span|table)\b/iu; |
| 155 | |
| 156 | expect(gesamt, 'Ein Element ist als Text in die Blöcke geraten.').not.toMatch(tags); |
| 157 | expect(gesamt).not.toMatch(/\*\*[^*]+\*\*/u); |
| 158 | /* Gegenprobe: Der Text ist tatsächlich da, und die Wache trifft, wenn |
| 159 | etwas dasteht. */ |
| 160 | expect(gesamt.length).toBeGreaterThan(10_000); |
| 161 | expect(tags.test('Ein <strong>fetter</strong> Text.')).toBe(true); |
| 162 | expect(tags.test('Der Pfad C:\\Users\\<Ihr Name>\\AppData.')).toBe(false); |
| 163 | }); |
| 164 | |
| 165 | it('trägt die beiden Tabellen als Tabellen, nicht als Fließtext', () => { |
| 166 | /* |
| 167 | Der Anlass: Bis zum 01.09.2026 rechnete der Umsetzer Tabellen in |
| 168 | Aufzählungen um. Als die HTML-Fassung Tabellen lernte, brach er ab — |
| 169 | und weil die erzeugte Datei danach nicht neu geschrieben wurde, fiel |
| 170 | es erst auf, als die Erklärung wirklich nachzuführen war. |
| 171 | |
| 172 | Für einen Bildschirmleser ist der Unterschied nicht kosmetisch: Die |
| 173 | Nachprüfmatrix in Abschnitt 2 hat drei Spalten, und ohne |
| 174 | Spaltenzuordnung sind ihre achtzehn Zellen achtzehn Bruchstücke. |
| 175 | */ |
| 176 | const tabellen = erzeugt.bloecke.filter((block) => block.art === 'tabelle'); |
| 177 | expect(tabellen).toHaveLength(2); |
| 178 | |
| 179 | for (const tabelle of tabellen) { |
| 180 | expect(tabelle.zeilen.length).toBeGreaterThan(0); |
| 181 | /* Jede Zeile gleich breit – sonst zeigt eine Kopfzelle auf nichts. */ |
| 182 | const breiten = new Set(tabelle.zeilen.map((zeile) => zeile.length)); |
| 183 | expect(breiten.size, 'Die Zeilen sind verschieden breit.').toBe(1); |
| 184 | if (tabelle.kopf.length > 0) { |
| 185 | expect([...breiten][0]).toBe(tabelle.kopf.length); |
| 186 | /* Eine Kopfzelle ohne Text wäre eine Ansage ins Nichts. */ |
| 187 | for (const spalte of tabelle.kopf) { |
| 188 | expect(spalte.trim().length).toBeGreaterThan(0); |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /* Die Standtabelle führt die Fassung – und nur einmal, sonst gäbe es |
| 194 | zwei Wahrheiten darüber, welche gilt. */ |
| 195 | const stand = tabellen.find((tabelle) => tabelle.kopf.length === 0); |
| 196 | const zeilenkoepfe = stand?.zeilen.map((zeile) => zeile[0]?.map((t) => t.text).join('') ?? ''); |
| 197 | expect(zeilenkoepfe).toContain('Fassung der Erklärung'); |
| 198 | }); |
| 199 | |
| 200 | it('hat dieselben Bauformen wie die veröffentlichte HTML-Fassung', () => { |
| 201 | /* |
| 202 | Die Lücke, durch die der Fehler vom 01.09.2026 kam, und die keine |
| 203 | Prüfsumme schliesst: Beide Dateien entstehen aus **derselben** |
| 204 | Umsetzung in `tools/datenschutz_html.py`. Ändert sich die Umsetzung, |
| 205 | ohne dass die Quelle sich ändert, bleibt die Quellprüfsumme richtig — |
| 206 | und trotzdem sind die beiden Erzeugnisse verschieden. |
| 207 | |
| 208 | Genau so geschah es: Die HTML-Fassung lernte Tabellen, die Fassung in |
| 209 | der Anwendung behielt Aufzählungen. Zwei Wochen lang stimmte jede |
| 210 | Wache, und die Anwendung zeigte etwas anderes als die veröffentlichte |
| 211 | Seite. |
| 212 | |
| 213 | Gezählt werden deshalb die Bauformen gegeneinander. Wer eine von |
| 214 | beiden neu erzeugt und die andere vergisst, sieht es hier. |
| 215 | */ |
| 216 | const imHtml = (muster: RegExp): number => html.match(muster)?.length ?? 0; |
| 217 | const inBloecken = (art: Datenschutzblock['art']): number => |
| 218 | erzeugt.bloecke.filter((block) => block.art === art).length; |
| 219 | |
| 220 | expect(imHtml(/<table\b/gu), 'Tabellen').toBe(inBloecken('tabelle')); |
| 221 | expect(imHtml(/<ul\b/gu), 'Aufzählungen').toBe(inBloecken('liste')); |
| 222 | expect(imHtml(/<pre\b/gu), 'Codeblöcke').toBe(inBloecken('code')); |
| 223 | /* Gegenprobe: Es wird wirklich gezählt und nicht null gegen null. */ |
| 224 | expect(inBloecken('tabelle')).toBeGreaterThan(0); |
| 225 | }); |
| 226 | |
| 227 | it('sagt in der Sache dasselbe wie die Oberfläche', () => { |
| 228 | /* Der eigentliche Anlass dieses ganzen Bereichs: „Über diese Software“ |
| 229 | sagte bis 0.24.2 „sie sammelt keine Daten“, während Abschnitt 3 der |
| 230 | Erklärung ausdrücklich das Gegenteil sagt. Diese Zusicherung hält |
| 231 | fest, dass die Erklärung bei ihrer Aussage bleibt. */ |
| 232 | const gesamt = erzeugt.bloecke |
| 233 | .flatMap((block) => |
| 234 | block.art === 'absatz' |
| 235 | ? block.teile.map((teil) => teil.text) |
| 236 | : block.art === 'liste' |
| 237 | ? block.punkte.flat().map((teil) => teil.text) |
| 238 | : block.art === 'tabelle' |
| 239 | ? [...block.kopf, ...block.zeilen.flat(2).map((teil) => teil.text)] |
| 240 | : [block.text], |
| 241 | ) |
| 242 | .join(' '); |
| 243 | |
| 244 | expect(gesamt).toContain('entstehen Daten'); |
| 245 | expect(gesamt).not.toMatch(/sammelt\s+kein/u); |
| 246 | }); |
| 247 | }); |
| 248 | |
| 249 | describe('Der Tagvorrat im Kopf des Umsetzers ist vollständig', () => { |
| 250 | /* |
| 251 | Die Wache, die gefehlt hat. |
| 252 | |
| 253 | Der Modulkopf von `tools/datenschutz_anwendung.py` führt auf, welche |
| 254 | Auszeichnungen die Anwendung darstellen kann, und schließt mit „Alles |
| 255 | andere lässt dieses Werkzeug abbrechen“. Er ist damit die Stelle, an der |
| 256 | jemand nachsieht, bevor er `docs/datenschutz.md` ändert. |
| 257 | |
| 258 | Seit `datenschutz_html.tabelle()` Pipe-Tabellen als echtes `<table>` |
| 259 | ausgibt, erzeugt der Umsetzer sechs Tags mehr, als der Kopf nennt: |
| 260 | `table`, `thead`, `tbody`, `tr`, `th`, `td` – ausgerechnet die, die |
| 261 | seinen aufwendigsten Zweig ausmachen. Der Kopf legte nahe, eine Tabelle |
| 262 | im Dokument löse einen Abbruch aus; angenommen wird sie sehr wohl. |
| 263 | |
| 264 | Gezählt wird aus den Mengen im Quelltext selbst, nicht aus einer hier |
| 265 | wiederholten Liste. |
| 266 | */ |
| 267 | const umsetzer = lies('tools/datenschutz_anwendung.py'); |
| 268 | |
| 269 | function menge(name: string): string[] { |
| 270 | const roh = new RegExp(`^${name} = \\{([^}]*)\\}`, 'mu').exec(umsetzer)?.[1]; |
| 271 | expect(roh, `${name} nicht gefunden`).toBeDefined(); |
| 272 | return [...(roh ?? '').matchAll(/"([a-z0-9]+)"/gu)].map((t) => t[1] ?? ''); |
| 273 | } |
| 274 | |
| 275 | it('nennt jeden Tag, den der Umsetzer annimmt', () => { |
| 276 | /* `li` steht in keiner der Mengen – es entsteht innerhalb von `ul` und |
| 277 | wird in `handle_starttag` gesondert behandelt. Es gehört trotzdem in |
| 278 | den Vorrat, deshalb hier ergänzt. */ |
| 279 | const angenommen = [ |
| 280 | ...menge('BLOCKTAGS'), |
| 281 | ...menge('TABELLENTAGS'), |
| 282 | ...menge('TEILTAGS'), |
| 283 | 'li', |
| 284 | ]; |
| 285 | expect(angenommen.length).toBeGreaterThan(10); |
| 286 | |
| 287 | const kopf = umsetzer.slice(0, umsetzer.indexOf('## Die Wache')); |
| 288 | const fehlend = angenommen.filter((tag) => !kopf.includes(`\`${tag}\``)); |
| 289 | |
| 290 | expect(fehlend, 'Tags, die der Umsetzer annimmt, aber der Modulkopf nicht aufzählt').toEqual( |
| 291 | [], |
| 292 | ); |
| 293 | }); |
| 294 | }); |
| 295 | |
| 296 | describe('Die Erklärung kennt jede Kopie, die die Anwendung von selbst anlegt', () => { |
| 297 | /* |
| 298 | Befund der Prüfrunde zu 0.27.2. Abschnitt 4.4 sagte „in **drei** Fällen“ |
| 299 | und zählte drei Muster auf; es sind vier. Die Kopie vor „Neu anfangen“ und |
| 300 | vor dem Löschen eines Profils (`Lernstand-vor-dem-Verwerfen-*`) fehlte |
| 301 | ganz — auch in Abschnitt 7, der sagt, was zu löschen ist, wenn man seinen |
| 302 | Lernstand restlos loswerden will. Wer der Anleitung folgte, ließ Kopien |
| 303 | seines gesamten Lernstands liegen. |
| 304 | |
| 305 | Geprüft wird gegen den Quelltext, nicht gegen eine gepflegte Liste: Jeder |
| 306 | Dateivorsatz, der in `src/main` als Zeichenkette „Lernstand-vor-dem-…“ |
| 307 | steht, muss in der Erklärung vorkommen — in Abschnitt 4.4 **und** in |
| 308 | Abschnitt 7. Eine fünfte Kopienart ist damit von selbst mitbewacht. |
| 309 | */ |
| 310 | const MAIN = join(wurzel, 'app', 'src', 'main'); |
| 311 | |
| 312 | /** |
| 313 | * Jeder Dateivorsatz, unter dem die Anwendung von selbst eine Kopie anlegt. |
| 314 | * |
| 315 | * Vier Stück: drei vor einem nicht rücknehmbaren Schritt, einer wöchentlich |
| 316 | * beim Beenden. Gelesen aus dem Quelltext, damit eine fünfte Art nicht |
| 317 | * unbemerkt dazukommen kann. |
| 318 | */ |
| 319 | function vorsaetze(): string[] { |
| 320 | const gefunden = new Set<string>(); |
| 321 | for (const datei of readdirSync(MAIN).filter((name) => name.endsWith('.ts'))) { |
| 322 | const inhalt = readFileSync(join(MAIN, datei), 'utf8'); |
| 323 | for (const [, name] of inhalt.matchAll( |
| 324 | /'(Lernstand-(?:vor-dem-[A-Za-z]+|selbsttaetig))'/gu, |
| 325 | )) { |
| 326 | if (name !== undefined) { |
| 327 | gefunden.add(name); |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | return [...gefunden].sort(); |
| 332 | } |
| 333 | |
| 334 | it('nennt jeden Dateivorsatz in Abschnitt 4.4 und in Abschnitt 7', () => { |
| 335 | const alle = vorsaetze(); |
| 336 | expect(alle.length, 'Keine Kopienart gefunden – der Test misst nichts').toBeGreaterThanOrEqual( |
| 337 | 4, |
| 338 | ); |
| 339 | |
| 340 | const text = readFileSync(join(wurzel, 'docs', 'datenschutz.md'), 'utf8'); |
| 341 | const vierViervier = text.slice( |
| 342 | text.indexOf('### 4.4 Sicherheitskopien'), |
| 343 | text.indexOf('### 4.4a'), |
| 344 | ); |
| 345 | const sieben = text.slice(text.indexOf('### 7.3')); |
| 346 | |
| 347 | for (const name of alle) { |
| 348 | expect(vierViervier, `${name} fehlt in Abschnitt 4.4`).toContain(name); |
| 349 | /* Die wöchentliche Kopie nennt Abschnitt 7 über ihren Ordner, nicht |
| 350 | über ihren Namen – der ganze Unterordner soll ja weg. */ |
| 351 | const inSieben = name === 'Lernstand-selbsttaetig' ? 'Unterordner `sicherungen`' : name; |
| 352 | expect(sieben, `${name} fehlt in der Löschanleitung`).toContain(inSieben); |
| 353 | } |
| 354 | }); |
| 355 | |
| 356 | it('nennt so viele Fälle, wie es Kopienarten gibt', () => { |
| 357 | /* Die Zahl im Satz ist eine eigene Falle: Sie stand auf „drei“, während |
| 358 | vier Muster im Quelltext lagen. */ |
| 359 | const zahlwort = ['null', 'einem', 'zwei', 'drei', 'vier', 'fünf', 'sechs']; |
| 360 | const text = readFileSync(join(wurzel, 'docs', 'datenschutz.md'), 'utf8'); |
| 361 | |
| 362 | expect(text).toContain(`in **${zahlwort[vorsaetze().length] ?? '?'}** Fällen ungefragt Kopien`); |
| 363 | }); |
| 364 | }); |