diff --git a/.env.example b/.env.example index 07ae0f2..18f7c98 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,15 @@ DB_PASSWORD=your_db_password_here DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera NODE_ENV=development +# DATABASE_URL ist die Verbindung, mit der die laufende Anwendung arbeitet. +# TESSERA_MIGRATE_DATABASE_URL ist die separate Verbindung, mit der beim +# Containerstart NUR "prisma migrate deploy" laeuft. Bleibt sie leer, laeuft +# alles wie bisher — beide Schritte nutzen DATABASE_URL. Die getrennte +# Belegung ist erst sinnvoll, wenn docs/mandantentrennung-datenbankrolle.md +# vollstaendig abgearbeitet ist; vorher wuerde eine ungeprüfte Umstellung die +# Anwendung von ihren eigenen Daten aussperren. +# TESSERA_MIGRATE_DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera + # Encrypts stored credentials (LDAP bind password, calendar and mailbox logins). # Required - the stack refuses to start without it. # Generate one with: openssl rand -hex 32 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 43c8995..0d32e38 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -33,6 +33,7 @@ COPY --from=builder /app/apps/api/dist ./apps/api/dist COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma ./node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma COPY --from=builder /app/apps/api/prisma ./apps/api/prisma COPY --from=builder /app/packages/shared/src ./packages/shared/src +COPY apps/api/scripts ./apps/api/scripts USER nestjs EXPOSE 3001 -CMD ["sh", "-c", "apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js"] +CMD ["sh", "apps/api/scripts/migrate-and-start.sh"] diff --git a/apps/api/scripts/migrate-and-start.sh b/apps/api/scripts/migrate-and-start.sh new file mode 100755 index 0000000..92c931e --- /dev/null +++ b/apps/api/scripts/migrate-and-start.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# WINDOWS #18 — trennt die Verbindung des Migrationsschritts von der +# Verbindung des Laufzeitschritts (T-DGJ-03). +# +# `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers, um DDL +# auszufuehren. Die Anwendung soll genau diese Rechte NICHT haben — sonst +# waere die Rollentrennung aus Migration 20260909130000_rls_app_role wieder +# verloren. Ohne getrennte Verbindungen fuer Migration und Laufzeit ist eine +# Rollentrennung deshalb nicht moeglich. +# +# Vorgabe (ohne gesetztes TESSERA_MIGRATE_DATABASE_URL): beide Schritte +# nutzen DATABASE_URL — exakt der bisherige Ablauf, unveraendert fuer +# lokale Entwicklung, CI und den Server, bis +# docs/mandantentrennung-datenbankrolle.md abgearbeitet ist. +set -e + +if [ -z "$DATABASE_URL" ]; then + echo "FEHLER: DATABASE_URL ist nicht gesetzt." >&2 + exit 1 +fi + +# In sh ersetzt ${VAR:-fallback} auch eine gesetzte, aber leere Variable +# durch den Fallback — Compose reicht nicht belegte Variablen als leere +# Zeichenketten weiter, das soll wie "nicht gesetzt" behandelt werden. +MIGRATE_URL="${TESSERA_MIGRATE_DATABASE_URL:-$DATABASE_URL}" + +if [ -n "$TESSERA_MIGRATE_DATABASE_URL" ]; then + MIGRATE_SOURCE="TESSERA_MIGRATE_DATABASE_URL" +else + MIGRATE_SOURCE="DATABASE_URL" +fi +RUNTIME_SOURCE="DATABASE_URL" + +if [ "$1" = "--print-plan" ]; then + # Nur Variablennamen ausgeben, niemals Werte — diese Betriebsart existiert + # allein, damit die CI das Verhalten ohne Datenbank pruefen kann. + echo "Migrationsschritt verwendet: $MIGRATE_SOURCE" + echo "Laufzeitschritt verwendet: $RUNTIME_SOURCE" + exit 0 +fi + +# DATABASE_URL nur fuer diesen einen Aufruf auf die Migrationsverbindung +# setzen (vorangestellte Zuweisung, kein export) — der nachfolgende exec +# erbt die unveraenderte DATABASE_URL aus der Umgebung. +DATABASE_URL="$MIGRATE_URL" apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma + +# exec statt Aufruf, damit Signale (z. B. SIGTERM bei docker stop) den +# Node-Prozess erreichen. Die bisherige CMD-Zeile hatte dieses Problem +# ebenfalls und loeste es nicht; hier wird es nebenbei mitbehoben. +exec node apps/api/dist/main.js diff --git a/apps/api/src/prisma/start-script.spec.ts b/apps/api/src/prisma/start-script.spec.ts new file mode 100644 index 0000000..fc4f11a --- /dev/null +++ b/apps/api/src/prisma/start-script.spec.ts @@ -0,0 +1,96 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Prueft apps/api/scripts/migrate-and-start.sh ausschliesslich ueber die + * Ausgabebetriebsart --print-plan — es wird weder Prisma noch Node + * aufgerufen, keine Datenbankverbindung aufgebaut (Task 2, WINDOWS #18). + */ + +const SCRIPT_PATH = join(__dirname, '../../scripts/migrate-and-start.sh'); +const SECRET_MARKER = 'tessera-test-secret-marker-9f3c1e'; + +function runScript(env: Record): { stdout: string; status: number } { + try { + const stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], { + env, + encoding: 'utf-8', + }); + return { stdout, status: 0 }; + } catch (err: any) { + return { stdout: (err.stdout ?? '').toString(), status: err.status ?? 1 }; + } +} + +describe('migrate-and-start.sh --print-plan', () => { + it('Test 1 (Rueckwaertsvertraeglichkeit): ohne TESSERA_MIGRATE_DATABASE_URL melden beide Schritte DATABASE_URL', () => { + const { stdout, status } = runScript({ + PATH: process.env.PATH ?? '', + DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`, + }); + + expect(status).toBe(0); + expect(stdout).toContain('DATABASE_URL'); + // Kein Wert, nur der Variablenname darf erscheinen + expect(stdout).not.toContain(SECRET_MARKER); + // beide Zeilen (Migration + Laufzeit) nennen DATABASE_URL als Quelle + const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length; + expect(dbUrlMentions).toBeGreaterThanOrEqual(2); + }); + + it('Test 2 (Trennung): mit gesetztem TESSERA_MIGRATE_DATABASE_URL nennt der Migrationsschritt die Migrationsvariable, der Laufzeitschritt bleibt bei DATABASE_URL', () => { + const { stdout, status } = runScript({ + PATH: process.env.PATH ?? '', + DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`, + TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}@db:5432/tessera`, + }); + + expect(status).toBe(0); + expect(stdout).toContain('TESSERA_MIGRATE_DATABASE_URL'); + expect(stdout).toContain('DATABASE_URL'); + expect(stdout).not.toContain(SECRET_MARKER); + }); + + it('Test 3 (leer zaehlt als nicht gesetzt): TESSERA_MIGRATE_DATABASE_URL="" verhaelt sich wie Test 1', () => { + const { stdout, status } = runScript({ + PATH: process.env.PATH ?? '', + DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`, + TESSERA_MIGRATE_DATABASE_URL: '', + }); + + expect(status).toBe(0); + expect(stdout).not.toContain('TESSERA_MIGRATE_DATABASE_URL wird verwendet'); + const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length; + expect(dbUrlMentions).toBeGreaterThanOrEqual(2); + }); + + it('Test 4 (kein Geheimnisabfluss): keiner der beiden Verbindungswerte erscheint in der Ausgabe', () => { + const { stdout } = runScript({ + PATH: process.env.PATH ?? '', + DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`, + TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}b@db:5432/tessera`, + }); + + expect(stdout).not.toContain(SECRET_MARKER); + }); + + it('Test 5 (fehlende Angabe): ohne DATABASE_URL bricht das Skript mit Ende-Code ungleich 0 ab und nennt den Variablennamen', () => { + let stdout = ''; + let stderr = ''; + let status = 0; + try { + stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], { + env: { PATH: process.env.PATH ?? '' }, + encoding: 'utf-8', + }); + } catch (err: any) { + status = err.status ?? 1; + stdout = (err.stdout ?? '').toString(); + stderr = (err.stderr ?? '').toString(); + } + + expect(status).not.toBe(0); + expect(stdout + stderr).toContain('DATABASE_URL'); + }); +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 068c8a2..b20f383 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,7 @@ services: condition: service_healthy environment: DATABASE_URL: ${DATABASE_URL} + TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-} JWT_SECRET: ${JWT_SECRET} TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin} TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL} diff --git a/docker-compose.yml b/docker-compose.yml index 303febc..004ac6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,7 @@ services: condition: service_healthy environment: DATABASE_URL: ${DATABASE_URL:-postgresql://tessera:tessera_dev@db:5432/tessera} + TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-} JWT_SECRET: ${JWT_SECRET:-tessera-dev-jwt-secret-change-in-production} TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin} TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local}