test(auth): KTO-01 Playwright-E2E (api, web, throttle) und CI-Job e2e #42

Merged
cschlaefke merged 9 commits from feature/38-auth-e2e into main 2026-11-03 10:42:02 +00:00
38 changed files with 3877 additions and 1 deletions

43
.forgejo/scripts/e2e-run.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Playwright-E2E (Projekte api, web, throttle) gegen den laufenden Supabase-Stack im dind (Issue #38, D2).
# Voraussetzungen: DOCKER_HOST/DOCKER_GW gesetzt, `supabase start` lief, Extra-GoTrue auf :9998 laeuft,
# Web-Build in app/build/web. Die Supabase-Ports liegen im dind; ein TCP-Relay macht sie unter
# 127.0.0.1 erreichbar, wie es die Tests und die Web-App (CORS/Origin, Site-URL localhost:3000) erwarten.
# Der Spike (SPIKE_GOTRUE_URL) laeuft in CI bewusst nicht.
set -euo pipefail
: "${DOCKER_GW:?DOCKER_GW (dind-Gateway) fehlt}"
: "${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY fehlt}"
node e2e/support/tcp-forward.mjs "$DOCKER_GW" 54321 54322 54324 9998 &
relay=$!
trap 'kill "$relay" 2>/dev/null || true' EXIT
# Warten, bis die Dienste HINTER dem Relay antworten (nicht nur der lokale Port); Timeout = Fehler.
wait_http() {
for _ in $(seq 1 60); do
curl -fsS -o /dev/null --max-time 3 -H "apikey: ${SUPABASE_ANON_KEY}" "$1" 2>/dev/null && return 0
sleep 1
done
echo "Timeout: $1 nicht erreichbar (Relay zu ${DOCKER_GW})" >&2
return 1
}
wait_http "http://127.0.0.1:54321/auth/v1/health" || exit 1 # Kong -> GoTrue
wait_http "http://127.0.0.1:54324/api/v1/info" || exit 1
wait_http "http://127.0.0.1:9998/health" || exit 1
pg_ok=""
for _ in $(seq 1 60); do
if (exec 3<>/dev/tcp/127.0.0.1/54322) 2>/dev/null; then pg_ok=1; break; fi
sleep 1
done
[ -n "$pg_ok" ] || { echo "Timeout: Postgres 54322 nicht erreichbar" >&2; exit 1; }
export SUPABASE_URL=http://127.0.0.1:54321
export MAILPIT_URL=http://127.0.0.1:54324
export E2E_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
export WEB_URL=http://localhost:3000
export THROTTLE_GOTRUE_URL=http://127.0.0.1:9998
export CI=true
cd e2e
npx playwright test --project=api --project=web --project=throttle

View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Mailvorlagen unter einem entfernten Docker-Daemon (dind) bereitstellen (Issue #38, CI-Laeufe 27/28).
#
# Ursache: Die Supabase CLI bindet supabase/templates/*.html per Bind-Mount mit dem Pfad des Job-Containers
# (z. B. /workspace/<owner>/Shuffle/supabase/templates/confirmation.html) in den Kong-Container ein
# (-> /home/kong/templates/email/<name>.html); GoTrue holt die Vorlagen per URL von Kong. Im dind existiert
# der Pfad nicht. Docker legt dann ein leeres VERZEICHNIS an, Kong zeigt "Index of /email/confirmation.html/",
# und GoTrue verschickt diese Seite still statt der Vorlage (Spec R5). Ein nachtraegliches Ersetzen hilft nicht:
# das Mount-Ziel im Container-Rootfs ist dann ein Verzeichnis, ein Neustart scheitert (runc).
#
# Deshalb: VOR `supabase start` die Dateien im dind unter genau diesem Pfad anlegen (Helfer-Container,
# Inhalt per stdin), dann entstehen von Anfang an Datei-Mounts. Die Auslieferung bleibt eine URL
# (wie in Staging/Prod: GOTRUE_MAILER_TEMPLATES_*).
#
# remote-templates.sh seed vor `supabase start`
# remote-templates.sh verify nach `start`/`db reset`: Datei-Mounts und Inhalt pruefen, sonst Exit 1
# Keine Secrets: Ausgabe nur Pfade und Dateinamen.
set -euo pipefail
PROJECT_ID="${SUPABASE_PROJECT_ID:-shuffle}"
HELPER_IMAGE="${HELPER_IMAGE:-public.ecr.aws/docker/library/busybox:1.37.0}"
SRC_DIR="$(pwd)/supabase/templates" # Pfad, den die CLI als Mount-Quelle verwendet
KONG="supabase_kong_${PROJECT_ID}"
seed() {
ls "$SRC_DIR"/*.html >/dev/null 2>&1 || { echo "Keine Vorlagen in $SRC_DIR" >&2; exit 1; }
for f in "$SRC_DIR"/*.html; do
name=$(basename "$f")
# Reste frueherer Laeufe (leeres Verzeichnis) werden ersetzt.
docker run --rm -i -v "$SRC_DIR:/t" "$HELPER_IMAGE" \
sh -c "[ ! -d '/t/$name' ] || rmdir '/t/$name'; cat > '/t/$name'" < "$f" \
|| { echo "Vorlage $name konnte im dind nicht angelegt werden (Image $HELPER_IMAGE, Pfad $SRC_DIR)" >&2; exit 1; }
echo "Vorlage im dind angelegt: $SRC_DIR/$name"
done
}
verify() {
ok=1
for f in "$SRC_DIR"/*.html; do
name=$(basename "$f")
if ! docker exec "$KONG" sh -c "[ -f '/home/kong/templates/email/$name' ] && [ -s '/home/kong/templates/email/$name' ]" 2>/dev/null; then
echo "FEHLER: $KONG: /home/kong/templates/email/$name ist keine nicht-leere Datei." >&2
ok=0
fi
done
if [ "$ok" -ne 1 ]; then
echo "Bind-Mounts von $KONG (Quelle -> Ziel):" >&2
docker inspect "$KONG" --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}{{end}}' >&2 || true
echo "Ohne gueltige Vorlagen verschickt GoTrue still eine Fehlerseite statt der Mail (Spec R5)." >&2
exit 1
fi
echo "Mailvorlagen in $KONG vorhanden"
}
case "${1:-}" in
seed) seed ;;
verify) verify ;;
*) echo "Aufruf: $0 seed|verify" >&2; exit 2 ;;
esac

View file

@ -155,6 +155,9 @@ jobs:
# Nutzt die eingecheckte supabase/config.toml. Ohne passenden Runner Job bei Bedarf deaktivieren. # Nutzt die eingecheckte supabase/config.toml. Ohne passenden Runner Job bei Bedarf deaktivieren.
supabase-tests: supabase-tests:
runs-on: docker-host runs-on: docker-host
concurrency:
group: docker-host-supabase
cancel-in-progress: false
env: env:
DOCKER_CLI_VERSION: 27.3.1 DOCKER_CLI_VERSION: 27.3.1
DIND_PORT: 2375 DIND_PORT: 2375
@ -182,3 +185,157 @@ jobs:
- name: Aufräumen - name: Aufräumen
if: always() if: always()
run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true
# Web-E2E (Playwright: api, web, throttle) gegen lokalen Supabase-Stack und gebaute Flutter-Web-App (Issue #38, Spec #1 D2).
# Läuft wie supabase-tests auf "docker-host" (Docker-CLI + dind per TCP). Der Stack liegt im dind; ein
# TCP-Relay (e2e/support/tcp-forward.mjs) macht ihn im Job unter 127.0.0.1 erreichbar.
# SICHERHEIT: Der Job führt PR-Code mit Zugriff auf den dind-Daemon des Runners aus (Daemon = Root-Äquivalent).
# Der `if:` unten ist KEIN Schutz vor böswilligen Forks: Bei pull_request kommt der Workflow aus dem PR-Head,
# ein Fork kann die Bedingung einfach entfernen. Er verhindert nur versehentliche Läufe. Die echte Absicherung
# gehört in Repo-/Runner-Einstellungen (Freigabe für Fork-PRs, Runner nur für vertrauenswürdige Repos; Folge-Issue).
# Gleiches Risiko hat supabase-tests.
# needs + concurrency: Beide Jobs belegen dieselben festen Container-Namen/Ports (project_id shuffle, 54321 ...) im dind.
# `needs` serialisiert innerhalb eines Laufs; die concurrency-Gruppe (auch an supabase-tests) soll Läufe verschiedener
# PRs hintereinander schalten. Ob Forgejo 8.0.3 / Runner v5.0.4 Job-concurrency auswertet, ist hier nicht verifiziert;
# sonst wird der Schlüssel ignoriert und Kollisionen bleiben möglich (dann Runner mit capacity 1 verwenden).
# Der Spike (SPIKE_GOTRUE_URL, Issue #37) läuft nicht in CI.
e2e:
runs-on: docker-host
concurrency:
group: docker-host-supabase
cancel-in-progress: false
needs: [supabase-tests]
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
env:
DOCKER_CLI_VERSION: 27.3.1
DIND_PORT: 2375
PUB_CACHE: ${{ github.workspace }}/.pub-cache
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Werkzeuge prüfen
run: |
missing=""
for t in git curl unzip xz jq; do
command -v "$t" >/dev/null || { [ "$t" = xz ] && missing="$missing xz-utils" || missing="$missing $t"; }
done
if [ -n "$missing" ]; then
apt-get update -qq && apt-get install -y -qq --no-install-recommends $missing ca-certificates
fi
node --version
- name: Docker-CLI installieren und dind anbinden
run: |
arch=$(uname -m)
curl -fsSL "https://download.docker.com/linux/static/stable/${arch}/docker-${DOCKER_CLI_VERSION}.tgz" \
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker
hex=$(awk '$2=="00000000"{print $3; exit}' /proc/net/route)
gw=$(printf '%d.%d.%d.%d' "0x${hex:6:2}" "0x${hex:4:2}" "0x${hex:2:2}" "0x${hex:0:2}")
echo "DOCKER_GW=${gw}" >> "$GITHUB_ENV"
echo "DOCKER_HOST=tcp://${gw}:${DIND_PORT}" >> "$GITHUB_ENV"
DOCKER_HOST="tcp://${gw}:${DIND_PORT}" docker version --format 'dind {{.Server.Version}}'
- name: Mailvorlagen im dind anlegen
# Unter dind existiert der Bind-Mount-Pfad der Vorlagen nicht; sie müssen VOR `supabase start` dort liegen,
# sonst entstehen leere Verzeichnis-Mounts und GoTrue verschickt still eine Fehlerseite. Details im Skript.
run: bash .forgejo/scripts/remote-templates.sh seed
- name: Supabase starten (mit Mailpit, ohne Studio u. a.)
# Ausgabe nicht ins Log: Die Zusammenfassung von `start` enthält die lokalen Schlüssel (noch nicht maskiert).
run: |
if ! npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} start -x studio,imgproxy,edge-runtime,logflare,vector >"${RUNNER_TEMP:-/tmp}/supabase-start.log" 2>&1; then
echo "supabase start fehlgeschlagen, letzte Zeilen (Schlüssel gefiltert):"
tail -n 40 "${RUNNER_TEMP:-/tmp}/supabase-start.log" | grep -viE 'key|secret|token|jwt'
exit 1
fi
echo "supabase start ok"
- name: Datenbank zurücksetzen
run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} db reset
- name: Mailvorlagen im dind prüfen
run: bash .forgejo/scripts/remote-templates.sh verify
- name: Lokale Schlüssel lesen
# Öffentliche Demo-Schlüssel der CLI; trotzdem wird die Maske gesetzt, bevor sie irgendwo ausgegeben werden.
run: |
status=$(npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} status -o env 2>/dev/null)
get() { printf '%s\n' "$status" | sed -n "s/^$1=\"\{0,1\}\([^\"]*\)\"\{0,1\}$/\1/p" | head -n1; }
anon=$(get ANON_KEY); service=$(get SERVICE_ROLE_KEY)
[ -z "$anon" ] || echo "::add-mask::$anon"
[ -z "$service" ] || echo "::add-mask::$service"
test -n "$anon" && test -n "$service" || { echo "ANON_KEY/SERVICE_ROLE_KEY nicht in 'supabase status -o env' gefunden"; exit 1; }
echo "SUPABASE_ANON_KEY=${anon}" >> "$GITHUB_ENV"
echo "SUPABASE_SERVICE_ROLE_KEY=${service}" >> "$GITHUB_ENV"
# Env-Datei für den Web-Build: Platzhalter-Datei bleibt unverändert, der Schlüssel kommt zur Laufzeit.
jq --arg k "$anon" '.SUPABASE_ANON_KEY = $k | .SUPABASE_URL = "http://127.0.0.1:54321"' \
app/env/ci.example.json > "${RUNNER_TEMP:-/tmp}/ci.e2e.json"
echo "E2E_ENV_FILE=${RUNNER_TEMP:-/tmp}/ci.e2e.json" >> "$GITHUB_ENV"
- name: Extra-GoTrue für Drosselung (Krit. 7) starten
run: |
BIND_ADDR=0.0.0.0 HEALTH_HOST="$DOCKER_GW" bash e2e/support/start-throttle-gotrue.sh start throttle
- name: Flutter-SDK-Cache
uses: https://code.forgejo.org/actions/cache@v4
with:
path: ${{ env.FLUTTER_HOME }}
key: flutter-${{ env.FLUTTER_VERSION }}-docker-host
- name: Flutter installieren
run: |
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
git clone --depth 1 --branch "$FLUTTER_VERSION" https://github.com/flutter/flutter.git "$FLUTTER_HOME"
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
export PATH="$FLUTTER_HOME/bin:$PATH"
export CI=true
flutter config --no-analytics --enable-web
flutter precache --web
- name: Pub-Cache
uses: https://code.forgejo.org/actions/cache@v4
with:
path: ${{ github.workspace }}/.pub-cache
key: pub-${{ env.FLUTTER_VERSION }}-${{ hashFiles('app/pubspec.lock') }}
restore-keys: pub-${{ env.FLUTTER_VERSION }}-
- name: Web-Build mit lokalem Anon-Key
working-directory: app
run: |
flutter pub get
flutter build web --release --dart-define-from-file="$E2E_ENV_FILE"
- name: Playwright-Cache
uses: https://code.forgejo.org/actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: playwright-${{ hashFiles('e2e/package-lock.json') }}
- name: E2E-Abhängigkeiten und Browser
working-directory: e2e
run: |
npm ci
npx playwright install --with-deps chromium
- name: Playwright (api, web, throttle)
run: bash .forgejo/scripts/e2e-run.sh
- name: Artefakte hochladen (Report, Traces)
if: failure()
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: playwright-${{ github.run_number }}
path: |
e2e/playwright-report
e2e/test-results
retention-days: 7
if-no-files-found: ignore
- name: Aufräumen
if: always()
run: |
bash e2e/support/start-throttle-gotrue.sh stop throttle || true
npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true

4
.gitignore vendored
View file

@ -28,6 +28,10 @@ app/coverage/
node_modules/ node_modules/
test-results/ test-results/
playwright-report/ playwright-report/
blob-report/
playwright/.cache/
.playwright-mcp/
e2e/.auth/
# OS # OS
.DS_Store .DS_Store

View file

@ -176,3 +176,42 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
- **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4). - **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4).
- **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5). - **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5).
- **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil. - **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil.
## 7. E2E-Tests und SMTP-Ausfall (Issue #38, Spike #37)
Die Playwright-Tests unter `e2e/` (Projekte `api`, `web`, `throttle`, `spike`; Anleitung `e2e/README.md`) sichern diese Konfiguration ab. Konstanten stehen in `e2e/support/auth-config.ts`; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und diese Datei (alle IDs AUTH-CFG-01 bis -25 müssen in Abschnitt 1 vorkommen).
| ID | Absichernder Test |
|---|---|
| AUTH-CFG-04, -24 | `tests/api/krit04-enumeration.spec.ts` (Signup mit bestätigter Adresse → 200, `identities` leer, keine Mail, altes Passwort gilt) |
| AUTH-CFG-05 | `krit02-03-validierung.spec.ts` (9 Zeichen → 422 `weak_password`) |
| AUTH-CFG-07 | `krit08-reset-link.spec.ts` und `web/krit08-reset.spec.ts` (Link nach 1 h + 1 s abgelaufen, 60 s vor Ablauf gültig) |
| AUTH-CFG-10, -13 | `tests/throttle/krit07-drosselung.spec.ts` (Login #31 → 429, zweite IP unbeeinflusst, kein `Retry-After`, ohne Header keine Drosselung) |
| AUTH-CFG-14, -15, -16 | `krit05-sitzung.spec.ts`, `config.spec.ts` |
| AUTH-CFG-17, -18, -19 | `krit11-sprache-benachrichtigungen.spec.ts` |
### CI-Job `e2e` (Issue #38, Spec #1 D2)
Der Job `e2e` in `.forgejo/workflows/ci.yml` läuft auf dem Runner `docker-host` (Docker-CLI + dind per TCP, wie `supabase-tests`) und führt die Projekte `api`, `web` und `throttle` aus. Ablauf:
1. `supabase start -x studio,imgproxy,edge-runtime,logflare,vector` (Mailpit und Postgres bleiben) und `supabase db reset`.
2. Anon- und Service-Role-Key kommen zur Laufzeit aus `supabase status -o env`; daraus entsteht eine temporäre Env-Datei (`jq` auf `app/env/ci.example.json`, im `RUNNER_TEMP`, nicht im Repo) für `flutter build web --dart-define-from-file`. Die Datei `ci.example.json` bleibt unverändert.
3. Zweiter GoTrue-Container für Krit. 7: `BIND_ADDR=0.0.0.0 HEALTH_HOST=<dind-Gateway> e2e/support/start-throttle-gotrue.sh start throttle` (Port 9998).
4. `.forgejo/scripts/e2e-run.sh` startet den TCP-Relay `e2e/support/tcp-forward.mjs` (127.0.0.1:54321/54322/54324/9998 -> dind-Gateway) und ruft `npx playwright test --project=api --project=web --project=throttle` auf. Der Relay ist nötig, weil die Ports im dind liegen, die Tests, die Web-App (CORS/Origin) und `e2e/support/db.ts` (erlaubt nur lokale Hosts) aber 127.0.0.1 erwarten. Die statische Auslieferung der Web-App auf Port 3000 übernimmt das `webServer` der Playwright-Konfiguration (`serve -s`).
5. Bei Fehlern werden `e2e/playwright-report` und `e2e/test-results` (Traces) als Artefakt `playwright-<Lauf>` hochgeladen. Der Schritt „Aufräumen“ (`if: always()`) entfernt den Extra-Container und stoppt den Supabase-Stack.
Das Projekt `spike` (SMTP-Ausfall, `SPIKE_GOTRUE_URL`) läuft in CI nicht. Der Job hat `needs: supabase-tests` (gleiche Container-Namen und Ports im dind) und läuft nicht für Fork-PRs: Wer PR-Code auf `docker-host` ausführt, hat Zugriff auf den dind-Daemon des Runners.
**Achtung Artefakte:** Playwright-Reports, Traces und Screenshots (CI-Artefakte, `test-results/`, `playwright-report/`) enthalten Tokens, Passwörter und Mailinhalte der angelegten Testkonten. Die Konten existieren nur in der Wegwerf-Instanz (lokale CLI/CI), dennoch Artefakte nicht in ein öffentliches Repo oder eine öffentliche Release-Seite legen und nur kurz aufbewahren. Die Tests lehnen nicht-lokale Hosts ab (`SUPABASE_URL`, `MAILPIT_URL`, `E2E_DB_URL`; CI-Hosts nur explizit über `E2E_ALLOWED_HOSTS`).
### SMTP-Ausfall (Spike #37, Stand 2026-11-03, GoTrue `v2.197.0`)
Messung gegen einen zusätzlichen GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt (`e2e/support/start-throttle-gotrue.sh start smtp-down`, Test `e2e/tests/spike/smtp-down.spec.ts`). Mailpit bleibt für die anderen Tests unberührt. Die Tests prüfen nichts, sie protokollieren.
| Aufruf | bekannte Adresse | unbekannte Adresse |
|---|---|---|
| `POST /recover` | **500** `unexpected_failure` „Error sending recovery email“ | **200** |
| `POST /resend` (signup) | unbestätigt: **500** „Error sending confirmation email“ | **200** |
| `POST /signup` | unbestätigt: **500**; bestätigt: **200** (erfundener Nutzer, kein Versand) | neu: **500** „Error sending confirmation email“, Nutzer wird **nicht** angelegt (Rollback) |
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; laut Mapping 4.4 (nicht getestet) würde die App bei Reset 500 als `unexpected`, bei Registrierung als `registrationRejected` anzeigen, also einen Fehler statt des neutralen Bildschirms. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.

View file

@ -355,7 +355,7 @@ Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“).
| Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 | | Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 |
| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 | | Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 |
| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 | | Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 |
| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 | | Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt; ein geändertes `expires_at` im `localStorage` wirkt nicht, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 |
| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 | | Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 |
| Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 | | Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 |
| QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 | | QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 |
@ -509,6 +509,11 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert). 8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).
9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab. 9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.
### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`)
1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit.
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`).
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris ### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst. 1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst.

55
e2e/README.md Normal file
View file

@ -0,0 +1,55 @@
# E2E-Tests (Playwright)
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38). Jeder Test
nutzt zufällige E-Mail-Adressen und filtert Mails je Adresse (Mailpit wird nie global geleert),
die Tests laufen deshalb parallel.
## Projekte
| Projekt | Ziel | Voraussetzung | Tests |
|---|---|---|---|
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 21 |
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
`throttle` und `spike` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
## Lokal ausführen
```bash
supabase start -x studio,imgproxy,vector,logflare,edge-runtime,postgres-meta,realtime,storage-api
supabase db reset
# Web-Build gegen die lokale CLI (echten Anon-Key aus `supabase status -o env` eintragen)
cd app && flutter build web --dart-define-from-file=env/ci.example.json # SUPABASE_URL/ANON_KEY anpassen
cd ../e2e && npm ci && npx playwright install chromium
npx playwright test --project=api
npx playwright test --project=web # startet `serve -s ../app/build/web -l 3000` selbst
# Drosselung (Krit. 7) und SMTP-Spike
./support/start-throttle-gotrue.sh start throttle # :9998
./support/start-throttle-gotrue.sh start smtp-down # :9997
THROTTLE_GOTRUE_URL=http://127.0.0.1:9998 SPIKE_GOTRUE_URL=http://127.0.0.1:9997 npx playwright test
./support/start-throttle-gotrue.sh stop throttle; ./support/start-throttle-gotrue.sh stop smtp-down
```
Umgebungsvariablen (Standard = lokale CLI-Demo-Werte, keine Secrets): `SUPABASE_URL`,
`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`,
`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]).
## Aufbau
- `support/auth-config.ts` – Konstanten mit `AUTH-CFG-nn`-Bezug; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und `docs/auth-konfiguration.md`.
- `support/gotrue.ts`, `support/mailpit.ts` – REST-Client und Mailfänger-Helfer.
- `support/db.ts` – direkter Postgres-Zugriff, **nur lokal/CI** (bricht bei anderem Host ab); setzt `recovery_sent_at`/`confirmation_sent_at` für Ablauftests.
- `support/web.ts` – Flutter-Web-Helfer (Semantics einschalten, Felder füllen, Uhr verschieben).
- `support/start-throttle-gotrue.sh` – zweiter GoTrue-Container (Profile `throttle`, `smtp-down`).
## Hinweise zum Web-Projekt
- Flutter zeichnet auf Canvas. Der Semantics-Baum wird über `flt-semantics-placeholder` eingeschaltet (`enableSemantics`).
- Eingabefelder werden mit `click` + `pressSequentially` gefüllt; `fill` allein geht gelegentlich verloren.
- Immer `localhost` (nicht `127.0.0.1`): Die Mail-Links nutzen die Site-URL (AUTH-CFG-01), sonst liegt die Sitzung in einem anderen `localStorage`.
- Gegen die gemeinsame CLI-Instanz werden nie `X-Forwarded-For`-Header gesendet (R13).

1311
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
e2e/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "shuffle-e2e",
"private": true,
"version": "0.0.0",
"description": "Playwright E2E tests for Shuffle (auth: KTO-01, Issue #38)",
"scripts": {
"test": "playwright test",
"test:api": "playwright test --project=api",
"test:web": "playwright test --project=web",
"test:throttle": "playwright test --project=throttle",
"test:spike": "playwright test --project=spike"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"pg": "^8.13.0",
"pg-connection-string": "^2.7.0",
"serve": "^14.2.4",
"typescript": "^5.6.0"
}
}

54
e2e/playwright.config.ts Normal file
View file

@ -0,0 +1,54 @@
import { defineConfig, devices, type Project } from '@playwright/test';
import { env } from './support/auth-config';
const webUrl = env.webUrl;
/**
* Projekte (Spec #1 § 7):
* - api GoTrue ueber Kong + Mailpit-Helfer
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
*/
const projects: Project[] = [
{ name: 'api', testDir: './tests/api', use: {} },
{
name: 'web',
testDir: './tests/web',
use: {
...devices['Desktop Chrome'],
baseURL: webUrl,
// Englische Texte (app_en.arb); die App waehlt die Sprache nach dem Browser.
locale: 'en-US',
// Der Flutter-Service-Worker wuerde alte Builds cachen.
serviceWorkers: 'block',
},
},
];
if (process.env.THROTTLE_GOTRUE_URL) {
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
}
if (process.env.SPIKE_GOTRUE_URL) {
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
}
export default defineConfig({
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: { trace: 'retain-on-failure' },
projects,
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404.
webServer: {
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
url: webUrl,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});

119
e2e/support/auth-config.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Konstanten der Auth-Konfiguration (Spec #1 § 4.1, `docs/auth-konfiguration.md`).
*
* Diese Datei muss mit `supabase/config.toml` und `docs/auth-konfiguration.md` uebereinstimmen;
* `tests/api/config.spec.ts` prueft das. Tests lesen Zahlen nur von hier und verweisen im Namen
* auf die Zeilen-ID (z. B. `AUTH-CFG-07`).
*
* Werte, die lokal (Supabase CLI) und in Staging/Prod unterschiedlich sind, tragen hier den
* LOKALEN Wert (die E2E-Tests laufen nur lokal bzw. in der CI gegen die CLI).
*/
/** Hosts, die als „lokal“ gelten (URL-Hostnamen; IPv6 steht in URL.hostname mit Klammern). */
const LOCAL_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
/**
* Zusaetzlich erlaubte Hosts fuer die CI (z. B. der Docker-Host `docker`), kommagetrennt in
* E2E_ALLOWED_HOSTS. Nur setzen, wenn der Dienst ein Wegwerf-Stack ist.
*/
function allowedHosts(): string[] {
const extra = (process.env.E2E_ALLOWED_HOSTS ?? '').split(',').map((h) => h.trim()).filter(Boolean);
return [...LOCAL_HOSTS, ...extra];
}
export function isLocalHost(hostname: string): boolean {
return allowedHosts().includes(hostname.toLowerCase());
}
/** Bricht ab, wenn die URL nicht auf einen lokalen (oder explizit erlaubten) Host zeigt. */
export function assertLocalUrl(name: string, value: string): string {
const host = new URL(value).hostname;
if (!isLocalHost(host)) {
throw new Error(`${name}: Host "${host}" ist nicht lokal. Die E2E-Tests legen Testkonten an; ggf. E2E_ALLOWED_HOSTS setzen.`);
}
return value;
}
const DEMO_ANON_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';
const DEMO_SERVICE_KEY =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU';
const supabaseUrl = assertLocalUrl('SUPABASE_URL', process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321');
const isDefaultLocal = ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(supabaseUrl).hostname);
function key(envName: string, demo: string): string {
const v = process.env[envName];
if (v) return v;
// Demo-Schluessel nur bei lokaler URL; sonst muss der Aufrufer den Schluessel setzen.
if (!isDefaultLocal) throw new Error(`${envName} muss gesetzt sein, wenn SUPABASE_URL nicht lokal ist.`);
return demo;
}
/** Umgebung (Standardwerte = lokale Demo-Werte der Supabase CLI, keine Secrets). */
export const env = {
/** Kong, wie die App. */
supabaseUrl,
/** Legacy-JWT-Anon-Key der CLI (oeffentlicher Demo-Wert, nur bei lokaler URL als Fallback). */
anonKey: key('SUPABASE_ANON_KEY', DEMO_ANON_KEY),
/** Legacy-Service-Role-Key der CLI (oeffentlicher Demo-Wert, nur lokal). */
serviceRoleKey: key('SUPABASE_SERVICE_ROLE_KEY', DEMO_SERVICE_KEY),
mailpitUrl: assertLocalUrl('MAILPIT_URL', process.env.MAILPIT_URL ?? 'http://127.0.0.1:54324'),
dbUrl: process.env.E2E_DB_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
/** Web-Build. Immer `localhost` (nicht 127.0.0.1): Die Mail-Links nutzen AUTH-CFG-01. */
webUrl: process.env.WEB_URL ?? 'http://localhost:3000',
/** Zusaetzlicher GoTrue-Container mit AUTH-CFG-13 (Projekt `throttle`, Skript start-throttle-gotrue.sh). */
throttleGotrueUrl: process.env.THROTTLE_GOTRUE_URL,
/** GoTrue-Container mit unerreichbarem SMTP (Projekt `spike`, Issue #37). */
spikeGotrueUrl: process.env.SPIKE_GOTRUE_URL,
} as const;
export const authConfig = {
/** AUTH-CFG-01: Site-URL lokal. */
siteUrl: 'http://localhost:3000',
/** AUTH-CFG-05: Passwort-Mindestlaenge. */
passwordMinLength: 10,
/** AUTH-CFG-07: Gueltigkeit Bestaetigungs- und Reset-Link in Sekunden. */
otpExpirySeconds: 3600,
/** AUTH-CFG-08: Mindestabstand zwischen Mails je Nutzer, lokal 1 s. */
maxFrequencySeconds: 1,
/** AUTH-CFG-10: Burst des Login-Limiters je IP (Login #31 -> 429). */
loginBurst: 30,
/** AUTH-CFG-11: Burst des OTP-Limiters (Signup, Recover, Resend) je IP. */
otpBurst: 30,
/** AUTH-CFG-14: JWT-Laufzeit in Sekunden. */
jwtExpirySeconds: 3600,
/** AUTH-CFG-15: Wiederverwendungsfenster der Refresh-Token-Rotation in Sekunden. */
refreshTokenReuseIntervalSeconds: 10,
/** Anzeigename: Grenzen in Codepoints (Spec 3.2). */
displayNameMaxLength: 30,
} as const;
/** Mailbetreffe (AUTH-CFG-17/-18, `supabase/config.toml`). */
export const subjects = {
confirmation: { de: 'Bestätige deine E-Mail-Adresse für Shuffle', en: 'Confirm your email address for Shuffle' },
recovery: { de: 'Passwort für Shuffle zurücksetzen', en: 'Reset your Shuffle password' },
} as const;
/** Fehlercodes von GoTrue, auf die sich die Spec stuetzt (§ 4.3/4.4). */
export const errorCodes = {
invalidCredentials: 'invalid_credentials',
emailNotConfirmed: 'email_not_confirmed',
weakPassword: 'weak_password',
samePassword: 'same_password',
otpExpired: 'otp_expired',
overRequestRateLimit: 'over_request_rate_limit',
overEmailSendRateLimit: 'over_email_send_rate_limit',
userAlreadyExists: 'user_already_exists',
} as const;
/** Trigger-Fehlertexte bei signUp (HTTP 500, Body `{"code":"P0001","message":...}`, B0 (f)). */
export const triggerErrors = {
displayNameInvalid: 'display_name_invalid',
ageConfirmationRequired: 'age_confirmation_required',
termsAcceptanceRequired: 'terms_acceptance_required',
termsVersionRequired: 'terms_version_required',
} as const;
/** Version der Rechtstexte, die der Web-Build sendet (`app/env/ci.example.json`). */
export const TERMS_VERSION = process.env.TERMS_VERSION ?? '2026-11-draft';

103
e2e/support/db.ts Normal file
View file

@ -0,0 +1,103 @@
/**
* Direkter Datenbankzugriff fuer Tests. NUR lokal/CI: der Helfer bricht ab, wenn der Host nicht
* lokal ist (er veraendert `auth.users`). Spec 7.1 Krit. 8 (B0 (i)): Maßgeblich fuer den
* Linkablauf sind `auth.users.recovery_sent_at` und `confirmation_sent_at`, nicht
* `auth.one_time_tokens.created_at`.
*/
import { Client } from 'pg';
import { parse } from 'pg-connection-string';
import { authConfig, env, isLocalHost } from './auth-config';
/**
* Parst die Verbindungszeichenfolge mit demselben Parser wie `pg` und prueft den Host des
* GEPARSTEN Ergebnisses (auch `?host=...`, Unix-Sockets und mehrere Hosts werden so erfasst).
* Der Client wird aus genau diesem Ergebnis gebaut, nicht erneut aus dem String.
*/
export function localDbConfig(connectionString: string) {
const cfg = parse(connectionString);
const host = (cfg.host ?? '').replace(/^\[|\]$/g, '');
// `parse` liefert IPv6 ohne Klammern; unsere Liste fuehrt sie mit Klammern.
const candidate = host.includes(':') ? `[${host}]` : host;
if (!candidate || !isLocalHost(candidate)) {
throw new Error(`e2e/support/db.ts nur lokal/CI: Host "${cfg.host}" ist nicht erlaubt`);
}
return cfg;
}
export async function withDb<T>(fn: (c: Client) => Promise<T>): Promise<T> {
const cfg = localDbConfig(env.dbUrl);
const client = new Client({
host: cfg.host ?? undefined,
port: cfg.port ? Number(cfg.port) : undefined,
user: cfg.user,
password: cfg.password,
database: cfg.database ?? undefined,
});
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
/** Zeilen aus `auth.users` zu E-Mail oder ID (nie global zaehlen: Tests laufen parallel). */
export async function usersWhere(by: { email?: string; id?: string }) {
return withDb(async (c) =>
by.email !== undefined
? (await c.query('select id from auth.users where email = $1', [by.email.toLowerCase()])).rows
: (await c.query('select id from auth.users where id = $1', [by.id])).rows,
);
}
export async function countUsers(): Promise<number> {
return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count));
}
export async function userByEmail(email: string) {
return withDb(async (c) => (await c.query('select * from auth.users where email = $1', [email.toLowerCase()])).rows[0]);
}
export async function profileByEmail(email: string) {
return withDb(
async (c) =>
(
await c.query(
`select p.* from public.profiles p join auth.users u on u.id = p.id where u.email = $1`,
[email.toLowerCase()],
)
).rows[0],
);
}
/** Setzt den Link als abgelaufen: `recovery_sent_at = now() - (otp_expiry + 1 s)` (AUTH-CFG-07). */
export async function expireRecoveryLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
await withDb((c) =>
c.query(`update auth.users set recovery_sent_at = now() - make_interval(secs => $2) where email = $1`, [
email.toLowerCase(),
secondsAgo,
]),
);
}
/** Wie oben fuer den Bestaetigungslink (`confirmation_sent_at`). */
export async function expireConfirmationLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
await withDb((c) =>
c.query(`update auth.users set confirmation_sent_at = now() - make_interval(secs => $2) where email = $1`, [
email.toLowerCase(),
secondsAgo,
]),
);
}
/** `not_after` aller Sitzungen des Nutzers (AUTH-CFG-16: nicht gesetzt). */
export async function sessionNotAfter(email: string): Promise<(string | null)[]> {
return withDb(async (c) =>
(
await c.query(
`select s.not_after from auth.sessions s join auth.users u on u.id = s.user_id where u.email = $1`,
[email.toLowerCase()],
)
).rows.map((r) => r.not_after),
);
}

134
e2e/support/gotrue.ts Normal file
View file

@ -0,0 +1,134 @@
import { randomBytes, randomUUID } from 'node:crypto';
import { authConfig, env, TERMS_VERSION } from './auth-config';
/** Zufaellige E-Mail-Adresse je Test (Spec 7: jeder Test nutzt eigene Adressen). */
export function randomEmail(prefix = 'e2e'): string {
return `${prefix}-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}@example.test`;
}
/** Zufaelliges, ausreichend langes Passwort (AUTH-CFG-05: mind. 10 Zeichen). */
export function randomPassword(): string {
return `Pw-${randomBytes(8).toString('hex')}-x`;
}
/** Zufaellige IPv4 fuer `X-Forwarded-For` (nur Projekt `throttle`, nie gegen die CLI, R13). */
export function randomIp(): string {
const octet = () => 1 + Math.floor(Math.random() * 253);
return `${octet()}.${octet()}.${octet()}.${octet()}`;
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** Wartet etwas mehr als AUTH-CFG-08 (1 s), damit Folgemails nicht an 429 scheitern. */
export const waitMailInterval = () => sleep(authConfig.maxFrequencySeconds * 1000 + 300);
export interface GoTrueResponse<T = any> {
status: number;
body: T;
headers: Headers;
/** Dauer in Millisekunden. */
ms: number;
}
export interface ClientOptions {
/** Basis-URL inkl. `/auth/v1` (Standard: Kong). */
baseUrl?: string;
/** Beim Zugriff ueber Kong noetig, direkt auf GoTrue nicht. */
apikey?: string | null;
/** Zusatz-Header, z. B. `X-Forwarded-For` (nur `throttle`). */
headers?: Record<string, string>;
}
/** Minimaler GoTrue-Client (REST, wie `supabase_flutter`). */
export class GoTrueClient {
readonly baseUrl: string;
private readonly apikey: string | null;
private readonly extraHeaders: Record<string, string>;
constructor(opts: ClientOptions = {}) {
this.baseUrl = opts.baseUrl ?? `${env.supabaseUrl}/auth/v1`;
this.apikey = opts.apikey === undefined ? env.anonKey : opts.apikey;
this.extraHeaders = opts.headers ?? {};
}
async request<T = any>(
method: string,
path: string,
body?: unknown,
token?: string,
extra: Record<string, string> = {},
): Promise<GoTrueResponse<T>> {
const headers: Record<string, string> = { 'content-type': 'application/json', ...this.extraHeaders, ...extra };
if (this.apikey) headers.apikey = this.apikey;
if (token) headers.authorization = `Bearer ${token}`;
const started = Date.now();
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let parsed: any = text;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
/* Body ist kein JSON */
}
return { status: res.status, body: parsed, headers: res.headers, ms: Date.now() - started };
}
signUp(email: string, password: string, data: Record<string, unknown> | null = validMetadata()) {
return this.request('POST', '/signup', { email, password, ...(data ? { data } : {}) });
}
signIn(email: string, password: string) {
return this.request('POST', '/token?grant_type=password', { email, password });
}
refresh(refreshToken: string) {
return this.request('POST', '/token?grant_type=refresh_token', { refresh_token: refreshToken });
}
recover(email: string) {
return this.request('POST', '/recover', { email });
}
resend(email: string, type: 'signup' = 'signup') {
return this.request('POST', '/resend', { email, type });
}
/** `verifyOTP(tokenHash, type)`. */
verify(tokenHash: string, type: 'email' | 'signup' | 'recovery') {
return this.request('POST', '/verify', { token_hash: tokenHash, type });
}
getUser(accessToken: string) {
return this.request('GET', '/user', undefined, accessToken);
}
updateUser(accessToken: string, attributes: Record<string, unknown>) {
return this.request('PUT', '/user', attributes, accessToken);
}
/** Legt ein bestaetigtes Konto direkt an (Service-Role, nur lokal), fuer Tests, die die Anmeldung pruefen. */
async createConfirmedUser(
email: string,
password: string,
data: Record<string, unknown> = validMetadata(),
) {
return this.request(
'POST',
'/admin/users',
{ email, password, email_confirm: true, user_metadata: data },
env.serviceRoleKey,
);
}
}
/** Gueltige Registrierungs-Metadaten (Spec 4.2). */
export function validMetadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const data: Record<string, unknown> = {
display_name: 'E2E Tester',
age_confirmed: true,
terms_accepted: true,
terms_version: TERMS_VERSION,
locale: 'en',
...overrides,
};
for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
return data;
}
export const uniqueId = () => randomUUID();

90
e2e/support/mailpit.ts Normal file
View file

@ -0,0 +1,90 @@
import { env } from './auth-config';
import { GoTrueClient, randomEmail, randomPassword, sleep } from './gotrue';
export interface MailSummary {
ID: string;
Subject: string;
To: { Address: string }[];
Created: string;
}
export interface Mail extends MailSummary {
html: string;
text: string;
}
async function getJson(path: string): Promise<any> {
const res = await fetch(`${env.mailpitUrl}/api/v1${path}`);
if (!res.ok) throw new Error(`Mailpit ${path}: HTTP ${res.status}`);
return res.json();
}
/** Alle Mails an eine Adresse (Suche per `to:`; nie global loeschen, Tests laufen parallel). */
export async function mailsTo(address: string): Promise<MailSummary[]> {
const result = await getJson(`/search?query=${encodeURIComponent(`to:"${address}"`)}&limit=50`);
return (result.messages ?? []) as MailSummary[];
}
export async function countMails(address: string): Promise<number> {
return (await mailsTo(address)).length;
}
export async function readMail(id: string): Promise<Mail> {
const m = await getJson(`/message/${id}`);
return { ID: m.ID, Subject: m.Subject, To: m.To, Created: m.Date ?? '', html: m.HTML ?? '', text: m.Text ?? '' };
}
/**
* Wartet, bis mindestens `count` Mails an die Adresse vorliegen, und liefert die neueste.
* Faellt nach `timeoutMs` mit einer aussagekraeftigen Meldung durch.
*/
export async function waitForMail(address: string, count = 1, timeoutMs = 10_000): Promise<Mail> {
const deadline = Date.now() + timeoutMs;
let found: MailSummary[] = [];
while (Date.now() < deadline) {
found = await mailsTo(address);
if (found.length >= count) {
// Mailpit liefert die neueste zuerst.
return readMail(found[0].ID);
}
await sleep(200);
}
throw new Error(`Keine ${count}. Mail an ${address} nach ${timeoutMs} ms (vorhanden: ${found.length})`);
}
/**
* Belegt „es kommt keine (weitere) Mail“ ohne festes Zeitfenster: GoTrue versendet synchron im
* Request, Mailpit nimmt die Mails der Reihe nach an. Der Helfer loest deshalb eine bekannte
* Folgemail (Passwort-Reset fuer ein frisches Sentinel-Konto) aus, wartet auf deren Eintreffen
* und zaehlt danach die Mails an `address`. Ist die Sentinel-Mail da, waeren zuvor gesendete
* Mails es auch.
* Nur dort einsetzen, wo die Abwesenheit selbst das Kriterium ist (Krit. 4, 8, 11).
*/
export async function expectMailCount(address: string, expected: number): Promise<number> {
const client = new GoTrueClient();
const sentinel = randomEmail('sentinel');
const created = await client.createConfirmedUser(sentinel, randomPassword());
if (created.status !== 200) throw new Error(`Sentinel-Konto nicht anlegbar: HTTP ${created.status}`);
const res = await client.recover(sentinel);
if (res.status !== 200) throw new Error(`Sentinel-Reset fehlgeschlagen: HTTP ${res.status}`);
await waitForMail(sentinel);
const actual = await countMails(address);
if (actual !== expected) throw new Error(`Mails an ${address}: erwartet ${expected}, vorhanden ${actual}`);
return actual;
}
/** Link aus der Mail: `.../auth/confirm?token_hash=...&type=email|recovery` (HTML-Entities decodiert). */
export function extractConfirmLink(mail: Mail): URL {
const decoded = mail.html.replace(/&amp;/g, '&');
const match = decoded.match(/https?:\/\/[^"'\s<]+\/auth\/confirm\?[^"'\s<]+/);
if (!match) throw new Error(`Kein Bestaetigungslink in der Mail "${mail.Subject}"`);
return new URL(match[0]);
}
export function extractTokenHash(mail: Mail): { tokenHash: string; type: string; link: URL } {
const link = extractConfirmLink(mail);
const tokenHash = link.searchParams.get('token_hash');
const type = link.searchParams.get('type');
if (!tokenHash || !type) throw new Error('token_hash oder type fehlen im Link');
return { tokenHash, type, link };
}

View file

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Startet einen ZWEITEN GoTrue-Container neben der Supabase CLI (Spec #1 D2/T3, F11).
#
# Profile
# throttle (Standard) AUTH-CFG-13: GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For, Port 9998.
# Fuer das Playwright-Projekt `throttle` (THROTTLE_GOTRUE_URL=http://127.0.0.1:9998).
# smtp-down SMTP-Spike (Issue #37): wie die CLI, aber SMTP-Port 1 (Verbindung abgelehnt), Port 9997.
# Fuer das Playwright-Projekt `spike` (SPIKE_GOTRUE_URL=http://127.0.0.1:9997).
#
# Aufruf
# e2e/support/start-throttle-gotrue.sh start [throttle|smtp-down]
# e2e/support/start-throttle-gotrue.sh stop [throttle|smtp-down]
#
# Der Container nutzt dasselbe Image, dieselbe DB und dasselbe Docker-Netz wie der Auth-Container
# der CLI; die Umgebung wird per `docker inspect` uebernommen (Version nicht hart kodiert). Die
# Rate-Limit-Buckets liegen im Speicher und sind damit von der CLI getrennt (R13).
# Docker-in-Docker (CI, DOCKER_HOST=tcp://...): Der Port wird im dind veroeffentlicht, nicht im Job-Container.
# Dann BIND_ADDR=0.0.0.0 und HEALTH_HOST=<dind-Gateway> setzen (siehe .forgejo/workflows/ci.yml, Job e2e);
# die Tests erreichen den Container ueber e2e/support/tcp-forward.mjs auf 127.0.0.1.
# Bindung: lokal nur 127.0.0.1. Unter dind muss 0.0.0.0 gelten (der Job-Container erreicht den dind nur ueber
# das Gateway; eine feinere Bindung ist dort nicht moeglich). Der Port ist dann im Netz des Runners sichtbar.
# Nur lokal/CI benutzen: Die kopierte Umgebung enthaelt die lokalen Demo-Secrets der CLI.
set -euo pipefail
BIND_ADDR="${BIND_ADDR:-127.0.0.1}"
HEALTH_HOST="${HEALTH_HOST:-127.0.0.1}"
ACTION="${1:-start}"
PROFILE="${2:-throttle}"
# Auth-Container gezielt ueber die Projekt-ID (project_id in supabase/config.toml), nicht "erster Treffer"
PROJECT_ID="${SUPABASE_PROJECT_ID:-shuffle}"
SOURCE="${SOURCE_CONTAINER:-supabase_auth_${PROJECT_ID}}"
case "$PROFILE" in
throttle) NAME="${CONTAINER_NAME:-shuffle_auth_throttle}"; HOST_PORT="${HOST_PORT:-9998}" ;;
smtp-down) NAME="${CONTAINER_NAME:-shuffle_auth_smtp_down}"; HOST_PORT="${HOST_PORT:-9997}" ;;
*) echo "Unbekanntes Profil: $PROFILE (throttle|smtp-down)" >&2; exit 2 ;;
esac
if [ "$ACTION" = "stop" ]; then
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "Container $NAME entfernt."
exit 0
fi
[ "$ACTION" = "start" ] || { echo "Aktion: start|stop" >&2; exit 2; }
if ! docker inspect "$SOURCE" >/dev/null 2>&1; then
echo "Container $SOURCE nicht gefunden. Zuerst 'supabase start' (oder SUPABASE_PROJECT_ID setzen)." >&2
exit 1
fi
IMAGE="$(docker inspect "$SOURCE" --format '{{.Config.Image}}')"
NETWORK="$(docker inspect "$SOURCE" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')"
docker rm -f "$NAME" >/dev/null 2>&1 || true
ENV_ARGS=()
while IFS= read -r -d '' entry; do
case "$entry" in
GOTRUE_RATE_LIMIT_HEADER=*) continue ;; # wird unten gezielt gesetzt
API_EXTERNAL_URL=*) continue ;; # wird unten explizit gesetzt
GOTRUE_SMTP_PORT=*) [ "$PROFILE" = "smtp-down" ] && continue ;;
esac
ENV_ARGS+=(-e "$entry")
done < <(docker inspect "$SOURCE" --format '{{json .Config.Env}}' | jq -j '.[] + "\u0000"')
case "$PROFILE" in
throttle) ENV_ARGS+=(-e "GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For") ;;
smtp-down) ENV_ARGS+=(-e "GOTRUE_SMTP_PORT=1") ;; # Host bleibt Mailpit, Port 1 lehnt die Verbindung ab
esac
# API_EXTERNAL_URL explizit (Spec D2): Kong-URL der CLI, nur Basis fuer Links in Mails/Redirects.
ENV_ARGS+=(-e "API_EXTERNAL_URL=${API_EXTERNAL_URL:-http://127.0.0.1:54321}")
docker run -d --name "$NAME" --network "$NETWORK" -p "${BIND_ADDR}:${HOST_PORT}:9999" \
"${ENV_ARGS[@]}" "$IMAGE" auth >/dev/null
for _ in $(seq 1 40); do
if curl -fsS "http://${HEALTH_HOST}:${HOST_PORT}/health" >/dev/null 2>&1; then
echo "Container $NAME ($IMAGE, Profil $PROFILE) laeuft auf http://${HEALTH_HOST}:${HOST_PORT}"
exit 0
fi
sleep 0.5
done
echo "Container $NAME wurde nicht gesund. Log:" >&2
docker logs --tail 30 "$NAME" >&2
exit 1

