Files
QR-master/PLAN_TESTUMGEBUNG_2026-08-12.md
Timo Knuth 113acc073f Make the session cookie name configurable for a staging deployment
Groundwork for testmodul.qrmaster.net, a second stack running the `test` branch on a real
qrmaster.net subdomain.

Production scopes its session cookie to .qrmaster.net, so the browser sends it to every
subdomain including staging. With both environments naming the cookie `userId`, the
browser holds two cookies of the same name and cookies.get() picks one arbitrarily -
staging logins would look randomly signed-out. AUTH_COOKIE_NAME lets staging pick
`userId_test` instead. Production keeps the `userId` default; changing it there would
invalidate every existing session.

Wired getAuthCookieName() into the six places that named the cookie literally. The account
deletion route now expires both the host-only and the domain-scoped variant like the logout
route already does, instead of a single cookies().delete() that would leave the other one
behind.

NEXT_PUBLIC_WWW_URL and NEXT_PUBLIC_APP_URL become build ARGs so the same image can be
built pointing at the staging host - the defaults keep a plain production build byte
identical to before. Like COOKIE_DOMAIN these must exist at build time, because process.env
is inlined into the Edge middleware bundle.

robots.ts now serves Disallow-all unless NEXT_PUBLIC_INDEXABLE is true. Staging otherwise
returns the production robots.txt and invites crawlers to index a duplicate of www.

docker-compose.test.yml is the staging overlay. Two things it must get right, both verified
against `docker compose config`:

- db and redis need `networks: !override`. Compose MERGES the networks mapping from the base
  file, and since qrmaster-network is external and shared, a plain list left them attached
  to it - `db` would then resolve to two containers and staging could read and write the
  production database.
- The web entrypoint is replaced so `prisma migrate deploy` never runs. prisma/migrations
  stopped in April 2026 and the schema has moved on through manual SQL since, so applying
  them to a fresh database would build a stale schema. Staging gets its schema from
  `pg_dump --schema-only` against production instead.

Verified: tsc clean, production build succeeds, and the merged compose config confirms
staging keeps db/redis off the shared network while production resolves unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:09:56 +02:00

12 KiB

Plan: Testumgebung auf testmodul.qrmaster.net

Stand: 2026-08-12 · Ziel: eine vollständige zweite Instanz auf testmodul.qrmaster.net, die einen anderen Branch (test) fährt, damit riskante Features - z.B. ein Bot, der bei Kundenerfolgen automatisch tweetet - vor dem Merge nach master real getestet werden können.

Status: nur Planung. Es wurde noch kein Code geändert.

Architektur

Zweiter, vollständig eigenständiger Compose-Stack aus demselben Repo, anderer Branch, eigene Datenbank, eigenes Redis. Caddy routet testmodul.qrmaster.net auf den Test-Container.

www.qrmaster.net   ─┐
app.qrmaster.net   ─┴─> qrmaster-web       (Branch master, Prod-DB)
testmodul.qrmaster.net ─> qrmaster-test-web (Branch test, eigene DB)

Prod und Test teilen sich nichts außer dem Docker-Netzwerk qrmaster-network (das ist external: true, darüber erreicht Caddy beide Container per Namen).

Ausgangslage im Repo

Im Working Tree liegen uncommittete Änderungen, die nicht aus der Subdomain-Arbeit stammen:

  • src/lib/cookieConfig.ts - getAuthCookieName() ist angelegt (liest AUTH_COOKIE_NAME, Default userId)
  • src/lib/session.ts - importiert getAuthCookieName, benutzt es aber nicht; Zeile 14 hat weiterhin export const AUTH_COOKIE_NAME = 'userId'

Damit ist die Funktion aktuell wirkungslos. Die Verkabelung fehlt an 6 Stellen (siehe unten). Vor dem Weiterbauen klären, ob diese Änderungen bewusst so stehen oder committed werden sollen.

Ein Branch test existiert noch nicht. Vorhanden: analytics, dynamisch, feature/mockup-landing-page, icons, master.

