Files
kleiderboerse/tests/test_zugriff.py
T
StefanandClaude Opus 5 01cd0a73e8 Weisse Seite beheben: Swagger lokal ausliefern, Startseite ergänzen
Zwei Fehler, beide erst im Browser sichtbar:

1. /docs blieb weiss. FastAPI holt Swagger von cdn.jsdelivr.net, und die
   in Phase 3 eingeführte CSP (script-src 'self') blockiert das zu Recht.
   Die Dateien liegen jetzt unter app/static/swagger/ im Repository - das
   funktioniert auch ohne Internet und ruft keinen fremden Server auf, aus
   demselben Grund, aus dem die Kantone-App ihre Schriften lokal ausliefert.
   Das Favicon holte FastAPI ebenfalls von aussen; auch das ist jetzt lokal.

2. Die Wurzel / lieferte ein nacktes 404-JSON - im Browser sieht das aus
   wie eine kaputte Anwendung. Jetzt eine schlichte Platzhalter-Seite, bis
   die Galerie in Phase 4 steht.

Ausserdem beim Testen aufgefallen: eine lokale .env-Datei reicht
ADMIN_PASSWORD auch dann herein, wenn die Umgebungsvariable gelöscht wird -
bei pydantic-settings haben Umgebungsvariablen Vorrang, ein leerer Wert
überstimmt die Datei, ein Löschen nicht. Der Test setzt jetzt leer statt zu
löschen; im Betrieb ist das dieselbe Falle.

Der neue Test prüft, dass /docs keinen einzigen Verweis auf einen fremden
Host enthält - genau das würde sonst unbemerkt wieder hereinrutschen.

61 Tests, alle grün.

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

206 lines
7.4 KiB
Python

"""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