View file

@ -0,0 +1,40 @@
// Einfacher TCP-Relay fuer CI unter Docker-in-Docker (Issue #38, D2).
//
// Die Supabase-Ports (Kong 54321, Postgres 54322, Mailpit 54324, Extra-GoTrue 9998) liegen im dind,
// nicht im Job-Container. Die Tests und die Web-App erwarten 127.0.0.1. Dieses Skript leitet
// 127.0.0.1:<port> an <host>:<port> weiter (HTTP und Postgres, rein TCP).
//
// Aufruf: node tcp-forward.mjs <ziel-host> <port> [<port> ...]
import net from 'node:net';
const [host, ...ports] = process.argv.slice(2);
if (!host || ports.length === 0) {
console.error('Aufruf: node tcp-forward.mjs <ziel-host> <port> [<port> ...]');
process.exit(2);
}
for (const p of ports) {
const port = Number(p);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
console.error(`Ungueltiger Port: ${p}`);
process.exit(2);
}
net
.createServer((client) => {
const upstream = net.connect(port, host);
client.pipe(upstream).pipe(client);
const close = () => {
client.destroy();
upstream.destroy();
};
client.on('error', close);
upstream.on('error', close);
client.on('close', close);
upstream.on('close', close);
})
.on('error', (err) => {
console.error(`Relay ${port}: ${err.message}`);
process.exit(1);
})
.listen(port, '127.0.0.1', () => console.log(`127.0.0.1:${port} -> ${host}:${port}`));
}

