diff --git a/.env.example b/.env.example index 302a60e..ed601f0 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,13 @@ MYSQL_PASSWORD=change-me MYSQL_DATABASE=wabenhain # Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys) +# +# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig +# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der +# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel +# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements — +# solange es die nicht gibt, waere er Konfiguration, die nichts tut. STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx -STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx # Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx diff --git a/README.md b/README.md index 6eb06b8..07eaad3 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge ## Stripe -1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`). +1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet. 2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen. 3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden. diff --git a/docker-compose.yml b/docker-compose.yml index 7c86299..4d104b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,8 +104,10 @@ services: # MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306). DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql} DATABASE_URL: ${DATABASE_URL} + # Nur der geheime Schluessel: der Checkout laeuft vollstaendig + # serverseitig, ein veroeffentlichbarer pk_-Schluessel wird nirgends + # gelesen (siehe .env.example). STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} - STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} # Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an, # siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 6910dfd..4486b6c 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -1,46 +1,88 @@ -# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Keys) gehoeren -# in 02-secret.yaml (aus 02-secret.example.yaml kopiert), NIE hierher. +# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Schluessel) +# gehoeren in 02-secret.yaml, NIE hierher: eine ConfigMap ist unverschluesselt +# und in der Cluster-Oberflaeche im Klartext lesbar. +# +# ANPASSEN vor dem ersten Ausrollen: NEXT_PUBLIC_SITE_URL und SMTP_FROM. Alle +# uebrigen Werte sind sinnvolle Vorgaben und koennen so bleiben; die leeren +# ("") gehoeren zu optionalen Funktionen, die dann einfach ruhen. +# +# Zusammen mit 02-secret.yaml deckt diese Datei JEDE Umgebungsvariable ab, die +# die Anwendung liest. Die Vollstaendigkeit wird automatisch geprueft, siehe +# src/lib/k8s-manifest-env.test.ts — kommt im Code eine neue Variable dazu, +# faellt das beim naechsten "pnpm test" auf, nicht erst im Betrieb. apiVersion: v1 kind: ConfigMap metadata: name: wabenhain-config namespace: wabenhain data: - # "postgresql" oder "mysql" — muss zum Service passen, den du unter - # 03-postgres.yaml/03-mysql.yaml tatsaechlich ausrollst, UND zum - # DATABASE_URL-Host in 02-secret.yaml. Ein einzelnes Image traegt beide + # --------------------------------------------------------------------- + # ANPASSEN + # --------------------------------------------------------------------- + + # Oeffentliche Adresse des Shops. Einzige Quelle fuer Sitemap, + # Canonical-Tags, JSON-LD und JEDEN Link in JEDER E-Mail (src/lib/url.ts). + # Muss zum "host" in 06-ingress.yaml passen — mit https, ohne + # abschliessenden Schraegstrich. + NEXT_PUBLIC_SITE_URL: "https://wabenhain.com" + + # Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen + # Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner. + SMTP_FROM: "Wabenhain " + + # --------------------------------------------------------------------- + # VORGABEN — koennen so bleiben + # --------------------------------------------------------------------- + + NODE_ENV: "production" + + # "postgresql" oder "mysql" — muss zu dem Datenbank-Manifest passen, das du + # tatsaechlich ausrollst (03-postgres.yaml bzw. 03-mysql.yaml), UND zum + # Schema in DATABASE_URL im Secret. Ein einzelnes Image traegt beide # Prisma-Client-Varianten, siehe src/lib/prisma.ts. DATABASE_PROVIDER: "postgresql" - NODE_ENV: "production" - # Oeffentliche URL des Shops (fuer absolute Links in E-Mails/Sitemaps/CSP). - # Muss zum "host" in 06-ingress.yaml passen, sonst zeigen Sitemap, - # Canonical-Tags und jeder Link in jeder Bestellbestaetigung ins Leere. - NEXT_PUBLIC_SITE_URL: "https://wabenhain.com" + + # Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere + # implizites TLS. SMTP_PORT: "587" - SMTP_FROM: "Wabenhain " - # Optional, siehe README-Abschnitte "Google-Rezensionen"/"CAPTCHA" — leer - # lassen, wenn nicht genutzt, die App faellt automatisch zurueck. - # - # Nur die Place-ID steht hier: sie ist oeffentlich, sie steckt in jeder - # Google-Maps-Adresse des Eintrags. Der zugehoerige GOOGLE_PLACES_API_KEY - # ist dagegen ein abrechenbarer Zugangsschluessel und liegt im Secret — - # eine ConfigMap ist unverschluesselt und in der Cluster-Oberflaeche im - # Klartext lesbar. - GOOGLE_PLACE_ID: "" - NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" - # Adresse der ApiCore-API fuer den Stammdaten-Sync (Ernten, Produkte, - # Chargen — siehe src/lib/apicore-client.ts). Nur die URL; die Zugangsdaten - # des Sync-Kontos liegen im Secret. - APICORE_API_URL: "" - # Threads fuer das Umrechnen hochgeladener Videos. Nicht gesetzt bedeutet 2. - # NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit dieses Pods - # mit dem Node-Prozess, und jeder zusaetzliche Thread haelt eigene - # Bildpuffer (bei 4K rund 12 MB je Bild). Siehe src/lib/video-transcode.ts. - VIDEO_TRANSCODE_THREADS: "2" - # "sandbox" bis DHL-Versandmarken getestet sind, danach "production". - DHL_ENVIRONMENT: "sandbox" - # Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos, - # siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des - # "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst - # weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example. + + # Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath + # des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App + # weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad. + # Fehlt der Wert, schreibt die App nach "./uploads" relativ zum + # Arbeitsverzeichnis: das trifft im Image zufaellig denselben Ort, ist aber + # nirgends zugesichert. Deshalb hier ausdruecklich setzen. UPLOAD_DIR: "/app/uploads" + + # Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte -> + # Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit + # dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt + # eigene Bildpuffer — bei 4K-Material rund 12 MB je Bild. Zu viele Threads + # bringen den Pod beim Video-Upload um. Siehe src/lib/video-transcode.ts. + VIDEO_TRANSCODE_THREADS: "2" + + # "sandbox", bis die DHL-Versandmarken getestet sind, danach "production". + # Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind. + DHL_ENVIRONMENT: "sandbox" + + # --------------------------------------------------------------------- + # OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird + # --------------------------------------------------------------------- + + # Oeffentlicher Teil von Cloudflare Turnstile (CAPTCHA). Der geheime + # Gegenpart TURNSTILE_SECRET_KEY steht im Secret — beide setzen oder beide + # leer lassen. Leer = kein CAPTCHA-Widget, die Formulare bleiben nutzbar. + NEXT_PUBLIC_TURNSTILE_SITE_KEY: "" + + # Google-Eintrag, aus dem die echten Rezensionen auf der Startseite kommen. + # Die Place-ID ist oeffentlich (sie steckt in jeder Google-Maps-Adresse des + # Eintrags) und darf deshalb hier stehen; der abrechenbare + # GOOGLE_PLACES_API_KEY liegt im Secret. Leer = statische Beispiel-Stimmen. + GOOGLE_PLACE_ID: "" + + # Adresse der ApiCore-API fuer den Stammdaten-Sync (Ernten, Produkte, + # Chargen — siehe src/lib/apicore-client.ts), mit dem globalen Praefix + # "api", z.B. "https://apicore.example.org/api". Nur die Adresse; die + # Zugangsdaten des Sync-Kontos liegen im Secret. Leer = der Sync-Knopf im + # Admin meldet einen Konfigurationsfehler. + APICORE_API_URL: "" diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 016fb1e..27d42e3 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -1,19 +1,17 @@ -# Vorlage OHNE echte Geheimnisse — analog zu .env.example. Kopieren nach -# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), Werte -# ausfuellen, dann "kubectl apply -f k8s/02-secret.yaml". +# Geheime Konfiguration. Vorlage OHNE echte Werte — kopieren nach +# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), ausfuellen, +# dann "kubectl apply -f k8s/02-secret.yaml". # -# Alternative ohne Datei mit Klartext-Secrets auf der Platte: -# kubectl create secret generic wabenhain-secrets -n wabenhain \ -# --from-literal=DATABASE_URL='postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public' \ -# --from-literal=DB_PASSWORD='CHANGE-ME' \ -# --from-literal=SESSION_SECRET="$(openssl rand -base64 32)" \ -# --from-literal=ADMIN_EMAIL='change-me@example.com' \ -# --from-literal=ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | head -c 28)" \ -# --from-literal=STRIPE_SECRET_KEY='' \ -# --from-literal=STRIPE_PUBLISHABLE_KEY='' \ -# --from-literal=STRIPE_WEBHOOK_SECRET='' \ -# --from-literal=SMTP_HOST='' --from-literal=SMTP_USER='' --from-literal=SMTP_PASSWORD='' \ -# --from-literal=TURNSTILE_SECRET_KEY='' +# ANPASSEN: jeder Wert, der "CHANGE-ME" enthaelt. Die leeren Werte ("") +# gehoeren zu optionalen Funktionen und koennen leer bleiben — die App faellt +# dann nachvollziehbar zurueck (siehe Kommentar an der jeweiligen Stelle) und +# stuerzt nicht ab. Nichts hier darf FEHLEN: ein fehlender und ein leerer +# Schluessel sind fuer die App zwar dasselbe, aber ein vollstaendiges Secret +# macht sofort sichtbar, welche Funktionen es ueberhaupt gibt. +# +# Gegenprobe nach dem Ausrollen: "pnpm preflight" meldet jeden Wert, der fuer +# den Live-Betrieb fehlt, als Blocker — mit Begruendung, was ohne ihn kaputt +# geht. apiVersion: v1 kind: Secret metadata: @@ -21,55 +19,115 @@ metadata: namespace: wabenhain type: Opaque stringData: + # --------------------------------------------------------------------- + # PFLICHT — ohne diese Werte ist kein Live-Betrieb moeglich + # --------------------------------------------------------------------- + # Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus # 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306 # (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen. - # WICHTIG: das Passwort hier MUSS URL-encodiert sein (Sonderzeichen wie - # @ : / % # ? & sonst ungueltig bzw. falsch geparst) — DB_PASSWORD unten - # bleibt dagegen roh/unencodiert. Encodieren z.B. mit: + # + # WICHTIG: das Passwort MUSS hier URL-encodiert sein — Sonderzeichen wie + # @ : / % # ? & werden sonst als Trennzeichen der URL gelesen, und die + # Verbindung schlaegt mit einer irrefuehrenden Meldung fehl. DB_PASSWORD + # weiter unten bleibt dagegen roh. Encodieren mit: # node -e "console.log(encodeURIComponent('DEIN-PASSWORT'))" DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public" - # Dasselbe Passwort wie oben in DATABASE_URL — wird vom Postgres-/MySQL- - # StatefulSet (je nachdem, welches du anwendest) zum Anlegen des - # Datenbank-Nutzers verwendet. + + # Dasselbe Passwort wie in DATABASE_URL, aber UNENCODIERT. Das + # Datenbank-StatefulSet legt damit den Datenbanknutzer an (03-postgres.yaml + # bzw. 03-mysql.yaml), und die Sicherung in 07-backup-cronjob.yaml meldet + # sich damit an. Die App selbst liest diesen Wert nicht — sie kennt nur + # DATABASE_URL. DB_PASSWORD: "CHANGE-ME" - # Mindestens 32 Zeichen, z.B.: openssl rand -base64 32 - SESSION_SECRET: "CHANGE-ME-min-32-zeichen-zufaelliger-string" - # Unvorhersehbar waehlen (nicht "admin@..."), siehe README "Admin-Zugang" — - # sonst koennte sich jemand die erwartete Adresse vorregistrieren. - ADMIN_EMAIL: "change-me@example.com" + + # Signiert die Login-Cookies. Mindestens 32 Zeichen, unvorhersehbar. + # Erzeugen mit: openssl rand -base64 32 + # Wird dieser Wert bekannt, ist jede Sitzung faelschbar, auch die eines + # Admins. Ihn zu aendern meldet alle Kundinnen und Kunden ab, mehr nicht. + SESSION_SECRET: "CHANGE-ME-mindestens-32-zeichen-zufaellig" + + # Erstes Admin-Konto, angelegt beim Start durch "prisma db seed". + # Unvorhersehbare Adresse waehlen, NICHT "admin@..." oder "info@...": + # sonst kann sich jemand die erwartete Adresse vorher selbst registrieren + # und das Konto belegen (siehe README "Admin-Zugang"). + ADMIN_EMAIL: "CHANGE-ME@wabenhain.com" + + # Mindestens 12 Zeichen. Dieses Konto darf Rueckerstattungen ausloesen und + # alle Kundendaten einsehen. Erzeugen mit: + # openssl rand -base64 24 | tr -d '=+/' | head -c 28 ADMIN_PASSWORD: "CHANGE-ME" - STRIPE_SECRET_KEY: "" - STRIPE_PUBLISHABLE_KEY: "" - STRIPE_WEBHOOK_SECRET: "" - SMTP_HOST: "" - SMTP_USER: "" - SMTP_PASSWORD: "" + + # Stripe im LIVE-Modus (sk_live_...). Mit einem sk_test-Schluessel sehen + # Bestellungen erfolgreich aus, ohne dass Geld fliesst. + STRIPE_SECRET_KEY: "CHANGE-ME" + + # Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben + # auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail. + # Stammt aus dem Webhook-Endpunkt im Stripe-Dashboard, der auf + # https://wabenhain.com/api/checkout/webhook zeigt (whsec_...). + STRIPE_WEBHOOK_SECRET: "CHANGE-ME" + + # Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts): + # keine Bestellbestaetigung, keine Versandmeldung, keine + # Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und + # Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml. + SMTP_HOST: "CHANGE-ME" + SMTP_USER: "CHANGE-ME" + SMTP_PASSWORD: "CHANGE-ME" + + # --------------------------------------------------------------------- + # EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt + # --------------------------------------------------------------------- + + # Cloudflare Turnstile (CAPTCHA) fuer Registrierung, Newsletter und + # Firmenanfrage. Leer = kein CAPTCHA; die Formulare bleiben nutzbar, aber + # bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege. + # Der oeffentliche Gegenpart NEXT_PUBLIC_TURNSTILE_SITE_KEY steht in + # 01-configmap.yaml — beide setzen oder beide leer lassen. TURNSTILE_SECRET_KEY: "" + + # --------------------------------------------------------------------- + # OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird + # --------------------------------------------------------------------- + + # Google-Places-API fuer echte Google-Rezensionen auf der Startseite. + # Leer = die Startseite zeigt die statischen Beispiel-Stimmen. + # + # Der Schluessel gehoert hierher und NICHT in 01-configmap.yaml: er ist + # abrechenbar, und eine ConfigMap ist unverschluesselt und in der + # Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige GOOGLE_PLACE_ID + # ist dagegen oeffentlich und steht dort. + GOOGLE_PLACES_API_KEY: "" + + # Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte -> + # "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen + # Konfigurationsfehler, statt still nichts zu tun. + # + # ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login — + # deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen + # echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read, + # extractions:read, bottling:read und optional apiaries:read. Die Adresse + # APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml. + APICORE_SYNC_EMAIL: "" + APICORE_SYNC_PASSWORD: "" + # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe - # .env.example fuer die vollstaendige Einrichtungsanleitung. Leer lassen, - # bis vorhanden — der Button zeigt dann "noch nicht konfiguriert" statt - # zu crashen. + # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet + # "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox + # oder Echtbetrieb, steuert DHL_ENVIRONMENT in 01-configmap.yaml. DHL_API_KEY: "" DHL_API_SECRET: "" DHL_GKP_USERNAME: "" DHL_GKP_PASSWORD: "" DHL_ACCOUNT_NUMBER: "" + # Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die - # Einrichtung in Azure. ENTRA_REDIRECT_URI muss auf die echte, oeffentliche - # Domain zeigen: https://wabenhain.com/api/auth/entra/callback + # Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst. + # ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse + # entsprechen und auf die echte Domain zeigen: + # https://wabenhain.com/api/auth/entra/callback ENTRA_TENANT_ID: "" ENTRA_CLIENT_ID: "" ENTRA_CLIENT_SECRET: "" ENTRA_REDIRECT_URI: "" - # Abrechenbarer Zugangsschluessel fuer die Google-Places-API (echte - # Google-Rezensionen auf der Startseite). Gehoert hierher und NICHT in - # 01-configmap.yaml: eine ConfigMap ist unverschluesselt und in der - # Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige, oeffentliche - # GOOGLE_PLACE_ID steht dort. - GOOGLE_PLACES_API_KEY: "" - # Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte -> - # "ApiCore-Synchronisierung"), siehe .env.example. Die zugehoerige Adresse - # APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml. - APICORE_SYNC_EMAIL: "" - APICORE_SYNC_PASSWORD: "" diff --git a/src/lib/k8s-manifest-env.test.ts b/src/lib/k8s-manifest-env.test.ts new file mode 100644 index 0000000..07bd938 --- /dev/null +++ b/src/lib/k8s-manifest-env.test.ts @@ -0,0 +1,129 @@ +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 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)) + .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([]); + }); +});