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
206 lines
7.4 KiB
Python
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
|