179
e2e/support/web.ts Normal file
View file

@ -0,0 +1,179 @@
/**
* Helfer fuer das Projekt `web` (Flutter-Web-Build, Port 3000).
*
* Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum
* eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). Locator-Namen stammen aus
* `app/lib/l10n/app_en.arb` (Browser-Sprache en-US, siehe playwright.config.ts).
*/
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
import { env, TERMS_VERSION } from './auth-config';
export { TERMS_VERSION };
/** Texte (app_en.arb). */
export const t = {
invalidCredentials: 'Email address or password is incorrect.',
emailNotConfirmed: 'Please confirm your email address first. We sent you an email for this.',
resendConfirmation: 'Resend confirmation email',
rateLimited: 'Too many attempts. Please wait a few minutes and try again.',
linkInvalid: 'The link has expired or has already been used.',
checkEmailTitle: 'Check your inbox',
checkEmailResent: 'We resent the email, if there is something to send for this address.',
ageNotConfirmed: 'Shuffle can only be used from age 16.',
termsNotAccepted: 'Please agree to the privacy policy and the terms of use.',
confirmSuccess: 'Email confirmed.',
passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.',
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
} as const;
/**
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
*/
export function textOf(page: Page, text: string | RegExp): Locator {
return page.getByText(text).or(page.getByLabel(text)).first();
}
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
export async function enableSemantics(page: Page): Promise<void> {
const placeholder = page.locator('flt-semantics-placeholder');
await placeholder.waitFor({ state: 'attached' });
await placeholder.evaluate((el: HTMLElement) => el.click());
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
}
/**
* Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur.
*
* BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu
* EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf
* die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer
* auf `getByRole('button', ...)` umgestellt werden.
*/
export async function pressFirstButton(page: Page, tabs = 1): Promise<void> {
for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
}
/** Navigiert und schaltet Semantics ein. */
export async function open(page: Page, path: string): Promise<void> {
await page.goto(path);
await enableSemantics(page);
}
/**
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
* (mit Wiederholung).
*/
export async function fillField(field: Locator, value: string): Promise<void> {
await expect(async () => {
await field.click();
await field.fill('');
await field.pressSequentially(value);
await expect(field).toHaveValue(value, { timeout: 1_000 });
}).toPass({ timeout: 15_000 });
}
export async function fillRegistration(
page: Page,
v: { email: string; password: string; displayName?: string; age?: boolean; terms?: boolean },
): Promise<void> {
await fillField(page.getByRole('textbox', { name: 'Email address' }), v.email);
await fillField(page.getByRole('textbox', { name: 'Password' }), v.password);
await fillField(page.getByRole('textbox', { name: 'Display name' }), v.displayName ?? 'E2E Tester');
if (v.age ?? true) await page.getByRole('checkbox', { name: /at least 16 years old/ }).click();
if (v.terms ?? true) await page.getByRole('checkbox', { name: /privacy policy/ }).click();
}
export async function register(page: Page, v: Parameters<typeof fillRegistration>[1]): Promise<void> {
await open(page, '/register');
await fillRegistration(page, v);
await page.getByRole('button', { name: 'Create account' }).click();
}
export async function login(page: Page, email: string, password: string): Promise<void> {
await open(page, '/login');
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
await fillField(page.getByRole('textbox', { name: 'Password' }), password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
}
export async function expectSignedIn(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/$/);
await expect(textOf(page, t.home)).toBeVisible();
}
/**
* Angemeldet <=> `/login` leitet weg (Guest-only-Route, Spec 5.3). Prueft ohne Fixture-Annahmen
* ueber den Storage-Key.
*/
export async function expectSignedInViaLoginRedirect(page: Page): Promise<void> {
await page.goto('/login');
await expect(page).toHaveURL(/\/$/);
}
/** `localStorage`-Eintraege von supabase_flutter im Web (Schluessel nicht raten, auslesen). */
export async function readAuthStorage(page: Page): Promise<Record<string, string>> {
return page.evaluate(() => {
const out: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)!;
if (k.includes('supabase.auth.token') || /sb-.*-auth-token/.test(k)) out[k] = localStorage.getItem(k)!;
}
return out;
});
}
/** Neue Seite in einem frischen Kontext mit denselben Browser-Optionen. */
export async function freshContext(context: BrowserContext): Promise<BrowserContext> {
return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl });
}
/**
* Verschiebt die Browser-Uhr ab dem naechsten Seitenladen um `offsetMs` in die Zukunft.
*
* Zweck (Krit. 9, F1): Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, 60 min) ab; der
* Test erzwingt den Refresh, ohne zu warten. `Session.expiresAt` in supabase_flutter wird aus dem
* JWT-Claim `exp` berechnet, nicht aus dem gespeicherten Feld `expires_at`; das Aendern des
* localStorage haette also keine Wirkung. Der Server rechnet weiter mit der echten Zeit.
*/
export async function shiftBrowserClock(page: Page, offsetMs: number): Promise<void> {
await page.addInitScript((offset: number) => {
const RealDate = Date;
class ShiftedDate extends RealDate {
constructor(...args: any[]) {
if (args.length === 0) super(RealDate.now() + offset);
else super(...(args as [any]));
}
static now() {
return RealDate.now() + offset;
}
}
(globalThis as any).Date = ShiftedDate;
}, offsetMs);
}
/** Schluessel der supabase_flutter-Sitzung im localStorage (nicht fest verdrahtet, nicht ueber Index). */
export async function authStorageKey(page: Page): Promise<string | null> {
return page.evaluate(() => {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)!;
if (/^sb-.*-auth-token$/.test(k)) return k;
}
return null;
});
}
/** Aktuelle Sitzung aus dem localStorage oder `null`. */
export async function readSession(page: Page): Promise<{ access_token: string; refresh_token: string } | null> {
const key = await authStorageKey(page);
if (!key) return null;
const raw = await page.evaluate((k) => localStorage.getItem(k), key);
return raw ? JSON.parse(raw) : null;
}
/** Frischer Browser-Kontext mit den Projekt-Optionen (en-US, kein Service Worker, Basis-URL aus der Config). */
export async function freshBrowserContext(browser: import('@playwright/test').Browser): Promise<BrowserContext> {
return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl });
}

View file

@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { authConfig } from '../../support/auth-config';
const root = resolve(__dirname, '../../..');
const toml = readFileSync(resolve(root, 'supabase/config.toml'), 'utf8');
const doc = readFileSync(resolve(root, 'docs/auth-konfiguration.md'), 'utf8');
/** Aktive (nicht auskommentierte) Zeilen von `config.toml`. */
const active = toml
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'));
function value(key: string, section?: string): string | undefined {
let current = '';
for (const line of active) {
const sec = line.match(/^\[(.+)\]$/);
if (sec) {
current = sec[1];
continue;
}
if (section !== undefined && current !== section) continue;
const kv = line.match(new RegExp(`^${key}\\s*=\\s*(.+)$`));
if (kv) return kv[1].replace(/^"|"$/g, '');
}
return undefined;
}
test.describe('Auth-Konfiguration: auth-config.ts stimmt mit config.toml und Doku ueberein', () => {
test('AUTH-CFG-01: Site-URL', () => {
expect(value('site_url', 'auth')).toBe(authConfig.siteUrl);
});
test('AUTH-CFG-04: E-Mail-Bestaetigung Pflicht', () => {
expect(value('enable_confirmations', 'auth.email')).toBe('true');
});
test('AUTH-CFG-05/-06: Passwort-Mindestlaenge und keine Zeichenklassen', () => {
expect(Number(value('minimum_password_length', 'auth'))).toBe(authConfig.passwordMinLength);
expect(value('password_requirements', 'auth')).toBe('');
});
test('AUTH-CFG-07: Gueltigkeit der Links', () => {
expect(Number(value('otp_expiry', 'auth.email'))).toBe(authConfig.otpExpirySeconds);
});
test('AUTH-CFG-08: Mindestabstand lokal 1 s', () => {
expect(value('max_frequency', 'auth.email')).toBe(`${authConfig.maxFrequencySeconds}s`);
});
test('AUTH-CFG-10: Login-/Token-Refresh-Drosselung (150 je 5 min, Burst 30)', () => {
expect(Number(value('token_refresh', 'auth.rate_limit'))).toBe(150);
expect(authConfig.loginBurst).toBe(30);
});
test('AUTH-CFG-11: Signup/Reset/Resend-Drosselung (30 je 5 min)', () => {
expect(Number(value('sign_in_sign_ups', 'auth.rate_limit'))).toBe(authConfig.otpBurst);
});
test('AUTH-CFG-12: Link-Pruefung (30 je 5 min)', () => {
expect(Number(value('token_verifications', 'auth.rate_limit'))).toBe(30);
});
test('AUTH-CFG-14/-15: JWT-Laufzeit und Refresh-Token-Rotation', () => {
expect(Number(value('jwt_expiry', 'auth'))).toBe(authConfig.jwtExpirySeconds);
expect(value('enable_refresh_token_rotation', 'auth')).toBe('true');
expect(Number(value('refresh_token_reuse_interval', 'auth'))).toBe(authConfig.refreshTokenReuseIntervalSeconds);
});
test('KTO-01 Kriterium 5: AUTH-CFG-16 Sitzungsbegrenzung ist nicht gesetzt (timebox, inactivity_timeout)', () => {
expect(active.some((l) => /^timebox\s*=/.test(l))).toBe(false);
expect(active.some((l) => /^inactivity_timeout\s*=/.test(l))).toBe(false);
expect(active).not.toContain('[auth.sessions]');
});
test('AUTH-CFG-19: alle Benachrichtigungs-Mails aus', () => {
const sections = active.filter((l) => l.startsWith('[auth.email.notification.'));
expect(sections.length).toBeGreaterThan(0);
for (const s of sections) {
expect(value('enabled', s.slice(1, -1)), s).toBe('false');
}
});
test('AUTH-CFG-24: SMS-Autoconfirm aus (enable_confirmations = true), Voraussetzung fuer den Enumerationsschutz', () => {
expect(value('enable_confirmations', 'auth.sms')).toBe('true');
});
test('docs/auth-konfiguration.md fuehrt AUTH-CFG-01 bis -25', () => {
for (let i = 1; i <= 25; i++) {
const id = `AUTH-CFG-${String(i).padStart(2, '0')}`;
expect(doc, id).toContain(`| ${id} |`);
}
});
});

