waffensachkunde

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

/ app tests kontakt.test.ts

13,3 KB Rohdatei
app/tests/kontakt.test.ts — 332 Zeilen
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 });