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