Produktion setzt das Session-Cookie userId auf .qrmaster.net. Der Browser schickt es damit auch an testmodul.qrmaster.net. Setzt Test sein eigenes userId als Host-Cookie, liegen zwei gleichnamige Cookies vor, und req.cookies.get('userId') in middleware.ts:250 liefert undefiniert welches davon. Folge: Login auf Test verhält sich sporadisch wie ausgeloggt - ein Fehlerbild, das schwer zu greifen ist, weil es vom Cookie-Zustand des jeweiligen Browsers abhängt.

Lösung: Cookie-Name pro Umgebung konfigurierbar. getAuthCookieName() fertig verkabeln, Test setzt AUTH_COOKIE_NAME=userId_test.

Zu ändernde Stellen:

Datei Was
src/lib/session.ts:14 AUTH_COOKIE_NAME-Konstante durch getAuthCookieName() ersetzen (Zeilen 71, 78 ziehen nach)
src/middleware.ts:250 req.cookies.get('userId')getAuthCookieName()
src/app/(main)/api/auth/google/route.ts:227 cookies.set('userId', …)
src/app/(main)/api/auth/verify-email/route.ts:40 cookies.set('userId', …)
src/app/(main)/api/auth/logout/route.ts:12 Eintrag in appendExpiredCookies
src/app/(main)/api/user/delete/route.ts:77 cookies().delete('userId')

Wie COOKIE_DOMAIN muss AUTH_COOKIE_NAME auch zur Build-Zeit gesetzt sein - process.env wird ins Edge-Middleware-Bundle inlined. Also ARG + ENV im Dockerfile und als Build-Arg im Compose-Override, analog zu COOKIE_DOMAIN.

Achtung beim Umstellen der Produktion: ändert sich dort der Cookie-Name, werden alle bestehenden Sessions ungültig (alle Nutzer ausgeloggt). Deshalb Prod beim Default userId lassen und nur Test abweichen - dann passiert genau nichts.

Falle 2 - Host-Routing würde nach Prod umleiten

Die Middleware kennt nur zwei Hosts. Auf testmodul.qrmaster.net greift der Zweig else if (isAppPath(path)) und schickt /dashboard per 301 auf app.qrmaster.net, also in die Produktion.

Lösung ohne Codeänderung: Test zeigt beide Origins auf sich selbst. Dann ist isHostSplitEnabled() false, getAppHostname() liefert null, und das komplette Host-Routing schaltet sich ab. Test läuft als Ein-Host-Umgebung mit Marketing und App unter einer Domain - genau wie die lokale Entwicklung.

Das ist der Grund, warum beide Variablen in .env.test identisch sein müssen. Wer dort später aus Versehen NEXT_PUBLIC_APP_URL=https://app.qrmaster.net einträgt, schickt seine Testklicks in die Produktion.

Falle 3 - ausgehende Nebenwirkungen

Das ist beim Twitter-Bot der eigentliche Punkt. Eine Testumgebung, die auf echte Dienste zeigt, ist keine Testumgebung.

Dienst Auf Test
Twitter/X eigener App-Key auf einen Test-Account, oder ein DRY_RUN-Flag, das den Tweet nur loggt
Stripe Test-Keys (sk_test_…), eigener Webhook-Endpoint auf testmodul
Resend / SMTP Test-Key oder komplett deaktivieren - sonst mailt Test an echte Kunden
Meta Conversions / PostHog / Umami leer lassen, sonst verschmutzt Test die Prod-Analytics
Cron (Retention-Mails) auf Test abschalten

Empfehlung für den Bot: das DRY_RUN-Flag von Anfang an einbauen, nicht erst wenn es einmal schiefging. Ein Bot, der Kundenerfolge tweetet, ist genau die Sorte Feature, die man nicht "kurz mal live" testen will.

.env.test

NODE_ENV=production

# Ein-Host-Betrieb: schaltet das Host-Routing ab
NEXT_PUBLIC_WWW_URL=https://testmodul.qrmaster.net
NEXT_PUBLIC_APP_URL=https://testmodul.qrmaster.net
NEXTAUTH_URL=https://testmodul.qrmaster.net

# Host-Cookie, nicht .qrmaster.net - sonst leckt die Test-Session nach Prod
COOKIE_DOMAIN=
AUTH_COOKIE_NAME=userId_test

