waffensachkunde

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

/ app tests unterstuetzung.test.ts

11,7 KB Rohdatei
app/tests/unterstuetzung.test.ts — 284 Zeilen
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 });