Automatisert backend-testinfrastruktur: RLS, versjonssjekk, aldersgrense
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 <noreply@anthropic.com>
This commit is contained in:
parent
17fac05ddc
commit
d89e22f09c
13 changed files with 697 additions and 4 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
Temp-uploads/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
|
|
|
|||
|
|
@ -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å
|
||||
|
|
|
|||
53
CHANGELOG.md
53
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.
|
||||
|
|
|
|||
17
Dockerfile.test
Normal file
17
Dockerfile.test
Normal file
|
|
@ -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"]
|
||||
4
app/requirements-dev.txt
Normal file
4
app/requirements-dev.txt
Normal file
|
|
@ -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
|
||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
91
scripts/run_backend_tests.sh
Executable file
91
scripts/run_backend_tests.sh
Executable file
|
|
@ -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}"
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
120
tests/conftest.py
Normal file
120
tests/conftest.py
Normal file
|
|
@ -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
|
||||
82
tests/test_auth_age_gate.py
Normal file
82
tests/test_auth_age_gate.py
Normal file
|
|
@ -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)
|
||||
138
tests/test_concurrency_version_check.py
Normal file
138
tests/test_concurrency_version_check.py
Normal file
|
|
@ -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"
|
||||
79
tests/test_rls_isolation.py
Normal file
79
tests/test_rls_isolation.py
Normal file
|
|
@ -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>" -- 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
|
||||
22
tests/test_smoke.py
Normal file
22
tests/test_smoke.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue