diff --git a/.forgejo/scripts/e2e-run.sh b/.forgejo/scripts/e2e-run.sh new file mode 100755 index 0000000..2caaecb --- /dev/null +++ b/.forgejo/scripts/e2e-run.sh @@ -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 diff --git a/.forgejo/scripts/remote-templates.sh b/.forgejo/scripts/remote-templates.sh new file mode 100755 index 0000000..5a522b2 --- /dev/null +++ b/.forgejo/scripts/remote-templates.sh @@ -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//Shuffle/supabase/templates/confirmation.html) in den Kong-Container ein +# (-> /home/kong/templates/email/.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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a480a10..423c16f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -155,6 +155,9 @@ jobs: # Nutzt die eingecheckte supabase/config.toml. Ohne passenden Runner Job bei Bedarf deaktivieren. supabase-tests: runs-on: docker-host + concurrency: + group: docker-host-supabase + cancel-in-progress: false env: DOCKER_CLI_VERSION: 27.3.1 DIND_PORT: 2375 @@ -182,3 +185,157 @@ jobs: - name: Aufräumen if: always() 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 diff --git a/.gitignore b/.gitignore index 09dff15..222a72c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ app/coverage/ node_modules/ test-results/ playwright-report/ +blob-report/ +playwright/.cache/ +.playwright-mcp/ +e2e/.auth/ # OS .DS_Store diff --git a/docs/auth-konfiguration.md b/docs/auth-konfiguration.md index 82410f5..62701c0 100644 --- a/docs/auth-konfiguration.md +++ b/docs/auth-konfiguration.md @@ -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). - **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. + +## 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= 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-` 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. diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 3c654f6..e6dccc2 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -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. 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. 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. 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 | @@ -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). 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 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. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..3685cd0 --- /dev/null +++ b/e2e/README.md @@ -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). diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..aa5e01a --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1311 @@ +{ + "name": "shuffle-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shuffle-e2e", + "version": "0.0.0", + "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" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..438a262 --- /dev/null +++ b/e2e/package.json @@ -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" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..64494be --- /dev/null +++ b/e2e/playwright.config.ts @@ -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, + }, +}); diff --git a/e2e/support/auth-config.ts b/e2e/support/auth-config.ts new file mode 100644 index 0000000..5522221 --- /dev/null +++ b/e2e/support/auth-config.ts @@ -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'; diff --git a/e2e/support/db.ts b/e2e/support/db.ts new file mode 100644 index 0000000..b525653 --- /dev/null +++ b/e2e/support/db.ts @@ -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(fn: (c: Client) => Promise): Promise { + 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 { + 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), + ); +} diff --git a/e2e/support/gotrue.ts b/e2e/support/gotrue.ts new file mode 100644 index 0000000..b255d99 --- /dev/null +++ b/e2e/support/gotrue.ts @@ -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 { + 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; +} + +/** Minimaler GoTrue-Client (REST, wie `supabase_flutter`). */ +export class GoTrueClient { + readonly baseUrl: string; + private readonly apikey: string | null; + private readonly extraHeaders: Record; + + 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( + method: string, + path: string, + body?: unknown, + token?: string, + extra: Record = {}, + ): Promise> { + const headers: Record = { '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 | 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) { + 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 = 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 = {}): Record { + const data: Record = { + 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(); diff --git a/e2e/support/mailpit.ts b/e2e/support/mailpit.ts new file mode 100644 index 0000000..cc34374 --- /dev/null +++ b/e2e/support/mailpit.ts @@ -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 { + 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 { + const result = await getJson(`/search?query=${encodeURIComponent(`to:"${address}"`)}&limit=50`); + return (result.messages ?? []) as MailSummary[]; +} + +export async function countMails(address: string): Promise { + return (await mailsTo(address)).length; +} + +export async function readMail(id: string): Promise { + 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 { + 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 { + 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(/&/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 }; +} diff --git a/e2e/support/start-throttle-gotrue.sh b/e2e/support/start-throttle-gotrue.sh new file mode 100755 index 0000000..19da8f4 --- /dev/null +++ b/e2e/support/start-throttle-gotrue.sh @@ -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= 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 diff --git a/e2e/support/tcp-forward.mjs b/e2e/support/tcp-forward.mjs new file mode 100644 index 0000000..c937bba --- /dev/null +++ b/e2e/support/tcp-forward.mjs @@ -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: an : weiter (HTTP und Postgres, rein TCP). +// +// Aufruf: node tcp-forward.mjs [ ...] +import net from 'node:net'; + +const [host, ...ports] = process.argv.slice(2); +if (!host || ports.length === 0) { + console.error('Aufruf: node tcp-forward.mjs [ ...]'); + 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}`)); +} diff --git a/e2e/support/web.ts b/e2e/support/web.ts new file mode 100644 index 0000000..bb93888 --- /dev/null +++ b/e2e/support/web.ts @@ -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 { + 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 { + 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 { + await page.goto(path); + await enableSemantics(page); +} + +/** + * Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte 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 { + 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 { + 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[1]): Promise { + 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 { + 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 { + 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 { + 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> { + return page.evaluate(() => { + const out: Record = {}; + 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 { + 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 { + 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 { + 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 { + return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl }); +} diff --git a/e2e/tests/api/config.spec.ts b/e2e/tests/api/config.spec.ts new file mode 100644 index 0000000..9d86af9 --- /dev/null +++ b/e2e/tests/api/config.spec.ts @@ -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} |`); + } + }); +}); diff --git a/e2e/tests/api/guards.spec.ts b/e2e/tests/api/guards.spec.ts new file mode 100644 index 0000000..b61d509 --- /dev/null +++ b/e2e/tests/api/guards.spec.ts @@ -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/); + }); +}); diff --git a/e2e/tests/api/krit01-registrierung.spec.ts b/e2e/tests/api/krit01-registrierung.spec.ts new file mode 100644 index 0000000..20edf1b --- /dev/null +++ b/e2e/tests/api/krit01-registrierung.spec.ts @@ -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'); + }); +}); diff --git a/e2e/tests/api/krit02-03-validierung.spec.ts b/e2e/tests/api/krit02-03-validierung.spec.ts new file mode 100644 index 0000000..9518f9d --- /dev/null +++ b/e2e/tests/api/krit02-03-validierung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit04-enumeration.spec.ts b/e2e/tests/api/krit04-enumeration.spec.ts new file mode 100644 index 0000000..83b1ae5 --- /dev/null +++ b/e2e/tests/api/krit04-enumeration.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit05-sitzung.spec.ts b/e2e/tests/api/krit05-sitzung.spec.ts new file mode 100644 index 0000000..f8630b5 --- /dev/null +++ b/e2e/tests/api/krit05-sitzung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit06-07-anmeldung.spec.ts b/e2e/tests/api/krit06-07-anmeldung.spec.ts new file mode 100644 index 0000000..79528ea --- /dev/null +++ b/e2e/tests/api/krit06-07-anmeldung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit08-reset-link.spec.ts b/e2e/tests/api/krit08-reset-link.spec.ts new file mode 100644 index 0000000..ddea037 --- /dev/null +++ b/e2e/tests/api/krit08-reset-link.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit09-abmeldung.spec.ts b/e2e/tests/api/krit09-abmeldung.spec.ts new file mode 100644 index 0000000..5171ba4 --- /dev/null +++ b/e2e/tests/api/krit09-abmeldung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit10-anzeigename.spec.ts b/e2e/tests/api/krit10-anzeigename.spec.ts new file mode 100644 index 0000000..7f5be97 --- /dev/null +++ b/e2e/tests/api/krit10-anzeigename.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts b/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts new file mode 100644 index 0000000..f1df705 --- /dev/null +++ b/e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts @@ -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(''); + }); + + 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(''); + }); + + 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(''); + 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(''); + }); + + 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); + }); +}); diff --git a/e2e/tests/spike/smtp-down.spec.ts b/e2e/tests/spike/smtp-down.spec.ts new file mode 100644 index 0000000..45b0332 --- /dev/null +++ b/e2e/tests/spike/smtp-down.spec.ts @@ -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-.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) }); + }); +}); diff --git a/e2e/tests/throttle/krit07-drosselung.spec.ts b/e2e/tests/throttle/krit07-drosselung.spec.ts new file mode 100644 index 0000000..0268eed --- /dev/null +++ b/e2e/tests/throttle/krit07-drosselung.spec.ts @@ -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); + } + }); +}); diff --git a/e2e/tests/web/krit01-registrierung.spec.ts b/e2e/tests/web/krit01-registrierung.spec.ts new file mode 100644 index 0000000..ef7336b --- /dev/null +++ b/e2e/tests/web/krit01-registrierung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/web/krit02-03-10-formular.spec.ts b/e2e/tests/web/krit02-03-10-formular.spec.ts new file mode 100644 index 0000000..a2170a8 --- /dev/null +++ b/e2e/tests/web/krit02-03-10-formular.spec.ts @@ -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([]); + }); +}); diff --git a/e2e/tests/web/krit04-06-07-anmeldung.spec.ts b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts new file mode 100644 index 0000000..e78b931 --- /dev/null +++ b/e2e/tests/web/krit04-06-07-anmeldung.spec.ts @@ -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 { + // 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(); + }); +}); diff --git a/e2e/tests/web/krit05-sitzung.spec.ts b/e2e/tests/web/krit05-sitzung.spec.ts new file mode 100644 index 0000000..9bb5def --- /dev/null +++ b/e2e/tests/web/krit05-sitzung.spec.ts @@ -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$/); + }); +}); diff --git a/e2e/tests/web/krit08-reset.spec.ts b/e2e/tests/web/krit08-reset.spec.ts new file mode 100644 index 0000000..5ee03c2 --- /dev/null +++ b/e2e/tests/web/krit08-reset.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/web/krit09-abmeldung.spec.ts b/e2e/tests/web/krit09-abmeldung.spec.ts new file mode 100644 index 0000000..cf4099d --- /dev/null +++ b/e2e/tests/web/krit09-abmeldung.spec.ts @@ -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); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..a399888 --- /dev/null +++ b/e2e/tsconfig.json @@ -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"] +} diff --git a/supabase/README.md b/supabase/README.md index 09513d8..b805cd6 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -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). - 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 - 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.