View file

@ -0,0 +1,35 @@
import { expect, test } from '@playwright/test';
import { assertLocalUrl } from '../../support/auth-config';
import { localDbConfig } from '../../support/db';
test.describe('E2E-Guards: Tests laufen nur gegen lokale Dienste', () => {
test('db.ts: lokale Hosts werden akzeptiert (inkl. IPv6 in Klammern)', () => {
for (const url of [
'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
'postgresql://postgres:postgres@localhost:54322/postgres',
'postgresql://postgres:postgres@[::1]:54322/postgres',
]) {
expect(() => localDbConfig(url), url).not.toThrow();
}
});
test('db.ts: fremde Hosts werden abgelehnt', () => {
expect(() => localDbConfig('postgresql://u:p@db.example.com:5432/postgres')).toThrow(/nicht erlaubt/);
});
test('db.ts: ?host= kann den Guard nicht umgehen', () => {
expect(() => localDbConfig('postgresql://u:p@127.0.0.1:5432/postgres?host=db.example.com')).toThrow(/nicht erlaubt/);
expect(() => localDbConfig('postgresql://u:p@localhost/postgres?host=/var/run/postgresql,db.example.com')).toThrow();
});
test('db.ts: Host-Namen mit lokalem Praefix/Suffix gelten nicht als lokal', () => {
expect(() => localDbConfig('postgresql://u:p@localhost.evil.com:5432/postgres')).toThrow();
expect(() => localDbConfig('postgresql://u:p@127.0.0.1.evil.com:5432/postgres')).toThrow();
});
test('SUPABASE_URL/MAILPIT_URL: nur lokale Hosts', () => {
expect(() => assertLocalUrl('X', 'http://127.0.0.1:54321')).not.toThrow();
expect(() => assertLocalUrl('X', 'http://[::1]:54321')).not.toThrow();
expect(() => assertLocalUrl('X', 'https://abc.supabase.co')).toThrow(/nicht lokal/);
});
});

View file

@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { subjects, TERMS_VERSION } from '../../support/auth-config';
import { profileByEmail, userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 1: Registrierung und Bestaetigung (api)', () => {
test('KTO-01 Kriterium 1: Signup legt Nutzer und Profil an, Sitzung bleibt aus, Bestaetigungsmail im Faenger', async () => {
const email = randomEmail();
const res = await gotrue.signUp(
email,
randomPassword(),
validMetadata({ display_name: ' Anna Beispiel ', locale: 'de' }),
);
expect(res.status).toBe(200);
expect(res.body.id).toBeTruthy();
expect(res.body.session ?? res.body.access_token ?? null).toBeNull();
expect(res.body.email_confirmed_at ?? null).toBeNull();
const profile = await profileByEmail(email);
expect(profile).toBeTruthy();
expect(profile.display_name).toBe('Anna Beispiel');
expect(profile.locale).toBe('de');
expect(profile.terms_version).toBe(TERMS_VERSION);
expect(profile.terms_accepted_at).toBeTruthy();
expect(profile.age_confirmed_at).toBeTruthy();
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.de);
const { type, link } = extractTokenHash(mail);
expect(type).toBe('email');
// AUTH-CFG-01: Link baut auf der Site-URL auf.
expect(link.origin).toBe('http://localhost:3000');
expect(link.pathname).toBe('/auth/confirm');
});
test('KTO-01 Kriterium 1: Link einloesen bestaetigt die Adresse, danach ist die Anmeldung moeglich', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const verified = await gotrue.verify(tokenHash, 'email');
expect(verified.status).toBe(200);
expect(verified.body.access_token).toBeTruthy();
expect(verified.body.user.email_confirmed_at).toBeTruthy();
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(200);
expect(login.body.access_token).toBeTruthy();
expect((await userByEmail(email)).email_confirmed_at).toBeTruthy();
});
test('KTO-01 Kriterium 1: ohne Bestaetigung kein Zugang (AUTH-CFG-04)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(400);
expect(login.body.error_code).toBe('email_not_confirmed');
});
});

View file

@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { authConfig, triggerErrors } from '../../support/auth-config';
import { userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => {
test('KTO-01 Kriterium 2: Signup ohne age_confirmed -> 500 age_confirmation_required, kein Nutzer', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
expect(await userByEmail(email)).toBeUndefined();
});
test('KTO-01 Kriterium 2: age_confirmed=false wird ebenfalls abgelehnt', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: false }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
expect(await userByEmail(email)).toBeUndefined();
});
});
test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (api)', () => {
test('QUE-04 Kriterium 1: Signup ohne terms_accepted -> terms_acceptance_required', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired);
expect(await userByEmail(email)).toBeUndefined();
});
test('QUE-04 Kriterium 1: Signup ohne terms_version -> terms_version_required', async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.termsVersionRequired);
expect(await userByEmail(email)).toBeUndefined();
});
});
test.describe('KTO-01 Kriterium 3: Passwortregeln (api)', () => {
test(`KTO-01 Kriterium 3: AUTH-CFG-05 ${authConfig.passwordMinLength - 1} Zeichen -> 422 weak_password (length)`, async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, 'a'.repeat(authConfig.passwordMinLength - 1));
expect(res.status).toBe(422);
expect(res.body.error_code).toBe('weak_password');
expect(res.body.weak_password?.reasons).toContain('length');
expect(await userByEmail(email)).toBeUndefined();
});
test(`KTO-01 Kriterium 3: AUTH-CFG-05/-06 genau ${authConfig.passwordMinLength} Zeichen ohne Zeichenklassen werden akzeptiert`, async () => {
const res = await gotrue.signUp(randomEmail(), 'a'.repeat(authConfig.passwordMinLength));
expect(res.status).toBe(200);
});
});

View file

