Compare commits
3 Commits
53ef4b3b91
...
d623f39c54
| Author | SHA1 | Date | |
|---|---|---|---|
| d623f39c54 | |||
| 40b73877b6 | |||
| 113acc073f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -31,10 +31,14 @@ ENV NEXTAUTH_SECRET="build-time-secret"
|
|||||||
ENV IP_SALT="build-time-salt"
|
ENV IP_SALT="build-time-salt"
|
||||||
ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build"
|
ENV STRIPE_SECRET_KEY="sk_test_placeholder_for_build"
|
||||||
ENV RESEND_API_KEY="re_placeholder_for_build"
|
ENV RESEND_API_KEY="re_placeholder_for_build"
|
||||||
# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www: it is the origin
|
# Marketing host vs app host. NEXT_PUBLIC_WWW_URL must stay on www in production: it is the
|
||||||
# encoded into downloaded QR codes and used for public email links.
|
# origin encoded into downloaded QR codes and used for public email links.
|
||||||
ENV NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"
|
# Declared as ARG so the staging overlay can build the same image pointing at
|
||||||
ENV NEXT_PUBLIC_APP_URL="https://app.qrmaster.net"
|
# testmodul.qrmaster.net - the defaults keep a plain production build unchanged.
|
||||||
|
ARG NEXT_PUBLIC_WWW_URL="https://www.qrmaster.net"
|
||||||
|
ENV NEXT_PUBLIC_WWW_URL=$NEXT_PUBLIC_WWW_URL
|
||||||
|
ARG NEXT_PUBLIC_APP_URL="https://app.qrmaster.net"
|
||||||
|
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
|
||||||
# PostHog Analytics - REQUIRED at build time for client-side bundle
|
# PostHog Analytics - REQUIRED at build time for client-side bundle
|
||||||
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
|
ENV NEXT_PUBLIC_POSTHOG_KEY="phc_97JBJVVQlqqiZuTVRHuBnnG9HasOv3GSsdeVjossizJ"
|
||||||
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
ENV NEXT_PUBLIC_POSTHOG_HOST="https://us.i.posthog.com"
|
||||||
@@ -50,6 +54,10 @@ ENV NEXT_PUBLIC_UMAMI_ID=$NEXT_PUBLIC_UMAMI_ID
|
|||||||
# middleware and the route handlers disagreeing about the cookie scope.
|
# middleware and the route handlers disagreeing about the cookie scope.
|
||||||
ARG COOKIE_DOMAIN=""
|
ARG COOKIE_DOMAIN=""
|
||||||
ENV COOKIE_DOMAIN=$COOKIE_DOMAIN
|
ENV COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||||
|
# Distinct session cookie name for the staging deployment, so its cookie cannot collide
|
||||||
|
# with the production one the browser also sends to testmodul.qrmaster.net.
|
||||||
|
ARG AUTH_COOKIE_NAME=""
|
||||||
|
ENV AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
299
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
299
PLAN_TESTUMGEBUNG_2026-08-12.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# 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
|
||||||
65
docker-compose.test.yml
Normal file
65
docker-compose.test.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Test/staging overlay for testmodul.qrmaster.net.
|
||||||
|
#
|
||||||
|
# Start with:
|
||||||
|
# docker compose -p qrmaster-test --env-file .env.test \
|
||||||
|
# -f docker-compose.yml -f docker-compose.test.yml up -d --build
|
||||||
|
#
|
||||||
|
# The project name is what keeps the data apart: `-p qrmaster-test` gives this stack its own
|
||||||
|
# volumes, so its Postgres can never touch the production one.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
container_name: qrmaster-test-db
|
||||||
|
# Production already publishes 5435 on the host.
|
||||||
|
ports: !reset []
|
||||||
|
# The base file hardcodes the database name in the probe. Staging uses its own name so a
|
||||||
|
# hand-typed psql session makes it obvious which instance you are on - without this
|
||||||
|
# override the probe would check a database that does not exist, the container would stay
|
||||||
|
# unhealthy and web (depends_on: service_healthy) would never start.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d qrmaster_test"]
|
||||||
|
# Only on the internal network. `db` and `redis` are network aliases assigned per
|
||||||
|
# compose project, so leaving them on the shared external network would make `db`
|
||||||
|
# resolve to two containers and this stack could reach the production database.
|
||||||
|
#
|
||||||
|
# `!override` is required: compose MERGES the networks mapping from the base file, so a
|
||||||
|
# plain list would leave qrmaster-network attached and reintroduce exactly that bug.
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: qrmaster-test-redis
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
web:
|
||||||
|
container_name: qrmaster-test-web
|
||||||
|
# No `prisma migrate deploy` here. The migrations in prisma/migrations stopped in
|
||||||
|
# April 2026 and the schema has moved on through manual SQL since, so running them
|
||||||
|
# against a fresh database would build a stale schema the app cannot work with.
|
||||||
|
# Bring the schema in with `pg_dump --schema-only` from production instead.
|
||||||
|
entrypoint: ["node", "server.js"]
|
||||||
|
build:
|
||||||
|
args:
|
||||||
|
# Host-only cookie on staging, so `:-` (empty) is the correct value here.
|
||||||
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
# These three use `:?` on purpose: an empty value would silently fall back to the
|
||||||
|
# production defaults baked into the Dockerfile, and the staging frontend would then
|
||||||
|
# talk to production. Better to fail the build with a readable message.
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:?set AUTH_COOKIE_NAME in .env.test, e.g. userId_test}
|
||||||
|
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:?set NEXT_PUBLIC_WWW_URL in .env.test to https://testmodul.qrmaster.net}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env.test to https://testmodul.qrmaster.net}
|
||||||
|
# Reachable by Caddy over the shared network, everything else stays internal.
|
||||||
|
networks:
|
||||||
|
- test-internal
|
||||||
|
- qrmaster-network
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
container_name: qrmaster-test-adminer
|
||||||
|
ports: !reset []
|
||||||
|
networks: !override
|
||||||
|
- test-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
test-internal:
|
||||||
|
driver: bridge
|
||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
|
NEXT_PUBLIC_UMAMI_SRC: ${NEXT_PUBLIC_UMAMI_SRC:-}
|
||||||
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
|
NEXT_PUBLIC_UMAMI_ID: ${NEXT_PUBLIC_UMAMI_ID:-}
|
||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
container_name: qrmaster-web
|
container_name: qrmaster-web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -59,6 +60,7 @@ services:
|
|||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3050}
|
||||||
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050}
|
NEXT_PUBLIC_WWW_URL: ${NEXT_PUBLIC_WWW_URL:-http://localhost:3050}
|
||||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||||
|
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-}
|
||||||
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
INTERNAL_API_SECRET: ${INTERNAL_API_SECRET}
|
||||||
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-}
|
||||||
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-}
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ NEXTAUTH_SECRET=your-secret-key-here-change-in-production
|
|||||||
# between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production.
|
# between www.qrmaster.net and app.qrmaster.net. Only honoured when NODE_ENV=production.
|
||||||
COOKIE_DOMAIN=
|
COOKIE_DOMAIN=
|
||||||
|
|
||||||
|
# Name of the session cookie. Leave empty in production and development (defaults to
|
||||||
|
# `userId`). The staging deployment on testmodul.qrmaster.net must set its own name, e.g.
|
||||||
|
# `userId_test`: production scopes its cookie to .qrmaster.net, so the browser sends it to
|
||||||
|
# every subdomain, and two cookies with the same name make the lookup ambiguous.
|
||||||
|
# Changing this in production logs out every user.
|
||||||
|
AUTH_COOKIE_NAME=
|
||||||
|
|
||||||
# Host split: marketing/SEO on WWW, the logged-in app on APP. Keep both pointing at the
|
# Host split: marketing/SEO on WWW, the logged-in app on APP. Keep both pointing at the
|
||||||
# same origin locally so nothing redirects across hosts in development.
|
# same origin locally so nothing redirects across hosts in development.
|
||||||
# In production: NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
|
# In production: NEXT_PUBLIC_WWW_URL=https://www.qrmaster.net
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
appendExpiredCookies,
|
appendExpiredCookies,
|
||||||
|
getAuthCookieName,
|
||||||
getAuthCookieOptions,
|
getAuthCookieOptions,
|
||||||
getCookieDomain,
|
getCookieDomain,
|
||||||
getFlowCookieOptions,
|
getFlowCookieOptions,
|
||||||
@@ -224,7 +225,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const redirectUrl = new URL(urlForPath(onboardingTarget));
|
const redirectUrl = new URL(urlForPath(onboardingTarget));
|
||||||
|
|
||||||
const response = NextResponse.redirect(redirectUrl.toString());
|
const response = NextResponse.redirect(redirectUrl.toString());
|
||||||
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions());
|
||||||
response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
response.cookies.delete({ name: GOOGLE_OAUTH_STATE_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
response.cookies.delete({ name: POST_AUTH_REDIRECT_COOKIE_NAME, path: '/', domain: getCookieDomain() });
|
||||||
// Must stay after the last cookies.set()/delete() call - see appendExpiredCookies.
|
// Must stay after the last cookies.set()/delete() call - see appendExpiredCookies.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
|
import { ATTRIBUTION_COOKIE_NAME } from '@/lib/revops';
|
||||||
import { appendExpiredCookies } from '@/lib/cookieConfig';
|
import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const response = NextResponse.json({ success: true });
|
const response = NextResponse.json({ success: true });
|
||||||
@@ -9,7 +9,7 @@ export async function POST() {
|
|||||||
// it can only ever emit one variant per cookie. Logout has to expire both the
|
// it can only ever emit one variant per cookie. Logout has to expire both the
|
||||||
// host-only and the domain-scoped variant (see appendExpiredCookies).
|
// host-only and the domain-scoped variant (see appendExpiredCookies).
|
||||||
appendExpiredCookies(response.headers, [
|
appendExpiredCookies(response.headers, [
|
||||||
{ name: 'userId', httpOnly: true },
|
{ name: getAuthCookieName(), httpOnly: true },
|
||||||
{ name: 'newsletter-admin', httpOnly: true },
|
{ name: 'newsletter-admin', httpOnly: true },
|
||||||
{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
|
{ name: ATTRIBUTION_COOKIE_NAME, httpOnly: false },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
import { getAuthCookieName, getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||||
import { signUserId } from '@/lib/session';
|
import { signUserId } from '@/lib/session';
|
||||||
import { sendWelcomeEmail } from '@/lib/email';
|
import { sendWelcomeEmail } from '@/lib/email';
|
||||||
import { appUrl, wwwUrl } from '@/lib/hosts';
|
import { appUrl, wwwUrl } from '@/lib/hosts';
|
||||||
@@ -37,6 +37,6 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = NextResponse.redirect(appUrl('/onboarding?email_verified=1'));
|
const response = NextResponse.redirect(appUrl('/onboarding?email_verified=1'));
|
||||||
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
|
response.cookies.set(getAuthCookieName(), signUserId(user.id), getAuthCookieOptions());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { appendExpiredCookies, getAuthCookieName } from '@/lib/cookieConfig';
|
||||||
import { getSessionUserId } from '@/lib/session';
|
import { getSessionUserId } from '@/lib/session';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { stripe } from '@/lib/stripe';
|
import { stripe } from '@/lib/stripe';
|
||||||
@@ -73,10 +73,13 @@ export async function DELETE(request: NextRequest) {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear auth cookie
|
// Clear auth cookie. Same reasoning as the logout route: both the host-only and the
|
||||||
cookies().delete('userId');
|
// domain-scoped variant have to be expired, otherwise the survivor keeps a session
|
||||||
|
// pointing at a user row that no longer exists.
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
appendExpiredCookies(response.headers, [{ name: getAuthCookieName(), httpOnly: true }]);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting account:', error);
|
console.error('Error deleting account:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { MetadataRoute } from 'next';
|
|||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
const baseUrl = 'https://www.qrmaster.net';
|
const baseUrl = 'https://www.qrmaster.net';
|
||||||
|
|
||||||
|
// Staging (testmodul.qrmaster.net) runs the same code on a real qrmaster.net subdomain.
|
||||||
|
// Without this it would serve the production robots.txt and invite crawlers in, competing
|
||||||
|
// with www for the same content. The layouts already emit a noindex meta tag when this
|
||||||
|
// flag is off; this closes the robots.txt half.
|
||||||
|
if (process.env.NEXT_PUBLIC_INDEXABLE !== 'true') {
|
||||||
|
return {
|
||||||
|
rules: [{ userAgent: '*', disallow: '/' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const privatePaths = [
|
const privatePaths = [
|
||||||
'/api/',
|
'/api/',
|
||||||
'/dashboard/',
|
'/dashboard/',
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export function getCookieDomain(): string | undefined {
|
|||||||
return domain ? domain : undefined;
|
return domain ? domain : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name of the session cookie.
|
||||||
|
*
|
||||||
|
* Configurable so a staging deployment on another qrmaster.net subdomain can pick a
|
||||||
|
* distinct name. Production scopes its cookie to `.qrmaster.net`, so the browser sends it
|
||||||
|
* to testmodul.qrmaster.net as well; two cookies with the same name would make
|
||||||
|
* `cookies.get()` ambiguous and staging logins flaky.
|
||||||
|
*
|
||||||
|
* Like COOKIE_DOMAIN this must be set at build time too, because process.env is inlined
|
||||||
|
* into the Edge middleware bundle.
|
||||||
|
*/
|
||||||
|
export function getAuthCookieName(): string {
|
||||||
|
return process.env.AUTH_COOKIE_NAME?.trim() || 'userId';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cookie options for authentication cookies
|
* Get cookie options for authentication cookies
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { getAuthCookieOptions } from './cookieConfig';
|
import { getAuthCookieName, getAuthCookieOptions } from './cookieConfig';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signed session cookie.
|
* Signed session cookie.
|
||||||
@@ -11,8 +11,6 @@ import { getAuthCookieOptions } from './cookieConfig';
|
|||||||
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
|
* detect a tampered/forged cookie and reject it. Format: `<userId>.<signature>`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const AUTH_COOKIE_NAME = 'userId';
|
|
||||||
|
|
||||||
function getSecret(): string {
|
function getSecret(): string {
|
||||||
const secret = process.env.NEXTAUTH_SECRET;
|
const secret = process.env.NEXTAUTH_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
@@ -68,12 +66,12 @@ export function verifySignedUserId(value: string | undefined | null): string | n
|
|||||||
* Use this in route handlers instead of reading the `userId` cookie directly.
|
* Use this in route handlers instead of reading the `userId` cookie directly.
|
||||||
*/
|
*/
|
||||||
export function getSessionUserId(): string | null {
|
export function getSessionUserId(): string | null {
|
||||||
return verifySignedUserId(cookies().get(AUTH_COOKIE_NAME)?.value);
|
return verifySignedUserId(cookies().get(getAuthCookieName())?.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the signed auth cookie for the given user id (server component / route handler context).
|
* Set the signed auth cookie for the given user id (server component / route handler context).
|
||||||
*/
|
*/
|
||||||
export function setSessionCookie(userId: string): void {
|
export function setSessionCookie(userId: string): void {
|
||||||
cookies().set(AUTH_COOKIE_NAME, signUserId(userId), getAuthCookieOptions());
|
cookies().set(getAuthCookieName(), signUserId(userId), getAuthCookieOptions());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
serializeAttributionCookie,
|
serializeAttributionCookie,
|
||||||
} from '@/lib/revops';
|
} from '@/lib/revops';
|
||||||
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
import { verifySignedUserIdEdge } from '@/lib/session-edge';
|
||||||
import { getCookieDomain } from '@/lib/cookieConfig';
|
import { getAuthCookieName, getCookieDomain } from '@/lib/cookieConfig';
|
||||||
import {
|
import {
|
||||||
getAppOrigin,
|
getAppOrigin,
|
||||||
getWwwOrigin,
|
getWwwOrigin,
|
||||||
@@ -247,7 +247,7 @@ async function routeRequest(req: NextRequest): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For protected routes, require a validly signed userId cookie
|
// For protected routes, require a validly signed userId cookie
|
||||||
const userId = await verifySignedUserIdEdge(req.cookies.get('userId')?.value);
|
const userId = await verifySignedUserIdEdge(req.cookies.get(getAuthCookieName())?.value);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Not authenticated - redirect to signup, which lives on the marketing host.
|
// Not authenticated - redirect to signup, which lives on the marketing host.
|
||||||
|
|||||||
Reference in New Issue
Block a user