import { describe, test, expect } from "vitest"; import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; /** * Haelt die Kubernetes-Manifeste und den Code in Deckung. * * Die Manifeste unter k8s/ sind ausdruecklich dafuer gedacht, mit moeglichst * wenig Anpassung als Produktivkonfiguration uebernommen zu werden. Das * traegt nur, solange sie VOLLSTAENDIG sind. Ohne diese Pruefung faellt eine * im Code neu hinzugekommene Umgebungsvariable erst im Betrieb auf, und zwar * als stiller Rueckfall auf einen Standardwert statt als Fehler: zur Laufzeit * ist eine fehlende Variable nicht von einer absichtlich leeren zu * unterscheiden. * * Bewusst KEIN YAML-Parser: das Projekt hat keinen als Abhaengigkeit, und * fuer diesen Zweck genuegt es, die Schluesselzeilen der flachen * data:/stringData:-Bloecke zu lesen. Die Manifeste sind von Hand gepflegt * und halten dieses einfache Format ein. */ const REPO_ROOT = process.cwd(); const CONFIGMAP = path.join(REPO_ROOT, "k8s", "01-configmap.yaml"); const SECRET = path.join(REPO_ROOT, "k8s", "02-secret.example.yaml"); const SRC = path.join(REPO_ROOT, "src"); /** * Schluessel eines Manifests: genau zwei Leerzeichen eingerueckt, beginnend * mit einem Grossbuchstaben. Kommentarzeilen (" # ...") und Metadatenfelder * (" name: ...") fallen dadurch heraus, ohne Sonderfaelle. */ function manifestKeys(file: string): string[] { return readFileSync(file, "utf8") .split("\n") .flatMap((line) => { const match = /^ {2}([A-Z][A-Z0-9_]*):/.exec(line); return match ? [match[1]] : []; }); } /** Alle process.env-Zugriffe unterhalb von src/ — das ist die Laufzeit der App. */ function envNamesUsedInCode(dir: string): Set { const found = new Set(); for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { for (const name of envNamesUsedInCode(full)) found.add(name); continue; } if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith(".test.ts")) continue; for (const match of readFileSync(full, "utf8").matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) { found.add(match[1]); } } return found; } /** * Schluessel, die die App zwar liest, aber nicht ueber ein woertliches * `process.env.NAME` — deshalb findet sie die Suche unten nicht. * * lib/vat.ts bekommt die Umgebung als Parameter uebergeben, damit sich die * Steuersaetze ohne globalen Zustand testen lassen, und greift auf die * Kategorie-Saetze ueber einen berechneten Schluessel zu. Beides ist Absicht; * eine Regex kann es nicht sehen. */ const READ_INDIRECTLY: Record = { VAT_RATE_PERCENT: "lib/vat.ts — als Parameter uebergebene Umgebung (vatRateForCategory).", VAT_RATE_PERCENT_HONIG: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", VAT_RATE_PERCENT_KERZEN: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", VAT_RATE_PERCENT_PROPOLIS: "lib/vat.ts — berechneter Schluessel in CATEGORY_ENV_NAMES.", }; /** * Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie * liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst * liest — sonst sammelt sich Konfiguration an, die nichts mehr tut. */ const USED_OUTSIDE_THE_APP: Record = { DB_PASSWORD: "Datenbank-StatefulSet (03-postgres.yaml/03-mysql.yaml) und Sicherung (07-backup-cronjob.yaml); die App kennt nur DATABASE_URL.", ADMIN_EMAIL: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.", ADMIN_PASSWORD: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.", }; describe("Kubernetes-Manifeste und Code", () => { test("die Manifeste liefern jede Variable, die die App liest", () => { // Arrange const provided = new Set([...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]); // Act const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name)).sort(); // Assert expect(missing, `In k8s/ fehlen: ${missing.join(", ")}`).toEqual([]); }); test("kein Schluessel steht doppelt in ConfigMap und Secret", () => { // Arrange const config = manifestKeys(CONFIGMAP); const secret = new Set(manifestKeys(SECRET)); // Act const inBoth = config.filter((name) => secret.has(name)).sort(); // Assert: stuenden sie in beiden, entschiede allein die Reihenfolge der // envFrom-Eintraege, welcher Wert gewinnt — eine Falle beim Suchen. expect(inBoth, `Doppelt vergeben: ${inBoth.join(", ")}`).toEqual([]); }); test("kein Schluessel in den Manifesten, den niemand liest", () => { // Arrange const used = envNamesUsedInCode(SRC); // Act const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)] .filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP) && !(name in READ_INDIRECTLY)) .sort(); // Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus, // als liesse sich damit etwas einstellen. expect(unused, `Wird nirgends gelesen: ${unused.join(", ")}`).toEqual([]); }); test("die Secret-Vorlage enthaelt keine ausgefuellten Werte", () => { // Arrange const secret = readFileSync(SECRET, "utf8"); // Act const filledIn = secret .split("\n") .flatMap((line) => { const match = /^ {2}([A-Z][A-Z0-9_]*): "(.*)"$/.exec(line); if (!match) return []; const [, key, value] = match; const isPlaceholder = value === "" || value.includes("CHANGE-ME"); return isPlaceholder ? [] : [key]; }) .sort(); // Assert: die Vorlage wird committet — ein echter Wert waere damit im // Repository. Platzhalter sind leer oder tragen "CHANGE-ME". expect(filledIn, `Sieht nach echten Werten aus: ${filledIn.join(", ")}`).toEqual([]); }); });