test(auth): KTO-01 Playwright-E2E (api, web, throttle) und CI-Job e2e #42
38 changed files with 3877 additions and 1 deletions
43
.forgejo/scripts/e2e-run.sh
Executable file
43
.forgejo/scripts/e2e-run.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Playwright-E2E (Projekte api, web, throttle) gegen den laufenden Supabase-Stack im dind (Issue #38, D2).
|
||||||
|
# Voraussetzungen: DOCKER_HOST/DOCKER_GW gesetzt, `supabase start` lief, Extra-GoTrue auf :9998 laeuft,
|
||||||
|
# Web-Build in app/build/web. Die Supabase-Ports liegen im dind; ein TCP-Relay macht sie unter
|
||||||
|
# 127.0.0.1 erreichbar, wie es die Tests und die Web-App (CORS/Origin, Site-URL localhost:3000) erwarten.
|
||||||
|
# Der Spike (SPIKE_GOTRUE_URL) laeuft in CI bewusst nicht.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${DOCKER_GW:?DOCKER_GW (dind-Gateway) fehlt}"
|
||||||
|
: "${SUPABASE_ANON_KEY:?SUPABASE_ANON_KEY fehlt}"
|
||||||
|
|
||||||
|
node e2e/support/tcp-forward.mjs "$DOCKER_GW" 54321 54322 54324 9998 &
|
||||||
|
relay=$!
|
||||||
|
trap 'kill "$relay" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
# Warten, bis die Dienste HINTER dem Relay antworten (nicht nur der lokale Port); Timeout = Fehler.
|
||||||
|
wait_http() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -fsS -o /dev/null --max-time 3 -H "apikey: ${SUPABASE_ANON_KEY}" "$1" 2>/dev/null && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Timeout: $1 nicht erreichbar (Relay zu ${DOCKER_GW})" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
wait_http "http://127.0.0.1:54321/auth/v1/health" || exit 1 # Kong -> GoTrue
|
||||||
|
wait_http "http://127.0.0.1:54324/api/v1/info" || exit 1
|
||||||
|
wait_http "http://127.0.0.1:9998/health" || exit 1
|
||||||
|
pg_ok=""
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if (exec 3<>/dev/tcp/127.0.0.1/54322) 2>/dev/null; then pg_ok=1; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$pg_ok" ] || { echo "Timeout: Postgres 54322 nicht erreichbar" >&2; exit 1; }
|
||||||
|
|
||||||
|
export SUPABASE_URL=http://127.0.0.1:54321
|
||||||
|
export MAILPIT_URL=http://127.0.0.1:54324
|
||||||
|
export E2E_DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||||
|
export WEB_URL=http://localhost:3000
|
||||||
|
export THROTTLE_GOTRUE_URL=http://127.0.0.1:9998
|
||||||
|
export CI=true
|
||||||
|
|
||||||
|
cd e2e
|
||||||
|
npx playwright test --project=api --project=web --project=throttle
|
||||||
59
.forgejo/scripts/remote-templates.sh
Executable file
59
.forgejo/scripts/remote-templates.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mailvorlagen unter einem entfernten Docker-Daemon (dind) bereitstellen (Issue #38, CI-Laeufe 27/28).
|
||||||
|
#
|
||||||
|
# Ursache: Die Supabase CLI bindet supabase/templates/*.html per Bind-Mount mit dem Pfad des Job-Containers
|
||||||
|
# (z. B. /workspace/<owner>/Shuffle/supabase/templates/confirmation.html) in den Kong-Container ein
|
||||||
|
# (-> /home/kong/templates/email/<name>.html); GoTrue holt die Vorlagen per URL von Kong. Im dind existiert
|
||||||
|
# der Pfad nicht. Docker legt dann ein leeres VERZEICHNIS an, Kong zeigt "Index of /email/confirmation.html/",
|
||||||
|
# und GoTrue verschickt diese Seite still statt der Vorlage (Spec R5). Ein nachtraegliches Ersetzen hilft nicht:
|
||||||
|
# das Mount-Ziel im Container-Rootfs ist dann ein Verzeichnis, ein Neustart scheitert (runc).
|
||||||
|
#
|
||||||
|
# Deshalb: VOR `supabase start` die Dateien im dind unter genau diesem Pfad anlegen (Helfer-Container,
|
||||||
|
# Inhalt per stdin), dann entstehen von Anfang an Datei-Mounts. Die Auslieferung bleibt eine URL
|
||||||
|
# (wie in Staging/Prod: GOTRUE_MAILER_TEMPLATES_*).
|
||||||
|
#
|
||||||
|
# remote-templates.sh seed vor `supabase start`
|
||||||
|
# remote-templates.sh verify nach `start`/`db reset`: Datei-Mounts und Inhalt pruefen, sonst Exit 1
|
||||||
|
# Keine Secrets: Ausgabe nur Pfade und Dateinamen.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ID="${SUPABASE_PROJECT_ID:-shuffle}"
|
||||||
|
HELPER_IMAGE="${HELPER_IMAGE:-public.ecr.aws/docker/library/busybox:1.37.0}"
|
||||||
|
SRC_DIR="$(pwd)/supabase/templates" # Pfad, den die CLI als Mount-Quelle verwendet
|
||||||
|
KONG="supabase_kong_${PROJECT_ID}"
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
ls "$SRC_DIR"/*.html >/dev/null 2>&1 || { echo "Keine Vorlagen in $SRC_DIR" >&2; exit 1; }
|
||||||
|
for f in "$SRC_DIR"/*.html; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
# Reste frueherer Laeufe (leeres Verzeichnis) werden ersetzt.
|
||||||
|
docker run --rm -i -v "$SRC_DIR:/t" "$HELPER_IMAGE" \
|
||||||
|
sh -c "[ ! -d '/t/$name' ] || rmdir '/t/$name'; cat > '/t/$name'" < "$f" \
|
||||||
|
|| { echo "Vorlage $name konnte im dind nicht angelegt werden (Image $HELPER_IMAGE, Pfad $SRC_DIR)" >&2; exit 1; }
|
||||||
|
echo "Vorlage im dind angelegt: $SRC_DIR/$name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
verify() {
|
||||||
|
ok=1
|
||||||
|
for f in "$SRC_DIR"/*.html; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
if ! docker exec "$KONG" sh -c "[ -f '/home/kong/templates/email/$name' ] && [ -s '/home/kong/templates/email/$name' ]" 2>/dev/null; then
|
||||||
|
echo "FEHLER: $KONG: /home/kong/templates/email/$name ist keine nicht-leere Datei." >&2
|
||||||
|
ok=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$ok" -ne 1 ]; then
|
||||||
|
echo "Bind-Mounts von $KONG (Quelle -> Ziel):" >&2
|
||||||
|
docker inspect "$KONG" --format '{{range .Mounts}}{{if eq .Type "bind"}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}{{end}}' >&2 || true
|
||||||
|
echo "Ohne gueltige Vorlagen verschickt GoTrue still eine Fehlerseite statt der Mail (Spec R5)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Mailvorlagen in $KONG vorhanden"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
seed) seed ;;
|
||||||
|
verify) verify ;;
|
||||||
|
*) echo "Aufruf: $0 seed|verify" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
@ -155,6 +155,9 @@ jobs:
|
||||||
# Nutzt die eingecheckte supabase/config.toml. Ohne passenden Runner Job bei Bedarf deaktivieren.
|
# Nutzt die eingecheckte supabase/config.toml. Ohne passenden Runner Job bei Bedarf deaktivieren.
|
||||||
supabase-tests:
|
supabase-tests:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
|
concurrency:
|
||||||
|
group: docker-host-supabase
|
||||||
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DOCKER_CLI_VERSION: 27.3.1
|
DOCKER_CLI_VERSION: 27.3.1
|
||||||
DIND_PORT: 2375
|
DIND_PORT: 2375
|
||||||
|
|
@ -182,3 +185,157 @@ jobs:
|
||||||
- name: Aufräumen
|
- name: Aufräumen
|
||||||
if: always()
|
if: always()
|
||||||
run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true
|
run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true
|
||||||
|
|
||||||
|
# Web-E2E (Playwright: api, web, throttle) gegen lokalen Supabase-Stack und gebaute Flutter-Web-App (Issue #38, Spec #1 D2).
|
||||||
|
# Läuft wie supabase-tests auf "docker-host" (Docker-CLI + dind per TCP). Der Stack liegt im dind; ein
|
||||||
|
# TCP-Relay (e2e/support/tcp-forward.mjs) macht ihn im Job unter 127.0.0.1 erreichbar.
|
||||||
|
# SICHERHEIT: Der Job führt PR-Code mit Zugriff auf den dind-Daemon des Runners aus (Daemon = Root-Äquivalent).
|
||||||
|
# Der `if:` unten ist KEIN Schutz vor böswilligen Forks: Bei pull_request kommt der Workflow aus dem PR-Head,
|
||||||
|
# ein Fork kann die Bedingung einfach entfernen. Er verhindert nur versehentliche Läufe. Die echte Absicherung
|
||||||
|
# gehört in Repo-/Runner-Einstellungen (Freigabe für Fork-PRs, Runner nur für vertrauenswürdige Repos; Folge-Issue).
|
||||||
|
# Gleiches Risiko hat supabase-tests.
|
||||||
|
# needs + concurrency: Beide Jobs belegen dieselben festen Container-Namen/Ports (project_id shuffle, 54321 ...) im dind.
|
||||||
|
# `needs` serialisiert innerhalb eines Laufs; die concurrency-Gruppe (auch an supabase-tests) soll Läufe verschiedener
|
||||||
|
# PRs hintereinander schalten. Ob Forgejo 8.0.3 / Runner v5.0.4 Job-concurrency auswertet, ist hier nicht verifiziert;
|
||||||
|
# sonst wird der Schlüssel ignoriert und Kollisionen bleiben möglich (dann Runner mit capacity 1 verwenden).
|
||||||
|
# Der Spike (SPIKE_GOTRUE_URL, Issue #37) läuft nicht in CI.
|
||||||
|
e2e:
|
||||||
|
runs-on: docker-host
|
||||||
|
concurrency:
|
||||||
|
group: docker-host-supabase
|
||||||
|
cancel-in-progress: false
|
||||||
|
needs: [supabase-tests]
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
env:
|
||||||
|
DOCKER_CLI_VERSION: 27.3.1
|
||||||
|
DIND_PORT: 2375
|
||||||
|
PUB_CACHE: ${{ github.workspace }}/.pub-cache
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||||
|
steps:
|
||||||
|
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Werkzeuge prüfen
|
||||||
|
run: |
|
||||||
|
missing=""
|
||||||
|
for t in git curl unzip xz jq; do
|
||||||
|
command -v "$t" >/dev/null || { [ "$t" = xz ] && missing="$missing xz-utils" || missing="$missing $t"; }
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
apt-get update -qq && apt-get install -y -qq --no-install-recommends $missing ca-certificates
|
||||||
|
fi
|
||||||
|
node --version
|
||||||
|
|
||||||
|
- name: Docker-CLI installieren und dind anbinden
|
||||||
|
run: |
|
||||||
|
arch=$(uname -m)
|
||||||
|
curl -fsSL "https://download.docker.com/linux/static/stable/${arch}/docker-${DOCKER_CLI_VERSION}.tgz" \
|
||||||
|
| tar -xz -C /usr/local/bin --strip-components=1 docker/docker
|
||||||
|
hex=$(awk '$2=="00000000"{print $3; exit}' /proc/net/route)
|
||||||
|
gw=$(printf '%d.%d.%d.%d' "0x${hex:6:2}" "0x${hex:4:2}" "0x${hex:2:2}" "0x${hex:0:2}")
|
||||||
|
echo "DOCKER_GW=${gw}" >> "$GITHUB_ENV"
|
||||||
|
echo "DOCKER_HOST=tcp://${gw}:${DIND_PORT}" >> "$GITHUB_ENV"
|
||||||
|
DOCKER_HOST="tcp://${gw}:${DIND_PORT}" docker version --format 'dind {{.Server.Version}}'
|
||||||
|
|
||||||
|
- name: Mailvorlagen im dind anlegen
|
||||||
|
# Unter dind existiert der Bind-Mount-Pfad der Vorlagen nicht; sie müssen VOR `supabase start` dort liegen,
|
||||||
|
# sonst entstehen leere Verzeichnis-Mounts und GoTrue verschickt still eine Fehlerseite. Details im Skript.
|
||||||
|
run: bash .forgejo/scripts/remote-templates.sh seed
|
||||||
|
|
||||||
|
- name: Supabase starten (mit Mailpit, ohne Studio u. a.)
|
||||||
|
# Ausgabe nicht ins Log: Die Zusammenfassung von `start` enthält die lokalen Schlüssel (noch nicht maskiert).
|
||||||
|
run: |
|
||||||
|
if ! npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} start -x studio,imgproxy,edge-runtime,logflare,vector >"${RUNNER_TEMP:-/tmp}/supabase-start.log" 2>&1; then
|
||||||
|
echo "supabase start fehlgeschlagen, letzte Zeilen (Schlüssel gefiltert):"
|
||||||
|
tail -n 40 "${RUNNER_TEMP:-/tmp}/supabase-start.log" | grep -viE 'key|secret|token|jwt'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "supabase start ok"
|
||||||
|
|
||||||
|
- name: Datenbank zurücksetzen
|
||||||
|
run: npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} db reset
|
||||||
|
|
||||||
|
- name: Mailvorlagen im dind prüfen
|
||||||
|
run: bash .forgejo/scripts/remote-templates.sh verify
|
||||||
|
|
||||||
|
- name: Lokale Schlüssel lesen
|
||||||
|
# Öffentliche Demo-Schlüssel der CLI; trotzdem wird die Maske gesetzt, bevor sie irgendwo ausgegeben werden.
|
||||||
|
run: |
|
||||||
|
status=$(npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} status -o env 2>/dev/null)
|
||||||
|
get() { printf '%s\n' "$status" | sed -n "s/^$1=\"\{0,1\}\([^\"]*\)\"\{0,1\}$/\1/p" | head -n1; }
|
||||||
|
anon=$(get ANON_KEY); service=$(get SERVICE_ROLE_KEY)
|
||||||
|
[ -z "$anon" ] || echo "::add-mask::$anon"
|
||||||
|
[ -z "$service" ] || echo "::add-mask::$service"
|
||||||
|
test -n "$anon" && test -n "$service" || { echo "ANON_KEY/SERVICE_ROLE_KEY nicht in 'supabase status -o env' gefunden"; exit 1; }
|
||||||
|
echo "SUPABASE_ANON_KEY=${anon}" >> "$GITHUB_ENV"
|
||||||
|
echo "SUPABASE_SERVICE_ROLE_KEY=${service}" >> "$GITHUB_ENV"
|
||||||
|
# Env-Datei für den Web-Build: Platzhalter-Datei bleibt unverändert, der Schlüssel kommt zur Laufzeit.
|
||||||
|
jq --arg k "$anon" '.SUPABASE_ANON_KEY = $k | .SUPABASE_URL = "http://127.0.0.1:54321"' \
|
||||||
|
app/env/ci.example.json > "${RUNNER_TEMP:-/tmp}/ci.e2e.json"
|
||||||
|
echo "E2E_ENV_FILE=${RUNNER_TEMP:-/tmp}/ci.e2e.json" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Extra-GoTrue für Drosselung (Krit. 7) starten
|
||||||
|
run: |
|
||||||
|
BIND_ADDR=0.0.0.0 HEALTH_HOST="$DOCKER_GW" bash e2e/support/start-throttle-gotrue.sh start throttle
|
||||||
|
|
||||||
|
- name: Flutter-SDK-Cache
|
||||||
|
uses: https://code.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ env.FLUTTER_HOME }}
|
||||||
|
key: flutter-${{ env.FLUTTER_VERSION }}-docker-host
|
||||||
|
|
||||||
|
- name: Flutter installieren
|
||||||
|
run: |
|
||||||
|
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
|
||||||
|
rm -rf "$FLUTTER_HOME"
|
||||||
|
git clone --depth 1 --branch "$FLUTTER_VERSION" https://github.com/flutter/flutter.git "$FLUTTER_HOME"
|
||||||
|
fi
|
||||||
|
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
|
||||||
|
export PATH="$FLUTTER_HOME/bin:$PATH"
|
||||||
|
export CI=true
|
||||||
|
flutter config --no-analytics --enable-web
|
||||||
|
flutter precache --web
|
||||||
|
|
||||||
|
- name: Pub-Cache
|
||||||
|
uses: https://code.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.pub-cache
|
||||||
|
key: pub-${{ env.FLUTTER_VERSION }}-${{ hashFiles('app/pubspec.lock') }}
|
||||||
|
restore-keys: pub-${{ env.FLUTTER_VERSION }}-
|
||||||
|
|
||||||
|
- name: Web-Build mit lokalem Anon-Key
|
||||||
|
working-directory: app
|
||||||
|
run: |
|
||||||
|
flutter pub get
|
||||||
|
flutter build web --release --dart-define-from-file="$E2E_ENV_FILE"
|
||||||
|
|
||||||
|
- name: Playwright-Cache
|
||||||
|
uses: https://code.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.playwright-browsers
|
||||||
|
key: playwright-${{ hashFiles('e2e/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: E2E-Abhängigkeiten und Browser
|
||||||
|
working-directory: e2e
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Playwright (api, web, throttle)
|
||||||
|
run: bash .forgejo/scripts/e2e-run.sh
|
||||||
|
|
||||||
|
- name: Artefakte hochladen (Report, Traces)
|
||||||
|
if: failure()
|
||||||
|
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
e2e/playwright-report
|
||||||
|
e2e/test-results
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
- name: Aufräumen
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
bash e2e/support/start-throttle-gotrue.sh stop throttle || true
|
||||||
|
npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} stop --no-backup || true
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -28,6 +28,10 @@ app/coverage/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
playwright/.cache/
|
||||||
|
.playwright-mcp/
|
||||||
|
e2e/.auth/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -176,3 +176,42 @@ Automatisiert nicht abdeckbar (Spec § 7.1); vor dem Release abhaken:
|
||||||
- **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4).
|
- **Magic Link / OTP** sind nicht abschaltbar, nur am Gateway sperrbar (R4).
|
||||||
- **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5).
|
- **Vorlagen-Rückfall:** Unerreichbare Vorlagen-URL → stille englische Standardvorlage (R5).
|
||||||
- **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil.
|
- **Plain-Text-Teil der Mails:** GoTrue sendet nur HTML. Die Vorlagen zeigen den Link deshalb zusätzlich als Klartext; Mailpit erzeugt lokal einen Text-Teil.
|
||||||
|
|
||||||
|
## 7. E2E-Tests und SMTP-Ausfall (Issue #38, Spike #37)
|
||||||
|
|
||||||
|
Die Playwright-Tests unter `e2e/` (Projekte `api`, `web`, `throttle`, `spike`; Anleitung `e2e/README.md`) sichern diese Konfiguration ab. Konstanten stehen in `e2e/support/auth-config.ts`; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und diese Datei (alle IDs AUTH-CFG-01 bis -25 müssen in Abschnitt 1 vorkommen).
|
||||||
|
|
||||||
|
| ID | Absichernder Test |
|
||||||
|
|---|---|
|
||||||
|
| AUTH-CFG-04, -24 | `tests/api/krit04-enumeration.spec.ts` (Signup mit bestätigter Adresse → 200, `identities` leer, keine Mail, altes Passwort gilt) |
|
||||||
|
| AUTH-CFG-05 | `krit02-03-validierung.spec.ts` (9 Zeichen → 422 `weak_password`) |
|
||||||
|
| AUTH-CFG-07 | `krit08-reset-link.spec.ts` und `web/krit08-reset.spec.ts` (Link nach 1 h + 1 s abgelaufen, 60 s vor Ablauf gültig) |
|
||||||
|
| AUTH-CFG-10, -13 | `tests/throttle/krit07-drosselung.spec.ts` (Login #31 → 429, zweite IP unbeeinflusst, kein `Retry-After`, ohne Header keine Drosselung) |
|
||||||
|
| AUTH-CFG-14, -15, -16 | `krit05-sitzung.spec.ts`, `config.spec.ts` |
|
||||||
|
| AUTH-CFG-17, -18, -19 | `krit11-sprache-benachrichtigungen.spec.ts` |
|
||||||
|
|
||||||
|
### CI-Job `e2e` (Issue #38, Spec #1 D2)
|
||||||
|
|
||||||
|
Der Job `e2e` in `.forgejo/workflows/ci.yml` läuft auf dem Runner `docker-host` (Docker-CLI + dind per TCP, wie `supabase-tests`) und führt die Projekte `api`, `web` und `throttle` aus. Ablauf:
|
||||||
|
|
||||||
|
1. `supabase start -x studio,imgproxy,edge-runtime,logflare,vector` (Mailpit und Postgres bleiben) und `supabase db reset`.
|
||||||
|
2. Anon- und Service-Role-Key kommen zur Laufzeit aus `supabase status -o env`; daraus entsteht eine temporäre Env-Datei (`jq` auf `app/env/ci.example.json`, im `RUNNER_TEMP`, nicht im Repo) für `flutter build web --dart-define-from-file`. Die Datei `ci.example.json` bleibt unverändert.
|
||||||
|
3. Zweiter GoTrue-Container für Krit. 7: `BIND_ADDR=0.0.0.0 HEALTH_HOST=<dind-Gateway> e2e/support/start-throttle-gotrue.sh start throttle` (Port 9998).
|
||||||
|
4. `.forgejo/scripts/e2e-run.sh` startet den TCP-Relay `e2e/support/tcp-forward.mjs` (127.0.0.1:54321/54322/54324/9998 -> dind-Gateway) und ruft `npx playwright test --project=api --project=web --project=throttle` auf. Der Relay ist nötig, weil die Ports im dind liegen, die Tests, die Web-App (CORS/Origin) und `e2e/support/db.ts` (erlaubt nur lokale Hosts) aber 127.0.0.1 erwarten. Die statische Auslieferung der Web-App auf Port 3000 übernimmt das `webServer` der Playwright-Konfiguration (`serve -s`).
|
||||||
|
5. Bei Fehlern werden `e2e/playwright-report` und `e2e/test-results` (Traces) als Artefakt `playwright-<Lauf>` hochgeladen. Der Schritt „Aufräumen“ (`if: always()`) entfernt den Extra-Container und stoppt den Supabase-Stack.
|
||||||
|
|
||||||
|
Das Projekt `spike` (SMTP-Ausfall, `SPIKE_GOTRUE_URL`) läuft in CI nicht. Der Job hat `needs: supabase-tests` (gleiche Container-Namen und Ports im dind) und läuft nicht für Fork-PRs: Wer PR-Code auf `docker-host` ausführt, hat Zugriff auf den dind-Daemon des Runners.
|
||||||
|
|
||||||
|
**Achtung Artefakte:** Playwright-Reports, Traces und Screenshots (CI-Artefakte, `test-results/`, `playwright-report/`) enthalten Tokens, Passwörter und Mailinhalte der angelegten Testkonten. Die Konten existieren nur in der Wegwerf-Instanz (lokale CLI/CI), dennoch Artefakte nicht in ein öffentliches Repo oder eine öffentliche Release-Seite legen und nur kurz aufbewahren. Die Tests lehnen nicht-lokale Hosts ab (`SUPABASE_URL`, `MAILPIT_URL`, `E2E_DB_URL`; CI-Hosts nur explizit über `E2E_ALLOWED_HOSTS`).
|
||||||
|
|
||||||
|
### SMTP-Ausfall (Spike #37, Stand 2026-11-03, GoTrue `v2.197.0`)
|
||||||
|
|
||||||
|
Messung gegen einen zusätzlichen GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt (`e2e/support/start-throttle-gotrue.sh start smtp-down`, Test `e2e/tests/spike/smtp-down.spec.ts`). Mailpit bleibt für die anderen Tests unberührt. Die Tests prüfen nichts, sie protokollieren.
|
||||||
|
|
||||||
|
| Aufruf | bekannte Adresse | unbekannte Adresse |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /recover` | **500** `unexpected_failure` „Error sending recovery email“ | **200** |
|
||||||
|
| `POST /resend` (signup) | unbestätigt: **500** „Error sending confirmation email“ | **200** |
|
||||||
|
| `POST /signup` | unbestätigt: **500**; bestätigt: **200** (erfundener Nutzer, kein Versand) | neu: **500** „Error sending confirmation email“, Nutzer wird **nicht** angelegt (Rollback) |
|
||||||
|
|
||||||
|
Antwortzeiten liegen alle im einstelligen bis niedrigen zweistelligen Millisekundenbereich (Verbindung wird sofort abgelehnt), also kein Zeit-Seitenkanal. **Befund:** Bei nicht erreichbarem SMTP unterscheiden sich die Antworten von `/recover`, `/resend` und `/signup` je nach Existenz des Kontos (500 vs. 200), und zwar nicht nur bei erschöpftem Mail-Limit (R3, F12). Die rohe API verrät dann die Existenz; laut Mapping 4.4 (nicht getestet) würde die App bei Reset 500 als `unexpected`, bei Registrierung als `registrationRejected` anzeigen, also einen Fehler statt des neutralen Bildschirms. Ob die App bei HTTP 500 aus Sendefehlern neutral bleiben soll (wie bei `over_email_send_rate_limit`), ist offen (Issue #37). Nicht untersucht: ein SMTP-Server, der die Verbindung annimmt, aber nicht antwortet (Timeout), und GoTrue hinter dem Produktions-Gateway. Dafür den Container mit einer nicht routbaren SMTP-Adresse (z. B. `GOTRUE_SMTP_HOST=10.255.255.1`) starten und `GOTRUE_SMTP_PORT` bis zum Timeout beobachten.
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,7 @@ Testnamen und Beschreibungen enthalten die Referenz („KTO-01 Kriterium n“).
|
||||||
| Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 |
|
| Krit. 6 | Mapping `email_not_confirmed`; `ResendController`: `emailSendLimited` mit `ResendOrigin.checkEmail` → Erfolg, mit `ResendOrigin.login` → Drosselungshinweis (F12) | Hinweis + Resend-Aktion; Resend-Erfolg/429; **F12:** CheckEmailScreen mit gemocktem `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (exakter Textvergleich), kein Drosselungshinweis; LoginScreen-Resend mit `emailSendLimited` → Drosselungshinweis | api: unbestätigtes Konto + richtiges Passwort → `email_not_confirmed`; falsches Passwort → `invalid_credentials`; Resend → zweite Mail | – | A3, T3 |
|
||||||
| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 |
|
| Krit. 7 | Mapping `invalid_credentials`/429 → genau ein Fehlertyp | falsche E-Mail und falsches Passwort zeigen **denselben** Text (Golden-String-Vergleich) | throttle (Extra-Container mit `GOTRUE_RATE_LIMIT_HEADER`): 30 Fehllogins mit gleichem `X-Forwarded-For` → jeweils `invalid_credentials`, Login #31 → 429 `over_request_rate_limit` (Burst aus AUTH-CFG-10); gleichzeitig eine zweite IP nicht betroffen; Antwort ohne `Retry-After` (App zeigt keine Dauer). api (CLI): falsche E-Mail und falsches Passwort → identische Meldung. web: Drosselungshinweis sichtbar, 429 per Playwright `page.route` simuliert (die CLI drosselt nie, ein echter Test wäre nie rot) | – | B2, A3, T3, D2 |
|
||||||
| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 |
|
| Krit. 8 | `EmailLinkController`: Parameter-Parsing, Mapping `otp_expired` | Link-Screen: Knopf nötig; Fehlerzustand bei ungültigem Link | api: Reset anfordern → Mail → `verifyOTP` ok → zweites Einlösen 403; zweites Einlösen → 403 `otp_expired`; abgelaufener Link: Helfer `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Port 54322, Rolle `postgres`) und setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)` (B0 (i); `auth.one_time_tokens.created_at` ist wirkungslos). Wert aus AUTH-CFG-07; Reset für unbekannte Adresse → 200, keine Mail | – | B2, A4, T3 |
|
||||||
| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 |
|
| Krit. 9 | `onSignedOut`-Hook invalidiert private Provider | fehlgeschlagener Refresh → Navigation zu `/login` | api: Sitzungen A und B; Reset über A → Refresh-Token von B ungültig (`refresh_token_not_found`/`session_not_found`); web: zweiter Browser-Kontext ist nach dem nächsten Refresh abgemeldet (Test erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt; ein geändertes `expires_at` im `localStorage` wirkt nicht, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird; die Wartezeit bis 60 min wird bewusst nicht getestet, F1) | – | A4, T3 |
|
||||||
| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 |
|
| Krit. 10 | `DisplayNameRule`: leer, nur Leerzeichen, 30/31 Codepoints, Emoji | Fehler am Feld, Zähler | api: Signup mit `display_name` = 31 Zeichen → HTTP 500, Body `{"code":"P0001","message":"display_name_invalid"}` (sichert das Format aus B0 (f) ab), kein Nutzer | `05_auth_registration`: fehlend/leer/Leerzeichen/31 → `display_name_invalid`; Trim; neuer Check bei Update `' '` → 23514 | B1, A1, A2 |
|
||||||
| Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 |
|
| Krit. 11 | – | – | api: Signup mit `locale=en` → englischer Betreff und Text; `de` → deutsch; Profilsprache per Update auf `en` ändern → Reset-Mail englisch; nach Reset und Login **keine** weitere Mail (Benachrichtigungen aus) | `05_auth_registration`: Update `profiles.locale` → `auth.users.raw_user_meta_data.locale` folgt; fremdes Profil nicht betroffen; Signup ohne `locale` → Profil `'en'` (F6); Signup mit ungültigem `locale` → `'en'` | B1, B2, T3 |
|
||||||
| QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 |
|
| QUE-04 Krit. 1 (+ Nachweis F5) | Validierung Zustimmung; `RegisterController` sendet `terms_version` aus `AppConfig` | Links zu Datenschutzerklärung/Nutzungsbedingungen vorhanden und bedienbar; ohne Haken kein Absenden | – | bestehend (terms_acceptance_required); neu `05_auth_registration`: fehlende/leere/zu lange `terms_version` → `terms_version_required`; `authenticated` kann `terms_version` nicht ändern (42501) | B1, A2 |
|
||||||
|
|
@ -509,6 +509,11 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
||||||
8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).
|
8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).
|
||||||
9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.
|
9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.
|
||||||
|
|
||||||
|
### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`)
|
||||||
|
|
||||||
|
1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit.
|
||||||
|
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`).
|
||||||
|
|
||||||
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
|
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
|
||||||
|
|
||||||
1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst.
|
1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst.
|
||||||
|
|
|
||||||
55
e2e/README.md
Normal file
55
e2e/README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# E2E-Tests (Playwright)
|
||||||
|
|
||||||
|
Auth-Tests für KTO-01 (Spec `docs/specs/1-auth-registrierung-anmeldung.md` § 7, Issue #38). Jeder Test
|
||||||
|
nutzt zufällige E-Mail-Adressen und filtert Mails je Adresse (Mailpit wird nie global geleert),
|
||||||
|
die Tests laufen deshalb parallel.
|
||||||
|
|
||||||
|
## Projekte
|
||||||
|
|
||||||
|
| Projekt | Ziel | Voraussetzung | Tests |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `api` | GoTrue über Kong (`:54321`) + Mailpit (`:54324`) + Postgres (`:54322`) | `supabase start` | 52 |
|
||||||
|
| `web` | gebaute Flutter-Web-App auf `http://localhost:3000` | zusätzlich `flutter build web` | 21 |
|
||||||
|
| `throttle` | Extra-GoTrue-Container mit `GOTRUE_RATE_LIMIT_HEADER` (AUTH-CFG-13) | `THROTTLE_GOTRUE_URL` | 4 |
|
||||||
|
| `spike` | GoTrue mit unerreichbarem SMTP (Issue #37), nicht blockierend | `SPIKE_GOTRUE_URL` | 3 |
|
||||||
|
|
||||||
|
`throttle` und `spike` werden nur registriert, wenn die jeweilige Variable gesetzt ist.
|
||||||
|
|
||||||
|
## Lokal ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
supabase start -x studio,imgproxy,vector,logflare,edge-runtime,postgres-meta,realtime,storage-api
|
||||||
|
supabase db reset
|
||||||
|
|
||||||
|
# Web-Build gegen die lokale CLI (echten Anon-Key aus `supabase status -o env` eintragen)
|
||||||
|
cd app && flutter build web --dart-define-from-file=env/ci.example.json # SUPABASE_URL/ANON_KEY anpassen
|
||||||
|
|
||||||
|
cd ../e2e && npm ci && npx playwright install chromium
|
||||||
|
npx playwright test --project=api
|
||||||
|
npx playwright test --project=web # startet `serve -s ../app/build/web -l 3000` selbst
|
||||||
|
|
||||||
|
# Drosselung (Krit. 7) und SMTP-Spike
|
||||||
|
./support/start-throttle-gotrue.sh start throttle # :9998
|
||||||
|
./support/start-throttle-gotrue.sh start smtp-down # :9997
|
||||||
|
THROTTLE_GOTRUE_URL=http://127.0.0.1:9998 SPIKE_GOTRUE_URL=http://127.0.0.1:9997 npx playwright test
|
||||||
|
./support/start-throttle-gotrue.sh stop throttle; ./support/start-throttle-gotrue.sh stop smtp-down
|
||||||
|
```
|
||||||
|
|
||||||
|
Umgebungsvariablen (Standard = lokale CLI-Demo-Werte, keine Secrets): `SUPABASE_URL`,
|
||||||
|
`SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `MAILPIT_URL`, `E2E_DB_URL`, `WEB_URL`,
|
||||||
|
`TERMS_VERSION`, `E2E_ALLOWED_HOSTS` (zusätzlich erlaubte Hosts für SUPABASE_URL/MAILPIT_URL/E2E_DB_URL, kommagetrennt; Standard nur 127.0.0.1, localhost, [::1]).
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
- `support/auth-config.ts` – Konstanten mit `AUTH-CFG-nn`-Bezug; `tests/api/config.spec.ts` prüft sie gegen `supabase/config.toml` und `docs/auth-konfiguration.md`.
|
||||||
|
- `support/gotrue.ts`, `support/mailpit.ts` – REST-Client und Mailfänger-Helfer.
|
||||||
|
- `support/db.ts` – direkter Postgres-Zugriff, **nur lokal/CI** (bricht bei anderem Host ab); setzt `recovery_sent_at`/`confirmation_sent_at` für Ablauftests.
|
||||||
|
- `support/web.ts` – Flutter-Web-Helfer (Semantics einschalten, Felder füllen, Uhr verschieben).
|
||||||
|
- `support/start-throttle-gotrue.sh` – zweiter GoTrue-Container (Profile `throttle`, `smtp-down`).
|
||||||
|
|
||||||
|
## Hinweise zum Web-Projekt
|
||||||
|
|
||||||
|
- Flutter zeichnet auf Canvas. Der Semantics-Baum wird über `flt-semantics-placeholder` eingeschaltet (`enableSemantics`).
|
||||||
|
- Eingabefelder werden mit `click` + `pressSequentially` gefüllt; `fill` allein geht gelegentlich verloren.
|
||||||
|
- Immer `localhost` (nicht `127.0.0.1`): Die Mail-Links nutzen die Site-URL (AUTH-CFG-01), sonst liegt die Sitzung in einem anderen `localStorage`.
|
||||||
|
- Gegen die gemeinsame CLI-Instanz werden nie `X-Forwarded-For`-Header gesendet (R13).
|
||||||
1311
e2e/package-lock.json
generated
Normal file
1311
e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
e2e/package.json
Normal file
22
e2e/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "shuffle-e2e",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Playwright E2E tests for Shuffle (auth: KTO-01, Issue #38)",
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:api": "playwright test --project=api",
|
||||||
|
"test:web": "playwright test --project=web",
|
||||||
|
"test:throttle": "playwright test --project=throttle",
|
||||||
|
"test:spike": "playwright test --project=spike"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"pg-connection-string": "^2.7.0",
|
||||||
|
"serve": "^14.2.4",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
e2e/playwright.config.ts
Normal file
54
e2e/playwright.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { defineConfig, devices, type Project } from '@playwright/test';
|
||||||
|
|
||||||
|
import { env } from './support/auth-config';
|
||||||
|
|
||||||
|
const webUrl = env.webUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projekte (Spec #1 § 7):
|
||||||
|
* - api GoTrue ueber Kong + Mailpit-Helfer
|
||||||
|
* - web gebaute Flutter-Web-App (`app/build/web`, statisch auf Port 3000)
|
||||||
|
* - throttle Drosselung (Krit. 7) gegen den Extra-Container, nur mit THROTTLE_GOTRUE_URL
|
||||||
|
* - spike SMTP-Spike (Issue #37), nur mit SPIKE_GOTRUE_URL, nicht blockierend
|
||||||
|
*/
|
||||||
|
const projects: Project[] = [
|
||||||
|
{ name: 'api', testDir: './tests/api', use: {} },
|
||||||
|
{
|
||||||
|
name: 'web',
|
||||||
|
testDir: './tests/web',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
baseURL: webUrl,
|
||||||
|
// Englische Texte (app_en.arb); die App waehlt die Sprache nach dem Browser.
|
||||||
|
locale: 'en-US',
|
||||||
|
// Der Flutter-Service-Worker wuerde alte Builds cachen.
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (process.env.THROTTLE_GOTRUE_URL) {
|
||||||
|
projects.push({ name: 'throttle', testDir: './tests/throttle', use: {} });
|
||||||
|
}
|
||||||
|
if (process.env.SPIKE_GOTRUE_URL) {
|
||||||
|
projects.push({ name: 'spike', testDir: './tests/spike', use: {}, retries: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: process.env.CI ? 2 : 4,
|
||||||
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
|
||||||
|
use: { trace: 'retain-on-failure' },
|
||||||
|
projects,
|
||||||
|
// SPA-Fallback (`-s`), sonst liefert /auth/confirm?... 404.
|
||||||
|
webServer: {
|
||||||
|
command: 'npx serve -s ../app/build/web -l 3000 --no-clipboard',
|
||||||
|
url: webUrl,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
119
e2e/support/auth-config.ts
Normal file
119
e2e/support/auth-config.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* Konstanten der Auth-Konfiguration (Spec #1 § 4.1, `docs/auth-konfiguration.md`).
|
||||||
|
*
|
||||||
|
* Diese Datei muss mit `supabase/config.toml` und `docs/auth-konfiguration.md` uebereinstimmen;
|
||||||
|
* `tests/api/config.spec.ts` prueft das. Tests lesen Zahlen nur von hier und verweisen im Namen
|
||||||
|
* auf die Zeilen-ID (z. B. `AUTH-CFG-07`).
|
||||||
|
*
|
||||||
|
* Werte, die lokal (Supabase CLI) und in Staging/Prod unterschiedlich sind, tragen hier den
|
||||||
|
* LOKALEN Wert (die E2E-Tests laufen nur lokal bzw. in der CI gegen die CLI).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Hosts, die als „lokal“ gelten (URL-Hostnamen; IPv6 steht in URL.hostname mit Klammern). */
|
||||||
|
const LOCAL_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusaetzlich erlaubte Hosts fuer die CI (z. B. der Docker-Host `docker`), kommagetrennt in
|
||||||
|
* E2E_ALLOWED_HOSTS. Nur setzen, wenn der Dienst ein Wegwerf-Stack ist.
|
||||||
|
*/
|
||||||
|
function allowedHosts(): string[] {
|
||||||
|
const extra = (process.env.E2E_ALLOWED_HOSTS ?? '').split(',').map((h) => h.trim()).filter(Boolean);
|
||||||
|
return [...LOCAL_HOSTS, ...extra];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalHost(hostname: string): boolean {
|
||||||
|
return allowedHosts().includes(hostname.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bricht ab, wenn die URL nicht auf einen lokalen (oder explizit erlaubten) Host zeigt. */
|
||||||
|
export function assertLocalUrl(name: string, value: string): string {
|
||||||
|
const host = new URL(value).hostname;
|
||||||
|
if (!isLocalHost(host)) {
|
||||||
|
throw new Error(`${name}: Host "${host}" ist nicht lokal. Die E2E-Tests legen Testkonten an; ggf. E2E_ALLOWED_HOSTS setzen.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_ANON_KEY =
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0';
|
||||||
|
const DEMO_SERVICE_KEY =
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU';
|
||||||
|
|
||||||
|
const supabaseUrl = assertLocalUrl('SUPABASE_URL', process.env.SUPABASE_URL ?? 'http://127.0.0.1:54321');
|
||||||
|
const isDefaultLocal = ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(supabaseUrl).hostname);
|
||||||
|
|
||||||
|
function key(envName: string, demo: string): string {
|
||||||
|
const v = process.env[envName];
|
||||||
|
if (v) return v;
|
||||||
|
// Demo-Schluessel nur bei lokaler URL; sonst muss der Aufrufer den Schluessel setzen.
|
||||||
|
if (!isDefaultLocal) throw new Error(`${envName} muss gesetzt sein, wenn SUPABASE_URL nicht lokal ist.`);
|
||||||
|
return demo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Umgebung (Standardwerte = lokale Demo-Werte der Supabase CLI, keine Secrets). */
|
||||||
|
export const env = {
|
||||||
|
/** Kong, wie die App. */
|
||||||
|
supabaseUrl,
|
||||||
|
/** Legacy-JWT-Anon-Key der CLI (oeffentlicher Demo-Wert, nur bei lokaler URL als Fallback). */
|
||||||
|
anonKey: key('SUPABASE_ANON_KEY', DEMO_ANON_KEY),
|
||||||
|
/** Legacy-Service-Role-Key der CLI (oeffentlicher Demo-Wert, nur lokal). */
|
||||||
|
serviceRoleKey: key('SUPABASE_SERVICE_ROLE_KEY', DEMO_SERVICE_KEY),
|
||||||
|
mailpitUrl: assertLocalUrl('MAILPIT_URL', process.env.MAILPIT_URL ?? 'http://127.0.0.1:54324'),
|
||||||
|
dbUrl: process.env.E2E_DB_URL ?? 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
|
||||||
|
/** Web-Build. Immer `localhost` (nicht 127.0.0.1): Die Mail-Links nutzen AUTH-CFG-01. */
|
||||||
|
webUrl: process.env.WEB_URL ?? 'http://localhost:3000',
|
||||||
|
/** Zusaetzlicher GoTrue-Container mit AUTH-CFG-13 (Projekt `throttle`, Skript start-throttle-gotrue.sh). */
|
||||||
|
throttleGotrueUrl: process.env.THROTTLE_GOTRUE_URL,
|
||||||
|
/** GoTrue-Container mit unerreichbarem SMTP (Projekt `spike`, Issue #37). */
|
||||||
|
spikeGotrueUrl: process.env.SPIKE_GOTRUE_URL,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
/** AUTH-CFG-01: Site-URL lokal. */
|
||||||
|
siteUrl: 'http://localhost:3000',
|
||||||
|
/** AUTH-CFG-05: Passwort-Mindestlaenge. */
|
||||||
|
passwordMinLength: 10,
|
||||||
|
/** AUTH-CFG-07: Gueltigkeit Bestaetigungs- und Reset-Link in Sekunden. */
|
||||||
|
otpExpirySeconds: 3600,
|
||||||
|
/** AUTH-CFG-08: Mindestabstand zwischen Mails je Nutzer, lokal 1 s. */
|
||||||
|
maxFrequencySeconds: 1,
|
||||||
|
/** AUTH-CFG-10: Burst des Login-Limiters je IP (Login #31 -> 429). */
|
||||||
|
loginBurst: 30,
|
||||||
|
/** AUTH-CFG-11: Burst des OTP-Limiters (Signup, Recover, Resend) je IP. */
|
||||||
|
otpBurst: 30,
|
||||||
|
/** AUTH-CFG-14: JWT-Laufzeit in Sekunden. */
|
||||||
|
jwtExpirySeconds: 3600,
|
||||||
|
/** AUTH-CFG-15: Wiederverwendungsfenster der Refresh-Token-Rotation in Sekunden. */
|
||||||
|
refreshTokenReuseIntervalSeconds: 10,
|
||||||
|
/** Anzeigename: Grenzen in Codepoints (Spec 3.2). */
|
||||||
|
displayNameMaxLength: 30,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Mailbetreffe (AUTH-CFG-17/-18, `supabase/config.toml`). */
|
||||||
|
export const subjects = {
|
||||||
|
confirmation: { de: 'Bestätige deine E-Mail-Adresse für Shuffle', en: 'Confirm your email address for Shuffle' },
|
||||||
|
recovery: { de: 'Passwort für Shuffle zurücksetzen', en: 'Reset your Shuffle password' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Fehlercodes von GoTrue, auf die sich die Spec stuetzt (§ 4.3/4.4). */
|
||||||
|
export const errorCodes = {
|
||||||
|
invalidCredentials: 'invalid_credentials',
|
||||||
|
emailNotConfirmed: 'email_not_confirmed',
|
||||||
|
weakPassword: 'weak_password',
|
||||||
|
samePassword: 'same_password',
|
||||||
|
otpExpired: 'otp_expired',
|
||||||
|
overRequestRateLimit: 'over_request_rate_limit',
|
||||||
|
overEmailSendRateLimit: 'over_email_send_rate_limit',
|
||||||
|
userAlreadyExists: 'user_already_exists',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Trigger-Fehlertexte bei signUp (HTTP 500, Body `{"code":"P0001","message":...}`, B0 (f)). */
|
||||||
|
export const triggerErrors = {
|
||||||
|
displayNameInvalid: 'display_name_invalid',
|
||||||
|
ageConfirmationRequired: 'age_confirmation_required',
|
||||||
|
termsAcceptanceRequired: 'terms_acceptance_required',
|
||||||
|
termsVersionRequired: 'terms_version_required',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Version der Rechtstexte, die der Web-Build sendet (`app/env/ci.example.json`). */
|
||||||
|
export const TERMS_VERSION = process.env.TERMS_VERSION ?? '2026-11-draft';
|
||||||
103
e2e/support/db.ts
Normal file
103
e2e/support/db.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* Direkter Datenbankzugriff fuer Tests. NUR lokal/CI: der Helfer bricht ab, wenn der Host nicht
|
||||||
|
* lokal ist (er veraendert `auth.users`). Spec 7.1 Krit. 8 (B0 (i)): Maßgeblich fuer den
|
||||||
|
* Linkablauf sind `auth.users.recovery_sent_at` und `confirmation_sent_at`, nicht
|
||||||
|
* `auth.one_time_tokens.created_at`.
|
||||||
|
*/
|
||||||
|
import { Client } from 'pg';
|
||||||
|
import { parse } from 'pg-connection-string';
|
||||||
|
import { authConfig, env, isLocalHost } from './auth-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die Verbindungszeichenfolge mit demselben Parser wie `pg` und prueft den Host des
|
||||||
|
* GEPARSTEN Ergebnisses (auch `?host=...`, Unix-Sockets und mehrere Hosts werden so erfasst).
|
||||||
|
* Der Client wird aus genau diesem Ergebnis gebaut, nicht erneut aus dem String.
|
||||||
|
*/
|
||||||
|
export function localDbConfig(connectionString: string) {
|
||||||
|
const cfg = parse(connectionString);
|
||||||
|
const host = (cfg.host ?? '').replace(/^\[|\]$/g, '');
|
||||||
|
// `parse` liefert IPv6 ohne Klammern; unsere Liste fuehrt sie mit Klammern.
|
||||||
|
const candidate = host.includes(':') ? `[${host}]` : host;
|
||||||
|
if (!candidate || !isLocalHost(candidate)) {
|
||||||
|
throw new Error(`e2e/support/db.ts nur lokal/CI: Host "${cfg.host}" ist nicht erlaubt`);
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withDb<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
||||||
|
const cfg = localDbConfig(env.dbUrl);
|
||||||
|
const client = new Client({
|
||||||
|
host: cfg.host ?? undefined,
|
||||||
|
port: cfg.port ? Number(cfg.port) : undefined,
|
||||||
|
user: cfg.user,
|
||||||
|
password: cfg.password,
|
||||||
|
database: cfg.database ?? undefined,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
return await fn(client);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilen aus `auth.users` zu E-Mail oder ID (nie global zaehlen: Tests laufen parallel). */
|
||||||
|
export async function usersWhere(by: { email?: string; id?: string }) {
|
||||||
|
return withDb(async (c) =>
|
||||||
|
by.email !== undefined
|
||||||
|
? (await c.query('select id from auth.users where email = $1', [by.email.toLowerCase()])).rows
|
||||||
|
: (await c.query('select id from auth.users where id = $1', [by.id])).rows,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countUsers(): Promise<number> {
|
||||||
|
return withDb(async (c) => Number((await c.query('select count(*) from auth.users')).rows[0].count));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userByEmail(email: string) {
|
||||||
|
return withDb(async (c) => (await c.query('select * from auth.users where email = $1', [email.toLowerCase()])).rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function profileByEmail(email: string) {
|
||||||
|
return withDb(
|
||||||
|
async (c) =>
|
||||||
|
(
|
||||||
|
await c.query(
|
||||||
|
`select p.* from public.profiles p join auth.users u on u.id = p.id where u.email = $1`,
|
||||||
|
[email.toLowerCase()],
|
||||||
|
)
|
||||||
|
).rows[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt den Link als abgelaufen: `recovery_sent_at = now() - (otp_expiry + 1 s)` (AUTH-CFG-07). */
|
||||||
|
export async function expireRecoveryLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
|
||||||
|
await withDb((c) =>
|
||||||
|
c.query(`update auth.users set recovery_sent_at = now() - make_interval(secs => $2) where email = $1`, [
|
||||||
|
email.toLowerCase(),
|
||||||
|
secondsAgo,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie oben fuer den Bestaetigungslink (`confirmation_sent_at`). */
|
||||||
|
export async function expireConfirmationLink(email: string, secondsAgo = authConfig.otpExpirySeconds + 1) {
|
||||||
|
await withDb((c) =>
|
||||||
|
c.query(`update auth.users set confirmation_sent_at = now() - make_interval(secs => $2) where email = $1`, [
|
||||||
|
email.toLowerCase(),
|
||||||
|
secondsAgo,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `not_after` aller Sitzungen des Nutzers (AUTH-CFG-16: nicht gesetzt). */
|
||||||
|
export async function sessionNotAfter(email: string): Promise<(string | null)[]> {
|
||||||
|
return withDb(async (c) =>
|
||||||
|
(
|
||||||
|
await c.query(
|
||||||
|
`select s.not_after from auth.sessions s join auth.users u on u.id = s.user_id where u.email = $1`,
|
||||||
|
[email.toLowerCase()],
|
||||||
|
)
|
||||||
|
).rows.map((r) => r.not_after),
|
||||||
|
);
|
||||||
|
}
|
||||||
134
e2e/support/gotrue.ts
Normal file
134
e2e/support/gotrue.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import { authConfig, env, TERMS_VERSION } from './auth-config';
|
||||||
|
|
||||||
|
/** Zufaellige E-Mail-Adresse je Test (Spec 7: jeder Test nutzt eigene Adressen). */
|
||||||
|
export function randomEmail(prefix = 'e2e'): string {
|
||||||
|
return `${prefix}-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}@example.test`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zufaelliges, ausreichend langes Passwort (AUTH-CFG-05: mind. 10 Zeichen). */
|
||||||
|
export function randomPassword(): string {
|
||||||
|
return `Pw-${randomBytes(8).toString('hex')}-x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zufaellige IPv4 fuer `X-Forwarded-For` (nur Projekt `throttle`, nie gegen die CLI, R13). */
|
||||||
|
export function randomIp(): string {
|
||||||
|
const octet = () => 1 + Math.floor(Math.random() * 253);
|
||||||
|
return `${octet()}.${octet()}.${octet()}.${octet()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Wartet etwas mehr als AUTH-CFG-08 (1 s), damit Folgemails nicht an 429 scheitern. */
|
||||||
|
export const waitMailInterval = () => sleep(authConfig.maxFrequencySeconds * 1000 + 300);
|
||||||
|
|
||||||
|
export interface GoTrueResponse<T = any> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
headers: Headers;
|
||||||
|
/** Dauer in Millisekunden. */
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
/** Basis-URL inkl. `/auth/v1` (Standard: Kong). */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** Beim Zugriff ueber Kong noetig, direkt auf GoTrue nicht. */
|
||||||
|
apikey?: string | null;
|
||||||
|
/** Zusatz-Header, z. B. `X-Forwarded-For` (nur `throttle`). */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimaler GoTrue-Client (REST, wie `supabase_flutter`). */
|
||||||
|
export class GoTrueClient {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
private readonly apikey: string | null;
|
||||||
|
private readonly extraHeaders: Record<string, string>;
|
||||||
|
|
||||||
|
constructor(opts: ClientOptions = {}) {
|
||||||
|
this.baseUrl = opts.baseUrl ?? `${env.supabaseUrl}/auth/v1`;
|
||||||
|
this.apikey = opts.apikey === undefined ? env.anonKey : opts.apikey;
|
||||||
|
this.extraHeaders = opts.headers ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async request<T = any>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
token?: string,
|
||||||
|
extra: Record<string, string> = {},
|
||||||
|
): Promise<GoTrueResponse<T>> {
|
||||||
|
const headers: Record<string, string> = { 'content-type': 'application/json', ...this.extraHeaders, ...extra };
|
||||||
|
if (this.apikey) headers.apikey = this.apikey;
|
||||||
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let parsed: any = text;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
/* Body ist kein JSON */
|
||||||
|
}
|
||||||
|
return { status: res.status, body: parsed, headers: res.headers, ms: Date.now() - started };
|
||||||
|
}
|
||||||
|
|
||||||
|
signUp(email: string, password: string, data: Record<string, unknown> | null = validMetadata()) {
|
||||||
|
return this.request('POST', '/signup', { email, password, ...(data ? { data } : {}) });
|
||||||
|
}
|
||||||
|
signIn(email: string, password: string) {
|
||||||
|
return this.request('POST', '/token?grant_type=password', { email, password });
|
||||||
|
}
|
||||||
|
refresh(refreshToken: string) {
|
||||||
|
return this.request('POST', '/token?grant_type=refresh_token', { refresh_token: refreshToken });
|
||||||
|
}
|
||||||
|
recover(email: string) {
|
||||||
|
return this.request('POST', '/recover', { email });
|
||||||
|
}
|
||||||
|
resend(email: string, type: 'signup' = 'signup') {
|
||||||
|
return this.request('POST', '/resend', { email, type });
|
||||||
|
}
|
||||||
|
/** `verifyOTP(tokenHash, type)`. */
|
||||||
|
verify(tokenHash: string, type: 'email' | 'signup' | 'recovery') {
|
||||||
|
return this.request('POST', '/verify', { token_hash: tokenHash, type });
|
||||||
|
}
|
||||||
|
getUser(accessToken: string) {
|
||||||
|
return this.request('GET', '/user', undefined, accessToken);
|
||||||
|
}
|
||||||
|
updateUser(accessToken: string, attributes: Record<string, unknown>) {
|
||||||
|
return this.request('PUT', '/user', attributes, accessToken);
|
||||||
|
}
|
||||||
|
/** Legt ein bestaetigtes Konto direkt an (Service-Role, nur lokal), fuer Tests, die die Anmeldung pruefen. */
|
||||||
|
async createConfirmedUser(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
data: Record<string, unknown> = validMetadata(),
|
||||||
|
) {
|
||||||
|
return this.request(
|
||||||
|
'POST',
|
||||||
|
'/admin/users',
|
||||||
|
{ email, password, email_confirm: true, user_metadata: data },
|
||||||
|
env.serviceRoleKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gueltige Registrierungs-Metadaten (Spec 4.2). */
|
||||||
|
export function validMetadata(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
display_name: 'E2E Tester',
|
||||||
|
age_confirmed: true,
|
||||||
|
terms_accepted: true,
|
||||||
|
terms_version: TERMS_VERSION,
|
||||||
|
locale: 'en',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
for (const key of Object.keys(data)) if (data[key] === undefined) delete data[key];
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uniqueId = () => randomUUID();
|
||||||
90
e2e/support/mailpit.ts
Normal file
90
e2e/support/mailpit.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { env } from './auth-config';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, sleep } from './gotrue';
|
||||||
|
|
||||||
|
export interface MailSummary {
|
||||||
|
ID: string;
|
||||||
|
Subject: string;
|
||||||
|
To: { Address: string }[];
|
||||||
|
Created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mail extends MailSummary {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${env.mailpitUrl}/api/v1${path}`);
|
||||||
|
if (!res.ok) throw new Error(`Mailpit ${path}: HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Mails an eine Adresse (Suche per `to:`; nie global loeschen, Tests laufen parallel). */
|
||||||
|
export async function mailsTo(address: string): Promise<MailSummary[]> {
|
||||||
|
const result = await getJson(`/search?query=${encodeURIComponent(`to:"${address}"`)}&limit=50`);
|
||||||
|
return (result.messages ?? []) as MailSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countMails(address: string): Promise<number> {
|
||||||
|
return (await mailsTo(address)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readMail(id: string): Promise<Mail> {
|
||||||
|
const m = await getJson(`/message/${id}`);
|
||||||
|
return { ID: m.ID, Subject: m.Subject, To: m.To, Created: m.Date ?? '', html: m.HTML ?? '', text: m.Text ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartet, bis mindestens `count` Mails an die Adresse vorliegen, und liefert die neueste.
|
||||||
|
* Faellt nach `timeoutMs` mit einer aussagekraeftigen Meldung durch.
|
||||||
|
*/
|
||||||
|
export async function waitForMail(address: string, count = 1, timeoutMs = 10_000): Promise<Mail> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let found: MailSummary[] = [];
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
found = await mailsTo(address);
|
||||||
|
if (found.length >= count) {
|
||||||
|
// Mailpit liefert die neueste zuerst.
|
||||||
|
return readMail(found[0].ID);
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
throw new Error(`Keine ${count}. Mail an ${address} nach ${timeoutMs} ms (vorhanden: ${found.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belegt „es kommt keine (weitere) Mail“ ohne festes Zeitfenster: GoTrue versendet synchron im
|
||||||
|
* Request, Mailpit nimmt die Mails der Reihe nach an. Der Helfer loest deshalb eine bekannte
|
||||||
|
* Folgemail (Passwort-Reset fuer ein frisches Sentinel-Konto) aus, wartet auf deren Eintreffen
|
||||||
|
* und zaehlt danach die Mails an `address`. Ist die Sentinel-Mail da, waeren zuvor gesendete
|
||||||
|
* Mails es auch.
|
||||||
|
* Nur dort einsetzen, wo die Abwesenheit selbst das Kriterium ist (Krit. 4, 8, 11).
|
||||||
|
*/
|
||||||
|
export async function expectMailCount(address: string, expected: number): Promise<number> {
|
||||||
|
const client = new GoTrueClient();
|
||||||
|
const sentinel = randomEmail('sentinel');
|
||||||
|
const created = await client.createConfirmedUser(sentinel, randomPassword());
|
||||||
|
if (created.status !== 200) throw new Error(`Sentinel-Konto nicht anlegbar: HTTP ${created.status}`);
|
||||||
|
const res = await client.recover(sentinel);
|
||||||
|
if (res.status !== 200) throw new Error(`Sentinel-Reset fehlgeschlagen: HTTP ${res.status}`);
|
||||||
|
await waitForMail(sentinel);
|
||||||
|
const actual = await countMails(address);
|
||||||
|
if (actual !== expected) throw new Error(`Mails an ${address}: erwartet ${expected}, vorhanden ${actual}`);
|
||||||
|
return actual;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link aus der Mail: `.../auth/confirm?token_hash=...&type=email|recovery` (HTML-Entities decodiert). */
|
||||||
|
export function extractConfirmLink(mail: Mail): URL {
|
||||||
|
const decoded = mail.html.replace(/&/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 };
|
||||||
|
}
|
||||||
85
e2e/support/start-throttle-gotrue.sh
Executable file
85
e2e/support/start-throttle-gotrue.sh
Executable file
|
|
@ -0,0 +1,85 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Startet einen ZWEITEN GoTrue-Container neben der Supabase CLI (Spec #1 D2/T3, F11).
|
||||||
|
#
|
||||||
|
# Profile
|
||||||
|
# throttle (Standard) AUTH-CFG-13: GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For, Port 9998.
|
||||||
|
# Fuer das Playwright-Projekt `throttle` (THROTTLE_GOTRUE_URL=http://127.0.0.1:9998).
|
||||||
|
# smtp-down SMTP-Spike (Issue #37): wie die CLI, aber SMTP-Port 1 (Verbindung abgelehnt), Port 9997.
|
||||||
|
# Fuer das Playwright-Projekt `spike` (SPIKE_GOTRUE_URL=http://127.0.0.1:9997).
|
||||||
|
#
|
||||||
|
# Aufruf
|
||||||
|
# e2e/support/start-throttle-gotrue.sh start [throttle|smtp-down]
|
||||||
|
# e2e/support/start-throttle-gotrue.sh stop [throttle|smtp-down]
|
||||||
|
#
|
||||||
|
# Der Container nutzt dasselbe Image, dieselbe DB und dasselbe Docker-Netz wie der Auth-Container
|
||||||
|
# der CLI; die Umgebung wird per `docker inspect` uebernommen (Version nicht hart kodiert). Die
|
||||||
|
# Rate-Limit-Buckets liegen im Speicher und sind damit von der CLI getrennt (R13).
|
||||||
|
# Docker-in-Docker (CI, DOCKER_HOST=tcp://...): Der Port wird im dind veroeffentlicht, nicht im Job-Container.
|
||||||
|
# Dann BIND_ADDR=0.0.0.0 und HEALTH_HOST=<dind-Gateway> setzen (siehe .forgejo/workflows/ci.yml, Job e2e);
|
||||||
|
# die Tests erreichen den Container ueber e2e/support/tcp-forward.mjs auf 127.0.0.1.
|
||||||
|
# Bindung: lokal nur 127.0.0.1. Unter dind muss 0.0.0.0 gelten (der Job-Container erreicht den dind nur ueber
|
||||||
|
# das Gateway; eine feinere Bindung ist dort nicht moeglich). Der Port ist dann im Netz des Runners sichtbar.
|
||||||
|
# Nur lokal/CI benutzen: Die kopierte Umgebung enthaelt die lokalen Demo-Secrets der CLI.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BIND_ADDR="${BIND_ADDR:-127.0.0.1}"
|
||||||
|
HEALTH_HOST="${HEALTH_HOST:-127.0.0.1}"
|
||||||
|
ACTION="${1:-start}"
|
||||||
|
PROFILE="${2:-throttle}"
|
||||||
|
# Auth-Container gezielt ueber die Projekt-ID (project_id in supabase/config.toml), nicht "erster Treffer"
|
||||||
|
PROJECT_ID="${SUPABASE_PROJECT_ID:-shuffle}"
|
||||||
|
SOURCE="${SOURCE_CONTAINER:-supabase_auth_${PROJECT_ID}}"
|
||||||
|
|
||||||
|
case "$PROFILE" in
|
||||||
|
throttle) NAME="${CONTAINER_NAME:-shuffle_auth_throttle}"; HOST_PORT="${HOST_PORT:-9998}" ;;
|
||||||
|
smtp-down) NAME="${CONTAINER_NAME:-shuffle_auth_smtp_down}"; HOST_PORT="${HOST_PORT:-9997}" ;;
|
||||||
|
*) echo "Unbekanntes Profil: $PROFILE (throttle|smtp-down)" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$ACTION" = "stop" ]; then
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
echo "Container $NAME entfernt."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[ "$ACTION" = "start" ] || { echo "Aktion: start|stop" >&2; exit 2; }
|
||||||
|
|
||||||
|
if ! docker inspect "$SOURCE" >/dev/null 2>&1; then
|
||||||
|
echo "Container $SOURCE nicht gefunden. Zuerst 'supabase start' (oder SUPABASE_PROJECT_ID setzen)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE="$(docker inspect "$SOURCE" --format '{{.Config.Image}}')"
|
||||||
|
NETWORK="$(docker inspect "$SOURCE" --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')"
|
||||||
|
docker rm -f "$NAME" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
ENV_ARGS=()
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
case "$entry" in
|
||||||
|
GOTRUE_RATE_LIMIT_HEADER=*) continue ;; # wird unten gezielt gesetzt
|
||||||
|
API_EXTERNAL_URL=*) continue ;; # wird unten explizit gesetzt
|
||||||
|
GOTRUE_SMTP_PORT=*) [ "$PROFILE" = "smtp-down" ] && continue ;;
|
||||||
|
esac
|
||||||
|
ENV_ARGS+=(-e "$entry")
|
||||||
|
done < <(docker inspect "$SOURCE" --format '{{json .Config.Env}}' | jq -j '.[] + "\u0000"')
|
||||||
|
|
||||||
|
case "$PROFILE" in
|
||||||
|
throttle) ENV_ARGS+=(-e "GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For") ;;
|
||||||
|
smtp-down) ENV_ARGS+=(-e "GOTRUE_SMTP_PORT=1") ;; # Host bleibt Mailpit, Port 1 lehnt die Verbindung ab
|
||||||
|
esac
|
||||||
|
|
||||||
|
# API_EXTERNAL_URL explizit (Spec D2): Kong-URL der CLI, nur Basis fuer Links in Mails/Redirects.
|
||||||
|
ENV_ARGS+=(-e "API_EXTERNAL_URL=${API_EXTERNAL_URL:-http://127.0.0.1:54321}")
|
||||||
|
|
||||||
|
docker run -d --name "$NAME" --network "$NETWORK" -p "${BIND_ADDR}:${HOST_PORT}:9999" \
|
||||||
|
"${ENV_ARGS[@]}" "$IMAGE" auth >/dev/null
|
||||||
|
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if curl -fsS "http://${HEALTH_HOST}:${HOST_PORT}/health" >/dev/null 2>&1; then
|
||||||
|
echo "Container $NAME ($IMAGE, Profil $PROFILE) laeuft auf http://${HEALTH_HOST}:${HOST_PORT}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
echo "Container $NAME wurde nicht gesund. Log:" >&2
|
||||||
|
docker logs --tail 30 "$NAME" >&2
|
||||||
|
exit 1
|
||||||
40
e2e/support/tcp-forward.mjs
Normal file
40
e2e/support/tcp-forward.mjs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Einfacher TCP-Relay fuer CI unter Docker-in-Docker (Issue #38, D2).
|
||||||
|
//
|
||||||
|
// Die Supabase-Ports (Kong 54321, Postgres 54322, Mailpit 54324, Extra-GoTrue 9998) liegen im dind,
|
||||||
|
// nicht im Job-Container. Die Tests und die Web-App erwarten 127.0.0.1. Dieses Skript leitet
|
||||||
|
// 127.0.0.1:<port> an <host>:<port> weiter (HTTP und Postgres, rein TCP).
|
||||||
|
//
|
||||||
|
// Aufruf: node tcp-forward.mjs <ziel-host> <port> [<port> ...]
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const [host, ...ports] = process.argv.slice(2);
|
||||||
|
if (!host || ports.length === 0) {
|
||||||
|
console.error('Aufruf: node tcp-forward.mjs <ziel-host> <port> [<port> ...]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of ports) {
|
||||||
|
const port = Number(p);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
console.error(`Ungueltiger Port: ${p}`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
net
|
||||||
|
.createServer((client) => {
|
||||||
|
const upstream = net.connect(port, host);
|
||||||
|
client.pipe(upstream).pipe(client);
|
||||||
|
const close = () => {
|
||||||
|
client.destroy();
|
||||||
|
upstream.destroy();
|
||||||
|
};
|
||||||
|
client.on('error', close);
|
||||||
|
upstream.on('error', close);
|
||||||
|
client.on('close', close);
|
||||||
|
upstream.on('close', close);
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
console.error(`Relay ${port}: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.listen(port, '127.0.0.1', () => console.log(`127.0.0.1:${port} -> ${host}:${port}`));
|
||||||
|
}
|
||||||
179
e2e/support/web.ts
Normal file
179
e2e/support/web.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* Helfer fuer das Projekt `web` (Flutter-Web-Build, Port 3000).
|
||||||
|
*
|
||||||
|
* Flutter zeichnet auf ein Canvas; Playwright sieht Elemente erst, wenn der Semantics-Baum
|
||||||
|
* eingeschaltet ist (Platzhalter `flt-semantics-placeholder`). Locator-Namen stammen aus
|
||||||
|
* `app/lib/l10n/app_en.arb` (Browser-Sprache en-US, siehe playwright.config.ts).
|
||||||
|
*/
|
||||||
|
import { expect, type BrowserContext, type Locator, type Page } from '@playwright/test';
|
||||||
|
import { env, TERMS_VERSION } from './auth-config';
|
||||||
|
|
||||||
|
export { TERMS_VERSION };
|
||||||
|
|
||||||
|
/** Texte (app_en.arb). */
|
||||||
|
export const t = {
|
||||||
|
invalidCredentials: 'Email address or password is incorrect.',
|
||||||
|
emailNotConfirmed: 'Please confirm your email address first. We sent you an email for this.',
|
||||||
|
resendConfirmation: 'Resend confirmation email',
|
||||||
|
rateLimited: 'Too many attempts. Please wait a few minutes and try again.',
|
||||||
|
linkInvalid: 'The link has expired or has already been used.',
|
||||||
|
checkEmailTitle: 'Check your inbox',
|
||||||
|
checkEmailResent: 'We resent the email, if there is something to send for this address.',
|
||||||
|
ageNotConfirmed: 'Shuffle can only be used from age 16.',
|
||||||
|
termsNotAccepted: 'Please agree to the privacy policy and the terms of use.',
|
||||||
|
confirmSuccess: 'Email confirmed.',
|
||||||
|
passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.',
|
||||||
|
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sichtbarer Text im Semantics-Baum. Flutter legt Texte teils als Textknoten, teils als
|
||||||
|
* `aria-label` ab (z. B. der Inhalt der CheckEmail-Seite).
|
||||||
|
*/
|
||||||
|
export function textOf(page: Page, text: string | RegExp): Locator {
|
||||||
|
return page.getByText(text).or(page.getByLabel(text)).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schaltet den Semantics-Baum ein (einmal je Seitenladen). */
|
||||||
|
export async function enableSemantics(page: Page): Promise<void> {
|
||||||
|
const placeholder = page.locator('flt-semantics-placeholder');
|
||||||
|
await placeholder.waitFor({ state: 'attached' });
|
||||||
|
await placeholder.evaluate((el: HTMLElement) => el.click());
|
||||||
|
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur.
|
||||||
|
*
|
||||||
|
* BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu
|
||||||
|
* EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf
|
||||||
|
* die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer
|
||||||
|
* auf `getByRole('button', ...)` umgestellt werden.
|
||||||
|
*/
|
||||||
|
export async function pressFirstButton(page: Page, tabs = 1): Promise<void> {
|
||||||
|
for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigiert und schaltet Semantics ein. */
|
||||||
|
export async function open(page: Page, path: string): Promise<void> {
|
||||||
|
await page.goto(path);
|
||||||
|
await enableSemantics(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuellt ein Flutter-Textfeld. Das Semantics-Textfeld legt das echte <input> erst beim Fokus an;
|
||||||
|
* `fill` allein geht dann gelegentlich verloren. Deshalb klicken, fuellen und den Wert pruefen
|
||||||
|
* (mit Wiederholung).
|
||||||
|
*/
|
||||||
|
export async function fillField(field: Locator, value: string): Promise<void> {
|
||||||
|
await expect(async () => {
|
||||||
|
await field.click();
|
||||||
|
await field.fill('');
|
||||||
|
await field.pressSequentially(value);
|
||||||
|
await expect(field).toHaveValue(value, { timeout: 1_000 });
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillRegistration(
|
||||||
|
page: Page,
|
||||||
|
v: { email: string; password: string; displayName?: string; age?: boolean; terms?: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Email address' }), v.email);
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Password' }), v.password);
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Display name' }), v.displayName ?? 'E2E Tester');
|
||||||
|
if (v.age ?? true) await page.getByRole('checkbox', { name: /at least 16 years old/ }).click();
|
||||||
|
if (v.terms ?? true) await page.getByRole('checkbox', { name: /privacy policy/ }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(page: Page, v: Parameters<typeof fillRegistration>[1]): Promise<void> {
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, v);
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(page: Page, email: string, password: string): Promise<void> {
|
||||||
|
await open(page, '/login');
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Password' }), password);
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSignedIn(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await expect(textOf(page, t.home)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angemeldet <=> `/login` leitet weg (Guest-only-Route, Spec 5.3). Prueft ohne Fixture-Annahmen
|
||||||
|
* ueber den Storage-Key.
|
||||||
|
*/
|
||||||
|
export async function expectSignedInViaLoginRedirect(page: Page): Promise<void> {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `localStorage`-Eintraege von supabase_flutter im Web (Schluessel nicht raten, auslesen). */
|
||||||
|
export async function readAuthStorage(page: Page): Promise<Record<string, string>> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i)!;
|
||||||
|
if (k.includes('supabase.auth.token') || /sb-.*-auth-token/.test(k)) out[k] = localStorage.getItem(k)!;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Seite in einem frischen Kontext mit denselben Browser-Optionen. */
|
||||||
|
export async function freshContext(context: BrowserContext): Promise<BrowserContext> {
|
||||||
|
return context.browser()!.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt die Browser-Uhr ab dem naechsten Seitenladen um `offsetMs` in die Zukunft.
|
||||||
|
*
|
||||||
|
* Zweck (Krit. 9, F1): Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, 60 min) ab; der
|
||||||
|
* Test erzwingt den Refresh, ohne zu warten. `Session.expiresAt` in supabase_flutter wird aus dem
|
||||||
|
* JWT-Claim `exp` berechnet, nicht aus dem gespeicherten Feld `expires_at`; das Aendern des
|
||||||
|
* localStorage haette also keine Wirkung. Der Server rechnet weiter mit der echten Zeit.
|
||||||
|
*/
|
||||||
|
export async function shiftBrowserClock(page: Page, offsetMs: number): Promise<void> {
|
||||||
|
await page.addInitScript((offset: number) => {
|
||||||
|
const RealDate = Date;
|
||||||
|
class ShiftedDate extends RealDate {
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
if (args.length === 0) super(RealDate.now() + offset);
|
||||||
|
else super(...(args as [any]));
|
||||||
|
}
|
||||||
|
static now() {
|
||||||
|
return RealDate.now() + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(globalThis as any).Date = ShiftedDate;
|
||||||
|
}, offsetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schluessel der supabase_flutter-Sitzung im localStorage (nicht fest verdrahtet, nicht ueber Index). */
|
||||||
|
export async function authStorageKey(page: Page): Promise<string | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const k = localStorage.key(i)!;
|
||||||
|
if (/^sb-.*-auth-token$/.test(k)) return k;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktuelle Sitzung aus dem localStorage oder `null`. */
|
||||||
|
export async function readSession(page: Page): Promise<{ access_token: string; refresh_token: string } | null> {
|
||||||
|
const key = await authStorageKey(page);
|
||||||
|
if (!key) return null;
|
||||||
|
const raw = await page.evaluate((k) => localStorage.getItem(k), key);
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frischer Browser-Kontext mit den Projekt-Optionen (en-US, kein Service Worker, Basis-URL aus der Config). */
|
||||||
|
export async function freshBrowserContext(browser: import('@playwright/test').Browser): Promise<BrowserContext> {
|
||||||
|
return browser.newContext({ locale: 'en-US', serviceWorkers: 'block', baseURL: env.webUrl });
|
||||||
|
}
|
||||||
84
e2e/tests/api/config.spec.ts
Normal file
84
e2e/tests/api/config.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { authConfig } from '../../support/auth-config';
|
||||||
|
|
||||||
|
const root = resolve(__dirname, '../../..');
|
||||||
|
const toml = readFileSync(resolve(root, 'supabase/config.toml'), 'utf8');
|
||||||
|
const doc = readFileSync(resolve(root, 'docs/auth-konfiguration.md'), 'utf8');
|
||||||
|
|
||||||
|
/** Aktive (nicht auskommentierte) Zeilen von `config.toml`. */
|
||||||
|
const active = toml
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && !l.startsWith('#'));
|
||||||
|
|
||||||
|
function value(key: string, section?: string): string | undefined {
|
||||||
|
let current = '';
|
||||||
|
for (const line of active) {
|
||||||
|
const sec = line.match(/^\[(.+)\]$/);
|
||||||
|
if (sec) {
|
||||||
|
current = sec[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (section !== undefined && current !== section) continue;
|
||||||
|
const kv = line.match(new RegExp(`^${key}\\s*=\\s*(.+)$`));
|
||||||
|
if (kv) return kv[1].replace(/^"|"$/g, '');
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Auth-Konfiguration: auth-config.ts stimmt mit config.toml und Doku ueberein', () => {
|
||||||
|
test('AUTH-CFG-01: Site-URL', () => {
|
||||||
|
expect(value('site_url', 'auth')).toBe(authConfig.siteUrl);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-04: E-Mail-Bestaetigung Pflicht', () => {
|
||||||
|
expect(value('enable_confirmations', 'auth.email')).toBe('true');
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-05/-06: Passwort-Mindestlaenge und keine Zeichenklassen', () => {
|
||||||
|
expect(Number(value('minimum_password_length', 'auth'))).toBe(authConfig.passwordMinLength);
|
||||||
|
expect(value('password_requirements', 'auth')).toBe('');
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-07: Gueltigkeit der Links', () => {
|
||||||
|
expect(Number(value('otp_expiry', 'auth.email'))).toBe(authConfig.otpExpirySeconds);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-08: Mindestabstand lokal 1 s', () => {
|
||||||
|
expect(value('max_frequency', 'auth.email')).toBe(`${authConfig.maxFrequencySeconds}s`);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-10: Login-/Token-Refresh-Drosselung (150 je 5 min, Burst 30)', () => {
|
||||||
|
expect(Number(value('token_refresh', 'auth.rate_limit'))).toBe(150);
|
||||||
|
expect(authConfig.loginBurst).toBe(30);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-11: Signup/Reset/Resend-Drosselung (30 je 5 min)', () => {
|
||||||
|
expect(Number(value('sign_in_sign_ups', 'auth.rate_limit'))).toBe(authConfig.otpBurst);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-12: Link-Pruefung (30 je 5 min)', () => {
|
||||||
|
expect(Number(value('token_verifications', 'auth.rate_limit'))).toBe(30);
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-14/-15: JWT-Laufzeit und Refresh-Token-Rotation', () => {
|
||||||
|
expect(Number(value('jwt_expiry', 'auth'))).toBe(authConfig.jwtExpirySeconds);
|
||||||
|
expect(value('enable_refresh_token_rotation', 'auth')).toBe('true');
|
||||||
|
expect(Number(value('refresh_token_reuse_interval', 'auth'))).toBe(authConfig.refreshTokenReuseIntervalSeconds);
|
||||||
|
});
|
||||||
|
test('KTO-01 Kriterium 5: AUTH-CFG-16 Sitzungsbegrenzung ist nicht gesetzt (timebox, inactivity_timeout)', () => {
|
||||||
|
expect(active.some((l) => /^timebox\s*=/.test(l))).toBe(false);
|
||||||
|
expect(active.some((l) => /^inactivity_timeout\s*=/.test(l))).toBe(false);
|
||||||
|
expect(active).not.toContain('[auth.sessions]');
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-19: alle Benachrichtigungs-Mails aus', () => {
|
||||||
|
const sections = active.filter((l) => l.startsWith('[auth.email.notification.'));
|
||||||
|
expect(sections.length).toBeGreaterThan(0);
|
||||||
|
for (const s of sections) {
|
||||||
|
expect(value('enabled', s.slice(1, -1)), s).toBe('false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('AUTH-CFG-24: SMS-Autoconfirm aus (enable_confirmations = true), Voraussetzung fuer den Enumerationsschutz', () => {
|
||||||
|
expect(value('enable_confirmations', 'auth.sms')).toBe('true');
|
||||||
|
});
|
||||||
|
test('docs/auth-konfiguration.md fuehrt AUTH-CFG-01 bis -25', () => {
|
||||||
|
for (let i = 1; i <= 25; i++) {
|
||||||
|
const id = `AUTH-CFG-${String(i).padStart(2, '0')}`;
|
||||||
|
expect(doc, id).toContain(`| ${id} |`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
35
e2e/tests/api/guards.spec.ts
Normal file
35
e2e/tests/api/guards.spec.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { assertLocalUrl } from '../../support/auth-config';
|
||||||
|
import { localDbConfig } from '../../support/db';
|
||||||
|
|
||||||
|
test.describe('E2E-Guards: Tests laufen nur gegen lokale Dienste', () => {
|
||||||
|
test('db.ts: lokale Hosts werden akzeptiert (inkl. IPv6 in Klammern)', () => {
|
||||||
|
for (const url of [
|
||||||
|
'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
|
||||||
|
'postgresql://postgres:postgres@localhost:54322/postgres',
|
||||||
|
'postgresql://postgres:postgres@[::1]:54322/postgres',
|
||||||
|
]) {
|
||||||
|
expect(() => localDbConfig(url), url).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('db.ts: fremde Hosts werden abgelehnt', () => {
|
||||||
|
expect(() => localDbConfig('postgresql://u:p@db.example.com:5432/postgres')).toThrow(/nicht erlaubt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('db.ts: ?host= kann den Guard nicht umgehen', () => {
|
||||||
|
expect(() => localDbConfig('postgresql://u:p@127.0.0.1:5432/postgres?host=db.example.com')).toThrow(/nicht erlaubt/);
|
||||||
|
expect(() => localDbConfig('postgresql://u:p@localhost/postgres?host=/var/run/postgresql,db.example.com')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('db.ts: Host-Namen mit lokalem Praefix/Suffix gelten nicht als lokal', () => {
|
||||||
|
expect(() => localDbConfig('postgresql://u:p@localhost.evil.com:5432/postgres')).toThrow();
|
||||||
|
expect(() => localDbConfig('postgresql://u:p@127.0.0.1.evil.com:5432/postgres')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SUPABASE_URL/MAILPIT_URL: nur lokale Hosts', () => {
|
||||||
|
expect(() => assertLocalUrl('X', 'http://127.0.0.1:54321')).not.toThrow();
|
||||||
|
expect(() => assertLocalUrl('X', 'http://[::1]:54321')).not.toThrow();
|
||||||
|
expect(() => assertLocalUrl('X', 'https://abc.supabase.co')).toThrow(/nicht lokal/);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
e2e/tests/api/krit01-registrierung.spec.ts
Normal file
64
e2e/tests/api/krit01-registrierung.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { subjects, TERMS_VERSION } from '../../support/auth-config';
|
||||||
|
import { profileByEmail, userByEmail } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
|
||||||
|
import { extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 1: Registrierung und Bestaetigung (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 1: Signup legt Nutzer und Profil an, Sitzung bleibt aus, Bestaetigungsmail im Faenger', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(
|
||||||
|
email,
|
||||||
|
randomPassword(),
|
||||||
|
validMetadata({ display_name: ' Anna Beispiel ', locale: 'de' }),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.id).toBeTruthy();
|
||||||
|
expect(res.body.session ?? res.body.access_token ?? null).toBeNull();
|
||||||
|
expect(res.body.email_confirmed_at ?? null).toBeNull();
|
||||||
|
|
||||||
|
const profile = await profileByEmail(email);
|
||||||
|
expect(profile).toBeTruthy();
|
||||||
|
expect(profile.display_name).toBe('Anna Beispiel');
|
||||||
|
expect(profile.locale).toBe('de');
|
||||||
|
expect(profile.terms_version).toBe(TERMS_VERSION);
|
||||||
|
expect(profile.terms_accepted_at).toBeTruthy();
|
||||||
|
expect(profile.age_confirmed_at).toBeTruthy();
|
||||||
|
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.confirmation.de);
|
||||||
|
const { type, link } = extractTokenHash(mail);
|
||||||
|
expect(type).toBe('email');
|
||||||
|
// AUTH-CFG-01: Link baut auf der Site-URL auf.
|
||||||
|
expect(link.origin).toBe('http://localhost:3000');
|
||||||
|
expect(link.pathname).toBe('/auth/confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 1: Link einloesen bestaetigt die Adresse, danach ist die Anmeldung moeglich', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
|
||||||
|
const verified = await gotrue.verify(tokenHash, 'email');
|
||||||
|
expect(verified.status).toBe(200);
|
||||||
|
expect(verified.body.access_token).toBeTruthy();
|
||||||
|
expect(verified.body.user.email_confirmed_at).toBeTruthy();
|
||||||
|
|
||||||
|
const login = await gotrue.signIn(email, password);
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
expect(login.body.access_token).toBeTruthy();
|
||||||
|
expect((await userByEmail(email)).email_confirmed_at).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 1: ohne Bestaetigung kein Zugang (AUTH-CFG-04)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
const login = await gotrue.signIn(email, password);
|
||||||
|
expect(login.status).toBe(400);
|
||||||
|
expect(login.body.error_code).toBe('email_not_confirmed');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
e2e/tests/api/krit02-03-validierung.spec.ts
Normal file
58
e2e/tests/api/krit02-03-validierung.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { authConfig, triggerErrors } from '../../support/auth-config';
|
||||||
|
import { userByEmail } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 2: Altersbestaetigung (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 2: Signup ohne age_confirmed -> 500 age_confirmation_required, kein Nutzer', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: undefined }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 2: age_confirmed=false wird ebenfalls abgelehnt', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ age_confirmed: false }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.message).toBe(triggerErrors.ageConfirmationRequired);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('QUE-04 Kriterium 1 / F5: Zustimmung und Version der Rechtstexte (api)', () => {
|
||||||
|
test('QUE-04 Kriterium 1: Signup ohne terms_accepted -> terms_acceptance_required', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_accepted: undefined }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.message).toBe(triggerErrors.termsAcceptanceRequired);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QUE-04 Kriterium 1: Signup ohne terms_version -> terms_version_required', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ terms_version: undefined }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.message).toBe(triggerErrors.termsVersionRequired);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 3: Passwortregeln (api)', () => {
|
||||||
|
test(`KTO-01 Kriterium 3: AUTH-CFG-05 ${authConfig.passwordMinLength - 1} Zeichen -> 422 weak_password (length)`, async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, 'a'.repeat(authConfig.passwordMinLength - 1));
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(res.body.error_code).toBe('weak_password');
|
||||||
|
expect(res.body.weak_password?.reasons).toContain('length');
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`KTO-01 Kriterium 3: AUTH-CFG-05/-06 genau ${authConfig.passwordMinLength} Zeichen ohne Zeichenklassen werden akzeptiert`, async () => {
|
||||||
|
const res = await gotrue.signUp(randomEmail(), 'a'.repeat(authConfig.passwordMinLength));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
e2e/tests/api/krit04-enumeration.spec.ts
Normal file
82
e2e/tests/api/krit04-enumeration.spec.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { profileByEmail, userByEmail, usersWhere } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
|
||||||
|
import { countMails, expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 4: Enumerationsschutz (api)', () => {
|
||||||
|
/**
|
||||||
|
* Regressionstest fuer AUTH-CFG-04 (MAILER_AUTOCONFIRM=false) UND AUTH-CFG-24 (SMS_AUTOCONFIRM=false).
|
||||||
|
* Mit SMS_AUTOCONFIRM=true (CLI-Standard, Self-Hosting-Vorlage) kaeme hier 422 user_already_exists.
|
||||||
|
*/
|
||||||
|
test('KTO-01 Kriterium 4: AUTH-CFG-04/-24 Signup mit bestaetigter Adresse -> 200 (nicht 422), identities leer, erfundener Nutzer, keine Mail, altes Passwort gilt', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
const created = await gotrue.signUp(email, oldPassword);
|
||||||
|
const realId = created.body.id;
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
|
||||||
|
expect(await countMails(email)).toBe(1);
|
||||||
|
|
||||||
|
await waitMailInterval();
|
||||||
|
const newPassword = randomPassword();
|
||||||
|
const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' }));
|
||||||
|
|
||||||
|
expect(again.status, 'nicht 422 user_already_exists (AUTH-CFG-24)').toBe(200);
|
||||||
|
expect(again.body.error_code).toBeUndefined();
|
||||||
|
expect(again.body.identities ?? []).toEqual([]);
|
||||||
|
expect(again.body.id).toBeTruthy();
|
||||||
|
expect(again.body.id).not.toBe(realId);
|
||||||
|
// Kein zweites Konto: genau eine Zeile fuer die Adresse, keine mit der erfundenen ID
|
||||||
|
// (unabhaengig von parallel laufenden Tests, kein globaler Zaehler).
|
||||||
|
expect(await usersWhere({ email })).toHaveLength(1);
|
||||||
|
expect(await usersWhere({ id: again.body.id })).toHaveLength(0);
|
||||||
|
// Keine (weitere) Mail an die Adresse: nach festem Fenster weiterhin nur die erste Bestaetigungsmail.
|
||||||
|
await expectMailCount(email, 1);
|
||||||
|
|
||||||
|
// Das urspruengliche Passwort gilt weiter, das neue nicht; das Profil bleibt unveraendert.
|
||||||
|
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
|
||||||
|
const wrong = await gotrue.signIn(email, newPassword);
|
||||||
|
expect(wrong.status).toBe(400);
|
||||||
|
expect(wrong.body.error_code).toBe('invalid_credentials');
|
||||||
|
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 4: Signup mit unbestaetigter Adresse -> 200, Mail erneut an das bestehende Konto, Passwort und Profil unveraendert', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
const first = await gotrue.signUp(email, oldPassword);
|
||||||
|
await waitForMail(email);
|
||||||
|
await waitMailInterval();
|
||||||
|
|
||||||
|
const newPassword = randomPassword();
|
||||||
|
const again = await gotrue.signUp(email, newPassword, validMetadata({ display_name: 'Angreifer' }));
|
||||||
|
expect(again.status).toBe(200);
|
||||||
|
expect(again.body.id).toBe(first.body.id);
|
||||||
|
|
||||||
|
// Die Bestaetigungsmail geht erneut an das bestehende Konto.
|
||||||
|
const second = await waitForMail(email, 2);
|
||||||
|
const { tokenHash } = extractTokenHash(second);
|
||||||
|
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
|
||||||
|
|
||||||
|
expect((await gotrue.signIn(email, oldPassword)).status).toBe(200);
|
||||||
|
expect((await gotrue.signIn(email, newPassword)).body.error_code).toBe('invalid_credentials');
|
||||||
|
expect((await profileByEmail(email)).display_name).toBe('E2E Tester');
|
||||||
|
expect((await userByEmail(email)).id).toBe(first.body.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 4: Reset fuer unbekannte Adresse -> 200, keine Mail; fuer bekannte Adresse 200 mit Mail', async () => {
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
const res = await gotrue.recover(unknown);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
await expectMailCount(unknown, 0);
|
||||||
|
|
||||||
|
const known = randomEmail();
|
||||||
|
await gotrue.createConfirmedUser(known, randomPassword());
|
||||||
|
const ok = await gotrue.recover(known);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(ok.body).toEqual(res.body);
|
||||||
|
await waitForMail(known);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
e2e/tests/api/krit05-sitzung.spec.ts
Normal file
30
e2e/tests/api/krit05-sitzung.spec.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { authConfig } from '../../support/auth-config';
|
||||||
|
import { sessionNotAfter } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 5: Sitzung bleibt bestehen (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 5: AUTH-CFG-14/-16 Sitzung ohne Zeitbegrenzung, JWT-Laufzeit 3600 s', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
const login = await gotrue.signIn(email, password);
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
expect(login.body.expires_in).toBe(authConfig.jwtExpirySeconds);
|
||||||
|
// AUTH-CFG-16: weder timebox noch inactivity_timeout -> not_after bleibt leer.
|
||||||
|
expect(await sessionNotAfter(email)).toEqual([null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 5: AUTH-CFG-15 Refresh liefert neue Tokens, die Sitzung bleibt gueltig', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
const login = await gotrue.signIn(email, password);
|
||||||
|
const refreshed = await gotrue.refresh(login.body.refresh_token);
|
||||||
|
expect(refreshed.status).toBe(200);
|
||||||
|
expect(refreshed.body.refresh_token).not.toBe(login.body.refresh_token);
|
||||||
|
expect((await gotrue.getUser(refreshed.body.access_token)).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
e2e/tests/api/krit06-07-anmeldung.spec.ts
Normal file
58
e2e/tests/api/krit06-07-anmeldung.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
|
||||||
|
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 6: unbestaetigtes Konto (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 6: richtiges Passwort -> email_not_confirmed, falsches Passwort -> invalid_credentials', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
|
||||||
|
const right = await gotrue.signIn(email, password);
|
||||||
|
expect(right.status).toBe(400);
|
||||||
|
expect(right.body.error_code).toBe('email_not_confirmed');
|
||||||
|
|
||||||
|
const wrong = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
expect(wrong.status).toBe(400);
|
||||||
|
expect(wrong.body.error_code).toBe('invalid_credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 6: erneut senden -> zweite Mail, deren Link das Konto bestaetigt (AUTH-CFG-08: lokal 1 s)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
await waitForMail(email);
|
||||||
|
await waitMailInterval();
|
||||||
|
|
||||||
|
const resend = await gotrue.resend(email);
|
||||||
|
expect(resend.status).toBe(200);
|
||||||
|
const second = await waitForMail(email, 2);
|
||||||
|
const { tokenHash } = extractTokenHash(second);
|
||||||
|
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
|
||||||
|
expect((await gotrue.signIn(email, password)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 6: erneut senden fuer unbekannte Adresse -> 200, keine Mail (keine Enumeration)', async () => {
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
const res = await gotrue.resend(unknown);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
await expectMailCount(unknown, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 7: keine Auskunft ueber Konten (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort liefern identische Antworten', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
const wrongPassword = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
const wrongEmail = await gotrue.signIn(randomEmail('unknown'), password);
|
||||||
|
expect(wrongPassword.status).toBe(400);
|
||||||
|
expect(wrongEmail.status).toBe(wrongPassword.status);
|
||||||
|
expect(wrongEmail.body.error_code).toBe('invalid_credentials');
|
||||||
|
expect(wrongEmail.body).toEqual(wrongPassword.body);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
e2e/tests/api/krit08-reset-link.spec.ts
Normal file
106
e2e/tests/api/krit08-reset-link.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { authConfig, errorCodes } from '../../support/auth-config';
|
||||||
|
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
|
||||||
|
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
async function confirmedUser() {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
const created = await gotrue.createConfirmedUser(email, password);
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 8: Passwort-Reset-Link (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 8: Reset anfordern -> Mail mit type=recovery -> einloesen ok -> zweites Einloesen 403 otp_expired', async () => {
|
||||||
|
const { email } = await confirmedUser();
|
||||||
|
expect((await gotrue.recover(email)).status).toBe(200);
|
||||||
|
const { tokenHash, type } = extractTokenHash(await waitForMail(email));
|
||||||
|
expect(type).toBe('recovery');
|
||||||
|
|
||||||
|
const first = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.body.access_token).toBeTruthy();
|
||||||
|
|
||||||
|
const second = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
expect(second.status).toBe(403);
|
||||||
|
expect(second.body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: neue Reset-Anforderung ersetzt den alten Link', async () => {
|
||||||
|
const { email } = await confirmedUser();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const oldLink = extractTokenHash(await waitForMail(email));
|
||||||
|
await waitMailInterval();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const newLink = extractTokenHash(await waitForMail(email, 2));
|
||||||
|
expect(newLink.tokenHash).not.toBe(oldLink.tokenHash);
|
||||||
|
|
||||||
|
const old = await gotrue.verify(oldLink.tokenHash, 'recovery');
|
||||||
|
expect(old.status).toBe(403);
|
||||||
|
expect(old.body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
expect((await gotrue.verify(newLink.tokenHash, 'recovery')).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: AUTH-CFG-07 Reset-Link ist nach 1 h + 1 s abgelaufen (recovery_sent_at zurueckgesetzt)', async () => {
|
||||||
|
const { email } = await confirmedUser();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
await expireRecoveryLink(email);
|
||||||
|
|
||||||
|
const res = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: AUTH-CFG-07 Grenze: Reset-Link kurz vor Ablauf ist noch gueltig', async () => {
|
||||||
|
const { email } = await confirmedUser();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
await expireRecoveryLink(email, authConfig.otpExpirySeconds - 60);
|
||||||
|
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: AUTH-CFG-07 Bestaetigungslink ist nach 1 h + 1 s abgelaufen (confirmation_sent_at zurueckgesetzt)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
await expireConfirmationLink(email);
|
||||||
|
|
||||||
|
const res = await gotrue.verify(tokenHash, 'email');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
// Konto bleibt unbestaetigt.
|
||||||
|
expect((await gotrue.signIn(email, password)).body.error_code).toBe(errorCodes.emailNotConfirmed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: Bestaetigungslink ist einmalig', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.signUp(email, randomPassword());
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
expect((await gotrue.verify(tokenHash, 'email')).status).toBe(200);
|
||||||
|
const again = await gotrue.verify(tokenHash, 'email');
|
||||||
|
expect(again.status).toBe(403);
|
||||||
|
expect(again.body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: Reset fuer unbekannte Adresse -> 200, keine Mail', async () => {
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
expect((await gotrue.recover(unknown)).status).toBe(200);
|
||||||
|
await expectMailCount(unknown, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: nach dem Setzen des neuen Passworts ist der Reset-Link verbraucht', async () => {
|
||||||
|
const { email } = await confirmedUser();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
const session = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
const update = await gotrue.updateUser(session.body.access_token, { password: randomPassword() });
|
||||||
|
expect(update.status).toBe(200);
|
||||||
|
expect((await gotrue.verify(tokenHash, 'recovery')).body.error_code).toBe(errorCodes.otpExpired);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
e2e/tests/api/krit09-abmeldung.spec.ts
Normal file
76
e2e/tests/api/krit09-abmeldung.spec.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { env, errorCodes } from '../../support/auth-config';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
async function resetSession(email: string) {
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
const res = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return res.body.access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 9: Passwort aendern beendet andere Sitzungen (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 9: nach Reset sind die Refresh-Tokens anderer Sitzungen ungueltig, die Reset-Sitzung bleibt', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
const newPassword = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, oldPassword);
|
||||||
|
|
||||||
|
const a = (await gotrue.signIn(email, oldPassword)).body;
|
||||||
|
const b = (await gotrue.signIn(email, oldPassword)).body;
|
||||||
|
expect(a.refresh_token).not.toBe(b.refresh_token);
|
||||||
|
|
||||||
|
const recoveryToken = await resetSession(email);
|
||||||
|
const update = await gotrue.updateUser(recoveryToken, { password: newPassword });
|
||||||
|
expect(update.status).toBe(200);
|
||||||
|
|
||||||
|
for (const [name, session] of [['A', a], ['B', b]] as const) {
|
||||||
|
const refreshed = await gotrue.refresh(session.refresh_token);
|
||||||
|
expect(refreshed.status, `Sitzung ${name}`).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(['refresh_token_not_found', 'session_not_found']).toContain(
|
||||||
|
refreshed.body.error_code,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Passwort gilt, altes nicht.
|
||||||
|
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
|
||||||
|
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe(errorCodes.invalidCredentials);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 9 / R8: GoTrue /user lehnt das Access-Token der beendeten Sitzung ab (PostgREST nicht: bis zu 60 min, F1)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, oldPassword);
|
||||||
|
const a = (await gotrue.signIn(email, oldPassword)).body;
|
||||||
|
const recoveryToken = await resetSession(email);
|
||||||
|
await gotrue.updateUser(recoveryToken, { password: randomPassword() });
|
||||||
|
|
||||||
|
const user = await gotrue.getUser(a.access_token);
|
||||||
|
expect([401, 403]).toContain(user.status);
|
||||||
|
|
||||||
|
// Dokumentiert das akzeptierte Restrisiko R8 (AUTH-CFG-14): PostgREST prueft keine Sitzungen.
|
||||||
|
const rest = await fetch(`${env.supabaseUrl}/rest/v1/profiles?select=id`, {
|
||||||
|
headers: { apikey: env.anonKey, authorization: `Bearer ${a.access_token}` },
|
||||||
|
});
|
||||||
|
expect(rest.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 9: gleiches Passwort -> 422 same_password, zu kurzes -> 422 weak_password', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
const token = await resetSession(email);
|
||||||
|
|
||||||
|
const same = await gotrue.updateUser(token, { password });
|
||||||
|
expect(same.status).toBe(422);
|
||||||
|
expect(same.body.error_code).toBe(errorCodes.samePassword);
|
||||||
|
|
||||||
|
const weak = await gotrue.updateUser(token, { password: 'kurz' });
|
||||||
|
expect(weak.status).toBe(422);
|
||||||
|
expect(weak.body.error_code).toBe(errorCodes.weakPassword);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
e2e/tests/api/krit10-anzeigename.spec.ts
Normal file
64
e2e/tests/api/krit10-anzeigename.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { authConfig, triggerErrors } from '../../support/auth-config';
|
||||||
|
import { profileByEmail, userByEmail } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 10: Anzeigename (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 10: 31 Zeichen -> HTTP 500, Body {"code":"P0001","message":"display_name_invalid"}, kein Nutzer', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(
|
||||||
|
email,
|
||||||
|
randomPassword(),
|
||||||
|
validMetadata({ display_name: 'a'.repeat(authConfig.displayNameMaxLength + 1) }),
|
||||||
|
);
|
||||||
|
// Format aus Spike B0 (f), Mapping-Regel 4.4: maßgeblich ist HTTP 500 beim signUp.
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ code: 'P0001', message: triggerErrors.displayNameInvalid });
|
||||||
|
expect(res.headers.get('x-sb-error-code')).toBe('unexpected_failure');
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, value] of [
|
||||||
|
['fehlend', undefined],
|
||||||
|
['leer', ''],
|
||||||
|
['nur Leerzeichen', ' '],
|
||||||
|
] as const) {
|
||||||
|
test(`KTO-01 Kriterium 10: Anzeigename ${label} -> display_name_invalid`, async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: value }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.message).toBe(triggerErrors.displayNameInvalid);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 10: genau 30 Zeichen werden akzeptiert', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const name = 'b'.repeat(authConfig.displayNameMaxLength);
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: name }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await profileByEmail(email)).display_name).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 10: Codepoints zaehlen, nicht UTF-16-Einheiten (30 Emoji ok, 31 abgelehnt)', async () => {
|
||||||
|
const ok = randomEmail();
|
||||||
|
const accepted = await gotrue.signUp(ok, randomPassword(), validMetadata({ display_name: '😀'.repeat(30) }));
|
||||||
|
expect(accepted.status).toBe(200);
|
||||||
|
expect([...(await profileByEmail(ok)).display_name]).toHaveLength(30);
|
||||||
|
|
||||||
|
const bad = randomEmail();
|
||||||
|
const rejected = await gotrue.signUp(bad, randomPassword(), validMetadata({ display_name: '😀'.repeat(31) }));
|
||||||
|
expect(rejected.status).toBe(500);
|
||||||
|
expect(rejected.body.message).toBe(triggerErrors.displayNameInvalid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 10: Anzeigename wird getrimmt gespeichert; 30 Zeichen plus Rand-Leerzeichen sind zulaessig', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const name = 'c'.repeat(authConfig.displayNameMaxLength);
|
||||||
|
const res = await gotrue.signUp(email, randomPassword(), validMetadata({ display_name: ` ${name} ` }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await profileByEmail(email)).display_name).toBe(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts
Normal file
81
e2e/tests/api/krit11-sprache-benachrichtigungen.spec.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { env, subjects } from '../../support/auth-config';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata, waitMailInterval } from '../../support/gotrue';
|
||||||
|
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 11: Mails nur in der Profilsprache, nur die zwei Mailarten (api)', () => {
|
||||||
|
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=de -> deutsche Bestaetigungsmail', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'de' }));
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.confirmation.de);
|
||||||
|
expect(mail.html).toContain('<html lang="de">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 11: AUTH-CFG-17 locale=en -> englische Bestaetigungsmail', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: 'en' }));
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.confirmation.en);
|
||||||
|
expect(mail.html).toContain('<html lang="en">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 11: ohne locale -> Englisch (F6)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.signUp(email, randomPassword(), validMetadata({ locale: undefined }));
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.confirmation.en);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 11: AUTH-CFG-18 Profilsprache auf en aendern -> Reset-Mail englisch; vorher deutsch', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
const created = await gotrue.createConfirmedUser(email, password, validMetadata({ locale: 'de' }));
|
||||||
|
const userId = created.body.id as string;
|
||||||
|
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const german = await waitForMail(email);
|
||||||
|
expect(german.Subject).toBe(subjects.recovery.de);
|
||||||
|
expect(german.html).toContain('<html lang="de">');
|
||||||
|
expect(extractTokenHash(german).type).toBe('recovery');
|
||||||
|
|
||||||
|
// Profilsprache wie die App ueber PostgREST mit dem Token des Nutzers aendern (Sync-Trigger -> user_metadata).
|
||||||
|
const token = (await gotrue.signIn(email, password)).body.access_token as string;
|
||||||
|
const patch = await fetch(`${env.supabaseUrl}/rest/v1/profiles?id=eq.${userId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
apikey: env.anonKey,
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
prefer: 'return=representation',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ locale: 'en' }),
|
||||||
|
});
|
||||||
|
expect(patch.status).toBe(200);
|
||||||
|
expect((await patch.json())[0].locale).toBe('en');
|
||||||
|
|
||||||
|
await waitMailInterval();
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const english = await waitForMail(email, 2);
|
||||||
|
expect(english.Subject).toBe(subjects.recovery.en);
|
||||||
|
expect(english.html).toContain('<html lang="en">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 11: AUTH-CFG-19 nach Reset und Login keine weitere Mail (Benachrichtigungen aus)', async () => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
const session = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
const newPassword = randomPassword();
|
||||||
|
expect((await gotrue.updateUser(session.body.access_token, { password: newPassword })).status).toBe(200);
|
||||||
|
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
|
||||||
|
|
||||||
|
// Genau die eine Reset-Mail; keine password_changed-Benachrichtigung.
|
||||||
|
await expectMailCount(email, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
e2e/tests/spike/smtp-down.spec.ts
Normal file
78
e2e/tests/spike/smtp-down.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* SPIKE (Issue #37), NICHT blockierend: Verhalten von /recover, /signup und /resend, wenn der
|
||||||
|
* SMTP-Server nicht erreichbar ist, fuer bekannte vs. unbekannte Adressen.
|
||||||
|
*
|
||||||
|
* Laeuft gegen einen dritten GoTrue-Container, dessen SMTP-Port 1 die Verbindung ablehnt
|
||||||
|
* (`e2e/support/start-throttle-gotrue.sh start smtp-down`, SPIKE_GOTRUE_URL). Mailpit bleibt fuer
|
||||||
|
* die anderen Projekte unberuehrt.
|
||||||
|
*
|
||||||
|
* Die Tests pruefen NICHTS ausser dem Ablauf; sie erfassen Status, Body, Dauer und den Zustand in
|
||||||
|
* der DB je Fall und schreiben ihn in die Konsole und als Anhang `smtp-down-<fall>.json`. Eine
|
||||||
|
* Auswertung steht in docs/auth-konfiguration.md (Abschnitt „SMTP-Ausfall (Spike #37)“).
|
||||||
|
*/
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { env } from '../../support/auth-config';
|
||||||
|
import { userByEmail } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, validMetadata } from '../../support/gotrue';
|
||||||
|
import { countMails } from '../../support/mailpit';
|
||||||
|
|
||||||
|
const base = env.spikeGotrueUrl;
|
||||||
|
test.skip(!base, 'SPIKE_GOTRUE_URL nicht gesetzt (start-throttle-gotrue.sh start smtp-down)');
|
||||||
|
|
||||||
|
const spike = () => new GoTrueClient({ baseUrl: base!, apikey: null });
|
||||||
|
const cli = new GoTrueClient();
|
||||||
|
|
||||||
|
async function record(info: typeof test.info extends () => infer R ? R : never, name: string, data: unknown) {
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
console.log(`[SPIKE smtp-down] ${name}: ${json}`);
|
||||||
|
await info.attach(`smtp-down-${name}.json`, { body: json, contentType: 'application/json' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarize = (r: { status: number; body: any; ms: number }) => ({
|
||||||
|
status: r.status,
|
||||||
|
error_code: r.body?.error_code ?? null,
|
||||||
|
msg: r.body?.msg ?? r.body?.message ?? null,
|
||||||
|
hasUserId: Boolean(r.body?.id),
|
||||||
|
ms: r.ms,
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Spike #37: SMTP nicht erreichbar', () => {
|
||||||
|
test('recover: bekannte (bestaetigte) vs. unbekannte Adresse', async () => {
|
||||||
|
const known = randomEmail();
|
||||||
|
await cli.createConfirmedUser(known, randomPassword());
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
const a = await spike().recover(known);
|
||||||
|
const b = await spike().recover(unknown);
|
||||||
|
await record(test.info(), 'recover', { known: summarize(a), unknown: summarize(b) });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signup: neue vs. bestaetigte vs. unbestaetigte Adresse', async () => {
|
||||||
|
const confirmed = randomEmail();
|
||||||
|
await cli.createConfirmedUser(confirmed, randomPassword());
|
||||||
|
const unconfirmed = randomEmail();
|
||||||
|
await cli.signUp(unconfirmed, randomPassword());
|
||||||
|
const fresh = randomEmail();
|
||||||
|
|
||||||
|
const rFresh = await spike().signUp(fresh, randomPassword());
|
||||||
|
const rConfirmed = await spike().signUp(confirmed, randomPassword());
|
||||||
|
// AUTH-CFG-08: sonst antwortet GoTrue mit 429 statt dem SMTP-Fehler.
|
||||||
|
await new Promise((r) => setTimeout(r, 1300));
|
||||||
|
const rUnconfirmed = await spike().signUp(unconfirmed, randomPassword());
|
||||||
|
await record(test.info(), 'signup', {
|
||||||
|
newAddress: { ...summarize(rFresh), userInDbAfterwards: Boolean(await userByEmail(fresh)) },
|
||||||
|
confirmedAddress: summarize(rConfirmed),
|
||||||
|
unconfirmedAddress: summarize(rUnconfirmed),
|
||||||
|
mailsToFresh: await countMails(fresh),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resend: unbestaetigte vs. unbekannte Adresse', async () => {
|
||||||
|
const unconfirmed = randomEmail();
|
||||||
|
await cli.signUp(unconfirmed, randomPassword(), validMetadata());
|
||||||
|
await new Promise((r) => setTimeout(r, 1300));
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
const a = await spike().resend(unconfirmed);
|
||||||
|
const b = await spike().resend(unknown);
|
||||||
|
await record(test.info(), 'resend', { unconfirmed: summarize(a), unknown: summarize(b) });
|
||||||
|
});
|
||||||
|
});
|
||||||
103
e2e/tests/throttle/krit07-drosselung.spec.ts
Normal file
103
e2e/tests/throttle/krit07-drosselung.spec.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/**
|
||||||
|
* KTO-01 Kriterium 7 (Drosselung), AUTH-CFG-10 und AUTH-CFG-13.
|
||||||
|
*
|
||||||
|
* Laeuft gegen den ZUSAETZLICHEN GoTrue-Container mit GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For
|
||||||
|
* (e2e/support/start-throttle-gotrue.sh, THROTTLE_GOTRUE_URL), nicht gegen die CLI: Die CLI
|
||||||
|
* drosselt nie (Spike A2, F11). Jeder Test nutzt eine eigene zufaellige IP (simuliert den Proxy,
|
||||||
|
* R13). Es wird direkt auf GoTrue zugegriffen, ohne Kong und ohne apikey.
|
||||||
|
*/
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { authConfig, env, errorCodes } from '../../support/auth-config';
|
||||||
|
import { GoTrueClient, randomEmail, randomIp, randomPassword } from '../../support/gotrue';
|
||||||
|
|
||||||
|
const base = env.throttleGotrueUrl;
|
||||||
|
test.skip(!base, 'THROTTLE_GOTRUE_URL nicht gesetzt (siehe e2e/support/start-throttle-gotrue.sh)');
|
||||||
|
|
||||||
|
/** Zusaetzliche Versuche, die der Nachfuellrate (0,5/s) der gemessenen Dauer entsprechen: ceil(t * 0,5) + 1. */
|
||||||
|
const refillTolerance = (elapsedMs: number) => Math.ceil((elapsedMs / 1000) * 0.5) + 1;
|
||||||
|
|
||||||
|
const client = (ip?: string) =>
|
||||||
|
new GoTrueClient({ baseUrl: base!, apikey: null, headers: ip ? { 'X-Forwarded-For': ip } : {} });
|
||||||
|
|
||||||
|
/** Konto, das GoTrue-Instanz und DB mit der CLI teilen; Anlage ohne Header ueber die CLI (Service-Role). */
|
||||||
|
async function createUser() {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
const res = await new GoTrueClient().createConfirmedUser(email, password);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 7: Drosselung der Anmeldung (throttle)', () => {
|
||||||
|
test('KTO-01 Kriterium 7: AUTH-CFG-10 30 Fehllogins einer IP -> invalid_credentials, Login #31 -> 429 over_request_rate_limit', async () => {
|
||||||
|
const { email, password } = await createUser();
|
||||||
|
const ip = randomIp();
|
||||||
|
const gotrue = client(ip);
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) {
|
||||||
|
const res = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
expect(res.status, `Versuch ${attempt}`).toBe(400);
|
||||||
|
expect(res.body.error_code, `Versuch ${attempt}`).toBe(errorCodes.invalidCredentials);
|
||||||
|
}
|
||||||
|
const tolerance = refillTolerance(Date.now() - started);
|
||||||
|
|
||||||
|
// Login #31 -> 429. Der Limiter fuellt 0,5 Token/s nach (AUTH-CFG-10); je Sekunde Laufzeit
|
||||||
|
// der ersten 30 Versuche darf die erste 429 einen halben Versuch spaeter kommen.
|
||||||
|
let limited = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
for (let extra = 0; extra < tolerance && limited.status !== 429; extra++) {
|
||||||
|
limited = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
}
|
||||||
|
expect(limited.status).toBe(429);
|
||||||
|
expect(limited.body.error_code).toBe(errorCodes.overRequestRateLimit);
|
||||||
|
// Auch der richtige Login dieser IP wird gedrosselt (Drosselung je IP, nicht je Passwort).
|
||||||
|
expect((await gotrue.signIn(email, password)).status).toBe(429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 7: AUTH-CFG-10 die 429-Antwort traegt weder Retry-After noch X-RateLimit-* (App nennt keine Dauer)', async () => {
|
||||||
|
const { email, password } = await createUser();
|
||||||
|
const gotrue = client(randomIp());
|
||||||
|
const started = Date.now();
|
||||||
|
let limited;
|
||||||
|
let attempts = 0;
|
||||||
|
// Hoechstens Burst + Toleranz aus der gemessenen Dauer; danach muss eine 429 gekommen sein.
|
||||||
|
while (attempts < authConfig.loginBurst + refillTolerance(Date.now() - started) + 1) {
|
||||||
|
attempts++;
|
||||||
|
const res = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
if (res.status === 429) {
|
||||||
|
limited = res;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!limited) throw new Error(`Keine 429 nach ${attempts} Versuchen in ${Date.now() - started} ms`);
|
||||||
|
expect(limited.headers.get('retry-after')).toBeNull();
|
||||||
|
const rateHeaders = [...limited.headers.keys()].filter((h) => h.toLowerCase().startsWith('x-ratelimit'));
|
||||||
|
expect(rateHeaders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 7: AUTH-CFG-10 eine zweite IP ist von der Drosselung der ersten nicht betroffen', async () => {
|
||||||
|
const { email, password } = await createUser();
|
||||||
|
const first = client(randomIp());
|
||||||
|
for (let attempt = 1; attempt <= authConfig.loginBurst; attempt++) {
|
||||||
|
await first.signIn(email, `${password}-falsch`);
|
||||||
|
}
|
||||||
|
expect((await first.signIn(email, `${password}-falsch`)).status).toBe(429);
|
||||||
|
|
||||||
|
const second = client(randomIp());
|
||||||
|
const wrong = await second.signIn(email, `${password}-falsch`);
|
||||||
|
expect(wrong.status).toBe(400);
|
||||||
|
expect(wrong.body.error_code).toBe(errorCodes.invalidCredentials);
|
||||||
|
const right = await second.signIn(email, password);
|
||||||
|
expect(right.status).toBe(200);
|
||||||
|
expect(right.body.access_token).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 7: AUTH-CFG-13 Requests ohne X-Forwarded-For werden nicht gedrosselt', async () => {
|
||||||
|
const { email, password } = await createUser();
|
||||||
|
const gotrue = client();
|
||||||
|
for (let attempt = 1; attempt <= authConfig.loginBurst + 5; attempt++) {
|
||||||
|
const res = await gotrue.signIn(email, `${password}-falsch`);
|
||||||
|
expect(res.status, `Versuch ${attempt}`).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
32
e2e/tests/web/krit01-registrierung.spec.ts
Normal file
32
e2e/tests/web/krit01-registrierung.spec.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { subjects } from '../../support/auth-config';
|
||||||
|
import { profileByEmail } from '../../support/db';
|
||||||
|
import { randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
import { expectSignedIn, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt“ und Anmeldung', () => {
|
||||||
|
test('KTO-01 Kriterium 1: Registrieren, Link in der Mail oeffnen, bestaetigen, anmelden', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
|
||||||
|
await register(page, { email, password, displayName: ' Webnutzer ' });
|
||||||
|
await expect(page).toHaveURL(/\/register\/check-email$/);
|
||||||
|
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
|
||||||
|
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.confirmation.en);
|
||||||
|
const { link } = extractTokenHash(mail);
|
||||||
|
expect((await profileByEmail(email)).display_name).toBe('Webnutzer');
|
||||||
|
|
||||||
|
// Der Link aus der Mail fuehrt auf die App; erst der Klick loest die Pruefung aus (Spec E3).
|
||||||
|
await open(page, link.pathname + link.search);
|
||||||
|
await pressFirstButton(page);
|
||||||
|
await expect(textOf(page, t.confirmSuccess)).toBeVisible();
|
||||||
|
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await login(page, email, password);
|
||||||
|
await expectSignedIn(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
e2e/tests/web/krit02-03-10-formular.spec.ts
Normal file
69
e2e/tests/web/krit02-03-10-formular.spec.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { userByEmail } from '../../support/db';
|
||||||
|
import { randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { fillRegistration, open, t, textOf } from '../../support/web';
|
||||||
|
|
||||||
|
/** Zaehlt Signup-Aufrufe der Seite (Krit. 2: „kein Aufruf“). */
|
||||||
|
function countSignups(page: import('@playwright/test').Page) {
|
||||||
|
const calls: string[] = [];
|
||||||
|
page.on('request', (r) => {
|
||||||
|
if (r.method() === 'POST' && r.url().includes('/auth/v1/signup')) calls.push(r.url());
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterien 2, 3, 10 (web): Formularvalidierung', () => {
|
||||||
|
test('KTO-01 Kriterium 2: ohne Altersbestaetigung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const calls = countSignups(page);
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, { email, password: randomPassword(), age: false });
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await expect(textOf(page, t.ageNotConfirmed)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/register$/);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(await userByEmail(email)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QUE-04 Kriterium 1: ohne Zustimmung erscheint der Fehlertext, es gibt keinen Signup-Aufruf', async ({ page }) => {
|
||||||
|
const calls = countSignups(page);
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), terms: false });
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await expect(textOf(page, t.termsNotAccepted)).toBeVisible();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 3: zu kurzes Passwort zeigt am Feld, wie viele Zeichen fehlen, und sendet nichts', async ({ page }) => {
|
||||||
|
const calls = countSignups(page);
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, { email: randomEmail(), password: 'kurz12345' });
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
// 9 von 10 Zeichen: noch 1 fehlt (AUTH-CFG-05).
|
||||||
|
await expect(textOf(page, /1 more character needed/)).toBeVisible();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 3: sehr verbreitetes Passwort wird am Feld abgelehnt', async ({ page }) => {
|
||||||
|
const calls = countSignups(page);
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, { email: randomEmail(), password: 'password123' });
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await expect(textOf(page, /very common and easy to guess/)).toBeVisible();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 10: leerer Anzeigename zeigt den Fehler am Feld', async ({ page }) => {
|
||||||
|
const calls = countSignups(page);
|
||||||
|
await open(page, '/register');
|
||||||
|
await fillRegistration(page, { email: randomEmail(), password: randomPassword(), displayName: ' ' });
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
|
||||||
|
await expect(textOf(page, 'Enter a display name.')).toBeVisible();
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
e2e/tests/web/krit04-06-07-anmeldung.spec.ts
Normal file
122
e2e/tests/web/krit04-06-07-anmeldung.spec.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
|
||||||
|
import { expectMailCount, waitForMail } from '../../support/mailpit';
|
||||||
|
import { freshBrowserContext, fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
/** Inhalt des Semantics-Baums, ohne die (je Lauf wechselnde) Adresse. */
|
||||||
|
async function screenText(page: import('@playwright/test').Page): Promise<string> {
|
||||||
|
// Eingabefelder (mit der je Lauf wechselnden Adresse) gehoeren nicht zum Vergleich.
|
||||||
|
return (await page.locator('body').ariaSnapshot()).replace(/^\s*- textbox.*\n?/gm, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 4 (web): gleicher Bildschirm fuer neue und vorhandene Adresse', () => {
|
||||||
|
test('KTO-01 Kriterium 4: Registrierung mit bestehender, bestaetigter Adresse zeigt exakt denselben Bildschirm wie mit neuer Adresse', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// Neue Adresse.
|
||||||
|
await register(page, { email: randomEmail(), password: randomPassword() });
|
||||||
|
await expect(page).toHaveURL(/\/register\/check-email$/);
|
||||||
|
await expect(textOf(page, t.checkEmailTitle)).toBeVisible();
|
||||||
|
const fresh = await screenText(page);
|
||||||
|
|
||||||
|
// Vorhandene, bestaetigte Adresse (nur ueber die API angelegt).
|
||||||
|
const existing = randomEmail();
|
||||||
|
await gotrue.createConfirmedUser(existing, randomPassword());
|
||||||
|
const second = await (await freshBrowserContext(browser)).newPage();
|
||||||
|
try {
|
||||||
|
await register(second, { email: existing, password: randomPassword() });
|
||||||
|
await expect(second).toHaveURL(/\/register\/check-email$/);
|
||||||
|
await expect(textOf(second, t.checkEmailTitle)).toBeVisible();
|
||||||
|
expect(await screenText(second)).toBe(fresh);
|
||||||
|
// Es geht keine Mail an das bestehende Konto.
|
||||||
|
await expectMailCount(existing, 0);
|
||||||
|
} finally {
|
||||||
|
await second.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 6 (web): unbestaetigtes Konto', () => {
|
||||||
|
test('KTO-01 Kriterium 6: Login mit richtigem Passwort zeigt Hinweis und Aktion; „erneut senden“ schickt eine zweite Mail', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.signUp(email, password);
|
||||||
|
await waitForMail(email);
|
||||||
|
await waitMailInterval();
|
||||||
|
|
||||||
|
await login(page, email, password);
|
||||||
|
await expect(textOf(page, t.emailNotConfirmed)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: t.resendConfirmation }).click();
|
||||||
|
await expect(textOf(page, t.checkEmailResent)).toBeVisible();
|
||||||
|
await waitForMail(email, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 7 (web): keine Auskunft ueber Konten, Drosselungshinweis', () => {
|
||||||
|
test('KTO-01 Kriterium 7: falsche E-Mail und falsches Passwort zeigen denselben Text', async ({ page, browser }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
await login(page, email, `${password}-falsch`);
|
||||||
|
await expect(textOf(page, t.invalidCredentials)).toBeVisible();
|
||||||
|
const wrongPassword = await screenText(page);
|
||||||
|
|
||||||
|
const ctx = await freshBrowserContext(browser);
|
||||||
|
try {
|
||||||
|
const other = await ctx.newPage();
|
||||||
|
await login(other, randomEmail('unknown'), password);
|
||||||
|
await expect(textOf(other, t.invalidCredentials)).toBeVisible();
|
||||||
|
expect(await screenText(other)).toBe(wrongPassword);
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die CLI drosselt nie (AUTH-CFG-13/A2), ein echter Test waere nie rot. Die 429-Antwort wird
|
||||||
|
* deshalb per `page.route` simuliert; der echte Limiter wird im Projekt `throttle` geprueft.
|
||||||
|
* CORS: Die App (localhost:3000) ruft Kong (127.0.0.1:54321) cross-origin auf; die simulierte
|
||||||
|
* Antwort braucht deshalb die CORS-Header, sonst sieht die App einen Netzfehler statt 429.
|
||||||
|
*/
|
||||||
|
test('KTO-01 Kriterium 7: AUTH-CFG-10 bei 429 erscheint der Drosselungshinweis ohne Dauer', async ({ page }) => {
|
||||||
|
await page.route('**/auth/v1/token?grant_type=password', async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
if (request.method() === 'OPTIONS') return route.fallback();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 429,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: { 'access-control-allow-origin': request.headers()['origin'] ?? '*' },
|
||||||
|
body: JSON.stringify({ code: 429, error_code: 'over_request_rate_limit', msg: 'Request rate limit reached' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await login(page, randomEmail(), randomPassword());
|
||||||
|
await expect(textOf(page, t.rateLimited)).toBeVisible();
|
||||||
|
await expect(textOf(page, t.invalidCredentials)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('QUE-06 (web): Ablauf nur per Tastatur', () => {
|
||||||
|
test('QUE-06 Kriterium 3: Anmelden nur mit Tab, Eingabe und Enter (E-Mail-Feld per Tab erreicht)', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
await open(page, '/login');
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
// Kein Klick: Tab fokussiert das erste Feld (E-Mail), Tab das zweite (Passwort).
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeFocused();
|
||||||
|
await page.keyboard.type(email);
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Password' })).toBeFocused();
|
||||||
|
await page.keyboard.type(password);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(textOf(page, t.home)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
51
e2e/tests/web/krit05-sitzung.spec.ts
Normal file
51
e2e/tests/web/krit05-sitzung.spec.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { env } from '../../support/auth-config';
|
||||||
|
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, open, readAuthStorage } from '../../support/web';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 5 (web): Sitzung bleibt bestehen', () => {
|
||||||
|
test('KTO-01 Kriterium 5: Login, Reload und neuer Browser-Kontext mit gleichem Storage bleiben angemeldet', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
|
||||||
|
await login(page, email, password);
|
||||||
|
await expectSignedIn(page);
|
||||||
|
const stored = await readAuthStorage(page);
|
||||||
|
expect(Object.keys(stored).length, 'Sitzung liegt im localStorage').toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Reload: weiterhin angemeldet (/login leitet auf die Startseite).
|
||||||
|
await page.reload();
|
||||||
|
await expectSignedInViaLoginRedirect(page);
|
||||||
|
|
||||||
|
// Neuer Browser-Kontext mit gleichem Storage (z. B. Browser neu gestartet).
|
||||||
|
const state = await page.context().storageState();
|
||||||
|
const restored = await browser.newContext({
|
||||||
|
storageState: state,
|
||||||
|
locale: 'en-US',
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
baseURL: env.webUrl,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const other = await restored.newPage();
|
||||||
|
await other.goto('/login');
|
||||||
|
await enableSemantics(other);
|
||||||
|
await expect(other).toHaveURL(/\/$/);
|
||||||
|
} finally {
|
||||||
|
await restored.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 5: ohne Sitzung bleibt /login erreichbar (Gegenprobe)', async ({ page }) => {
|
||||||
|
await open(page, '/login');
|
||||||
|
// Erst nach dem Flutter-Boot pruefen: das Formular ist sichtbar, ein spaeter Redirect waere bis dahin passiert.
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
e2e/tests/web/krit08-reset.spec.ts
Normal file
102
e2e/tests/web/krit08-reset.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { subjects } from '../../support/auth-config';
|
||||||
|
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
import { freshBrowserContext, fillField, open, pressFirstButton, t, textOf } from '../../support/web';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
async function requestReset(page: import('@playwright/test').Page, email: string) {
|
||||||
|
await open(page, '/forgot-password');
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'Email address' }), email);
|
||||||
|
await page.getByRole('button', { name: 'Send link' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/forgot-password\/check-email$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
|
||||||
|
test('KTO-01 Kriterium 8: Reset anfordern, Link oeffnen, neues Passwort setzen, mit neuem Passwort anmelden', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
const newPassword = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, oldPassword);
|
||||||
|
|
||||||
|
await requestReset(page, email);
|
||||||
|
const mail = await waitForMail(email);
|
||||||
|
expect(mail.Subject).toBe(subjects.recovery.en);
|
||||||
|
const { link, type } = extractTokenHash(mail);
|
||||||
|
expect(type).toBe('recovery');
|
||||||
|
|
||||||
|
// Der Link fuehrt auf die Link-Seite; erst der Klick loest die Pruefung aus.
|
||||||
|
await open(page, link.pathname + link.search);
|
||||||
|
await pressFirstButton(page);
|
||||||
|
await expect(page).toHaveURL(/\/reset-password$/);
|
||||||
|
|
||||||
|
await fillField(page.getByRole('textbox', { name: 'New password' }), newPassword);
|
||||||
|
await page.getByRole('button', { name: 'Save password' }).click();
|
||||||
|
await expect(textOf(page, t.passwordChanged)).toBeVisible();
|
||||||
|
|
||||||
|
expect((await gotrue.signIn(email, newPassword)).status).toBe(200);
|
||||||
|
expect((await gotrue.signIn(email, oldPassword)).body.error_code).toBe('invalid_credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: bereits benutzter Reset-Link zeigt „abgelaufen oder schon benutzt“', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.createConfirmedUser(email, randomPassword());
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { link, tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); // Link verbraucht
|
||||||
|
|
||||||
|
await open(page, link.pathname + link.search);
|
||||||
|
await pressFirstButton(page);
|
||||||
|
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Reset-Link (recovery_sent_at 1 h + 1 s zurueck) zeigt den Fehlerzustand', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.createConfirmedUser(email, randomPassword());
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { link } = extractTokenHash(await waitForMail(email));
|
||||||
|
await expireRecoveryLink(email);
|
||||||
|
|
||||||
|
await open(page, link.pathname + link.search);
|
||||||
|
await pressFirstButton(page);
|
||||||
|
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Request a new link' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: AUTH-CFG-07 abgelaufener Bestaetigungslink zeigt den Fehlerzustand', async ({ page }) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
await gotrue.signUp(email, randomPassword());
|
||||||
|
const { link } = extractTokenHash(await waitForMail(email));
|
||||||
|
await expireConfirmationLink(email);
|
||||||
|
|
||||||
|
await open(page, link.pathname + link.search);
|
||||||
|
await pressFirstButton(page);
|
||||||
|
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 8: Link ohne Parameter zeigt sofort den Fehlerzustand', async ({ page }) => {
|
||||||
|
await open(page, '/auth/confirm');
|
||||||
|
await expect(textOf(page, t.linkInvalid)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 4/8: Reset fuer unbekannte Adresse zeigt denselben Bildschirm, es geht keine Mail raus', async ({ page, browser }) => {
|
||||||
|
const known = randomEmail();
|
||||||
|
await gotrue.createConfirmedUser(known, randomPassword());
|
||||||
|
await requestReset(page, known);
|
||||||
|
await expect(textOf(page, /reset link if an account exists/)).toBeVisible();
|
||||||
|
const body = (await page.locator('body').ariaSnapshot()).trim();
|
||||||
|
|
||||||
|
const unknown = randomEmail('unknown');
|
||||||
|
const ctx = await freshBrowserContext(browser);
|
||||||
|
try {
|
||||||
|
const other = await ctx.newPage();
|
||||||
|
await requestReset(other, unknown);
|
||||||
|
expect((await other.locator('body').ariaSnapshot()).trim()).toBe(body);
|
||||||
|
} finally {
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
await expectMailCount(unknown, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
e2e/tests/web/krit09-abmeldung.spec.ts
Normal file
63
e2e/tests/web/krit09-abmeldung.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
|
||||||
|
import { extractTokenHash, waitForMail } from '../../support/mailpit';
|
||||||
|
import { enableSemantics, expectSignedIn, expectSignedInViaLoginRedirect, login, readSession, shiftBrowserClock } from '../../support/web';
|
||||||
|
|
||||||
|
const gotrue = new GoTrueClient();
|
||||||
|
|
||||||
|
test.describe('KTO-01 Kriterium 9 (web): Passwort-Reset beendet andere Sitzungen', () => {
|
||||||
|
test('KTO-01 Kriterium 9: zweiter Browser-Kontext ist nach dem Reset und dem naechsten (erzwungenen) Refresh abgemeldet', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const oldPassword = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, oldPassword);
|
||||||
|
|
||||||
|
// Sitzung B: ein zweites Geraet in eigenem Kontext.
|
||||||
|
await login(page, email, oldPassword);
|
||||||
|
await expectSignedIn(page);
|
||||||
|
|
||||||
|
// Reset ueber Sitzung A (API): neues Passwort setzen beendet alle anderen Sitzungen.
|
||||||
|
await gotrue.recover(email);
|
||||||
|
const { tokenHash } = extractTokenHash(await waitForMail(email));
|
||||||
|
const recovery = await gotrue.verify(tokenHash, 'recovery');
|
||||||
|
const update = await gotrue.updateUser(recovery.body.access_token, { password: randomPassword() });
|
||||||
|
expect(update.status).toBe(200);
|
||||||
|
|
||||||
|
// Der Access-Token laeuft erst nach jwt_expiry (AUTH-CFG-14, bis 60 min, F1) ab. Der Test
|
||||||
|
// erzwingt den Refresh, indem er die Browser-Uhr um 2 h vorstellt (Session.expiresAt kommt in
|
||||||
|
// supabase_flutter aus dem JWT-Claim `exp`, ein geaendertes `expires_at` im Storage wirkt nicht).
|
||||||
|
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
|
||||||
|
await page.reload();
|
||||||
|
await enableSemantics(page);
|
||||||
|
// Der Refresh scheitert (refresh_token_not_found): supabase_flutter loescht die Sitzung aus dem Storage.
|
||||||
|
await expect.poll(async () => await readSession(page), { timeout: 15_000 }).toBeNull();
|
||||||
|
// Abgemeldet: /login leitet nicht mehr auf die Startseite (Gegenprobe zu Krit. 5).
|
||||||
|
await page.goto('/login');
|
||||||
|
await enableSemantics(page);
|
||||||
|
await page.waitForTimeout(1_000);
|
||||||
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KTO-01 Kriterium 9: Gegenprobe ohne Reset und mit abgelaufenem Access-Token bleibt die Sitzung bestehen (Refresh klappt)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const email = randomEmail();
|
||||||
|
const password = randomPassword();
|
||||||
|
await gotrue.createConfirmedUser(email, password);
|
||||||
|
await login(page, email, password);
|
||||||
|
await expectSignedIn(page);
|
||||||
|
|
||||||
|
await shiftBrowserClock(page, 2 * 60 * 60 * 1000);
|
||||||
|
const before = (await readSession(page))?.access_token;
|
||||||
|
expect(before, 'Sitzung vor dem Reload').toBeTruthy();
|
||||||
|
await page.reload();
|
||||||
|
await expectSignedInViaLoginRedirect(page);
|
||||||
|
// Der Refresh lief wirklich: neues Access-Token im Storage.
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await readSession(page))?.access_token ?? before, { timeout: 15_000 })
|
||||||
|
.not.toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
13
e2e/tsconfig.json
Normal file
13
e2e/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["support", "tests", "playwright.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,12 @@ supabase test db # alle pgTAP-Tests ausführen
|
||||||
- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md).
|
- Auth-Einstellungen (`AUTH-CFG-nn` in `config.toml`), Env-Block für Staging/Produktion, Release-Checkliste und bekannte Grenzen: [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md).
|
||||||
- Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`).
|
- Mailvorlagen (Bestätigung, Passwort-Reset; de/en nach `user_metadata.locale`): `templates/`. Lokal landen Mails in Mailpit (`http://127.0.0.1:54324`).
|
||||||
|
|
||||||
|
## E2E-Tests (Playwright)
|
||||||
|
- `e2e/` testet GoTrue über Kong (`api`), die gebaute Web-App (`web`), die IP-Drosselung (`throttle`) und den SMTP-Ausfall (`spike`). Voraussetzung: `supabase start` mit Mailpit und Postgres (Ports 54321, 54322, 54324).
|
||||||
|
- `e2e/support/db.ts` verbindet sich direkt mit dem lokalen Postgres (Rolle `postgres`) und setzt `recovery_sent_at`/`confirmation_sent_at` für die Ablauftests; es bricht bei nicht-lokalem Host ab.
|
||||||
|
- Der Extra-Container für `throttle`/`spike` startet mit `e2e/support/start-throttle-gotrue.sh`. Details: [`e2e/README.md`](../e2e/README.md).
|
||||||
|
- CI: Job `e2e` in `.forgejo/workflows/ci.yml` (Runner `docker-host`, dind); Ablauf in [`docs/auth-konfiguration.md`](../docs/auth-konfiguration.md) Abschnitt 7.
|
||||||
|
|
||||||
## Regeln
|
## Regeln
|
||||||
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
|
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
|
||||||
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.
|
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue