"""Unit tests for `app.services.image_sanitizer.sanitize_image`. Coverage: - happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped) - resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes - pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size BEFORE img.load() is ever called (no full decode → no OOM) — #2214 - byte-size backstop: content over _MAX_BYTES rejected before Image.open - Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap - garbage bytes still raise ImageSanitizationError (regression guard) The oversized-dimension case is asserted via monkeypatch rather than a real 100 MP buffer: allocating the bomb is exactly the OOM we are defending against, so the test proves img.load() is NOT reached. """ from __future__ import annotations import io import os from unittest.mock import MagicMock import pytest # DATABASE_URL required by config before any app import (см. sibling service tests). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from PIL import Image from app.services import image_sanitizer from app.services.image_sanitizer import ( SANITIZED_CONTENT_TYPE, ImageSanitizationError, sanitize_image, ) def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes: buf = io.BytesIO() Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG") return buf.getvalue() def test_small_image_passes_and_becomes_jpeg() -> None: out, ctype = sanitize_image(_png_bytes(100, 80)) assert ctype == SANITIZED_CONTENT_TYPE # Output is a valid JPEG, decoded back in RGB (transparency dropped). reloaded = Image.open(io.BytesIO(out)) assert reloaded.format == "JPEG" assert reloaded.mode == "RGB" assert reloaded.size == (100, 80) def test_oversized_but_under_pixel_cap_is_resized() -> None: # 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed. out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB")) reloaded = Image.open(io.BytesIO(out)) assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION assert reloaded.size == (2400, 1600) def test_pixel_flood_rejected_without_decode(monkeypatch) -> None: """Image declaring > cap pixels is rejected via img.size, never img.load().""" fake_img = MagicMock() fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap fake_img.__enter__.return_value = fake_img fake_img.__exit__.return_value = False monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img) with pytest.raises(ImageSanitizationError) as excinfo: sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header") assert "exceeds" in str(excinfo.value) # The whole point of the fix: no full decode happened → no hundreds-of-MB buffer. fake_img.load.assert_not_called() def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None: """Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison).""" fake_img = MagicMock() fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap fake_img.mode = "RGB" fake_img.__enter__.return_value = fake_img fake_img.__exit__.return_value = False monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img) monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im) sanitize_image(b"whatever") fake_img.load.assert_called_once() def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None: called = {"open": False} def _guard(*_a, **_k): called["open"] = True raise AssertionError("Image.open must not be called for oversized bytes") monkeypatch.setattr(image_sanitizer.Image, "open", _guard) with pytest.raises(ImageSanitizationError): sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1)) assert called["open"] is False def test_pillow_bomb_threshold_lowered_to_cap() -> None: assert image_sanitizer._MAX_PIXELS == 40_000_000 assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS def test_garbage_bytes_rejected() -> None: with pytest.raises(ImageSanitizationError): sanitize_image(b"definitely not an image")