Files
StefanandClaude Fable 5 c0a18ac735 Wettlauf beim Reservieren, 500er bei unsinnigen Eingaben
Drei P3-Funde aus der Durchsicht vom 14.09.:

Reservieren: Statusprüfung und Schreiben waren getrennt - zwei
gleichzeitige Anfragen konnten beide passieren, die zweite überschrieb
Name und Token der ersten, ohne dass die es erfuhr. Jetzt entscheidet
ein UPDATE mit Status-Bedingung; der Verlierer bekommt None und die
Route meldet "schon weg" (Seite) bzw. 409 (API).

/?limit=abc lieferte jedem anonymen Besucher einen internen
Serverfehler, limit=-1 hiess in SQLite "alles". _limit_lesen() fällt
bei Unsinn auf die Seitengrösse zurück und deckelt bei 500.

Erfassen/Bearbeiten: int(category_id) und die Pydantic-Prüfung warfen
im Handler - 500 statt Fehlermeldung. Jetzt Meldung; ausserdem werden
die Angaben VOR den Bildern geprüft, damit bei abgelehnten Angaben
keine verwaisten Bilddateien liegen bleiben.

Vier neue Tests, jeder einmal gegen den alten Code gelaufen und dabei
rot geworden. 84 lokal grün (HEIC-Test braucht pillow-heif, Docker).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 17:22:54 +02:00

289 lines
10 KiB
Python

