waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests datenschutz.test.ts

15,6 KB Rohdatei
app/tests/datenschutz.test.ts — 364 Zeilen
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 });