waffensachkunde

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

/ app e2e anwendung.spec.ts

9,9 KB Rohdatei
app/e2e/anwendung.spec.ts — 270 Zeilen
1 import { type ElectronApplication, type Page } from '@playwright/test';
2
3 import { cspRichtlinie } from '../src/shared/csp';
4 import {
5 angewandteRichtlinien,
6 appStarten,
7 bauPruefen,
8 konsoleBeimLaden,
9 metaRichtlinie,
10 } from './electron-hilfe';
11 import { expect, test } from './konsolenwache';
12
13 // `undefined`, solange der beforeAll-Hook übersprungen wurde.
14 let app: ElectronApplication | undefined;
15 let fenster: Page;
16
17 test.beforeAll(async () => {
18 // Fehlender oder veralteter Bau ist ein Fehler, kein Grund zum Schweigen.
19 bauPruefen();
20 ({ app, fenster } = await appStarten());
21 });
22
23 test.afterAll(async () => {
24 await app?.close();
25 });
26
27 test.describe('Startbildschirm', () => {
28 test('zeigt Titel und Überschriftenhierarchie auf Deutsch', async () => {
29 await expect(fenster).toHaveTitle('Waffensachkunde – Lernsoftware');
30
31 await expect(fenster.getByRole('heading', { level: 1 })).toHaveText(
32 'Waffensachkunde – Lernsoftware',
33 );
34
35 // Genau eine h1, darunter h2-Abschnitte, darunter h3 – keine
36 // übersprungenen Ebenen. Die Anzahl der Abschnitte wächst mit dem
37 // Funktionsumfang; geprüft wird deshalb die Hierarchie, nicht die Menge.
38 await expect(fenster.getByRole('heading', { level: 1 })).toHaveCount(1);
39 expect(await fenster.getByRole('heading', { level: 2 }).count()).toBeGreaterThan(0);
40
41 const ebenen = await fenster.evaluate(() =>
42 [...document.querySelectorAll('h1, h2, h3, h4, h5, h6')].map((h) =>
43 Number(h.tagName.substring(1)),
44 ),
45 );
46 expect(ebenen.length).toBeGreaterThan(0);
47 expect(ebenen[0]).toBe(1);
48
49 // Eine Überschrift darf höchstens eine Ebene tiefer sein als die vorige.
50 let vorige = 1;
51 for (const ebene of ebenen) {
52 expect(ebene).toBeLessThanOrEqual(vorige + 1);
53 vorige = ebene;
54 }
55 });
56
57 test('setzt lang="de" auf dem Wurzelelement', async () => {
58 await expect(fenster.locator('html')).toHaveAttribute('lang', 'de');
59 });
60
61 test('meldet einen einsatzbereiten SQLite-Rauchtest', async () => {
62 const abzeichen = fenster.locator('.statusabzeichen--ok');
63 await expect(abzeichen).toBeVisible();
64 await expect(fenster.getByText(/SQLite \d+\.\d+/u)).toBeVisible();
65 });
66 });
67
68 test.describe('Sicherheitsgrundlagen', () => {
69 /* Anmerkung: Electron bietet ab Version 43 keine öffentliche API mehr, um
70 die angewandten `webPreferences` auszulesen. Deshalb wird hier das
71 beobachtbare Verhalten geprüft (das ist ohnehin die härtere Zusage);
72 die Konfigurationswerte selbst sichert `tests/sicherheit.test.ts` ab. */
73
74 test('stellt dem Renderer keine Node-Globals bereit', async () => {
75 const globals = await fenster.evaluate(() =>
76 ['require', 'process', 'module', 'exports', 'Buffer', 'global'].map(
77 (name) => `${name}=${typeof (globalThis as Record<string, unknown>)[name]}`,
78 ),
79 );
80
81 expect(globals).toEqual([
82 'require=undefined',
83 'process=undefined',
84 'module=undefined',
85 'exports=undefined',
86 'Buffer=undefined',
87 'global=undefined',
88 ]);
89 });
90
91 test('isoliert den Kontext: kein Zugriff auf Electron-Interna', async () => {
92 const interna = await fenster.evaluate(() =>
93 ['electron', 'ipcRenderer', 'contextBridge', '__electronApi'].map(
94 (name) => typeof (globalThis as Record<string, unknown>)[name],
95 ),
96 );
97
98 expect(interna).toEqual(['undefined', 'undefined', 'undefined', 'undefined']);
99 });
100
101 test('legt genau die vereinbarte Bridge frei – und nichts weiter', async () => {
102 const bruecke = await fenster.evaluate(() => {
103 const api = (globalThis as Record<string, unknown>)['lernApp'];
104 if (typeof api !== 'object' || api === null) {
105 return null;
106 }
107 return Object.keys(api).sort();
108 });
109
110 // Bewusst als vollständige Liste gepflegt: Der Test soll auffallen, sobald
111 // die Bridge etwas freilegt, das hier nicht bewusst eingetragen wurde.
112 expect(bruecke).toEqual([
113 'anwendungsInfoLesen',
114 'anzeigegroesseBeobachten',
115 'anzeigegroesseSetzen',
116 'dateiZeigen',
117 'datenschutzLesen',
118 'einstellungenLesen',
119 'einstellungenSchreiben',
120 'energiesparenBeobachten',
121 'erklaerungenLaden',
122 'fehlerprotokollDrucken',
123 'fragenlisteDrucken',
124 'glossarLaden',
125 'hilfsmittelAktiv',
126 'hilfsmittelBeobachten',
127 'katalogBild',
128 'katalogLaden',
129 'lernAntworten',
130 'lernFragestand',
131 'lernHartnaeckige',
132 'lernMerken',
133 'lernPlan',
134 'lernSitzung',
135 'lernUebersicht',
136 'lernZuruecksetzen',
137 'lernberichtDrucken',
138 'lizenzenLesen',
139 'menuebefehlBeobachten',
140 'normtexteLaden',
141 'profilAktualisieren',
142 'profilAnlegen',
143 'profilListe',
144 'profilLoeschen',
145 'pruefungAuswerten',
146 'pruefungLaeuft',
147 'pruefungOffen',
148 'pruefungSichern',
149 'pruefungStarten',
150 'pruefungVerlauf',
151 'pruefungVerwerfen',
152 'sicherungAnlegen',
153 'sicherungEinspielen',
154 'sicherungPruefen',
155 'sicherungUebernehmen',
156 'sicherungsordnerZeigen',
157 'themenLaden',
158 'unterstuetzungOeffnen',
159 'zwischenablageSchreiben',
160 ]);
161 });
162
163 test('erzwingt eine strenge Content-Security-Policy', async () => {
164 const richtlinie = await metaRichtlinie(fenster);
165
166 // Der Bau setzt die `<meta>`-Fassung ein; abweichen darf davon nichts.
167 expect(richtlinie).toBe(cspRichtlinie('production', 'meta'));
168 expect(richtlinie).toContain("default-src 'none'");
169 expect(richtlinie).toContain("object-src 'none'");
170 // Im Produktionsbuild darf es kein 'unsafe-inline' und kein 'unsafe-eval' geben.
171 expect(richtlinie).not.toContain('unsafe-inline');
172 expect(richtlinie).not.toContain('unsafe-eval');
173 });
174
175 test('lässt frame-ancestors über den HTTP-Kopf gelten, nicht über das <meta>-Element', async () => {
176 /*
177 Der Kern dieser Prüfung – und der Grund, warum sie nicht mehr am Wortlaut
178 des Attributs hängt.
179
180 Vorher stand hier `expect(richtlinie).toContain("frame-ancestors 'none'")`.
181 Das war grün, während die Direktive gar nicht galt: Chromium verwirft
182 `frame-ancestors` im `<meta>`-Element und schrieb bei jedem Start einen
183 Konsolenfehler darüber. Gemessen wird jetzt, welche Richtlinien der
184 Renderer tatsächlich anwendet.
185 */
186 const meta = await metaRichtlinie(fenster);
187 expect(meta).not.toContain('frame-ancestors');
188
189 const angewandt = await angewandteRichtlinien(fenster);
190
191 // Zwei Wege, zwei Richtlinien: das `<meta>`-Element und der HTTP-Kopf.
192 expect(angewandt).toContain(meta);
193 const ausDemKopf = angewandt.filter((r) => r !== meta);
194 expect(ausDemKopf, `Angewandte Richtlinien:\n${angewandt.join('\n')}`).toHaveLength(1);
195
196 /*
197 Nicht gegen einen festen Wortlaut geprüft: Dieser Lauf startet den Bau aus
198 `out/` unmittelbar, also ist `app.isPackaged` falsch und der Hauptprozess
199 liefert die Dev-Fassung in den Kopf (nachgemessen). Für die Frage, um die
200 es hier geht, ist das gleichgültig – entscheidend ist, dass die Direktive
201 über einen Weg ankommt, auf dem sie gilt.
202 */
203 expect(ausDemKopf[0]).toContain("frame-ancestors 'none'");
204
205 /*
206 Und die Schnittmenge bleibt streng: Chromium wertet beide Richtlinien
207 einzeln aus, erlaubt ist nur, was jede erlaubt. Dass die Kopf-Fassung im
208 ungepackten Lauf `'unsafe-inline'` enthält, lockert deshalb nichts – die
209 `<meta>`-Fassung verbietet es weiterhin (oben geprüft).
210 */
211 for (const einzelne of angewandt) {
212 expect(einzelne).toContain("default-src 'none'");
213 }
214 });
215
216 test('schreibt beim Laden keinen Konsolenfehler über die Richtlinie', async () => {
217 /*
218 Die Gegenprobe zur Direktive, die nicht gilt: Chromium meldet jede
219 ignorierte `<meta>`-Direktive als Konsolenfehler, bei jedem einzelnen
220 Start. Der Fehler ist hier die Zusage, nicht nur ein Schönheitsfehler –
221 eine Wache, die auf Konsolenfehler anspringt, hätte sonst dauerhaft eine
222 Ausnahme für diese eine Meldung tragen müssen.
223
224 Gemessen wird beim Neuladen – warum, steht bei `konsoleBeimLaden`.
225 */
226 const meldungen = await konsoleBeimLaden(fenster);
227
228 const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m));
229 expect(
230 zurRichtlinie,
231 `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`,
232 ).toEqual([]);
233 });
234 });
235
236 test.describe('Tastaturbedienung', () => {
237 test('erreicht den Skip-Link als ersten Tabstopp und springt zum Inhalt', async () => {
238 await fenster.locator('body').press('Tab');
239
240 const fokussiert = fenster.locator(':focus');
241 await expect(fokussiert).toHaveText('Direkt zum Hauptinhalt springen');
242
243 await fokussiert.press('Enter');
244 await expect(fenster.locator('#inhalt')).toBeFocused();
245 });
246
247 test('schaltet das Theme allein per Tastatur um', async () => {
248 const gruppe = fenster.getByRole('radiogroup', { name: 'Farbschema' });
249 await gruppe.getByRole('radio', { name: 'Systemvorgabe', exact: true }).focus();
250
251 await fenster.keyboard.press('End');
252 await expect(gruppe.getByRole('radio', { name: 'Hoher Kontrast', exact: true })).toBeChecked();
253
254 await expect(fenster.locator('html')).toHaveAttribute('data-thema', 'hochkontrast');
255 });
256
257 test('sagt die Umschaltung über aria-live an', async () => {
258 /* Gezielt die Ansage der Anwendung, nicht irgendeine Live-Region: Der
259 Startbildschirm hat inzwischen eine zweite für den Lernbericht. Zwei
260 höfliche Regionen nebeneinander sind zulässig – jede meldet nur, was in
261 ihr steht –, aber ein Test muss sagen, welche er meint. */
262 const meldung = fenster.locator('.nur-screenreader[role="status"][aria-live="polite"]');
263 await expect(meldung).toBeAttached();
264
265 // Der Auswahlglyph ist `aria-hidden`, der zugängliche Name ist deshalb
266 // exakt die Beschriftung.
267 await fenster.getByRole('radio', { name: 'Dunkel', exact: true }).click();
268 await expect(meldung).toHaveText('Darstellung umgestellt auf Dunkel.');
269 });
270 });