feat(quick-260909-dgj): Migrationsverbindung von Laufzeitverbindung trennen (Task 2/4)

- apps/api/scripts/migrate-and-start.sh: TESSERA_MIGRATE_DATABASE_URL fuer
  den Migrationsschritt, DATABASE_URL bleibt unveraendert fuer den
  Laufzeitschritt; leer/nicht gesetzt = bisheriges Verhalten
- Dockerfile kopiert scripts/ und ruft das Skript als CMD auf; exec statt
  &&-Verkettung, damit Signale den Node-Prozess erreichen
- docker-compose.yml/.prod.yml reichen TESSERA_MIGRATE_DATABASE_URL durch
  (leerer Vorgabewert); docker-compose.dev.yml/.ci.yml unveraendert
- .env.example erklaert beide Variablen mit Platzhaltern, Umstellung bleibt
  auskommentiert
- 5 Tests in start-script.spec.ts (rot vor dem Skript, jetzt gruen)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 10:00:45 +02:00
parent b3375ae016
commit a5f99e52e3
6 changed files with 159 additions and 1 deletions
+9
View File
@@ -2,6 +2,15 @@ DB_PASSWORD=your_db_password_here
DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
NODE_ENV=development 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). # Encrypts stored credentials (LDAP bind password, calendar and mailbox logins).
# Required - the stack refuses to start without it. # Required - the stack refuses to start without it.
# Generate one with: openssl rand -hex 32 # Generate one with: openssl rand -hex 32
+2 -1
View File
@@ -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/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/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts
USER nestjs USER nestjs
EXPOSE 3001 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"]
+50
View File
@@ -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
+96
View File
@@ -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<string, string>): { 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');
});
});
+1
View File
@@ -32,6 +32,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin} TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL} TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL}
+1
View File
@@ -31,6 +31,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql://tessera:tessera_dev@db:5432/tessera} 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} JWT_SECRET: ${JWT_SECRET:-tessera-dev-jwt-secret-change-in-production}
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin} TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local} TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local}