test(catalog): address review findings on PR 2a
All checks were successful
CI / flutter (pull_request) Successful in 1m28s
CI / web-build (pull_request) Successful in 59s
CI / supabase (pull_request) Successful in 9s
CI / supabase-tests (pull_request) Successful in 34s
CI / e2e (pull_request) Successful in 3m2s

Replace tests that could not fail (manual_fields stability, no-op update), select runbook games by id, add grant matrix and forged-admin tests, assert vocabulary seeding and Deno checksum in CI, clarify the set local convention and API notes.

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 18:18:32 +01:00
parent 52cbd79fb4
commit 9952efa2fe
6 changed files with 103 additions and 35 deletions

View file

@ -152,8 +152,14 @@ jobs:
# Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur. # Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur.
- name: Deno installieren - name: Deno installieren
run: | 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 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 unzip -q -o /tmp/deno.zip -d /usr/local/bin
deno --version deno --version
- name: Startbestand-Werkzeug testen - name: Startbestand-Werkzeug testen
@ -212,6 +218,22 @@ jobs:
test -f supabase/config.toml || { echo "supabase/config.toml fehlt"; exit 1; } test -f supabase/config.toml || { echo "supabase/config.toml fehlt"; exit 1; }
test -d supabase/migrations || { echo "Keine Migrationen – übersprungen"; exit 0; } 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 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 <<SQL
select count(*) from (values $expected) as v(kind, vocab_key)
where (v.kind = 'genre' and not exists (select 1 from public.genres t
where t.id = extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'genre:' || v.vocab_key)))
or (v.kind = 'mechanic' and not exists (select 1 from public.mechanics t
where t.id = extensions.uuid_generate_v5('6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4', 'mechanic:' || v.vocab_key)));
SQL
)
echo "Fehlende Vokabular-Einträge: $missing"
test "$missing" = "0"
- name: Datenbanktests (pgTAP) - name: Datenbanktests (pgTAP)
run: | run: |
test -d supabase/migrations || exit 0 test -d supabase/migrations || exit 0

View file

