diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 88c2a80..2e4004c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -152,8 +152,14 @@ jobs: # Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur. - name: Deno installieren run: | - arch=$(uname -m); [ "$arch" = "aarch64" ] && target=aarch64-unknown-linux-gnu || target=x86_64-unknown-linux-gnu + # SHA256 fest eingetragen (aus den veröffentlichten .sha256sum-Dateien von v2.9.8); bei neuer DENO_VERSION mit ändern. + if [ "$(uname -m)" = "aarch64" ]; then + target=aarch64-unknown-linux-gnu; sha=d49596c20b9788cf8f5d8ddf644103d4bde56ed239753f0bdb92ba9361d51905 + else + target=x86_64-unknown-linux-gnu; sha=e890975bc2549807bbcc0b584ffafda72cc7c93349dd99c80483dd784f6c4609 + fi curl -fsSL "https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${target}.zip" -o /tmp/deno.zip + echo "${sha} /tmp/deno.zip" | sha256sum -c - unzip -q -o /tmp/deno.zip -d /usr/local/bin deno --version - name: Startbestand-Werkzeug testen @@ -212,6 +218,22 @@ jobs: test -f supabase/config.toml || { echo "supabase/config.toml fehlt"; exit 1; } test -d supabase/migrations || { echo "Keine Migrationen – übersprungen"; exit 0; } npx --yes supabase@${{ env.SUPABASE_CLI_VERSION }} start -x studio,imgproxy,edge-runtime,logflare,vector + - name: Katalog-Vokabular eingespielt? (KAT-05) + # Schlägt fehl, wenn der Seed (sql_paths) das Vokabular nicht geladen hat, zum Beispiel wegen eines Pfad-Tippfehlers + # (die CLI überspringt eine fehlende Datei nur mit Warnung). IDs werden aus vocabulary.csv abgeleitet. + run: | + expected=$(awk -F, 'NR>1 && NF>=4 {printf "%s(\x27%s\x27,\x27%s\x27)", sep, $1, $2; sep=","}' supabase/catalog/vocabulary.csv) + test -n "$expected" || { echo "vocabulary.csv ist leer"; exit 1; } + missing=$(docker exec -i supabase_db_shuffle psql -U postgres -d postgres -At -v ON_ERROR_STOP=1 < 'catalog' then return new; end if; - -- Ausnahme für den späteren Import: nur als Rolle catalog_importer UND mit set local. + -- Ausnahme für den späteren Import: nur als Rolle catalog_importer UND mit Einstellung 'on' (der Import setzt sie per set local; das ist Konvention). -- Ein bloßes set im Studio (Rolle postgres) schaltet die Kennzeichnung nicht ab (W7). if current_user = 'catalog_importer' and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then diff --git a/supabase/tests/database/06_catalog_foundation.test.sql b/supabase/tests/database/06_catalog_foundation.test.sql index 60fd89e..e9c8241 100644 --- a/supabase/tests/database/06_catalog_foundation.test.sql +++ b/supabase/tests/database/06_catalog_foundation.test.sql @@ -3,7 +3,7 @@ -- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs, gezählt wird nur darüber. -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts aus catalog/testdata/. begin; -select plan(133); +select plan(148); 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"}'), @@ -120,13 +120,12 @@ update public.games set min_age = 8 where id = 'f6000000-0000-0000-0000-00000000 select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), array['description', 'description_locale', 'min_age', 'min_players', 'title', 'year'], 'KAT-05 Kriterium 1: Update als postgres ergänzt das geänderte Feld'); -update public.games set year = year where id = 'f6000000-0000-0000-0000-0000000000d1'; -select is((select cardinality(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), - 6, 'Ein Update ohne Wertänderung markiert nichts zusätzlich'); - update public.games set manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), false, 'KAT-05 Kriterium 4: Eine bewusst entfernte Markierung bleibt entfernt'); +update public.games set min_age = min_age where id = 'f6000000-0000-0000-0000-0000000000d1'; +select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), + false, 'Ein Update ohne Wertänderung markiert das Feld nicht wieder'); update public.games set min_age = 10, manual_fields = array_remove(manual_fields, 'min_age') where id = 'f6000000-0000-0000-0000-0000000000d1'; select is((select 'min_age' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-0000000000d1'), true, 'Wer das Feld ändert, markiert es wieder, auch wenn die Markierung im selben Update entfernt wird'); @@ -146,7 +145,7 @@ insert into public.games (id, title, year, visibility, owner_id) values select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000a2'), '{}'::text[], 'Private Spiele werden nicht gekennzeichnet'); --- Import-Ausnahme: Rolle catalog_importer UND set local +-- Import-Ausnahme: Rolle catalog_importer UND Einstellung on (der Import setzt sie per set local) create role catalog_importer nologin; grant catalog_importer to postgres with set true; grant usage on schema public to catalog_importer; @@ -167,13 +166,13 @@ insert into public.game_genres (game_id, genre_id) values reset shuffle.catalog_import; reset role; select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), - '{}'::text[], 'KAT-05 Kriterium 1: Rolle catalog_importer mit set local markiert nichts (Insert und Zuordnung)'); + '{}'::text[], 'KAT-05 Kriterium 1: Rolle catalog_importer mit Einstellung on (der Import setzt sie per set local) markiert nichts (Insert und Zuordnung)'); set local role catalog_importer; update public.games set year = 2011 where id = 'f6000000-0000-0000-0000-0000000000f1'; reset role; select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), - array['year'], 'KAT-05 Kriterium 1: Rolle catalog_importer ohne set local wird gekennzeichnet'); + array['year'], 'KAT-05 Kriterium 1: Rolle catalog_importer ohne die Einstellung wird gekennzeichnet'); -- Zuordnungen als Pseudo-Felder update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f1'; @@ -197,14 +196,22 @@ delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-0000 select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-000000000002'), array['genres', 'publishers', 'title'], 'KAT-05 Kriterium 1: Zuordnung löschen markiert genres und publishers'); --- Stabilität: eine bereits gesetzte Markierung ändert die Spielzeile nicht noch einmal +-- Stabilität: ist 'genres' schon markiert, schreibt eine neue oder gelöschte Zuordnung die Spielzeile nicht noch einmal. +-- (ctid statt updated_at, weil now() in der Transaktion konstant ist; jedes UPDATE erzeugt eine neue ctid.) create temp table snap_game on commit drop as - select ctid::text as c, updated_at from public.games where id = 'f6000000-0000-0000-0000-000000000002'; -insert into public.game_mechanics (game_id, mechanic_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e3') - on conflict do nothing; -delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; + select ctid::text as c from public.games where id = 'f6000000-0000-0000-0000-000000000002'; +insert into public.game_genres (game_id, genre_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e1'); +select is((select count(*) from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint, + 'Neue Zuordnung ist angelegt (Trigger hat gefeuert)'); +select is((select 'genres' = any(manual_fields) from public.games where id = 'f6000000-0000-0000-0000-000000000002'), true, + 'genres ist bereits markiert'); select is((select g.ctid::text from public.games g where g.id = 'f6000000-0000-0000-0000-000000000002'), - (select c from snap_game), 'Eine schon vorhandene Markierung schreibt die Spielzeile nicht erneut'); + (select c from snap_game), 'Eine neue Zuordnung schreibt die Spielzeile nicht erneut, wenn die Markierung schon da ist'); +delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; +select is((select count(*) from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'), 0::bigint, + 'Die Zuordnung ist gelöscht (Trigger hat gefeuert)'); +select is((select g.ctid::text from public.games g where g.id = 'f6000000-0000-0000-0000-000000000002'), + (select c from snap_game), 'Eine gelöschte Zuordnung schreibt die Spielzeile nicht erneut, wenn die Markierung schon da ist'); -- --------------------------------------------------------------------------------------------- -- View catalog_games: Ausblenden, Zusammenführen, Anzeige-Erlaubnis (Kriterien 4 und 6) @@ -301,6 +308,16 @@ select is_empty($$select p.proname from pg_proc p join pg_namespace n on n.oid = select is_empty($$select t from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t where not (select relrowsecurity from pg_class where oid = ('public.' || t)::regclass)$$, 'Die neuen Tabellen haben Row Level Security'); +select is_empty($$select t, p from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t, + unnest(array['insert', 'update', 'delete', 'truncate', 'references', 'trigger']) as p + where has_table_privilege('anon', 'public.' || t, p) + or (p in ('truncate', 'references', 'trigger') and has_table_privilege('authenticated', 'public.' || t, p))$$, + 'Grant-Matrix: anon hat auf den neuen Tabellen nur select, niemand hat truncate, references oder trigger'); +select is_empty($$select t, p from unnest(array['catalog_sources', 'publishers', 'game_publishers']) as t, + unnest(array['select', 'insert', 'update', 'delete']) as p + where not has_table_privilege('authenticated', 'public.' || t, p) + or (p = 'select' and not has_table_privilege('anon', 'public.' || t, p))$$, + 'Grant-Matrix: authenticated hat select, insert, update, delete (Schutz durch Policies), anon select'); -- Besucher set local role anon; @@ -318,6 +335,10 @@ select throws_ok($$update public.games set title = 'Gehackt' where id = 'f600000 'KAT-05 Kriterium 9: Besucher ändern keine Katalogspiele'); select throws_ok($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null, 'Besucher löschen keine Verlagszuordnungen'); +select throws_ok($$update public.catalog_sources set texts_allowed = true$$, '42501', null, 'Besucher ändern keine Quellen'); +select throws_ok($$delete from public.catalog_sources$$, '42501', null, 'Besucher löschen keine Quellen'); +select throws_ok($$update public.publishers set name = 'Gehackt'$$, '42501', null, 'Besucher ändern keine Verlage'); +select throws_ok($$delete from public.publishers$$, '42501', null, 'Besucher löschen keine Verlage'); select throws_ok($$update public.catalog_games set title = 'Gehackt'$$, '55000', null, 'Die View catalog_games ist für Besucher nicht schreibbar (nicht aktualisierbar)'); select is_empty($$select p from unnest(array['insert', 'update', 'delete']) as p where has_table_privilege('anon', 'public.catalog_games', p) or has_table_privilege('authenticated', 'public.catalog_games', p)$$, @@ -385,6 +406,13 @@ select throws_ok($$insert into public.publishers (name) values ('Admin-Verlag')$ 'KAT-05 Kriterium 9: user_metadata.role = admin gibt keine Rechte'); select is_empty($$update public.games set title = 'Gehackt' where id = 'f6000000-0000-0000-0000-000000000001' returning 1$$, 'KAT-05 Kriterium 9: user_metadata.role = admin ändert keine Katalogspiele'); +select throws_ok($$insert into public.catalog_sources (key, name) values ('admin', 'Admin')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin legt keine Quellen an'); +select is_empty($$update public.catalog_sources set texts_allowed = true returning 1$$, + 'KAT-05 Kriterium 9: user_metadata.role = admin ändert keine Quellen'); +select throws_ok($$insert into public.game_publishers (game_id, publisher_id) + values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000c1')$$, '42501', null, + 'KAT-05 Kriterium 9: user_metadata.role = admin ordnet Katalogspielen keinen Verlag zu'); reset role; -- Ben @@ -412,14 +440,19 @@ select is((select count(*) from public.games where id = 'f6000000-0000-0000-0000 -- --------------------------------------------------------------------------------------------- insert into public.genres (name_de, name_en) values ('Strategiespiel', 'Strategy game') on conflict do nothing; +-- Abschnitt 3: Spiel finden (vorab nachschlagen; das Spiel gibt es hier noch nicht) +select is((select count(*) from ( +select id, title, year, hidden_at from public.games +where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook')) f), 0::bigint, 'Runbook 3: Suche vor dem Anlegen findet nichts'); + -- Abschnitt 3: Neues Katalogspiel mit Verlag und Genre insert into public.publishers (name) values ('Beispielverlag') on conflict ((lower(name))) do nothing; with neu as ( - insert into public.games (title, year, min_players, max_players, min_minutes, max_minutes, min_age, + insert into public.games (id, title, year, min_players, max_players, min_minutes, max_minutes, min_age, description, description_locale, visibility) - values ('Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog') + values ('7a1b2c3d-0000-4000-8000-000000000001', 'Beispielspiel Runbook', 2020, 2, 4, 30, 60, 8, 'Eigener Kurztext.', 'de', 'catalog') returning id ), verlag as ( insert into public.game_publishers (game_id, publisher_id) @@ -428,7 +461,7 @@ with neu as ( insert into public.game_genres (game_id, genre_id) select neu.id, g.id from neu, public.genres g where g.name_de = 'Strategiespiel'; -select is((select count(*) from public.games where title = 'Beispielspiel Runbook' and visibility = 'catalog' and owner_id is null), 1::bigint, +select is((select count(*) from public.games where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and owner_id is null), 1::bigint, 'KAT-05 Kriterium 8: Runbook 3, Spiel anlegen'); select is((select manual_fields from public.games where title = 'Beispielspiel Runbook'), array['description', 'description_locale', 'genres', 'max_minutes', 'max_players', 'min_age', 'min_minutes', 'min_players', @@ -439,25 +472,29 @@ select is((select count(*) from public.game_publishers gp join public.games g on select is((select count(*) from public.game_genres gg join public.games g on g.id = gg.game_id where g.title = 'Beispielspiel Runbook'), 1::bigint, 'Runbook 3: Genre zugeordnet'); +select is((select count(*) from ( +select id, title, year, hidden_at from public.games +where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook')) f), 1::bigint, 'Runbook 3: Suche findet das Spiel'); + -- Abschnitt 3: Fakten ändern update public.games set min_age = 10, max_minutes = 90 -where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; select is((select min_age from public.games where title = 'Beispielspiel Runbook'), 10::smallint, 'Runbook 3: Fakten ändern'); -- Abschnitt 3: Markierung bewusst entfernen update public.games set manual_fields = array_remove(manual_fields, 'min_age') -where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; select is((select 'min_age' = any(manual_fields) from public.games where title = 'Beispielspiel Runbook'), false, 'Runbook 3: Markierung entfernen'); -- Abschnitt 4: ausblenden update public.games set hidden_at = now() -where title = 'Beispielspiel Runbook' and visibility = 'catalog' and hidden_at is null; +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog' and hidden_at is null; select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 0::bigint, 'KAT-05 Kriterium 8: Runbook 4, ausgeblendet'); -- Abschnitt 4: wieder einblenden update public.games set hidden_at = null -where title = 'Beispielspiel Runbook' and visibility = 'catalog'; +where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog'; select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 1::bigint, 'KAT-05 Kriterium 8: Runbook 4, wieder eingeblendet');