Files
kleiderboerse/tests/test_bilder.py
T
StefanandClaude Opus 5 e0212cfa2c
Docker-Image bauen / build (push) Successful in 1m7s
Tests von 55 auf 12 Sekunden, Abhängigkeiten nur noch einmal installieren
Der Testlauf war zu langsam. Gemessen statt geraten: 0,68 s Vorbereitung
pro Test, bei 81 Tests praktisch die ganze Laufzeit.

Ursache war bcrypt. Pro Test wird ein Hash gebildet und geprüft, und das
ist absichtlich langsam - genau das soll es im Betrieb sein. Der Aufwand
ist jetzt über BCRYPT_ROUNDS einstellbar (Standard bleibt 12) und in den
Tests auf 4 gesetzt: dort geht es um die Ablauflogik, nicht um die Stärke
des Hashes.

Der Dekompressionsbomben-Test erzeugte ein Bild mit 400 Megapixeln, allein
dafür 4,8 s. Jetzt wird stattdessen die Grenze heruntergesetzt und ein
kleines Bild verwendet - dieselbe Codestelle, ohne die Wartezeit.

Ausserdem installierten Test- und Anwendungs-Image dieselben
Abhängigkeiten zweimal. Dockerfile.test setzt nun auf dem gebauten
Anwendungs-Image auf, der Testschritt kommt entsprechend danach.

Dabei zwei Fallen, die beide auffielen, weil die Tests plötzlich wieder
langsam waren:
- Das Basis-Image bringt seinen eigenen Stand von app/ mit. Ohne erneutes
  Kopieren prüfen die Tests den Code des Basis-Images - ist es veraltet,
  läuft alles gegen alten Code und meldet Erfolg.
- Der ENTRYPOINT des Anwendungs-Images startet eine Datenbank-Migration.
  Für Tests weder nötig noch erwünscht, darum geleert.

Ganze Kette lokal durchgespielt: bauen, testen, Image prüfen - 22 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-30 14:45:24 +02:00

278 lines
9.9 KiB
Python

