Compare commits

..

No commits in common. "c5eed728e1492f6defb83b407427af2623ae64a0" and "17fac05ddce0c6f3c7ccd0b44342804ba1ba478c" have entirely different histories.

14 changed files with 4 additions and 739 deletions

View file

@ -1,42 +0,0 @@
name: Backend-tester
on:
push:
branches: [master]
paths:
- "app/**"
- "*.sql"
- "tests/**"
- "handicap_engine.py"
- "Dockerfile.test"
- "scripts/run_backend_tests.sh"
- ".forgejo/workflows/backend-tests.yml"
jobs:
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
# Første kjøring på denne runneren -- vet ikke ennå om den har
# Docker-sokkel-tilgang eller ser samme teeoff_default-nettverk som
# produksjonsverten (se ADR-058: bevisst utsatt til dette kunne
# verifiseres empirisk med en ekte push, ikke antatt på forhånd).
# Denne diagnostikk-blokken kjører ALLTID, selv om selve testen
# feiler under, slik at et miljø-avvik er synlig i loggen fremfor
# å bare se en kryptisk feil fra run_backend_tests.sh.
- name: Diagnostikk (Docker-tilgang, nettverk, teeoff_db-container)
run: |
echo "--- docker version ---"
docker version || echo "INGEN docker-tilgang fra runneren"
echo "--- teeoff_default-nettverk ---"
docker network inspect teeoff_default >/dev/null 2>&1 \
&& echo "teeoff_default finnes" \
|| echo "teeoff_default finnes IKKE fra denne runneren"
echo "--- teeoff_db-container ---"
docker exec teeoff_db true 2>&1 \
&& echo "teeoff_db nåbar via docker exec" \
|| echo "teeoff_db IKKE nåbar via docker exec"
- name: Kjør backend-testsuiten
run: ./scripts/run_backend_tests.sh

2
.gitignore vendored
View file

@ -2,5 +2,3 @@
__pycache__/ __pycache__/
*.pyc *.pyc
Temp-uploads/ Temp-uploads/
.venv/
.pytest_cache/

View file

@ -5533,97 +5533,6 @@ siden verken den opprinnelige eksterne vurderingen eller brukerens
oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det
trengs der også. 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: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -10191,11 +10191,8 @@ Neste steg:
vurderingen er bevisst IKKE besluttet i denne runden — krever en vurderingen er bevisst IKKE besluttet i denne runden — krever en
designbeslutning fra bruker først, se ADR-056. designbeslutning fra bruker først, se ADR-056.
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") — **Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
`teecup_api` bygget/restartet rent. Den konkrete runden brukeren `teecup_api` bygges/restartes mot ekte miljø.
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, 66. **Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10,
se ADR-057.** Direkte oppfølging av punkt 65: bruker fikk valget se ADR-057.** Direkte oppfølging av punkt 65: bruker fikk valget
@ -10240,47 +10237,5 @@ Neste steg:
**Bevisst utenfor omfang:** org-turneringenes match-scoring (helt **Bevisst utenfor omfang:** org-turneringenes match-scoring (helt
separat system/tabell) ikke undersøkt eller endret denne runden. separat system/tabell) ikke undersøkt eller endret denne runden.
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") — **Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
migrasjon 062 kjørt mot ekte `teecup_db` FØR `teecup_api`/ `teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø.
`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.

View file

@ -1,17 +0,0 @@
# 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"]

View file

@ -1,4 +0,0 @@
# 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

View file

@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto

View file

@ -1,91 +0,0 @@
#!/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}"

View file

View file

@ -1,120 +0,0 @@
"""
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), 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`
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 -- 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

View file

@ -1,82 +0,0 @@
"""
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)

View file

@ -1,138 +0,0 @@
"""
Automatiserer det som til (ADR-057) kun er verifisert manuelt i tre lag
i nettleser: den optimistiske versjonssjekken 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"

View file

@ -1,79 +0,0 @@
"""
Bevis at flertenant-isolasjonen (RLS organization_id) faktisk holder --
automatiserer det som til 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

View file

@ -1,22 +0,0 @@
"""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