Utgangspunktet

This commit is contained in:
Erol Haagenrud 2026-07-16 07:26:04 +02:00
parent 5873db18d1
commit 1b5362201e
11 changed files with 607 additions and 2 deletions

55
002_roles_and_grants.sql Normal file
View file

@ -0,0 +1,55 @@
-- =====================================================================
-- TeeCup — dedikert applikasjonsrolle + grants (migrasjon 002)
-- =====================================================================
-- Kjøres av en admin/superbruker (f.eks. teeoff_admin).
--
-- HVORFOR: RLS beskytter IKKE mot en superbruker. Postgres lar superuser og
-- roller med BYPASSRLS hoppe fullstendig over alle policyer. Runtime-appen MÅ
-- derfor koble til med en rolle som verken er superuser eller har BYPASSRLS.
-- Denne rollen eier heller ikke tabellene (tabellene har dessuten FORCE RLS),
-- så den er alltid underlagt policyene.
--
-- Passord: sett det utenfra, ikke i denne fila. Kjør f.eks.:
-- psql -d teecup_db -v teecup_app_password="$(cat /path/to/secret)" \
-- -f 002_roles_and_grants.sql
-- (Aldri sjekk inn et ekte passord i git.)
-- =====================================================================
\set ON_ERROR_STOP on
-- --- Rolle (cluster-nivå: opprett bare hvis den ikke finnes) ----------
-- Eksplisitt NOSUPERUSER / NOBYPASSRLS — dette er hele poenget.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'teecup_app') THEN
CREATE ROLE teecup_app
LOGIN
PASSWORD :'teecup_app_password'
NOSUPERUSER
NOCREATEDB
NOCREATEROLE
NOBYPASSRLS
INHERIT;
END IF;
END $$;
-- --- Grants (kjøres mens du er koblet til teecup_db) ------------------
GRANT CONNECT ON DATABASE teecup_db TO teecup_app;
GRANT USAGE ON SCHEMA public TO teecup_app;
-- Runtime-appen trenger vanlig DML på domenetabellene, men skal IKKE kunne
-- endre skjema eller policyer (det følger av at den ikke eier tabellene og
-- ikke er superuser).
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO teecup_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO teecup_app;
-- Samme rettigheter automatisk på fremtidige tabeller/sekvenser laget av
-- migrasjonseieren.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO teecup_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO teecup_app;
-- MERK: teecup_app får bevisst IKKE rettigheter til å endre RLS. Bootstrap av
-- nye organisasjoner (INSERT i organization) skjer via en privilegert sti i
-- auth-laget, ikke med runtime-rollen.

View file

@ -0,0 +1,98 @@
-- =====================================================================
-- TeeCup — migrasjon 003
-- Scoring-modus (ADR-012), blind draw (ADR-013), to-lags-format (ADR-011)
-- =====================================================================
-- Kjøres etter 001 (skjema) og 002 (roller). Idempotent der det er lett å få til.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- 1. Scoring-modus per økt (ADR-012)
-- ---------------------------------------------------------------------
-- 'stroke' : spillere taster slag per hull -> hole_score. Motoren utleder
-- netto og hull-resultat.
-- 'hole_result' : registrer bare hvem som vant hullet -> match_hole_result.
ALTER TABLE session
ADD COLUMN scoring_mode text NOT NULL DEFAULT 'stroke'
CHECK (scoring_mode IN ('stroke', 'hole_result'));
-- ---------------------------------------------------------------------
-- 2. Forenklet scoreføring: matchresultat per hull (ADR-012)
-- ---------------------------------------------------------------------
-- Brukes når session.scoring_mode = 'hole_result'. winning_side NULL = delt hull.
-- compute_match_state i motoren mates fra enten hole_score (stroke) eller denne.
CREATE TABLE match_hole_result (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
match_id uuid NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
winning_side team_side, -- NULL = delt (halved)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, match_id)
REFERENCES match(organization_id, id) ON DELETE CASCADE,
UNIQUE (match_id, hole_number)
);
CREATE INDEX ON match_hole_result (organization_id, match_id);
CREATE TRIGGER trg_match_hole_result_updated
BEFORE UPDATE ON match_hole_result
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------
-- 3. Blind draw: lås per lag per økt (ADR-013)
-- ---------------------------------------------------------------------
-- En økt er "avslørt" når det finnes en lineup_lock for BEGGE lag. Appen skjuler
-- motstanderens match_participant til da, og hindrer endring av egen oppstilling
-- etter lås. (Finkornet synlighet i app-laget, ikke RLS — begge lag samme org.)
CREATE TABLE lineup_lock (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
session_id uuid NOT NULL,
team_id uuid NOT NULL,
locked_at timestamptz NOT NULL DEFAULT now(),
locked_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
FOREIGN KEY (organization_id, session_id)
REFERENCES session(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, team_id)
REFERENCES team(organization_id, id) ON DELETE CASCADE,
UNIQUE (session_id, team_id) -- ett lås per lag per økt
);
CREATE INDEX ON lineup_lock (organization_id, session_id);
-- ---------------------------------------------------------------------
-- 4. RLS for de nye tabellene (samme org-isolasjon som resten, ADR-003)
-- ---------------------------------------------------------------------
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['match_hole_result', 'lineup_lock']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
EXECUTE format($p$
CREATE POLICY org_isolation ON %I
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
$p$, t);
END LOOP;
END $$;
-- Grants til runtime-rollen (den eier ikke tabellene, så den trenger eksplisitt DML).
GRANT SELECT, INSERT, UPDATE, DELETE ON match_hole_result TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON lineup_lock TO teecup_app;
-- ---------------------------------------------------------------------
-- 5. To-lags-format (ADR-011)
-- ---------------------------------------------------------------------
-- MERK: "nøyaktig to lag per turnering" håndheves i app-laget ved
-- turneringsoppsett, ikke som DB-trigger (holder match-modellen generell slik at
-- flere lag / knockout kan komme senere uten dataomskriving). Ingen skjemaendring
-- her — dette er kun en påminnelse om regelen.

