waffensachkunde

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

/ app tests offline.test.ts

7,3 KB Rohdatei
app/tests/offline.test.ts — 186 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Offline-Zusage – als Wache, nicht als Beobachtung.
4 *
5 * ## Warum es diese Datei gibt
6 *
7 * „Offline und ohne Konto. Keine Anmeldung, keine Cloud, keine Telemetrie“
8 * steht in `README.md`, in „Über diese Software“, im Handbuch und in
9 * Abschnitt 2 der Datenschutzerklärung. Es ist die weitreichendste Zusage
10 * dieses Programms, und sie war bis Fassung 0.24.1 die einzige große, die
11 * **niemand bewachte**: Dass in `app/src` kein `fetch`, kein `net.request`
12 * und kein `https.get` steht, war ein von Hand nachgezählter Befund. Beim
13 * nächsten Umbau wäre niemand rot geworden — `docs/stand.md` führt genau das
14 * unter „Als Nächstes“ (Abschnitt 8.2, Punkt 9), und die
15 * Datenschutzerklärung sagt in Abschnitt 2 ausdrücklich „nachgezählt, nicht
16 * bewacht“.
17 *
18 * ## Was hier geprüft wird
19 *
20 * Gelesen wird, was `git ls-files` unter `app/src` führt — nur das wird
21 * ausgeliefert. Gesucht wird nach den Aufrufen, mit denen eine Verbindung
22 * überhaupt entstehen kann. Nicht nach Zeichenketten wie „http“: Adressen
23 * stehen an mehreren Stellen als **Text** da (die Quellenangabe des
24 * Katalogs, die Adresse der Unterstützungsseite, `gesetze-im-internet.de` in
25 * Kommentaren), und ein Test, der daran anschlägt, wird nach dem zweiten
26 * Fehlalarm abgeschaltet.
27 *
28 * ## Die zwei erlaubten Wege nach außen
29 *
30 * Beide liegen in `main/sicherheit.ts`, beide öffnen nur auf ausdrücklichen
31 * Klick den **Systembrowser** — die Anwendung selbst verbindet sich dabei
32 * nicht. `shell.openExternal` ist deshalb erlaubt, aber nur dort; taucht es
33 * anderswo auf, ist das ein neuer Weg nach außen und gehört in die
34 * Datenschutzerklärung.
35 */
36
37 import { execFileSync } from 'node:child_process';
38 import { readFileSync } from 'node:fs';
39 import { join } from 'node:path';
40 import { fileURLToPath } from 'node:url';
41
42 import { describe, expect, it } from 'vitest';
43
44 const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..');
45
46 /**
47 * Aufrufe, mit denen eine Verbindung nach außen entsteht.
48 *
49 * Jeder mit Wortgrenze und öffnender Klammer: `fetch(` trifft, `prefetch(`
50 * nicht, und das Wort „fetch“ in einem Kommentar auch nicht.
51 */
52 const NETZAUFRUFE: readonly (readonly [RegExp, string])[] = Object.freeze([
53 [/(?<![\w.])fetch\s*\(/u, 'fetch('],
54 [/\bnet\.(?:request|fetch)\s*\(/u, 'net.request('],
55 [/\bhttps?\.(?:get|request)\s*\(/u, 'https.get('],
56 [/\bnew\s+XMLHttpRequest\b/u, 'new XMLHttpRequest'],
57 [/\bnew\s+WebSocket\b/u, 'new WebSocket'],
58 [/\bnew\s+EventSource\b/u, 'new EventSource'],
59 [/\bnavigator\.sendBeacon\s*\(/u, 'navigator.sendBeacon('],
60 [/\bimport\s*\(\s*['"`]https?:/u, 'dynamischer Import einer Adresse'],
61 ]);
62
63 /** Der eine Baustein, der den Systembrowser anstoßen darf. */
64 const AUSSENWEG = 'src/main/sicherheit.ts';
65
66 interface Quelldatei {
67 readonly pfad: string;
68 readonly inhalt: string;
69 }
70
71 /** Die von Git geführten Quelldateien der Anwendung, oder `null` ohne Git. */
72 function quelldateien(): readonly Quelldatei[] | null {
73 let liste: string[];
74 try {
75 liste = execFileSync('git', ['ls-files', 'app/src'], {
76 cwd: wurzel,
77 encoding: 'utf8',
78 stdio: ['ignore', 'pipe', 'ignore'],
79 })
80 .split('\n')
81 .map((zeile) => zeile.trim())
82 .filter((zeile) => zeile.endsWith('.ts') || zeile.endsWith('.tsx'));
83 } catch {
84 return null;
85 }
86 return liste.map((pfad) => ({ pfad, inhalt: readFileSync(join(wurzel, pfad), 'utf8') }));
87 }
88
89 describe('Die Offline-Zusage', () => {
90 const dateien = quelldateien();
91
92 it('findet überhaupt Quelldateien', () => {
93 /* Ohne diese Gegenprobe wären die Prüfungen unten immer grün und damit
94 wertlos – dieselbe Überlegung wie in `reuse-abdeckung.test.ts`. */
95 expect(
96 dateien,
97 'Ohne Git lässt sich der ausgelieferte Quelltext nicht bestimmen.',
98 ).not.toBeNull();
99 expect(dateien?.length ?? 0).toBeGreaterThan(150);
100 });
101
102 it('enthält keinen einzigen Netzaufruf', () => {
103 const befunde: string[] = [];
104 for (const { pfad, inhalt } of dateien ?? []) {
105 for (const [muster, name] of NETZAUFRUFE) {
106 if (muster.test(inhalt)) {
107 befunde.push(`${pfad}: ${name}`);
108 }
109 }
110 }
111
112 expect(
113 befunde,
114 'Diese Anwendung sagt zu, offline zu arbeiten – in README, in „Über diese ' +
115 'Software“, im Handbuch und in der Datenschutzerklärung. Ein neuer Weg ' +
116 'nach außen bricht diese Zusage und gehört in alle vier Texte.',
117 ).toEqual([]);
118 });
119
120 it('stößt den Systembrowser nur aus der einen dafür vorgesehenen Stelle an', () => {
121 /* Nur `openExternal`: `shell.openPath` und `shell.showItemInFolder`
122 öffnen eine **lokale** Datei im zuständigen Programm und führen nicht
123 nach außen. Sie stehen in `main/dateizugriff.ts` und sind dort mit
124 Grund. Gesucht wird der Aufruf, nicht die Erwähnung – der Name kommt
125 auch in Kommentaren vor, die genau das erklären. */
126 const anderswo = (dateien ?? [])
127 .filter(
128 ({ pfad, inhalt }) =>
129 !pfad.endsWith(AUSSENWEG) && /\bshell\.openExternal\s*\(/u.test(inhalt),
130 )
131 .map(({ pfad }) => pfad);
132
133 expect(
134 anderswo,
135 `Der Weg nach außen gehört in ${AUSSENWEG}: Dort wird die Adresse mit der ` +
136 'einen eingetragenen verglichen, und dort steht die Begründung.',
137 ).toEqual([]);
138 });
139
140 /*
141 Die Zusage muss auch richtig formuliert sein.
142
143 Bis Fassung 0.24.2 stand in „Über diese Software“ – ausgerechnet im
144 Absatz, der um Unterstützung bittet – der Satz „sie sammelt keine Daten“.
145 Das ist unrichtig: Die Anwendung legt einen Lernstand an, protokolliert
146 jede Antwort mit Zeitpunkt, Dauer und Freitext, schreibt drei
147 Selbstsicherungen und merkt sich die Fensterlage. Abschnitt 3 der
148 Datenschutzerklärung sagt es selbst — „Beim Benutzen der Anwendung
149 entstehen Daten“ — und nennt sie in Abschnitt 4 ausdrücklich
150 personenbezogen im Sinne von Art. 4 Nr. 1 DSGVO.
151
152 Richtig ist „überträgt“, und sechs andere Stellen sagten es schon so.
153 Die eine falsche wog am schwersten: Wer um Geld bittet, wird beim Wort
154 genommen.
155 */
156 it('verspricht nirgends, keine Daten zu sammeln – nur, keine zu übertragen', () => {
157 const befunde: string[] = [];
158 for (const { pfad, inhalt } of dateien ?? []) {
159 for (const treffer of inhalt.matchAll(/sammelt\s+kein/gu)) {
160 befunde.push(`${pfad}: ${treffer[0]}`);
161 }
162 }
163
164 expect(
165 befunde,
166 'Die Anwendung sammelt sehr wohl Daten – sie überträgt sie nur nicht. ' +
167 'Die Datenschutzerklärung sagt das in Abschnitt 3 ausdrücklich.',
168 ).toEqual([]);
169 });
170
171 it('erkennt die falsche Zusage, wenn eine dasteht', () => {
172 /* Gegenprobe: ohne sie bliebe unbemerkt, wenn das Muster nie mehr trifft. */
173 expect(/sammelt\s+kein/u.test('sie sammelt keine Daten')).toBe(true);
174 expect(/sammelt\s+kein/u.test('sie überträgt keine Daten')).toBe(false);
175 });
176
177 it('lädt keine Schrift und kein Stilblatt aus dem Netz', () => {
178 /* Eine eingebundene Webschrift wäre eine Verbindung, die niemand als
179 solche liest – und sie stünde in keinem der vier Texte. */
180 const befunde = (dateien ?? [])
181 .filter(({ inhalt }) => /@import\s+url\(\s*['"]?https?:/u.test(inhalt))
182 .map(({ pfad }) => pfad);
183
184 expect(befunde).toEqual([]);
185 });
186 });