import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync, readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { IPC, type KartenAntwort } from '../../shared/ipc'; /** * Befund 31: Fremder Antworttext eines Kartendienstes gelangte woertlich in das * Protokoll, das "keine Projektinhalte" verspricht. * * Die Zusage stand in electron/protokoll.ts und - als sichtbarer Anwendertext, * verbunden mit der Aufforderung, die Datei aus der Hand zu geben - im * Hilfefenster ("Bitte legen Sie diese Datei einer Fehlermeldung bei - sie * enthält nur technische Angaben, keine Projektinhalte."). Seit 5.43.0 steht * sie dort schwaecher, so wie sie belegt ist ("darauf angelegt, keine * Projektinhalte aufzunehmen"). Durchgesetzt wurde sie damals nirgends: Der Griff `IPC.fetchMapImage` schrieb `antwort.fehler` * ungefiltert ins Protokoll, und electron/karte.ts klebt dort bei einer * Nicht-Bild-Antwort bis zu 400 Byte des fremden Antwortkoerpers hinein. Ein WMS * meldet Fehler nach WMS 1.3.0 als `ServiceExceptionReport` mit Status 200 und * zitiert darin ueblicherweise die gestellte Anfrage - samt BBOX. Damit standen * die UTM-Koordinaten des geplanten Knotenpunkts in der Datei, die der Anwender * herausgibt. * * Geprueft wird gegen die echte Protokolldatei; ersetzt ist nur der * Kartendienst. */ /** Was ein ausgelasteter WMS zurueckgibt - mit der zitierten Anfrage darin. */ const FREMDTEXT = '' + 'Requested ' + 'BBOX=32456789.123,5678901.234,32457089.123,5679201.234 lies outside the layer extent' + ''; /** Der Ostwert des Knotenpunkts - Projektinhalt, keine technische Angabe. */ const OSTWERT = '32456789.123'; const DIENSTMELDUNG = `Der Kartendienst lieferte kein Bild (text/xml): ${FREMDTEXT}`; const ABRUFADRESSE = 'https://www.wms.nrw.de/geobasis/wms_nw_dop?SERVICE=WMS&REQUEST=GetMap&LAYERS=nw_dop_rgb' + `&CRS=EPSG:25832&BBOX=${OSTWERT},5678901.234,32457089.123,5679201.234&WIDTH=1600&HEIGHT=1600`; const welt = vi.hoisted(() => { type Horcher = (...werte: unknown[]) => void; class Ereignisquelle { private readonly horcher = new Map(); on(name: string, fn: Horcher): this { const liste = this.horcher.get(name) ?? []; liste.push(fn); this.horcher.set(name, liste); return this; } once(name: string, fn: Horcher): this { return this.on(name, fn); } removeListener(name: string, fn: Horcher): this { this.horcher.set( name, (this.horcher.get(name) ?? []).filter((h) => h !== fn), ); return this; } off(name: string, fn: Horcher): this { return this.removeListener(name, fn); } } class Anzeigeinhalt extends Ereignisquelle { send(): void { /* Im Test ohne Wirkung. */ } setZoomFactor(): void { /* Im Test ohne Wirkung. */ } setWindowOpenHandler(): void { /* Im Test ohne Wirkung. */ } isCrashed(): boolean { return false; } isDestroyed(): boolean { return false; } } const zustand = { fenster: null as Fenster | null, benutzerdaten: '.', /** Was der ersetzte Kartendienst zurueckgibt. */ kartenantwort: { ok: false, datenUrl: null, fehler: null } as { ok: boolean; datenUrl: string | null; fehler: string | null; }, }; class Fenster extends Ereignisquelle { readonly webContents = new Anzeigeinhalt(); constructor() { super(); zustand.fenster = this; } loadFile(): Promise { return Promise.resolve(); } show(): void { /* Im Test ohne Wirkung. */ } setBackgroundColor(): void { /* Im Test ohne Wirkung. */ } setDocumentEdited(): void { /* Im Test ohne Wirkung. */ } isDestroyed(): boolean { return false; } close(): void { /* Im Test ohne Wirkung. */ } static getAllWindows(): Fenster[] { return zustand.fenster === null ? [] : [zustand.fenster]; } } const behandler = new Map unknown>(); const electron = { app: { isPackaged: false, requestSingleInstanceLock: (): boolean => true, on: (): void => undefined, whenReady: (): Promise => Promise.resolve(), getPath: (): string => zustand.benutzerdaten, getVersion: (): string => '5.10.0', quit: (): void => undefined, }, BrowserWindow: Fenster, Menu: { buildFromTemplate: (): { getMenuItemById: () => null } => ({ getMenuItemById: () => null }), setApplicationMenu: (): void => undefined, }, dialog: { showMessageBoxSync: (): number => 0, showErrorBox: (): void => undefined, showSaveDialog: (): Promise<{ canceled: boolean }> => Promise.resolve({ canceled: true }), showOpenDialog: (): Promise<{ canceled: boolean; filePaths: string[] }> => Promise.resolve({ canceled: true, filePaths: [] }), }, ipcMain: { handle: (kanal: string, fn: (...werte: unknown[]) => unknown): void => { behandler.set(kanal, fn); }, on: (): void => undefined, }, nativeTheme: { shouldUseDarkColors: false, shouldUseHighContrastColors: false, on: (): void => undefined, }, systemPreferences: { getColor: (): string => '#000000' }, session: { defaultSession: { webRequest: { onHeadersReceived: (): void => undefined }, setPermissionRequestHandler: (): void => undefined, setPermissionCheckHandler: (): void => undefined, }, }, shell: { openExternal: (): Promise => Promise.resolve() }, }; return { electron, zustand, behandler, zuruecksetzen: (): void => { zustand.fenster = null; zustand.kartenantwort = { ok: false, datenUrl: null, fehler: null }; behandler.clear(); }, }; }); vi.mock('electron', () => welt.electron); /** * Nur der Abruf selbst ist ersetzt. `erlaubteRechner` bleibt das echte - sonst * pruefte dieser Test die Liste, die er sich selbst geschrieben hat. */ vi.mock('../../electron/karte', async (echtesModul) => { const echt = await echtesModul(); return { ...echt, holeKartenbild: (): Promise => Promise.resolve(welt.zustand.kartenantwort), }; }); process.setMaxListeners(64); async function starteHauptprozess(): Promise { vi.resetModules(); welt.zuruecksetzen(); await import('../../electron/main'); for (let i = 0; i < 5; i += 1) await Promise.resolve(); } /** Lageplan -> Kartenbild holen. */ async function holeKachel(adresse: string): Promise { return (await welt.behandler.get(IPC.fetchMapImage)?.(null, { url: adresse })) as KartenAntwort; } function protokolltext(): string { return readFileSync(path.join(welt.zustand.benutzerdaten, 'fehlerprotokoll.log'), 'utf8'); } describe('Fehlerprotokoll: kein fremder Antworttext, wie zugesagt', () => { beforeEach(async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); welt.zustand.benutzerdaten = mkdtempSync(path.join(os.tmpdir(), 'lsa-hauptprozess-protokoll-')); await starteHauptprozess(); }); afterEach(() => { vi.restoreAllMocks(); }); it('schreibt die Ausnahmemeldung des WMS nicht in die Datei', async () => { welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: DIENSTMELDUNG }; await holeKachel(ABRUFADRESSE); const protokoll = protokolltext(); expect(protokoll, 'fremder Antworttext im Protokoll').not.toContain('ServiceExceptionReport'); expect(protokoll, 'der Standort des Knotenpunkts im Protokoll').not.toContain(OSTWERT); }); it('schreibt auch die Abrufadresse nicht hinein - die BBOX steht in ihrer Abfrage', async () => { welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: DIENSTMELDUNG }; await holeKachel(ABRUFADRESSE); expect(protokolltext(), 'die Abrufadresse traegt die BBOX').not.toContain('BBOX='); }); it('vermerkt den Fehlschlag trotzdem und nennt den Dienst', async () => { welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: DIENSTMELDUNG }; await holeKachel(ABRUFADRESSE); const protokoll = protokolltext(); expect(protokoll).toContain('Kartendienst'); expect(protokoll, 'der Rechnername ist eine technische Angabe').toContain('www.wms.nrw.de'); }); it('reicht die Meldung des Dienstes unveraendert an die Anzeige durch', async () => { // Der Anwender soll erfahren, woran der Abruf scheiterte - an der // Oberflaeche, nicht in einer Datei, die er aus der Hand gibt. welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: DIENSTMELDUNG }; const antwort = await holeKachel(ABRUFADRESSE); expect(antwort.ok).toBe(false); expect(antwort.fehler).toBe(DIENSTMELDUNG); }); it('nennt einen nicht freigegebenen Rechnernamen nicht beim Namen', async () => { // Ausgerechnet der Fall, in dem der Name NICHT aus der festen Liste stammt, // ist ein Fehlschlagsfall und wird deshalb protokolliert: `holeKartenbild` // weist eine nicht freigegebene Adresse ab, und der Griff schriebe den vom // Anzeigeprozess frei gewaehlten Namen woertlich in die Datei, die der // Anwender laut Hilfefenster aus der Hand geben soll. welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: 'Die Adresse ist nicht freigegeben.', }; await holeKachel('https://fremder-sammler.beispiel.invalid/ablage?kennung=abc'); const protokoll = protokolltext(); expect(protokoll, 'ein frei gewaehlter Rechnername im Protokoll').not.toContain( 'fremder-sammler.beispiel.invalid', ); expect(protokoll, 'der Fehlschlag bleibt vermerkt').toContain('Kartendienst'); }); it('nennt bei einer Adresse ohne Rechnernamen keinen Namen', async () => { welt.zustand.kartenantwort = { ok: false, datenUrl: null, fehler: DIENSTMELDUNG }; await holeKachel('kein Netzort'); expect(protokolltext()).toContain('Kartendienst'); }); it('vermerkt bei einem gelungenen Abruf gar nichts', async () => { welt.zustand.kartenantwort = { ok: true, datenUrl: 'data:image/jpeg;base64,/9j/4AAQ', fehler: null, }; await holeKachel(ABRUFADRESSE); expect(protokolltext()).not.toContain('Kartendienst'); }); });