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
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""Tests rund ums Reservieren.
|
|
|
|
Schwerpunkt ist die Berechtigung: die Galerie ist frei zugänglich, also
|
|
darf ein offener release-Endpunkt nicht fremde Reservierungen löschen
|
|
können.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def item_id(client, kategorie_id):
|
|
return client.post(
|
|
"/api/v1/items", json={"size": "98/104", "category_id": kategorie_id}
|
|
).json()["id"]
|
|
|
|
|
|
def test_reservieren(client, item_id):
|
|
antwort = client.post(
|
|
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}
|
|
)
|
|
assert antwort.status_code == 200
|
|
daten = antwort.json()
|
|
assert daten["item"]["status"] == "reserved"
|
|
assert daten["item"]["reserved_by"] == "Familie Meier"
|
|
assert len(daten["reservation_token"]) > 20
|
|
|
|
|
|
def test_zweimal_reservieren_geht_nicht(client, item_id):
|
|
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "A"})
|
|
zweiter = client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "B"})
|
|
assert zweiter.status_code == 409
|
|
|
|
|
|
def test_freigeben_mit_token(client, item_id):
|
|
token = client.post(
|
|
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"}
|
|
).json()["reservation_token"]
|
|
|
|
antwort = client.post(f"/api/v1/items/{item_id}/release?token={token}")
|
|
assert antwort.status_code == 200
|
|
assert antwort.json()["status"] == "available"
|
|
assert antwort.json()["reserved_by"] is None
|
|
|
|
|
|
def test_freigeben_ohne_token_wird_abgewiesen(client, gast, item_id):
|
|
"""Der Kern der Entscheidung: sonst löscht jeder Besucher fremde
|
|
Reservierungen.
|
|
|
|
Bewusst als Gast geprüft - der angemeldete Betreiber darf das sehr wohl
|
|
ohne Token, damit er eine Missbrauchswelle aufräumen kann.
|
|
"""
|
|
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
|
client.post("/api/v1/auth/logout")
|
|
|
|
assert gast.post(f"/api/v1/items/{item_id}/release").status_code == 403
|
|
assert gast.post(f"/api/v1/items/{item_id}/release?token=falsch").status_code == 403
|
|
|
|
# Reservierung steht unverändert
|
|
item = gast.get(f"/api/v1/items/{item_id}").json()
|
|
assert item["status"] == "reserved"
|
|
assert item["reserved_by"] == "Familie Meier"
|
|
|
|
|
|
def test_token_gilt_nach_dem_freigeben_nicht_mehr(client, gast, item_id):
|
|
"""Sonst könnte ein alter Link später die Reservierung einer anderen
|
|
Person aufheben."""
|
|
token = client.post(
|
|
f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Erste"}
|
|
).json()["reservation_token"]
|
|
client.post(f"/api/v1/items/{item_id}/release?token={token}")
|
|
|
|
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Zweite"})
|
|
client.post("/api/v1/auth/logout")
|
|
assert gast.post(f"/api/v1/items/{item_id}/release?token={token}").status_code == 403
|
|
|
|
|
|
def test_abgegeben_loescht_den_namen(client, item_id):
|
|
"""Datensparsam - die Galerie ist öffentlich."""
|
|
client.post(f"/api/v1/items/{item_id}/reserve", json={"reserved_by": "Familie Meier"})
|
|
antwort = client.post(f"/api/v1/items/{item_id}/mark-given").json()
|
|
assert antwort["status"] == "given_away"
|
|
assert antwort["reserved_by"] is None
|
|
|
|
|
|
def test_name_ohne_werbung(client, item_id):
|
|
antwort = client.post(
|
|
f"/api/v1/items/{item_id}/reserve",
|
|
json={"reserved_by": "Jetzt billig kaufen http://spam.example"},
|
|
)
|
|
assert antwort.status_code == 422
|