"""Tests der sichtbaren Seiten."""
import io
import re
from PIL import Image
def bild_bytes() -> bytes:
puffer = io.BytesIO()
Image.new("RGB", (600, 800), (120, 160, 200)).save(puffer, "JPEG")
return puffer.getvalue()
def csrf_aus(html: str) -> str:
treffer = re.search(r'name="csrf" value="([^"]+)"', html)
assert treffer, "kein CSRF-Token im Formular"
return treffer.group(1)
def erfasse(client, kategorie_id, **felder):
"""Legt über das Formular ein Kleidungsstück an."""
seite = client.get("/erfassen")
daten = {
"csrf": csrf_aus(seite.text),
"size": "98/104",
"category_id": str(kategorie_id),
"gender": "unisex",
"season": "all_year",
"condition": "good",
"title": "",
"description": "",
}
daten.update(felder)
return client.post("/erfassen", data=daten, follow_redirects=True)
# --------------------------------------------------------------- Galerie ---
def test_galerie_ist_ohne_anmeldung_sichtbar(gast):
antwort = gast.get("/")
assert antwort.status_code == 200
assert "Kleider-Börse" in antwort.text
def test_galerie_zeigt_erfasstes(client, gast, kategorie_id):
erfasse(client, kategorie_id, title="Rote Winterjacke")
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
seite = gast.get("/")
assert "Rote Winterjacke" in seite.text
def test_abgeleiteter_titel_erscheint(client, kategorie_id):
"""Titel leer gelassen - dann bildet ihn die Anwendung."""
erfasse(client, kategorie_id, size="80")
seite = client.get("/")
assert "80" in seite.text
def test_filter_liefert_nur_die_liste(client, kategorie_id):
"""HTMX tauscht nur den Ausschnitt aus, nicht die ganze Seite."""
erfasse(client, kategorie_id, size="80", title="Kleines Teil")
erfasse(client, kategorie_id, size="122", title="Grosses Teil")
ausschnitt = client.get("/teil/liste?size=80")
assert ausschnitt.status_code == 200
assert "Kleines Teil" in ausschnitt.text
assert "Grosses Teil" not in ausschnitt.text
# Kein vollständiges Dokument
assert "<html" not in ausschnitt.text.lower()
def test_entwuerfe_tauchen_nicht_in_der_galerie_auf(client, db_sitzung):
from app import crud
crud.entwuerfe_anlegen(db_sitzung, 3)
assert "Nachtragen" not in client.get("/").text
# ------------------------------------------------------------- Erfassung ---
def test_erfassen_braucht_anmeldung(gast):
assert gast.get("/erfassen").status_code == 401
def test_erfassen_und_anzeigen(client, kategorie_id):
antwort = erfasse(client, kategorie_id, title="Blaue Hose")
assert antwort.status_code == 200
assert "gespeichert" in antwort.text
detail = client.get("/kleid/1")
assert detail.status_code == 200
assert "Blaue Hose" in detail.text
def test_werte_werden_fuer_das_naechste_teil_gemerkt(client, kategorie_id):
"""Beim Abarbeiten einer Kiste ändert sich meist nur der Titel."""
erfasse(client, kategorie_id, size="122", gender="girl")
formular = client.get("/erfassen").text
assert re.search(r'<option value="122"\s+selected', formular)
assert re.search(r'<option value="girl"\s+selected', formular)
def test_formular_ohne_token_wird_abgewiesen(client, kategorie_id):
antwort = client.post("/erfassen", data={
"csrf": "erfunden", "size": "80", "category_id": str(kategorie_id),
"gender": "unisex", "season": "all_year", "condition": "good",
"title": "", "description": "",
})
assert antwort.status_code == 400
# ----------------------------------------------------------- Stapelweise ---
def test_stapel_import_ueber_das_formular(client):
seite = client.get("/erfassen")
antwort = client.post(
"/stapel",
data={"csrf": csrf_aus(seite.text)},
files=[("dateien", (f"f{i}.jpg", bild_bytes(), "image/jpeg")) for i in range(4)],
follow_redirects=True,
)
assert antwort.status_code == 200
assert "4 Fotos hochgeladen" in antwort.text
assert "Unfertige Einträge (4)" in antwort.text
def test_entwurf_nachtragen_macht_ihn_sichtbar(client, kategorie_id):
seite = client.get("/erfassen")
client.post(
"/stapel",
data={"csrf": csrf_aus(seite.text)},
files={"dateien": ("f.jpg", bild_bytes(), "image/jpeg")},
follow_redirects=True,
)
formular = client.get("/kleid/1/bearbeiten")
assert formular.status_code == 200
assert "Entwurf nachtragen" in formular.text
fertig = client.post("/kleid/1/bearbeiten", data={
"csrf": csrf_aus(formular.text), "size": "92",
"category_id": str(kategorie_id), "gender": "boy",
"season": "all_year", "condition": "good", "title": "", "description": "",
}, follow_redirects=True)
assert fertig.status_code == 200
# Jetzt in der Galerie
galerie = client.get("/")
assert "92" in galerie.text
# ---------------------------------------------------------- Reservierung ---
def test_reservieren_zeigt_den_freigabe_link(client, gast, kategorie_id):
erfasse(client, kategorie_id, title="Warme Jacke")
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
detail = gast.get("/kleid/1")
antwort = gast.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
})
assert antwort.status_code == 200
assert "Reserviert für dich" in antwort.text
# Absolute Adresse - der Link soll sich aufbewahren und weiterschicken
# lassen, ein reiner Pfad nützte dabei nichts.
link = re.search(r'value="(https?://[^"]*/kleid/1/freigeben\?token=[^"]+)"',
antwort.text)
assert link, f"kein Freigabe-Link angezeigt:\n{antwort.text[-600:]}"
# Der Link hebt die Reservierung wieder auf
auf = gast.get(link.group(1), follow_redirects=True)
assert "aufgehoben" in auf.text
def test_name_bleibt_oeffentlich_verborgen(client, gast, kategorie_id):
"""Die Galerie ist frei zugänglich - der Name gehört nicht hinein."""
erfasse(client, kategorie_id, title="Warme Jacke")
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Geheim",
})
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
assert "Familie Geheim" not in gast.get("/").text
assert "Familie Geheim" not in gast.get("/kleid/1").text
assert "reserviert" in gast.get("/kleid/1").text
def test_betreiber_sieht_den_namen(client, kategorie_id):
erfasse(client, kategorie_id, title="Warme Jacke")
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Familie Meier",
})
assert "Familie Meier" in client.get("/kleid/1").text
def test_falscher_freigabe_link_aendert_nichts(client, gast, kategorie_id):
erfasse(client, kategorie_id)
detail = client.get("/kleid/1")
client.post("/kleid/1/reservieren", data={
"csrf": csrf_aus(detail.text), "reserved_by": "Wer auch immer",
})
client.post("/abmelden", data={"csrf": csrf_aus(client.get("/").text)})
antwort = gast.get("/kleid/1/freigeben?token=daneben", follow_redirects=True)
assert "passt nicht" in antwort.text
assert "reserviert" in gast.get("/kleid/1").text
# ---------------------------------------------------------------- Seiten ---
def test_seiten_laden_nichts_von_fremden_servern(client, kategorie_id):
"""Sonst blockiert die CSP es - und die Seite bliebe leer."""
erfasse(client, kategorie_id)
for pfad in ["/", "/kleid/1", "/erfassen", "/anmelden", "/entwuerfe"]:
html = client.get(pfad).text
fremde = re.findall(r'(?:src|href)="(?:https?:)?//[^"]+"', html)
assert not fremde, f"{pfad} lädt von aussen: {fremde}"
# --------------------------------------------------------- Darstellung ---
def test_fusszeile_zeigt_die_version(client, monkeypatch):
"""Die CI reicht Tag und Commit durch; lokal steht der Build-Zeitpunkt."""
from app.version import version_info
version_info.cache_clear()
monkeypatch.setenv("APP_VERSION", "1.2.3")
monkeypatch.setenv("APP_REVISION", "abcdef1234567")
try:
assert "Version 1.2.3" in client.get("/").text
finally:
version_info.cache_clear()
def test_ohne_tag_erscheint_der_commit(client, monkeypatch):
from app.version import version_info
version_info.cache_clear()
monkeypatch.setenv("APP_VERSION", "")
monkeypatch.setenv("APP_REVISION", "abcdef1234567")
try:
assert "Commit abcdef1" in client.get("/").text
finally:
version_info.cache_clear()
def test_farbschema_skript_laeuft_vor_dem_zeichnen(client):
"""Ohne das blitzt beim Laden kurz die helle Fassung auf."""
html = client.get("/").text
theme = html.index('src="/static/theme.js"')
koerper = html.index("<body")
assert theme < koerper, "theme.js muss vor <body> stehen"
# und darf nicht verzögert werden
zeile = html[html.rindex("<script", 0, theme):html.index(">", theme) + 1]
assert "defer" not in zeile and "async" not in zeile
def test_umschalter_ist_vorhanden(client):
assert 'id="schema-knopf"' in client.get("/").text
# ------------------------------------------------- Unsinnige Eingaben ---
def test_unsinniges_limit_ist_kein_serverfehler(gast):
"""?limit= kommt aus der URL - Buchstaben oder negative Werte dürfen
keinen 500er auslösen, sondern fallen auf die Seitengrösse zurück."""
assert gast.get("/?limit=abc").status_code == 200
assert gast.get("/teil/liste?limit=-5").status_code == 200
def test_unsinnige_kategorie_gibt_meldung_statt_500(client, kategorie_id):
antwort = erfasse(client, kategorie_id, category_id="abc")
assert antwort.status_code == 200
assert "Bitte die Angaben prüfen" in antwort.text
def test_zu_langer_titel_gibt_meldung_statt_500(client, kategorie_id):
"""maxlength im HTML schützt nur den Browserweg - die Anwendung muss
es selbst abfangen."""
antwort = erfasse(client, kategorie_id, title="x" * 101)
assert antwort.status_code == 200
assert "Bitte die Angaben prüfen" in antwort.text