feat(db): enforce registration rules and add terms_version
Adds display name validation, terms_version, locale sync trigger and pgTAP tests (KTO-01 criteria 1, 2, 10, 11). Includes GoTrue spike results. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
450af82234
commit
395816609f
9 changed files with 447 additions and 20 deletions
|
|
@ -34,7 +34,7 @@ R1 braucht 17 Tabellen in vier Bereichen. Jede Tabelle hat einen eigenen Primär
|
|||
|
||||
| Tabelle | Spalten | Zweck |
|
||||
| --- | --- | --- |
|
||||
| `profiles` | `id` (PK, zugleich FK auf `auth.users.id`), `display_name` (max. 30), `bio` (max. 300), `city`, `city_visible`, `avatar_path`, `locale`, `terms_accepted_at`, `age_confirmed_at`, `deleted_at` | Ein Profil je Konto; wird beim Registrieren automatisch angelegt |
|
||||
| `profiles` | `id` (PK, zugleich FK auf `auth.users.id`), `display_name` (1–30 Zeichen nach Trim), `bio` (max. 300), `city`, `city_visible`, `avatar_path`, `locale`, `terms_accepted_at`, `terms_version`, `age_confirmed_at`, `deleted_at` | Ein Profil je Konto; wird beim Registrieren automatisch angelegt. Regeln der Registrierung (Anzeigename, Zustimmung, Sprache): siehe unten |
|
||||
| `favorite_games` | `profile_id` (FK → `profiles`), `game_id` (FK → `games`), `position` (1–5) | Lieblingsspiele, höchstens 5 je Nutzer |
|
||||
| `bgg_links` | `profile_id` (FK → `profiles`, eindeutig), `bgg_username`, `last_import_at` | Verknüpfter BGG-Name für den Import |
|
||||
|
||||
|
|
@ -85,6 +85,22 @@ Dazu kommen diese Regeln:
|
|||
- **Konto löschen (DSGVO):** Löscht Profil, Sammlung, Wertungsbögen und Avatar. Partien bleiben für andere Mitspieler erhalten, der gelöschte Nutzer erscheint dort als „Gelöschter Nutzer“. Entschieden am 2. November 2026.
|
||||
- **Admin-Rolle:** Nur für Katalogpflege, serverseitig, nie aus dem Client heraus.
|
||||
|
||||
### Regeln bei der Registrierung (Migration `auth_registration_rules`, KTO-01)
|
||||
|
||||
Der Trigger `handle_new_user` legt das Profil an und lehnt die Registrierung mit einem Fehlertext (SQLSTATE `P0001`) ab, wenn eine Regel verletzt ist. Es gibt keinen stillen Ersatzwert.
|
||||
|
||||
| Regel | Fehlertext |
|
||||
| --- | --- |
|
||||
| Mindestalter (`age_confirmed = true`) | `age_confirmation_required` |
|
||||
| Zustimmung (`terms_accepted = true`) | `terms_acceptance_required` |
|
||||
| `terms_version` vorhanden, nach Trim 1–50 Zeichen (Nachweis der zugestimmten Rechtstexte) | `terms_version_required` |
|
||||
| `display_name` vorhanden, nach Trim 1–30 Unicode-Zeichen (Codepoints); kein E-Mail-Präfix als Ersatz, kein Kürzen | `display_name_invalid` |
|
||||
|
||||
- `terms_accepted_at`, `age_confirmed_at` setzt der Trigger auf `now()`. `terms_version` und beide Zeitstempel kann der Nutzer nicht ändern (Spalten-Grants).
|
||||
- `locale`: nur `de`/`en`, sonst und als Spalten-Standard `'en'`.
|
||||
- Check `profiles_display_name_not_blank` (`char_length(btrim(display_name)) between 1 and 30`) gilt auch für spätere Änderungen (KTO-02).
|
||||
- Trigger `sync_locale_to_auth`: Ändert sich `profiles.locale`, schreibt `sync_profile_locale_to_auth()` (security definer, leerer `search_path`) den Wert in `auth.users.raw_user_meta_data.locale`. GoTrue-Mailvorlagen lesen die Sprache von dort. Betroffen ist nur das eigene Konto.
|
||||
|
||||
## Dateien, Import, Offline, Statistik
|
||||
|
||||
- **Dateien:** Avatare (max. 5 MB) und Spielbilder liegen im Storage in einem privaten Bucket je Zweck. Die App greift über eine eigene Zugriffsschicht darauf zu, damit der Speicher später auf Hetzner Object Storage wechseln kann (ADR-0002). In der Datenbank steht nur der Pfad.
|
||||
|
|
@ -124,8 +140,9 @@ Diese Struktur ist aus den fertigen Migrationen in `supabase/migrations/` ausgel
|
|||
| `city` | text | nein | | |
|
||||
| `city_visible` | boolean | ja | `true` | |
|
||||
| `avatar_path` | text | nein | | |
|
||||
| `locale` | text | ja | `'de'` | |
|
||||
| `locale` | text | ja | `'en'` | |
|
||||
| `terms_accepted_at` | timestamptz | ja | | |
|
||||
| `terms_version` | text | nein (Altdaten) | | |
|
||||
| `age_confirmed_at` | timestamptz | ja | | |
|
||||
| `deleted_at` | timestamptz | nein | | |
|
||||
| `created_at` | timestamptz | ja | `now()` | |
|
||||
|
|
@ -307,8 +324,9 @@ Zwei Ansichten (Views) kommen dazu: `public_profiles` zeigt Mitspielern Name, Av
|
|||
| `city` | text | nein | | |
|
||||
| `city_visible` | boolean | ja | `true` | |
|
||||
| `avatar_path` | text | nein | | |
|
||||
| `locale` | text | ja | `'de'::text` | |
|
||||
| `locale` | text | ja | `'en'::text` | |
|
||||
| `terms_accepted_at` | timestamptz | ja | | |
|
||||
| `terms_version` | text | nein (Altdaten) | | |
|
||||
| `age_confirmed_at` | timestamptz | ja | | |
|
||||
| `deleted_at` | timestamptz | nein | | |
|
||||
| `created_at` | timestamptz | ja | `now()` | |
|
||||
|
|
|
|||
129
docs/specs/1-auth-spike-ergebnisse.md
Normal file
129
docs/specs/1-auth-spike-ergebnisse.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Spike B0 – Ergebnisse zu GoTrue (Spec #1, KTO-01)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Issue | #1 (KTO-01), Aufgabe B0 |
|
||||
| Spec | `docs/specs/1-auth-registrierung-anmeldung.md` (§ 4.1, 4.3, 5.4, 7.1, 8.2, 9.1) |
|
||||
| Datum | 2026-11-02 |
|
||||
| Autor | backend-dev |
|
||||
| Versionen | Supabase CLI 3.0.0, GoTrue `v2.197.0` (Image `public.ecr.aws/supabase/gotrue:v2.197.0`), Postgres 17.11, Mailpit v1.31.3 |
|
||||
|
||||
## Vorgehen und Grenzen der Aussagekraft
|
||||
|
||||
- Die lokale CLI wurde mit einer **temporären** `config.toml` gestartet: `enable_confirmations = true`, `minimum_password_length = 10`, `email_sent = 100`, `max_frequency = "1s"`, Vorlagen für Bestätigung und Reset mit `.Data.locale`-Verzweigung (Vorlagen und Änderungen sind wieder entfernt; `config.toml` im Repo ist unverändert, das ist Aufgabe B2).
|
||||
- Alle Requests liefen gegen `http://127.0.0.1:54321/auth/v1` (Kong, wie die App). Die Drosselungstests (c, d) liefen zusätzlich gegen einen **zweiten GoTrue-Container** desselben Images im Netz der CLI, mit den Umgebungsvariablen des CLI-Containers plus `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und `GOTRUE_RATE_LIMIT_EMAIL_SENT=5`. Das entspricht dem Self-Hosting-Pfad (Env statt `config.toml`).
|
||||
- **Korrektur während des Spikes:** Die erste Messung zu Krit. 4 zeigte 422 `user_already_exists`; die Ursache war `GOTRUE_SMS_AUTOCONFIRM=true` (CLI-Standard), nicht GoTrue selbst. Ein Wiederholungstest mit `[auth.sms] enable_confirmations = true` bestätigt das Verhalten der Spec (siehe A1).
|
||||
- **Nicht aussagekräftig lokal:** SMTP-Anbieter, Verhalten hinter dem echten Produktions-Gateway, Header-Behandlung durch den Proxy, Mailzustellung. Die Mail-Rate-Limits der CLI gelten nicht (siehe d).
|
||||
- Die Versionsprüfung (h) basiert auf der `docker-compose.yml` im Supabase-Repository (Stand heute, Branch `master`); eine eigene Self-Hosting-Instanz gibt es noch nicht.
|
||||
|
||||
## Kurzfassung: Abweichungen von der Spec
|
||||
|
||||
| # | Abweichung | Wirkung |
|
||||
|---|---|---|
|
||||
| **A1** | **Der Enumerationsschutz (Krit. 4) hängt an einer weiteren Einstellung: `GOTRUE_SMS_AUTOCONFIRM` muss `false` sein.** Die CLI-Vorlage setzt `[auth.sms] enable_confirmations = false` und damit `GOTRUE_SMS_AUTOCONFIRM=true`. Dann liefert Signup mit einer *bestätigten* Adresse **422 `user_already_exists`**. Mit `[auth.sms] enable_confirmations = true` (`GOTRUE_SMS_AUTOCONFIRM=false`) kommt wie in der Spec **200 mit erfundenem Nutzer** (zufällige ID, `identities` leer, keine Mail, Log `user_repeated_signup`). | Neue Zeile AUTH-CFG-24 für B2/B3 (`auth.sms.enable_confirmations = true`, Env `GOTRUE_SMS_AUTOCONFIRM=false`). Die Selbsthosting-Vorlage `docker/.env.example` setzt `ENABLE_PHONE_AUTOCONFIRM=true`, die Falle gilt dort also auch. Die Spec-Annahme 4.3 stimmt dann. |
|
||||
| **A2** | **Ohne `GOTRUE_RATE_LIMIT_HEADER` gibt es gar keine IP-Drosselung** (weder Login noch Signup noch Refresh). Auch mit gesetzter Konfiguration wird ein Request **ohne** diesen Header nicht gedrosselt (60 Fehllogins ohne `X-Forwarded-For` gegen den Container mit Header-Konfiguration: kein 429). Die lokale CLI kann den Header nicht setzen. | AUTH-CFG-13 ist Pflicht, nicht optional. GoTrue darf nur über den Proxy erreichbar sein, der den Header setzt (sonst umgeht ein direkter Zugriff die Drosselung). Drosselungstests (Krit. 7) sind gegen die CLI nicht möglich (Frage Q1). Betrifft B2, B3, D1, D2, T3. |
|
||||
| **A3** | `email_sent` wirkt in der lokalen CLI nicht (`GOTRUE_RATE_LIMIT_EMAIL_SENT=360000`, obwohl `email_sent = 100` gesetzt war). Wirkt im Self-Hosting-Env und ist dort **instanzweit**. | AUTH-CFG-09 lokal nicht testbar; Seiteneffekt auf Enumeration (siehe d). |
|
||||
| **A4** | Trigger-Fehler kommen als **HTTP 500 mit der Meldung des Triggers** im Body (`{"code":"P0001","message":"display_name_invalid"}`), nicht als generisches „Database error saving new user“. | Der Client kann den Grund unterscheiden. Verhalten ist versionsabhängig, siehe (f). |
|
||||
| **A5** | Das JWT einer Recovery-Sitzung trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach Bestätigungslink. Es ist also nicht als Recovery erkennbar. | Der `amr`-Weg aus 5.4 entfällt; es gilt R12 (Pfad „Wenn nein“). |
|
||||
| **A6** | Für den Linkablauf zählt `auth.users.confirmation_sent_at` bzw. `recovery_sent_at`, **nicht** `auth.one_time_tokens.created_at`. | T3 setzt die Spalten an `auth.users` zurück. |
|
||||
| **A7** | `/otp` und `/magiclink` lassen sich nicht abschalten, und ein neues Konto per `/otp` ist möglich, **wenn** der Aufrufer gültige Metadaten (`data`) mitschickt. Ohne Metadaten blockiert der Trigger (500). | AUTH-CFG-20 (Gateway-Sperre) bleibt nötig; der Trigger ersetzt sie nicht. |
|
||||
|
||||
Alle übrigen Annahmen der Spec wurden bestätigt (Details unten).
|
||||
|
||||
## Ergebnis je Punkt
|
||||
|
||||
### (a) Betreffzeile und Vorlage werten `.Data.locale` aus
|
||||
|
||||
- **Annahme:** Vorlage und Betreff lassen sich mit `{{ if and .Data (eq (index .Data "locale") "de") }}` verzweigen; sonst Englisch.
|
||||
- **Test:** Signup mit `locale` de, en und ohne `locale`; Mails in Mailpit gelesen. Danach `profiles.locale` auf `en` gesetzt (Sync-Trigger) und Passwort-Reset angefordert.
|
||||
- **Ergebnis: bestätigt.** `de` führte zu deutschem Betreff und Text, `en` und fehlende Angabe zu Englisch. Betreff und Text verzweigen gleich. Nach der Profiländerung auf `en` kam die Reset-Mail englisch (Sync-Trigger wirkt Ende-zu-Ende). `index .Data "locale"` ist sicher, wenn der Schlüssel fehlt (leer, kein Fehler). Die CLI liefert die Vorlagen per interner URL (`GOTRUE_MAILER_TEMPLATES_*=http://…kong:8088/email/…`), wie beim Self-Hosting.
|
||||
- **Konsequenz B2:** Der Rückfall „zweisprachiger Betreff“ ist nicht nötig. `{{ .Data.locale }}` und `index` funktionieren beide; für Else-Zweig `index` verwenden.
|
||||
|
||||
### (b) `type`-Werte für `token_hash`
|
||||
|
||||
- **Test:** `POST /verify` mit `token_hash` aus der Mail.
|
||||
- **Ergebnis: bestätigt.** Bestätigung: `type=email` **und** `type=signup` werden akzeptiert (beide getestet, Sitzung wird ausgestellt, `email_confirmed_at` gesetzt). Reset: `type=recovery`. Ein Token ist nach der Einlösung verbraucht: zweiter Versuch 403 `otp_expired` („Email link is invalid or has expired“).
|
||||
- **Konsequenz B2/A2/A4:** Bestätigungsvorlage mit `type=email`, Reset mit `type=recovery`. Der Client mappt `otp_expired` (403) auf `linkInvalidOrExpired`, auch für „schon benutzt“.
|
||||
|
||||
### (c) Limiter beim Passwort-Login (AUTH-CFG-10)
|
||||
|
||||
- **Annahme:** Passwort-Login nutzt den Token-Limiter (150 je 5 min je IP, Burst 30).
|
||||
- **Test:** Fehlgeschlagene Logins in Schleife. (1) Gegen die CLI über Kong: 600 Logins, 300 Refresh-Aufrufe, 80 Signups, 80 Recovers in wenigen Sekunden, **kein einziges 429**. (2) Gegen den Spike-Container mit `GOTRUE_RATE_LIMIT_HEADER=X-Forwarded-For` und fester Quell-IP: Login #31 wird mit **429 `over_request_rate_limit`** abgelehnt (Burst 30). Nach 20 s waren wieder 10 Logins möglich (etwa 0,5 pro Sekunde, also 150 je 300 s). Signup: ebenfalls Burst 30, danach 429 (Limiter `RATE_LIMIT_OTP` = 30 je 5 min). Eine zweite Quell-IP war währenddessen nicht betroffen. Die 429-Antwort hat **keinen** `Retry-After`- oder `X-RateLimit-*`-Header, nur `X-Sb-Error-Code: over_request_rate_limit`.
|
||||
- **Ergebnis: Limiter bestätigt, aber mit schwerwiegender Einschränkung (A2).** Passwort-Login nutzt `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` (150 je 5 min, Burst 30); Signup/Recover/Resend nutzen `GOTRUE_RATE_LIMIT_OTP` (30 je 5 min). **Ohne `GOTRUE_RATE_LIMIT_HEADER` greift keiner der Limiter.** Die CLI setzt den Header nicht und bietet keinen Schalter dafür; lokal und im CI (CLI) gibt es deshalb keine Drosselung. Zusätzlich zeigen die Audit-Logs `ip_address: ""`.
|
||||
- **Konsequenz:**
|
||||
- AUTH-CFG-13 ist für Staging/Prod **zwingend** (sonst keine Drosselung von Passwort-Raten, Krit. 7). Der Proxy muss den Header setzen und Client-Werte überschreiben.
|
||||
- T3/D2: Der Drosselungstest (Krit. 7) kann nicht gegen die CLI laufen. Optionen siehe Frage Q1.
|
||||
- Die Tabelle AUTH-CFG-10/11: Env-Namen stimmen (`…_TOKEN_REFRESH`, `…_OTP`). Die Aussage „Signups, Recover, Resend teilen sich diesen Limiter“ ist nur für Signup geprüft; `/recover` wurde bei unbekannten Adressen nicht gedrosselt, weil keine Mail ausgelöst wird (nur das IP-Limit gilt, und das greift ohne Header nicht).
|
||||
|
||||
### (d) `email_sent` instanzweit?
|
||||
|
||||
- **Test:** Spike-Container mit `GOTRUE_RATE_LIMIT_EMAIL_SENT=5`. Sechs Signups mit verschiedenen Adressen und verschiedenen Quell-IPs.
|
||||
- **Ergebnis: bestätigt, instanzweit.** Signup 1–5 ok, ab Signup 6 429 `over_email_send_rate_limit` („email rate limit exceeded“) für alle IPs. Das gilt auch für `/recover` an bestehende Adressen. In der **lokalen CLI** wirkt `email_sent` nicht: der CLI-Container läuft mit `GOTRUE_RATE_LIMIT_EMAIL_SENT=360000`, obwohl `config.toml` 100 enthielt (die CLI-Doku nennt „Requires auth.email.smtp to be enabled“). Der Standard der CLI-Vorlage (`email_sent = 2`) greift also lokal ebenfalls nicht.
|
||||
- **Neue Nebenwirkung auf Krit. 4:** Ist das Mail-Limit erschöpft, antwortet `/recover` für eine **bestehende** Adresse mit 429, für eine unbekannte Adresse mit 200 (es wird keine Mail ausgelöst). Das ist ein Enumerations-Seitenkanal während der Sperre. Mögliche Gegenmaßnahmen: Limit höher setzen oder ein Proxy vor GoTrue. Siehe Frage Q2.
|
||||
- **Konsequenz B2/B3:** AUTH-CFG-09 nur im Env-Block wirksam; in `config.toml` den Wert dokumentieren, aber als „lokal ohne Wirkung“ kennzeichnen. R3 bleibt bestehen.
|
||||
|
||||
### (e) Mailfänger
|
||||
|
||||
- **Ergebnis:** **Mailpit** v1.31.3 (Container `supabase_inbucket_shuffle`, Name historisch). Web-Oberfläche und API: `http://127.0.0.1:54324`. Relevante Endpunkte: `GET /api/v1/messages` (Liste mit `To[].Address`, `Subject`, `ID`), `GET /api/v1/message/{id}` (`HTML`, `Text`), `DELETE /api/v1/messages`. SMTP-Port nach außen nur mit `smtp_port` in `[local_smtp]`.
|
||||
- **Konsequenz T3:** Der Mail-Helfer spricht die Mailpit-API. Das Token steht im HTML (und im Text) doppelt; das erste Vorkommen genügt. Mails je Test per `To` filtern (zufällige Adressen), nicht per globalem Löschen, wenn Tests parallel laufen.
|
||||
|
||||
### (f) Trigger-Fehler bei `signUp`
|
||||
|
||||
- **Test:** Signup ohne `display_name`, ohne Altersbestätigung (`age_confirmed:"false"`), ohne `terms_version`.
|
||||
- **Ergebnis: abweichend (A4).** HTTP **500**, Header `X-Sb-Error-Code: unexpected_failure`, Body `{"code":"P0001","message":"display_name_invalid"}` (bzw. `age_confirmation_required`, …). Der Text des Triggers wird also durchgereicht. Im GoTrue-Log steht zusätzlich „current transaction is aborted“. Es entsteht kein Nutzer.
|
||||
- **Konsequenz A1/A2 (Flutter):** `registrationRejected` darf an `AuthException.message` unterscheiden, ist aber nur zum Logging und für Zusatzhinweise gedacht; die Hauptvalidierung bleibt im Client. Das Format ist nicht stabil vertraglich zugesichert (bei GoTrue-Updates erneut prüfen; T3 sichert es mit einem API-Test ab).
|
||||
|
||||
### (g) `weak_password`
|
||||
|
||||
- **Test:** Signup mit 9 Zeichen.
|
||||
- **Ergebnis: bestätigt.** HTTP 422, `error_code: weak_password`, `msg: "Password should be at least 10 characters."`, `weak_password.reasons = ["length"]`. Mit `password_requirements = ""` kommt kein Grund `characters`.
|
||||
- **Konsequenz A1:** Mapping 4.4 stimmt.
|
||||
|
||||
### (h) Versionen CLI und Self-Hosting-Image
|
||||
|
||||
- **Ergebnis: bestätigt.** CLI 3.0.0 startet GoTrue `v2.197.0`. Die `docker-compose.yml` des Supabase-Repos (Branch `master`, heute) verwendet `supabase/gotrue:v2.197.0`. Das Image `public.ecr.aws/supabase/gotrue:v2.197.0` ließ sich mit den Env-Variablen der CLI direkt starten (zusätzlich erforderlich: `API_EXTERNAL_URL`).
|
||||
- **Konsequenz B3/D1:** Das Self-Hosting-Image beim Aufsetzen auf `v2.197.0` pinnen (nicht `latest`) und bei jedem Update die Punkte (c), (d), (f) wiederholen. Die Postgres-Version im Compose (17.6.1.136) weicht von der CLI (17.11.0.011) im Patchstand ab; Hauptversion 17 stimmt mit `config.toml` überein.
|
||||
|
||||
### (i) Welche Spalte begrenzt die Linkgültigkeit?
|
||||
|
||||
- **Test:** Neuen Reset angefordert, nur `auth.users.recovery_sent_at` um 2 h zurückgesetzt, Token eingelöst. Dann neuen Reset, nur `auth.one_time_tokens.created_at` um 2 h zurückgesetzt, Token eingelöst. Für die Bestätigung `confirmation_sent_at` ebenso.
|
||||
- **Ergebnis: bestätigt für `auth.users`, abweichend von der Tabelle.** Zurückgesetztes `recovery_sent_at` (bzw. `confirmation_sent_at`) ergibt 403 `otp_expired`. Zurückgesetztes `one_time_tokens.created_at` allein hat **keine** Wirkung (Einlösung gelang).
|
||||
- **Konsequenz T3:** `e2e/support/db.ts` setzt `auth.users.recovery_sent_at` (Reset) bzw. `confirmation_sent_at` (Bestätigung) auf `now() - (otp_expiry + 1 s)`. Direktzugriff als `postgres` (Port 54322) genügt.
|
||||
|
||||
### (j) `amr` im Recovery-JWT
|
||||
|
||||
- **Test:** Access-Token nach `verify` mit `type=recovery`, nach `verify` mit `type=email` und nach Passwort-Login dekodiert.
|
||||
- **Ergebnis: abweichend (A5).** Recovery: `amr = [{"method":"otp"}]`, `aal1`. Bestätigung: ebenfalls `otp`. Passwort-Login: `password`. Recovery ist von einer Bestätigungssitzung nicht unterscheidbar; nach dem Passwortwechsel in der Sitzung bleibt der Eintrag gleich.
|
||||
- **Konsequenz A4/R12:** Pfad „Wenn nein“ aus 5.4: Der Guard `passwordRecovery` lebt nur im laufenden Prozess (Ereignis `passwordRecovery`). Eine Ableitung aus `amr = otp` würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist nicht zulässig. R12 bleibt als akzeptiertes Restrisiko.
|
||||
|
||||
## Weitere geprüfte Annahmen (4.3 und Tabelle 4.1)
|
||||
|
||||
| Annahme | Test | Ergebnis |
|
||||
|---|---|---|
|
||||
| **Krit. 4, bestätigte Adresse:** 200 mit erfundenem Nutzer, keine Mail | Signup mit bestätigter Adresse, zuerst mit CLI-Standard, dann mit `[auth.sms] enable_confirmations = true` | **Nur mit `GOTRUE_SMS_AUTOCONFIRM=false` bestätigt (A1):** Dann 200, zufällige ID (nicht die des echten Nutzers), `identities` leer, keine Mail, kein neues Konto, das ursprüngliche Passwort bleibt gültig. Mit dem CLI-Standard (`SMS_AUTOCONFIRM=true`) kommt stattdessen 422 `user_already_exists`. |
|
||||
| **Krit. 4, unbestätigte Adresse:** Mail erneut, Passwort und Metadaten unverändert | Signup mit neuem Passwort und neuer `locale` für unbestätigte Adresse | **Bestätigt:** 200 mit echtem Nutzer, Bestätigungsmail erneut, Passwort-Hash und `user_metadata` unverändert, kein zweites Konto. |
|
||||
| Resend / Reset immer 200 | `/resend` und `/recover` mit bestätigter, unbestätigter und unbekannter Adresse | **Bestätigt** (200 `{}`); Mail geht nur an eine existierende, passende Adresse. Einschränkung bei Mail-Limit: siehe (d). |
|
||||
| **Krit. 6/7:** `email_not_confirmed` erst nach richtigem Passwort | Login unbestätigt mit richtigem/falschem Passwort, unbekannte Adresse | **Bestätigt:** richtig → 400 `email_not_confirmed`; falsch und unbekannt → 400 `invalid_credentials`, identische Meldung. |
|
||||
| **Krit. 8:** Reset-Link einmalig | Zweites Einlösen | **Bestätigt** (403 `otp_expired`). |
|
||||
| **Krit. 9:** Reset beendet andere Sitzungen | Sitzungen A, B; Reset über Recovery-Sitzung; Passwort geändert | **Bestätigt:** Refresh-Token von A und B → `refresh_token_not_found`. Der alte Access-Token von A wird von GoTrue (`/user`) mit 403 abgelehnt, **von PostgREST aber weiter akzeptiert (200)**, bis er abläuft. Das ist R8 (bis 60 min), bewusst akzeptiert. |
|
||||
| `same_password` | Reset mit altem Passwort | **Bestätigt:** 422 `same_password`. |
|
||||
| Passwortmindestlänge 10 | Signup mit 9 Zeichen | **Bestätigt** (siehe g). |
|
||||
| Benachrichtigungen aus (Krit. 11) | Passwortänderung beobachtet | **Bestätigt:** Es kommt keine Mail. Der CLI-Standard ist „aus“; in `config.toml` trotzdem explizit setzen. |
|
||||
| Magic Link abschaltbar (AUTH-CFG-20) | `/otp` und `/magiclink` für bestätigten Nutzer; `/otp` für neue Adresse | **Nicht abschaltbar (A7):** Bestätigter Nutzer bekommt Mail „Your sign-in link“. Neue Adresse **ohne** Metadaten: 500 durch den Trigger (`age_confirmation_required`), kein Nutzer. Neue Adresse **mit** gültigen Metadaten: Konto wird angelegt (unbestätigt, mit Passwort-Hash eines Zufallswerts) und eine Bestätigungsmail geht raus. Ein zweiter Aufruf innerhalb von `max_frequency` ergibt 429. |
|
||||
| Zusätzliche Auffälligkeit | – | `GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true` (CLI-Standard) bleibt für KTO-11 offen (AUTH-CFG-23). |
|
||||
|
||||
## Konsequenzen für B2, B3 und die übrigen Aufgaben
|
||||
|
||||
- **B2:** Alle Werte aus 4.1 in `config.toml`. `email_sent` und die Rate-Limit-Einstellungen sind lokal wirkungslos bzw. nur mit Header wirksam; das im Kommentar kenntlich machen. Vorlagen: `type=email` / `type=recovery`, `index .Data "locale"`.
|
||||
- **B3:** Der Env-Block für Self-Hosting muss `GOTRUE_RATE_LIMIT_HEADER` als Pflicht aufführen, plus `GOTRUE_RATE_LIMIT_EMAIL_SENT`, `…_TOKEN_REFRESH`, `…_OTP`, `…_VERIFY`. Release-Checkliste: Drosselung am echten Gateway mit 31 Fehllogins einer IP prüfen; Test, dass ein gefälschter `X-Forwarded-For` überschrieben wird.
|
||||
- **Spec-Anpassungen** (nach Entscheidung von Chris): 4.3 (Krit. 4, Enumeration), 5.4 (`amr`), 7.1 (Krit. 7, 8, 4), 8.2 (B0-Text), R2, R3, R6, R12.
|
||||
- **B2/B3 (neu, A1):** Zeile AUTH-CFG-24 ergänzen: `auth.sms.enable_confirmations = true` bzw. `GOTRUE_SMS_AUTOCONFIRM=false` (SMS ist ausgeschaltet, die Einstellung steuert trotzdem den Enumerationsschutz bei E-Mail-Signup). T3 sichert das mit einem API-Test für Krit. 4 ab (bestätigte Adresse → 200, `identities` leer).
|
||||
- **T3:** Drosselungstests nur gegen einen GoTrue mit gesetztem Header und Requests mit Header (Q1).
|
||||
- **KTO-11-Hinweis:** `user_metadata.locale` kann der Nutzer selbst über `auth.updateUser(data: …)` ändern, die Mailsprache kann so von `profiles.locale` abweichen (Wirkung nur auf das eigene Konto). Der Client ändert die Sprache ausschließlich über `profiles`; der Sync-Trigger zieht `user_metadata` bei der nächsten Profiländerung nach.
|
||||
|
||||
## Offene Fragen an Chris
|
||||
|
||||
| # | Frage | Hintergrund |
|
||||
|---|---|---|
|
||||
| Q1 | **Krit. 7:** Die lokale CLI kann keine IP-Drosselung (kein `RATE_LIMIT_HEADER`). Soll der Drosselungstest im CI gegen einen zusätzlich gestarteten GoTrue-Container mit gesetztem Header laufen (wie in diesem Spike), oder nur als manuelle Prüfung auf Staging? | A2, Aufwand für D2/T3. |
|
||||
| Q2 | Seitenkanal bei erschöpftem Mail-Limit (`/recover` bestehende Adresse 429, unbekannte 200). Akzeptiert (Limit 30/h, Angriff erzeugt vor allem Ausfälle), oder soll das Limit höher gesetzt bzw. durch einen Proxy ergänzt werden? | (d), R3, R6. |
|
||||
|
|
@ -14,6 +14,11 @@ supabase db reset # Migrationen + Seed einspielen
|
|||
supabase test db # alle pgTAP-Tests ausführen
|
||||
```
|
||||
|
||||
## Registrierung (KTO-01)
|
||||
- Der Trigger `handle_new_user` erzwingt Altersbestätigung, Zustimmung, `terms_version` und Anzeigename (1–30 Zeichen). Fehlertexte: siehe `docs/datenmodell-r1.md`. Clients senden diese Werte als `user_metadata` bei `signUp`.
|
||||
- pgTAP-Fixtures, die `auth.users` direkt füllen, brauchen daher `age_confirmed`, `terms_accepted`, `terms_version` und `display_name` in `raw_user_meta_data`.
|
||||
- Ergebnisse der GoTrue-Prüfung: `docs/specs/1-auth-spike-ergebnisse.md`.
|
||||
|
||||
## Regeln
|
||||
- Jede Tabelle hat `id` (uuid, PK), `created_at`, `updated_at` und Row Level Security.
|
||||
- `updated_at` setzt ein Trigger; `id` und `created_at` sind nach dem Anlegen unveränderlich.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
-- KTO-01 Registrierung: strengere Regeln beim Anlegen des Profils (Spec #1, § 3.2).
|
||||
-- Nur additiv: bestehende Migrationen bleiben unverändert.
|
||||
--
|
||||
-- 1. display_name: kein Fallback auf das E-Mail-Präfix, kein stilles Kürzen; Länge 1-30 Codepoints
|
||||
-- nach btrim. Verstoß -> Registrierung scheitert mit 'display_name_invalid'.
|
||||
-- 2. terms_version: Version der zugestimmten Rechtstexte (Nachweis); Pflicht für neue Konten.
|
||||
-- 3. locale: Standard und Trigger-Fallback 'en' (nur 'de'/'en' erlaubt).
|
||||
-- 4. Sync-Trigger: profiles.locale -> auth.users.raw_user_meta_data.locale (Mailsprache).
|
||||
|
||||
-- Anzeigename: Leerraum zählt nicht. char_length zählt Zeichen (Codepoints), nicht Bytes.
|
||||
-- NOT VALID + VALIDATE: der Constraint gilt sofort für neue Zeilen; VALIDATE prüft den Bestand
|
||||
-- (nur Entwicklungsdaten) mit schwächerem Sperrniveau und bricht die Migration ab, falls ein
|
||||
-- Bestandsname nur aus Leerzeichen besteht.
|
||||
alter table public.profiles
|
||||
add constraint profiles_display_name_not_blank
|
||||
check (char_length(btrim(display_name)) between 1 and 30) not valid;
|
||||
alter table public.profiles validate constraint profiles_display_name_not_blank;
|
||||
|
||||
-- Version der Rechtstexte. Nullable nur wegen vorhandener Entwicklungszeilen; für neue Konten
|
||||
-- erzwingt handle_new_user() einen Wert. Die Spalte steht bewusst NICHT im Spalten-Grant
|
||||
-- "update" von 20261102100100_profiles.sql: der Nutzer kann sie nicht ändern.
|
||||
alter table public.profiles
|
||||
add column terms_version text
|
||||
check (terms_version is null or char_length(terms_version) between 1 and 50);
|
||||
|
||||
-- Ohne Angabe gilt Englisch (QUE-05); der Client sendet immer die aktive Sprache.
|
||||
alter table public.profiles alter column locale set default 'en';
|
||||
|
||||
-- Profil beim Registrieren anlegen (ersetzt die Funktion aus 20261102100100_profiles.sql).
|
||||
-- security definer nötig: Der Trigger läuft im Kontext von auth.users (Rolle supabase_auth_admin),
|
||||
-- die Rolle hat keine Rechte auf public.profiles (RLS, keine Insert-Policy für Clients).
|
||||
-- search_path ist leer, alle Objekte sind schema-qualifiziert.
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
meta jsonb := coalesce(new.raw_user_meta_data, '{}'::jsonb);
|
||||
name text := btrim(meta ->> 'display_name');
|
||||
version text := btrim(meta ->> 'terms_version');
|
||||
begin
|
||||
if coalesce(meta ->> 'age_confirmed', '') <> 'true' then
|
||||
raise exception 'age_confirmation_required';
|
||||
end if;
|
||||
if coalesce(meta ->> 'terms_accepted', '') <> 'true' then
|
||||
raise exception 'terms_acceptance_required';
|
||||
end if;
|
||||
if version is null or char_length(version) not between 1 and 50 then
|
||||
raise exception 'terms_version_required';
|
||||
end if;
|
||||
if name is null or char_length(name) not between 1 and 30 then
|
||||
raise exception 'display_name_invalid';
|
||||
end if;
|
||||
|
||||
insert into public.profiles
|
||||
(id, display_name, locale, terms_version, terms_accepted_at, age_confirmed_at)
|
||||
values (
|
||||
new.id,
|
||||
name,
|
||||
case when meta ->> 'locale' in ('de', 'en') then meta ->> 'locale' else 'en' end,
|
||||
version,
|
||||
now(),
|
||||
now()
|
||||
);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Mailsprache folgt dem Profil: GoTrue-Vorlagen sehen nur user_metadata (.Data.locale).
|
||||
-- security definer nötig: Schreiben auf auth.users ist dem Client verwehrt. Der Trigger
|
||||
-- ändert ausschließlich die Zeile des eigenen Nutzers (where id = new.id) und nur den Schlüssel
|
||||
-- 'locale'. search_path ist leer.
|
||||
create or replace function public.sync_profile_locale_to_auth()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
update auth.users
|
||||
set raw_user_meta_data =
|
||||
coalesce(raw_user_meta_data, '{}'::jsonb) || jsonb_build_object('locale', new.locale)
|
||||
where id = new.id;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.sync_profile_locale_to_auth() from public, anon, authenticated;
|
||||
|
||||
create trigger sync_locale_to_auth
|
||||
after update of locale on public.profiles
|
||||
for each row
|
||||
when (old.locale is distinct from new.locale)
|
||||
execute function public.sync_profile_locale_to_auth();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
-- Profile: Registrierung, Zugriff, Schutz der Spalten, Zeitstempel.
|
||||
begin;
|
||||
select plan(16);
|
||||
select plan(15);
|
||||
|
||||
-- Registrierung: ohne Altersbestätigung oder Zustimmung scheitert sie
|
||||
select throws_ok(
|
||||
|
|
@ -14,17 +14,13 @@ select throws_ok(
|
|||
|
||||
insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna","locale":"en"}'),
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna","locale":"en"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true"}'),
|
||||
('33333333-3333-3333-3333-333333333333', 'cara@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","display_name":"123456789012345678901234567890-zu-lang"}');
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
|
||||
|
||||
select is((select count(*) from public.profiles), 3::bigint, 'Profil wird bei der Registrierung angelegt');
|
||||
select is((select count(*) from public.profiles), 2::bigint, 'Profil wird bei der Registrierung angelegt');
|
||||
select is((select display_name from public.profiles where id = '22222222-2222-2222-2222-222222222222'),
|
||||
'ben', 'Ohne Anzeigename dient der Teil vor dem @ der E-Mail');
|
||||
select is((select char_length(display_name) from public.profiles where id = '33333333-3333-3333-3333-333333333333'),
|
||||
30, 'Zu langer Anzeigename wird auf 30 Zeichen gekürzt');
|
||||
'Ben', 'Anzeigename wird aus der Registrierung übernommen (Regeln der Registrierung: 05_auth_registration)');
|
||||
select is((select locale from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'en', 'Sprache aus der Registrierung wird übernommen');
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ delete from public.mechanics;
|
|||
delete from public.categories;
|
||||
|
||||
insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
|
||||
|
||||
insert into public.games (id, title, visibility, bgg_id) values
|
||||
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog', 13);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ begin;
|
|||
select plan(18);
|
||||
|
||||
insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true"}');
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}');
|
||||
|
||||
insert into public.games (id, title, visibility) values
|
||||
('a0000000-0000-0000-0000-000000000001', 'Catan', 'catalog'),
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ begin;
|
|||
select plan(39);
|
||||
|
||||
insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Anna"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Ben"}'),
|
||||
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Cara"}'),
|
||||
('44444444-4444-4444-4444-444444444444', 'dan@example.com', '{"age_confirmed":"true","terms_accepted":"true","display_name":"Dan"}');
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Ben"}'),
|
||||
('33333333-3333-3333-3333-333333333333', 'cara@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Cara"}'),
|
||||
('44444444-4444-4444-4444-444444444444', 'dan@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Dan"}');
|
||||
|
||||
update public.profiles set city = 'Hannover' where id = '11111111-1111-1111-1111-111111111111';
|
||||
update public.profiles set city = 'Bielefeld', city_visible = false where id = '22222222-2222-2222-2222-222222222222';
|
||||
|
|
|
|||
183
supabase/tests/database/05_auth_registration.test.sql
Normal file
183
supabase/tests/database/05_auth_registration.test.sql
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
-- KTO-01 Registrierung (Datenbankseite): Regeln des Triggers handle_new_user, Anzeigename,
|
||||
-- Nachweis der Zustimmung (terms_version), Sprache und Mailsprache-Sync.
|
||||
begin;
|
||||
select plan(46);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- KTO-01 Kriterium 1: gültige Registrierung legt ein Profil an
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select lives_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('11111111-1111-1111-1111-111111111111', 'anna@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"2026-11-draft","display_name":" Anna ","locale":"de"}')$$,
|
||||
'KTO-01 Kriterium 1: gültige Registrierung wird angenommen');
|
||||
select is((select display_name from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'Anna', 'KTO-01 Kriterium 1: Anzeigename wird getrimmt gespeichert');
|
||||
select is((select locale from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'de', 'KTO-01 Kriterium 11: Sprache aus der Registrierung wird übernommen');
|
||||
select is((select terms_version from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'2026-11-draft', 'KTO-01 Kriterium 1: terms_version wird gespeichert (QUE-04 Nachweis)');
|
||||
select ok((select terms_accepted_at is not null and age_confirmed_at is not null
|
||||
and terms_accepted_at = now() and age_confirmed_at = now()
|
||||
from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'KTO-01 Kriterium 1: Zeitpunkte von Zustimmung und Altersbestätigung sind gesetzt');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- KTO-01 Kriterium 2: Mindestalter muss bestätigt sein
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2a@example.com',
|
||||
'{"age_confirmed":"false","terms_accepted":"true","terms_version":"v1","display_name":"X"}')$$,
|
||||
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: age_confirmed=false wird abgelehnt');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2b@example.com',
|
||||
'{"terms_accepted":"true","terms_version":"v1","display_name":"X"}')$$,
|
||||
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: fehlende Altersbestätigung wird abgelehnt');
|
||||
select is((select count(*) from auth.users where email like 'k2%@example.com'), 0::bigint,
|
||||
'KTO-01 Kriterium 2: abgelehnte Registrierung legt kein Konto an');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- QUE-04 Kriterium 1: ausdrückliche Zustimmung
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'q1@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"false","terms_version":"v1","display_name":"X"}')$$,
|
||||
'P0001', 'terms_acceptance_required', 'QUE-04 Kriterium 1: terms_accepted=false wird abgelehnt');
|
||||
|
||||
-- terms_version Pflicht (Nachweis F5)
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv1@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","display_name":"X"}')$$,
|
||||
'P0001', 'terms_version_required', 'QUE-04 Nachweis: fehlende terms_version wird abgelehnt');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv2@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"","display_name":"X"}')$$,
|
||||
'P0001', 'terms_version_required', 'QUE-04 Nachweis: leere terms_version wird abgelehnt');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv3@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":" ","display_name":"X"}')$$,
|
||||
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version nur aus Leerzeichen wird abgelehnt');
|
||||
select throws_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv4@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"%s","display_name":"X"}')$$, repeat('v', 51)),
|
||||
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version mit 51 Zeichen wird abgelehnt');
|
||||
select lives_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('55555555-5555-5555-5555-555555555555', 'tv5@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":" %s ","display_name":"X"}')$$, repeat('v', 50)),
|
||||
'QUE-04 Nachweis: terms_version mit 50 Zeichen (getrimmt) wird angenommen');
|
||||
select is((select char_length(terms_version) from public.profiles where id = '55555555-5555-5555-5555-555555555555'),
|
||||
50, 'QUE-04 Nachweis: terms_version wird getrimmt gespeichert');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- KTO-01 Kriterium 10: Anzeigename 1-30 Zeichen, streng
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n1@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1"}')$$,
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: fehlender Anzeigename wird abgelehnt (kein E-Mail-Präfix)');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n2@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":""}')$$,
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: leerer Anzeigename wird abgelehnt');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n3@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":" "}')$$,
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Leerzeichen wird abgelehnt');
|
||||
select throws_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n4@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":null}')$$,
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename null wird abgelehnt');
|
||||
select throws_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n5@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('a', 31)),
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename mit 31 Zeichen wird abgelehnt (kein Kürzen)');
|
||||
select throws_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n6@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('😀', 31)),
|
||||
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: 31 Emoji (Codepoints) werden abgelehnt');
|
||||
select is((select count(*) from auth.users where email ~ '^n[0-9]@example.com$'), 0::bigint,
|
||||
'KTO-01 Kriterium 10: abgelehnte Registrierungen legen kein Konto an');
|
||||
select lives_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('66666666-6666-6666-6666-666666666666', 'n7@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"%s"}')$$, repeat('😀', 30)),
|
||||
'KTO-01 Kriterium 10: 30 Emoji (30 Codepoints, 120 Bytes) werden angenommen');
|
||||
select lives_ok(
|
||||
format($$insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('77777777-7777-7777-7777-777777777777', 'n8@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":" %s "}')$$, repeat('b', 30)),
|
||||
'KTO-01 Kriterium 10: 30 Zeichen mit umgebenden Leerzeichen werden nach dem Trim angenommen');
|
||||
select is((select display_name from public.profiles where id = '77777777-7777-7777-7777-777777777777'),
|
||||
repeat('b', 30), 'KTO-01 Kriterium 10: der Name wird ohne Leerraum gespeichert');
|
||||
|
||||
-- Check-Constraint (gilt auch für spätere Änderungen, KTO-02)
|
||||
select col_has_check('public', 'profiles', 'display_name', 'KTO-01 Kriterium 10: Anzeigename hat Check-Constraints');
|
||||
select throws_ok(
|
||||
$$update public.profiles set display_name = ' ' where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'23514', null, 'KTO-01 Kriterium 10: Constraint lehnt Anzeigename nur aus Leerzeichen ab (Update)');
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- KTO-01 Kriterium 11: Sprache
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
select lives_ok(
|
||||
$$insert into auth.users (id, email, raw_user_meta_data) values
|
||||
('22222222-2222-2222-2222-222222222222', 'ben@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Ben"}'),
|
||||
('33333333-3333-3333-3333-333333333333', 'cara@example.com',
|
||||
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Cara","locale":"fr"}')$$,
|
||||
'KTO-01 Kriterium 11: Registrierungen ohne und mit ungültiger Sprache werden angenommen');
|
||||
select is((select locale from public.profiles where id = '22222222-2222-2222-2222-222222222222'),
|
||||
'en', 'KTO-01 Kriterium 11: ohne locale gilt Englisch');
|
||||
select is((select locale from public.profiles where id = '33333333-3333-3333-3333-333333333333'),
|
||||
'en', 'KTO-01 Kriterium 11: ungültige locale fällt auf Englisch zurück');
|
||||
select col_default_is('public', 'profiles', 'locale', 'en', 'KTO-01 Kriterium 11: Spalten-Standard ist en');
|
||||
|
||||
-- Sync profiles.locale -> auth.users.raw_user_meta_data.locale
|
||||
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'de', 'KTO-01 Kriterium 11: Ausgangszustand der Mail-Sprache (Anna de)');
|
||||
|
||||
-- Marker setzen: Eine Änderung anderer Spalten darf den Sync nicht auslösen
|
||||
update auth.users set raw_user_meta_data = raw_user_meta_data || '{"locale":"xx"}'
|
||||
where id = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);
|
||||
set local role authenticated;
|
||||
select lives_ok($$update public.profiles set bio = 'nur Bio' where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'KTO-01 Kriterium 11: Anna darf ihre Bio ändern');
|
||||
reset role;
|
||||
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'xx', 'KTO-01 Kriterium 11: Änderung einer anderen Spalte löst den Sync nicht aus');
|
||||
|
||||
set local role authenticated;
|
||||
select lives_ok($$update public.profiles set locale = 'en' where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'KTO-01 Kriterium 11: Anna darf ihre Sprache ändern');
|
||||
select is_empty($$update public.profiles set locale = 'de' where id = '22222222-2222-2222-2222-222222222222' returning 1$$,
|
||||
'KTO-01 Kriterium 11: Anna kann die Sprache von Ben nicht ändern');
|
||||
select throws_ok($$update auth.users set raw_user_meta_data = '{}' where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'42501', null, 'KTO-01 Kriterium 11: Nutzer kann auth.users nicht direkt schreiben');
|
||||
select throws_ok($$select public.sync_profile_locale_to_auth()$$,
|
||||
'42501', null, 'KTO-01 Kriterium 11: Sync-Funktion ist für Nutzer nicht aufrufbar');
|
||||
select throws_ok($$select public.handle_new_user()$$,
|
||||
'42501', null, 'KTO-01 Kriterium 1: handle_new_user ist für Nutzer nicht aufrufbar');
|
||||
|
||||
-- Sicherheit: Zustimmungsnachweis ist für den Nutzer unveränderlich
|
||||
select throws_ok($$update public.profiles set terms_version = 'gefälscht' where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'42501', null, 'KTO-01 Sicherheit: Nutzer kann terms_version nicht ändern');
|
||||
select throws_ok($$update public.profiles set terms_accepted_at = now() where id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
'42501', null, 'KTO-01 Sicherheit: Nutzer kann terms_accepted_at nicht ändern');
|
||||
select is((select terms_version from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'2026-11-draft', 'KTO-01 Sicherheit: Nutzer kann seine terms_version lesen');
|
||||
select is((select count(*) from public.profiles), 1::bigint, 'KTO-01 Sicherheit: Nutzer sieht nur das eigene Profil');
|
||||
reset role;
|
||||
select set_config('request.jwt.claims', '', true);
|
||||
|
||||
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
|
||||
'en', 'KTO-01 Kriterium 11: Profilsprache wird in die Mail-Sprache übernommen');
|
||||
select is((select raw_user_meta_data ->> 'display_name' from auth.users where id = '11111111-1111-1111-1111-111111111111'),
|
||||
' Anna ', 'KTO-01 Kriterium 11: übrige Metadaten bleiben beim Sync erhalten');
|
||||
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '22222222-2222-2222-2222-222222222222'),
|
||||
null, 'KTO-01 Kriterium 11: Metadaten fremder Konten bleiben unverändert');
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
Loading…
Reference in a new issue