test_library_preview_thumbnail_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. """Integration tests for the client-rendered preview thumbnail upload (#2976).
  2. STEP/PDF/spreadsheet previews render in the browser and post their first
  3. render to POST /library/files/{id}/preview-thumbnail. These tests pin the
  4. endpoint's contract: PNG-only, capped size, only for the client-preview file
  5. types, and never replacing an existing thumbnail.
  6. """
  7. import io
  8. import zlib
  9. import pytest
  10. from httpx import AsyncClient
  11. from PIL import Image
  12. from backend.app.core.config import settings as app_settings
  13. from backend.app.models.library import LibraryFile
  14. from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
  15. def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
  16. buf = io.BytesIO()
  17. Image.new("RGB", size, color).save(buf, "PNG")
  18. return buf.getvalue()
  19. def _png_claiming(width: int, height: int) -> bytes:
  20. """A ~70-byte PNG whose IHDR declares a canvas it never delivers.
  21. This is the shape that costs memory: the header is what a decoder sizes
  22. its buffer from, and the payload stays small enough to pass any upload cap.
  23. """
  24. raw = bytearray(_png_bytes(size=(1, 1)))
  25. # 8-byte signature, then IHDR: length(4) type(4) data(13) crc(4).
  26. ihdr = raw[8:33]
  27. ihdr[8:12] = width.to_bytes(4, "big")
  28. ihdr[12:16] = height.to_bytes(4, "big")
  29. ihdr[21:25] = zlib.crc32(bytes(ihdr[4:21])).to_bytes(4, "big")
  30. raw[8:33] = ihdr
  31. return bytes(raw)
  32. @pytest.fixture
  33. def isolated_storage(monkeypatch, tmp_path):
  34. """Point thumbnail storage at a throwaway directory."""
  35. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  36. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  37. return tmp_path
  38. @pytest.fixture
  39. async def file_factory(db_session):
  40. """Factory for LibraryFile rows of arbitrary file_type."""
  41. _counter = [0]
  42. async def _create_file(**kwargs):
  43. _counter[0] += 1
  44. counter = _counter[0]
  45. defaults = {
  46. "filename": f"part{counter}.step",
  47. "file_path": f"library/files/part{counter}.step",
  48. "file_type": "step",
  49. "file_size": 100,
  50. }
  51. defaults.update(kwargs)
  52. library_file = LibraryFile(**defaults)
  53. db_session.add(library_file)
  54. await db_session.commit()
  55. await db_session.refresh(library_file)
  56. return library_file
  57. return _create_file
  58. class TestPreviewThumbnailUpload:
  59. @pytest.mark.asyncio
  60. @pytest.mark.integration
  61. async def test_upload_sets_thumbnail_path(
  62. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  63. ):
  64. library_file = await file_factory(file_type="step")
  65. response = await async_client.post(
  66. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  67. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  68. )
  69. assert response.status_code == 200
  70. assert response.json() == {"updated": True}
  71. await db_session.refresh(library_file)
  72. assert library_file.thumbnail_path
  73. stored = isolated_storage / library_file.thumbnail_path
  74. assert stored.exists()
  75. with Image.open(stored) as img:
  76. assert img.format == "PNG"
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_upload_downscales_oversized_image(
  80. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  81. ):
  82. library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
  83. response = await async_client.post(
  84. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  85. files={"thumbnail": ("preview.png", _png_bytes(size=(1024, 1024)), "image/png")},
  86. )
  87. assert response.status_code == 200
  88. await db_session.refresh(library_file)
  89. with Image.open(isolated_storage / library_file.thumbnail_path) as img:
  90. assert max(img.size) <= 512
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_upload_skips_when_thumbnail_exists(
  94. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  95. ):
  96. library_file = await file_factory(file_type="csv", thumbnail_path="archive/library/thumbnails/existing.png")
  97. response = await async_client.post(
  98. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  99. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  100. )
  101. assert response.status_code == 200
  102. assert response.json() == {"updated": False}
  103. await db_session.refresh(library_file)
  104. assert library_file.thumbnail_path == "archive/library/thumbnails/existing.png"
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_upload_rejected_for_server_rendered_types(
  108. self, async_client: AsyncClient, file_factory, isolated_storage
  109. ):
  110. # STL thumbnails are generated server-side; the client route must not
  111. # be able to overwrite them.
  112. library_file = await file_factory(file_type="stl", filename="part.stl")
  113. response = await async_client.post(
  114. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  115. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  116. )
  117. assert response.status_code == 400
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_upload_rejects_non_png(self, async_client: AsyncClient, file_factory, isolated_storage):
  121. library_file = await file_factory(file_type="step")
  122. buf = io.BytesIO()
  123. Image.new("RGB", (64, 64), "blue").save(buf, "JPEG")
  124. response = await async_client.post(
  125. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  126. files={"thumbnail": ("preview.png", buf.getvalue(), "image/png")},
  127. )
  128. assert response.status_code == 400
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_upload_rejects_garbage_bytes(self, async_client: AsyncClient, file_factory, isolated_storage):
  132. library_file = await file_factory(file_type="xlsx")
  133. response = await async_client.post(
  134. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  135. files={"thumbnail": ("preview.png", b"not an image at all", "image/png")},
  136. )
  137. assert response.status_code == 400
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_upload_rejects_oversized_payload(self, async_client: AsyncClient, file_factory, isolated_storage):
  141. library_file = await file_factory(file_type="ods")
  142. oversized = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
  143. response = await async_client.post(
  144. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  145. files={"thumbnail": ("preview.png", oversized, "image/png")},
  146. )
  147. assert response.status_code == 413
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_upload_missing_file_returns_404(self, async_client: AsyncClient, isolated_storage):
  151. response = await async_client.post(
  152. "/api/v1/library/files/999999/preview-thumbnail",
  153. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  154. )
  155. assert response.status_code == 404
  156. @pytest.mark.asyncio
  157. @pytest.mark.integration
  158. async def test_upload_rejects_a_canvas_it_would_have_to_allocate(
  159. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  160. ):
  161. """12000x7000 is under PIL's bomb limit and would decode for real.
  162. A few KB of upload turns into ~340 MB of pixels, so the declared size
  163. has to be refused from the header, before load() is ever reached.
  164. """
  165. library_file = await file_factory(file_type="step")
  166. response = await async_client.post(
  167. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  168. files={"thumbnail": ("preview.png", _png_claiming(12000, 7000), "image/png")},
  169. )
  170. assert response.status_code == 400
  171. await db_session.refresh(library_file)
  172. assert library_file.thumbnail_path is None
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_upload_rejects_a_decompression_bomb_header(
  176. self, async_client: AsyncClient, file_factory, isolated_storage
  177. ):
  178. """PIL raises DecompressionBombError straight off Exception.
  179. It is neither an OSError nor a ValueError, so it escaped the decode
  180. guard and surfaced as a 500 — it is a bad request like any other.
  181. """
  182. library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
  183. response = await async_client.post(
  184. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  185. files={"thumbnail": ("preview.png", _png_claiming(20000, 20000), "image/png")},
  186. )
  187. assert response.status_code == 400
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_storage_failure_is_not_reported_as_a_bad_image(
  191. self, async_client: AsyncClient, monkeypatch, file_factory, isolated_storage
  192. ):
  193. """A full disk is ours to own, not "Invalid thumbnail image"."""
  194. payload = _png_bytes()
  195. library_file = await file_factory(file_type="xlsx")
  196. def _no_space(*args, **kwargs):
  197. raise OSError(28, "No space left on device")
  198. monkeypatch.setattr(Image.Image, "save", _no_space)
  199. response = await async_client.post(
  200. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  201. files={"thumbnail": ("preview.png", payload, "image/png")},
  202. )
  203. assert response.status_code == 500
  204. assert response.json()["detail"] != "Invalid thumbnail image"
  205. class TestPreviewThumbnailOwnership(TestOwnershipPermissionsSetup):
  206. """The ownership branch of the upload route (#2976).
  207. Reuses the shared auth setup: Operators hold library:update_own only, so
  208. they are the group that can tell the two branches apart.
  209. """
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_operator_can_upload_for_their_own_file(
  213. self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
  214. ):
  215. library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator_user"]["id"])
  216. response = await async_client.post(
  217. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  218. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  219. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  220. )
  221. assert response.status_code == 200
  222. await db_session.refresh(library_file)
  223. assert library_file.thumbnail_path
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_operator_cannot_upload_for_someone_elses_file(
  227. self, async_client: AsyncClient, db_session, auth_setup, file_factory, isolated_storage
  228. ):
  229. library_file = await file_factory(file_type="step", created_by_id=auth_setup["operator2_user"]["id"])
  230. response = await async_client.post(
  231. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  232. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  233. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  234. )
  235. assert response.status_code == 403
  236. await db_session.refresh(library_file)
  237. assert library_file.thumbnail_path is None
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_operator_cannot_upload_for_an_ownerless_file(
  241. self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
  242. ):
  243. """created_by_id is NULL — an *_own permission owns nothing here."""
  244. library_file = await file_factory(file_type="step", created_by_id=None)
  245. response = await async_client.post(
  246. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  247. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  248. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  249. )
  250. assert response.status_code == 403
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. async def test_admin_can_upload_for_an_ownerless_file(
  254. self, async_client: AsyncClient, auth_setup, file_factory, isolated_storage
  255. ):
  256. library_file = await file_factory(file_type="step", created_by_id=None)
  257. response = await async_client.post(
  258. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  259. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  260. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  261. )
  262. assert response.status_code == 200