@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test';
import { profileByEmail, userByEmail, usersWhere } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
import { countMails, expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => {
/**
* Regressionstest fuer AUTH-CFG-04 (MAILER_AUTOCONFIRM=false) UND AUTH-CFG-24 (SMS_AUTOCONFIRM=false).
* Mit SMS_AUTOCONFIRM=true (CLI-Standard, Self-Hosting-Vorlage) kaeme hier 422 user_already_exists.
*/
test('KTO-01 Kriterium 4: AUTH-CFG-04/-24 Signup mit bestaetigter Adresse -> 200 (nicht 422), identities leer, erfundener Nutzer, keine Mail, altes Passwort gilt', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const created = await gotrue.signUp(email, oldPassword);
const realId = created.body.id;
const { tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
expect(await countMails(email)).toBe(1);
await waitMailInterval();
const newPassword = randomPassword();
const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' }));
expect(again.status, 'nicht 422 user_already_exists (AUTH-CFG-24)').toBe(200);
expect(again.body.error_code).toBeUndefined();
expect(again.body.identities ?? []).toEqual([]);
expect(again.body.id).toBeTruthy();
expect(again.body.id).not.toBe(realId);
// Kein zweites Konto: genau eine Zeile fuer die Adresse, keine mit der erfundenen ID
// (unabhaengig von parallel laufenden Tests, kein globaler Zaehler).
expect(await usersWhere({ email })).toHaveLength(1);
expect(await usersWhere({ id: again.body.id })).toHaveLength(0);
// Keine (weitere) Mail an die Adresse: nach festem Fenster weiterhin nur die erste Bestaetigungsmail.
await expectMailCount(email, 1);
// Das urspruengliche Passwort gilt weiter, das neue nicht; das Profil bleibt unveraendert.
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
const wrong = await gotrue.signIn(email, newPassword);
expect(wrong.status).toBe(400);
expect(wrong.body.error_code).toBe('invalid_credentials');
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
});
test('KTO-01 Kriterium 4: Signup mit unbestaetigter Adresse -> 200, Mail erneut an das bestehende Konto, Passwort und Profil unveraendert', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const first = await gotrue.signUp(email, oldPassword);
await waitForMail(email);
await waitMailInterval();
const newPassword = randomPassword();
const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' }));
expect(again.status).toBe(200);
expect(again.body.id).toBe(first.body.id);
// Die Bestaetigungsmail geht erneut an das bestehende Konto.
const second = await waitForMail(email, 2);
const { tokenHash } = extractTokenHash(second);
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
expect((await gotrue.signIn(email, newPassword)).body.error_code).toBe('invalid_credentials');
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
expect((await userByEmail(email)).id).toBe(first.body.id);
});
test('KTO-01 Kriterium 4: Reset fuer unbekannte Adresse -> 200, keine Mail; fuer bekannte Adresse 200 mit Mail', async () => {
const unknown = randomEmail('unknown');
const res = await gotrue.recover(unknown);
expect(res.status).toBe(200);
await expectMailCount(unknown, 0);
const known = randomEmail();
await gotrue.createConfirmedUser(known, randomPassword());
const ok = await gotrue.recover(known);
expect(ok.status).toBe(200);
expect(ok.body).toEqual(res.body);
await waitForMail(known);
});
});

View file

@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { authConfig } from '../../support/auth-config';
import { sessionNotAfter } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 5: Sitzung bleibt bestehen (api)', () => {
test('KTO-01 Kriterium 5: AUTH-CFG-14/-16 Sitzung ohne Zeitbegrenzung, JWT-Laufzeit 3600 s', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const login = await gotrue.signIn(email, password);
expect(login.status).toBe(200);
expect(login.body.expires_in).toBe(authConfig.jwtExpirySeconds);
// AUTH-CFG-16: weder timebox noch inactivity_timeout -> not_after bleibt leer.
expect(await sessionNotAfter(email)).toEqual([null]);
});
test('KTO-01 Kriterium 5: AUTH-CFG-15 Refresh liefert neue Tokens, die Sitzung bleibt gueltig', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const login = await gotrue.signIn(email, password);
const refreshed = await gotrue.refresh(login.body.refresh_token);
expect(refreshed.status).toBe(200);
expect(refreshed.body.refresh_token).not.toBe(login.body.refresh_token);
expect((await gotrue.getUser(refreshed.body.access_token)).status).toBe(200);
});
});

View file

@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 6: unbestaetigtes Konto (api)', () => {
test('KTO-01 Kriterium 6: richtiges Passwort -> email_not_confirmed, falsches Passwort -> invalid_credentials', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const right = await gotrue.signIn(email, password);
expect(right.status).toBe(400);
expect(right.body.error_code).toBe('email_not_confirmed');
const wrong = await gotrue.signIn(email, `${password}-falsch`);
expect(wrong.status).toBe(400);
expect(wrong.body.error_code).toBe('invalid_credentials');
});
test('KTO-01 Kriterium 6: erneut senden -> zweite Mail, deren Link das Konto bestaetigt (AUTH-CFG-08: lokal 1 s)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
await waitForMail(email);
await waitMailInterval();
const resend = await gotrue.resend(email);
expect(resend.status).toBe(200);
const second = await waitForMail(email, 2);
const { tokenHash } = extractTokenHash(second);
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
expect((await gotrue.signIn(email, password)).status).toBe(200);
});
test('KTO-01 Kriterium 6: erneut senden fuer unbekannte Adresse -> 200, keine Mail (keine Enumeration)', async () => {
const unknown = randomEmail('unknown');
const res = await gotrue.resend(unknown);
expect(res.status).toBe(200);
await expectMailCount(unknown, 0);
});
});
test.describe('KTO-01 Kriterium 7: keine Auskunft ueber Konten (api)', () => {
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort liefern identische Antworten', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const wrongPassword = await gotrue.signIn(email, `${password}-falsch`);
const wrongEmail = await gotrue.signIn(randomEmail('unknown'), password);
expect(wrongPassword.status).toBe(400);
expect(wrongEmail.status).toBe(wrongPassword.status);
expect(wrongEmail.body.error_code).toBe('invalid_credentials');
expect(wrongEmail.body).toEqual(wrongPassword.body);
});
});

View file

@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test';
import { authConfig, errorCodes } from '../../support/auth-config';
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
async function confirmedUser() {
const email = randomEmail();
const password = randomPassword();
const created = await gotrue.createConfirmedUser(email, password);
expect(created.status).toBe(200);
return { email, password };
}
test.describe('KTO-01 Kriterium 8: Passwort-Reset-Link (api)', () => {
test('KTO-01 Kriterium 8: Reset anfordern -> Mail mit type=recovery -> einloesen ok -> zweites Einloesen 403 otp_expired', async () => {
const { email } = await confirmedUser();
expect((await gotrue.recover(email)).status).toBe(200);
const { tokenHash, type } = extractTokenHash(await waitForMail(email));
expect(type).toBe('recovery');
const first = await gotrue.verify(tokenHash, 'recovery');
expect(first.status).toBe(200);
expect(first.body.access_token).toBeTruthy();
const second = await gotrue.verify(tokenHash, 'recovery');
expect(second.status).toBe(403);
expect(second.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: neue Reset-Anforderung ersetzt den alten Link', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const oldLink = extractTokenHash(await waitForMail(email));
await waitMailInterval();
await gotrue.recover(email);
const newLink = extractTokenHash(await waitForMail(email, 2));
expect(newLink.tokenHash).not.toBe(oldLink.tokenHash);
const old = await gotrue.verify(oldLink.tokenHash, 'recovery');
expect(old.status).toBe(403);
expect(old.body.error_code).toBe(errorCodes.otpExpired);
expect((await gotrue.verify(newLink.tokenHash, 'recovery')).status).toBe(200);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Reset-Link ist nach 1 h + 1 s abgelaufen (recovery_sent_at zurueckgesetzt)', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email);
const res = await gotrue.verify(tokenHash, 'recovery');
expect(res.status).toBe(403);
expect(res.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Grenze: Reset-Link kurz vor Ablauf ist noch gueltig', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email, authConfig.otpExpirySeconds - 60);
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200);
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 Bestaetigungslink ist nach 1 h + 1 s abgelaufen (confirmation_sent_at zurueckgesetzt)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
const { tokenHash } = extractTokenHash(await waitForMail(email));
await expireConfirmationLink(email);
const res = await gotrue.verify(tokenHash, 'email');
expect(res.status).toBe(403);
expect(res.body.error_code).toBe(errorCodes.otpExpired);
// Konto bleibt unbestaetigt.
expect((await gotrue.signIn(email, password)).body.error_code).toBe(errorCodes.emailNotConfirmed);
});
test('KTO-01 Kriterium 8: Bestaetigungslink ist einmalig', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword());
const { tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
const again = await gotrue.verify(tokenHash, 'email');
expect(again.status).toBe(403);
expect(again.body.error_code).toBe(errorCodes.otpExpired);
});
test('KTO-01 Kriterium 8: Reset fuer unbekannte Adresse -> 200, keine Mail', async () => {
const unknown = randomEmail('unknown');
expect((await gotrue.recover(unknown)).status).toBe(200);
await expectMailCount(unknown, 0);
});
test('KTO-01 Kriterium 8: nach dem Setzen des neuen Passworts ist der Reset-Link verbraucht', async () => {
const { email } = await confirmedUser();
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const session = await gotrue.verify(tokenHash, 'recovery');
const update = await gotrue.updateUser(session.body.access_token, { password: randomPassword() });
expect(update.status).toBe(200);
expect((await gotrue.verify(tokenHash, 'recovery')).body.error_code).toBe(errorCodes.otpExpired);
});
});

View file

@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';
import { env, errorCodes } from '../../support/auth-config';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
async function resetSession(email: string) {
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const res = await gotrue.verify(tokenHash, 'recovery');
expect(res.status).toBe(200);
return res.body.access_token as string;
}
test.describe('KTO-01 Kriterium 9: Passwort aendern beendet andere Sitzungen (api)', () => {
test('KTO-01 Kriterium 9: nach Reset sind die Refresh-Tokens anderer Sitzungen ungueltig, die Reset-Sitzung bleibt', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
const newPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
const a = (await gotrue.signIn(email, oldPassword)).body;
const b = (await gotrue.signIn(email, oldPassword)).body;
expect(a.refresh_token).not.toBe(b.refresh_token);
const recoveryToken = await resetSession(email);
const update = await gotrue.updateUser(recoveryToken, { password: newPassword });
expect(update.status).toBe(200);
for (const [name, session] of [['A', a], ['B', b]] as const) {
const refreshed = await gotrue.refresh(session.refresh_token);
expect(refreshed.status, `Sitzung ${name}`).toBeGreaterThanOrEqual(400);
expect(['refresh_token_not_found', 'session_not_found']).toContain(
refreshed.body.error_code,
);
}
// Neues Passwort gilt, altes nicht.
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe(errorCodes.invalidCredentials);
});
test('KTO-01 Kriterium 9 / R8: GoTrue /user lehnt das Access-Token der beendeten Sitzung ab (PostgREST nicht: bis zu 60 min, F1)', async () => {
const email = randomEmail();
const oldPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
const a = (await gotrue.signIn(email, oldPassword)).body;
const recoveryToken = await resetSession(email);
await gotrue.updateUser(recoveryToken, { password: randomPassword() });
const user = await gotrue.getUser(a.access_token);
expect([401, 403]).toContain(user.status);
// Dokumentiert das akzeptierte Restrisiko R8 (AUTH-CFG-14): PostgREST prueft keine Sitzungen.
const rest = await fetch(`${env.supabaseUrl}/rest/v1/profiles?select=id`, {
headers: { apikey: env.anonKey, authorization: `Bearer ${a.access_token}` },
});
expect(rest.status).toBe(200);
});
test('KTO-01 Kriterium 9: gleiches Passwort -> 422 same_password, zu kurzes -> 422 weak_password', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
const token = await resetSession(email);
const same = await gotrue.updateUser(token, { password });
expect(same.status).toBe(422);
expect(same.body.error_code).toBe(errorCodes.samePassword);
const weak = await gotrue.updateUser(token, { password: 'kurz' });
expect(weak.status).toBe(422);
expect(weak.body.error_code).toBe(errorCodes.weakPassword);
});
});

View file

@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { authConfig, triggerErrors } from '../../support/auth-config';
import { profileByEmail, userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 10: Anzeigename (api)', () => {
test('KTO-01 Kriterium 10: 31 Zeichen -> HTTP 500, Body {"code":"P0001","message":"display_name_invalid"}, kein Nutzer', async () => {
const email = randomEmail();
const res = await gotrue.signUp(
email,
randomPassword(),
validMetadata({ display_name: 'a'.repeat(authConfig.displayNameMaxLength + 1) }),
);
// Format aus Spike B0 (f), Mapping-Regel 4.4: maßgeblich ist HTTP 500 beim signUp.
expect(res.status).toBe(500);
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.displayNameInvalid });
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
expect(await userByEmail(email)).toBeUndefined();
});
for (const [label, value] of [
['fehlend', undefined],
['leer', ''],
['nur Leerzeichen', ' '],
] as const) {
test(`KTO-01 Kriterium 10: Anzeigename ${label} -> display_name_invalid`, async () => {
const email = randomEmail();
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: value }));
expect(res.status).toBe(500);
expect(res.body.message).toBe(triggerErrors.displayNameInvalid);
expect(await userByEmail(email)).toBeUndefined();
});
}
test('KTO-01 Kriterium 10: genau 30 Zeichen werden akzeptiert', async () => {
const email = randomEmail();
const name = 'b'.repeat(authConfig.displayNameMaxLength);
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: name }));
expect(res.status).toBe(200);
expect((await profileByEmail(email)).display_name).toBe(name);
});
test('KTO-01 Kriterium 10: Codepoints zaehlen, nicht UTF-16-Einheiten (30 Emoji ok, 31 abgelehnt)', async () => {
const ok = randomEmail();
const accepted = await gotrue.signUp(ok, randomPassword(), validMetadata({ display_name: '😀'.repeat(30) }));
expect(accepted.status).toBe(200);
expect([...(await profileByEmail(ok)).display_name]).toHaveLength(30);
const bad = randomEmail();
const rejected = await gotrue.signUp(bad, randomPassword(), validMetadata({ display_name: '😀'.repeat(31) }));
expect(rejected.status).toBe(500);
expect(rejected.body.message).toBe(triggerErrors.displayNameInvalid);
});
test('KTO-01 Kriterium 10: Anzeigename wird getrimmt gespeichert; 30 Zeichen plus Rand-Leerzeichen sind zulaessig', async () => {
const email = randomEmail();
const name = 'c'.repeat(authConfig.displayNameMaxLength);
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: ` ${name} ` }));
expect(res.status).toBe(200);
expect((await profileByEmail(email)).display_name).toBe(name);
});
});

