import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { describe, expect, it } from "vitest"; /** * Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker: * prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan * festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service, * Pflicht-Env-Keys, idempotente Migration vor App-Start). */ const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, "..", ".."); const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8"); describe("Deployment-Artefakte", () => { it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => { const df = read("Dockerfile"); expect(df).toMatch(/AS\s+deps/); expect(df).toMatch(/AS\s+builder/); expect(df).toMatch(/AS\s+runner/); // Standalone-Server wird kopiert und gestartet. expect(df).toMatch(/\.next\/standalone/); expect(df).toMatch(/server\.js/); // Non-root: dedizierte UID/GID 1001 + USER-Wechsel. expect(df).toMatch(/1001/); expect(df).toMatch(/^USER\s+(nextjs|1001)/m); // Entrypoint übernimmt Migration vor App-Start. expect(df).toMatch(/entrypoint\.sh/); }); it("Dockerfile kopiert die VOLLSTÄNDIGE pg/drizzle-orm-Laufzeit-Closure ins Runner-Image", () => { // Reale Laufzeit-Closure aus node_modules berechnen (nur dependencies, // wie sie zur Laufzeit von docker/migrate.mjs + docker/seed.mjs benötigt // wird). Jedes Paket MUSS eine COPY-Zeile im Runner-Stage haben, sonst // crasht der Container-Start mit ERR_MODULE_NOT_FOUND (z. B. pg-int8, // xtend). Schützt vor erneutem Brechen bei pg/drizzle-Updates. const df = read("Dockerfile"); const nodeModules = resolve(root, "node_modules"); const seen = new Set(); const resolvePkg = (name: string, fromDir: string): string | null => { let cur = fromDir; for (;;) { const candidate = resolve(cur, "node_modules", name); if (existsSync(resolve(candidate, "package.json"))) return candidate; const parent = dirname(cur); if (parent === cur) break; cur = parent; } const top = resolve(nodeModules, name); return existsSync(resolve(top, "package.json")) ? top : null; }; const walk = (dir: string): void => { const pkg = JSON.parse( readFileSync(resolve(dir, "package.json"), "utf8"), ) as { dependencies?: Record }; for (const dep of Object.keys(pkg.dependencies ?? {})) { if (seen.has(dep)) continue; seen.add(dep); const resolved = resolvePkg(dep, dir); // Nur skalierbar prüfbar, wenn auflösbar; sonst meldet npm ci es ohnehin. if (resolved) walk(resolved); } }; const pgRoot = resolve(nodeModules, "pg"); const drizzleRoot = resolve(nodeModules, "drizzle-orm"); walk(pgRoot); walk(drizzleRoot); // pg + drizzle-orm selbst sind ebenfalls Teil der Closure. const closure = new Set([...seen, "pg", "drizzle-orm"]); const missing = [...closure].filter( (name) => !df.includes(`/app/node_modules/${name} ./node_modules/${name}`), ); expect(missing, `nicht ins Runner-Image kopiert: ${missing.join(", ")}`).toEqual( [], ); }); it("Dockerfile bündelt den Seed (build-seed-bundle) und kopiert docker/seed.mjs ins Runner-Image", () => { const df = read("Dockerfile"); // Im builder wird der Seed zu Plain-ESM gebündelt ... expect(df).toMatch(/build-seed-bundle\.mjs/); // ... und ins Runner-Image kopiert, damit RUN_SEED=true nicht still no-opt. expect(df).toMatch(/docker\/seed\.mjs/); // Der Bundler-Entrypoint existiert. expect(existsSync(resolve(root, "scripts/build-seed-bundle.mjs"))).toBe(true); // Das Seed-Quellmodul, das gebündelt wird, existiert. expect(existsSync(resolve(root, "src/db/seed/index.ts"))).toBe(true); }); it(".dockerignore schließt node_modules, .next und Secrets aus", () => { const di = read(".dockerignore"); expect(di).toMatch(/node_modules/); expect(di).toMatch(/\.next/); expect(di).toMatch(/\.env/); }); it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => { const ep = resolve(root, "docker/entrypoint.sh"); expect(existsSync(ep)).toBe(true); // Syntaxprüfung (offline, kein Ausführen der Logik). execFileSync("sh", ["-n", ep]); const src = read("docker/entrypoint.sh"); expect(src).toMatch(/set -e/); // Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start. expect(src).toMatch(/DATABASE_URL/); expect(src).toMatch(/migrate/); expect(src).toMatch(/RUN_SEED/); expect(src).toMatch(/exec node server\.js/); }); it(".env.example listet alle Pflicht-Env-Keys", () => { const env = read(".env.example"); for (const key of [ "AUTH_URL", "AUTH_TRUST_HOST", "AUTH_SECRET", "AUTHENTIK_ISSUER", "AUTHENTIK_CLIENT_ID", "AUTHENTIK_CLIENT_SECRET", "DATABASE_URL", "OSRM_URL", "NOMINATIM_URL", "APP_HOST", ]) { expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m")); } }); it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => { const c = read("docker-compose.yml"); // Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE. const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:")); expect(servicesBlock).toMatch(/^\s{2}app:/m); expect(servicesBlock).toMatch(/^\s{2}postgres:/m); expect(servicesBlock).toMatch(/^\s{2}osrm:/m); expect(servicesBlock).toMatch(/^\s{2}nominatim:/m); expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m); expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m); // Externes Traefik-Netz. expect(c).toMatch(/external:\s*true/); // Traefik-Labels am App-Service. expect(c).toMatch(/traefik\.enable=true/); expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/); expect(c).toMatch(/entrypoints=websecure/); expect(c).toMatch(/tls\.certresolver/); // Forwarded-Header / sichere Cookies via Env. expect(c).toMatch(/AUTH_TRUST_HOST/); expect(c).toMatch(/AUTH_URL/); // Postgres-Healthcheck. expect(c).toMatch(/pg_isready/); // App-Healthcheck gegen /api/health. expect(c).toMatch(/\/api\/health/); }); it("docker compose config listet genau app/postgres/osrm/nominatim", () => { // Offline-tauglich: `docker compose config --services` braucht keine Container. let services: string; try { services = execFileSync( "docker", [ "compose", "-f", "docker-compose.yml", "-f", "docker-compose.geo.yml", "config", "--services", ], { cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } }, ); } catch { // Kein Docker in der Sandbox: dieser Teilschritt ist deferred. return; } const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean)); expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"])); }); it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => { const o = read("docker-compose.override.yml.example"); expect(o).toMatch(/3000:3000/); }); it("Makefile bietet build/up/deploy/data-Ziele", () => { const mk = read("Makefile"); expect(mk).toMatch(/^build:/m); expect(mk).toMatch(/^up:/m); expect(mk).toMatch(/^deploy:/m); expect(mk).toMatch(/^data:/m); }); it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => { const doc = read("docs/reference/deployment-traefik.md"); expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/); expect(doc).toMatch(/AUTH_URL/); expect(doc).toMatch(/external/); expect(doc).toMatch(/\/api\/health/); }); });