The staging database now runs as qrmaster_test instead of reusing the production name. Container and volume already kept the two apart, but a hand-typed psql session against two databases both called `qrmaster` looks identical on either side - the distinct name is what makes the wrong window obvious before a DELETE lands in it. The base compose file hardcodes `pg_isready -d qrmaster` in the db healthcheck, so the overlay has to override the probe as well. Without it the container stays unhealthy and web never starts, because it waits on service_healthy. Verified against `docker compose config`: staging resolves to qrmaster_test in POSTGRES_DB, DATABASE_URL and the healthcheck, while production still resolves to qrmaster. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
13 KiB
Markdown
300 lines
13 KiB
Markdown
# 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`.
|
|
|
|
## Falle 1 - Cookie-Kollision (blockierend)
|
|
|
|
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](src/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
|
|
|
|
```dotenv
|
|
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, hex - ein "/" aus base64 zerlegt die DATABASE_URL>
|
|
POSTGRES_DB=qrmaster_test
|
|
# DATABASE_URL nicht setzen - Compose baut sie aus den drei Werten oben
|
|
|
|
# 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](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:
|
|
|
|
```yaml
|
|
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` heißt auf Test `qrmaster_test`. Die Trennung kommt zwar schon vom eigenen
|
|
Container und Volume, aber der abweichende Name macht bei einer von Hand getippten
|
|
`psql`-Sitzung sofort sichtbar, auf welcher Instanz man ist - die billigste Versicherung
|
|
gegen ein `DELETE` in der falschen Datenbank.
|
|
|
|
Dafür muss der Healthcheck mitgezogen werden: das Basis-File hat `pg_isready -d qrmaster`
|
|
hartkodiert ([Zeile 19](docker-compose.yml:19)). Ohne Override prüft er eine Datenbank, die
|
|
es nicht gibt, der Container bleibt `unhealthy`, und `web` startet wegen
|
|
`depends_on: condition: service_healthy` nie.
|
|
|
|
Start:
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```caddyfile
|
|
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
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
# Struktur aus Prod ziehen (keine Zeilen)
|
|
docker exec qrmaster-db pg_dump -U postgres --schema-only qrmaster > schema.sql
|
|
|
|
# in die Test-DB einspielen
|
|
docker exec -i qrmaster-test-db psql -U postgres -d qrmaster_test < 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](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:
|
|
|
|
```yaml
|
|
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
|