fix(db): trim all Unicode whitespace in display name, harden templates
All checks were successful
CI / flutter (pull_request) Successful in 1m3s
CI / web-build (pull_request) Successful in 58s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 34s

- add public.trim_ws mirroring Dart String.trim
- require JSON string metadata, tolerate non-string locale in templates
- add tests, align docs with review

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-02 23:48:01 +01:00
parent 47721a48e8
commit fb0f4b9d3f
7 changed files with 119 additions and 22 deletions

View file

@ -42,8 +42,6 @@ Spalte „Wirkung“: **lokal** = wirkt in der CLI über `config.toml`; **Env**
| AUTH-CFG-24 | SMS-Autoconfirm aus | `SMS_AUTOCONFIRM=false` | lokal + Env | `auth.sms.enable_confirmations = true` / `GOTRUE_SMS_AUTOCONFIRM=false`; in der Self-Hosting-Vorlage `ENABLE_PHONE_AUTOCONFIRM=false` | SMS ist nicht im Einsatz, die Einstellung steuert aber den Enumerationsschutz beim E-Mail-Signup. Mit `true` (CLI-Standard und Vorlage `docker/.env.example`!) liefert Signup mit bestätigter Adresse 422 `user_already_exists` | 4 |
| AUTH-CFG-25 | Passwortänderung ohne erneute Anmeldung | `false` (Standard, ausdrücklich gesetzt) | lokal + Env | `auth.email.secure_password_change` / `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` (Env-Name vor Release gegen das Image prüfen) | Recovery-Sitzungen sind ausgenommen; ob das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13) | (KTO-11) |
AUTH-CFG-25: `auth.email.secure_password_change = false` (Standard, ausdrücklich gesetzt). Recovery-Sitzungen sind davon ausgenommen; ob für die Passwortänderung das aktuelle Passwort verlangt wird, entscheidet KTO-11 (N13).
## 2. Env-Block für Staging und Produktion (GoTrue, ohne Secrets)
Gilt für das Image `supabase/gotrue:v2.197.0` (pinnen, nicht `latest`). Secrets (JWT-Secret, SMTP-Passwort, DB-URL) stehen nicht hier, sondern im Secret-Speicher des Hosting-Setups.
@ -64,6 +62,12 @@ GOTRUE_PASSWORD_MIN_LENGTH=10
GOTRUE_PASSWORD_REQUIRED_CHARACTERS=
GOTRUE_PASSWORD_HIBP_ENABLED=false
# AUTH-CFG-23 (Stagingwert; in #1 nicht festgelegt, CLI-Standard true, Entscheidung in KTO-11)
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true
# AUTH-CFG-25 (Env-Name vor Release gegen das Image prüfen)
GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=false
# AUTH-CFG-07, -08
GOTRUE_MAILER_OTP_EXP=3600
GOTRUE_SMTP_MAX_FREQUENCY=60s
@ -83,8 +87,8 @@ GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=10
# AUTH-CFG-17, -18 (Betreff je Sprache verzweigt, Text identisch zu config.toml; URLs siehe Abschnitt 3)
GOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://<vorlagen-host>/confirmation.html
GOTRUE_MAILER_TEMPLATES_RECOVERY=https://<vorlagen-host>/recovery.html
GOTRUE_MAILER_SUBJECTS_CONFIRMATION={{ if and .Data (eq (index .Data "locale") "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}
GOTRUE_MAILER_SUBJECTS_RECOVERY={{ if and .Data (eq (index .Data "locale") "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}
GOTRUE_MAILER_SUBJECTS_CONFIRMATION={{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}
GOTRUE_MAILER_SUBJECTS_RECOVERY={{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}
# AUTH-CFG-19 (Benachrichtigungen aus; KTO-11 schaltet email_changed ein)
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED=false
@ -115,7 +119,7 @@ Getestet gegen CLI 3.0.0 und GoTrue `v2.197.0` über Kong (wie die App). Die Dro
| Punkt | Ergebnis |
|---|---|
| (a) Betreff und Vorlage werten `.Data.locale` aus | Bestätigt. `de` → deutsch; `en` oder fehlend → englisch. `index .Data "locale"` ist sicher, wenn der Schlüssel fehlt. Nach Profiländerung (Sync-Trigger) kommt die Mail in der neuen Sprache. |
| (a) Betreff und Vorlage werten `.Data.locale` aus | Bestätigt. `de` → deutsch; `en` oder fehlend → englisch. `printf "%v" (index .Data "locale")` ist sicher, wenn der Schlüssel fehlt oder kein String ist (sonst HTTP 500 bei `eq`). Nach Profiländerung (Sync-Trigger) kommt die Mail in der neuen Sprache. |
| (b) `type` für `token_hash` | Bestätigung `email` (auch `signup`), Reset `recovery`. Zweites Einlösen → 403 `otp_expired`. |
| (c) Limiter Passwort-Login | `GOTRUE_RATE_LIMIT_TOKEN_REFRESH` (150 je 5 min, Burst 30); Signup/Recover/Resend `GOTRUE_RATE_LIMIT_OTP` (30 je 5 min). **Greifen nur mit `GOTRUE_RATE_LIMIT_HEADER` und Requests mit dem Header.** Die lokale CLI kann den Header nicht setzen, also lokal keine Drosselung. 429 ohne `Retry-After`. |
| (d) `email_sent` | Instanzweit. In der CLI wirkungslos (`360000`). Bei erschöpftem Limit antwortet `/recover` für bestehende Adressen 429, für unbekannte 200 (Seitenkanal, akzeptiert F12). |

View file

@ -490,4 +490,4 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
10. **9.2:** F11 (Q1, CI-Drosselungstest mit Extra-Container) und F12 (Q2) ergänzt; 9.3 aktualisiert; 9.4 um zwei KTO-11-Hinweise aus dem Spike ergänzt.
11. **F12 entschieden (Chris, 2026-11-02):** Neuer Domänenfehler `AuthFailure.emailSendLimited` für `over_email_send_rate_limit` bei `signUp` und `resetPasswordForEmail`; `RegisterController` und `ForgotPasswordController` behandeln ihn wie Erfolg → neutraler CheckEmailScreen (4.3, 4.4, 5.4). CheckEmailScreen zeigt immer den Zusatz „Die E-Mail kann einige Minuten dauern …“, weil Nutzer eine Verzögerung sonst nicht erfahren. 7.1 Krit. 4 um Unit- und Widget-Tests ergänzt. R3/R6: Kanal in der App verdeckt, auf API-Ebene akzeptiert. Die Aussage „Krit. 4 gilt während einer Mail-Sperre nicht in der App“ ist entfallen.
12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst.
13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). In B2 noch nachzuziehen: `secure_password_change` dort „ohne eigene ID“ → AUTH-CFG-25.
13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt).

View file

@ -275,13 +275,13 @@ otp_expiry = 3600
# Betreff und Text verzweigen gleich (Spike B0 (a)). Staging/Prod: Vorlagen per URL
# (GOTRUE_MAILER_TEMPLATES_CONFIRMATION, GOTRUE_MAILER_SUBJECTS_CONFIRMATION).
[auth.email.template.confirmation]
subject = '{{ if and .Data (eq (index .Data "locale") "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}'
subject = '{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Bestätige deine E-Mail-Adresse für Shuffle{{ else }}Confirm your email address for Shuffle{{ end }}'
content_path = "./supabase/templates/confirmation.html"
# AUTH-CFG-18 (wirkt lokal): Mailvorlage Passwort zurücksetzen, wie oben.
# Staging/Prod: GOTRUE_MAILER_TEMPLATES_RECOVERY, GOTRUE_MAILER_SUBJECTS_RECOVERY.
[auth.email.template.recovery]
subject = '{{ if and .Data (eq (index .Data "locale") "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}'
subject = '{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}Passwort für Shuffle zurücksetzen{{ else }}Reset your Shuffle password{{ end }}'
content_path = "./supabase/templates/recovery.html"
# AUTH-CFG-19 (wirkt lokal): Benachrichtigungs-Mails explizit aus (KTO-01 Kriterium 11, nur Bestätigung

View file

@ -2,19 +2,43 @@
-- 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'.
-- nach trim_ws (Dart-kompatibel). 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).
-- Trim wie Dart String.trim(): btrim() entfernt nur U+0020, Tab, Zeilenumbruch, NBSP usw. blieben
-- stehen. Explizite Liste (Unicode White_Space + BOM, identisch zur Dart-Doku):
-- U+0009-000D, 0020, 0085, 00A0, 1680, 2000-200A, 2028, 2029, 202F, 205F, 3000, FEFF.
-- Bewusst kein [[:space:]] (locale-abhängig). U+200B (Zero Width Space) ist kein Leerraum und
-- bleibt erlaubt, wie in Dart.
-- immutable + strict (NULL -> NULL), search_path leer, kein security definer. Execute bleibt für
-- public: Der Check-Constraint unten wird mit den Rechten der schreibenden Rolle ausgewertet
-- (authenticated beim Profil-Update); die Funktion ist rein und greift auf keine Daten zu.
create or replace function public.trim_ws(value text)
returns text
language sql
immutable
strict
parallel safe
set search_path = ''
as $$
select pg_catalog.regexp_replace(
value,
'^[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+'
|| '|' ||
'[\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+$',
'',
'g'
);
$$;
-- 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.
-- Der Constraint wird direkt (ohne NOT VALID) angelegt: Er prüft sofort den Bestand und bricht die
-- Migration ab, falls ein vorhandener (Entwicklungs-)Name nur aus Leerraum 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;
check (char_length(public.trim_ws(display_name)) between 1 and 30);
-- 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
@ -38,8 +62,10 @@ 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');
name text := case when jsonb_typeof(meta -> 'display_name') = 'string'
then public.trim_ws(meta ->> 'display_name') end;
version text := case when jsonb_typeof(meta -> 'terms_version') = 'string'
then public.trim_ws(meta ->> 'terms_version') end;
begin
if coalesce(meta ->> 'age_confirmed', '') <> 'true' then
raise exception 'age_confirmation_required';

View file

@ -3,13 +3,15 @@
AUTH-CFG-17: Bestätigungs-Mail (KTO-01 Kriterium 1, 11).
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
Das Token (TokenHash) wird bewusst nicht als Klartext angezeigt, nur der Link.
Gültigkeit "1 Stunde" im Text entspricht AUTH-CFG-07 (GOTRUE_MAILER_OTP_EXP=3600); bei Änderung hier anpassen.
Der Link (inkl. TokenHash) ist sichtbar, damit er kopiert werden kann; der Code {{ .Token }} wird bewusst nicht angezeigt.
*/ -}}
{{ if and .Data (eq (index .Data "locale") "de") }}
{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Bestätige deine E-Mail-Adresse für Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">
@ -31,6 +33,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Confirm your email address for Shuffle</title>
</head>
<body style="margin:0;padding:24px;background-color:#f4f4f5;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:#1a1a1a;">

View file

@ -4,7 +4,7 @@
Sprache aus user_metadata.locale ("de"), sonst Englisch. Keine externen Ressourcen, keine Bilder.
Der Link führt auf die App-Seite /auth/confirm; erst der Klick dort löst die Prüfung aus (Spec E3).
*/ -}}
{{ if and .Data (eq (index .Data "locale") "de") }}
{{ if and .Data (eq (printf "%v" (index .Data "locale")) "de") }}
<html lang="de">
<head>
<meta charset="utf-8">

View file

@ -1,7 +1,7 @@
-- KTO-01 Registrierung (Datenbankseite): Regeln des Triggers handle_new_user, Anzeigename,
-- Nachweis der Zustimmung (terms_version), Sprache und Mailsprache-Sync.
begin;
select plan(46);
select plan(60);
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 1: gültige Registrierung legt ein Profil an
@ -22,6 +22,17 @@ select ok((select terms_accepted_at is not null and age_confirmed_at is not null
from public.profiles where id = '11111111-1111-1111-1111-111111111111'),
'KTO-01 Kriterium 1: Zeitpunkte von Zustimmung und Altersbestätigung sind gesetzt');
-- Der Client sendet JSON-Booleans (kein String): beide Formen müssen gelten.
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('88888888-8888-8888-8888-888888888888', 'bool@example.com',
'{"age_confirmed":true,"terms_accepted":true,"terms_version":"v1","display_name":"Bool"}')$$,
'KTO-01 Kriterium 1: JSON-Booleans true werden angenommen');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'k2c@example.com',
'{"age_confirmed":false,"terms_accepted":true,"terms_version":"v1","display_name":"X"}')$$,
'P0001', 'age_confirmation_required', 'KTO-01 Kriterium 2: JSON-Boolean age_confirmed=false wird abgelehnt');
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 2: Mindestalter muss bestätigt sein
-- ---------------------------------------------------------------------------------------------
@ -61,6 +72,14 @@ 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 throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv6@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"\u00a0","display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version nur aus NBSP wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'tv7@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":5,"display_name":"X"}')$$,
'P0001', 'terms_version_required', 'QUE-04 Nachweis: terms_version als Zahl 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',
@ -96,7 +115,23 @@ 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,
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n9@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\t"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Tab wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n10@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\n"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus Zeilenumbruch wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n11@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"\u00a0"}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename nur aus NBSP wird abgelehnt');
select throws_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values (gen_random_uuid(), 'n12@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":5}')$$,
'P0001', 'display_name_invalid', 'KTO-01 Kriterium 10: Anzeigename als Zahl wird 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
@ -111,11 +146,25 @@ select lives_ok(
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');
select lives_ok(
$$insert into auth.users (id, email, raw_user_meta_data) values
('99999999-9999-9999-9999-999999999999', 'n13@example.com',
'{"age_confirmed":"true","terms_accepted":"true","terms_version":"v1","display_name":"Anna\t"}')$$,
'KTO-01 Kriterium 10: Anzeigename mit Tab am Ende wird angenommen');
select is((select display_name from public.profiles where id = '99999999-9999-9999-9999-999999999999'),
'Anna', 'KTO-01 Kriterium 10: Tab wird entfernt, gespeichert wird Anna');
-- 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 ok(exists (select 1 from pg_constraint
where conrelid = 'public.profiles'::regclass and conname = 'profiles_display_name_not_blank'
and contype = 'c' and convalidated),
'KTO-01 Kriterium 10: Constraint profiles_display_name_not_blank existiert und ist validiert');
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)');
select throws_ok(
$$update public.profiles set display_name = chr(160) where id = '11111111-1111-1111-1111-111111111111'$$,
'23514', null, 'KTO-02 Kriterium 1: Constraint lehnt Anzeigename nur aus NBSP ab (Update)');
-- ---------------------------------------------------------------------------------------------
-- KTO-01 Kriterium 11: Sprache
@ -179,5 +228,20 @@ select is((select raw_user_meta_data ->> 'display_name' from auth.users where id
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');
-- anon darf die Trigger-Funktionen nicht ausführen
set local role anon;
select throws_ok($$select public.handle_new_user()$$,
'42501', null, 'KTO-01 Sicherheit: anon darf handle_new_user nicht ausführen');
select throws_ok($$select public.sync_profile_locale_to_auth()$$,
'42501', null, 'KTO-01 Sicherheit: anon darf sync_profile_locale_to_auth nicht ausführen');
reset role;
-- Update auf denselben Wert löst keinen Sync aus (Cara: locale en, Marker in den Metadaten)
update auth.users set raw_user_meta_data = raw_user_meta_data || '{"locale":"xx"}'
where id = '33333333-3333-3333-3333-333333333333';
update public.profiles set locale = 'en' where id = '33333333-3333-3333-3333-333333333333';
select is((select raw_user_meta_data ->> 'locale' from auth.users where id = '33333333-3333-3333-3333-333333333333'),
'xx', 'KTO-01 Kriterium 11: Update auf denselben Wert löst den Sync nicht aus');
select * from finish();
rollback;