View file

@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { env, subjects } from '../../support/auth-config';
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 11: Mails nur in der Profilsprache, nur die zwei Mailarten (api)', () => {
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=de -> deutsche Bestaetigungsmail', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'de' }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.de);
expect(mail.html).toContain('<html lang="de">');
});
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=en -> englische Bestaetigungsmail', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'en' }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
expect(mail.html).toContain('<html lang="en">');
});
test('KTO-01 Kriterium 11: ohne locale -> Englisch (F6)', async () => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: undefined }));
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
});
test('KTO-01 Kriterium 11: AUTH-CFG-18 Profilsprache auf en aendern -> Reset-Mail englisch; vorher deutsch', async () => {
const email = randomEmail();
const password = randomPassword();
const created = await gotrue.createConfirmedUser(email, password, validMetadata({ locale: 'de' }));
const userId = created.body.id as string;
await gotrue.recover(email);
const german = await waitForMail(email);
expect(german.Subject).toBe(subjects.recovery.de);
expect(german.html).toContain('<html lang="de">');
expect(extractTokenHash(german).type).toBe('recovery');
// Profilsprache wie die App ueber PostgREST mit dem Token des Nutzers aendern (Sync-Trigger -> user_metadata).
const token = (await gotrue.signIn(email, password)).body.access_token as string;
const patch = await fetch(`${env.supabaseUrl}/rest/v1/profiles?id=eq.${userId}`, {
method: 'PATCH',
headers: {
apikey: env.anonKey,
authorization: `Bearer ${token}`,
'content-type': 'application/json',
prefer: 'return=representation',
},
body: JSON.stringify({ locale: 'en' }),
});
expect(patch.status).toBe(200);
expect((await patch.json())[0].locale).toBe('en');
await waitMailInterval();
await gotrue.recover(email);
const english = await waitForMail(email, 2);
expect(english.Subject).toBe(subjects.recovery.en);
expect(english.html).toContain('<html lang="en">');
});
test('KTO-01 Kriterium 11: AUTH-CFG-19 nach Reset und Login keine weitere Mail (Benachrichtigungen aus)', async () => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const session = await gotrue.verify(tokenHash, 'recovery');
const newPassword = randomPassword();
expect((await gotrue.updateUser(session.body.access_token, { password: newPassword })).status).toBe(200);
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
// Genau die eine Reset-Mail; keine password_changed-Benachrichtigung.
await expectMailCount(email, 1);
});
});

View file

@ -0,0 +1,78 @@
/**
* SPIKE (Issue #37), NICHT blockierend: Verhalten von /recover, /signup und /resend, wenn der
* SMTP-Server nicht erreichbar ist, fuer bekannte vs. unbekannte Adressen.
*
* Laeuft gegen einen dritten GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt
* (`e2e/support/start-throttle-gotrue.sh start smtp-down`, SPIKE_GOTRUE_URL). Mailpit bleibt fuer
* die anderen Projekte unberuehrt.
*
* Die Tests pruefen NICHTS ausser dem Ablauf; sie erfassen Status, Body, Dauer und den Zustand in
* der DB je Fall und schreiben ihn in die Konsole und als Anhang `smtp-down-<fall>.json`. Eine
* Auswertung steht in docs/auth-konfiguration.md (Abschnitt „SMTP-Ausfall (Spike #37)“).
*/
import { test } from '@playwright/test';
import { env } from '../../support/auth-config';
import { userByEmail } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
import { countMails } from '../../support/mailpit';
const base = env.spikeGotrueUrl;
test.skip(!base, 'SPIKE_GOTRUE_URL nicht gesetzt (start-throttle-gotrue.sh start smtp-down)');
const spike = () => new GoTrueClient({ baseUrl: base!, apikey: null });
const cli = new GoTrueClient();
async function record(info: typeof test.info extends () => infer R ? R : never, name: string, data: unknown) {
const json = JSON.stringify(data, null, 2);
console.log(`[SPIKE smtp-down] ${name}: ${json}`);
await info.attach(`smtp-down-${name}.json`, { body: json, contentType: 'application/json' });
}
const summarize = (r: { status: number; body: any; ms: number }) => ({
status: r.status,
error_code: r.body?.error_code ?? null,
msg: r.body?.msg ?? r.body?.message ?? null,
hasUserId: Boolean(r.body?.id),
ms: r.ms,
});
test.describe('Spike #37: SMTP nicht erreichbar', () => {
test('recover: bekannte (bestaetigte) vs. unbekannte Adresse', async () => {
const known = randomEmail();
await cli.createConfirmedUser(known, randomPassword());
const unknown = randomEmail('unknown');
const a = await spike().recover(known);
const b = await spike().recover(unknown);
await record(test.info(), 'recover', { known: summarize(a), unknown: summarize(b) });
});
test('signup: neue vs. bestaetigte vs. unbestaetigte Adresse', async () => {
const confirmed = randomEmail();
await cli.createConfirmedUser(confirmed, randomPassword());
const unconfirmed = randomEmail();
await cli.signUp(unconfirmed, randomPassword());
const fresh = randomEmail();
const rFresh = await spike().signUp(fresh, randomPassword());
const rConfirmed = await spike().signUp(confirmed, randomPassword());
// AUTH-CFG-08: sonst antwortet GoTrue mit 429 statt dem SMTP-Fehler.
await new Promise((r) => setTimeout(r, 1300));
const rUnconfirmed = await spike().signUp(unconfirmed, randomPassword());
await record(test.info(), 'signup', {
newAddress: { ...summarize(rFresh), userInDbAfterwards: Boolean(await userByEmail(fresh)) },
confirmedAddress: summarize(rConfirmed),
unconfirmedAddress: summarize(rUnconfirmed),
mailsToFresh: await countMails(fresh),
});
});
test('resend: unbestaetigte vs. unbekannte Adresse', async () => {
const unconfirmed = randomEmail();
await cli.signUp(unconfirmed, randomPassword(), validMetadata());
await new Promise((r) => setTimeout(r, 1300));
const unknown = randomEmail('unknown');
const a = await spike().resend(unconfirmed);
const b = await spike().resend(unknown);
await record(test.info(), 'resend', { unconfirmed: summarize(a), unknown: summarize(b) });
});
});

View file

@ -0,0 +1,103 @@
/**
* KTO-01 Kriterium 7 (Drosselung), AUTH-CFG-10 und AUTH-CFG-13.
*
* Laeuft gegen den ZUSAETZLICHEN GoTrue-Container mit GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For
* (e2e/support/start-throttle-gotrue.sh, THROTTLE_GOTRUE_URL), nicht gegen die CLI: Die CLI
* drosselt nie (Spike A2, F11). Jeder Test nutzt eine eigene zufaellige IP (simuliert den Proxy,
* R13). Es wird direkt auf GoTrue zugegriffen, ohne Kong und ohne apikey.
*/
import { expect, test } from '@playwright/test';
import { authConfig, env, errorCodes } from '../../support/auth-config';
import { GoTrueClient, randomEmail, randomIp, randomPassword } from '../../support/gotrue';
const base = env.throttleGotrueUrl;
test.skip(!base, 'THROTTLE_GOTRUE_URL nicht gesetzt (siehe e2e/support/start-throttle-gotrue.sh)');
/** Zusaetzliche Versuche, die der Nachfuellrate (0,5/s) der gemessenen Dauer entsprechen: ceil(t * 0,5) + 1. */
const refillTolerance = (elapsedMs: number) => Math.ceil((elapsedMs / 1000) * 0.5) + 1;
const client = (ip?: string) =>
new GoTrueClient({ baseUrl: base!, apikey: null, headers: ip ? { 'X-Forwarded-For': ip } : {} });
/** Konto, das GoTrue-Instanz und DB mit der CLI teilen; Anlage ohne Header ueber die CLI (Service-Role). */
async function createUser() {
const email = randomEmail();
const password = randomPassword();
const res = await new GoTrueClient().createConfirmedUser(email, password);
expect(res.status).toBe(200);
return { email, password };
}
test.describe('KTO-01 Kriterium 7: Drosselung der Anmeldung (throttle)', () => {
test('KTO-01 Kriterium 7: AUTH-CFG-10 30 Fehllogins einer IP -> invalid_credentials, Login #31 -> 429 over_request_rate_limit', async () => {
const { email, password } = await createUser();
const ip = randomIp();
const gotrue = client(ip);
const started = Date.now();
for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) {
const res = await gotrue.signIn(email, `${password}-falsch`);
expect(res.status, `Versuch ${attempt}`).toBe(400);
expect(res.body.error_code, `Versuch ${attempt}`).toBe(errorCodes.invalidCredentials);
}
const tolerance = refillTolerance(Date.now() - started);
// Login #31 -> 429. Der Limiter fuellt 0,5 Token/s nach (AUTH-CFG-10); je Sekunde Laufzeit
// der ersten 30 Versuche darf die erste 429 einen halben Versuch spaeter kommen.
let limited = await gotrue.signIn(email, `${password}-falsch`);
for (let extra = 0; extra < tolerance && limited.status !== 429; extra++) {
limited = await gotrue.signIn(email, `${password}-falsch`);
}
expect(limited.status).toBe(429);
expect(limited.body.error_code).toBe(errorCodes.overRequestRateLimit);
// Auch der richtige Login dieser IP wird gedrosselt (Drosselung je IP, nicht je Passwort).
expect((await gotrue.signIn(email, password)).status).toBe(429);
});
test('KTO-01 Kriterium 7: AUTH-CFG-10 die 429-Antwort traegt weder Retry-After noch X-RateLimit-* (App nennt keine Dauer)', async () => {
const { email, password } = await createUser();
const gotrue = client(randomIp());
const started = Date.now();
let limited;
let attempts = 0;
// Hoechstens Burst + Toleranz aus der gemessenen Dauer; danach muss eine 429 gekommen sein.
while (attempts < authConfig.loginBurst + refillTolerance(Date.now() - started) + 1) {
attempts++;
const res = await gotrue.signIn(email, `${password}-falsch`);
if (res.status === 429) {
limited = res;
break;
}
}
if (!limited) throw new Error(`Keine 429 nach ${attempts} Versuchen in ${Date.now() - started} ms`);
expect(limited.headers.get('retry-after')).toBeNull();
const rateHeaders = [...limited.headers.keys()].filter((h) => h.toLowerCase().startsWith('x-ratelimit'));
expect(rateHeaders).toEqual([]);
});
test('KTO-01 Kriterium 7: AUTH-CFG-10 eine zweite IP ist von der Drosselung der ersten nicht betroffen', async () => {
const { email, password } = await createUser();
const first = client(randomIp());
for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) {
await first.signIn(email, `${password}-falsch`);
}
expect((await first.signIn(email, `${password}-falsch`)).status).toBe(429);
const second = client(randomIp());
const wrong = await second.signIn(email, `${password}-falsch`);
expect(wrong.status).toBe(400);
expect(wrong.body.error_code).toBe(errorCodes.invalidCredentials);
const right = await second.signIn(email, password);
expect(right.status).toBe(200);
expect(right.body.access_token).toBeTruthy();
});
test('KTO-01 Kriterium 7: AUTH-CFG-13 Requests ohne X-Forwarded-For werden nicht gedrosselt', async () => {
const { email, password } = await createUser();
const gotrue = client();
for (let attempt = 1; attempt <= authConfig.loginBurst + 5; attempt++) {
const res = await gotrue.signIn(email, `${password}-falsch`);
expect(res.status, `Versuch ${attempt}`).toBe(400);
}
});
});

View file

@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { subjects } from '../../support/auth-config';
import { profileByEmail } from '../../support/db';
import { randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
import { expectSignedIn, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt“ und Anmeldung', () => {
test('KTO-01 Kriterium 1: Registrieren, Link in der Mail oeffnen, bestaetigen, anmelden', async ({ page }) => {
const email = randomEmail();
const password = randomPassword();
await register(page, { email, password, displayName: ' Webnutzer ' });
await expect(page).toHaveURL(/\/register\/check-email$/);
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.confirmation.en);
const { link } = extractTokenHash(mail);
expect((await profileByEmail(email)).display_name).toBe('Webnutzer');
// Der Link aus der Mail fuehrt auf die App; erst der Klick loest die Pruefung aus (Spec E3).
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.confirmSuccess)).toBeVisible();
await page.context().clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, email, password);
await expectSignedIn(page);
});
});

View file

@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test';
import { userByEmail } from '../../support/db';
import { randomEmail, randomPassword } from '../../support/gotrue';
import { fillRegistration, open, t, textOf } from '../../support/web';
/** Zaehlt Signup-Aufrufe der Seite (Krit. 2: „kein Aufruf“). */
function countSignups(page: import('@playwright/test').Page) {
const calls: string[] = [];
page.on('request', (r) => {
if (r.method() === 'POST' && r.url().includes('/auth/v1/signup')) calls.push(r.url());
});
return calls;
}
test.describe('KTO-01 Kriterien 2, 3, 10 (web): Formularvalidierung', () => {
test('KTO-01 Kriterium 2: ohne Altersbestaetigung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
const email = randomEmail();
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email, password: randomPassword(), age: false });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, t.ageNotConfirmed)).toBeVisible();
await expect(page).toHaveURL(/\/register$/);
expect(calls).toEqual([]);
expect(await userByEmail(email)).toBeUndefined();
});
test('QUE-04 Kriterium 1: ohne Zustimmung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), terms: false });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, t.termsNotAccepted)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 3: zu kurzes Passwort zeigt am Feld, wie viele Zeichen fehlen, und sendet nichts', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: 'kurz12345' });
await page.getByRole('button', { name: 'Create account' }).click();
// 9 von 10 Zeichen: noch 1 fehlt (AUTH-CFG-05).
await expect(textOf(page, /1 more character needed/)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 3: sehr verbreitetes Passwort wird am Feld abgelehnt', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: 'password123' });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, /very common and easy to guess/)).toBeVisible();
expect(calls).toEqual([]);
});
test('KTO-01 Kriterium 10: leerer Anzeigename zeigt den Fehler am Feld', async ({ page }) => {
const calls = countSignups(page);
await open(page, '/register');
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), displayName: ' ' });
await page.getByRole('button', { name: 'Create account' }).click();
await expect(textOf(page, 'Enter a display name.')).toBeVisible();
expect(calls).toEqual([]);
});
});

View file

@ -0,0 +1,122 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, waitForMail } from '../../support/mailpit';
import { freshBrowserContext, fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();
/** Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse. */
async function screenText(page: import('@playwright/test').Page): Promise<string> {
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
return (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
}
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
test('KTO-01 Kriterium 4: Registrierung mit bestehender, bestaetigter Adresse zeigt exakt denselben Bildschirm wie mit neuer Adresse', async ({
page,
browser,
}) => {
// Neue Adresse.
await register(page, { email: randomEmail(), password: randomPassword() });
await expect(page).toHaveURL(/\/register\/check-email$/);
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
const fresh = await screenText(page);
// Vorhandene, bestaetigte Adresse (nur ueber die API angelegt).
const existing = randomEmail();
await gotrue.createConfirmedUser(existing, randomPassword());
const second = await (await freshBrowserContext(browser)).newPage();
try {
await register(second, { email: existing, password: randomPassword() });
await expect(second).toHaveURL(/\/register\/check-email$/);
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
expect(await screenText(second)).toBe(fresh);
// Es geht keine Mail an das bestehende Konto.
await expectMailCount(existing, 0);
} finally {
await second.context().close();
}
});
});
test.describe('KTO-01 Kriterium 6 (web): unbestaetigtes Konto', () => {
test('KTO-01 Kriterium 6: Login mit richtigem Passwort zeigt Hinweis und Aktion; „erneut senden“ schickt eine zweite Mail', async ({
page,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.signUp(email, password);
await waitForMail(email);
await waitMailInterval();
await login(page, email, password);
await expect(textOf(page, t.emailNotConfirmed)).toBeVisible();
await page.getByRole('button', { name: t.resendConfirmation }).click();
await expect(textOf(page, t.checkEmailResent)).toBeVisible();
await waitForMail(email, 2);
});
});
test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselungshinweis', () => {
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort zeigen denselben Text', async ({ page, browser }) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, `${password}-falsch`);
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
const wrongPassword = await screenText(page);
const ctx = await freshBrowserContext(browser);
try {
const other = await ctx.newPage();
await login(other, randomEmail('unknown'), password);
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
expect(await screenText(other)).toBe(wrongPassword);
} finally {
await ctx.close();
}
});
/**
* Die CLI drosselt nie (AUTH-CFG-13/A2), ein echter Test waere nie rot. Die 429-Antwort wird
* deshalb per `page.route` simuliert; der echte Limiter wird im Projekt `throttle` geprueft.
* CORS: Die App (localhost:3000) ruft Kong (127.0.0.1:54321) cross-origin auf; die simulierte
* Antwort braucht deshalb die CORS-Header, sonst sieht die App einen Netzfehler statt 429.
*/
test('KTO-01 Kriterium 7: AUTH-CFG-10 bei 429 erscheint der Drosselungshinweis ohne Dauer', async ({ page }) => {
await page.route('**/auth/v1/token?grant_type=password', async (route) => {
const request = route.request();
if (request.method() === 'OPTIONS') return route.fallback();
await route.fulfill({
status: 429,
contentType: 'application/json',
headers: { 'access-control-allow-origin': request.headers()['origin'] ?? '*' },
body: JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }),
});
});
await login(page, randomEmail(), randomPassword());
await expect(textOf(page, t.rateLimited)).toBeVisible();
await expect(textOf(page, t.invalidCredentials)).toHaveCount(0);
});
});
test.describe('QUE-06 (web): Ablauf nur per Tastatur', () => {
test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter (E-Mail-Feld per Tab erreicht)', async ({ page }) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await open(page, '/login');
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
// Kein Klick: Tab fokussiert das erste Feld (E-Mail), Tab das zweite (Passwort).
await page.keyboard.press('Tab');
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeFocused();
await page.keyboard.type(email);
await page.keyboard.press('Tab');
await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused();
await page.keyboard.type(password);
await page.keyboard.press('Enter');
await expect(textOf(page, t.home)).toBeVisible();
});
});

View file

@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { env } from '../../support/auth-config';
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, open, readAuthStorage } from '../../support/web';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 5 (web): Sitzung bleibt bestehen', () => {
test('KTO-01 Kriterium 5: Login, Reload und neuer Browser-Kontext mit gleichem Storage bleiben angemeldet', async ({
page,
browser,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, password);
await expectSignedIn(page);
const stored = await readAuthStorage(page);
expect(Object.keys(stored).length, 'Sitzung liegt im localStorage').toBeGreaterThan(0);
// Reload: weiterhin angemeldet (/login leitet auf die Startseite).
await page.reload();
await expectSignedInViaLoginRedirect(page);
// Neuer Browser-Kontext mit gleichem Storage (z. B. Browser neu gestartet).
const state = await page.context().storageState();
const restored = await browser.newContext({
storageState: state,
locale: 'en-US',
serviceWorkers: 'block',
baseURL: env.webUrl,
});
try {
const other = await restored.newPage();
await other.goto('/login');
await enableSemantics(other);
await expect(other).toHaveURL(/\/$/);
} finally {
await restored.close();
}
});
test('KTO-01 Kriterium 5: ohne Sitzung bleibt /login erreichbar (Gegenprobe)', async ({ page }) => {
await open(page, '/login');
// Erst nach dem Flutter-Boot pruefen: das Formular ist sichtbar, ein spaeter Redirect waere bis dahin passiert.
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
await page.waitForTimeout(500);
await expect(page).toHaveURL(/\/login$/);
});
});

View file

@ -0,0 +1,102 @@
import { expect, test } from '@playwright/test';
import { subjects } from '../../support/auth-config';
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
import { freshBrowserContext, fillField, open, pressFirstButton, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();
async function requestReset(page: import('@playwright/test').Page, email: string) {
await open(page, '/forgot-password');
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
await page.getByRole('button', { name: 'Send link' }).click();
await expect(page).toHaveURL(/\/forgot-password\/check-email$/);
}
test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
test('KTO-01 Kriterium 8: Reset anfordern, Link oeffnen, neues Passwort setzen, mit neuem Passwort anmelden', async ({ page }) => {
const email = randomEmail();
const oldPassword = randomPassword();
const newPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
await requestReset(page, email);
const mail = await waitForMail(email);
expect(mail.Subject).toBe(subjects.recovery.en);
const { link, type } = extractTokenHash(mail);
expect(type).toBe('recovery');
// Der Link fuehrt auf die Link-Seite; erst der Klick loest die Pruefung aus.
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(page).toHaveURL(/\/reset-password$/);
await fillField(page.getByRole('textbox', { name: 'New password' }), newPassword);
await page.getByRole('button', { name: 'Save password' }).click();
await expect(textOf(page, t.passwordChanged)).toBeVisible();
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe('invalid_credentials');
});
test('KTO-01 Kriterium 8: bereits benutzter Reset-Link zeigt „abgelaufen oder schon benutzt“', async ({ page }) => {
const email = randomEmail();
await gotrue.createConfirmedUser(email, randomPassword());
await gotrue.recover(email);
const { link, tokenHash } = extractTokenHash(await waitForMail(email));
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); // Link verbraucht
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Reset-Link (recovery_sent_at 1 h + 1 s zurueck) zeigt den Fehlerzustand', async ({ page }) => {
const email = randomEmail();
await gotrue.createConfirmedUser(email, randomPassword());
await gotrue.recover(email);
const { link } = extractTokenHash(await waitForMail(email));
await expireRecoveryLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
await expect(page.getByRole('button', { name: 'Request a new link' })).toBeVisible();
});
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Bestaetigungslink zeigt den Fehlerzustand', async ({ page }) => {
const email = randomEmail();
await gotrue.signUp(email, randomPassword());
const { link } = extractTokenHash(await waitForMail(email));
await expireConfirmationLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 8: Link ohne Parameter zeigt sofort den Fehlerzustand', async ({ page }) => {
await open(page, '/auth/confirm');
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
test('KTO-01 Kriterium 4/8: Reset fuer unbekannte Adresse zeigt denselben Bildschirm, es geht keine Mail raus', async ({ page, browser }) => {
const known = randomEmail();
await gotrue.createConfirmedUser(known, randomPassword());
await requestReset(page, known);
await expect(textOf(page, /reset link if an account exists/)).toBeVisible();
const body = (await page.locator('body').ariaSnapshot()).trim();
const unknown = randomEmail('unknown');
const ctx = await freshBrowserContext(browser);
try {
const other = await ctx.newPage();
await requestReset(other, unknown);
expect((await other.locator('body').ariaSnapshot()).trim()).toBe(body);
} finally {
await ctx.close();
}
await expectMailCount(unknown, 0);
});
});

View file

@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readSession, shiftBrowserClock } from '../../support/web';
const gotrue = new GoTrueClient();
test.describe('KTO-01 Kriterium 9 (web): Passwort-Reset beendet andere Sitzungen', () => {
test('KTO-01 Kriterium 9: zweiter Browser-Kontext ist nach dem Reset und dem naechsten (erzwungenen) Refresh abgemeldet', async ({
page,
browser,
}) => {
const email = randomEmail();
const oldPassword = randomPassword();
await gotrue.createConfirmedUser(email, oldPassword);
// Sitzung B: ein zweites Geraet in eigenem Kontext.
await login(page, email, oldPassword);
await expectSignedIn(page);
// Reset ueber Sitzung A (API): neues Passwort setzen beendet alle anderen Sitzungen.
await gotrue.recover(email);
const { tokenHash } = extractTokenHash(await waitForMail(email));
const recovery = await gotrue.verify(tokenHash, 'recovery');
const update = await gotrue.updateUser(recovery.body.access_token, { password: randomPassword() });
expect(update.status).toBe(200);
// Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, bis 60 min, F1) ab. Der Test
// erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt (Session.expiresAt kommt in
// supabase_flutter aus dem JWT-Claim `exp`, ein geaendertes `expires_at` im Storage wirkt nicht).
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
await page.reload();
await enableSemantics(page);
// Der Refresh scheitert (refresh_token_not_found): supabase_flutter loescht die Sitzung aus dem Storage.
await expect.poll(async () => await readSession(page), { timeout: 15_000 }).toBeNull();
// Abgemeldet: /login leitet nicht mehr auf die Startseite (Gegenprobe zu Krit. 5).
await page.goto('/login');
await enableSemantics(page);
await page.waitForTimeout(1_000);
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
});
test('KTO-01 Kriterium 9: Gegenprobe ohne Reset und mit abgelaufenem Access-Token bleibt die Sitzung bestehen (Refresh klappt)', async ({
page,
}) => {
const email = randomEmail();
const password = randomPassword();
await gotrue.createConfirmedUser(email, password);
await login(page, email, password);
await expectSignedIn(page);
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
const before = (await readSession(page))?.access_token;
expect(before, 'Sitzung vor dem Reload').toBeTruthy();
await page.reload();
await expectSignedInViaLoginRedirect(page);
// Der Refresh lief wirklich: neues Access-Token im Storage.
await expect
.poll(async () => (await readSession(page))?.access_token ?? before, { timeout: 15_000 })
.not.toBe(before);
});
});

13
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["support", "tests", "playwright.config.ts"]
}

View file

@ -22,6 +22,12 @@ supabase test db # alle pgTAP-Tests ausführen
- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md). - Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md).
- Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`). - Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`).
## E2E-Tests (Playwright)
- `e2e/` testet GoTrue über Kong (`api`), die gebaute Web-App (`web`), die IP-Drosselung (`throttle`) und den SMTP-Ausfall (`spike`). Voraussetzung: `supabase start` mit Mailpit und Postgres (Ports 54321, 54322, 54324).
- `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Rolle `postgres`) und setzt `recovery_sent_at`/`confirmation_sent_at` für die Ablauftests; es bricht bei nicht-lokalem Host ab.
- Der Extra-Container für `throttle`/`spike` startet mit `e2e/support/start-throttle-gotrue.sh`. Details: [`e2e/README.md`](../e2e/README.md).
- CI: Job `e2e` in `.forgejo/workflows/ci.yml` (Runner `docker-host`, dind); Ablauf in [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md) Abschnitt 7.
## Regeln ## Regeln
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security. - Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich. - `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.