Files
kleiderboerse/tests/conftest.py
T
StefanandClaude Opus 5 99ba74330c Phase 3: Anmeldung, Rate-Limits und Sicherheits-Header
Lesen darf jeder, schreiben nur der Betreiber - mit der bewussten Ausnahme
des Reservierens. Dazu Dockerfile und Compose-Datei, damit sich das lokal
ausprobieren lässt.

Beim Bauen sind zwei Fehler aufgefallen, die ohne Test nicht aufgefallen
wären:

1. slowapi zählt pro URL-Pfad. Weil jedes Kleidungsstück eine eigene URL
   hat, bekam jedes seinen eigenen Zähler - ein Skript hätte also den
   gesamten Bestand reservieren können, ohne je an ein Limit zu stossen.
   Genau der Missbrauch, gegen den das Limit gedacht ist. Behoben mit
   shared_limit und festem scope.
2. Der erste Anlauf des Tests machte fünf Anfragen gegen ein Limit von
   fünf und konnte damit gar nichts zeigen. Geprüft wird jetzt der
   tatsächlich ausgelieferte Standardwert, mit mehr Anfragen als erlaubt.

Weiter umgesetzt:

- Passwort als bcrypt-Hash aus der Umgebung, einmal beim Start gebildet
  und gemerkt. Bei jeder Anfrage neu gehasht liesse sich die Anwendung
  sonst ohne Anmeldung lahmlegen - bcrypt ist absichtlich langsam.
- Ohne hinterlegtes Passwort bleibt der Erfassungsbereich gesperrt (503)
  statt offen zu stehen. Kein mitgeliefertes Standardpasswort.
- Sitzung als signiertes Cookie, HttpOnly, SameSite=Lax (blockt
  seitenfremde POSTs), Secure abschaltbar nur fürs lokale Testen,
  Abmeldung nach zwei Stunden Ruhe.
- CSP mit script-src 'self', nosniff, frame-ancestors none, dazu noindex
  und robots.txt: die Galerie ist frei zugänglich, soll aber nicht
  dauerhaft im Suchindex stehen.
- Der Betreiber darf Reservierungen ohne Token aufheben, damit sich eine
  Missbrauchswelle aufräumen lässt.
- uvicorn mit --proxy-headers: hinter einem Reverse-Proxy zählte sonst
  alles auf dessen IP, und ein einzelner Besucher sperrte alle aus.
- Container läuft nicht als root; Code gehört root, nur Daten und Bilder
  dem Dienstbenutzer.

Umgebungsvariablen heissen jetzt ausdrücklich englisch (ADMIN_PASSWORD,
SECRET_KEY, ...), passend zur Anleitung und zur Kantone-App.

59 Tests, alle grün. Zusätzlich gegen den laufenden Container geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-29 23:00:23 +02:00

110 lines
3.6 KiB
Python

"""Gemeinsame Test-Vorbereitung.
Jeder Test bekommt eine eigene, frische SQLite-Datei in einem temporären
Verzeichnis - keine geteilten Zustände zwischen Tests, und die echte
Datenbank unter data/ wird nie angefasst.
"""
import os
TEST_PASSWORT = "test-passwort-123"
# MUSS vor dem Import von app.main stehen: dort wird die SessionMiddleware
# beim Import aufgebaut und liest cookie_nur_https genau einmal. Käme das
# erst als Fixture, bliebe das Cookie auf "Secure" - und der TestClient
# spricht über http://testserver, verwürfe es also stillschweigend. Die
# Anmeldung schlüge dann ohne erkennbaren Grund fehl.
os.environ.setdefault("COOKIE_HTTPS_ONLY", "false")
os.environ.setdefault("ADMIN_PASSWORD", TEST_PASSWORT)
os.environ.setdefault("SECRET_KEY", "nur-fuer-tests")
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from app import security # noqa: E402
from app.config import einstellungen # noqa: E402
from app.database import Basis, engine_erzeugen, get_db # noqa: E402
from app.main import app # noqa: E402
from app.seed import stammdaten_anlegen # noqa: E402
@pytest.fixture(autouse=True)
def admin_eingerichtet(monkeypatch):
"""Setzt ein Passwort für die Tests und leert die Zwischenspeicher.
autouse, weil sonst jeder Schreibzugriff mit 503 ("nicht eingerichtet")
scheitern würde - das ist das gewollte Verhalten ohne Passwort.
"""
monkeypatch.setenv("ADMIN_PASSWORD", TEST_PASSWORT)
monkeypatch.setenv("COOKIE_HTTPS_ONLY", "false")
einstellungen.cache_clear()
security.hash_zwischenspeicher_leeren()
# Rate-Limits zwischen Tests zurücksetzen, sonst schlägt der zehnte
# Anmeldeversuch über alle Tests hinweg zu.
security.limiter.reset()
yield
einstellungen.cache_clear()
security.hash_zwischenspeicher_leeren()
@pytest.fixture(autouse=True)
def upload_ordner(tmp_path, monkeypatch):
"""Jeder Test lädt in ein eigenes Verzeichnis hoch.
Ohne das schrieben die Tests in das echte uploads/ und liessen dort
ihre Bilder liegen. autouse, damit das auch für Tests gilt, die den
Ordner nicht selbst anfordern.
"""
ziel = tmp_path / "uploads"
ziel.mkdir()
monkeypatch.setenv("UPLOAD_PATH", str(ziel))
einstellungen.cache_clear()
yield ziel
einstellungen.cache_clear()
@pytest.fixture
def db_sitzung(tmp_path):
engine = engine_erzeugen(f"sqlite:///{tmp_path/'test.sqlite'}")
Basis.metadata.create_all(engine)
Sitzung = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
with Sitzung() as db:
stammdaten_anlegen(db)
yield db
engine.dispose()
@pytest.fixture
def gast(db_sitzung):
"""Nicht angemeldeter Besucher - sieht die Galerie, darf reservieren.
Bewusst ohne "with": nur dann startet Starlette den Lifespan, und der
würde die echte Datenbank unter data/ anlegen und befüllen. Die
Stammdaten stehen in der Testdatenbank ohnehin schon (db_sitzung).
"""
def _db():
yield db_sitzung
app.dependency_overrides[get_db] = _db
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def client(gast):
"""Angemeldeter Betreiber - darf alles.
Die meisten Tests brauchen das, weil Anlegen und Ändern seit Phase 3
eine Anmeldung voraussetzen.
"""
antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT})
assert antwort.status_code == 200, antwort.text
return gast
@pytest.fixture
def kategorie_id(gast):
return gast.get("/api/v1/categories").json()[0]["id"]