Docker-Image bauen / build (push) Successful in 1m7s
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
278 lines
9.9 KiB
Python
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")
|