-- KAT-05 (PR 2c): Meldungen. Tabelle game_reports (RLS, Checks, Indizes, Fremdschlüssel), -- Trigger games_visibility_immutable, admin_resolve_report, admin_merge_games nach dem -- create or replace (Rechte, Schritt 4), Konto-Löschen nach Freigabe, Fremdschlüssel-Wächter und die -- SQL-Bausteine aus docs/katalog-pflege.md, Abschnitt 6 (unverändert, ohne begin/commit; Auswahl per id). -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs (Präfix 7c1d/f8), gezählt wird nur darüber. -- Regel dieser Datei: Jede Verweigerung steht neben einem Positivfall, sonst würde "alles verweigern" bestehen. begin; select plan(153); 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":"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"}'); -- --------------------------------------------------------------------------------------------- -- Tabellenrechte (KAT-05 Kriterium 9) -- --------------------------------------------------------------------------------------------- select ok((select relrowsecurity from pg_class where oid = 'public.game_reports'::regclass), 'Sicherheit Kriterium 1: game_reports hat Row Level Security'); select ok(has_table_privilege('authenticated', 'public.game_reports', 'select'), 'KAT-05 Kriterium 5: authenticated darf game_reports lesen (eigene Zeilen, Policy)'); -- Formuliert stabil gegenüber späteren Spaltenrechten (#13 ergänzt Insert-Recht und update (result_seen_at)): -- geprüft wird je Spalte, nicht "kein Insert/Update auf der Tabelle". select is_empty($$select c, p from unnest(array['status', 'target_game_id', 'resolved_at', 'origin', 'owner_id', 'bgg_id', 'created_at']) c, unnest(array['insert', 'update']) p where has_column_privilege('authenticated', 'public.game_reports', c, p)$$, 'KAT-05 Kriterium 9: authenticated hat kein Schreibrecht auf status, target_game_id, resolved_at, origin, owner_id, bgg_id, created_at'); select is_empty($$select c, p from (select attname as c from pg_attribute where attrelid = 'public.game_reports'::regclass and attnum > 0 and not attisdropped) a, unnest(array['insert', 'update']) p where has_column_privilege('anon', 'public.game_reports', c, p)$$, 'KAT-05 Kriterium 9: anon hat auf keine Spalte von game_reports ein Schreibrecht'); select is_empty($$select r, p from unnest(array['anon', 'authenticated']) r, unnest(array['delete', 'truncate', 'references', 'trigger']) p where has_table_privilege(r, 'public.game_reports', p)$$, 'KAT-05 Kriterium 9: kein Löschen, Leeren oder Trigger-Recht für anon und authenticated'); select is_empty($$select 1 from pg_policies where tablename = 'game_reports' and cmd in ('DELETE', 'ALL')$$, 'KAT-05 Kriterium 9: game_reports hat keine Delete- oder All-Policy'); select ok(not has_table_privilege('anon', 'public.game_reports', 'select'), 'Sicherheit Kriterium 3: anon darf game_reports nicht einmal lesen'); -- --------------------------------------------------------------------------------------------- -- Fixtures (als postgres) -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility) values ('7c1d2c3d-0000-4000-8000-000000000004', 'Bestehendes Spiel', 'catalog'), ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 'catalog'), ('f8000000-0000-0000-0000-00000000000e', 'Unbeteiligt', 'catalog'); update public.games set year = 2023 where id = 'f8000000-0000-0000-0000-00000000000c'; insert into public.games (id, title, year, bgg_id, visibility) values ('f8000000-0000-0000-0000-00000000000d', 'Anderer Titel', 2020, 4711, 'catalog'); insert into public.game_variants (id, game_id, name) values ('f8000000-0000-0000-0000-0000000000a4', '7c1d2c3d-0000-4000-8000-000000000004', 'Standard'); -- vorläufige private Einträge: P1 (Anna, freigeben), P2 (Anna, zusammenführen), P3 und P4 (Cara, ablehnen) insert into public.games (id, title, year, description, image_path, visibility, owner_id) values ('7c1d2c3d-0000-4000-8000-000000000003', 'Meldungsspiel', 2024, 'Annas privater Text', 'private/anna.png', 'private', '11111111-1111-1111-1111-111111111111'), ('7c1d2c3d-0000-4000-8000-000000000008', 'Bestehendes Spiel', 2019, null, null, 'private', '11111111-1111-1111-1111-111111111111'), ('7c1d2c3d-0000-4000-8000-000000000009', 'Caras Prototyp', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'), ('7c1d2c3d-0000-4000-8000-00000000000a', 'Caras Wunsch', 2025, null, null, 'private', '33333333-3333-3333-3333-333333333333'); insert into public.game_reports (id, owner_id, title, publisher_name, year, link, bgg_id, provisional_game_id, created_at) values ('7c1d2c3d-0000-4000-8000-000000000001', '11111111-1111-1111-1111-111111111111', 'Meldungsspiel', 'Beispielverlag', 2024, 'https://example.org/spiel', 4711, '7c1d2c3d-0000-4000-8000-000000000003', '2026-01-01'), ('7c1d2c3d-0000-4000-8000-000000000005', '11111111-1111-1111-1111-111111111111', 'Bestehendes Spiel', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000008', '2026-01-03'), ('7c1d2c3d-0000-4000-8000-000000000006', '33333333-3333-3333-3333-333333333333', 'Caras Prototyp', null, null, null, null, '7c1d2c3d-0000-4000-8000-000000000009', '2026-01-02'), ('7c1d2c3d-0000-4000-8000-000000000007', '33333333-3333-3333-3333-333333333333', 'Caras Wunsch', null, null, null, null, '7c1d2c3d-0000-4000-8000-00000000000a', '2026-01-04'), ('7c1d2c3d-0000-4000-8000-00000000000b', '22222222-2222-2222-2222-222222222222', 'Bens Meldung ohne Eintrag', null, null, null, null, null, '2026-01-05'); -- Verweise an den vorläufigen Einträgen insert into public.game_variants (id, game_id, name) values ('f8000000-0000-0000-0000-0000000000e1', '7c1d2c3d-0000-4000-8000-000000000003', 'Deluxe'), ('f8000000-0000-0000-0000-0000000000e2', '7c1d2c3d-0000-4000-8000-000000000003', 'Prototyp'), ('f8000000-0000-0000-0000-0000000000e3', '7c1d2c3d-0000-4000-8000-000000000008', ' standard'); insert into public.collection_items (id, owner_id, game_id, variant_id, list, note) values ('f8000000-0000-0000-0000-0000000000c1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e2', 'owned', 'Annas Notiz'), ('f8000000-0000-0000-0000-0000000000c2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000008', 'f8000000-0000-0000-0000-0000000000e3', 'wishlist', 'Annas Wunsch'), ('f8000000-0000-0000-0000-0000000000c3', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000004', null, 'owned', null), ('f8000000-0000-0000-0000-0000000000c4', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', 'owned', 'Bens Notiz'), ('f8000000-0000-0000-0000-0000000000c5', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, 'owned', 'Caras Notiz'), ('f8000000-0000-0000-0000-0000000000c6', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-00000000000a', null, 'wishlist', 'Caras Wunschnotiz'); insert into public.favorite_games (id, profile_id, game_id, position) values ('f8000000-0000-0000-0000-0000000000d1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 1); insert into public.plays (id, owner_id, game_id, variant_id, played_at, client_id) values ('f8000000-0000-0000-0000-0000000000b1', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'f8000000-0000-0000-0000-0000000000e1', '2026-01-01', 'f8000000-0000-0000-0000-0000000000b1'), ('f8000000-0000-0000-0000-0000000000b2', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', null, '2026-01-02', 'f8000000-0000-0000-0000-0000000000b2'), ('f8000000-0000-0000-0000-0000000000b3', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000004', 'f8000000-0000-0000-0000-0000000000a4', '2026-01-03', 'f8000000-0000-0000-0000-0000000000b3'), ('f8000000-0000-0000-0000-0000000000b4', '33333333-3333-3333-3333-333333333333', '7c1d2c3d-0000-4000-8000-000000000009', null, '2026-01-04', 'f8000000-0000-0000-0000-0000000000b4'); insert into public.score_sheets (id, owner_id, game_id, name) values ('f8000000-0000-0000-0000-0000000000b5', '11111111-1111-1111-1111-111111111111', '7c1d2c3d-0000-4000-8000-000000000003', 'Bogen'); -- --------------------------------------------------------------------------------------------- -- Lesen, RLS (Sicherheit Kriterium 1 und 3, KAT-05 Kriterium 5 und 9) -- --------------------------------------------------------------------------------------------- select cmp_ok((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), '>', 0::bigint, 'Vorbedingung: es gibt Meldungen anderer Nutzer'); select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); set local role authenticated; select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 2::bigint, 'Sicherheit Kriterium 3: Anna liest ihre beiden eigenen Meldungen'); select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, 'Sicherheit Kriterium 3: Anna liest keine fremden Meldungen'); select is_empty($$select 1 from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'$$, 'Sicherheit Kriterium 3: Anna findet Caras Meldung auch per ID nicht'); select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', 'Im Namen von Ben')$$, '42501', null, 'KAT-05 Kriterium 9: Anna kann keine Meldung für einen anderen Nutzer anlegen'); select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at, target_game_id) values ('11111111-1111-1111-1111-111111111111', 'Selbst freigegeben', 'approved', now(), '7c1d2c3d-0000-4000-8000-000000000004')$$, '42501', null, 'KAT-05 Kriterium 9: Anna kann keine bereits freigegebene Meldung anlegen'); select throws_ok($$update public.game_reports set target_game_id = '7c1d2c3d-0000-4000-8000-000000000004' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: Anna kann das Ziel ihrer Meldung nicht setzen'); select throws_ok($$update public.game_reports set status = 'approved' where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht selbst freigeben'); select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: Anna kann ihre Meldung nicht löschen'); reset role; select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true); set local role authenticated; select is((select count(*) from public.game_reports where owner_id = '22222222-2222-2222-2222-222222222222'), 1::bigint, 'Sicherheit Kriterium 3: Ben liest seine eigene Meldung'); select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, 'Sicherheit Kriterium 3: Ben liest Annas und Caras Meldungen nicht'); reset role; -- Gefälschte Rolle im user_metadata (vom Client änderbar) öffnet nichts select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","user_metadata":{"role":"admin"}}', true); set local role authenticated; select is((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), 0::bigint, 'Sicherheit Kriterium 3: user_metadata.role = admin öffnet keine fremden Meldungen'); reset role; -- Die Admin-Policy (R4) ist vorbereitet: nur app_metadata (serverseitig gesetzt) öffnet das Lesen, schreiben kann auch der Admin nicht select set_config('request.jwt.claims', '{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated","app_metadata":{"role":"admin"}}', true); set local role authenticated; select cmp_ok((select count(*) from public.game_reports where owner_id <> '22222222-2222-2222-2222-222222222222'), '>=', 4::bigint, 'Admin-Policy für R4: app_metadata.role = admin liest alle Meldungen'); select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Ändern des Status über die Schnittstelle'); select throws_ok($$delete from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: auch mit app_metadata.role = admin kein Löschen über die Schnittstelle'); reset role; set local role anon; select throws_ok($$select count(*) from public.game_reports$$, '42501', null, 'Sicherheit Kriterium 3: anon liest keine Meldungen'); select throws_ok($$insert into public.game_reports (owner_id, title) values ('11111111-1111-1111-1111-111111111111', 'Neu')$$, '42501', null, 'KAT-05 Kriterium 9: anon kann keine Meldung anlegen'); select throws_ok($$update public.game_reports set status = 'rejected', resolved_at = now() where id = '7c1d2c3d-0000-4000-8000-000000000001'$$, '42501', null, 'KAT-05 Kriterium 9: anon kann keine Meldung ändern'); reset role; -- --------------------------------------------------------------------------------------------- -- Checks (jeweils mit dem Namen der verletzten Regel, damit nicht ein anderer Fehler besteht) -- --------------------------------------------------------------------------------------------- select lives_ok($$insert into public.game_reports (id, owner_id, title, link, year, publisher_name) values ('f8000000-0000-0000-0000-000000000a01', '22222222-2222-2222-2222-222222222222', 'Gültig https', 'https://example.org/x', 1800, repeat('v', 200)), ('f8000000-0000-0000-0000-000000000a02', '22222222-2222-2222-2222-222222222222', repeat('t', 200), 'http://example.org/x', 2100, null)$$, 'Gültige Meldungen (https/http, Jahresgrenzen, Längengrenzen) lassen sich anlegen'); select throws_ok($$insert into public.game_reports (owner_id, title, origin) values ('22222222-2222-2222-2222-222222222222', 'X', 'bot')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_origin_check"', 'Check: origin nur user/import'); select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', '')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel nicht leer'); select throws_ok($$insert into public.game_reports (owner_id, title) values ('22222222-2222-2222-2222-222222222222', repeat('t', 201))$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_title_check"', 'Check: Titel höchstens 200 Zeichen'); select throws_ok($$insert into public.game_reports (owner_id, title, publisher_name) values ('22222222-2222-2222-2222-222222222222', 'X', repeat('v', 201))$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_publisher_name_check"', 'Check: Verlag höchstens 200 Zeichen'); select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 1799)$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr mindestens 1800'); select throws_ok($$insert into public.game_reports (owner_id, title, year) values ('22222222-2222-2222-2222-222222222222', 'X', 2101)$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_year_check"', 'Check: Jahr höchstens 2100'); select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'ftp://example.org')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link nur http(s)'); select throws_ok($$insert into public.game_reports (owner_id, title, link) values ('22222222-2222-2222-2222-222222222222', 'X', 'https://' || repeat('a', 493))$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_link_check"', 'Check: Link höchstens 500 Zeichen'); select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'done', now())$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_status_check"', 'Check: Status nur open/approved/rejected/merged'); select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'open', now())$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: offene Meldung hat kein resolved_at'); select throws_ok($$insert into public.game_reports (owner_id, title, status) values ('22222222-2222-2222-2222-222222222222', 'X', 'rejected')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_resolved_matches_status"', 'Check: erledigte Meldung braucht resolved_at'); select throws_ok($$insert into public.game_reports (owner_id, title, target_game_id) values ('22222222-2222-2222-2222-222222222222', 'X', '7c1d2c3d-0000-4000-8000-000000000004')$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_open_has_no_target"', 'Check: offene Meldung hat kein Ziel'); select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'approved', now())$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: approved braucht ein Ziel'); select throws_ok($$insert into public.game_reports (owner_id, title, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'X', 'merged', now())$$, '23514', 'new row for relation "game_reports" violates check constraint "game_reports_target_required"', 'Check: merged braucht ein Ziel'); select lives_ok($$insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values ('f8000000-0000-0000-0000-000000000a03', '22222222-2222-2222-2222-222222222222', 'rejected mit Ziel', 'rejected', now(), '7c1d2c3d-0000-4000-8000-000000000004'), ('f8000000-0000-0000-0000-000000000a04', '22222222-2222-2222-2222-222222222222', 'rejected ohne Ziel', 'rejected', now(), null)$$, 'Positiv: rejected ist mit und ohne Ziel zulässig'); select is((select origin || ':' || status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000a01'), 'user:open', 'Standardwerte: origin = user, status = open'); -- --------------------------------------------------------------------------------------------- -- N12a: eine offene Import-Meldung je Nutzer und BGG-Kennung -- --------------------------------------------------------------------------------------------- insert into public.game_reports (id, owner_id, title, origin, bgg_id) values ('f8000000-0000-0000-0000-000000000b01', '22222222-2222-2222-2222-222222222222', 'Import 1', 'import', 9001); select throws_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values ('22222222-2222-2222-2222-222222222222', 'Import 1 doppelt', 'import', 9001)$$, '23505', null, 'N12a: zweite offene Import-Meldung desselben Nutzers mit derselben BGG-Kennung wird abgelehnt'); select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values ('33333333-3333-3333-3333-333333333333', 'Import anderer Nutzer', 'import', 9001), ('22222222-2222-2222-2222-222222222222', 'Import andere Kennung', 'import', 9002), ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung', 'user', 9001), ('22222222-2222-2222-2222-222222222222', 'Von Hand dieselbe Kennung 2', 'user', 9001), ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung', 'import', null), ('22222222-2222-2222-2222-222222222222', 'Import ohne Kennung 2', 'import', null)$$, 'N12a: anderer Nutzer, andere Kennung, origin = user und fehlende Kennung bleiben zulässig'); insert into public.game_reports (id, owner_id, title, origin, bgg_id, status, resolved_at) values ('f8000000-0000-0000-0000-000000000b02', '22222222-2222-2222-2222-222222222222', 'Import 1 erledigt', 'import', 9001, 'rejected', now()); select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values ('22222222-2222-2222-2222-222222222222', 'Import 1 neu', 'import', 9003)$$, 'N12a: Vorbedingung für die nächste Prüfung'); update public.game_reports set status = 'rejected', resolved_at = now() where id = 'f8000000-0000-0000-0000-000000000b01'; select lives_ok($$insert into public.game_reports (owner_id, title, origin, bgg_id) values ('22222222-2222-2222-2222-222222222222', 'Import 1 nach Erledigung', 'import', 9001)$$, 'N12a: ist die frühere Meldung erledigt, darf eine neue offene für dieselbe Kennung angelegt werden'); select is((select indexdef from pg_indexes where indexname = 'game_reports_open_idx'), 'CREATE INDEX game_reports_open_idx ON public.game_reports USING btree (created_at) WHERE (status = ''open''::text)', 'Index für die Admin-Liste (offene Meldungen nach Alter)'); select is((select count(*) from pg_indexes where tablename = 'game_reports' and indexname in ('game_reports_owner_created_idx', 'game_reports_provisional_game_id_key', 'game_reports_target_game_id_idx')), 3::bigint, 'Indizes für Tageslimit und beide Fremdschlüssel auf games vorhanden'); select throws_ok($$insert into public.game_reports (owner_id, title, provisional_game_id) values ('11111111-1111-1111-1111-111111111111', 'Zweite Meldung zum selben Eintrag', '7c1d2c3d-0000-4000-8000-000000000003')$$, '23505', null, 'Ein vorläufiger Eintrag gehört zu höchstens einer Meldung (1:1)'); select lives_ok($$insert into public.game_reports (owner_id, title, target_game_id, status, resolved_at) values ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 1', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now()), ('22222222-2222-2222-2222-222222222222', 'Gleiches Ziel 2', '7c1d2c3d-0000-4000-8000-000000000004', 'merged', now())$$, 'Mehrere Meldungen dürfen dasselbe Ziel haben, und mehrere dürfen ohne vorläufigen Eintrag sein'); -- --------------------------------------------------------------------------------------------- -- Fremdschlüssel-Regeln -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility, owner_id) values ('f8000000-0000-0000-0000-000000000c01', 'Wird gelöscht', 'private', '22222222-2222-2222-2222-222222222222'); insert into public.game_reports (id, owner_id, title, provisional_game_id) values ('f8000000-0000-0000-0000-000000000c02', '22222222-2222-2222-2222-222222222222', 'Vorläufiger Eintrag verschwindet', 'f8000000-0000-0000-0000-000000000c01'); delete from public.games where id = 'f8000000-0000-0000-0000-000000000c01'; select is((select count(*) || ':' || count(provisional_game_id) from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c02'), '1:0', 'Fremdschlüssel: wird der vorläufige Eintrag gelöscht, bleibt die Meldung und der Verweis wird leer (on delete set null)'); -- eigenes Ziel, auf das nur eine Meldung verweist (sonst würde ein anderer Verweis die Prüfung vortäuschen) insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000c03', 'Ziel nur für eine Meldung', 'catalog'); insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values ('f8000000-0000-0000-0000-000000000c04', '22222222-2222-2222-2222-222222222222', 'Verweist auf Ziel', 'rejected', now(), 'f8000000-0000-0000-0000-000000000c03'); select throws_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000c03'$$, '23503', null, 'Fremdschlüssel: ein Ziel mit Verweis aus einer Meldung lässt sich nicht löschen (on delete restrict)'); select is((select target_game_id from public.game_reports where id = 'f8000000-0000-0000-0000-000000000c04'), 'f8000000-0000-0000-0000-000000000c03'::uuid, 'Der Verweis der Meldung auf das Ziel bleibt bestehen'); select throws_ok($$insert into public.game_reports (owner_id, title) values (gen_random_uuid(), 'Ohne Konto')$$, '23503', null, 'Fremdschlüssel: Meldung braucht ein vorhandenes Profil'); -- --------------------------------------------------------------------------------------------- -- Trigger games_visibility_immutable (E14), KAT-05 Kriterium 9 -- --------------------------------------------------------------------------------------------- select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, unnest(array['anon', 'authenticated', 'service_role']) as r where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report') and has_function_privilege(r, p.oid, 'execute')$$, 'KAT-05 Kriterium 9: games_visibility_immutable und admin_resolve_report sind für anon, authenticated und service_role nicht ausführbar'); select is((select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname in ('games_visibility_immutable', 'admin_resolve_report', 'admin_merge_games') and not p.prosecdef and p.proconfig = array['search_path=""']), 3::bigint, 'Alle drei Funktionen sind security invoker mit leerem search_path'); -- als postgres: private -> Katalog (mit allem, was der Check games_owner_matches_visibility verlangt) select throws_ok($$update public.games set visibility = 'catalog', owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann einen privaten Eintrag nicht öffentlich schalten'); select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch nur visibility allein wird abgelehnt'); select throws_ok($$update public.games set visibility = 'private', owner_id = '11111111-1111-1111-1111-111111111111' where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch postgres kann ein Katalogspiel nicht privat schalten'); select throws_ok($$update public.games set owner_id = '22222222-2222-2222-2222-222222222222' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: der Eigentümer eines privaten Spiels ist unveränderlich'); select throws_ok($$update public.games set owner_id = null where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: auch ein leerer Eigentümer wird abgelehnt'); select is((select visibility || ':' || owner_id::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private:11111111-1111-1111-1111-111111111111', 'Nach den Fehlern ist das Spiel unverändert'); select lives_ok($$update public.games set title = 'Meldungsspiel', visibility = 'private' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'Positiv: dieselbe Sichtbarkeit nochmals zu setzen ist keine Änderung'); select lives_ok($$update public.games set year = 2022 where id = '7c1d2c3d-0000-4000-8000-000000000004'$$, 'Positiv: andere Felder eines Katalogspiels bleiben als postgres änderbar'); update public.games set year = null where id = '7c1d2c3d-0000-4000-8000-000000000004'; -- als Nutzer: der Trigger läuft vor der RLS-Prüfung, deshalb P0001 statt 42501 select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); set local role authenticated; select throws_ok($$update public.games set visibility = 'catalog' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'P0001', 'visibility_immutable', 'KAT-05 Kriterium 9: Anna kann ihren privaten Eintrag nicht öffentlich schalten (Trigger vor RLS)'); select lives_ok($$update public.games set title = 'Meldungsspiel (neu)' where id = '7c1d2c3d-0000-4000-8000-000000000003'$$, 'Positiv: Anna ändert den Titel ihres privaten Eintrags weiterhin'); update public.games set title = 'Meldungsspiel' where id = '7c1d2c3d-0000-4000-8000-000000000003'; reset role; select is((select visibility from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 'private', 'Annas Eintrag ist weiterhin privat'); -- --------------------------------------------------------------------------------------------- -- admin_resolve_report: Rechte (KAT-05 Kriterium 9) -- --------------------------------------------------------------------------------------------- select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); set local role authenticated; select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_resolve_report nicht aufrufen'); reset role; set local role anon; select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, 'KAT-05 Kriterium 9: anon kann admin_resolve_report nicht aufrufen'); reset role; set local role service_role; select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected')$$, '42501', null, 'KAT-05 Kriterium 9: service_role kann admin_resolve_report nicht aufrufen'); reset role; select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', 'Die verweigerten Aufrufe haben nichts verändert'); -- Die Rollenprüfung im Funktionskörper: eine Rolle MIT execute-Recht, aber ohne postgres/supabase_admin create role report_tester nologin; grant report_tester to postgres with set true; grant usage on schema public to report_tester; grant execute on function public.admin_resolve_report(uuid, text, uuid) to report_tester; set local role report_tester; do $$ begin perform public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected'); perform set_config('t.resolve_err', 'kein Fehler', true); exception when others then perform set_config('t.resolve_err', sqlstate || ':' || sqlerrm, true); end $$; reset role; select is(current_setting('t.resolve_err'), 'P0001:admin_only', 'KAT-05 Kriterium 9: admin_resolve_report prüft die Datenbankrolle (admin_only)'); select is((select status from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), 'open', 'admin_only ändert nichts'); -- --------------------------------------------------------------------------------------------- -- admin_resolve_report: Fehlerfälle (alle ohne Änderung) -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, visibility, hidden_at) values ('f8000000-0000-0000-0000-000000000d01', 'Ziel versteckt', 'catalog', '2020-01-01'); insert into public.games (id, title, visibility, hidden_at, merged_into_id) values ('f8000000-0000-0000-0000-000000000d02', 'Ziel zusammengeführt', 'catalog', '2020-01-01', '7c1d2c3d-0000-4000-8000-000000000004'); insert into public.games (id, title, visibility, owner_id) values ('f8000000-0000-0000-0000-000000000d03', 'Ziel privat', 'private', '22222222-2222-2222-2222-222222222222'); create temp table rb_before as select (select count(*) from public.game_reports where status <> 'open') as erledigt, (select count(*) from public.games where hidden_at is not null) as ausgeblendet, (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%') as meldungen; select throws_ok($$select public.admin_resolve_report(gen_random_uuid(), 'rejected')$$, 'P0001', 'report_not_found', 'KAT-05 Kriterium 5: unbekannte Meldung (report_not_found)'); select throws_ok($$select public.admin_resolve_report(null, 'rejected')$$, 'P0001', 'report_not_found', 'KAT-05 Kriterium 5: fehlende Meldung (null) ist report_not_found'); select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000a04', 'rejected')$$, 'P0001', 'report_not_open', 'KAT-05 Kriterium 5: bereits abgelehnte Meldung (report_not_open)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'open')$$, 'P0001', 'report_status_invalid', 'KAT-05 Kriterium 5: Status open ist kein Ergebnis (report_status_invalid)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig')$$, 'P0001', 'report_status_invalid', 'KAT-05 Kriterium 5: unbekannter Status (report_status_invalid)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', null)$$, 'P0001', 'report_status_invalid', 'KAT-05 Kriterium 5: fehlender Status (null) ist report_status_invalid'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'fertig', null)$$, 'P0001', 'report_status_invalid', 'KAT-05 Kriterium 5: ungültiger Status geht vor fehlendem Ziel'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved')$$, 'P0001', 'report_target_required', 'KAT-05 Kriterium 5: approved ohne Ziel (report_target_required)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', null)$$, 'P0001', 'report_target_required', 'KAT-05 Kriterium 5: merged ohne Ziel (report_target_required)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d01')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: ausgeblendetes Ziel (report_target_not_listed)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'merged', 'f8000000-0000-0000-0000-000000000d02')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: zusammengeführtes Ziel (report_target_not_listed)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-000000000d03')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: privates Ziel (report_target_not_listed)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', 'f8000000-0000-0000-0000-0000000000ff')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: unbekanntes Ziel (report_target_not_listed)'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'rejected', 'f8000000-0000-0000-0000-000000000d01')$$, 'P0001', 'report_target_not_listed', 'KAT-05 Kriterium 5: auch bei rejected wird das Ziel geprüft'); -- Datenfehler: vorläufiger Eintrag ist ein Katalogspiel (Zusatzprüfung gegenüber der Spec) insert into public.game_reports (id, owner_id, title, provisional_game_id) values ('f8000000-0000-0000-0000-000000000e01', '22222222-2222-2222-2222-222222222222', 'Vorläufig ist Katalog', 'f8000000-0000-0000-0000-00000000000e'); select throws_ok($$select public.admin_resolve_report('f8000000-0000-0000-0000-000000000e01', 'merged', '7c1d2c3d-0000-4000-8000-000000000004')$$, 'P0001', 'report_provisional_not_private', 'Zusatzprüfung: ein Katalogspiel als vorläufiger Eintrag wird nicht umgehängt'); select is((select status from public.game_reports where id = 'f8000000-0000-0000-0000-000000000e01'), 'open', 'Nach report_provisional_not_private ist die Meldung weiterhin offen'); select is((select hidden_at is null and merged_into_id is null from public.games where id = 'f8000000-0000-0000-0000-00000000000e'), true, 'Das Katalogspiel wurde nicht verändert'); select is((select row(erledigt, ausgeblendet, meldungen)::text from rb_before), (select row((select count(*) from public.game_reports where status <> 'open'), (select count(*) from public.games where hidden_at is not null), (select string_agg(id::text || target_game_id::text || status, ',' order by id) from public.game_reports where id::text like '7c1d2c3d%'))::text), 'Nach allen Fehlerfällen ist nichts verändert (Meldungen, Ausblendungen)'); -- --------------------------------------------------------------------------------------------- -- Runbook Abschnitt 6, Schritt 1 und 2 (SQL-Bausteine unverändert) -- --------------------------------------------------------------------------------------------- -- Vorbereitung für Schritt 2 (ähnliche Spiele): Titel gleich bzw. BGG-Kennung 4711 am Katalogspiel -- (Fixtures oben: MELDUNGSSPIEL, Anderer Titel) -- Schritt 1: Offene Meldungen create temp table rb_open as select r.id, r.created_at, r.origin, r.title, r.publisher_name, r.year, r.link, r.bgg_id, r.provisional_game_id from public.game_reports r where r.status = 'open' order by r.created_at, r.id; select results_eq($$select id from rb_open where id::text like '7c1d2c3d%' order by created_at, id$$, $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid), ('7c1d2c3d-0000-4000-8000-000000000006'), ('7c1d2c3d-0000-4000-8000-000000000005'), ('7c1d2c3d-0000-4000-8000-000000000007'), ('7c1d2c3d-0000-4000-8000-00000000000b')$$, 'KAT-05 Kriterium 8: Runbook 6, offene Meldungen (älteste zuerst)'); select is((select row(origin, title, publisher_name, year, link, bgg_id, provisional_game_id)::text from rb_open where id = '7c1d2c3d-0000-4000-8000-000000000001'), '(user,Meldungsspiel,Beispielverlag,2024,https://example.org/spiel,4711,7c1d2c3d-0000-4000-8000-000000000003)', 'KAT-05 Kriterium 8: Runbook 6, die Spalten der Meldung stehen in der Liste'); -- Schritt 2: Ähnliche Spiele im Katalog create temp table rb_similar as select g.id, g.title, g.year, g.bgg_id from public.game_reports r join public.catalog_games g on lower(btrim(g.title)) = lower(btrim(r.title)) or (r.bgg_id is not null and g.bgg_id = r.bgg_id) where r.id = '7c1d2c3d-0000-4000-8000-000000000001' order by g.title, g.id; select results_eq($$select id, title, year, bgg_id from rb_similar$$, $$values ('f8000000-0000-0000-0000-00000000000d'::uuid, 'Anderer Titel'::text, 2020::smallint, 4711::integer), ('f8000000-0000-0000-0000-00000000000c', 'MELDUNGSSPIEL', 2023::smallint, null::integer)$$, 'KAT-05 Kriterium 8: Runbook 6, ähnliche Spiele (gleicher Titel ohne Groß-/Kleinschreibung oder gleiche BGG-Kennung)'); -- Schritt 2: Was hängt am vorläufigen Eintrag? create temp table rb_counts as select g.id, g.title, (select count(*) from public.collection_items c where c.game_id = g.id) as sammlung, (select count(*) from public.favorite_games f where f.game_id = g.id) as favoriten, (select count(*) from public.plays p where p.game_id = g.id) as partien, (select count(*) from public.score_sheets s where s.game_id = g.id) as wertungsboegen from public.game_reports r join public.games g on g.id = r.provisional_game_id where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; select results_eq($$select id, title, sammlung, favoriten, partien, wertungsboegen from rb_counts$$, $$values ('7c1d2c3d-0000-4000-8000-000000000003'::uuid, 'Meldungsspiel'::text, 1::bigint, 1::bigint, 2::bigint, 1::bigint)$$, 'KAT-05 Kriterium 8: Runbook 6, Anzahlen am vorläufigen Eintrag'); -- Schritt 2: Editionen des vorläufigen Eintrags create temp table rb_editions as select v.id, v.name, v.year, v.language from public.game_reports r join public.game_variants v on v.game_id = r.provisional_game_id where r.id = '7c1d2c3d-0000-4000-8000-000000000001' order by v.name, v.id; select results_eq($$select id, name from rb_editions$$, $$values ('f8000000-0000-0000-0000-0000000000e1'::uuid, 'Deluxe'::text), ('f8000000-0000-0000-0000-0000000000e2', 'Prototyp')$$, 'KAT-05 Kriterium 8: Runbook 6, Namen der Editionen des vorläufigen Eintrags'); -- --------------------------------------------------------------------------------------------- -- Runbook Abschnitt 6, Schritt 3a: Freigeben (neuer Katalogeintrag, nur Fakten aus der Meldung) -- --------------------------------------------------------------------------------------------- insert into public.games (id, title, year, description, description_locale, visibility) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Meldungsspiel', 2024, 'Eigener Kurztext des Admins.', 'de', 'catalog'); insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; insert into public.game_publishers (game_id, publisher_id) select '7c1d2c3d-0000-4000-8000-000000000002', p.id from public.publishers p where lower(p.name) = lower('Beispielverlag'); insert into public.game_variants (game_id, name, year) values ('7c1d2c3d-0000-4000-8000-000000000002', 'Deluxe', 2024); select is((select manual_fields from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), array['description', 'description_locale', 'publishers', 'title', 'year'], 'KAT-05 Kriterium 8: Runbook 6, der neue Katalogeintrag vermerkt seine Felder in manual_fields'); -- Ben hat das neue Spiel nicht; Bens Daten am vorhandenen Ziel (…04) merken, sie dürfen sich nicht ändern create temp table ben_before as select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222' union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'; create temp table rb_approve as select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'approved', '7c1d2c3d-0000-4000-8000-000000000002') as r; select is((select r - 'report_id' from rb_approve), jsonb_build_object( 'status', 'approved', 'target_id', '7c1d2c3d-0000-4000-8000-000000000002', 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000003', 'repointed', true, 'collection_items', 1, 'collection_conflicts', 0, 'favorite_games', 1, 'favorite_conflicts', 0, 'plays', 2, 'score_sheets', 1, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 1), 'KAT-05 Kriterium 5: Runbook 6, approved: Ergebnis-JSON zählt alles richtig (nichts kopiert, eine Edition abgebildet, eine geleert)'); select is((select r ->> 'report_id' from rb_approve), '7c1d2c3d-0000-4000-8000-000000000001', 'Das Ergebnis nennt die Meldung'); -- Schritt 4: Nachprüfen create temp table rb_check as select r.id, r.status, r.target_game_id, r.resolved_at is not null as erledigt, g.hidden_at is not null as vorlaeufig_ausgeblendet, g.merged_into_id from public.game_reports r left join public.games g on g.id = r.provisional_game_id where r.id = '7c1d2c3d-0000-4000-8000-000000000001'; select results_eq($$select id, status, target_game_id, erledigt, vorlaeufig_ausgeblendet, merged_into_id from rb_check$$, $$values ('7c1d2c3d-0000-4000-8000-000000000001'::uuid, 'approved'::text, '7c1d2c3d-0000-4000-8000-000000000002'::uuid, true, true, '7c1d2c3d-0000-4000-8000-000000000002'::uuid)$$, 'KAT-05 Kriterium 8: Runbook 6, Nachprüfen: Meldung freigegeben, vorläufiger Eintrag ausgeblendet und verwiesen'); select is((select row(visibility, owner_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '(private,11111111-1111-1111-1111-111111111111)', 'KAT-05 Kriterium 5: der vorläufige Eintrag bleibt privat und gehört weiter Anna (E14)'); select is((select row(description, image_path)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), '("Eigener Kurztext des Admins.",)', 'Beschreibung und Bild des vorläufigen Eintrags werden nie übernommen'); select is((select row(visibility, owner_id, hidden_at is null, merged_into_id is null)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000002'), '(catalog,,t,t)', 'Das neue Katalogspiel ist sichtbar und gehört niemandem'); select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, 'Das neue Katalogspiel erscheint in catalog_games'); select is((select count(*) from public.catalog_games where id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, 'Der vorläufige Eintrag erscheint nie in catalog_games'); select is((select resolved_at is not null and updated_at >= created_at from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), true, 'resolved_at ist gesetzt'); -- Verweise select is((select row(game_id, variant_id, list, note)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c1'), '(7c1d2c3d-0000-4000-8000-000000000002,,owned,"Annas Notiz")', 'RB1: Sammlungseintrag am neuen Spiel, Edition "Prototyp" ohne Gegenstück ist geleert'); select is((select row(p.game_id, v.name, v.game_id)::text from public.plays p join public.game_variants v on v.id = p.variant_id where p.id = 'f8000000-0000-0000-0000-0000000000b1'), '(7c1d2c3d-0000-4000-8000-000000000002,Deluxe,7c1d2c3d-0000-4000-8000-000000000002)', 'RB1: die Edition "Deluxe" der Partie zeigt auf die vom Admin angelegte gleichnamige Variante am Katalogeintrag'); select is((select row(game_id, variant_id)::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b2'), '(7c1d2c3d-0000-4000-8000-000000000002,)', 'Partie ohne Edition wechselt auf das neue Spiel'); select is((select game_id from public.favorite_games where id = 'f8000000-0000-0000-0000-0000000000d1'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, 'Favorit wechselt auf das neue Spiel'); select is((select game_id from public.score_sheets where id = 'f8000000-0000-0000-0000-0000000000b5'), '7c1d2c3d-0000-4000-8000-000000000002'::uuid, 'Wertungsbogen wechselt auf das neue Spiel'); select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000002'), 1::bigint, 'RB1: am Katalogeintrag gibt es nur die vom Admin angelegte Variante, die private wurde nicht kopiert'); select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 2::bigint, 'RB1: die privaten Varianten bleiben am vorläufigen Eintrag'); select is((select count(*) from public.collection_items where game_id = '7c1d2c3d-0000-4000-8000-000000000003'), 0::bigint, 'Nichts hängt mehr am vorläufigen Eintrag'); -- --------------------------------------------------------------------------------------------- -- Runbook Abschnitt 6, Schritt 3b: Zusammenführen mit einem vorhandenen Eintrag (Kollision) -- --------------------------------------------------------------------------------------------- create temp table rb_merged as select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000005', 'merged', '7c1d2c3d-0000-4000-8000-000000000004') as r; select is((select r - 'report_id' from rb_merged), jsonb_build_object( 'status', 'merged', 'target_id', '7c1d2c3d-0000-4000-8000-000000000004', 'provisional_game_id', '7c1d2c3d-0000-4000-8000-000000000008', 'repointed', true, 'collection_items', 0, 'collection_conflicts', 1, 'favorite_games', 0, 'favorite_conflicts', 0, 'plays', 0, 'score_sheets', 0, 'variants_copied', 0, 'variants_mapped', 1, 'variants_cleared', 0), 'KAT-05 Kriterium 5: Runbook 6, merged: Kollision (Anna hatte das Ziel schon), gleichnamige Variante abgebildet'); select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000005'), '(merged,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: Status und Ziel gespeichert'); select is((select row(hidden_at is not null, merged_into_id)::text from public.games where id = '7c1d2c3d-0000-4000-8000-000000000008'), '(t,7c1d2c3d-0000-4000-8000-000000000004)', 'merged: der vorläufige Eintrag ist ausgeblendet und verwiesen'); select is((select row(list, note, variant_id)::text from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c3'), '(owned,"Annas Wunsch",f8000000-0000-0000-0000-0000000000a4)', 'Spec-F1: Anna: der Zieleintrag gewinnt (owned vor wishlist), die leere Notiz und Edition füllt der vorläufige Eintrag (Edition über die gleichnamige Variante)'); select is((select count(*) from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c2'), 0::bigint, 'Spec-F1: der Quell-Eintrag der Kollision ist gelöscht'); select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000004'), 1::bigint, 'RB1: das vorhandene Katalogspiel bekommt keine Variante'); select is((select count(*) from public.game_variants where game_id = '7c1d2c3d-0000-4000-8000-000000000008'), 1::bigint, 'RB1: die private Variante bleibt am vorläufigen Eintrag'); select results_eq($$select c.id, c.game_id, c.variant_id, c.list, c.note from public.collection_items c where c.owner_id = '22222222-2222-2222-2222-222222222222' union all select p.id, p.game_id, p.variant_id, 'play', null from public.plays p where p.owner_id = '22222222-2222-2222-2222-222222222222'$$, $$select id, game_id, variant_id, list, note from ben_before$$, 'Daten anderer Nutzer am Ziel (Bens Sammlung und Partie) sind unverändert'); -- --------------------------------------------------------------------------------------------- -- Runbook Abschnitt 6, Schritt 3c: Ablehnen -- --------------------------------------------------------------------------------------------- create temp table rb_rej_snapshot as select 'c5' as k, game_id::text || coalesce(variant_id::text, '') || list as v from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'; create temp table rb_rej1 as select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000006', 'rejected') as r; create temp table rb_rej2 as select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000007', 'rejected', '7c1d2c3d-0000-4000-8000-000000000004') as r; select is((select r - 'report_id' from rb_rej1), '{"status":"rejected","target_id":null,"provisional_game_id":"7c1d2c3d-0000-4000-8000-000000000009","repointed":false}'::jsonb, 'KAT-05 Kriterium 5: Runbook 6, rejected ohne Ziel: nichts umgehängt, keine Zähler'); select is((select r - 'report_id' from rb_rej2), '{"status":"rejected","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":"7c1d2c3d-0000-4000-8000-00000000000a","repointed":false}'::jsonb, 'KAT-05 Kriterium 5: Runbook 6, rejected mit Ziel: nur der Verweis, nichts umgehängt'); select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000006'), '(rejected,,t)', 'rejected ohne Ziel: Status und Zeitpunkt gespeichert, kein Ziel'); select is((select row(status, target_game_id, resolved_at is not null)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000007'), '(rejected,7c1d2c3d-0000-4000-8000-000000000004,t)', 'rejected mit Ziel: der Verweis auf den Zieleintrag ist gespeichert (Spec-F3)'); select results_eq($$select 'c5', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c5' union all select 'c6', game_id::text || coalesce(variant_id::text, '') || list from public.collection_items where id = 'f8000000-0000-0000-0000-0000000000c6' union all select 'b4', game_id::text from public.plays where id = 'f8000000-0000-0000-0000-0000000000b4' union all select 'g9', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-000000000009' union all select 'ga', visibility || coalesce(hidden_at::text, '') || coalesce(merged_into_id::text, '') from public.games where id = '7c1d2c3d-0000-4000-8000-00000000000a'$$, $$select k, v from rb_rej_snapshot$$, 'KAT-05 Kriterium 5: bei rejected (mit und ohne Ziel) bleiben vorläufige Einträge, Sammlung und Partien unverändert'); -- approved ohne vorläufigen Eintrag: nur Status und Ziel create temp table rb_noprov as select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-00000000000b', 'approved', '7c1d2c3d-0000-4000-8000-000000000004') as r; select is((select r - 'report_id' from rb_noprov), '{"status":"approved","target_id":"7c1d2c3d-0000-4000-8000-000000000004","provisional_game_id":null,"repointed":false}'::jsonb, 'approved ohne vorläufigen Eintrag: nichts umzuhängen (repointed = false)'); select is((select row(status, target_game_id)::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-00000000000b'), '(approved,7c1d2c3d-0000-4000-8000-000000000004)', 'approved ohne vorläufigen Eintrag: Status und Ziel gespeichert'); select throws_ok($$select public.admin_resolve_report('7c1d2c3d-0000-4000-8000-000000000001', 'rejected')$$, 'P0001', 'report_not_open', 'KAT-05 Kriterium 5: eine freigegebene Meldung lässt sich nicht erneut bearbeiten'); -- Der Nutzer sieht das Ergebnis seiner Meldung (KAT-04 Krit. 4-6), aber keine fremden select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); set local role authenticated; select is((select status || ':' || target_game_id::text from public.game_reports where id = '7c1d2c3d-0000-4000-8000-000000000001'), 'approved:7c1d2c3d-0000-4000-8000-000000000002', 'Anna sieht das Ergebnis ihrer Meldung'); select is((select count(*) from public.game_reports where owner_id <> '11111111-1111-1111-1111-111111111111'), 0::bigint, 'Anna sieht weiterhin keine fremden Meldungen'); select cmp_ok((select count(*) from public.games where id = '7c1d2c3d-0000-4000-8000-000000000003'), '=', 1::bigint, 'Anna sieht ihren ausgeblendeten vorläufigen Eintrag weiterhin'); reset role; -- --------------------------------------------------------------------------------------------- -- admin_merge_games nach dem create or replace (Schritt 4: Meldungen umhängen) und Rechte -- --------------------------------------------------------------------------------------------- select is_empty($$select p.proname, r from pg_proc p join pg_namespace n on n.oid = p.pronamespace, unnest(array['anon', 'authenticated', 'service_role']) as r where n.nspname = 'public' and p.proname in ('admin_merge_games', 'repoint_game_references') and has_function_privilege(r, p.oid, 'execute')$$, 'KAT-05 Kriterium 9: nach dem create or replace sind admin_merge_games und repoint_game_references für anon, authenticated und service_role nicht ausführbar'); select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated","app_metadata":{"role":"admin"}}', true); set local role authenticated; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: authenticated (auch mit app_metadata.role = admin) kann admin_merge_games weiterhin nicht aufrufen'); reset role; set local role anon; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: anon kann admin_merge_games weiterhin nicht aufrufen'); reset role; set local role service_role; select throws_ok($$select public.admin_merge_games(gen_random_uuid(), gen_random_uuid())$$, '42501', null, 'KAT-05 Kriterium 9: service_role kann admin_merge_games weiterhin nicht aufrufen'); reset role; create role merge_tester nologin; grant merge_tester to postgres with set true; grant usage on schema public to merge_tester; grant execute on function public.admin_merge_games(uuid, uuid) to merge_tester; set local role merge_tester; do $$ begin perform public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02'); perform set_config('t.merge_err', 'kein Fehler', true); exception when others then perform set_config('t.merge_err', sqlstate || ':' || sqlerrm, true); end $$; reset role; select is(current_setting('t.merge_err'), 'P0001:admin_only', 'KAT-05 Kriterium 9: admin_merge_games prüft nach dem create or replace weiterhin die Datenbankrolle'); -- Schritt 4: Quelle (…f01) -> Ziel (…f02); Meldungen mit Ziel = Quelle ziehen mit insert into public.games (id, title, visibility) values ('f8000000-0000-0000-0000-000000000f01', 'Merge Quelle', 'catalog'), ('f8000000-0000-0000-0000-000000000f02', 'Merge Ziel', 'catalog'), ('f8000000-0000-0000-0000-000000000f03', 'Anderes Ziel', 'catalog'); insert into public.game_reports (id, owner_id, title, status, resolved_at, target_game_id) values ('f8000000-0000-0000-0000-000000000f11', '22222222-2222-2222-2222-222222222222', 'Freigegeben auf Quelle', 'approved', now(), 'f8000000-0000-0000-0000-000000000f01'), ('f8000000-0000-0000-0000-000000000f12', '22222222-2222-2222-2222-222222222222', 'Abgelehnt mit Verweis auf Quelle', 'rejected', now(), 'f8000000-0000-0000-0000-000000000f01'), ('f8000000-0000-0000-0000-000000000f13', '22222222-2222-2222-2222-222222222222', 'Zusammengeführt mit anderem', 'merged', now(), 'f8000000-0000-0000-0000-000000000f03'); insert into public.game_reports (id, owner_id, title) values ('f8000000-0000-0000-0000-000000000f14', '22222222-2222-2222-2222-222222222222', 'Offen'); create temp table rb_mg as select public.admin_merge_games('f8000000-0000-0000-0000-000000000f01', 'f8000000-0000-0000-0000-000000000f02') as r; select is((select r -> 'reports_repointed' from rb_mg), '2'::jsonb, 'KAT-05 Kriterium 4: admin_merge_games meldet 2 umgehängte Meldungen'); select results_eq($$select id, target_game_id from public.game_reports where id::text like 'f8000000-0000-0000-0000-000000000f1%' order by id$$, $$values ('f8000000-0000-0000-0000-000000000f11'::uuid, 'f8000000-0000-0000-0000-000000000f02'::uuid), ('f8000000-0000-0000-0000-000000000f12', 'f8000000-0000-0000-0000-000000000f02'), ('f8000000-0000-0000-0000-000000000f13', 'f8000000-0000-0000-0000-000000000f03'), ('f8000000-0000-0000-0000-000000000f14', null)$$, 'KAT-05 Kriterium 4: Meldungen mit Ziel = Quelle zeigen auf das Ziel, andere Meldungen bleiben unverändert'); select is((select row(status, resolved_at is not null)::text from public.game_reports where id = 'f8000000-0000-0000-0000-000000000f11'), '(approved,t)', 'Das Umhängen ändert Status und Zeitpunkt der Meldung nicht'); select is(public.admin_merge_games('f8000000-0000-0000-0000-000000000f03', 'f8000000-0000-0000-0000-000000000f02') -> 'reports_repointed', '1'::jsonb, 'Ein weiteres Zusammenführen hängt die nächste Meldung um'); select lives_ok($$delete from public.games where id = 'f8000000-0000-0000-0000-000000000f01'$$, 'Positiv: Die Quelle ist nach dem Umhängen nicht mehr Ziel einer Meldung und lässt sich löschen (keine verwaiste Restrict-Sperre)'); -- --------------------------------------------------------------------------------------------- -- Fremdschlüssel-Wächter (wie in 07): jeder Verweis auf games ist behandelt oder bewusst ausgelassen -- --------------------------------------------------------------------------------------------- create temp table fk_found as select (select relname from pg_class where oid = c.conrelid) || '.' || (select attname from pg_attribute where attrelid = c.conrelid and attnum = c.conkey[1]) as fk from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass; select is((select count(*) from pg_constraint c where c.contype = 'f' and c.confrelid = 'public.games'::regclass and cardinality(c.conkey) > 1), 0::bigint, 'Fremdschlüssel-Wächter: alle Verweise auf games sind einspaltig'); select set_eq($$select fk from fk_found$$, $$values -- behandelt von repoint_game_references / admin_merge_games ('collection_items.game_id'), ('favorite_games.game_id'), ('plays.game_id'), ('score_sheets.game_id'), ('game_variants.game_id'), ('games.merged_into_id'), ('game_reports.target_game_id'), -- bewusst ausgelassen: bleiben am ausgeblendeten Quell-Eintrag ('game_genres.game_id'), ('game_mechanics.game_id'), ('game_categories.game_id'), ('game_publishers.game_id'), -- bewusst ausgelassen: zeigt auf ein privates Spiel, das Zusammenführen zweier Katalogspiele betrifft ihn nicht ('game_reports.provisional_game_id')$$, 'KAT-05 Kriterium 4: Fremdschlüssel-Wächter: neue Verweise auf games erzwingen eine Entscheidung im Zusammenführen'); -- --------------------------------------------------------------------------------------------- -- Konto löschen nach Freigabe (QUE-04, Klärung 10; Spec 9.5 KTO-05) -- Anna: freigegebene Meldung (neu angelegter Katalogeintrag), zusammengeführte Meldung, ausgeblendete -- vorläufige Einträge mit merged_into_id auf Katalogspiele. -- --------------------------------------------------------------------------------------------- insert into public.collection_items (id, owner_id, game_id, list, note) values ('f8000000-0000-0000-0000-0000000000c7', '22222222-2222-2222-2222-222222222222', '7c1d2c3d-0000-4000-8000-000000000002', 'owned', 'Bens Notiz am neuen Spiel'); create temp table other_before as select 'game2' as k, row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text as v from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text; select cmp_ok((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), '=', 2::bigint, 'Vorbedingung: Anna hat zwei bearbeitete Meldungen'); select cmp_ok((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111' and merged_into_id is not null), '=', 2::bigint, 'Vorbedingung: Anna hat zwei ausgeblendete vorläufige Einträge mit merged_into_id'); select lives_ok($$delete from auth.users where id = '11111111-1111-1111-1111-111111111111'$$, 'KAT-05 Kriterium 5 / QUE-04: das Konto der Meldenden lässt sich nach der Freigabe löschen'); select is((select count(*) from public.game_reports where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, 'QUE-04: die Meldungen sind mit dem Konto gelöscht'); select is((select count(*) from public.games where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, 'QUE-04: die vorläufigen Einträge sind mit dem Konto gelöscht'); select is((select count(*) from public.collection_items where owner_id = '11111111-1111-1111-1111-111111111111'), 0::bigint, 'QUE-04: Annas Sammlung (auch die umgehängten Einträge) ist gelöscht'); select results_eq($$select 'game2', row(g.title, g.year, g.description, g.hidden_at, g.merged_into_id, g.visibility, g.manual_fields)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000002' union all select 'game4', row(g.title, g.year, g.hidden_at, g.merged_into_id, g.visibility)::text from public.games g where g.id = '7c1d2c3d-0000-4000-8000-000000000004' union all select 'ben_c7', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c7' union all select 'ben_c4', row(c.game_id, c.variant_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c4' union all select 'cara_report', row(r.status, r.target_game_id)::text from public.game_reports r where r.id = '7c1d2c3d-0000-4000-8000-000000000007' union all select 'cara_c5', row(c.game_id, c.list, c.note)::text from public.collection_items c where c.id = 'f8000000-0000-0000-0000-0000000000c5' union all select 'variants', (select count(*) from public.game_variants where game_id in ('7c1d2c3d-0000-4000-8000-000000000002', '7c1d2c3d-0000-4000-8000-000000000004'))::text$$, $$select k, v from other_before$$, 'QUE-04: Katalogspiele (neu angelegt und vorhanden), ihre Varianten und die Daten und Meldungen anderer Nutzer bleiben unverändert'); select is((select count(*) from public.game_reports where owner_id = '33333333-3333-3333-3333-333333333333'), 3::bigint, 'Caras Meldungen (zwei aus dem Runbook, eine Import-Meldung) sind nach Annas Löschung vorhanden'); select * from finish(); rollback;