# Anderes Secret: ein Leak auf Test kann dann keine Prod-Session fälschen
NEXTAUTH_SECRET=<eigenes Secret>
IP_SALT=<eigenes Salt>

# Nicht indexieren
NEXT_PUBLIC_INDEXABLE=false

# Eigene DB im Test-Stack
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<eigenes>
POSTGRES_DB=qrmaster
DATABASE_URL=postgresql://postgres:<eigenes>@db:5432/qrmaster?schema=public

# Test-Keys / leer, siehe Falle 3
STRIPE_SECRET_KEY=sk_test_…
RESEND_API_KEY=

NEXTAUTH_URL darf hier auf testmodul zeigen - der einzige echte Leser ist die Social-Assets-Route, und die soll auf Test ohnehin nicht gegen die verifizierte TikTok-Domain laufen.

Infra

docker-compose.test.yml als Override, das nur die Abweichungen setzt:

  • container_name: qrmaster-test-db, qrmaster-test-redis, qrmaster-test-web - die Namen sind im Basis-File fest vergeben und würden sonst kollidieren
  • Host-Ports entfernen - 5435 (db) und 8080 (adminer) sind schon von Prod belegt
  • entrypoint: ["node", "server.js"] für web, siehe Migrationen oben
  • Build-Args für COOKIE_DOMAIN, AUTH_COOKIE_NAME, NEXT_PUBLIC_*

Netzwerk-Isolation (kritisch)

db und redis hängen im Basis-File am Netzwerk qrmaster-network (docker-compose.yml:22), und das ist external: true, also für beide Stacks dasselbe. Compose vergibt jedem Service automatisch einen Netzwerk-Alias mit seinem Servicenamen - zwei Stacks mit einem Service db am selben Netzwerk heißt: db löst auf zwei Container auf und Dockers DNS wählt zufällig. Der Test-Container könnte damit auf der Produktionsdatenbank landen, nicht deterministisch, sondern mal so und mal so.

Deshalb bekommt der Test-Stack ein eigenes internes Netzwerk. Nur web hängt zusätzlich am geteilten Netz, damit Caddy es erreicht:

services:
  db:
    container_name: qrmaster-test-db
    ports: !reset []
    networks: [test-internal]
  redis:
    container_name: qrmaster-test-redis
    networks: [test-internal]
  web:
    container_name: qrmaster-test-web
    entrypoint: ["node", "server.js"]
    networks: [test-internal, qrmaster-network]

networks:
  test-internal:
    driver: bridge

POSTGRES_DB auf Test gleich lassen (qrmaster): der Healthcheck hat pg_isready -d qrmaster hartkodiert (Zeile 19) und würde bei abweichendem Namen den Container als unhealthy melden. Die Trennung kommt vom Volume, nicht vom Datenbanknamen.

Start:

docker compose -p qrmaster-test --env-file .env.test \
  -f docker-compose.yml -f docker-compose.test.yml up -d --build

Der Projektname -p qrmaster-test gibt automatisch eigene Volumes - die Test-DB kann die Prod-DB also nicht anfassen.

Caddy:

testmodul.qrmaster.net {
    reverse_proxy qrmaster-test-web:3000
}

robots.ts an NEXT_PUBLIC_INDEXABLE koppeln. Aktuell liefert es hart Allow plus www-Sitemap; auf Test soll Disallow: / stehen. NEXT_PUBLIC_INDEXABLE=false setzt heute nur das Meta-Tag in den beiden Layouts, nicht die robots.txt.

Branch-Workflow

git checkout -b test master
git push -u origin test

Deploy auf Test: auf dem Server git checkout test && git pull, dann der Compose-Befehl oben. Rebuild ist immer nötig, weil NEXT_PUBLIC_* und AUTH_COOKIE_NAME zur Build-Zeit inlined werden.

Ablauf für ein Feature: Branch von test abzweigen → auf Test deployen und prüfen → nach master mergen → Prod-Deploy. test bleibt dauerhaft bestehen und wird regelmäßig von master nachgezogen, damit er nicht wegdriftet.

Datenbank - entschieden: leeres Schema

Eigene PostgreSQL-Instanz im Test-Stack, befüllt mit Struktur ohne Zeilen. Keine Kundendaten verlassen die Produktion.

# auf dem Server, aus dem Prod-Stack
docker compose exec db pg_dump -U postgres --schema-only qrmaster > /tmp/schema.sql

# in den Test-Stack einspielen
docker compose -p qrmaster-test exec -T db psql -U postgres qrmaster < /tmp/schema.sql

Danach einen Testaccount anlegen - entweder über das Signup-Formular auf testmodul oder per INSERT.

Warum der Dump aus Prod und nicht aus Prisma: prisma/migrations steht auf April 2026. Alles seitdem (Pulse-Spalten, BARCODE-Enum-Wert, ...) kam per Hand-SQL. Die Produktions-DB ist damit die einzige Stelle, die die aktuelle Struktur kennt.

Warum leer statt Kopie: beim Tweet-Bot willst du wissen, welcher Testfall den Tweet ausgelöst hat. Mit Prod-Daten könnte es auch irgendein echter Kunde von vor Monaten gewesen sein.

Keine Migrationen - der Entrypoint muss überschrieben werden

docker/entrypoint.sh:5 führt bei jedem Container-Start npx prisma migrate deploy aus. Das widerspricht der Policy aus CLAUDE.md (Schema-Änderungen nur per Hand-SQL) und würde auf einer frischen Test-DB die 6 veralteten Migrationen anwenden - also ein Schema vom Stand April 2026 bauen, dem alle späteren SQL-Änderungen fehlen. Die App liefe dann in "column does not exist".

Im Test-Override deshalb:

web:
  entrypoint: ["node", "server.js"]

Prod bleibt unverändert. Der Schema-Dump bringt die Tabelle _prisma_migrations ohnehin mit, inklusive der 6 als angewandt markierten Einträge - der Zustand ist also identisch zu Prod.

Separat zu klären (nicht Teil dieses Plans): ob prisma migrate deploy langfristig auch aus dem Prod-Entrypoint verschwinden soll. Heute ist es dort ein No-Op, aber es ist eine scharfe Waffe, die bei einem versehentlich hinzugefügten Migrationsfile auf die Produktion losgeht.

Spätere Schema-Änderungen

Ein SQL-Statement, das auf Test getestet wurde, wird auf Prod erneut von Hand ausgeführt - es gibt keinen automatischen Weg von Test nach Prod. Die Dateien in sql/ sind der Ort dafür.

Aufwand

Cookie-Name verkabeln (6 Stellen) + Dockerfile/Compose ~1 h
docker-compose.test.yml + robots.ts an INDEXABLE koppeln ~1 h
.env.test, Secrets, Test-Keys besorgen Timo
Caddy-Block + erster Deploy ~30 min
DB-Variante B zusätzlich +2-3 h für das Anonymisierungs-Skript

Der Twitter-Bot selbst ist davon unabhängig und noch nicht geschätzt.

Testcheckliste nach dem ersten Test-Deploy

  • testmodul.qrmaster.net lädt, gültiges Zertifikat
  • testmodul.qrmaster.net/dashboard bleibt auf testmodul und springt nicht nach app.qrmaster.net
  • Login auf Test funktioniert, während man in Prod eingeloggt ist - beide Sessions unabhängig
  • Logout auf Test loggt nicht aus Prod aus (und umgekehrt)
  • Im Browser liegen zwei Cookies: userId (Domain .qrmaster.net) und userId_test (Host testmodul.qrmaster.net)
  • curl -sI https://testmodul.qrmaster.net → kein X-Robots-Tag nötig, aber /robots.txt liefert Disallow: /
  • Test-DB enthält keine echten Kunden: SELECT count(*) FROM "User"; muss die Zahl der selbst angelegten Testaccounts sein
  • docker compose -p qrmaster-test exec web env | grep DATABASE_URL zeigt auf den Test-Container, und SELECT count(*) dort weicht von Prod ab - beweist, dass der db-Alias nicht auf Prod zeigt
  • Container-Logs beim Start enthalten kein "Applying Prisma migrations"
  • Ein Testlauf des Bots postet nichts auf dem echten Account