waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests unterstuetzung.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Das Unterstützungsangebot: die Adresse, der Weg nach außen, sein Ort. |
| 4 | * |
| 5 | * Drei Dinge sind hier zu sichern, und sie hängen nicht zusammen: |
| 6 | * |
| 7 | * 1. **Die Form der Adresse.** Sie ist heute leer und wird später von Hand |
| 8 | * eingetragen. Die Prüfungen dürfen deshalb nicht vom heutigen Wert |
| 9 | * abhängen – ein Test, der auf „leer“ besteht, machte aus der zugesagten |
| 10 | * Einzeiler-Änderung einen roten Lauf. |
| 11 | * 2. **Der Weg nach außen.** `shell.openExternal` reicht an das |
| 12 | * Betriebssystem weiter; was dort ankommt, ist nicht mehr einzufangen. |
| 13 | * Geöffnet werden darf deshalb ausschließlich die eingetragene Adresse, |
| 14 | * und nichts sonst. |
| 15 | * 3. **Der Ort des Angebots.** Es steht in „Über diese Software“ und |
| 16 | * ausdrücklich nirgends sonst. Ein Einblenden mitten in der Lernsitzung |
| 17 | * wäre genau das Muster, das dieses Projekt bei Streaks und Tagesziel-Ring |
| 18 | * abgelehnt hat (`docs/entscheidung-motivation.md`). Ein Verhaltenstest |
| 19 | * könnte das nicht beweisen – er könnte nur an einer Stelle nachsehen, an |
| 20 | * der gerade niemand etwas eingebaut hat. Geprüft wird deshalb der |
| 21 | * Quelltext. |
| 22 | */ |
| 23 | |
| 24 | import { execFileSync } from 'node:child_process'; |
| 25 | import { readFileSync } from 'node:fs'; |
| 26 | import { join } from 'node:path'; |
| 27 | import { fileURLToPath } from 'node:url'; |
| 28 | |
| 29 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 30 | |
| 31 | import { |
| 32 | UNTERSTUETZUNG_KNOPF, |
| 33 | UNTERSTUETZUNG_URL, |
| 34 | unterstuetzungsziel, |
| 35 | } from '../src/shared/unterstuetzung'; |
| 36 | /* Nur für die Typangabe von `importOriginal` weiter unten – als benannter |
| 37 | Namensraum, weil `import()`-Typen projektweit untersagt sind. */ |
| 38 | import type * as Unterstuetzungsmodul from '../src/shared/unterstuetzung'; |
| 39 | |
| 40 | const wurzel = join(fileURLToPath(new URL('..', import.meta.url)), '..'); |
| 41 | |
| 42 | /** Eine erfundene, aber formgerechte Adresse – die echte steht noch nicht fest. */ |
| 43 | const PROBE = 'https://beispiel.example/unterstuetzen'; |
| 44 | |
| 45 | // ─── 1. Die Form der Adresse ──────────────────────────────────────────── |
| 46 | |
| 47 | describe('Adresse der Unterstützungsseite', () => { |
| 48 | it('lässt eine https-Adresse gelten und gibt sie unverändert zurück', () => { |
| 49 | /* Unverändert und nicht als `URL.href` neu zusammengesetzt: Angezeigt und |
| 50 | geöffnet wird so dieselbe Zeichenkette, und der Vergleich im |
| 51 | Hauptprozess trifft genau das, was auf dem Bildschirm stand. `href` |
| 52 | hängte hier ein „/“ an und die Anzeige liefe von der Eingabe weg. */ |
| 53 | expect(unterstuetzungsziel('https://ko-fi.example/name')).toBe('https://ko-fi.example/name'); |
| 54 | expect(unterstuetzungsziel(' https://ko-fi.example/name ')).toBe( |
| 55 | 'https://ko-fi.example/name', |
| 56 | ); |
| 57 | }); |
| 58 | |
| 59 | it.each([ |
| 60 | ['leer', ''], |
| 61 | ['nur Leerzeichen', ' '], |
| 62 | ['ohne Schema', 'ko-fi.example/name'], |
| 63 | ['unverschlüsselt', 'http://ko-fi.example/name'], |
| 64 | ['Skript-Schema', 'javascript:alert(1)'], |
| 65 | ['Postfach-Schema', 'mailto:name@beispiel.example'], |
| 66 | ['Datei-Schema', 'file:///C:/Windows/win.ini'], |
| 67 | ['unvollständig', 'https://'], |
| 68 | ['mit Zugangsdaten', 'https://name:wort@ko-fi.example/'], |
| 69 | ['Unsinn', 'nichts'], |
| 70 | ])('behandelt eine Adresse %s wie „nicht gesetzt“', (_fall, eingabe) => { |
| 71 | /* „Nicht gesetzt“ und nicht „Fehler“: Ein Vertipper soll dieselbe Folge |
| 72 | haben wie ein leeres Feld – das Angebot entfällt. Ein toter Knopf oder |
| 73 | eine Fehlermeldung an dieser Stelle wäre für den Lernenden ein Mangel |
| 74 | der Anwendung, obwohl es einer des Eintrags ist. */ |
| 75 | expect(unterstuetzungsziel(eingabe)).toBeNull(); |
| 76 | }); |
| 77 | |
| 78 | it('hält die eingetragene Adresse an die eigene Form', () => { |
| 79 | /* |
| 80 | Bewusst KEINE Prüfung auf „heute leer“. Der Eintrag ist eine |
| 81 | Einzeiler-Änderung und darf keinen roten Lauf auslösen. Geprüft wird |
| 82 | deshalb die Zusage, die in beiden Zuständen gilt: Was durchkommt, ist |
| 83 | entweder nichts oder eine https-Adresse – nie etwas dazwischen. |
| 84 | */ |
| 85 | const ziel = unterstuetzungsziel(UNTERSTUETZUNG_URL); |
| 86 | |
| 87 | if (ziel !== null) { |
| 88 | expect(ziel).toMatch(/^https:\/\//u); |
| 89 | // Und die Prüfung ist stabil: eine bereits geprüfte Adresse bleibt gültig. |
| 90 | expect(unterstuetzungsziel(ziel)).toBe(ziel); |
| 91 | } |
| 92 | }); |
| 93 | }); |
| 94 | |
| 95 | // ─── 2. Der Weg nach außen ────────────────────────────────────────────── |
| 96 | |
| 97 | describe('Der Weg nach außen', () => { |
| 98 | /** Steht für `shell.openExternal` und meldet, ob und womit es gerufen wurde. */ |
| 99 | const browserOeffnen = vi.fn<(url: string) => Promise<void>>(); |
| 100 | |
| 101 | /** |
| 102 | * Was der Hauptprozess ins Protokoll geschrieben hat. |
| 103 | * |
| 104 | * Aufgefangen statt ausgegeben: Das Protokoll gehört nicht in die |
| 105 | * Testausgabe, und eine Abweisung, die stillschweigend geschieht, wäre bei |
| 106 | * der Fehlersuche später nicht auffindbar – deshalb wird sie auch geprüft. |
| 107 | * Der Wächter entsteht in `beforeEach`, weil `restoreMocks` in der |
| 108 | * Vitest-Einstellung jeden Spion nach jedem Test zurücknimmt. |
| 109 | */ |
| 110 | const warnungen: string[] = []; |
| 111 | |
| 112 | beforeEach(() => { |
| 113 | warnungen.length = 0; |
| 114 | vi.spyOn(console, 'warn').mockImplementation((...teile: unknown[]) => { |
| 115 | warnungen.push(teile.map(String).join(' ')); |
| 116 | }); |
| 117 | }); |
| 118 | |
| 119 | /** |
| 120 | * Lädt `main/sicherheit.ts` mit einer gesetzten Adresse neu. |
| 121 | * |
| 122 | * Über `doMock` und einen dynamischen Import, weil beide Zustände zu prüfen |
| 123 | * sind – der von heute (keine Adresse) und der von morgen. Ein Testlauf, der |
| 124 | * nur den heutigen sähe, ließe genau die Änderung ungeprüft, für die dieses |
| 125 | * Modul gebaut ist. |
| 126 | */ |
| 127 | async function aussenweg(adresse: string): Promise<(gewuenscht: unknown) => Promise<boolean>> { |
| 128 | vi.resetModules(); |
| 129 | browserOeffnen.mockReset(); |
| 130 | browserOeffnen.mockResolvedValue(undefined); |
| 131 | |
| 132 | vi.doMock('electron', () => ({ |
| 133 | /* Nur, was `sicherheit.ts` importiert. Aufgerufen wird an dieser Stelle |
| 134 | allein `shell.openExternal`; die übrigen sind Platzhalter, damit der |
| 135 | Import überhaupt gelingt. */ |
| 136 | app: { on: vi.fn() }, |
| 137 | session: { defaultSession: {} }, |
| 138 | shell: { openExternal: browserOeffnen }, |
| 139 | })); |
| 140 | vi.doMock('../src/shared/unterstuetzung', async (echt) => ({ |
| 141 | ...(await echt<typeof Unterstuetzungsmodul>()), |
| 142 | UNTERSTUETZUNG_URL: adresse, |
| 143 | })); |
| 144 | |
| 145 | const { unterstuetzungOeffnen } = await import('../src/main/sicherheit'); |
| 146 | return unterstuetzungOeffnen; |
| 147 | } |
| 148 | |
| 149 | afterEach(() => { |
| 150 | vi.doUnmock('electron'); |
| 151 | vi.doUnmock('../src/shared/unterstuetzung'); |
| 152 | vi.resetModules(); |
| 153 | }); |
| 154 | |
| 155 | it('öffnet die eingetragene Adresse im Standardbrowser', async () => { |
| 156 | const oeffnen = await aussenweg(PROBE); |
| 157 | |
| 158 | await expect(oeffnen(PROBE)).resolves.toBe(true); |
| 159 | expect(browserOeffnen.mock.calls).toEqual([[PROBE]]); |
| 160 | }); |
| 161 | |
| 162 | it('öffnet keine andere Adresse, auch keine harmlos aussehende', async () => { |
| 163 | /* |
| 164 | Der Kern der Sache. Die Adresse wandert über die Brücke, damit prüfbar |
| 165 | bleibt, dass die Oberfläche genau die anfordert, die sie anzeigt – |
| 166 | maßgeblich ist sie damit nicht. Wer eine Adresse mitbringen darf, darf |
| 167 | sonst auch eine andere mitbringen. |
| 168 | */ |
| 169 | const oeffnen = await aussenweg(PROBE); |
| 170 | |
| 171 | for (const fremd of [ |
| 172 | 'https://beispiel.example/etwas-anderes', |
| 173 | 'https://boeswillig.example/', |
| 174 | `${PROBE}/`, |
| 175 | PROBE.toUpperCase(), |
| 176 | 'file:///C:/Windows/System32/cmd.exe', |
| 177 | ]) { |
| 178 | await expect(oeffnen(fremd)).resolves.toBe(false); |
| 179 | } |
| 180 | |
| 181 | expect(browserOeffnen).not.toHaveBeenCalled(); |
| 182 | expect(warnungen.length).toBeGreaterThan(0); |
| 183 | }); |
| 184 | |
| 185 | it('öffnet nichts, was keine Zeichenkette ist', async () => { |
| 186 | /* Was über die Brücke kommt, ist an dieser Grenze unbekannt – die |
| 187 | Typisierung beschreibt, was der Renderer schicken SOLL. */ |
| 188 | const oeffnen = await aussenweg(PROBE); |
| 189 | |
| 190 | for (const unsinn of [undefined, null, 42, { url: PROBE }, [PROBE]]) { |
| 191 | await expect(oeffnen(unsinn)).resolves.toBe(false); |
| 192 | } |
| 193 | |
| 194 | expect(browserOeffnen).not.toHaveBeenCalled(); |
| 195 | }); |
| 196 | |
| 197 | it('öffnet nichts, solange keine Adresse eingetragen ist', async () => { |
| 198 | /* Der heutige Zustand. Selbst wenn die Oberfläche fragte – sie zeigt das |
| 199 | Angebot gar nicht erst –, geschähe nichts. */ |
| 200 | const oeffnen = await aussenweg(''); |
| 201 | |
| 202 | await expect(oeffnen(PROBE)).resolves.toBe(false); |
| 203 | await expect(oeffnen('')).resolves.toBe(false); |
| 204 | expect(browserOeffnen).not.toHaveBeenCalled(); |
| 205 | }); |
| 206 | |
| 207 | it('öffnet nichts, wenn die eingetragene Adresse die Form verfehlt', async () => { |
| 208 | /* Ein `http://` im Eintrag ist kein halber Treffer, sondern kein Ziel. */ |
| 209 | const oeffnen = await aussenweg('http://beispiel.example/unterstuetzen'); |
| 210 | |
| 211 | await expect(oeffnen('http://beispiel.example/unterstuetzen')).resolves.toBe(false); |
| 212 | expect(browserOeffnen).not.toHaveBeenCalled(); |
| 213 | }); |
| 214 | |
| 215 | it('meldet einen gescheiterten Aufruf, statt ihn durchschlagen zu lassen', async () => { |
| 216 | /* Scheitert der Systemaufruf, bekommt die Oberfläche `false` und zeigt die |
| 217 | Adresse zum Abschreiben. Eine geworfene Ausnahme über die Brücke wäre |
| 218 | für den Lernenden dieselbe Lage mit schlechterem Text. */ |
| 219 | const oeffnen = await aussenweg(PROBE); |
| 220 | browserOeffnen.mockRejectedValueOnce(new Error('Kein Standardbrowser eingerichtet')); |
| 221 | |
| 222 | await expect(oeffnen(PROBE)).resolves.toBe(false); |
| 223 | expect(warnungen.join('\n')).toMatch(/Standardbrowser/u); |
| 224 | }); |
| 225 | }); |
| 226 | |
| 227 | // ─── 3. Der Ort des Angebots ──────────────────────────────────────────── |
| 228 | |
| 229 | describe('Wo das Angebot stehen darf', () => { |
| 230 | /** Der ausgelieferte Quelltext, oder `null` ohne Git. */ |
| 231 | function quelldateien(): readonly { readonly pfad: string; readonly inhalt: string }[] | null { |
| 232 | let dateien: string[]; |
| 233 | try { |
| 234 | dateien = execFileSync('git', ['ls-files', 'app/src'], { |
| 235 | cwd: wurzel, |
| 236 | encoding: 'utf8', |
| 237 | stdio: ['ignore', 'pipe', 'ignore'], |
| 238 | }) |
| 239 | .split('\n') |
| 240 | .map((zeile) => zeile.trim()) |
| 241 | .filter((zeile) => zeile.endsWith('.tsx')); |
| 242 | } catch { |
| 243 | return null; |
| 244 | } |
| 245 | return dateien.map((pfad) => ({ pfad, inhalt: readFileSync(join(wurzel, pfad), 'utf8') })); |
| 246 | } |
| 247 | |
| 248 | it('steht in genau einer Ansicht – und das ist „Über diese Software“', () => { |
| 249 | const dateien = quelldateien(); |
| 250 | if (dateien === null) { |
| 251 | /* Kein Git – dann ist nicht feststellbar, was ausgeliefert würde. */ |
| 252 | return; |
| 253 | } |
| 254 | |
| 255 | expect(dateien.length).toBeGreaterThan(20); |
| 256 | |
| 257 | const verwender = dateien |
| 258 | .filter( |
| 259 | (datei) => |
| 260 | datei.inhalt.includes('<Unterstuetzung') && |
| 261 | !datei.pfad.endsWith('components/ueber/Unterstuetzung.tsx'), |
| 262 | ) |
| 263 | .map((datei) => datei.pfad); |
| 264 | |
| 265 | expect( |
| 266 | verwender, |
| 267 | 'Das Unterstützungsangebot gehört an genau eine ruhige Stelle. Ein zweiter ' + |
| 268 | 'Einbau – im Startbildschirm, in der Lernsitzung, in der Auswertung – ' + |
| 269 | 'wäre das Muster, das docs/entscheidung-motivation.md ablehnt.', |
| 270 | ).toEqual(['app/src/renderer/src/components/ueber/UeberSoftware.tsx']); |
| 271 | }); |
| 272 | }); |
| 273 | |
| 274 | // ─── Was der Knopf verspricht ─────────────────────────────────────────── |
| 275 | |
| 276 | describe('Beschriftung des Knopfes', () => { |
| 277 | it('kündigt an, dass das Fenster verlassen wird', () => { |
| 278 | /* WCAG 3.2: Ein Verweis, der unangekündigt den Browser startet, ist eine |
| 279 | Überraschung. Die Beschriftung sagt es – und sie ist zugleich der ganze |
| 280 | zugängliche Name, es gibt kein `aria-label` daneben (WCAG 2.5.3). */ |
| 281 | expect(UNTERSTUETZUNG_KNOPF).toMatch(/Browser/u); |
| 282 | expect(UNTERSTUETZUNG_KNOPF).toMatch(/öffnen/u); |
| 283 | }); |
| 284 | }); |