"""Tests zum Bild-Upload.
Schwerpunkt sind die Fälle, die schiefgehen sollen: der Upload ist die
grösste Angriffsfläche der Anwendung.
"""
import io
import pytest
from PIL import Image
from app import crud
def bild_bytes(breite=800, hoehe=600, format="JPEG", farbe=(200, 30, 30)) -> bytes:
puffer = io.BytesIO()
Image.new("RGB", (breite, hoehe), farbe).save(puffer, format)
return puffer.getvalue()
@pytest.fixture
def item_id(client, kategorie_id):
return client.post(
"/api/v1/items", json={"size": "98/104", "category_id": kategorie_id}
).json()["id"]
# ------------------------------------------------------------ Normalfall ---
def test_hochladen_und_abrufen(client, item_id, upload_ordner):
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("foto.jpg", bild_bytes(), "image/jpeg")},
)
assert antwort.status_code == 201
bilder = antwort.json()
assert len(bilder) == 1
name = bilder[0]["image_url"]
# Selbst vergebener Name, nicht der hochgeladene
assert name.endswith(".webp")
assert "foto" not in name
# Als WebP abgelegt, nicht als JPEG
datei = upload_ordner / name
assert datei.is_file()
assert Image.open(datei).format == "WEBP"
ausgeliefert = client.get(f"/uploads/{name}")
assert ausgeliefert.status_code == 200
assert ausgeliefert.headers["content-type"] == "image/webp"
assert ausgeliefert.headers["x-content-type-options"] == "nosniff"
def test_erstes_bild_wird_hauptbild(client, item_id):
erstes = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")},
).json()[0]
zweites = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("b.jpg", bild_bytes(), "image/jpeg")},
).json()[0]
assert erstes["is_primary"] is True
assert zweites["is_primary"] is False
def test_hauptbild_umstellen(client, item_id):
client.post(f"/api/v1/items/{item_id}/images",
files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")})
zweites = client.post(f"/api/v1/items/{item_id}/images",
files={"dateien": ("b.jpg", bild_bytes(), "image/jpeg")}).json()[0]
client.post(f"/api/v1/images/{zweites['id']}/primary")
bilder = client.get(f"/api/v1/items/{item_id}").json()["bilder"]
assert [b["is_primary"] for b in bilder] == [False, True]
def test_grosses_bild_wird_verkleinert(client, item_id, upload_ordner):
bild = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("gross.jpg", bild_bytes(4000, 3000), "image/jpeg")},
).json()[0]
assert Image.open(upload_ordner / bild["image_url"]).width == 1200
def test_kleines_bild_bleibt_wie_es_ist(client, item_id, upload_ordner):
bild = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("klein.jpg", bild_bytes(400, 300), "image/jpeg")},
).json()[0]
assert Image.open(upload_ordner / bild["image_url"]).width == 400
# --------------------------------------------------------- Abwehrverhalten ---
def test_getarnte_datei_wird_abgewiesen(client, item_id):
"""Eine PHP-Datei mit der Endung .jpg darf nicht durchkommen - der
Inhalt entscheidet, nicht die Endung."""
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("harmlos.jpg", b"<?php system($_GET['c']); ?>", "image/jpeg")},
)
assert antwort.status_code == 422
def test_leere_datei_wird_abgewiesen(client, item_id):
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("leer.jpg", b"", "image/jpeg")},
)
assert antwort.status_code == 422
def test_zu_grosse_datei_wird_abgewiesen(client, item_id, monkeypatch):
from app.config import einstellungen
einstellungen.cache_clear()
monkeypatch.setenv("MAX_UPLOAD_BYTES", "1024")
try:
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("gross.jpg", bild_bytes(2000, 2000), "image/jpeg")},
)
assert antwort.status_code == 422
finally:
einstellungen.cache_clear()
def test_dekompressionsbombe_wird_abgewiesen(client, item_id, monkeypatch):
"""Ein paar Kilobyte, die beim Entpacken den Speicher füllen.
Pillow allein würde hier nur warnen - der Fehler kommt erst bei der
doppelten Pixelzahl. Darum die ausdrückliche Prüfung in images.py.
Statt wirklich 400 Megapixel zu erzeugen (was den Test um Sekunden
verlängerte) wird die Grenze heruntergesetzt: geprüft wird dieselbe
Codestelle, nur eben mit einem kleinen Bild.
"""
from app import images
monkeypatch.setattr(images, "MAX_PIXEL", 100_000)
puffer = io.BytesIO()
# 600 x 600 = 360'000 Bildpunkte, also über der gesetzten Grenze
Image.new("RGB", (600, 600), (0, 0, 0)).save(puffer, "PNG", compress_level=9)
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("bombe.png", puffer.getvalue(), "image/png")},
)
assert antwort.status_code == 422
assert "Bildpunkte" in antwort.json()["detail"]
def test_pfadmanipulation_beim_ausliefern(client):
"""Der Dateiname kommt aus der URL und ist damit Nutzereingabe."""
for versuch in ["../../etc/passwd", "..%2f..%2fetc%2fpasswd", "beliebig.txt",
"0123456789abcdef.webp"]:
assert client.get(f"/uploads/{versuch}").status_code == 404
def test_heic_vom_iphone_wird_angenommen(client, item_id, upload_ordner):
"""iPhones nehmen in HEIC auf. Pillow allein kann das nicht öffnen -
ohne pillow-heif fiele beim Stapel-Import jedes iPhone-Foto durch."""
puffer = io.BytesIO()
Image.new("RGB", (2000, 1500), (40, 90, 160)).save(puffer, "HEIF")
antwort = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("IMG_0042.HEIC", puffer.getvalue(), "image/heic")},
)
assert antwort.status_code == 201
gespeichert = Image.open(upload_ordner / antwort.json()[0]["image_url"])
assert gespeichert.format == "WEBP"
def test_exif_wird_entfernt(client, item_id, upload_ordner):
"""Handyfotos tragen oft die GPS-Koordinaten der Wohnung mit sich."""
puffer = io.BytesIO()
bild = Image.new("RGB", (600, 400), (10, 120, 10))
exif = bild.getexif()
exif[271] = "TestKamera" # Make
exif[274] = 1 # Orientation
bild.save(puffer, "JPEG", exif=exif)
hochgeladen = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("mit-exif.jpg", puffer.getvalue(), "image/jpeg")},
).json()[0]
gespeichert = Image.open(upload_ordner / hochgeladen["image_url"])
assert not dict(gespeichert.getexif())
# -------------------------------------------------------------- Aufräumen ---
def test_bild_loeschen_entfernt_die_datei(client, item_id, upload_ordner):
bild = client.post(
f"/api/v1/items/{item_id}/images",
files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")},
).json()[0]
datei = upload_ordner / bild["image_url"]
assert datei.is_file()
assert client.delete(f"/api/v1/images/{bild['id']}").status_code == 204
assert not datei.exists()
def test_naechstes_bild_rueckt_als_hauptbild_nach(client, item_id):
erstes = client.post(f"/api/v1/items/{item_id}/images",
files={"dateien": ("a.jpg", bild_bytes(), "image/jpeg")}).json()[0]
client.post(f"/api/v1/items/{item_id}/images",
files={"dateien": ("b.jpg", bild_bytes(), "image/jpeg")})
client.delete(f"/api/v1/images/{erstes['id']}")
bilder = client.get(f"/api/v1/items/{item_id}").json()["bilder"]
assert len(bilder) == 1
assert bilder[0]["is_primary"] is True
def test_eintrag_loeschen_entfernt_die_dateien(client, item_id, upload_ordner):
"""ON DELETE CASCADE räumt nur die Zeilen weg, nicht die Dateien."""
bilder = client.post(
f"/api/v1/items/{item_id}/images",
files=[("dateien", ("a.jpg", bild_bytes(), "image/jpeg")),
("dateien", ("b.jpg", bild_bytes(), "image/jpeg"))],
).json()
dateien = [upload_ordner / b["image_url"] for b in bilder]
assert all(d.is_file() for d in dateien)
client.delete(f"/api/v1/items/{item_id}")
assert not any(d.exists() for d in dateien)
# ----------------------------------------------------------- Stapel-Import ---
def test_stapel_import_legt_entwuerfe_an(client, upload_ordner):
antwort = client.post(
"/api/v1/items/batch-upload",
files=[("dateien", (f"foto{i}.jpg", bild_bytes(), "image/jpeg")) for i in range(5)],
)
assert antwort.status_code == 201
entwuerfe = antwort.json()
assert len(entwuerfe) == 5
assert all(e["status"] == "draft" for e in entwuerfe)
assert all(len(e["bilder"]) == 1 for e in entwuerfe)
assert all(e["display_title"] == "Kleidungsstück" for e in entwuerfe)
# Nicht in der Galerie, aber als Entwurf auffindbar
assert client.get("/api/v1/items").json()["total"] == 0
assert client.get("/api/v1/items?status=draft").json()["total"] == 5
def test_stapel_import_bricht_bei_kaputtem_bild_ab(client):
"""Lieber der ganze Stapel als ein still fehlendes Foto unter zwanzig."""
antwort = client.post(
"/api/v1/items/batch-upload",
files=[("dateien", ("gut.jpg", bild_bytes(), "image/jpeg")),
("dateien", ("kaputt.jpg", b"kein bild", "image/jpeg"))],
)
assert antwort.status_code == 422
def test_entwurf_wird_mit_bild_zum_eintrag(client, kategorie_id):
entwurf = client.post(
"/api/v1/items/batch-upload",
files={"dateien": ("foto.jpg", bild_bytes(), "image/jpeg")},
).json()[0]
fertig = client.patch(
f"/api/v1/items/{entwurf['id']}",
json={"size": "92", "category_id": kategorie_id, "status": "available"},
).json()
assert fertig["status"] == "available"
assert len(fertig["bilder"]) == 1
assert fertig["display_title"].endswith("92")