import { readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import ts from 'typescript'; import { describe, expect, it } from 'vitest'; import { erlaubteRechner } from '../../electron/karte'; import { ANSICHT_DATEINAME } from '../../electron/schriftgroesse'; import { VERWEISADRESSEN } from '../../shared/verweise'; import { KARTENDIENSTE, rechnernamen } from '@/services/kartendienste'; /** * Die Datenschutzhinweise - Satz fuer Satz gegen den Quelltext gehalten. * * ANLASS. Ein Programm, das weitergegeben wird, braucht Datenschutzhinweise, * und zwar gut sichtbar im Programm selbst und nicht nur auf einer * Internetseite. Im Baum gab es keinen Datenschutztext. Die Seite des * Herausgebers sagte sinngemaess, die Software uebertrage keine Daten - und * das stimmt nicht: Der Luftbildabruf schickt die Lage des geplanten * Knotenpunkts an einen Kartendienst. * * EINE QUELLE. `docs/datenschutz.md` wird im "Ueber"-Fenster angezeigt und vom * Herausgeber auf seiner Internetseite veroeffentlicht. Zwei Texte liefen * auseinander; deshalb gibt es einen, und die Zahlen und Namen darin sind * hier an den Code gebunden: Rechnernamen und ihre Anzahl, Herausgeber, * Wiederherstellungspunkte, Protokollgrenze, Dateinamen, Menueeintraege und * Verweisadressen. * * DAS FORMAT IST EINE SCHNITTSTELLE. Das Programm stellt die Datei mit einem * eigenen kleinen Leser dar, ohne innerHTML (Grundsatz: kein innerHTML mit * Daten). Er kennt Titel, Abschnitte, Absaetze und einfache Aufzaehlungen * und sonst nichts. Eine Tabelle, ein Verweis in Markdown-Schreibweise oder * Fettdruck erschienen dort als Zeichensalat - und eine spitze Klammer ist * genau das Zeichen, das in einem HTML-Leser zur Gefahr wird. * * WAS HIER NICHT GEPRUEFT WIRD, weil es kein Test pruefen kann: * - die Ablage der Benutzerdaten dort, wo Windows sie je nach * Installationsart gesondert fuehrt, und ihr Verbleib nach der * Deinstallation. Beides ist am 17.09.2026 unter Windows 11 gemessen * (Neukunde und Umsteiger) und steht datiert im Text; hier wird nur * gehalten, dass Datum und die Ausnahme fuer den Umsteiger dort stehen und * dass die Ausnahme nicht ueber Windows 11 hinaus zugesagt wird. * - die Berichte, die ein Vertriebsdienst dem Herausgeber bereitstellen * kann. Der Text nennt sie als Moeglichkeit, ohne Einzelheiten. * - die Anfragekoepfe des Abrufs (Programmkennung "node", kein Cookie). Sie * sind datiert gemessen und stehen so im Text. Ein Test geht nicht ins * Netz, und `fetch` ist in den Abruftests ersetzt. * - dass die Deinstallation aus dem Setup das Benutzerdatenverzeichnis stehen * laesst. Das folgt aus der Vorgabe des Installationsbauers * (`deleteAppDataOnUninstall`, nur fuer den Ein-Klick-Einrichter) und ist * nicht gemessen. */ const TEXT = readFileSync('docs/datenschutz.md', 'utf8'); const ZEILEN = TEXT.split(/\r?\n/); const PAKET = JSON.parse(readFileSync('package.json', 'utf8')) as { name: string; version: string; productName?: string; scripts: Record; devDependencies: Record; build: { extraMetadata?: { productName?: string }; win: { target: (string | { target: string })[] }; }; }; /** Text ohne Zeilenumbrueche - ein Absatz darf ueber mehrere Zeilen laufen. */ const EINZEILIG = TEXT.replace(/\s+/g, ' '); /** Die Zeilen des Abschnitts "## " bis zum naechsten Abschnitt. */ function abschnitt(ueberschrift: string): string[] { const beginn = ZEILEN.indexOf(`## ${ueberschrift}`); expect(beginn, `Abschnitt "${ueberschrift}"`).toBeGreaterThanOrEqual(0); const rest = ZEILEN.slice(beginn + 1); const ende = rest.findIndex((zeile) => zeile.startsWith('## ')); return ende === -1 ? rest : rest.slice(0, ende); } /** * Die Aussagen einer Zeilenfolge: jeder Aufzaehlungspunkt fuer sich und jeder * Absatz als Ganzes, ohne Zeilenumbrueche. Eine Einschraenkung gehoert in * dieselbe Aussage wie die Zusage, die sie einschraenkt - zwei Absaetze * weiter liest sie niemand mit. */ function aussagen(zeilen: readonly string[]): string[] { const ergebnis: string[] = []; let absatz: string[] = []; const schliesse = (): void => { if (absatz.length > 0) ergebnis.push(absatz.join(' ')); absatz = []; }; for (const zeile of zeilen) { if (zeile.startsWith('- ')) { schliesse(); ergebnis.push(zeile.slice(2).trim()); } else if (zeile.trim() === '') { schliesse(); } else { absatz.push(zeile.trim()); } } schliesse(); return ergebnis; } const ZAHLWOERTER: ReadonlyMap = new Map([ ['ein', 1], ['eine', 1], ['einen', 1], ['eins', 1], ['zwei', 2], ['drei', 3], ['vier', 4], ['fünf', 5], ['sechs', 6], ['sieben', 7], ['acht', 8], ['neun', 9], ['zehn', 10], ['elf', 11], ['zwölf', 12], ['dreizehn', 13], ['vierzehn', 14], ['fünfzehn', 15], ]); /** Ziffern (auch mit Tausenderpunkten) oder ein Zahlwort als Zahl; sonst NaN. */ function zahl(wort: string): number { if (/^\d{1,3}(\.\d{3})*$|^\d+$/.test(wort)) return Number(wort.replace(/\./g, '')); return ZAHLWOERTER.get(wort.toLowerCase()) ?? Number.NaN; } /** Erste Fundgruppe eines Musters im einzeiligen Text - der Fall bricht, wenn es fehlt. */ function fundstelle(muster: RegExp, wofuer: string): string { const treffer = muster.exec(EINZEILIG); expect(treffer, `${wofuer}: Satz nicht gefunden (${String(muster)})`).not.toBeNull(); return treffer?.[1] ?? ''; } function quelle(datei: string): ts.SourceFile { return ts.createSourceFile( datei, readFileSync(datei, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS, ); } /** * Alle Zeichenkettenliterale einer Quelldatei. * * Ueber den Syntaxbaum und nicht ueber `toContain`: Eine Beschriftung, die nur * noch in einem Kommentar steht ("hier stand frueher ..."), zaehlt nicht. Von * einer Vorlage mit Platzhaltern zaehlt das Stueck vor dem ersten. */ function zeichenketten(datei: string): ReadonlySet { const gefunden = new Set(); const besuche = (knoten: ts.Node): void => { if (ts.isStringLiteral(knoten) || ts.isNoSubstitutionTemplateLiteral(knoten)) { gefunden.add(knoten.text); } else if (ts.isTemplateExpression(knoten)) { gefunden.add(knoten.head.text.trim()); } ts.forEachChild(knoten, besuche); }; besuche(quelle(datei)); return gefunden; } /** Wert einer Zahlenkonstante `const NAME = 123` einer Quelldatei. */ function zahlenkonstante(datei: string, name: string): number { let wert = Number.NaN; const besuche = (knoten: ts.Node): void => { if ( ts.isVariableDeclaration(knoten) && ts.isIdentifier(knoten.name) && knoten.name.text === name && knoten.initializer !== undefined && ts.isNumericLiteral(knoten.initializer) ) { wert = Number(knoten.initializer.text.replace(/_/g, '')); } ts.forEachChild(knoten, besuche); }; besuche(quelle(datei)); expect(Number.isFinite(wert), `Konstante ${name} in ${datei} nicht gefunden`).toBe(true); return wert; } /** * Die Dateiendungen aller Dateidialoge einer Quelldatei: jedes * `extensions: [...]` als Feldliteral. Ein Bezeichner darin wird ueber eine * `const NAME = '...'` derselben Datei aufgeloest; laesst er sich nicht * aufloesen, steht er mit "?" davor im Ergebnis und bricht den Fall, statt * still zu fehlen. */ function dialogendungen(datei: string): string[] { const baum = quelle(datei); const konstanten = new Map(); const felder: ts.ArrayLiteralExpression[] = []; const besuche = (knoten: ts.Node): void => { if ( ts.isVariableDeclaration(knoten) && ts.isIdentifier(knoten.name) && knoten.initializer !== undefined && ts.isStringLiteral(knoten.initializer) ) { konstanten.set(knoten.name.text, knoten.initializer.text); } if ( ts.isPropertyAssignment(knoten) && ts.isIdentifier(knoten.name) && knoten.name.text === 'extensions' && ts.isArrayLiteralExpression(knoten.initializer) ) { felder.push(knoten.initializer); } ts.forEachChild(knoten, besuche); }; besuche(baum); return felder.flatMap((feld) => feld.elements.map((element) => { if (ts.isStringLiteral(element)) return element.text; if (ts.isIdentifier(element)) return konstanten.get(element.text) ?? `?${element.text}`; return `?${element.getText(baum)}`; }), ); } /** Ein einzelnes Wort, das eine Behauptung dahinter verneint oder zur Frage macht. */ const VERNEINUNGSWORT = /^(?:nicht|kein\w*|weder|ob|ohne|not|no)$/i; /** * Behauptet der Satz, was `pruefwort` sagt? Ja, sobald eine Fundstelle des * Pruefworts ohne Verneinung in den drei Woertern davor steht. Die Funktion * und VERNEINUNGSWORT sind aus einer anderen Pruefdatei wortgleich * abgeschrieben und nicht eingebunden, weil ein Import deren Faelle mitliefe; * eine eigene Pruefung haelt beide Abschriften gleich. Mitgekommen sind damit auch * die englischen Verneinungen und das Wegraeumen von Markdown-Verweisen und * Dateinamen, die in diesem Text nicht vorkommen - das Format verbietet * Verweise (Faelle ganz oben). * * NUR DAVOR UND NUR DREI WOERTER: Vor der Fassung 5.43.0 genuegte * hier eine Verneinung irgendwo im Satz, und "Das Programm erfuellt die * Pflichten der DSGVO und braucht kein Konto." blieb gruen. */ function behauptet(satz: string, pruefwort: RegExp): boolean { const woerter = satz .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/[\w-]+\.md\b/g, '') .split(/\s+/) .filter((wort) => wort !== ''); return woerter.some( (wort, i) => pruefwort.test(wort) && !woerter .slice(Math.max(0, i - 3), i) .some((davor) => VERNEINUNGSWORT.test(davor.replace(/[^\p{L}]/gu, ''))), ); } // --- Netzabrufe im Quelltext --------------------------------------------------- interface Netzstelle { readonly datei: string; readonly zeile: number; readonly art: string; } /** Module, deren Einbinden allein schon einen Weg ins Netz oeffnet. */ const NETZMODULE: ReadonlySet = new Set( ['http', 'https', 'http2', 'net', 'tls', 'dgram', 'undici', 'ws', 'electron-updater'].flatMap( (name) => [name, `node:${name}`], ), ); /** Klassen des Browserkerns, die eine Verbindung aufbauen. */ const NETZKLASSEN: ReadonlySet = new Set([ 'XMLHttpRequest', 'WebSocket', 'EventSource', 'WebTransport', 'RTCPeerConnection', ]); /** Was Electron an Netzwegen mitbringt: Abruf, Absturzberichte, Selbstaktualisierung. */ const ELECTRON_NETZ: ReadonlySet = new Set(['net', 'crashReporter', 'autoUpdater']); /** * Stellen einer Quelldatei, an denen das Programm ins Netz greift. * * Ueber den Syntaxbaum, damit ein Kommentar ("das einzige `fetch` ...") und * eine Zeichenkette nicht mitzaehlen, ein Aufruf in derselben Zeile hinter * einer Adresse mit "//" aber sehr wohl. Gesucht wird, was einen Weg nach * draussen oeffnet: `fetch` und `sendBeacon` als Aufruf, gleich an welchem * Gegenstand; `request`/`get`/`connect` an `net`, `http`, `https`; die * Verbindungsklassen des Browserkerns; das Einbinden von Netzmodulen und der * Netzteile von Electron. */ function netzstellen(datei: string, quelltext: string): Netzstelle[] { const baum = ts.createSourceFile( datei, quelltext, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS, ); const funde: Netzstelle[] = []; const merke = (knoten: ts.Node, art: string): void => { const zeile = baum.getLineAndCharacterOfPosition(knoten.getStart(baum)).line + 1; funde.push({ datei, zeile, art }); }; const besuche = (knoten: ts.Node): void => { if (ts.isCallExpression(knoten)) { const ziel = knoten.expression; const name = ts.isIdentifier(ziel) ? ziel.text : ts.isPropertyAccessExpression(ziel) ? ziel.name.text : ''; if (name === 'fetch' || name === 'sendBeacon') merke(knoten, name); else if ( ts.isPropertyAccessExpression(ziel) && ts.isIdentifier(ziel.expression) && ['net', 'http', 'https', 'http2'].includes(ziel.expression.text) && ['request', 'get', 'connect'].includes(name) ) { merke(knoten, `${ziel.expression.text}.${name}`); } const erstes = knoten.arguments[0]; if ( (name === 'require' || knoten.expression.kind === ts.SyntaxKind.ImportKeyword) && erstes !== undefined && ts.isStringLiteral(erstes) && NETZMODULE.has(erstes.text) ) { merke(knoten, `laedt ${erstes.text}`); } } if ( ts.isNewExpression(knoten) && ts.isIdentifier(knoten.expression) && NETZKLASSEN.has(knoten.expression.text) ) { merke(knoten, knoten.expression.text); } if (ts.isImportDeclaration(knoten) && ts.isStringLiteral(knoten.moduleSpecifier)) { const modul = knoten.moduleSpecifier.text; if (NETZMODULE.has(modul)) merke(knoten, `bindet ${modul} ein`); const benannt = knoten.importClause?.namedBindings; if (modul === 'electron' && benannt !== undefined && ts.isNamedImports(benannt)) { for (const element of benannt.elements) { const original = (element.propertyName ?? element.name).text; if (ELECTRON_NETZ.has(original)) merke(element, `electron.${original}`); } } } if ( ts.isPropertyAccessExpression(knoten) && (knoten.name.text === 'crashReporter' || knoten.name.text === 'autoUpdater') ) { merke(knoten, `.${knoten.name.text}`); } ts.forEachChild(knoten, besuche); }; besuche(baum); return funde; } /** Alle TypeScript-Dateien unter einem Verzeichnis, mit Schraegstrichen. */ function tsDateien(verzeichnis: string): string[] { return readdirSync(verzeichnis).flatMap((name) => { const pfad = path.posix.join(verzeichnis, name); if (statSync(pfad).isDirectory()) return tsDateien(pfad); return name.endsWith('.ts') ? [pfad] : []; }); } /** Quellenangaben einer Richtlinie, die keinen fremden Rechner erreichen. */ const QUELLEN_OHNE_NETZ: ReadonlySet = new Set([ "'self'", "'none'", "'unsafe-inline'", 'data:', 'blob:', ]); /** * Jede Quelle einer Abrufdirektive (`*-src`), die nicht in * `QUELLEN_OHNE_NETZ` steht, als " ". Bewusst eine * Positivliste: Ein Rechnername, `https:`, `*` oder eine kuenftige neue * Schreibweise faellt auf, statt durchzurutschen. */ function fremdeQuellen(richtlinie: string): string[] { return richtlinie .split(';') .map((direktive) => direktive.trim().split(/\s+/)) .flatMap(([name = '', ...quellen]) => name.endsWith('-src') ? quellen.filter((q) => !QUELLEN_OHNE_NETZ.has(q)).map((q) => `${name} ${q}`) : [], ); } /** Die Richtlinie einer Quelldatei: alle Zeichenkettenliterale mit einer `*-src`-Direktive. */ function richtlinieAus(datei: string): string { return [...zeichenketten(datei)].filter((text) => /\b[a-z]+-src\b/.test(text)).join('; '); } /** * Die Windows-Pakete, die gebaut werden: `build.win.target` und die Ziele * hinter `--win` in den Skripten, auch solche ausserhalb von * `build.win.target`. */ function windowsPaketziele(): string[] { const ziele = new Set(); for (const ziel of PAKET.build.win.target) { ziele.add(typeof ziel === 'string' ? ziel : ziel.target); } for (const skript of Object.values(PAKET.scripts)) { for (const treffer of skript.matchAll(/--win((?:\s+[a-z][a-z0-9-]*)+)/g)) { for (const ziel of (treffer[1] ?? '').trim().split(/\s+/)) ziele.add(ziel); } } return [...ziele].sort(); } /** Wie der Text jedes Windows-Paket nennt. Ein neues Ziel ohne Eintrag bricht den Fall. */ const PAKETBEZEICHNUNG: ReadonlyMap = new Map([ ['nsis', 'Programm aus dem Setup'], ['portable', 'tragbaren Programm'], ]); /** * Wie der Text die Dateien nennt, deren Ort der Anwender im Dateidialog * bestimmt - je Endung, die ein Dateidialog im Quelltext anbietet. Eine neue * Endung ohne Eintrag bricht den Fall, ein Eintrag ohne Endung im Quelltext * ebenso. `null` heisst: keine eigene Art Datei. */ const DATEIART: ReadonlyMap = new Map([ // src/services/storage.ts (PROJECT_FILE_FILTERS) und exportView.ts ['lsap', 'Projektdateien'], // dieselbe Projektdatei, im Dialog als "JSON-Datei" waehlbar ['json', 'Projektdateien'], ['pdf', 'Planunterlagen'], // src/ui/views/exportView.ts: vier Plan-Tabellen und der Pruefbericht ['csv', 'Tabellen'], // electron/main.ts, "Fehlerprotokoll speichern" ['txt', 'Kopien des Fehlerprotokolls'], // electron/main.ts, Rueckfall "Alle Dateien", wenn der Renderer keine Filter schickt ['*', null], ]); // --- Faelle -------------------------------------------------------------------- describe('Format: Die Datei laesst sich ohne innerHTML darstellen', () => { it('beginnt mit der Titelzeile und fuehrt keinen zweiten Titel', () => { expect(ZEILEN[0], 'erste Zeile').toMatch(/^# \S/); const titel = ZEILEN.filter((zeile) => zeile.startsWith('# ')); expect(titel, 'Titelzeilen').toHaveLength(1); }); it.each([ [/^\s*\|/, 'eine Tabellenzeile'], [/^#{3,}/, 'eine Ueberschrift ab der dritten Ebene'], [/^#(?!#? )/, 'eine Ueberschrift ohne Leerzeichen'], [/\]\(/, 'einen Verweis in Markdown-Schreibweise'], [/ { const verstoesse = ZEILEN.map((zeile, i) => ({ zeile, nummer: i + 1 })).filter(({ zeile }) => muster.test(zeile), ); expect(verstoesse, `Zeilen mit ${String(muster)}`).toEqual([]); }); }); describe('Die einzige Verbindung ins Internet', () => { it('nennt jeden freigegebenen Rechnernamen als eigenen Eintrag', () => { /* * Als Zeilenanfang und nicht mit `toContain`: "wms.nrw.de" steht auch in * "www.wms.nrw.de", und ein fehlender Eintrag fiele sonst nicht auf. */ for (const name of erlaubteRechner()) { const eintraege = ZEILEN.filter((zeile) => zeile.startsWith(`- ${name}: `)); expect(eintraege, `Eintrag zu ${name}`).toHaveLength(1); } }); it('fuehrt keinen Rechnernamen, der nicht freigegeben ist', () => { const genannt = ZEILEN.map((zeile) => /^- ([a-z0-9.-]+\.[a-z]{2,}): /.exec(zeile)?.[1]) .filter((name): name is string => name !== undefined) .sort(); expect(genannt).toEqual([...erlaubteRechner()].sort()); }); it('nennt die Zahl der Rechnernamen so, wie die Freigabeliste lang ist', () => { const wort = fundstelle(/Liste von (\S+) Rechnernamen/, 'Anzahl der Rechnernamen'); expect(zahl(wort), `"${wort}" Rechnernamen`).toBe(erlaubteRechner().length); }); it('nennt zu jedem eingetragenen Dienst den Herausgeber, den das Programm fuehrt', () => { for (const dienst of KARTENDIENSTE) { const rechner = new URL(dienst.basisUrl).hostname; const eintrag = ZEILEN.find((zeile) => zeile.startsWith(`- ${rechner}: `)) ?? ''; expect(eintrag, `Herausgeber zu ${rechner}`).toContain(dienst.herausgeber); } }); it('sagt "derzeit keine Anfrage" genau bei den Namen, zu denen es keinen Dienst gibt', () => { const angefragt = new Set(rechnernamen()); for (const name of erlaubteRechner()) { const eintrag = ZEILEN.find((zeile) => zeile.startsWith(`- ${name}: `)) ?? ''; expect(eintrag.includes('derzeit keine Anfrage'), `Eintrag zu ${name}`).toBe( !angefragt.has(name), ); } }); it('findet der Zaehler Netzabrufe und uebergeht Kommentare und Zeichenketten', () => { // Ohne diesen Fall koennte der Fall darunter gruen sein, weil der Zaehler // gar nichts findet - auch den einen Abruf in electron/karte.ts nicht. const probe = [ '// fetch(im Kommentar)', '/* new WebSocket("im Kommentar") */', "const nurText = 'fetch(adresse)';", "const adresse = 'https://beispiel.invalid/'; await fetch(adresse);", 'globalThis.fetch(adresse);', 'navigator.sendBeacon("/x");', 'new XMLHttpRequest();', "import { request } from 'node:https';", "import { net as netz, shell } from 'electron';", 'net.request("x");', 'bruecke.fetchMapImage({ url: adresse });', ].join('\n'); expect(netzstellen('probe.ts', probe).map((stelle) => `${stelle.zeile} ${stelle.art}`)).toEqual( [ '4 fetch', '5 fetch', '6 sendBeacon', '7 XMLHttpRequest', '8 bindet node:https ein', '9 electron.net', '10 net.request', ], ); }); it('steht im eigenen Quelltext genau ein Netzabruf, und zwar in electron/karte.ts', () => { /* * Der eigene Code steht in `electron/`, `src/` und `shared/`. `tools/` und * `scripts/` erzeugen Dateien am Arbeitsplatz und gehen nicht ins Paket. * Ausgeliefert wird daneben fremder Code, den Vite in die Oberflaeche * buendelt - der zaehlt hier nicht mit, und deshalb sagt der Text "der * eigene Programmcode" (Fall darunter). */ expect(EINZEILIG, 'die Zusage im Text').toContain( 'Der eigene Programmcode enthält genau einen Netzabruf', ); const funde = ['electron', 'src', 'shared'] .flatMap(tsDateien) .flatMap((datei) => netzstellen(datei, readFileSync(datei, 'utf8'))); expect(funde.map((stelle) => `${stelle.datei} ${stelle.art}`)).toEqual([ 'electron/karte.ts fetch', ]); }); it('nennt die Abrufbefehle der mitgebuendelten Bibliotheken und haelt die Richtlinie dagegen', () => { /* * Pruefbefund zu 5.43.0: "Der Programmcode enthaelt genau einen Netzabruf" * stimmte nur fuer den eigenen Quelltext. Im app.asar stecken jsPDF und * seine Hilfsbibliotheken mit `XMLHttpRequest` und `fetch`, dazu die * Ladehilfe von Vite. Hinaus kommen sie nicht, weil die * Inhaltssicherheitsrichtlinie der Oberflaeche keine fremde Quelle * zulaesst - und genau das muss der Text sagen und der Code halten. * * Die Richtlinie wird an beiden wirksamen Stellen gelesen (Meta-Element * aus vite.config.ts, Antwortkopf aus electron/main.ts); dass beide * dasselbe `connect-src` fuehren, bewacht * tests/domain/sicherheitsrichtlinie.test.ts. Der Hauptprozess * unterliegt ihr nicht, buendelt aber auch nichts Fremdes: Das Paket hat * keine `dependencies`, und `electron/` wird nur uebersetzt. */ const absatz = aussagen(ZEILEN).find((aussage) => aussage.includes('genau einen Netzabruf')); expect(absatz, 'Absatz mit der Zusage').toBeDefined(); for (const teil of ['Bibliotheken', 'jsPDF', 'Abrufbefehle', 'Sicherheitsrichtlinie']) { expect(absatz, `im selben Absatz: ${teil}`).toContain(teil); } // Die genannte Bibliothek wird gebuendelt und enthaelt wirklich Abrufbefehle. expect(PAKET.devDependencies, 'jspdf in package.json').toHaveProperty('jspdf'); const jspdf = JSON.parse(readFileSync('node_modules/jspdf/package.json', 'utf8')) as { browser: string; }; const buendel = path.posix.join('node_modules/jspdf', jspdf.browser); expect(netzstellen(buendel, readFileSync(buendel, 'utf8')).length, buendel).toBeGreaterThan(0); // Der Leser der Richtlinie erkennt eine fremde Quelle ... expect( fremdeQuellen("default-src 'none'; connect-src 'self' https://a.example; img-src *"), ).toEqual(['connect-src https://a.example', 'img-src *']); // ... und an beiden wirksamen Stellen steht keine. for (const datei of ['vite.config.ts', 'electron/main.ts']) { const richtlinie = richtlinieAus(datei); expect(richtlinie, `${datei}: default-src`).toContain("default-src 'none'"); expect(richtlinie, `${datei}: connect-src`).toContain('connect-src '); expect(fremdeQuellen(richtlinie), `${datei}: fremde Quellen`).toEqual([]); } }); }); describe('Was auf dem Rechner liegt - Zahlen und Namen wie im Code', () => { it('nennt die Zahl der Wiederherstellungspunkte, die der Sitzungsspeicher fuehrt', () => { const wort = fundstelle(/bis zu (\S+) Wiederherstellungspunkte/, 'Wiederherstellungspunkte'); expect(zahl(wort), `bis zu "${wort}"`).toBe( zahlenkonstante('src/services/storage.ts', 'RECOVERY_SLOTS'), ); }); it('nennt die Groessengrenze, ab der das Fehlerprotokoll abgeloest wird', () => { const wort = fundstelle(/Hat die Datei (\S+) Byte erreicht/, 'Protokollgrenze'); expect(zahl(wort), `"${wort}" Byte`).toBe( zahlenkonstante('electron/protokoll.ts', 'MAX_BYTES'), ); expect(readFileSync('electron/protokoll.ts', 'utf8'), 'Endung der abgeloesten Datei').toContain( '.alt', ); expect(EINZEILIG).toContain('fehlerprotokoll.log.alt'); }); it('nennt die Zahl der Stapelzeilen, die das Protokoll uebernimmt', () => { const wort = fundstelle(/bis zu (\S+) Zeilen des Aufrufstapels/, 'Stapelzeilen'); const code = /\.split\([^)]*\)\s*\.slice\(0,\s*(\d+)\)/.exec( readFileSync('electron/protokoll.ts', 'utf8'), ); expect(code, 'Kuerzung des Stapels in electron/protokoll.ts').not.toBeNull(); expect(zahl(wort), `bis zu "${wort}" Zeilen`).toBe(Number(code?.[1])); }); it('nennt die Dateinamen, die das Programm anlegt', () => { expect(zeichenketten('electron/protokoll.ts'), 'Name der Protokolldatei').toContain( 'fehlerprotokoll.log', ); expect(EINZEILIG).toContain('fehlerprotokoll.log'); expect(EINZEILIG).toContain(ANSICHT_DATEINAME); expect(zeichenketten('src/services/storage.ts'), 'Name der Datenbank').toContain( 'lsa-planer.v5', ); expect(EINZEILIG).toContain('Datenbank lsa-planer.v5'); }); it('nennt das Benutzerdatenverzeichnis so, wie Electron es aus package.json bildet', () => { /* * `app.getName()` nimmt `productName` und sonst `name` aus der * package.json im Paket. Am gebauten Stand nachgesehen (17.09.2026): * Die package.json in `app.asar` traegt kein `productName`. Wer eines * nachtraegt, verlegt die Daten aller Anwender - und diese Zeile im Text. */ expect(PAKET.productName, 'productName in package.json').toBeUndefined(); expect(PAKET.build.extraMetadata?.productName, 'productName in extraMetadata').toBeUndefined(); expect(EINZEILIG).toContain(`%APPDATA%\\${PAKET.name}`); }); it('nennt jede Art Datei, deren Ort ein Dateidialog bestimmt, beim Ablegen und beim Loeschen', () => { /* * BEFUND (Fassung 5.43.0): Die Liste "Was das Programm auf Ihrem Rechner * ablegt" nannte am selbst bestimmten Ort nur Projektdateien und * Planunterlagen, und der Satz darunter legte alles andere ins * Benutzerdatenverzeichnis. Die Ansicht Ausgabe schreibt aber auch * CSV-Tabellen dorthin, wo der Anwender es bestimmt, darunter den * Pruefbericht, der keine Planunterlage ist (exportView.ts), und das Menue * Hilfe eine Kopie des Fehlerprotokolls. * * Gelesen werden die Endungen aller Dateidialoge und nicht nur die der * Speicherdialoge: Beide Arten teilen sich Filterlisten * (PROJECT_FILE_FILTERS), und eine Endung, die nur geoeffnet wird, bekommt * ihren Eintrag in DATEIART mit `null` und einem Grund. */ const endungen = [ ...new Set(['electron', 'src', 'shared'].flatMap(tsDateien).flatMap(dialogendungen)), ].sort(); expect(endungen, 'Endungen der Dateidialoge').toEqual([...DATEIART.keys()].sort()); const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( (aussage) => aussage.includes('Speicherdialog'), ); expect(ablage, 'Aussage zu den Dateien am selbst bestimmten Ort').toHaveLength(1); const loeschen = aussagen(abschnitt('Was Sie selbst in der Hand haben')).filter((aussage) => aussage.includes('wie jede andere Datei'), ); expect(loeschen, 'Aussage zum Loeschen dieser Dateien').toHaveLength(1); for (const [endung, art] of DATEIART) { if (art === null) continue; expect(ablage[0], `.${endung}: ${art} am selbst bestimmten Ort`).toContain(art); expect(loeschen[0], `.${endung}: ${art} beim Loeschen`).toContain(art); } expect(EINZEILIG, 'was ausserhalb des Benutzerdatenverzeichnisses liegt').not.toContain( 'Außer Projektdateien und Planunterlagen', ); }); it('nennt zu einer Art Datei jede Endung, die ein Dialog anbietet, sobald es Endungen nennt', () => { /* * BEFUND (Fassung 5.43.0): "Projektdateien (Endung .lsap)" stand da, * waehrend der Speicherdialog fuer Projekte auch "JSON-Datei" anbietet * (PROJECT_FILE_FILTERS in src/services/storage.ts) und eine Projektdatei * mit Bearbeiter und Auftraggeber damit als .json entstehen kann. Der Fall * darueber prueft je Endung nur die Art und blieb gruen. * * Geprueft wird die Klammer hinter der Art, und nur, wenn sie eine Endung * mit Punkt nennt: "Planunterlagen (PDF)" nennt keine, und eine Art ganz * ohne Endungsangabe ist nicht falsch, nur knapper. */ const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( (aussage) => aussage.includes('Speicherdialog'), ); expect(ablage, 'Aussage zu den Dateien am selbst bestimmten Ort').toHaveLength(1); const endungenJeArt = new Map(); for (const [endung, art] of DATEIART) { if (art !== null) endungenJeArt.set(art, [...(endungenJeArt.get(art) ?? []), endung]); } const geprueft: string[] = []; for (const [art, endungenDerArt] of endungenJeArt) { const maskiert = art.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const klammer = new RegExp(`${maskiert} \\(([^)]*)\\)`).exec(ablage[0] ?? '')?.[1] ?? ''; if (!/\.[a-z]+\b/.test(klammer)) continue; geprueft.push(art); for (const endung of endungenDerArt) { expect(klammer, `${art}: Endung .${endung}`).toMatch(new RegExp(`\\.${endung}\\b`)); } } /* * Anker gegen einen leeren Durchlauf, am heutigen Text: Die Projektdateien * nennen ihre Endungen. Faellt die Angabe dort bewusst weg, geht der Anker * mit fort. */ expect(geprueft, 'Arten mit Endungsangabe').toContain('Projektdateien'); }); it.each(windowsPaketziele())( 'sagt fuer das Paketziel "%s", wo die Daten liegen und wie sie wieder fortkommen', (ziel) => { /* * Pruefbefund zu 5.43.0: Der Text kannte das Setup, aber das tragbare * Programm fehlte, obwohl es ausgeliefert wird und seine * Daten ebenso unter %APPDATA% ablegt (kein `setPath('userData')` in * electron/) - wer die Programmdatei loescht, behaelt sie. * * Mit "beim" davor: Eine blosse Nennung in einer Ausnahme ("Dateien, * die schon vorher vom tragbaren Programm dort lagen") sagt nichts * darueber, wo dessen eigene Daten liegen. Ohne das "beim" blieb der * Fall gruen, als die eigentliche Aussage gestrichen war. */ const bezeichnung = PAKETBEZEICHNUNG.get(ziel); expect(bezeichnung, `Paketziel ${ziel}: keine Aussage im Text vorgesehen`).toBeDefined(); const aussageDazu = new RegExp(`\\b[Bb]eim ${bezeichnung ?? ''}\\b`); const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).join(' '); expect(ablage, 'Abschnitt zum Ablageort').toMatch(aussageDazu); const entfernen = aussagen(abschnitt('Was Sie selbst in der Hand haben')).filter((aussage) => aussage.includes('Benutzerdatenverzeichnis'), ); expect(entfernen, 'Aussage zum Entfernen der Daten').toHaveLength(1); expect(entfernen[0], 'Aussage zum Entfernen der Daten').toMatch(aussageDazu); }, ); it('sagt das Entfernen bei der Deinstallation nur mit der Ausnahme vorher angelegter Ordner zu', () => { /* * Pruefbefund zu 5.43.0: Der Abschnitt zum Ablageort schraenkte ein - * Dateien, die schon vorher unter AppData lagen, leitet Windows nicht um * und entfernt sie nicht -, die Liste "Was Sie selbst in der Hand haben" * sagte es ohne Einschraenkung zu. Jede Installationsart nutzt denselben * Ordnernamen; der Wechsel ist genau der Fall. Jede Aussage, die beides * nennt, traegt deshalb die Ausnahme. * * Gemessen am 17.09.2026 unter Windows 11: Ohne vorhandenen Ordner fuehrt * Windows die Daten gesondert, und die Deinstallation entfernt sie; mit * vorhandenem Ordner landen auch NEUE Dateien im echten Ordner, und der * bleibt. Die Aussage zum Ablageort traegt deshalb das Datum. Erkannt wird * sie an "Installationsart". */ const zusagen = aussagen(ZEILEN).filter( (aussage) => aussage.includes('Installationsart') && aussage.includes('Deinstallation'), ); expect(zusagen.length, 'Aussagen zur Deinstallation je nach Installationsart').toBeGreaterThan( 0, ); for (const aussage of zusagen) { expect(aussage, 'Ausnahme der vorher angelegten Ordner').toMatch(/\bvorher\b/); } const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).filter( (aussage) => aussage.includes('Installationsart') && aussage.includes('Deinstallation'), ); expect(ablage, 'Aussage zum gesonderten Ablageort').toHaveLength(1); expect(ablage[0], 'Messdatum').toContain('17.09.2026'); }); it('sagt die Ausnahme fuer Umsteiger nur fuer das Gemessene zu, Windows 11', () => { /* * BEFUND (Fassung 5.43.0): "Dann benutzt das Paket diesen Ordner weiter * ... teilt sich mit dem anderen Programm Sitzungsspeicher und * Einstellungen" stand ohne Einschraenkung da; Windows 11 hiess nur der * Ort, an dem nachgesehen wurde. Das Programm laeuft aber ab Windows 10 * Version 1809 (Systemvoraussetzung in LIESMICH.txt), und fuer Windows 10 * vor Version 1903 beschreibt Microsoft, dass ein Paket in diesem Fall nur * in eine eigene Kopie schreibt. LIESMICH.txt war schon so eingegrenzt * (tests/tools/liesmich.test.ts, Fall zu den Umsteigern), dieser Text * nicht - und beide gehen mit demselben Paket hinaus. */ const ablage = aussagen(abschnitt('Was das Programm auf Ihrem Rechner ablegt')).join(' '); const ausnahme = ablage.split(/(?<=[.!?])\s+/u).filter((satz) => satz.includes('vorher schon')); expect(ausnahme, 'Satz zur Ausnahme mit "vorher schon"').toHaveLength(1); expect(ausnahme[0], 'wofuer die Ausnahme gilt').toContain('Windows 11'); expect(ablage, 'was unter Windows 10 nicht nachgesehen ist').toContain( 'unter Windows 10 nicht', ); expect(ablage, 'was Microsoft fuer aeltere Fassungen beschreibt').toContain( 'Windows 10 vor Version 1903', ); }); }); describe('Bedienwege und Verweise, die der Text nennt', () => { /* * Der Text nennt nur Bedienwege, die es als Beschriftung gibt; "Fehlerprotokoll * speichern …" kam mit 5.43.0 in electron/menu.ts hinzu. */ it.each([ ['Fehlerprotokoll speichern …', 'electron/menu.ts'], ['Internetseite des Herstellers', 'electron/menu.ts'], ['Unterstützen', 'electron/menu.ts'], ['Quellcode öffnen', 'src/ui/ueber.ts'], ['Unterstützen', 'src/ui/ueber.ts'], ['Über LSA-Planer Professional', 'src/ui/ueber.ts'], ['Amtliches Luftbild holen', 'src/ui/views/lageplanView.ts'], ['Amtliches Luftbild', 'src/ui/views/lageplanView.ts'], ['Amtliches Luftbild laden', 'src/ui/lageplan/luftbild.ts'], ['Ausschnitt holen', 'src/ui/lageplan/luftbild.ts'], ['Kartengrundlage', 'src/ui/lageplan/luftbild.ts'], ['Bei Störungen', 'src/main.ts'], ])('fuehrt "%s" in %s', (beschriftung, datei) => { expect(EINZEILIG, 'Nennung im Text').toContain(`„${beschriftung}“`); expect(zeichenketten(datei), `Beschriftung in ${datei}`).toContain(beschriftung); }); it('nennt jede Adresse, die ein Verweis oeffnen kann', () => { const imMenue = [...zeichenketten('electron/menu.ts')].filter((text) => text.startsWith('https://'), ); expect(imMenue.length, 'Adressen im Menue').toBeGreaterThan(0); for (const adresse of [...Object.values(VERWEISADRESSEN), ...imMenue]) { expect(EINZEILIG, `Adresse ${adresse}`).toContain(adresse); } }); }); describe('Keine Zusage, die der Text nicht belegen kann', () => { it('behauptet keine Erfuellung und keine Rechtskonformitaet', () => { /* * Die Datei beschreibt Tatsachen des Programms. Ob damit Pflichten * erfuellt sind, ist eine Rechtsfrage - dieselbe Haltung wie in den * uebrigen Unterlagen des Projekts. */ const saetze = EINZEILIG.split(/(?<=[.;:])\s/); expect( saetze.filter((satz) => behauptet(satz, /erfüll|erfuell/i)), 'Saetze behaupten Erfuellung', ).toEqual([]); expect(EINZEILIG).not.toMatch(/konform|gewährleist|garantier|rechtssicher/i); }); it('erkennt eine Erfuellungsbehauptung auch neben einer Verneinung an anderer Stelle', () => { /* * Ohne diesen Fall waere der Fall darueber auch mit einer Pruefung gruen, * die gar nichts erkennt - docs/datenschutz.md enthaelt heute keinen Satz * mit "erfuell", die Schleife laeuft ueber null Saetze. Ein Anker "mindestens * ein solcher Satz" waere dagegen falsch: Er verlangte, dass der Text einen * aufnimmt. */ const pruefwort = /erfüll|erfuell/i; expect( behauptet('Das Programm erfüllt die Pflichten der DSGVO und braucht kein Konto.', pruefwort), 'Verneinung hinter der Behauptung', ).toBe(true); expect( behauptet( 'Das Programm erfüllt die Pflichten der DSGVO, nicht nur die des TTDSG.', pruefwort, ), 'Verneinung eines anderen Satzteils', ).toBe(true); expect( behauptet('Ob damit Pflichten erfüllt sind, ist eine Rechtsfrage.', pruefwort), 'Frage', ).toBe(false); expect( behauptet('Diese Seite behauptet nicht, dass Pflichten erfüllt sind.', pruefwort), 'Verneinung davor', ).toBe(false); }); it('verschweigt in der Kurzfassung nicht, dass ein Vertriebsdienst Absturzberichte bereitstellen kann', () => { /* * Pruefbefund zu 5.43.0: "schickt keine Absturzberichte an den * Herausgeber" stimmte fuer das Programm, aber wer nur die Kurzfassung * las, schloss, dass keine Absturzdaten beim Herausgeber ankommen. Beim * Bezug ueber einen Vertriebsdienst koennen sie es - ueber den Dienst, * weiter unten im Text. Wo die Kurzfassung von Absturzberichten spricht, * nennt sie den Vertriebsdienst mit. */ const dienst = aussagen(abschnitt('Bezug über einen Vertriebsdienst')).join(' '); expect(dienst, 'Voraussetzung: der Abschnitt nennt Abstuerze').toContain('Abstürze'); const kurz = aussagen(abschnitt('Das Wichtigste in Kürze')).filter((aussage) => aussage.includes('Absturz'), ); expect(kurz.length, 'Absturzberichte in der Kurzfassung').toBeGreaterThan(0); for (const aussage of kurz) { expect(aussage, 'Absturzberichte in der Kurzfassung').toContain('Vertriebsdienst'); } }); it('datiert den Stand auf die Fassung aus package.json', () => { /* * Die Fassungsnummer steht einmal - in package.json. Der Text sagt, fuer * welche Fassung er gilt, und muss mit ihr gehen; sonst beschreibt er ein * Programm, das der Anwender nicht vor sich hat. */ expect(ZEILEN, 'Zeile "Stand: Fassung ..."').toContain(`Stand: Fassung ${PAKET.version}`); }); });