waffensachkunde

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

/ app tests datenschutz.test.ts

10,8 KB Rohdatei
app/tests/datenschutz.test.ts — 247 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 { 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 });