"""Tests zum Zugriffsschutz. Grundsatz: lesen darf jeder, schreiben nur der Betreiber - mit der einen Ausnahme des Reservierens. """ import io import pytest from PIL import Image from app import security from app.config import einstellungen from tests.conftest import TEST_PASSWORT def bild_bytes() -> bytes: puffer = io.BytesIO() Image.new("RGB", (400, 300), (10, 10, 200)).save(puffer, "JPEG") return puffer.getvalue() # ------------------------------------------------------------- Anmeldung --- def test_anmelden_und_abmelden(gast): assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False antwort = gast.post("/api/v1/auth/login", json={"passwort": TEST_PASSWORT}) assert antwort.status_code == 200 assert gast.get("/api/v1/auth/me").json()["angemeldet"] is True gast.post("/api/v1/auth/logout") assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False def test_falsches_passwort(gast): antwort = gast.post("/api/v1/auth/login", json={"passwort": "daneben"}) assert antwort.status_code == 401 assert gast.get("/api/v1/auth/me").json()["angemeldet"] is False def test_ohne_passwort_ist_der_bereich_gesperrt(gast, monkeypatch, kategorie_id): """Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen. Leerer Wert statt delenv: Umgebungsvariablen haben bei pydantic-settings Vorrang vor der .env-Datei. Ein blosses Löschen brächte hier gar nichts, weil eine lokale .env den Wert sonst wieder hereinreicht - dieselbe Falle, die auch im Betrieb zuschlagen kann. """ monkeypatch.setenv("ADMIN_PASSWORD", "") monkeypatch.setenv("ADMIN_PASSWORD_HASH", "") einstellungen.cache_clear() security.hash_zwischenspeicher_leeren() assert gast.post("/api/v1/auth/login", json={"passwort": "x"}).status_code == 503 antwort = gast.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id}) assert antwort.status_code == 503 # Die Galerie funktioniert trotzdem assert gast.get("/api/v1/items").status_code == 200 # ------------------------------------------------------- Schreibzugriffe --- @pytest.mark.parametrize( "methode,pfad,rumpf", [ ("post", "/api/v1/items", {"json": {"size": "80"}}), ("patch", "/api/v1/items/1", {"json": {"size": "92"}}), ("delete", "/api/v1/items/1", {}), ("post", "/api/v1/items/1/mark-given", {}), ("post", "/api/v1/images/1/primary", {}), ("delete", "/api/v1/images/1", {}), ], ) def test_schreiben_ohne_anmeldung_wird_abgewiesen(gast, methode, pfad, rumpf): antwort = getattr(gast, methode)(pfad, **rumpf) assert antwort.status_code == 401, f"{methode.upper()} {pfad} war offen!" def test_hochladen_ohne_anmeldung_wird_abgewiesen(gast): antwort = gast.post( "/api/v1/items/batch-upload", files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")}, ) assert antwort.status_code == 401 def test_lesen_ist_offen(gast, client, kategorie_id): item = client.post( "/api/v1/items", json={"size": "80", "category_id": kategorie_id} ).json() client.post("/api/v1/auth/logout") assert gast.get("/api/v1/items").status_code == 200 assert gast.get(f"/api/v1/items/{item['id']}").status_code == 200 assert gast.get("/api/v1/categories").status_code == 200 assert gast.get("/api/v1/sizes").status_code == 200 def test_reservieren_bleibt_ohne_anmeldung_moeglich(gast, client, kategorie_id): """Die bewusste Ausnahme - Nachbarn sollen kein Konto brauchen.""" item = client.post( "/api/v1/items", json={"size": "80", "category_id": kategorie_id} ).json() client.post("/api/v1/auth/logout") antwort = gast.post( f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Familie Meier"} ) assert antwort.status_code == 200 def test_betreiber_darf_ohne_token_freigeben(client, kategorie_id): """Damit eine Missbrauchswelle aufgeräumt werden kann.""" item = client.post( "/api/v1/items", json={"size": "80", "category_id": kategorie_id} ).json() client.post(f"/api/v1/items/{item['id']}/reserve", json={"reserved_by": "Wer auch immer"}) antwort = client.post(f"/api/v1/items/{item['id']}/release") assert antwort.status_code == 200 assert antwort.json()["status"] == "available" # ------------------------------------------------------------ Rate-Limit --- def test_reservieren_ist_begrenzt(gast, client, kategorie_id): """Bei offener Galerie die einzige Bremse vor dem Bestand. Geprüft wird der tatsächlich ausgelieferte Standardwert (5/Stunde), nicht ein für den Test heruntergesetzter: sonst sagte der Test nichts darüber aus, was im Betrieb gilt. Ein erster Anlauf mit genau fünf Anfragen gegen ein Limit von fünf konnte gar nichts zeigen. """ assert einstellungen().limit_reservieren == "5/hour" ids = [ client.post("/api/v1/items", json={"size": "80", "category_id": kategorie_id}).json()["id"] for _ in range(8) ] client.post("/api/v1/auth/logout") ergebnisse = [ gast.post(f"/api/v1/items/{i}/reserve", json={"reserved_by": "Skript"}).status_code for i in ids ] assert ergebnisse[:5] == [200] * 5, f"unerwartet früh gebremst: {ergebnisse}" assert ergebnisse[5:] == [429] * 3, f"Rate-Limit griff nicht: {ergebnisse}" def test_anmelden_ist_begrenzt(gast): """Ein Konto, aus dem Internet erreichbar - ohne Bremse liesse sich das Passwort in Ruhe durchprobieren.""" assert einstellungen().limit_anmelden == "10/hour" codes = [ gast.post("/api/v1/auth/login", json={"passwort": "falsch"}).status_code for _ in range(13) ] assert codes[:10] == [401] * 10, f"unerwartet früh gebremst: {codes}" assert 429 in codes[10:], f"Passwort liess sich unbegrenzt durchprobieren: {codes}" # --------------------------------------------------------------- Header --- def test_sicherheits_header(gast): kopf = gast.get("/api/v1/items").headers assert "script-src 'self'" in kopf["content-security-policy"] assert kopf["x-content-type-options"] == "nosniff" assert kopf["x-frame-options"] == "DENY" assert "noindex" in kopf["x-robots-tag"] def test_robots_txt_sperrt_suchmaschinen(gast): antwort = gast.get("/robots.txt") assert antwort.status_code == 200 assert "Disallow: /" in antwort.text def test_startseite_antwortet(gast): """Ohne eigene Route lieferte die Wurzel ein nacktes 404-JSON - im Browser sieht das aus wie eine kaputte Anwendung.""" antwort = gast.get("/") assert antwort.status_code == 200 assert "Kinderkleider-Börse" in antwort.text def test_swagger_laedt_nichts_von_fremden_servern(gast): """Die mitgelieferte /docs-Seite holt Swagger von cdn.jsdelivr.net. Die CSP (script-src 'self') blockiert das zu Recht - die Seite bliebe weiss. Ausserdem ginge dabei die IP jedes Besuchers an einen Dritten. """ import re antwort = gast.get("/docs") assert antwort.status_code == 200 # Kein einziger Verweis auf einen anderen Host fremde = re.findall(r'(?:src|href)="(https?:)?//[^"]+"', antwort.text) assert not fremde, f"lädt von aussen: {fremde}" assert "/static/swagger/swagger-ui-bundle.js" in antwort.text # Und die Dateien sind auch wirklich da assert gast.get("/static/swagger/swagger-ui-bundle.js").status_code == 200 assert gast.get("/static/swagger/swagger-ui.css").status_code == 200