View file

@ -173,6 +173,91 @@ alternativ kan tilbys senere som konfig per turnering uten skjemaendring.
---
## ADR-009 — Egen innlogging, uavhengig av teeoff
**Beslutning:** TeeCup har egne brukerkontoer (`app_user`), uavhengig av teeoffs
innlogging.
**Begrunnelse:** TeeCup selges kommersielt til klubber/bedrifter som kanskje aldri
har hørt om teeoff.no. Delt innlogging ville vært forvirrende for dem og koblet de
to systemenes auth tettere sammen enn ellers ønskelig. Styrker isolasjonen fra
ADR-004.
**Konsekvens:** Egen auth (registrering, Google/magic-link e.l.), egne secrets
(jf. åpent spørsmål 1), egen sesjonshåndtering. Kaptein- og tilskuer-roller
defineres innenfor TeeCups eget auth-lag.
---
## ADR-010 — Feed krever innlogging (ingen anonym lesesti i v1)
**Beslutning:** Den «offentlige» runde-feeden er felles for begge lag i
turneringen, men bak innlogging. Ingen verdenssynlig lenke i v1.
**Begrunnelse:** «Offentlig» betyr her «alle innloggede deltakere i turneringen»,
ikke «hvem som helst med en lenke». Dette fjerner den uautentiserte lesestien og
den tunge modereringen (som ellers kreves når innhold er synlig for verden) fra
v1.
**Konsekvens:** Synlighet modelleres som kanal-scope (lag / turnering). En ekte
verdenssynlig delingslenke kan legges til senere som egen bryter, med moderering,
uten å bygge om.
---
## ADR-011 — Turneringsstruktur: lagformat i v1, bracket som egen fremtidig type
**Beslutning:** v1 bygges for Ryder Cup-lagformatet (økter → uavhengige matcher →
summerte poeng), og låses til **nøyaktig to lag**.
**Begrunnelse:** En match/flight er alltid to-sidig. Tre lag ville krevd
kryss-oppgjør (AB, AC, BC), nok spillere til å møte to motstandere samtidig,
balansert oppsett, og tre-veis blind draw — mye kompleksitet for lite gevinst. To
lag er både enklere og riktigere for formatet. Et knockout-bracket (f.eks. 128
spillere, utslagsmatcher, finale + bronsefinale) er en helt egen turneringstype:
rundene er *avhengige* (vinner av match A møter vinner av match B), og krever
progresjon mellom matcher, seeding, fripass og bronsegren — noe dagens modell ikke
har.
**Konsekvens:** To-lags-grensen håndheves i app-laget (validering ved
turneringsoppsett), ikke med DB-trigger. Match-modellen holdes generell (to sider,
vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme senere
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
Knockout er fanget som egen type i FEATURE_BACKLOG.md.
---
## ADR-012 — To scoring-moduser per økt
**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per
hull) eller `hole_result` (registrer bare hvem som vant hullet / delt).
**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller
raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang.
**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og
hull-resultat). `hole_result` skriver til ny tabell `match_hole_result`
(vinnende side per hull, NULL = delt). Begge mater samme
`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus.
---
## ADR-013 — Blind draw via lås per lag per økt
**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har
låst. Modellert med tabell `lineup_lock` (én rad per lag per økt).
**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og
oppgjørene avsløres samtidig.
**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men
motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge
lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet
(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme
organisasjon (jf. ADR-010).
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

View file

@ -40,7 +40,7 @@
rettigheter (sette oppstilling), ikke bare være et flagg.
### Blind draw (skjult lagoppstilling)
- **Status:** 📋 planlagt (i din egen utviklingsplan)
- **Status:** 🔨 skjema bygget (migrasjon 003, `lineup_lock`) — app-logikk gjenstår
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
ferdige.
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst →
@ -48,7 +48,7 @@
e.l. Ikke bygget.
### Forenklet scoreføring (uten slagtall)
- **Status:** ❓ trenger beslutning (påvirker skjema)
- **Status:** 🔨 skjema bygget (migrasjon 003) — begge moduser besluttet
- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet /
delt».
- **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren
@ -76,6 +76,14 @@
- Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard-viewet,
men ikke sanntidsleveringen. Valg: WebSockets vs. polling.
### Knockout / cup-turnering (egen turneringstype)
- **Status:** 💤 utsatt (egen fremtidig type, ADR-011)
- Utslagsmatcher: 128 spillere → finale + bronsefinale. Rundene er *avhengige*
(vinner går videre), krever bracket/progresjon, seeding, fripass. IKKE i v1.
- v1 er låst til nøyaktig to lag (Ryder Cup-format). Match-modellen er generell, så
denne typen kan legges til senere uten dataomskriving — den legger bare til et
progresjons-lag.
---
## Kommunikasjon (under design)

0
app/__init__.py Normal file
View file

71
app/auth.py Normal file
View file

@ -0,0 +1,71 @@
"""
Auth-lag for TeeCup.
Selve innloggingen er en STUB her (erstattes med ekte sesjon/JWT senere). Det som
IKKE er en stub, og som er sikkerhetskritisk, er `get_authorized_org`: den
verifiserer at den innloggede brukeren faktisk er medlem av organisasjonen før
org-konteksten settes.
Hvorfor det er kritisk: RLS stoler blindt `app.current_org`. Setter appen den
til en organisasjon brukeren ikke tilhører, gir RLS lydig tilgang til den
organisasjonens data. Isolasjonen står og faller altså at denne sjekken skjer
FØR org_connection kalles aldri sett konteksten fra en uverifisert kilde (som
en klient-oppgitt header).
"""
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, status
from .db import plain_connection
@dataclass(frozen=True)
class CurrentUser:
user_id: str
async def get_current_user(
x_debug_user_id: str | None = Header(default=None),
) -> CurrentUser:
"""STUB: hent brukeren fra en verifisert sesjon.
Midlertidig leses bruker-id fra en header for at skjelettet skal kunne kjøres.
ERSTATT dette med validering av sesjonscookie/JWT (signert med
settings.SESSION_SECRET) før noe settes i produksjon. En header er IKKE en
autentisert kilde.
"""
if not x_debug_user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ikke autentisert (stub forventer X-Debug-User-Id).",
)
return CurrentUser(user_id=x_debug_user_id)
async def get_authorized_org(
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> str:
"""Returnerer organization_id KUN hvis brukeren er medlem — ellers 403.
Medlemskapstabellen er ikke organisasjonsavgrenset, oppslaget gjøres en
tilkobling uten org-kontekst.
"""
async with plain_connection() as conn:
is_member = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM organization_membership
WHERE user_id = $1 AND organization_id = $2
)
""",
user.user_id,
organization_id,
)
if not is_member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Brukeren er ikke medlem av denne organisasjonen.",
)
return organization_id

34
app/config.py Normal file
View file

@ -0,0 +1,34 @@
"""
Konfigurasjon for TeeCup-API-et.
ADR (åpent spørsmål 1): TeeCup bruker EGNE, uavhengige secrets den skal ikke
falle tilbake teeoff sine. Manglende variabel skal feile høyt ved oppstart,
ikke stille bruke en usikker standard.
"""
import os
def _required(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(
f"Miljøvariabelen {name} mangler. TeeCup starter ikke uten den "
f"(ingen fallback til teeoff sine verdier — se ADR)."
)
return value
class Settings:
# Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin).
# Format: postgresql://teecup_app:<passord>@<host>:<port>/teecup_db
DATABASE_URL: str = _required("TEECUP_DATABASE_URL")
# Egen sesjons-secret, uavhengig av teeoff.
SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET")
DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2"))
DB_POOL_MAX: int = int(os.getenv("TEECUP_DB_POOL_MAX", "10"))
settings = Settings()

81
app/db.py Normal file
View file

@ -0,0 +1,81 @@
"""
Databaselag for TeeCup.
Det kritiske her er hvordan organisasjonskonteksten (`app.current_org`) settes:
* Den settes med set_config(..., is_local => true), dvs. SET LOCAL verdien
gjelder KUN inne i den aktive transaksjonen og nullstilles automatisk når
transaksjonen avsluttes.
* Uten dette (en vanlig SET en pool-tilkobling) ville konteksten blitt
liggende tilkoblingen når den returneres til poolen, og neste forespørsel
potensielt en ANNEN organisasjon ville arvet den. Det ville brutt
isolasjonen ved gjenbruk av tilkoblinger.
Hver forespørsel som rører organisasjonsdata kjører altså i én transaksjon med
org-konteksten satt lokalt.
"""
from contextlib import asynccontextmanager
from typing import AsyncIterator
import asyncpg
from .config import settings
_pool: asyncpg.Pool | None = None
async def init_pool() -> None:
global _pool
_pool = await asyncpg.create_pool(
dsn=settings.DATABASE_URL,
min_size=settings.DB_POOL_MIN,
max_size=settings.DB_POOL_MAX,
)
async def close_pool() -> None:
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def _require_pool() -> asyncpg.Pool:
if _pool is None:
raise RuntimeError("DB-poolen er ikke initialisert (kall init_pool ved oppstart).")
return _pool
@asynccontextmanager
async def plain_connection() -> AsyncIterator[asyncpg.Connection]:
"""Tilkobling UTEN org-kontekst.
Brukes kun til auth-laget (f.eks. oppslag i organization_membership, som
ikke er organisasjonsavgrenset). Rør aldri organisasjonsdata her.
"""
pool = _require_pool()
async with pool.acquire() as conn:
yield conn
@asynccontextmanager
async def org_connection(organization_id: str) -> AsyncIterator[asyncpg.Connection]:
"""Tilkobling med org-kontekst satt lokalt for én transaksjon.
All lesing/skriving av organisasjonsdata skal gjennom denne. RLS filtrerer
da automatisk organization_id = app.current_org.
VIKTIG: `organization_id` forhånd være verifisert mot brukerens
medlemskap (se auth.get_authorized_org). Denne funksjonen stoler at
verdien allerede er autorisert.
"""
pool = _require_pool()
async with pool.acquire() as conn:
async with conn.transaction():
# SET LOCAL via set_config(..., true): nullstilles ved transaksjonsslutt.
await conn.execute(
"SELECT set_config('app.current_org', $1, true)",
str(organization_id),
)
yield conn

80
app/main.py Normal file
View file

@ -0,0 +1,80 @@
"""
TeeCup API skjelett.
Demonstrerer det org-scopede mønsteret: hver rute under /orgs/{organization_id}
verifiserer medlemskap (get_authorized_org) og kjører deretter mot en tilkobling
med org-konteksten satt lokalt (org_connection). RLS gjør resten.
Dette er et FUNDAMENT, ikke full CRUD. To ruter er nok til å vise mønsteret;
resten (økter, matcher, score, leaderboard) bygges samme lest.
Kjøres i app-containeren (FastAPI + asyncpg + uvicorn):
uvicorn app.main:app --host 0.0.0.0 --port 8000
"""
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from pydantic import BaseModel, Field
from .auth import get_authorized_org
from .db import init_pool, close_pool, org_connection
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_pool()
try:
yield
finally:
await close_pool()
app = FastAPI(title="TeeCup API", lifespan=lifespan)
class TournamentCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class Tournament(BaseModel):
id: str
name: str
status: str
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
# Ingen manuell 'WHERE organization_id = ...' — RLS filtrerer automatisk.
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC"
)
return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows]
@app.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
async def create_tournament(
body: TournamentCreate,
organization_id: str = Depends(get_authorized_org),
) -> Tournament:
# organization_id settes eksplisitt; WITH CHECK i RLS avviser uansett en verdi
# som ikke matcher konteksten — dobbel sikring.
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO tournament (organization_id, name)
VALUES ($1, $2)
RETURNING id::text, name, status::text
""",
organization_id,
body.name,
)
return Tournament(id=row["id"], name=row["name"], status=row["status"])

4
app/requirements.txt Normal file
View file

@ -0,0 +1,4 @@
fastapi>=0.111
uvicorn[standard]>=0.30
asyncpg>=0.29
pydantic>=2.7

89
test_isolation.sql Normal file
View file

@ -0,0 +1,89 @@
-- =====================================================================
-- TeeCup — isolasjonstest (oppførsel, ikke bare skjema)
-- =====================================================================
-- Beviser at RLS faktisk hindrer én organisasjon i å se en annens data,
-- sett fra runtime-rollen teecup_app (som IKKE er superuser/BYPASSRLS).
--
-- Kjør som admin/superbruker mot en scratch- eller test-database der
-- 001_initial_schema.sql og 002_roles_and_grants.sql allerede er kjørt:
-- psql -d teecup_scratch -f test_isolation.sql
--
-- Alt kjøres i én transaksjon som RULLES TILBAKE til slutt — ingen testdata
-- blir liggende igjen. Skriptet stopper med feil hvis isolasjonen svikter.
-- =====================================================================
\set ON_ERROR_STOP on
BEGIN;
-- --- Seed to organisasjoner + én turnering hver (som privilegert rolle) ---
-- (Superbruker omgår RLS, så innsettingen går uhindret her — det er meningen.)
INSERT INTO organization (id, name) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Org A'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Org B');
INSERT INTO tournament (organization_id, name) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup');
-- --- Bytt til runtime-rollen: nå SKAL RLS gjelde ---------------------
SET ROLE teecup_app;
SELECT set_config('app.current_org', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', false);
-- Test 1: Ser bare Org A sine turneringer (1, ikke 2).
DO $$
DECLARE n int;
BEGIN
SELECT count(*) INTO n FROM tournament;
IF n <> 1 THEN
RAISE EXCEPTION 'ISOLASJON FEILET (lesing): ser % turneringer, forventet 1', n;
END IF;
RAISE NOTICE 'OK Test 1: ser kun egen organisasjons turneringer (%).', n;
END $$;
-- Test 2: Ser bare sin egen organisasjonsrad.
DO $$
DECLARE n int;
BEGIN
SELECT count(*) INTO n FROM organization;
IF n <> 1 THEN
RAISE EXCEPTION 'ISOLASJON FEILET (organization): ser % rader, forventet 1', n;
END IF;
RAISE NOTICE 'OK Test 2: ser kun egen organisasjonsrad (%).', n;
END $$;
-- Test 3: Kan IKKE sette inn data merket med en annen organisasjon.
-- WITH CHECK skal blokkere det (SQLSTATE 42501).
DO $$
BEGIN
BEGIN
INSERT INTO tournament (organization_id, name)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk');
-- Kommer vi hit, ble det tillatt -> isolasjonen er brutt.
RAISE EXCEPTION 'ISOLASJON FEILET (skriving): fikk sette inn data for annen org';
EXCEPTION
WHEN insufficient_privilege THEN
RAISE NOTICE 'OK Test 3: WITH CHECK blokkerte kryss-org innsetting.';
END;
END $$;
-- Test 4: Kontrollprøve — bytter kontekst til Org B og ser Bs turnering isteden.
SELECT set_config('app.current_org', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', false);
DO $$
DECLARE nm text;
BEGIN
SELECT name INTO nm FROM tournament;
IF nm <> 'B-Cup' THEN
RAISE EXCEPTION 'ISOLASJON FEILET (kontekstbytte): forventet B-Cup, fikk %', nm;
END IF;
RAISE NOTICE 'OK Test 4: kontekstbytte gir riktig organisasjons data (%).', nm;
END $$;
RESET ROLE;
RESET app.current_org;
ROLLBACK; -- ingen testdata blir liggende igjen
\echo '============================================'
\echo ' Alle isolasjonstester bestått (se OK-linjer)'
\echo '============================================'