lsa-planer

LSA-Planer Professional – Planungssoftware für Lichtsignalanlagen nach RiLSA 2015 und § 45 StVO. EUPL-1.2.

/ tests electron dateigrenzenHauptprozess.test.ts

16,0 KB Rohdatei
tests/electron/dateigrenzenHauptprozess.test.ts — 467 Zeilen
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2 import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3 import os from 'node:os';
4 import path from 'node:path';
5 import { IPC, type OpenFileResult, type SaveFileResult } from '../../shared/ipc';
6
7 /**
8 * Befunde 70 und 73 - beide an den Dateiwegen des Hauptprozesses.
9 *
10 * BEFUND 70: `readFile(..., 'utf8')` erzeugt eine einzige Zeichenkette. V8
11 * laesst hoechstens rund 512 MiB Zeichen zu; darueber wirft der Aufruf einen
12 * `RangeError` OHNE `code`. `describe` deckt nur EACCES/EPERM, ENOSPC, ENOENT
13 * und EBUSY ab und gab sonst `error.message` unveraendert zurueck - der Anwender
14 * las "Invalid string length" und erfuhr nicht, dass die Datei schlicht zu gross
15 * ist. Eine vorgelagerte Groessenpruefung gab es nicht. Der Dateidialog laesst
16 * neben `.lsap` auch `.json` zu; ein Fehlgriff auf eine Datenausleitung von
17 * mehreren hundert Megabyte genuegt.
18 *
19 * BEFUND 73: Der Name der Nachbardatei `<Ziel>.teil` ist fuenf Zeichen laenger
20 * als das Ziel, NTFS laesst je Namensbestandteil aber 255 Zeichen zu. Bei einem
21 * Zielnamen von 251 bis 255 Zeichen scheiterte das Oeffnen der Nachbardatei mit
22 * ENOENT, und `describe` machte daraus "Die Datei oder der Ordner wurde nicht
23 * gefunden." - eine Aussage, die hier falsch ist: Der Ordner besteht, und den
24 * Namen hat der Dialog eben noch angenommen.
25 *
26 * Geprueft wird gegen ein echtes Verzeichnis. Ersetzt ist nur die Groessenangabe
27 * des Dateisystems: Eine Datei von mehreren hundert Megabyte anzulegen, um eine
28 * Schranke zu pruefen, waere die falsche Art von Genauigkeit.
29 */
30
31 const welt = vi.hoisted(() => {
32 type Horcher = (...werte: unknown[]) => void;
33
34 class Ereignisquelle {
35 private readonly horcher = new Map<string, Horcher[]>();
36
37 on(name: string, fn: Horcher): this {
38 const liste = this.horcher.get(name) ?? [];
39 liste.push(fn);
40 this.horcher.set(name, liste);
41 return this;
42 }
43
44 once(name: string, fn: Horcher): this {
45 return this.on(name, fn);
46 }
47
48 removeListener(name: string, fn: Horcher): this {
49 this.horcher.set(
50 name,
51 (this.horcher.get(name) ?? []).filter((h) => h !== fn),
52 );
53 return this;
54 }
55
56 off(name: string, fn: Horcher): this {
57 return this.removeListener(name, fn);
58 }
59 }
60
61 class Anzeigeinhalt extends Ereignisquelle {
62 send(): void {
63 /* Im Test ohne Wirkung. */
64 }
65 setZoomFactor(): void {
66 /* Im Test ohne Wirkung. */
67 }
68 setWindowOpenHandler(): void {
69 /* Im Test ohne Wirkung. */
70 }
71 isCrashed(): boolean {
72 return false;
73 }
74 isDestroyed(): boolean {
75 return false;
76 }
77 }
78
79 const zustand = {
80 fenster: null as Fenster | null,
81 benutzerdaten: '.',
82 /** Was der Speichern-unter-Dialog zurueckgibt. */
83 speichernZiel: null as string | null,
84 /** Was der Oeffnen-Dialog zurueckgibt. */
85 oeffnenZiel: null as string | null,
86 /** Vorgetaeuschte Dateigroesse in Byte; `null` = die echte Angabe gilt. */
87 vorgetaeuschteGroesse: null as number | null,
88 /** Scharfgestellter Wurf beim Lesen. */
89 lesefehler: null as Error | null,
90 /** Wie oft wirklich gelesen wurde. */
91 lesevorgaenge: 0,
92 };
93
94 class Fenster extends Ereignisquelle {
95 readonly webContents = new Anzeigeinhalt();
96
97 constructor() {
98 super();
99 zustand.fenster = this;
100 }
101
102 loadFile(): Promise<void> {
103 return Promise.resolve();
104 }
105 show(): void {
106 /* Im Test ohne Wirkung. */
107 }
108 setBackgroundColor(): void {
109 /* Im Test ohne Wirkung. */
110 }
111 setDocumentEdited(): void {
112 /* Im Test ohne Wirkung. */
113 }
114 isDestroyed(): boolean {
115 return false;
116 }
117 close(): void {
118 /* Im Test ohne Wirkung. */
119 }
120
121 static getAllWindows(): Fenster[] {
122 return zustand.fenster === null ? [] : [zustand.fenster];
123 }
124 }
125
126 const behandler = new Map<string, (...werte: unknown[]) => unknown>();
127
128 const electron = {
129 app: {
130 isPackaged: false,
131 requestSingleInstanceLock: (): boolean => true,
132 on: (): void => undefined,
133 whenReady: (): Promise<void> => Promise.resolve(),
134 getPath: (): string => zustand.benutzerdaten,
135 getVersion: (): string => '5.10.0',
136 quit: (): void => undefined,
137 },
138 BrowserWindow: Fenster,
139 Menu: {
140 buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }),
141 setApplicationMenu: (): void => undefined,
142 },
143 dialog: {
144 showMessageBoxSync: (): number => 0,
145 showErrorBox: (): void => undefined,
146 showSaveDialog: (): Promise<{ canceled: boolean; filePath: string | undefined }> =>
147 Promise.resolve({
148 canceled: zustand.speichernZiel === null,
149 filePath: zustand.speichernZiel ?? undefined,
150 }),
151 showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> =>
152 Promise.resolve({
153 canceled: zustand.oeffnenZiel === null,
154 filePaths: zustand.oeffnenZiel === null ? [] : [zustand.oeffnenZiel],
155 }),
156 },
157 ipcMain: {
158 handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => {
159 behandler.set(kanal, fn);
160 },
161 on: (): void => undefined,
162 },
163 nativeTheme: {
164 shouldUseDarkColors: false,
165 shouldUseHighContrastColors: false,
166 on: (): void => undefined,
167 },
168 systemPreferences: { getColor: (): string => '#000000' },
169 session: {
170 defaultSession: {
171 webRequest: { onHeadersReceived: (): void => undefined },
172 setPermissionRequestHandler: (): void => undefined,
173 setPermissionCheckHandler: (): void => undefined,
174 },
175 },
176 shell: { openExternal: (): Promise<void> => Promise.resolve() },
177 };
178
179 return {
180 electron,
181 zustand,
182 behandler,
183 zuruecksetzen: (): void => {
184 zustand.fenster = null;
185 zustand.speichernZiel = null;
186 zustand.oeffnenZiel = null;
187 zustand.vorgetaeuschteGroesse = null;
188 zustand.lesefehler = null;
189 zustand.lesevorgaenge = 0;
190 behandler.clear();
191 },
192 };
193 });
194
195 vi.mock('electron', () => welt.electron);
196
197 /**
198 * Ersatz fuer `node:fs/promises`.
199 *
200 * Nur zwei Stellen sind ersetzt: die gemeldete Dateigroesse und der Wurf beim
201 * Lesen. Alles uebrige - Oeffnen, Schreiben, Umbenennen, Entfernen - geht an das
202 * echte Dateisystem, damit die Namenslaenge an NTFS gemessen wird und nicht an
203 * einer Nachbildung.
204 */
205 vi.mock('node:fs/promises', async (echtesModul) => {
206 const echt = await echtesModul<typeof import('node:fs/promises')>();
207 return {
208 ...echt,
209 default: echt,
210 async stat(ziel: unknown): Promise<unknown> {
211 const angabe = await echt.stat(ziel as string);
212 if (welt.zustand.vorgetaeuschteGroesse === null) return angabe;
213 return { ...angabe, size: welt.zustand.vorgetaeuschteGroesse };
214 },
215 async readFile(ziel: unknown, kennzeichen?: unknown): Promise<unknown> {
216 welt.zustand.lesevorgaenge += 1;
217 if (welt.zustand.lesefehler !== null) throw welt.zustand.lesefehler;
218 return echt.readFile(ziel as string, kennzeichen as never);
219 },
220 };
221 });
222
223 process.setMaxListeners(64);
224
225 const STAND = '{"schemaVersion":13,"meta":{"name":"Bahnhofstraße"}}';
226
227 let ablage = '';
228
229 async function starteHauptprozess(): Promise<void> {
230 vi.resetModules();
231 welt.zuruecksetzen();
232 await import('../../electron/main');
233 for (let i = 0; i < 5; i += 1) await Promise.resolve();
234 }
235
236 /** "Datei > Öffnen" mit dem Dialog davor. */
237 async function oeffne(ziel: string): Promise<OpenFileResult> {
238 welt.zustand.oeffnenZiel = ziel;
239 return (await welt.behandler.get(IPC.openFile)?.(null, [])) as OpenFileResult;
240 }
241
242 /** Der Kanal `IPC.readFile` auf einen bereits bestaetigten Pfad. */
243 async function lieseErneut(ziel: string): Promise<OpenFileResult> {
244 return (await welt.behandler.get(IPC.readFile)?.(null, ziel)) as OpenFileResult;
245 }
246
247 /** "Speichern unter" mit dem Dialog davor. */
248 async function speichereUnter(ziel: string, daten: string): Promise<SaveFileResult> {
249 welt.zustand.speichernZiel = ziel;
250 return (await welt.behandler.get(IPC.saveFile)?.(null, {
251 defaultName: path.basename(ziel),
252 filters: [],
253 data: daten,
254 })) as SaveFileResult;
255 }
256
257 /** Strg+S auf einen bereits bestaetigten Pfad. */
258 async function speichere(ziel: string, daten: string): Promise<SaveFileResult> {
259 return (await welt.behandler.get(IPC.writeFile)?.(null, {
260 filePath: ziel,
261 data: daten,
262 })) as SaveFileResult;
263 }
264
265 /**
266 * Dieselben beiden Wege mit einer Nutzlast, die der Typ gar nicht zulaesst.
267 *
268 * Ueber die Bruecke kommt an, was der Anzeigeprozess sendet; der Typ
269 * `string | Uint8Array` ist eine Zusage des Uebersetzers, keine Wache zur
270 * Laufzeit. `toBuffer` faengt das ab.
271 */
272 async function speichereUnterMitNutzlast(ziel: string, daten: unknown): Promise<SaveFileResult> {
273 welt.zustand.speichernZiel = ziel;
274 return (await welt.behandler.get(IPC.saveFile)?.(null, {
275 defaultName: path.basename(ziel),
276 filters: [],
277 data: daten,
278 })) as SaveFileResult;
279 }
280
281 async function speichereMitNutzlast(ziel: string, daten: unknown): Promise<SaveFileResult> {
282 return (await welt.behandler.get(IPC.writeFile)?.(null, {
283 filePath: ziel,
284 data: daten,
285 })) as SaveFileResult;
286 }
287
288 beforeEach(async () => {
289 vi.spyOn(console, 'error').mockImplementation(() => undefined);
290 ablage = mkdtempSync(path.join(os.tmpdir(), 'lsa-hauptprozess-'));
291 welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-hauptprozess-benutzer-'));
292 await starteHauptprozess();
293 });
294
295 afterEach(() => {
296 vi.restoreAllMocks();
297 });
298
299 describe('Öffnen: eine übergroße Datei wird auf Deutsch abgewiesen', () => {
300 it('nennt beim Öffnen die tatsächliche und die zulässige Größe', async () => {
301 const ziel = path.join(ablage, 'ausleitung.json');
302 writeFileSync(ziel, STAND, 'utf8');
303 welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024;
304
305 const ergebnis = await oeffne(ziel);
306
307 expect(ergebnis.ok).toBe(false);
308 expect(ergebnis.error, 'die englische V8-Meldung erreicht den Anwender').not.toContain(
309 'Invalid string length',
310 );
311 expect(ergebnis.error).toContain('600,0 MB');
312 expect(ergebnis.error).toContain('Grenze von 256 MB');
313 });
314
315 it('liest die Datei gar nicht erst ein', async () => {
316 const ziel = path.join(ablage, 'ausleitung.json');
317 writeFileSync(ziel, STAND, 'utf8');
318 welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024;
319
320 await oeffne(ziel);
321
322 expect(welt.zustand.lesevorgaenge, 'die Datei wurde trotzdem gelesen').toBe(0);
323 });
324
325 it('weist auch den Kanal für das erneute Lesen ab', async () => {
326 const ziel = path.join(ablage, 'knotenpunkt.lsap');
327 writeFileSync(ziel, STAND, 'utf8');
328 await oeffne(ziel);
329
330 welt.zustand.vorgetaeuschteGroesse = 600 * 1024 * 1024;
331 const ergebnis = await lieseErneut(path.resolve(ziel));
332
333 expect(ergebnis.ok).toBe(false);
334 expect(ergebnis.error).toContain('Grenze von 256 MB');
335 });
336
337 it('lässt eine Projektdatei üblicher Größe unverändert durch', async () => {
338 // Gegenprobe: Die Schranke liegt um ein Vielfaches ueber allem, was dieses
339 // Programm schreiben kann - das groesste eingebettete Bild belegt 32 MiB.
340 const ziel = path.join(ablage, 'knotenpunkt.lsap');
341 writeFileSync(ziel, STAND, 'utf8');
342
343 const ergebnis = await oeffne(ziel);
344
345 expect(ergebnis.ok).toBe(true);
346 expect(ergebnis.content).toBe(STAND);
347 });
348
349 it('setzt einen deutschen Satz um eine Meldung, die nur die Laufzeitumgebung kennt', async () => {
350 // Der Auffangzweig von `describe`: EIO, ELOOP, EMFILE und alles ohne `code`
351 // kamen bis hierher unuebersetzt an der Oberflaeche an.
352 const ziel = path.join(ablage, 'knotenpunkt.lsap');
353 writeFileSync(ziel, STAND, 'utf8');
354 welt.zustand.lesefehler = new RangeError('Invalid string length');
355
356 const ergebnis = await oeffne(ziel);
357
358 expect(ergebnis.ok).toBe(false);
359 expect(ergebnis.error, 'kein deutscher Satz vor der fremden Meldung').toMatch(
360 /^Der Vorgang ist fehlgeschlagen\. Das System meldet: /,
361 );
362 expect(ergebnis.error, 'die Ursache selbst darf nicht verschwinden').toContain(
363 'Invalid string length',
364 );
365 });
366 });
367
368 /** Ein Name aus `laenge` Zeichen, wie ihn ein Anwender einfuegen kann. */
369 function langerName(laenge: number): string {
370 const stamm = 'Knotenpunkt-Bahnhofstrasse-Suedring-Bauabschnitt-';
371 return stamm.repeat(Math.ceil(laenge / stamm.length)).slice(0, laenge);
372 }
373
374 describe('Speichern: ein zu langer Dateiname wird beim Namen genannt', () => {
375 it('nennt den wirklichen Grund statt "wurde nicht gefunden"', async () => {
376 // 251 Zeichen: Der Zielname geht, die Nachbardatei "<Ziel>.teil" kaeme auf
377 // 256 und damit ueber die Grenze von NTFS.
378 const ziel = path.join(ablage, langerName(251));
379
380 const ergebnis = await speichereUnter(ziel, STAND);
381
382 expect(ergebnis.ok).toBe(false);
383 expect(ergebnis.error, 'die irreführende Begründung steht noch').not.toBe(
384 'Die Datei oder der Ordner wurde nicht gefunden.',
385 );
386 expect(ergebnis.error).toContain('Der Dateiname ist zu lang');
387 expect(ergebnis.error, 'die zulässige Länge wird nicht genannt').toContain('250 Zeichen');
388 });
389
390 it('lässt beim Abweisen nichts im Ordner zurück', async () => {
391 const ziel = path.join(ablage, langerName(251));
392
393 await speichereUnter(ziel, STAND);
394
395 expect(readdirSync(ablage)).toEqual([]);
396 });
397
398 it('nennt denselben Grund beim Strg+S auf einen bestätigten Pfad', async () => {
399 const ziel = path.join(ablage, langerName(255));
400 // Der Dialog bestaetigt den Pfad, bevor geschrieben wird.
401 await speichereUnter(ziel, STAND);
402
403 const ergebnis = await speichere(path.resolve(ziel), STAND);
404
405 expect(ergebnis.ok).toBe(false);
406 expect(ergebnis.error).toContain('Der Dateiname ist zu lang');
407 });
408
409 it('speichert bei 250 Zeichen weiterhin', async () => {
410 // Gegenprobe: Genau bis hierher reicht es, und die Grenze wird nicht
411 // strenger gezogen als das Dateisystem sie zieht.
412 const ziel = path.join(ablage, langerName(250));
413
414 const ergebnis = await speichereUnter(ziel, STAND);
415
416 expect(ergebnis.ok).toBe(true);
417 expect(readFileSync(ziel, 'utf8')).toBe(STAND);
418 expect(readdirSync(ablage)).toEqual([langerName(250)]);
419 });
420 });
421
422 /**
423 * Der Auffangzweig von `describe` legt um jede fremde Meldung den Satz "Der
424 * Vorgang ist fehlgeschlagen. Das System meldet: ...". Das ist richtig fuer
425 * Meldungen der Laufzeitumgebung - und falsch fuer die eine Stelle im
426 * Hauptprozess, die selbst einen bereits fuer den Anwender geschriebenen
427 * deutschen Satz wirft: `toBuffer`. Sonst liest der Anwender einen deutschen
428 * Satz des eigenen Programms als Meldung des Systems.
429 */
430 const NUTZLASTMELDUNG = 'Ungültige Daten: erwartet wird Text oder ein Bytefeld.';
431
432 describe('Speichern: eine Nutzlast, die weder Text noch Bytefeld ist', () => {
433 it('gibt den eigenen deutschen Satz ohne einen zweiten davor aus', async () => {
434 const ziel = path.join(ablage, 'knotenpunkt.lsap');
435
436 const ergebnis = await speichereUnterMitNutzlast(ziel, 42);
437
438 expect(ergebnis.ok).toBe(false);
439 expect(ergebnis.error, 'ein eigener Satz als Meldung des Systems ausgegeben').toBe(
440 NUTZLASTMELDUNG,
441 );
442 });
443
444 it('nennt denselben Satz beim Strg+S auf einen bestätigten Pfad', async () => {
445 const ziel = path.join(ablage, 'knotenpunkt.lsap');
446 await speichereUnter(ziel, STAND);
447
448 const ergebnis = await speichereMitNutzlast(path.resolve(ziel), { daten: 'nichts davon' });
449
450 expect(ergebnis.ok).toBe(false);
451 expect(ergebnis.error).toBe(NUTZLASTMELDUNG);
452 });
453
454 it('lässt die zuvor gesicherte Fassung unberührt', async () => {
455 // Gegenprobe, auch am Altstand gruen: Der Wurf faellt vor dem ersten Byte,
456 // weil `toBuffer` ausgewertet wird, bevor `schreibeUnteilbar` laeuft. Was
457 // sich geaendert hat, ist allein die Meldung - nicht, was auf dem
458 // Datentraeger steht.
459 const ziel = path.join(ablage, 'knotenpunkt.lsap');
460 await speichereUnter(ziel, STAND);
461
462 await speichereMitNutzlast(path.resolve(ziel), null);
463
464 expect(readFileSync(ziel, 'utf8')).toBe(STAND);
465 expect(readdirSync(ablage)).toEqual(['knotenpunkt.lsap']);
466 });
467 });