waffensachkunde

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

/ app e2e anwendung.spec.ts

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