test_attachments_prep.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. """Tests de la préparation des pièces jointes (images, PDF, fichiers texte). Lancer depuis la racine du projet : python -m unittest"""
  2. import asyncio
  3. import codecs
  4. import io
  5. import random
  6. import struct
  7. import threading
  8. import time
  9. import unittest
  10. import zlib
  11. from types import SimpleNamespace
  12. from unittest import mock
  13. import aiohttp
  14. from PIL import Image
  15. from pypdf import PdfWriter
  16. from bot import attachments, images
  17. from bot.attachments import AttachmentReader
  18. from bot.images import MAX_DIMENSION, MAX_PIXELS, ImageError, prepare_image
  19. from bot.media import ImageData
  20. from bot.pdf_files import PdfError, prepare_pdf
  21. from bot.text_files import TextFileError, decode_text_file
  22. def encode(img: Image.Image, fmt: str, **options) -> bytes:
  23. buffer = io.BytesIO()
  24. img.save(buffer, format=fmt, **options)
  25. return buffer.getvalue()
  26. def noise(size: tuple[int, int], mode: str = "RGB") -> Image.Image:
  27. """Image de bruit (toujours le même) : presque impossible à compresser."""
  28. channels = len(mode)
  29. return Image.frombytes(mode, size, random.Random(0).randbytes(size[0] * size[1] * channels))
  30. def opened(data: bytes) -> Image.Image:
  31. img = Image.open(io.BytesIO(data))
  32. img.load()
  33. return img
  34. def png_header(width: int, height: int) -> bytes:
  35. """PNG qui annonce ses dimensions mais ne contient aucun pixel."""
  36. def chunk(kind: bytes, content: bytes) -> bytes:
  37. return struct.pack(">I", len(content)) + kind + content + struct.pack(">I", zlib.crc32(kind + content))
  38. return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
  39. + chunk(b"IDAT", b""))
  40. class PrepareImageTest(unittest.TestCase):
  41. def test_small_jpeg_and_png_sent_as_is(self):
  42. for fmt, media_type in (("JPEG", "image/jpeg"), ("PNG", "image/png"),
  43. ("GIF", "image/gif"), ("WEBP", "image/webp")):
  44. with self.subTest(fmt):
  45. data = encode(Image.new("RGB", (200, 100), "red"), fmt)
  46. result = prepare_image(data)
  47. self.assertEqual(result.media_type, media_type)
  48. self.assertIs(result.data, data)
  49. def test_exactly_max_dimension_sent_as_is(self):
  50. data = encode(Image.new("RGB", (MAX_DIMENSION, 100), "red"), "PNG")
  51. self.assertIs(prepare_image(data).data, data)
  52. def test_large_png_reduced(self):
  53. result = prepare_image(encode(Image.new("RGB", (3136, 1000), "blue"), "PNG"))
  54. self.assertEqual(result.media_type, "image/png") # sans perte : le texte des captures reste net
  55. self.assertEqual(opened(result.data).size, (MAX_DIMENSION, 500))
  56. def test_large_jpeg_reduced(self):
  57. result = prepare_image(encode(Image.new("RGB", (1000, 3136), "green"), "JPEG"))
  58. self.assertEqual(result.media_type, "image/jpeg")
  59. self.assertEqual(opened(result.data).size, (500, MAX_DIMENSION))
  60. def test_unsupported_formats_converted(self):
  61. for fmt in ("BMP", "TIFF"):
  62. with self.subTest(fmt):
  63. result = prepare_image(encode(Image.new("RGB", (200, 100), "red"), fmt))
  64. self.assertEqual(result.media_type, "image/png")
  65. img = opened(result.data)
  66. self.assertEqual((img.format, img.size), ("PNG", (200, 100)))
  67. def test_transparency_kept(self):
  68. result = prepare_image(encode(Image.new("RGBA", (3000, 100), (255, 0, 0, 0)), "PNG"))
  69. self.assertEqual(result.media_type, "image/png")
  70. self.assertEqual(opened(result.data).mode, "RGBA")
  71. def test_heavy_png_becomes_jpeg(self):
  72. # Limite de l'API abaissée : le PNG la dépasse, le JPEG (avec perte) passe en dessous.
  73. img = noise((300, 300))
  74. png, jpeg = encode(img, "PNG", optimize=True), encode(img, "JPEG", quality=45, optimize=True)
  75. self.assertLess(len(jpeg), len(png))
  76. with mock.patch.object(images, "API_MAX_BYTES", (len(png) + len(jpeg)) // 2):
  77. result = prepare_image(encode(img, "PNG"))
  78. self.assertEqual(result.media_type, "image/jpeg")
  79. self.assertLessEqual(len(result.data), (len(png) + len(jpeg)) // 2)
  80. def test_heavy_transparent_png_on_white(self):
  81. img = noise((300, 300), "RGBA")
  82. with mock.patch.object(images, "API_MAX_BYTES", len(encode(img, "PNG", optimize=True)) // 2):
  83. result = prepare_image(encode(img, "PNG"))
  84. self.assertEqual(result.media_type, "image/jpeg")
  85. self.assertEqual(opened(result.data).mode, "RGB")
  86. def test_too_heavy_even_compressed(self):
  87. with mock.patch.object(images, "API_MAX_BYTES", 1000), self.assertRaises(ImageError) as ctx:
  88. prepare_image(encode(noise((300, 300)), "PNG"))
  89. self.assertIn("compresser suffisamment", ctx.exception.user_message)
  90. def test_not_an_image(self):
  91. for data in (b"", b"bonjour, ceci n'est pas une image", encode(Image.new("RGB", (50, 50)), "PNG")[:40]):
  92. with self.subTest(data=data[:10]), self.assertRaises(ImageError) as ctx:
  93. prepare_image(data)
  94. self.assertIn("Je n'arrive pas à lire cette image", ctx.exception.user_message)
  95. def test_too_many_pixels_refused_before_decoding(self):
  96. # En-tête seul, sans pixels : refusée d'après l'en-tête, sinon le décodage échouerait.
  97. with self.assertRaises(ImageError) as ctx:
  98. prepare_image(png_header(MAX_PIXELS // 1000 + 1, 1000))
  99. self.assertIn("beaucoup trop grande", ctx.exception.user_message)
  100. with self.assertRaises(ImageError) as ctx: # juste à la limite : décodage tenté
  101. prepare_image(png_header(MAX_PIXELS // 1000, 1000))
  102. self.assertIn("Je n'arrive pas à lire", ctx.exception.user_message)
  103. def test_pillow_limit_also_refused(self):
  104. with mock.patch.object(Image, "MAX_IMAGE_PIXELS", 1000), self.assertRaises(ImageError) as ctx:
  105. prepare_image(encode(Image.new("RGB", (100, 100)), "PNG"))
  106. self.assertIn("beaucoup trop grande", ctx.exception.user_message)
  107. def test_baseline_jpeg_allowed_more_pixels(self):
  108. # Seuils abaissés pour ne pas fabriquer une vraie image géante.
  109. img = Image.new("RGB", (3136, 1000), "green")
  110. with mock.patch.object(images, "MAX_PIXELS", 1000):
  111. self.assertEqual(opened(prepare_image(encode(img, "JPEG")).data).size, (MAX_DIMENSION, 500))
  112. with self.assertRaises(ImageError) as ctx: # progressif : gardé en entier au décodage
  113. prepare_image(encode(img, "JPEG", progressive=True))
  114. self.assertIn("beaucoup trop grande", ctx.exception.user_message)
  115. with mock.patch.object(images, "MAX_JPEG_PIXELS", 1000), self.assertRaises(ImageError):
  116. prepare_image(encode(img, "JPEG"))
  117. def test_jpeg_decoded_small(self):
  118. # Panorama : réduit au décodage d'après son grand côté, pas son petit.
  119. decoded_sizes = []
  120. thumbnail = Image.Image.thumbnail
  121. def spy(img, *args, **kwargs):
  122. decoded_sizes.append(img.size)
  123. return thumbnail(img, *args, **kwargs)
  124. with mock.patch.object(Image.Image, "thumbnail", autospec=True, side_effect=spy):
  125. result = prepare_image(encode(Image.new("RGB", (4 * MAX_DIMENSION, 1000), "green"), "JPEG"))
  126. self.assertEqual(decoded_sizes, [(MAX_DIMENSION, 250)])
  127. self.assertEqual(opened(result.data).size, (MAX_DIMENSION, 250))
  128. def test_exif_orientation_applied(self):
  129. img = Image.new("RGB", (3136, 1000), "green")
  130. exif = img.getexif()
  131. exif[0x0112] = 6 # photo prise téléphone tourné : à pivoter d'un quart de tour
  132. result = prepare_image(encode(img, "JPEG", exif=exif))
  133. self.assertEqual(opened(result.data).size, (500, MAX_DIMENSION))
  134. class ImagesAtOnceTest(unittest.IsolatedAsyncioTestCase):
  135. async def test_images_prepared_one_at_a_time(self):
  136. running = peak = 0
  137. lock = threading.Lock()
  138. def slow_prepare(data: bytes) -> ImageData:
  139. nonlocal running, peak
  140. with lock:
  141. running += 1
  142. peak = max(peak, running)
  143. time.sleep(0.05)
  144. with lock:
  145. running -= 1
  146. return ImageData("image/png", data)
  147. reader, files = self.reader_and_files(4)
  148. with mock.patch.object(attachments, "prepare_image", slow_prepare):
  149. results = await asyncio.gather(reader.download_images(files[:2]),
  150. reader.quoted_images(files[2:], "cité"))
  151. self.assertEqual(peak, attachments.MAX_IMAGES_AT_ONCE) # tous messages confondus
  152. self.assertEqual([r.data for batch in results for r in batch], [b"\0", b"\1", b"\2", b"\3"])
  153. async def test_slot_kept_until_cancelled_image_is_decoded(self):
  154. # Annuler l'attente n'arrête pas le thread : sa place reste prise jusqu'à ce qu'il finisse.
  155. started = []
  156. release = threading.Event()
  157. def blocking_prepare(data: bytes) -> ImageData:
  158. started.append(data)
  159. release.wait(5)
  160. return ImageData("image/png", data)
  161. reader, files = self.reader_and_files(2)
  162. with mock.patch.object(attachments, "prepare_image", blocking_prepare):
  163. try:
  164. first = asyncio.create_task(reader.download_image(files[0]))
  165. while not started:
  166. await asyncio.sleep(0.01)
  167. first.cancel()
  168. with self.assertRaises(asyncio.CancelledError):
  169. await first
  170. second = asyncio.create_task(reader.download_image(files[1]))
  171. await asyncio.sleep(0.1)
  172. self.assertEqual(started, [b"\0"]) # la première décode encore : la deuxième attend
  173. finally:
  174. release.set()
  175. self.assertEqual((await second).data, b"\1")
  176. @staticmethod
  177. def reader_and_files(count: int) -> tuple[AttachmentReader, list[SimpleNamespace]]:
  178. reader = AttachmentReader(SimpleNamespace(max_image_size=10_000_000))
  179. files = [SimpleNamespace(filename=f"{i}.png", content_type="image/png", size=10,
  180. read=mock.AsyncMock(return_value=bytes([i])))
  181. for i in range(count)]
  182. return reader, files
  183. class DownloadFailureTest(unittest.IsolatedAsyncioTestCase):
  184. """Une coupure réseau ou un délai dépassé pendant le téléchargement prévient le membre au lieu de tout faire échouer."""
  185. def setUp(self):
  186. self.reader = AttachmentReader(SimpleNamespace(max_image_size=10_000_000, pdf_max_pages=10))
  187. @staticmethod
  188. def file(name: str, read) -> SimpleNamespace:
  189. return SimpleNamespace(filename=name, content_type=None, size=10, read=read)
  190. def broken(self, name: str) -> SimpleNamespace:
  191. return self.file(name, mock.AsyncMock(side_effect=aiohttp.ClientConnectionError("connexion réinitialisée")))
  192. async def test_connection_error_asks_to_retry(self):
  193. for name, error in (("photo.png", ImageError), ("doc.pdf", PdfError), ("notes.txt", TextFileError)):
  194. with self.subTest(name), self.assertLogs("chatbot.discord", "ERROR"):
  195. with self.assertRaises(error) as caught:
  196. await self.reader.read([self.broken(name)])
  197. self.assertIn("réessaie", caught.exception.user_message)
  198. async def test_quoted_image_unreadable_is_ignored(self):
  199. good = self.file("ok.png", mock.AsyncMock(return_value=b"ok"))
  200. with mock.patch.object(attachments, "prepare_image", lambda data: ImageData("image/png", data)), \
  201. self.assertLogs("chatbot.discord", "WARNING"):
  202. result = await self.reader.quoted_images([self.broken("ko.png"), good], "cité")
  203. self.assertEqual([r.data for r in result], [b"ok"])
  204. async def test_download_timeout(self):
  205. async def never_ends():
  206. await asyncio.sleep(10)
  207. with mock.patch.object(attachments, "DOWNLOAD_TIMEOUT", 0.01), self.assertLogs("chatbot.discord", "ERROR"):
  208. with self.assertRaises(ImageError):
  209. await self.reader.download_image(self.file("lente.png", never_ends))
  210. def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
  211. writer = PdfWriter()
  212. for _ in range(pages):
  213. writer.add_blank_page(width=200, height=200)
  214. if password is not None:
  215. writer.encrypt(user_password=password, owner_password=owner_password or "propriétaire")
  216. buffer = io.BytesIO()
  217. writer.write(buffer)
  218. return buffer.getvalue()
  219. class PreparePdfTest(unittest.TestCase):
  220. def test_valid_pdf(self):
  221. data = pdf(3)
  222. result = prepare_pdf("cours.pdf", data, max_pages=3)
  223. self.assertTrue(result.is_pdf)
  224. self.assertEqual(result.name, "cours.pdf")
  225. self.assertIs(result.data, data)
  226. def test_too_many_pages(self):
  227. with self.assertRaises(PdfError) as ctx:
  228. prepare_pdf("cours.pdf", pdf(4), max_pages=3)
  229. self.assertEqual(ctx.exception.user_message,
  230. "Ce PDF a 4 pages : je n'en lis que 3 au maximum. Envoie-moi seulement les pages utiles.")
  231. def test_no_page(self):
  232. with self.assertRaises(PdfError) as ctx:
  233. prepare_pdf("vide.pdf", pdf(0), max_pages=3)
  234. self.assertIn("aucune page", ctx.exception.user_message)
  235. def test_password(self):
  236. with self.assertRaises(PdfError) as ctx:
  237. prepare_pdf("secret.pdf", pdf(1, password="secret"), max_pages=3)
  238. self.assertIn("mot de passe", ctx.exception.user_message)
  239. def test_owner_password_only(self):
  240. # Chiffré sans mot de passe d'ouverture (seulement des droits limités) : lisible.
  241. self.assertTrue(prepare_pdf("protégé.pdf", pdf(1, password=""), max_pages=3).is_pdf)
  242. def test_not_a_pdf(self):
  243. for data in (b"", b"bonjour, ceci n'est pas un PDF", encode(Image.new("RGB", (50, 50)), "PNG")):
  244. with self.subTest(data=data[:10]), self.assertLogs("chatbot.pdf", "INFO"), \
  245. self.assertRaises(PdfError) as ctx:
  246. prepare_pdf("faux.pdf", data, max_pages=3)
  247. self.assertEqual(ctx.exception.user_message, "Je n'arrive pas à lire ce PDF.")
  248. class DecodeTextFileTest(unittest.TestCase):
  249. def test_utf8(self):
  250. self.assertEqual(decode_text_file("Été à Noël 😀".encode("utf-8")), "Été à Noël 😀")
  251. def test_utf8_with_bom(self):
  252. self.assertEqual(decode_text_file(codecs.BOM_UTF8 + "Été".encode("utf-8")), "Été")
  253. def test_windows_1252(self):
  254. # « ANSI » du Bloc-notes : pas de l'UTF-8 valide, lu en Windows-1252.
  255. self.assertEqual(decode_text_file("Été, 5 € œuvre".encode("cp1252")), "Été, 5 € œuvre")
  256. def test_bytes_undefined_in_windows_1252(self):
  257. self.assertEqual(decode_text_file(b"caf\xe9 \x81"), "café �")
  258. def test_utf16_with_bom(self):
  259. for encoding in ("utf-16-le", "utf-16-be"):
  260. with self.subTest(encoding):
  261. bom = codecs.BOM_UTF16_LE if encoding == "utf-16-le" else codecs.BOM_UTF16_BE
  262. self.assertEqual(decode_text_file(bom + "Été".encode(encoding)), "Été")
  263. def test_broken_utf16(self):
  264. with self.assertRaises(TextFileError) as ctx:
  265. decode_text_file(codecs.BOM_UTF16_LE + b"a\x00b") # nombre impair d'octets
  266. self.assertIn("pas du texte lisible", ctx.exception.user_message)
  267. def test_line_endings_and_spaces(self):
  268. self.assertEqual(decode_text_file(b"\r\n ligne 1\r\nligne 2\r\n\r\n"), "ligne 1\nligne 2")
  269. def test_empty(self):
  270. for data in (b"", b" \r\n\t ", codecs.BOM_UTF8):
  271. with self.subTest(data=data), self.assertRaises(TextFileError) as ctx:
  272. decode_text_file(data)
  273. self.assertIn("vide", ctx.exception.user_message)
  274. def test_binary(self):
  275. for data in (bytes(range(256)), encode(Image.new("RGB", (50, 50)), "PNG"), "texte".encode("utf-16-le")):
  276. with self.subTest(data=data[:10]), self.assertRaises(TextFileError) as ctx:
  277. decode_text_file(data)
  278. self.assertIn("pas du texte lisible", ctx.exception.user_message)
  279. if __name__ == "__main__":
  280. unittest.main()