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