From d89e22f09ce63765d9679c2b7b8bc00cc9a55efc Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 10 Aug 2026 22:25:02 +0200 Subject: [PATCH] Automatisert backend-testinfrastruktur: RLS, versjonssjekk, aldersgrense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Svarer på det største enkeltfunnet i investor-statusrapporten (nesten ingen automatisert testdekning utenfor HCP-motoren). 17 nye integrasjonstester mot en automatisk opprettet/migrert/nedrevet scratch-database (scripts/run_backend_tests.sh), som kaller de faktiske router-/auth-funksjonene direkte -- ikke en SQL-gjenimplementering. Se ADR-058 og CHANGELOG punkt 67 for full detalj. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + ARCHITECTURE_DECISIONS.md | 91 ++++++++++++++++ CHANGELOG.md | 53 ++++++++- Dockerfile.test | 17 +++ app/requirements-dev.txt | 4 + pytest.ini | 2 + scripts/run_backend_tests.sh | 91 ++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 120 +++++++++++++++++++++ tests/test_auth_age_gate.py | 82 ++++++++++++++ tests/test_concurrency_version_check.py | 138 ++++++++++++++++++++++++ tests/test_rls_isolation.py | 79 ++++++++++++++ tests/test_smoke.py | 22 ++++ 13 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.test create mode 100644 app/requirements-dev.txt create mode 100644 pytest.ini create mode 100755 scripts/run_backend_tests.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_auth_age_gate.py create mode 100644 tests/test_concurrency_version_check.py create mode 100644 tests/test_rls_isolation.py create mode 100644 tests/test_smoke.py diff --git a/.gitignore b/.gitignore index aac7154..6fe5ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc Temp-uploads/ +.venv/ +.pytest_cache/ diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index e6ca875..9766cfe 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -5533,6 +5533,97 @@ siden verken den opprinnelige eksterne vurderingen eller brukerens oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det trengs der også. +--- + +## ADR-058: Automatisert backend-testinfrastruktur — RLS, versjonssjekk, aldersgrense — 2026-08-10 + +Direkte oppfølging av investor-statusrapporten (kodebasert revisjon, +samme dag): "nesten ingen automatisert testdekning utenfor HCP-motoren" +pekt ut som det klart største enkeltfunnet. Bruker ba om å ta tak i +akkurat dette, med to avklarte avgrensninger: (1) usikkert om Forgejo +Actions faktisk har en registrert runner for dette repoet på +forgejo.jegvil.no — bekreftet KUN at instansen har `has_actions: true` +for repoet (anonymt API-kall), ikke at en runner faktisk plukker opp +jobber. CI-workflow-fil er derfor BEVISST utenfor denne runden — egen, +senere runde, verifisert empirisk ved faktisk push. (2) Frontend- +testoppsett (Vitest/Playwright) valgt bort denne runden — kun backend. + +**Beslutning A — ekte integrasjonstester mot en automatisert scratch- +database, ikke mot mocks.** Samme prinsipp som all manuell scratch- +verifisering i prosjektet til nå (se CLAUDE.md), men automatisert i +`scripts/run_backend_tests.sh`: oppretter en midlertidig database + +en midlertidig rolle i den SAMME `teeoff_db`-Postgres-containeren, +kjører ALLE 62 migrasjonene i rekkefølge, kjører testene, dropper +database + rolle igjen. Rører aldri ekte `teecup_db` — bekreftet +eksplisitt etter hver kjøring (ACL uendret, ingen gjenglemte +scratch-ressurser). + +Migrasjon 002 hardkoder BÅDE rollenavnet `teecup_app` (cluster-globalt +navn, delt med den ekte rollen) OG databasenavnet `teecup_db` i én +GRANT-setning. Begge patches med `sed` (ordgrense-presist, unngår å +treffe `teecup_app_password`/`teecup_app_exists`-variabelnavnene i +samme fil) til scratch-spesifikke navn før migrasjonen kjøres — samme +sed-patch-mønster som er brukt manuelt i alle tidligere scratch-runder +i prosjektet, nå skriptet. + +**Beslutning B — testene kaller de FAKTISKE router-/auth-funksjonene +direkte (`app.routers.rounds.update_hole`, `app.auth.get_authorized_org`, +`app.routers.auth.update_profile`), ikke en gjenimplementering av +logikken i SQL.** FastAPI sin `Depends()`-injeksjon trengs ikke når +funksjonen kalles direkte i Python — de resolverte verdiene (en +`CurrentUser`, en allerede-verifisert `organization_id`) sendes rett +inn som vanlige argumenter. Dette betyr testene kjører gjennom SAMME +kodesti som produksjon (samme SQL, samme feilhåndtering, samme +`app_error`) — IKKE en parallell test-bare implementasjon som kunne +drevet fra virkeligheten uten å bli fanget opp. + +Testene kjører i en dedikert `Dockerfile.test`-container (pytest + +pytest-asyncio, IKKE del av prod-imaget) tilkoblet samme +`teeoff_default`-Docker-nettverk som `teecup_api` selv bruker i +produksjon — nødvendig fordi Postgres-containeren ikke har noen +host-publisert port i dette miljøet, kun cluster-intern DNS +(`teeoff_db:5432`, nøyaktig slik den ekte appen kobler til). +`lifespan`/MinIO-oppstart (`app.main`) er BEVISST unngått — testene +importerer routerne direkte i stedet for å boote hele ASGI-appen, så +ingen ekte MinIO-bucket berøres av denne runden. + +**Beslutning C — tre dekningsområder, valgt etter risiko, ikke +fullstendighet.** 17 tester totalt: +- `tests/test_rls_isolation.py` (4 tester) — automatiserer + `test_isolation.sql`s manuelle sjekker: cross-org SELECT lekker + aldri rader (selv uten eksplisitt WHERE i spørringen), cross-org + UPDATE påvirker 0 rader, INGEN org-kontekst satt gir TOMT resultat + (ikke "alt" — regresjonsvern for migrasjon 005s NULL-guard-fiks), + og `get_authorized_org` avviser et faktisk ikke-medlem FØR noen + org-scopet spørring i det hele tatt kjøres. +- `tests/test_concurrency_version_check.py` (6 tester) — ADR-057s + versjonssjekk, nå automatisert: vellykket skrivning inkrementerer + versjon, en utdatert `expected_version` gir 409 STALE_VERSION, + `expected_version=None` hopper bevisst over sjekken + (bakoverkompatibilitet), et ikke-eksisterende hull gir 404 (ikke + 409) — alt dekket for BÅDE `update_hole` (deltaker-eide hull) og + `update_side_hole` (side-eide hull, egen 404-vs-409-logikk lagt til + i samme runde som versjonskolonnen). +- `tests/test_auth_age_gate.py` (5 tester) — 13-årsgrensen på selve + dagsgrensen (eksakt 13 år i dag tillatt, én dag for tidlig avvist), + godt under grensen avvist, andre feltoppdateringer uten fødselsdato + utløser ikke sjekken, og en avvist oppdatering skriver INGENTING + til databasen (ingen delvis skrivning før feilen kastes). + +**Verifisert:** alle 17 nye tester + de eksisterende 117 HCP-testene +grønne. Ekte `teecup_db` sitt rolleoppsett (`teecup_app`: +`NOSUPERUSER`/`NOBYPASSRLS`) og radantall i sentrale tabeller bekreftet +uendret etter kjøring. Ingen deploy denne runden — dette er ren +testinfrastruktur, ingen produksjonskode endret. + +**Bevisst utenfor omfang, egne fremtidige runder:** CI-workflow-fil +(venter på bekreftet runner), frontend-tester, og dekning av flere +router-funksjoner enn de tre høyest-risiko-områdene over (f.eks. +øvrige `rounds.py`-endepunkter, `individual_tournaments.py`, +org-turnering-match-scoring sin `scoring.py` — sistnevnte spesielt +interessant siden ADR-057 dokumenterte AT den mangler samtidighetsvern, +men det er foreløpig udekket av en test som beviser det empirisk). + Disse må avklares før eller under de relevante fasene: 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på diff --git a/CHANGELOG.md b/CHANGELOG.md index 27714d7..ca72e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10191,8 +10191,11 @@ Neste steg: vurderingen er bevisst IKKE besluttet i denne runden — krever en designbeslutning fra bruker først, se ADR-056. - **Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før - `teecup_api` bygges/restartes mot ekte miljø. + **Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") — + `teecup_api` bygget/restartet rent. Den konkrete runden brukeren + rapporterte (`round.tee_name_snapshot` feilaktig "50") korrigert til + "55" med en engangs `UPDATE` mot ekte `teecup_db`, bekreftet i + etterkant. 66. **Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10, se ADR-057.** Direkte oppfølging av punkt 65: bruker fikk valget @@ -10237,5 +10240,47 @@ Neste steg: **Bevisst utenfor omfang:** org-turneringenes match-scoring (helt separat system/tabell) ikke undersøkt eller endret denne runden. - **Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før - `teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø. + **Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") — + migrasjon 062 kjørt mot ekte `teecup_db` FØR `teecup_api`/ + `teecup_frontend` ble bygget/restartet (riktig rekkefølge, ny kode + leser `version`-kolonnen). Bekreftet live: begge containere startet + rent, ingen konsollfeil på `teecup.golf`. + +67. **Automatisert backend-testinfrastruktur — RLS, versjonssjekk, + aldersgrense — 2026-08-10, se ADR-058.** Direkte oppfølging av + investor-statusrapporten samme dag: "nesten ingen automatisert + testdekning utenfor HCP-motoren" var det klart største enkeltfunnet. + Bruker ba om å ta tak i akkurat dette. + + `scripts/run_backend_tests.sh` automatiserer det som til nå har + vært en manuell prosedyre hver runde: oppretter en scratch-database + + scratch-rolle i den samme `teeoff_db`-containeren, kjører alle 62 + migrasjonene i rekkefølge (med `sed`-patch av migrasjon 002 sitt + hardkodede rollenavn OG databasenavn til scratch-spesifikke verdier + — sistnevnte et nytt funn: uten patch ville testrollen fått en + reell, om enn ufarlig, `GRANT CONNECT`-rettighet på den EKTE + `teecup_db`), bygger en dedikert `Dockerfile.test`-container + (pytest, IKKE i prod-imaget) tilkoblet samme `teeoff_default`- + nettverk som `teecup_api` selv bruker (nødvendig — Postgres- + containeren har ingen host-publisert port i dette miljøet), kjører + testene, dropper scratch-database + -rolle igjen. Rører aldri ekte + `teecup_db` — bekreftet uendret ACL og radantall etter hver kjøring. + + 17 nye tester, tre områder valgt etter risiko: RLS/tenant-isolasjon + (4 — inkl. regresjonsvern for migrasjon 005s NULL-guard), den + optimistiske versjonssjekken fra punkt 66 (6 — begge + `update_hole`/`update_side_hole`), 13-årsgrensen (5 — inkl. eksakt + dagsgrense begge veier). Testene kaller de FAKTISKE router-/auth- + funksjonene direkte (ikke en SQL-gjenimplementering) — samme + kodesti som produksjon. + + **Verifisert:** alle 17 nye + de eksisterende 117 HCP-testene + grønne. Ingen produksjonskode endret denne runden — ren + testinfrastruktur, ingen deploy. + + **Bevisst utenfor omfang:** CI-workflow-fil (Forgejo Actions har + `has_actions: true` for repoet, men ingen bekreftet aktiv runner — + egen runde), frontend-tester, og dekning av org-turneringenes + match-scoring (`scoring.py`) — som ADR-057 allerede har dokumentert + mangler samtidighetsvern, men som fortsatt ikke har en test som + beviser det empirisk. diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..4ddea09 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,17 @@ +# Kun for kjøring av backend-testsuiten (tests/) mot en scratch-database -- +# IKKE brukt i produksjon, IKKE del av deploy-pipelinen (se Dockerfile for +# det ekte prod-imaget, som bevisst ikke installerer pytest). +FROM python:3.12-slim + +WORKDIR /srv + +COPY app/requirements.txt app/requirements.txt +COPY app/requirements-dev.txt app/requirements-dev.txt +RUN pip install --no-cache-dir -r app/requirements.txt -r app/requirements-dev.txt + +COPY app/ app/ +COPY handicap_engine.py handicap_engine.py +COPY tests/ tests/ +COPY pytest.ini pytest.ini + +CMD ["pytest", "tests/", "-v"] diff --git a/app/requirements-dev.txt b/app/requirements-dev.txt new file mode 100644 index 0000000..77b07aa --- /dev/null +++ b/app/requirements-dev.txt @@ -0,0 +1,4 @@ +# Kun for testing/utvikling -- IKKE del av app.Dockerfile sitt image. +# Se scripts/run_backend_tests.sh og tests/conftest.py. +pytest>=8.0 +pytest-asyncio>=0.24 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/scripts/run_backend_tests.sh b/scripts/run_backend_tests.sh new file mode 100755 index 0000000..034cadf --- /dev/null +++ b/scripts/run_backend_tests.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Kjører backend-testsuiten (tests/) mot en fullt isolert scratch-database -- +# opprettet, migrert og ryddet opp automatisk. Rører ALDRI den ekte +# teecup_db (egen database + egen rolle, droppet igjen ved slutt). +# +# Gjenbruker den samme teeoff_db-Postgres-containeren som all manuell +# scratch-verifisering i prosjektet har brukt til nå (se CLAUDE.md), men +# automatiserer opprettelse/migrering/opprydning i stedet for å gjøre det +# for hånd hver gang. +# +# Bruk: ./scripts/run_backend_tests.sh + +set -euo pipefail +cd "$(dirname "$0")/.." + +SUFFIX="$(openssl rand -hex 4)" +DB_NAME="teecup_pytest_${SUFFIX}" +APP_ROLE="teecup_app_pytest_${SUFFIX}" +APP_PASSWORD="$(openssl rand -base64 24)" +SESSION_SECRET="$(openssl rand -base64 32)" +MIGRATIONS_TMPDIR="$(mktemp -d)" +PATCHED_002="${MIGRATIONS_TMPDIR}/002_roles_and_grants.sql" + +cleanup() { + echo "--- Rydder opp scratch-database ${DB_NAME} og rolle ${APP_ROLE} ---" + docker exec teeoff_db psql -U teeoff_admin -d postgres -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS ${DB_NAME};" \ + -c "DROP ROLE IF EXISTS ${APP_ROLE};" >/dev/null + rm -rf "${MIGRATIONS_TMPDIR}" +} +trap cleanup EXIT + +echo "--- Oppretter scratch-database ${DB_NAME} ---" +docker exec teeoff_db psql -U teeoff_admin -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE ${DB_NAME};" + +# Migrasjon 002 hardkoder rollenavnet "teecup_app" -- det navnet finnes +# allerede (cluster-globalt, delt mellom ALLE databaser på denne +# Postgres-instansen, inkl. den ekte teecup_db). En scratch-testrunde MÅ +# derfor bruke sin egen, midlertidige rolle -- ellers hadde testene kjørt +# som (og med samme passord som) den ekte runtime-rollen. Samme sed-patch- +# mønster som er brukt manuelt i alle tidligere scratch-runder. +# +# Migrasjonen hardkoder OGSÅ databasenavnet "teecup_db" i én GRANT CONNECT- +# setning -- uten patch ville den (uskadelig, men uønsket) gitt CONNECT- +# rettighet på den EKTE teecup_db til testrollen. Patches til scratch- +# databasenavnet av samme grunn. +sed -E "s/\bteecup_app\b/${APP_ROLE}/g; s/\bteecup_db\b/${DB_NAME}/g" \ + 002_roles_and_grants.sql > "${PATCHED_002}" + +echo "--- Kjører migrasjoner ---" +for f in [0-9][0-9][0-9]_*.sql; do + if [ "$f" = "002_roles_and_grants.sql" ]; then + docker cp "${PATCHED_002}" "teeoff_db:/tmp/pytest_${f}" + else + docker cp "$f" "teeoff_db:/tmp/pytest_${f}" + fi + docker exec teeoff_db psql -U teeoff_admin -d "${DB_NAME}" -v ON_ERROR_STOP=1 \ + -v teecup_app_password="${APP_PASSWORD}" \ + -f "/tmp/pytest_${f}" >/dev/null + docker exec teeoff_db rm -f "/tmp/pytest_${f}" +done + +echo "--- Setter passord for ${APP_ROLE} ---" +# psql sin :'var'-interpolering virker upålitelig i -c-modus (samme klasse +# feil som kommentaren i 002_roles_and_grants.sql selv nevner) -- passordet +# er selvgenerert base64 uten enkeltfnutter, så det er trygt å sette direkte +# i SQL-strengen fremfor å stole på -v her. +docker exec teeoff_db psql -U teeoff_admin -d postgres -v ON_ERROR_STOP=1 \ + -c "ALTER ROLE ${APP_ROLE} PASSWORD '${APP_PASSWORD}';" + +echo "--- Bygger testimage ---" +docker build -f Dockerfile.test -t teecup-backend-test . >/dev/null + +echo "--- Kjører pytest (nettverk: teeoff_default, host: teeoff_db) ---" +set +e +docker run --rm \ + --network teeoff_default \ + -e TEECUP_DB_HOST=teeoff_db \ + -e TEECUP_DB_PORT=5432 \ + -e TEECUP_DB_NAME="${DB_NAME}" \ + -e TEECUP_DB_USER="${APP_ROLE}" \ + -e TEECUP_DB_PASS="${APP_PASSWORD}" \ + -e TEECUP_SESSION_SECRET="${SESSION_SECRET}" \ + -e TEECUP_MINIO_ROOT_USER="pytest-unused" \ + -e TEECUP_MINIO_ROOT_PASSWORD="pytest-unused" \ + teecup-backend-test +TEST_EXIT_CODE=$? +set -e + +exit "${TEST_EXIT_CODE}" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..339f1ef --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,120 @@ +""" +Delt testoppsett for backend-integrasjonstestene. + +Kjøres ALLTID mot en scratch-database (se scripts/run_backend_tests.sh) -- +aldri mot ekte teecup_db. Poolen initialiseres per test (funksjonsscope, +ikke sesjonsscope) for å unngå event loop-fallgruver med pytest-asyncio -- +en full pool-oppstart tar millisekunder og suiten er liten nok til at det +ikke koster noe i praksis. + +Hjelpefunksjonene under (create_user, create_org, ...) setter kun de +kolonnene testene faktisk trenger å kontrollere, med fornuftige defaults +for resten -- se \\d-utskrift av schema for hvilke felt som er NOT NULL. +""" + +import uuid + +import pytest_asyncio + +from app import db as app_db + + +def new_id() -> str: + return str(uuid.uuid4()) + + +@pytest_asyncio.fixture +async def pool(): + await app_db.init_pool() + yield + await app_db.close_pool() + + +async def create_user(**overrides) -> str: + """Egen tilkobling, samme begrunnelse som create_org -- app_user har + ingen RLS (ikke org-scopet), så det er trygt/enkelt å ikke kreve en + ferdig-åpnet `conn` fra kalleren.""" + user_id = overrides.get("id", new_id()) + display_name = overrides.get("display_name", f"Testbruker {user_id[:8]}") + birth_date = overrides.get("birth_date") + email = overrides.get("email", f"{user_id}@pytest.invalid") + async with app_db.plain_connection() as conn: + await conn.execute( + """ + INSERT INTO app_user (id, display_name, birth_date, email) + VALUES ($1, $2, $3, $4) + """, + user_id, display_name, birth_date, email, + ) + return user_id + + +async def create_org(**overrides) -> str: + """Egen tilkobling (ikke `conn`-parameter som de andre hjelperne) -- + speiler den ekte selvrefererende RLS-bootstrappen i + routers/organizations.py::create_organization: `app.current_org` MÅ + være satt til organisasjonens EGEN, ennå-ikke-eksisterende id FØR + innsettingen, ellers avviser org_self-policyen den (teecup_app er + NOSUPERUSER/NOBYPASSRLS, akkurat som i produksjon).""" + org_id = overrides.get("id", new_id()) + name = overrides.get("name", f"Testklubb {org_id[:8]}") + async with app_db.org_connection(org_id) as conn: + await conn.execute("INSERT INTO organization (id, name) VALUES ($1, $2)", org_id, name) + return org_id + + +async def add_membership(organization_id: str, user_id: str, role: str = "member") -> None: + """organization_membership er org-scopet (RLS), samme begrunnelse som + create_org over -- må skrives med riktig app.current_org satt.""" + async with app_db.org_connection(organization_id) as conn: + await conn.execute( + "INSERT INTO organization_membership (organization_id, user_id, role) VALUES ($1, $2, $3)", + organization_id, user_id, role, + ) + + +async def create_round(conn, owner_user_id: str, **overrides) -> str: + round_id = overrides.get("id", new_id()) + # course_source='teeoff' krever KUN teeoff_facility_slug/teeoff_course_id + # (fritekst, ingen FK mot en ekte teeoff-bane) -- enklere for et testfixtur + # enn 'custom', som (round_check-constrainten) også krever en ekte rad i + # personal_course. + await conn.execute( + """ + INSERT INTO round (id, owner_user_id, course_source, teeoff_facility_slug, + teeoff_course_id, course_name_snapshot, tee_name_snapshot, played_at) + VALUES ($1, $2, 'teeoff', 'test-facility', 'test-course', 'Testbanen', 'Gul', CURRENT_DATE) + """, + round_id, owner_user_id, + ) + return round_id + + +async def create_participant(conn, round_id: str, **overrides) -> str: + participant_id = overrides.get("id", new_id()) + user_id = overrides.get("user_id") + is_owner = overrides.get("is_owner", True) + course_handicap_snapshot = overrides.get("course_handicap_snapshot") + await conn.execute( + """ + INSERT INTO round_participant (id, round_id, user_id, is_owner, gender, + tee_name_snapshot, course_handicap_snapshot) + VALUES ($1, $2, $3, $4, 'x', 'Gul', $5) + """, + participant_id, round_id, user_id, is_owner, course_handicap_snapshot, + ) + return participant_id + + +async def create_hole(conn, participant_id: str, hole_number: int, par: int = 4, + stroke_index: int = 9, **overrides) -> str: + hole_id = overrides.get("id", new_id()) + version = overrides.get("version", 1) + await conn.execute( + """ + INSERT INTO round_hole (id, round_participant_id, hole_number, par, stroke_index, version) + VALUES ($1, $2, $3, $4, $5, $6) + """, + hole_id, participant_id, hole_number, par, stroke_index, version, + ) + return hole_id diff --git a/tests/test_auth_age_gate.py b/tests/test_auth_age_gate.py new file mode 100644 index 0000000..aec58cc --- /dev/null +++ b/tests/test_auth_age_gate.py @@ -0,0 +1,82 @@ +""" +13-årsgrensen (2026-08-10, ADR) håndheves server-side i update_profile -- +eneste vei inn i appen. Kaller den faktiske funksjonen direkte, ikke en +gjenimplementering av alderslogikken.""" + +from datetime import date, timedelta + +import pytest +from fastapi import HTTPException + +from app import db as app_db +from app.auth import CurrentUser +from app.routers.auth import ProfileUpdate, update_profile + +from tests.conftest import create_user + + +def _birth_date_for_age(years: int, extra_days: int = 0) -> date: + """extra_days=0 gir eksakt `years` år gammel i dag; extra_days=+1 gir + én dag YNGRE enn `years` (dvs. fyller `years` år i MORGEN, ikke i dag -- + fødselsdatoen flyttes én dag SENERE enn "eksakt `years` i dag").""" + today = date.today() + try: + return today.replace(year=today.year - years) + timedelta(days=extra_days) + except ValueError: + # 29. februar-kant -- ikke relevant for testens formål, men trygt. + return today.replace(year=today.year - years, day=28) + timedelta(days=extra_days) + + +async def test_exactly_13_today_is_allowed(pool): + user_id = await create_user() + out = await update_profile( + ProfileUpdate(birth_date=_birth_date_for_age(13)), + user=CurrentUser(user_id=user_id), + ) + assert out.birth_date == _birth_date_for_age(13) + + +async def test_one_day_short_of_13_is_rejected(pool): + user_id = await create_user() + with pytest.raises(HTTPException) as exc_info: + await update_profile( + ProfileUpdate(birth_date=_birth_date_for_age(13, extra_days=1)), + user=CurrentUser(user_id=user_id), + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "UNDER_MINIMUM_AGE" + + # Og bekreft at feilen faktisk stoppet skrivingen -- ingen delvis + # oppdatering av fødselsdato ble liggende igjen. + async with app_db.plain_connection() as conn: + row = await conn.fetchrow("SELECT birth_date FROM app_user WHERE id = $1", user_id) + assert row["birth_date"] is None + + +async def test_well_under_13_is_rejected(pool): + user_id = await create_user() + with pytest.raises(HTTPException) as exc_info: + await update_profile( + ProfileUpdate(birth_date=_birth_date_for_age(8)), + user=CurrentUser(user_id=user_id), + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "UNDER_MINIMUM_AGE" + + +async def test_updating_other_fields_without_birth_date_skips_age_check(pool): + user_id = await create_user() + out = await update_profile( + ProfileUpdate(bio="Elsker links-golf"), + user=CurrentUser(user_id=user_id), + ) + assert out.bio == "Elsker links-golf" + + +async def test_adult_birth_date_is_allowed(pool): + user_id = await create_user() + out = await update_profile( + ProfileUpdate(birth_date=_birth_date_for_age(35)), + user=CurrentUser(user_id=user_id), + ) + assert out.birth_date == _birth_date_for_age(35) diff --git a/tests/test_concurrency_version_check.py b/tests/test_concurrency_version_check.py new file mode 100644 index 0000000..61a5ef1 --- /dev/null +++ b/tests/test_concurrency_version_check.py @@ -0,0 +1,138 @@ +""" +Automatiserer det som til nå (ADR-057) kun er verifisert manuelt i tre lag +i nettleser: den optimistiske versjonssjekken på round_hole. Kaller de +faktiske router-funksjonene i rounds.py direkte (samme SQL, samme +feilhåndtering som produksjon) -- IKKE en gjenimplementering av logikken. +""" + +import pytest +from fastapi import HTTPException + +from app import db as app_db +from app.auth import CurrentUser +from app.routers.rounds import ( + HoleUpdate, + SideHoleUpdate, + update_hole, + update_side_hole, +) + +from tests.conftest import create_hole, create_participant, create_round + + +async def _setup_round_with_hole(hole_number: int = 1): + async with app_db.plain_connection() as conn: + from tests.conftest import create_user + + owner_id = await create_user() + round_id = await create_round(conn, owner_id) + participant_id = await create_participant(conn, round_id, user_id=owner_id, is_owner=True) + await create_hole(conn, participant_id, hole_number=hole_number, par=4, stroke_index=9) + return owner_id, round_id, participant_id + + +async def test_update_succeeds_and_increments_version(pool): + owner_id, round_id, participant_id = await _setup_round_with_hole() + user = CurrentUser(user_id=owner_id) + + out = await update_hole( + round_id, participant_id, 1, + HoleUpdate(score=5, expected_version=1), + user=user, + ) + assert out.score == 5 + assert out.version == 2 + + +async def test_stale_version_is_rejected_with_409(pool): + owner_id, round_id, participant_id = await _setup_round_with_hole() + user = CurrentUser(user_id=owner_id) + + # "Enhet 1" leser versjon 1, skriver -- lykkes, hullet er nå versjon 2. + await update_hole(round_id, participant_id, 1, HoleUpdate(score=4, expected_version=1), user=user) + + # "Enhet 2" leste OGSÅ versjon 1 (før enhet 1 sin skriving nådde den), + # og prøver nå å skrive basert på den utdaterte versjonen. + with pytest.raises(HTTPException) as exc_info: + await update_hole(round_id, participant_id, 1, HoleUpdate(score=6, expected_version=1), user=user) + + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "STALE_VERSION" + + +async def test_expected_version_none_bypasses_check(pool): + owner_id, round_id, participant_id = await _setup_round_with_hole() + user = CurrentUser(user_id=owner_id) + + await update_hole(round_id, participant_id, 1, HoleUpdate(score=4, expected_version=1), user=user) + # Hullet er nå versjon 2 -- men vi sender ingen forventet versjon i det + # hele tatt (klient som ikke sender feltet, f.eks. eldre app-versjon). + out = await update_hole(round_id, participant_id, 1, HoleUpdate(score=7, expected_version=None), user=user) + assert out.score == 7 + assert out.version == 3 + + +async def test_nonexistent_hole_is_404_not_409(pool): + owner_id, round_id, participant_id = await _setup_round_with_hole(hole_number=1) + user = CurrentUser(user_id=owner_id) + + with pytest.raises(HTTPException) as exc_info: + await update_hole(round_id, participant_id, 18, HoleUpdate(score=4, expected_version=1), user=user) + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "NOT_FOUND" + + +# --------------------------------------------------------------------------- +# round_side (delt-ball-format) -- update_side_hole har sin EGEN 404-vs-409- +# logikk (egen eksistenssjekk lagt til i samme runde som versjonskolonnen), +# adskilt kode fra update_hole -- verifiseres derfor separat, ikke antatt +# dekket av testene over. +# --------------------------------------------------------------------------- + +async def _setup_round_with_side_hole(hole_number: int = 1): + async with app_db.plain_connection() as conn: + from tests.conftest import create_user, new_id + + owner_id = await create_user() + round_id = await create_round(conn, owner_id) + side_id = new_id() + await conn.execute( + "INSERT INTO round_side (id, round_id, label) VALUES ($1, $2, 'Side A')", + side_id, round_id, + ) + participant_id = await create_participant( + conn, round_id, user_id=owner_id, is_owner=True, + ) + await conn.execute( + "UPDATE round_participant SET round_side_id = $1 WHERE id = $2", side_id, participant_id + ) + await conn.execute( + """ + INSERT INTO round_hole (round_side_id, hole_number, par, stroke_index, version) + VALUES ($1, $2, 4, 9, 1) + """, + side_id, hole_number, + ) + return owner_id, round_id, side_id + + +async def test_side_hole_stale_version_is_409(pool): + owner_id, round_id, side_id = await _setup_round_with_side_hole() + user = CurrentUser(user_id=owner_id) + + await update_side_hole(round_id, side_id, 1, SideHoleUpdate(score=4, expected_version=1), user=user) + + with pytest.raises(HTTPException) as exc_info: + await update_side_hole(round_id, side_id, 1, SideHoleUpdate(score=6, expected_version=1), user=user) + assert exc_info.value.status_code == 409 + assert exc_info.value.detail["code"] == "STALE_VERSION" + + +async def test_side_hole_nonexistent_is_404_not_409(pool): + owner_id, round_id, side_id = await _setup_round_with_side_hole(hole_number=1) + user = CurrentUser(user_id=owner_id) + + with pytest.raises(HTTPException) as exc_info: + await update_side_hole(round_id, side_id, 18, SideHoleUpdate(score=4, expected_version=1), user=user) + assert exc_info.value.status_code == 404 + assert exc_info.value.detail["code"] == "NOT_FOUND" diff --git a/tests/test_rls_isolation.py b/tests/test_rls_isolation.py new file mode 100644 index 0000000..9104a23 --- /dev/null +++ b/tests/test_rls_isolation.py @@ -0,0 +1,79 @@ +""" +Bevis at flertenant-isolasjonen (RLS på organization_id) faktisk holder -- +automatiserer det som til nå kun er sjekket manuelt via test_isolation.sql. + +Kjører som `teecup_app`-rollen (NOSUPERUSER/NOBYPASSRLS), akkurat som +produksjon -- ikke som en superbruker som ville hoppet over RLS uansett. +""" + +import pytest + +from app import db as app_db +from app.auth import CurrentUser, get_authorized_org +from app.errors import app_error # noqa: F401 (dokumenterer avhengigheten) +from fastapi import HTTPException + +from tests.conftest import add_membership, create_org, create_user + + +async def test_org_scoped_read_never_leaks_across_orgs(pool): + org_a = await create_org() + org_b = await create_org() + + # Les ALLE rader i en org-scopet tabell mens app.current_org peker på A -- + # ingen eksplisitt WHERE organization_id i selve spørringen. Hvis RLS + # ikke virket ville dette returnert BEGGE organisasjonene. + async with app_db.org_connection(org_a) as conn: + rows = await conn.fetch("SELECT id::text FROM organization") + + ids = {r["id"] for r in rows} + assert org_a in ids + assert org_b not in ids + + +async def test_org_scoped_write_cannot_touch_other_org(pool): + org_a = await create_org() + org_b = await create_org() + + async with app_db.org_connection(org_a) as conn: + result = await conn.execute( + "UPDATE organization SET name = 'kapret' WHERE id = $1", org_b + ) + # asyncpg execute() returnerer "UPDATE " -- n MÅ være 0: RLS sin + # USING-klausul filtrerer bort org B-raden før UPDATE-en i det hele tatt + # vurderer den, selv om spørringen eksplisitt ba om akkurat den raden. + assert result == "UPDATE 0" + + async with app_db.org_connection(org_b) as conn: + row = await conn.fetchrow("SELECT name FROM organization WHERE id = $1", org_b) + assert row["name"] != "kapret" + + +async def test_no_org_context_set_yields_zero_rows_not_all_rows(pool): + """Regresjonsvern for migrasjon 005 (RLS NULL-guard): en tilkobling UTEN + `app.current_org` satt i det hele tatt skal se INGENTING i en org-scopet + tabell -- IKKE (feilaktig) alt, som ville skjedd hvis policyen + sammenlignet mot en tom streng i stedet for å garantere NULL != noe.""" + await create_org() + await create_org() + + async with app_db.plain_connection() as conn: + rows = await conn.fetch("SELECT id FROM organization") + assert rows == [] + + +async def test_membership_verified_before_org_context_is_ever_set(pool): + org = await create_org() + member = await create_user() + outsider = await create_user() + await add_membership(org, member) + + # Medlem: får org_id tilbake. + result = await get_authorized_org(org, user=CurrentUser(user_id=member)) + assert result == org + + # Ikke-medlem: 403, IKKE en stille tom respons -- og det skjer FØR + # noen org-scopet spørring i det hele tatt kjøres. + with pytest.raises(HTTPException) as exc_info: + await get_authorized_org(org, user=CurrentUser(user_id=outsider)) + assert exc_info.value.status_code == 403 diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..c8793b4 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,22 @@ +"""Bekrefter selve test-riggen fungerer: pool kan opprettes mot scratch- +databasen, og migrasjonene har faktisk kjørt (organization-tabellen finnes +og er tom).""" + +from tests.conftest import create_org + + +async def test_pool_connects_and_schema_exists(pool): + from app import db as app_db + + async with app_db.plain_connection() as conn: + count = await conn.fetchval("SELECT count(*) FROM organization") + assert count == 0 + + +async def test_can_insert_via_helper(pool): + from app import db as app_db + + org_id = await create_org() + async with app_db.org_connection(org_id) as conn: + row = await conn.fetchrow("SELECT name FROM organization WHERE id = $1", org_id) + assert row is not None