Compare commits
2 commits
17fac05ddc
...
c5eed728e1
| Author | SHA1 | Date | |
|---|---|---|---|
| c5eed728e1 | |||
| d89e22f09c |
14 changed files with 739 additions and 4 deletions
42
.forgejo/workflows/backend-tests.yml
Normal file
42
.forgejo/workflows/backend-tests.yml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
Temp-uploads/
|
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
|
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å
|
||||||
|
|
|
||||||
53
CHANGELOG.md
53
CHANGELOG.md
|
|
@ -10191,8 +10191,11 @@ 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.
|
||||||
|
|
||||||
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
|
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") —
|
||||||
`teecup_api` bygges/restartes mot ekte miljø.
|
`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,
|
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
|
||||||
|
|
@ -10237,5 +10240,47 @@ 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.
|
||||||
|
|
||||||
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
|
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør alt") —
|
||||||
`teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø.
|
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