waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 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 | }); |