@ -343,9 +343,9 @@ Die Pflege läuft in R1 nur über Supabase Studio bzw. `psql` als Datenbankrolle
- **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf). - **Ausblenden:** `games.hidden_at` gesetzt = ausgeblendet. Die View `catalog_games` enthält nur Katalogspiele (`visibility = 'catalog'`) ohne `hidden_at` und ohne `merged_into_id`. **Suche und Entdecken lesen nur diese View.** Ein ausgeblendetes Spiel bleibt per ID lesbar (Sammlungen, Partien und Detailseite verweisen darauf).
- **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`). - **Zusammenführen:** `games.merged_into_id` zeigt auf das Zielspiel (Spalte seit 2a; die Funktion `admin_merge_games` kommt mit PR 2b). Ein Trigger lässt nur Katalogspiele als Ziel zu (Fehler `merged_into_not_catalog`).
- **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** `set local shuffle.catalog_import = 'on'`. Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder. - **Manuell gepflegte Felder:** Der Trigger `games_mark_manual_fields` trägt jedes Feld eines Katalogspiels, das ein Mensch setzt oder ändert, in `manual_fields` ein (beim Anlegen alle gesetzten Felder). Der Trigger `game_links_mark_manual` ergänzt `genres`, `mechanics`, `categories` und `publishers`, wenn Zuordnungen angelegt, geändert oder gelöscht werden. Ausgenommen ist nur ein späterer Import: Rolle `catalog_importer` **und** Einstellung `shuffle.catalog_import = 'on'` (der Import setzt sie per `set local`; das ist Konvention, die Rolle ist die Sperre). Private Spiele werden nicht gekennzeichnet. Eine bewusst entfernte Markierung entfernt der Trigger nicht wieder.
- **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import). - **Anzeige-Erlaubnis:** `catalog_games.description` und `image_path` sind leer, wenn die Quelle (`catalog_sources`) Texte bzw. Bilder nicht erlaubt und das Feld nicht in `manual_fields` steht. Das ist nur die zweite Sicherung: Daten ohne Erlaubnis dürfen gar nicht erst gespeichert werden (Pflicht für den Import).
- **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`. - **Spaltenrechte auf `games`:** Nutzer dürfen nur `owner_id` (nur beim Anlegen), `title`, `description`, `description_locale`, `year`, `min_players`, `max_players`, `min_minutes`, `max_minutes`, `min_age`, `image_path`, `bgg_id` und `visibility` schreiben, nicht `id`, `hidden_at`, `merged_into_id`, `manual_fields`, `catalog_source_id` und `source_*`.
- **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung. - **Startbestand:** `supabase/catalog/` (CSV, Werkzeug, erzeugte SQL-Dateien); `supabase db reset` spielt Vokabular und Startbestand ein. Der Startbestand enthält nur Fakten und einen eigenen deutschen Kurztext, kein Cover, keine Quelle, keine BGG-Kennung.
- **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht. - **Konto löschen:** Neue Nutzerdaten entstehen nicht; die Zuordnungen privater Spiele (`game_publishers` usw.) werden mit dem Spiel gelöscht.
- **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id` mehr mitsenden (die ID kommt per `returning`). Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an. - **API-Änderungen für das Frontend:** neu lesbar für alle: View `catalog_games`, Tabellen `catalog_sources`, `publishers`, `game_publishers`. Beim Anlegen und Ändern eines privaten Spiels dürfen Clients keine `id`, `created_at` und `updated_at` mehr mitsenden (42501; die ID kommt per `returning`); ein `toJson()` eines Modells darf nie ungefiltert an `insert`/`update` gehen (Merkpunkt für #3 und #13). Der `is_admin()`-Zweig der Zuordnungs-Policies (`game_*_write`) greift für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` `manual_fields` schreiben muss; ein späterer Admin-Pfad (QUE-08) braucht eigene RPCs. Es gibt keine generierten Datenbanktypen im Repo; das Domänenmodell `CatalogGame` legt KAT-01 (#3) auf Basis der Spalten von `catalog_games` an.

View file

@ -11,7 +11,7 @@ Stand: PR 2a (Datenmodell, Startbestand, Ausblenden). Die Abschnitte **Zusammenf
- Studio läuft als Datenbankrolle `postgres` (bzw. `supabase_admin`) und **umgeht Row Level Security**. Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten. Mit `select current_user;` im SQL-Editor prüfen, unter welcher Rolle man arbeitet. - Studio läuft als Datenbankrolle `postgres` (bzw. `supabase_admin`) und **umgeht Row Level Security**. Wer Studio erreicht, kann alles lesen und ändern, auch private Nutzerdaten. Mit `select current_user;` im SQL-Editor prüfen, unter welcher Rolle man arbeitet.
- **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops.) - **Nicht öffentlich erreichbar** sein dürfen: Studio, der Postgres-Port 5432, der Pooler (Supavisor, Standard 6543) und die Kong-Route `/pg/` (pg-meta). Zugriff nur über SSH-Tunnel oder VPN; die Basic-Auth des Gateways (`DASHBOARD_USERNAME`/`DASHBOARD_PASSWORD`) bleibt mit starkem Passwort als zweite Schicht. Gilt ebenso für Staging. (Einrichtung und Bestätigung: Aufgabe D1, devops.)
- **Immer in einer Transaktion arbeiten:** `begin; … commit;` (bei Fehler `rollback;`). Vor größeren Änderungen den Stand des Backups prüfen. - **Immer in einer Transaktion arbeiten:** `begin; … commit;` (bei Fehler `rollback;`). Vor größeren Änderungen den Stand des Backups prüfen.
- **Niemals `set …` ohne `local` verwenden** (zum Beispiel `set shuffle.catalog_import`): Ohne `local` gilt die Einstellung für die ganze Sitzung. Der Import-Schalter `shuffle.catalog_import` ist ohnehin nur für die Rolle `catalog_importer` wirksam und gehört nicht ins Studio. - **Niemals `set …` ohne `local` verwenden** (zum Beispiel `set shuffle.catalog_import`): Ohne `local` gilt die Einstellung für die ganze Sitzung. Der Import-Schalter `shuffle.catalog_import` ist ohnehin nur für die Rolle `catalog_importer` wirksam und gehört nicht ins Studio. Hinweis: `set local` statt `set` ist eine Konvention des Imports; ein sitzungsweites `set` bleibt hinter einem Pooler bis zum Ende der Verbindung bestehen und kann auf die nächste Anfrage durchschlagen.
- Die Rolle `app_metadata.role = 'admin'` (JWT) wird in R1 **niemandem vergeben** (siehe 7). - Die Rolle `app_metadata.role = 'admin'` (JWT) wird in R1 **niemandem vergeben** (siehe 7).
## 2. Startbestand pflegen, einspielen und abnehmen ## 2. Startbestand pflegen, einspielen und abnehmen
@ -119,16 +119,23 @@ Je Regel eine Zeile (Soll, Ist, Status `ok`/`Verstoß`/`Information`). **Abnahme
Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt. Jede Änderung an einem Fakt eines Katalogspiels (und jede Zuordnung zu Verlag, Genre, Mechanik oder Kategorie) trägt der Trigger automatisch in `games.manual_fields` ein, damit ein späterer Import sie nicht überschreibt. Im Studio muss dafür nichts zusätzlich gepflegt werden. Als Rolle `postgres` bleibt das Kennzeichnen auch dann aktiv, wenn jemand `shuffle.catalog_import` setzt.
**Neues Katalogspiel mit Verlag und Genre** (Werte ersetzen; in einer Transaktion): **Spiel finden, bevor man es ändert.** Titel sind nicht eindeutig (ab PR 2b kann es gleichnamige Duplikate geben). Deshalb zuerst die ID nachschlagen und alle weiteren Bausteine über `id` ausführen:
```sql
select id, title, year, hidden_at from public.games
where visibility = 'catalog' and lower(title) = lower('Beispielspiel Runbook');
```
**Neues Katalogspiel mit Verlag und Genre** (Werte ersetzen; in einer Transaktion). Die ID vorher mit `select gen_random_uuid();` erzeugen und in diesem und den folgenden Bausteinen einsetzen (hier die Beispiel-ID):
```sql ```sql
insert into public.publishers (name) values ('Beispielverlag') insert into public.publishers (name) values ('Beispielverlag')
on conflict ((lower(name))) do nothing; on conflict ((lower(name))) do nothing;
with neu as ( 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) 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 returning id
), verlag as ( ), verlag as (
insert into public.game_publishers (game_id, publisher_id) insert into public.game_publishers (game_id, publisher_id)
@ -144,14 +151,14 @@ Weitere Mechaniken, Kategorien und Verlage ordnet man mit `insert into public.ga
```sql ```sql
update public.games set min_age = 10, max_minutes = 90 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';
``` ```
**Markierung „manuell gepflegt“ bewusst entfernen** (nur per SQL; eine Änderung allein an `manual_fields` wird vom Trigger nicht zurückgesetzt): **Markierung „manuell gepflegt“ bewusst entfernen** (nur per SQL; eine Änderung allein an `manual_fields` wird vom Trigger nicht zurückgesetzt):
```sql ```sql
update public.games set manual_fields = array_remove(manual_fields, 'min_age') 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';
``` ```
Regeln: Regeln:
@ -168,11 +175,11 @@ Ausgeblendete Spiele (`hidden_at` gesetzt) erscheinen nicht mehr in Suche und En
```sql ```sql
-- ausblenden -- ausblenden
update public.games set hidden_at = now() 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;
-- wieder einblenden -- wieder einblenden
update public.games set hidden_at = null update public.games set hidden_at = null
where title = 'Beispielspiel Runbook' and visibility = 'catalog'; where id = '7a1b2c3d-0000-4000-8000-000000000001' and visibility = 'catalog';
``` ```
Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ist öffentlich lesbar). Muss ein Spiel aus rechtlichen Gründen wirklich verschwinden, zusätzlich Titel und Texte inhaltlich leeren. Hinweis: Der Eintrag bleibt über die rohe Schnittstelle per ID lesbar (`games` ist öffentlich lesbar). Muss ein Spiel aus rechtlichen Gründen wirklich verschwinden, zusätzlich Titel und Texte inhaltlich leeren.
@ -193,4 +200,6 @@ In R1 gibt es **kein App-Konto mit Admin-Rolle** (E10, Klärung 44). Die vorhand
update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = '<nutzer-id>'; update auth.users set raw_app_meta_data = raw_app_meta_data || '{"role":"admin"}' where id = '<nutzer-id>';
``` ```
**Nicht in R1 ausführen.** Nie `user_metadata` dafür verwenden (vom Client änderbar). **Nicht in R1 ausführen und nicht in pgTAP getestet.** Nie `user_metadata` dafür verwenden (vom Client änderbar).
Hinweis für QUE-08 (R4): Der `is_admin()`-Zweig der Policies `game_*_write` (Genre, Mechanik, Kategorie, Verlag) funktioniert für Katalogspiele faktisch nicht mehr, weil der Trigger `game_links_mark_manual` (security invoker) `games.manual_fields` schreiben muss und `authenticated` dafür kein Spaltenrecht hat. Ein Admin-Pfad über die API braucht eigene RPCs (security definer).

View file

@ -693,7 +693,7 @@ Umgesetzt: B1, B2, B3a, B3, B4 und die beiden CI-Schritte aus D1 (Deno-Tests und
- **Branch:** `feature/2a-katalog-datenmodell` statt `feature/2-katalog-fundament` (Vorgabe des Auftrags). - **Branch:** `feature/2a-katalog-datenmodell` statt `feature/2-katalog-fundament` (Vorgabe des Auftrags).
- **CLI-Annahmen (R11) geprüft** mit Supabase CLI 3.0.0, Ergebnis in `docs/katalog-pflege.md` 2.7: Reihenfolge und `begin`/`commit` funktionieren; ein Fehler rollt die ganze Datei zurück (Exit 1); ein Pfad ohne Treffer ist nur eine Warnung (Exit 0). Die erzeugten Dateien enthalten kein `begin`/`commit`. - **CLI-Annahmen (R11) geprüft** mit Supabase CLI 3.0.0, Ergebnis in `docs/katalog-pflege.md` 2.7: Reihenfolge und `begin`/`commit` funktionieren; ein Fehler rollt die ganze Datei zurück (Exit 1); ein Pfad ohne Treffer ist nur eine Warnung (Exit 0). Die erzeugten Dateien enthalten kein `begin`/`commit`.
- **Nicht in 2a:** `games_visibility_immutable` (2c, wie in 3.4). - **Nicht in 2a:** `games_visibility_immutable` (2c, wie in 3.4).
- **API-Hinweis für das Frontend:** `catalog_games` ist über PostgREST nicht schreibbar (Fehler 55000 „cannot insert/update view“, zusätzlich fehlen die Rechte). Clients dürfen beim Anlegen oder Ändern von `games` keine `id`, `created_at` und `updated_at` mehr senden (42501; bisher setzte der Trigger `set_updated_at` diese Werte stillschweigend zurück). Ein `toJson()` eines Modells darf deshalb nicht unverändert an `insert`/`update` gehen, sondern nur die erlaubten Spalten (3.2). Es gibt keine generierten Datenbanktypen im Repo. - **API-Hinweis für das Frontend:** `catalog_games` ist über PostgREST nicht schreibbar (Fehler 55000 „cannot insert/update view“, zusätzlich fehlen die Rechte). Clients dürfen beim Anlegen oder Ändern von `games` keine `id`, `created_at` und `updated_at` mehr senden (42501; bisher setzte der Trigger `set_updated_at` diese Werte nur bei UPDATE stillschweigend zurück, bei INSERT wurden `id` und `created_at` vom Client übernommen). Ein `toJson()` eines Modells darf deshalb nicht unverändert an `insert`/`update` gehen, sondern nur die erlaubten Spalten (3.2); Merkpunkt für KAT-01 (#3) und KAT-04 (#13). Außerdem funktioniert der `is_admin()`-Zweig der `game_*_write`-Policies für Katalogspiele faktisch nicht mehr (der Trigger `game_links_mark_manual` braucht Schreibrecht auf `manual_fields`; R8), relevant für QUE-08. Es gibt keine generierten Datenbanktypen im Repo.
- **Ergänzung `startbestand.sql`:** Ein Wächter am Anfang bricht ab, wenn ein Genre oder eine Mechanik aus der CSV in der Datenbank fehlt; sonst würde der Abgleich der Zuordnungen still Verknüpfungen löschen. - **Ergänzung `startbestand.sql`:** Ein Wächter am Anfang bricht ab, wenn ein Genre oder eine Mechanik aus der CSV in der Datenbank fehlt; sonst würde der Abgleich der Zuordnungen still Verknüpfungen löschen.
- **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten. - **Ergänzung Testdaten:** `supabase/catalog/testdata/` (fiktives Vokabular und drei Testspiele) erzeugt `supabase/tests/support/*.inc`, die `06_catalog_foundation.test.sql` per `\ir` einbindet; so laufen Idempotenz und `checks.sql` im pgTAP-Test mit den echten erzeugten Skripten.
- **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben. - **`game_links_mark_manual`:** prüft zuerst per `select`, ob das Spiel ein Katalogspiel ist, und schreibt nur dann `manual_fields`. Ohne diese Prüfung scheiterte jede Zuordnung zu einem privaten Spiel mit 42501, weil Nutzer kein Schreibrecht auf `manual_fields` haben.

View file

@ -127,7 +127,7 @@ begin
if new.visibility <> 'catalog' then if new.visibility <> 'catalog' then
return new; return new;
end if; 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). -- Ein bloßes set im Studio (Rolle postgres) schaltet die Kennzeichnung nicht ab (W7).
if current_user = 'catalog_importer' if current_user = 'catalog_importer'
and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then and coalesce(current_setting('shuffle.catalog_import', true), '') = 'on' then

View file

@ -3,7 +3,7 @@
-- Läuft auch mit eingespieltem Startbestand: eigene Fixtures mit festen IDs, gezählt wird nur darüber. -- 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/. -- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts aus catalog/testdata/.
begin; begin;
select plan(133); select plan(148);
insert into auth.users (id, email, raw_user_meta_data) values 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"}'), ('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'), 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'], array['description', 'description_locale', 'min_age', 'min_players', 'title', 'year'],
'KAT-05 Kriterium 1: Update als postgres ergänzt das geänderte Feld'); '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'; 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'), 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'); 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'; 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'), 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'); 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'), select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000a2'),
'{}'::text[], 'Private Spiele werden nicht gekennzeichnet'); '{}'::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; create role catalog_importer nologin;
grant catalog_importer to postgres with set true; grant catalog_importer to postgres with set true;
grant usage on schema public to catalog_importer; 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 shuffle.catalog_import;
reset role; reset role;
select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), 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; set local role catalog_importer;
update public.games set year = 2011 where id = 'f6000000-0000-0000-0000-0000000000f1'; update public.games set year = 2011 where id = 'f6000000-0000-0000-0000-0000000000f1';
reset role; reset role;
select is((select manual_fields from public.games where id = 'f6000000-0000-0000-0000-0000000000f1'), 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 -- Zuordnungen als Pseudo-Felder
update public.games set manual_fields = '{}' where id = 'f6000000-0000-0000-0000-0000000000f1'; 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'), 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'); 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 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'; select ctid::text as c 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') insert into public.game_genres (game_id, genre_id) values ('f6000000-0000-0000-0000-000000000002', 'f6000000-0000-0000-0000-0000000000e1');
on conflict do nothing; select is((select count(*) from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'), 1::bigint,
delete from public.game_genres where game_id = 'f6000000-0000-0000-0000-000000000002'; '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 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) -- 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 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)$$, where not (select relrowsecurity from pg_class where oid = ('public.' || t)::regclass)$$,
'Die neuen Tabellen haben Row Level Security'); '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 -- Besucher
set local role anon; 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'); '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, select throws_ok($$delete from public.game_publishers where game_id = 'f6000000-0000-0000-0000-000000000001'$$, '42501', null,
'Besucher löschen keine Verlagszuordnungen'); '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 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 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)$$, 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'); '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$$, 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'); '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; reset role;
-- Ben -- 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; 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 -- Abschnitt 3: Neues Katalogspiel mit Verlag und Genre
insert into public.publishers (name) values ('Beispielverlag') insert into public.publishers (name) values ('Beispielverlag')
on conflict ((lower(name))) do nothing; on conflict ((lower(name))) do nothing;
with neu as ( 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) 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 returning id
), verlag as ( ), verlag as (
insert into public.game_publishers (game_id, publisher_id) 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) 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 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'); 'KAT-05 Kriterium 8: Runbook 3, Spiel anlegen');
select is((select manual_fields from public.games where title = 'Beispielspiel Runbook'), 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', 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 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'); 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 -- Abschnitt 3: Fakten ändern
update public.games set min_age = 10, max_minutes = 90 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'); select is((select min_age from public.games where title = 'Beispielspiel Runbook'), 10::smallint, 'Runbook 3: Fakten ändern');
-- Abschnitt 3: Markierung bewusst entfernen -- Abschnitt 3: Markierung bewusst entfernen
update public.games set manual_fields = array_remove(manual_fields, 'min_age') 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, select is((select 'min_age' = any(manual_fields) from public.games where title = 'Beispielspiel Runbook'), false,
'Runbook 3: Markierung entfernen'); 'Runbook 3: Markierung entfernen');
-- Abschnitt 4: ausblenden -- Abschnitt 4: ausblenden
update public.games set hidden_at = now() 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, select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 0::bigint,
'KAT-05 Kriterium 8: Runbook 4, ausgeblendet'); 'KAT-05 Kriterium 8: Runbook 4, ausgeblendet');
-- Abschnitt 4: wieder einblenden -- Abschnitt 4: wieder einblenden
update public.games set hidden_at = null 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, select is((select count(*) from public.catalog_games where title = 'Beispielspiel Runbook'), 1::bigint,
'KAT-05 Kriterium 8: Runbook 4, wieder eingeblendet'); 'KAT-05 Kriterium 8: Runbook 4, wieder eingeblendet');