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 — 269 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 'themenLaden',
157 'unterstuetzungOeffnen',
158 'zwischenablageSchreiben',
159 ]);
160 });
161
162 test('erzwingt eine strenge Content-Security-Policy', async () => {
163 const richtlinie = await metaRichtlinie(fenster);
164
165 // Der Bau setzt die `<meta>`-Fassung ein; abweichen darf davon nichts.
166 expect(richtlinie).toBe(cspRichtlinie('production', 'meta'));
167 expect(richtlinie).toContain("default-src 'none'");
168 expect(richtlinie).toContain("object-src 'none'");
169 // Im Produktionsbuild darf es kein 'unsafe-inline' und kein 'unsafe-eval' geben.
170 expect(richtlinie).not.toContain('unsafe-inline');
171 expect(richtlinie).not.toContain('unsafe-eval');
172 });
173
174 test('lässt frame-ancestors über den HTTP-Kopf gelten, nicht über das <meta>-Element', async () => {
175 /*
176 Der Kern dieser Prüfung – und der Grund, warum sie nicht mehr am Wortlaut
177 des Attributs hängt.
178
179 Vorher stand hier `expect(richtlinie).toContain("frame-ancestors 'none'")`.
180 Das war grün, während die Direktive gar nicht galt: Chromium verwirft
181 `frame-ancestors` im `<meta>`-Element und schrieb bei jedem Start einen
182 Konsolenfehler darüber. Gemessen wird jetzt, welche Richtlinien der
183 Renderer tatsächlich anwendet.
184 */
185 const meta = await metaRichtlinie(fenster);
186 expect(meta).not.toContain('frame-ancestors');
187
188 const angewandt = await angewandteRichtlinien(fenster);
189
190 // Zwei Wege, zwei Richtlinien: das `<meta>`-Element und der HTTP-Kopf.
191 expect(angewandt).toContain(meta);
192 const ausDemKopf = angewandt.filter((r) => r !== meta);
193 expect(ausDemKopf, `Angewandte Richtlinien:\n${angewandt.join('\n')}`).toHaveLength(1);
194
195 /*
196 Nicht gegen einen festen Wortlaut geprüft: Dieser Lauf startet den Bau aus
197 `out/` unmittelbar, also ist `app.isPackaged` falsch und der Hauptprozess
198 liefert die Dev-Fassung in den Kopf (nachgemessen). Für die Frage, um die
199 es hier geht, ist das gleichgültig – entscheidend ist, dass die Direktive
200 über einen Weg ankommt, auf dem sie gilt.
201 */
202 expect(ausDemKopf[0]).toContain("frame-ancestors 'none'");
203
204 /*
205 Und die Schnittmenge bleibt streng: Chromium wertet beide Richtlinien
206 einzeln aus, erlaubt ist nur, was jede erlaubt. Dass die Kopf-Fassung im
207 ungepackten Lauf `'unsafe-inline'` enthält, lockert deshalb nichts – die
208 `<meta>`-Fassung verbietet es weiterhin (oben geprüft).
209 */
210 for (const einzelne of angewandt) {
211 expect(einzelne).toContain("default-src 'none'");
212 }
213 });
214
215 test('schreibt beim Laden keinen Konsolenfehler über die Richtlinie', async () => {
216 /*
217 Die Gegenprobe zur Direktive, die nicht gilt: Chromium meldet jede
218 ignorierte `<meta>`-Direktive als Konsolenfehler, bei jedem einzelnen
219 Start. Der Fehler ist hier die Zusage, nicht nur ein Schönheitsfehler –
220 eine Wache, die auf Konsolenfehler anspringt, hätte sonst dauerhaft eine
221 Ausnahme für diese eine Meldung tragen müssen.
222
223 Gemessen wird beim Neuladen – warum, steht bei `konsoleBeimLaden`.
224 */
225 const meldungen = await konsoleBeimLaden(fenster);
226
227 const zurRichtlinie = meldungen.filter((m) => /content security policy/iu.test(m));
228 expect(
229 zurRichtlinie,
230 `Konsolenausgabe beim Laden:\n${meldungen.join('\n') || '(keine)'}`,
231 ).toEqual([]);
232 });
233 });
234
235 test.describe('Tastaturbedienung', () => {
236 test('erreicht den Skip-Link als ersten Tabstopp und springt zum Inhalt', async () => {
237 await fenster.locator('body').press('Tab');
238
239 const fokussiert = fenster.locator(':focus');
240 await expect(fokussiert).toHaveText('Direkt zum Hauptinhalt springen');
241
242 await fokussiert.press('Enter');
243 await expect(fenster.locator('#inhalt')).toBeFocused();
244 });
245
246 test('schaltet das Theme allein per Tastatur um', async () => {
247 const gruppe = fenster.getByRole('radiogroup', { name: 'Farbschema' });
248 await gruppe.getByRole('radio', { name: 'Systemvorgabe', exact: true }).focus();
249
250 await fenster.keyboard.press('End');
251 await expect(gruppe.getByRole('radio', { name: 'Hoher Kontrast', exact: true })).toBeChecked();
252
253 await expect(fenster.locator('html')).toHaveAttribute('data-thema', 'hochkontrast');
254 });
255
256 test('sagt die Umschaltung über aria-live an', async () => {
257 /* Gezielt die Ansage der Anwendung, nicht irgendeine Live-Region: Der
258 Startbildschirm hat inzwischen eine zweite für den Lernbericht. Zwei
259 höfliche Regionen nebeneinander sind zulässig – jede meldet nur, was in
260 ihr steht –, aber ein Test muss sagen, welche er meint. */
261 const meldung = fenster.locator('.nur-screenreader[role="status"][aria-live="polite"]');
262 await expect(meldung).toBeAttached();
263
264 // Der Auswahlglyph ist `aria-hidden`, der zugängliche Name ist deshalb
265 // exakt die Beschriftung.
266 await fenster.getByRole('radio', { name: 'Dunkel', exact: true }).click();
267 await expect(meldung).toHaveText('Darstellung umgestellt auf Dunkel.');
268 });
269 });