waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | }); |