waffensachkunde

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

/ app tests datenschutz.test.ts

7,4 KB Rohdatei
app/tests/datenschutz.test.ts — 175 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 { abschnitte, type Datenschutzerklaerung } from '../src/shared/datenschutz';
33
34 const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..');
35
36 const TRENNER = '---\n---\n';
37
38 function lies(pfad: string): string {
39 /* Zeilenenden vereinheitlicht: `.gitattributes` erzwingt LF, aber ein
40 Arbeitsbaum, der einmal mit CRLF ausgecheckt wird, machte diese Wache
41 sonst dauerhaft und grundlos rot — und eine grundlos rote Wache wird
42 abgeschaltet. */
43 return readFileSync(join(wurzel, pfad), 'utf8').replace(/\r\n/gu, '\n');
44 }
45
46 /**
47 * Derselbe Schnitt wie in `tools/datenschutz_html.py`.
48 *
49 * Einschliesslich des abschliessenden Abschneidens der Leerzeilen dort
50 * (`strip`) – ohne das käme eine andere Prüfsumme heraus, und die Wache wäre
51 * dauerhaft rot, ohne dass etwas falsch wäre.
52 */
53 function veroeffentlichungsteil(markdown: string): string {
54 const stelle = markdown.indexOf(TRENNER);
55 const teil = stelle === -1 ? markdown : markdown.slice(stelle + TRENNER.length);
56 return teil.replace(/^\n+/u, '').replace(/\n+$/u, '');
57 }
58
59 const quelle = lies('docs/datenschutz.md');
60 const erzeugt = JSON.parse(lies('content/datenschutz.json')) as Datenschutzerklaerung;
61
62 describe('Datenschutzerklärung – die erzeugte Fassung', () => {
63 it('stammt aus der heutigen Quelle', () => {
64 const erwartet = createHash('sha256')
65 .update(veroeffentlichungsteil(quelle), 'utf8')
66 .digest('hex');
67
68 expect(
69 erzeugt.quellpruefsumme,
70 'docs/datenschutz.md hat sich geändert, content/datenschutz.json nicht. ' +
71 'Neu erzeugen mit: python tools/datenschutz_anwendung.py',
72 ).toBe(erwartet);
73 });
74
75 it('nennt dieselbe Fassung und denselben Stand wie die Quelle', () => {
76 /* Beide werden aus der Standtabelle gelesen und nicht eingetragen. Eine
77 Fassungsnummer an zwei Orten ist genau die zweite Wahrheit, um die es
78 hier geht. */
79 const fassung = /^\|\s*\*\*Fassung der Erklärung\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle);
80 const stand = /^\|\s*\*\*Stand\*\*\s*\|\s*([^|]+?)\s*\|/mu.exec(quelle);
81
82 expect(fassung?.[1]).toBeDefined();
83 expect(stand?.[1]).toBeDefined();
84 expect(erzeugt.fassung).toBe(fassung?.[1]);
85 expect(erzeugt.stand).toBe(stand?.[1]);
86 });
87
88 it('enthält den ganzen Veröffentlichungsteil und nicht nur den Anfang', () => {
89 /* Gegenprobe gegen eine stillschweigend abgeschnittene Umsetzung: Der
90 Erzeuger bricht bei unbekannten Auszeichnungen ab, aber ein Fehler in
91 der Blockbildung könnte hinten etwas verlieren, ohne dass jemand es
92 sähe. Verglichen wird die Zahl der Überschriften. */
93 const ueberschriften = veroeffentlichungsteil(quelle)
94 .split('\n')
95 .filter((zeile) => /^#{1,3} /u.test(zeile)).length;
96
97 const gebaut = erzeugt.bloecke.filter((block) => block.art === 'ueberschrift').length;
98
99 expect(gebaut, 'Es fehlen Überschriften in der erzeugten Fassung.').toBe(ueberschriften);
100 expect(gebaut).toBeGreaterThan(20);
101 });
102
103 it('führt für jeden Abschnitt ein eindeutiges Sprungziel', () => {
104 const ziele = abschnitte(erzeugt);
105 expect(ziele.length).toBeGreaterThan(10);
106 expect(new Set(ziele.map((ziel) => ziel.kennung)).size).toBe(ziele.length);
107 for (const ziel of ziele) {
108 expect(ziel.kennung, `Leeres Sprungziel bei „${ziel.text}“`).not.toBe('');
109 }
110 });
111
112 it('enthält kein Markup in den Texten', () => {
113 /* Dieselbe Zusage wie beim Handbuch: Die Blöcke werden als reiner Text
114 gerendert. Stünde hier eine spitze Klammer oder ein Sternchenpaar,
115 erschiene es wörtlich auf dem Bildschirm. */
116 /*
117 Kennzeichnungen bleiben ausgenommen. Sie sind der Ort, an dem das
118 Dokument über Auszeichnung **spricht**: Abschnitt 8 erklärt, die Adresse
119 stehe „technisch in einem `<code>`-Element, nicht in einem Link“. Der
120 Satz ist richtig und soll genau so dastehen; ihn als Markup-Rückstand zu
121 werten wäre der erste Fehlalarm, nach dem eine Wache abgeschaltet wird.
122 */
123 const sichtbar = (teile: readonly { art: string; text: string }[]): string[] =>
124 teile.filter((teil) => teil.art !== 'kennzeichnung').map((teil) => teil.text);
125
126 const texte: string[] = [];
127 for (const block of erzeugt.bloecke) {
128 if (block.art === 'ueberschrift' || block.art === 'code') {
129 texte.push(block.text);
130 } else if (block.art === 'absatz') {
131 texte.push(...sichtbar(block.teile));
132 } else {
133 texte.push(...sichtbar(block.punkte.flat()));
134 }
135 }
136 const gesamt = texte.join('\n');
137
138 /*
139 Gesucht werden die Elemente, die der Umsetzer erzeugt – nicht jede
140 spitze Klammer. Das Dokument enthält echte Platzhalter in spitzen
141 Klammern („C:\\Users\\<Ihr Name>\\AppData“,
142 „Lernstand-selbsttaetig-<Zeitstempel>.wsklernstand“), und die sollen
143 genau so dastehen. Eine Wache, die daran anschlägt, wird nach dem
144 zweiten Fehlalarm abgeschaltet.
145 */
146 const tags = /<\/?(?:p|ul|li|h[1-6]|strong|code|pre|a|em|br|div|span|table)\b/iu;
147
148 expect(gesamt, 'Ein Element ist als Text in die Blöcke geraten.').not.toMatch(tags);
149 expect(gesamt).not.toMatch(/\*\*[^*]+\*\*/u);
150 /* Gegenprobe: Der Text ist tatsächlich da, und die Wache trifft, wenn
151 etwas dasteht. */
152 expect(gesamt.length).toBeGreaterThan(10_000);
153 expect(tags.test('Ein <strong>fetter</strong> Text.')).toBe(true);
154 expect(tags.test('Der Pfad C:\\Users\\<Ihr Name>\\AppData.')).toBe(false);
155 });
156
157 it('sagt in der Sache dasselbe wie die Oberfläche', () => {
158 /* Der eigentliche Anlass dieses ganzen Bereichs: „Über diese Software“
159 sagte bis 0.24.2 „sie sammelt keine Daten“, während Abschnitt 3 der
160 Erklärung ausdrücklich das Gegenteil sagt. Diese Zusicherung hält
161 fest, dass die Erklärung bei ihrer Aussage bleibt. */
162 const gesamt = erzeugt.bloecke
163 .flatMap((block) =>
164 block.art === 'absatz'
165 ? block.teile.map((teil) => teil.text)
166 : block.art === 'liste'
167 ? block.punkte.flat().map((teil) => teil.text)
168 : [block.text],
169 )
170 .join(' ');
171
172 expect(gesamt).toContain('entstehen Daten');
173 expect(gesamt).not.toMatch(/sammelt\s+kein/u);
174 });
175 });