"""Tests für das Säubern hochgeladener Bilder. 🔑 Der Kern ist **nicht** „erkennt kaputte Dateien", sondern **„gibt nur weiter, was wirklich ein Bild war"**. Eine Prüfung lässt alles stehen, was sie nicht kennt; das Neuschreiben nimmt nur die Pixel mit. Die Tests hier zielen deshalb auf das, was NICHT mehr in der gespeicherten Datei steht. """ from __future__ import annotations import io from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from PIL import Image from .bilder import MAX_KANTE, KeinBild, saeubern def bild_mit_exif(gps=True, drehung=None, groesse=(80, 50)) -> SimpleUploadedFile: """Ein JPEG mit EXIF — mit Ort, wie es ein Handy liefert.""" im = Image.new("RGB", groesse, (90, 140, 70)) exif = im.getexif() exif[0x010E] = "Testaufnahme" # ImageDescription if drehung is not None: exif[0x0112] = drehung # Orientation if gps: # 52,52 N / 13,405 O. Pillow erwartet Grad, Minuten, Sekunden als # einfache Zahlen — ein verschachteltes Zähler/Nenner-Paar lehnt es ab. exif[0x8825] = { 1: "N", 2: (52.0, 31.0, 12.0), 3: "E", 4: (13.0, 24.0, 18.0), } puffer = io.BytesIO() im.save(puffer, format="JPEG", exif=exif) return SimpleUploadedFile("urlaub.jpg", puffer.getvalue(), "image/jpeg") def einfaches_bild(format="PNG", groesse=(40, 40), modus="RGB") -> SimpleUploadedFile: im = Image.new(modus, groesse, (10, 20, 30) if modus == "RGB" else 128) puffer = io.BytesIO() im.save(puffer, format=format) endung = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}[format] return SimpleUploadedFile(f"v.{endung}", puffer.getvalue(), f"image/{endung}") def gelesen(datei) -> Image.Image: datei.seek(0) return Image.open(io.BytesIO(datei.read())) class SaeubernTests(TestCase): def test_exif_verschwindet_vollstaendig(self): """🔴 Der eigentliche Zweck: Der Ort darf nicht öffentlich werden. Ein Handyfoto trägt GPS — bei dieser App den Garten, in dem ein Kind steht. ``/media/`` ist ohne Anmeldung erreichbar. """ roh = bild_mit_exif(gps=True) # Gegenprobe: im Original IST der Ort drin. self.assertTrue(gelesen(roh).getexif().get_ifd(0x8825)) sauber = saeubern(roh) exif = gelesen(sauber).getexif() self.assertFalse(exif.get_ifd(0x8825), "GPS steht noch im Bild") self.assertFalse(dict(exif), f"EXIF steht noch im Bild: {dict(exif)}") def test_drehung_wird_angewandt_nicht_weggeworfen(self): """⚠️ EXIF wegwerfen ohne zu drehen legt jedes Hochkantfoto um. Der Fehler fiele erst beim Anzeigen auf, weit weg von der Ursache. """ # Orientation 6 heißt: um 90° gedreht anzeigen. Also wird aus einem # 80x50-Bild ein 50x80-Bild. roh = bild_mit_exif(gps=False, drehung=6, groesse=(80, 50)) sauber = saeubern(roh) self.assertEqual(gelesen(sauber).size, (50, 80)) def test_ohne_drehungsangabe_bleibt_die_groesse(self): roh = bild_mit_exif(gps=False, drehung=None, groesse=(80, 50)) self.assertEqual(gelesen(saeubern(roh)).size, (80, 50)) def test_ergebnis_ist_immer_jpeg(self): for format in ("PNG", "JPEG", "WEBP"): with self.subTest(format=format): sauber = saeubern(einfaches_bild(format)) self.assertEqual(gelesen(sauber).format, "JPEG") self.assertTrue(sauber.name.endswith(".jpg")) def test_durchsichtigkeit_wird_auf_weiss_gelegt(self): """Ein PNG mit Alphakanal lässt sich nicht als JPEG schreiben.""" im = Image.new("RGBA", (20, 20), (255, 0, 0, 0)) puffer = io.BytesIO() im.save(puffer, format="PNG") datei = SimpleUploadedFile("t.png", puffer.getvalue(), "image/png") sauber = saeubern(datei) self.assertEqual(gelesen(sauber).mode, "RGB") def test_grosse_bilder_werden_verkleinert(self): sauber = saeubern(einfaches_bild(groesse=(4000, 3000))) breite, hoehe = gelesen(sauber).size self.assertEqual(max(breite, hoehe), MAX_KANTE) # Seitenverhältnis bleibt. self.assertAlmostEqual(breite / hoehe, 4 / 3, places=2) def test_kleine_bilder_werden_nicht_aufgeblasen(self): self.assertEqual(gelesen(saeubern(einfaches_bild(groesse=(40, 40)))).size, (40, 40)) def test_keine_bilddatei_wird_abgewiesen(self): datei = SimpleUploadedFile("boese.jpg", b"GIF89a", "image/jpeg") with self.assertRaises(KeinBild): saeubern(datei) def test_leere_datei_wird_abgewiesen(self): with self.assertRaises(KeinBild): saeubern(SimpleUploadedFile("leer.jpg", b"", "image/jpeg")) def test_pfadanteile_im_namen_verschwinden(self): """⚠️ Ein hochgeladener Name darf keine Verzeichnisse mitbringen.""" datei = einfaches_bild() datei.name = "../../etc/passwd.png" self.assertEqual(saeubern(datei).name, "passwd.jpg") def test_riesige_abmessungen_werden_abgewiesen(self): """Ein winziges Bild kann beim Öffnen Gigabyte belegen.""" from . import bilder with self.settings(): alt = bilder.MAX_PIXEL bilder.MAX_PIXEL = 100 # 10x10 try: with self.assertRaises(KeinBild): saeubern(einfaches_bild(groesse=(40, 40))) finally: bilder.MAX_PIXEL = alt @override_settings(LESE_TOKEN="lesen") class UploadWegTests(TestCase): """Dass das Säubern auch wirklich in den Upload-Wegen steckt.""" def setUp(self): from .tests import make_camera self.kamera = make_camera(token="kamera-geheim", mobil=True) def test_app_upload_speichert_ohne_exif(self): resp = self.client.post( "/api/hochladen/", {"bilder": bild_mit_exif(gps=True)}, HTTP_AUTHORIZATION="Bearer kamera-geheim", ) self.assertEqual(resp.status_code, 201, resp.content) from .models import Sighting s = Sighting.objects.get(id=resp.json()["angelegt"][0]) with s.image.open("rb") as f: exif = Image.open(f).getexif() self.assertFalse(exif.get_ifd(0x8825), "GPS kam durch den Upload") def test_app_upload_weist_unbrauchbares_ab(self): resp = self.client.post( "/api/hochladen/", {"bilder": SimpleUploadedFile("x.jpg", b"kein bild", "image/jpeg")}, HTTP_AUTHORIZATION="Bearer kamera-geheim", ) self.assertEqual(resp.status_code, 400) from .models import Sighting # ⚠️ Und es bleibt KEINE halbe Sichtung stehen. self.assertEqual(Sighting.objects.count(), 0) def test_mehrere_bilder_entweder_alle_oder_keins(self): """Sonst weiß der Hochladende nicht, was angekommen ist.""" resp = self.client.post( "/api/hochladen/", {"bilder": [ einfaches_bild(), SimpleUploadedFile("kaputt.jpg", b"nein", "image/jpeg"), ]}, HTTP_AUTHORIZATION="Bearer kamera-geheim", ) self.assertEqual(resp.status_code, 400) from .models import Sighting self.assertEqual(Sighting.objects.count(), 0) class HeicTests(TestCase): """🔴 iPhones fotografieren HEIC — eine App für iOS muss es annehmen. Am 14.10.2026 wies die Instanz jedes Foto vom Telefon mit 400 ab, Minuten nachdem die Bildprüfung ausgerollt war. Die Prüfung war richtig, nur kannte sie das Format nicht, das die Hälfte aller Telefone erzeugt. """ def heic(self, groesse=(400, 300)) -> SimpleUploadedFile: im = Image.new("RGB", groesse, (90, 140, 70)) puffer = io.BytesIO() im.save(puffer, format="HEIF") return SimpleUploadedFile("IMG_0042.HEIC", puffer.getvalue(), "image/heic") def test_heic_wird_angenommen_und_zu_jpeg(self): from .bilder import HEIC_LESBAR self.assertTrue(HEIC_LESBAR, "pillow-heif fehlt — iPhone-Fotos gehen nicht") sauber = saeubern(self.heic()) self.assertEqual(gelesen(sauber).format, "JPEG") self.assertTrue(sauber.name.endswith(".jpg")) def test_heic_verliert_seine_metadaten_genauso(self): sauber = saeubern(self.heic()) self.assertFalse(dict(gelesen(sauber).getexif())) def test_heic_ueber_den_app_weg(self): from .tests import make_camera make_camera(token="kamera-geheim", mobil=True) resp = self.client.post( "/api/hochladen/", {"bilder": self.heic()}, HTTP_AUTHORIZATION="Bearer kamera-geheim", ) self.assertEqual(resp.status_code, 201, resp.content)