test_library_file_metadata_3077.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. """Integration tests for library file notes, external link and photos (#3077).
  2. Pins the contracts of the details modal's backend: the PUT round-trip for
  3. ``external_url`` (empty string clears), the list-view indicators
  4. (``has_notes`` / ``photo_count``), and the photo routes — membership check
  5. before any disk access, extension allowlist, size cap, and the photo
  6. directory going away with the file.
  7. """
  8. import io
  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.app.models.user import User
  15. from backend.app.utils.library_paths import library_photos_dir
  16. def _jpeg_bytes() -> bytes:
  17. buf = io.BytesIO()
  18. Image.new("RGB", (32, 32), "red").save(buf, "JPEG")
  19. return buf.getvalue()
  20. @pytest.fixture
  21. def isolated_storage(monkeypatch, tmp_path):
  22. """Point library storage at a throwaway directory."""
  23. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  24. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  25. return tmp_path
  26. @pytest.fixture
  27. async def file_factory(db_session):
  28. """Factory for LibraryFile rows with sensible defaults."""
  29. _counter = [0]
  30. async def _create_file(**kwargs):
  31. _counter[0] += 1
  32. counter = _counter[0]
  33. defaults = {
  34. "filename": f"part{counter}.3mf",
  35. "file_path": f"library/files/part{counter}.3mf",
  36. "file_type": "3mf",
  37. "file_size": 100,
  38. }
  39. defaults.update(kwargs)
  40. library_file = LibraryFile(**defaults)
  41. db_session.add(library_file)
  42. await db_session.commit()
  43. await db_session.refresh(library_file)
  44. return library_file
  45. return _create_file
  46. async def _upload(async_client: AsyncClient, file_id: int, name: str = "result.jpg", content: bytes | None = None):
  47. return await async_client.post(
  48. f"/api/v1/library/files/{file_id}/photos",
  49. files={"file": (name, content if content is not None else _jpeg_bytes(), "image/jpeg")},
  50. )
  51. class TestExternalUrlAndNotes:
  52. @pytest.mark.asyncio
  53. @pytest.mark.integration
  54. async def test_external_url_round_trip(self, async_client: AsyncClient, file_factory, isolated_storage):
  55. library_file = await file_factory()
  56. response = await async_client.put(
  57. f"/api/v1/library/files/{library_file.id}",
  58. json={"external_url": "https://www.printables.com/model/1234", "notes": "Print at 0.2mm"},
  59. )
  60. assert response.status_code == 200
  61. body = response.json()
  62. assert body["external_url"] == "https://www.printables.com/model/1234"
  63. assert body["notes"] == "Print at 0.2mm"
  64. assert body["photos"] == []
  65. detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
  66. assert detail.json()["external_url"] == "https://www.printables.com/model/1234"
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_empty_string_clears_external_url(self, async_client: AsyncClient, file_factory, isolated_storage):
  70. library_file = await file_factory(external_url="https://example.com/x")
  71. response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": ""})
  72. assert response.status_code == 200
  73. assert response.json()["external_url"] is None
  74. @pytest.mark.asyncio
  75. @pytest.mark.integration
  76. @pytest.mark.parametrize(
  77. "url",
  78. ["javascript:alert(1)", "data:text/html,hi", "ftp://example.com/x", "www.printables.com/model/1"],
  79. )
  80. async def test_non_http_external_url_is_rejected(
  81. self, async_client: AsyncClient, file_factory, isolated_storage, url: str
  82. ):
  83. library_file = await file_factory(external_url="https://example.com/x")
  84. response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": url})
  85. assert response.status_code == 422
  86. detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
  87. assert detail.json()["external_url"] == "https://example.com/x"
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_omitted_external_url_is_left_alone(self, async_client: AsyncClient, file_factory, isolated_storage):
  91. library_file = await file_factory(external_url="https://example.com/x")
  92. response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"notes": "hi"})
  93. assert response.status_code == 200
  94. assert response.json()["external_url"] == "https://example.com/x"
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_detail_exposes_source_url_read_only(self, async_client: AsyncClient, file_factory, isolated_storage):
  98. library_file = await file_factory(source_type="makerworld", source_url="https://makerworld.com/models/1")
  99. detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
  100. assert detail.json()["source_url"] == "https://makerworld.com/models/1"
  101. @pytest.mark.asyncio
  102. @pytest.mark.integration
  103. async def test_list_carries_indicators_but_not_notes(
  104. self, async_client: AsyncClient, file_factory, isolated_storage
  105. ):
  106. with_meta = await file_factory(
  107. notes="secret notes", external_url="https://example.com/a", photos=["a.jpg", "b.png"]
  108. )
  109. bare = await file_factory()
  110. response = await async_client.get("/api/v1/library/files")
  111. assert response.status_code == 200
  112. by_id = {item["id"]: item for item in response.json()}
  113. assert by_id[with_meta.id]["has_notes"] is True
  114. assert by_id[with_meta.id]["photo_count"] == 2
  115. assert by_id[with_meta.id]["external_url"] == "https://example.com/a"
  116. assert "notes" not in by_id[with_meta.id]
  117. assert "photos" not in by_id[with_meta.id]
  118. assert by_id[bare.id]["has_notes"] is False
  119. assert by_id[bare.id]["photo_count"] == 0
  120. assert by_id[bare.id]["external_url"] is None
  121. class TestPhotos:
  122. @pytest.mark.asyncio
  123. @pytest.mark.integration
  124. async def test_upload_serve_delete_round_trip(
  125. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  126. ):
  127. library_file = await file_factory()
  128. upload = await _upload(async_client, library_file.id)
  129. assert upload.status_code == 200
  130. body = upload.json()
  131. filename = body["filename"]
  132. assert body["status"] == "uploaded"
  133. assert body["photos"] == [filename]
  134. assert filename.endswith(".jpg")
  135. assert (library_photos_dir(library_file.id) / filename).is_file()
  136. await db_session.refresh(library_file)
  137. assert library_file.photos == [filename]
  138. served = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
  139. assert served.status_code == 200
  140. assert served.headers["content-type"] == "image/jpeg"
  141. assert served.content == _jpeg_bytes()
  142. detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
  143. assert detail.json()["photos"] == [filename]
  144. deleted = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
  145. assert deleted.status_code == 200
  146. assert deleted.json() == {"status": "deleted", "photos": []}
  147. assert not (library_photos_dir(library_file.id) / filename).exists()
  148. gone = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
  149. assert gone.status_code == 404
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_external_file_takes_photos_too(self, async_client: AsyncClient, file_factory, isolated_storage):
  153. library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
  154. upload = await _upload(async_client, library_file.id, name="shot.png")
  155. assert upload.status_code == 200
  156. assert (library_photos_dir(library_file.id) / upload.json()["filename"]).is_file()
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_unlisted_filename_is_404_even_when_on_disk(
  160. self, async_client: AsyncClient, file_factory, isolated_storage
  161. ):
  162. library_file = await file_factory()
  163. photos_dir = library_photos_dir(library_file.id)
  164. photos_dir.mkdir(parents=True)
  165. (photos_dir / "stray.jpg").write_bytes(_jpeg_bytes())
  166. response = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
  167. assert response.status_code == 404
  168. response = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
  169. assert response.status_code == 404
  170. assert (photos_dir / "stray.jpg").exists()
  171. @pytest.mark.asyncio
  172. @pytest.mark.integration
  173. async def test_traversal_filename_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
  174. # Even a traversal-looking name that IS in the stored list never leaves
  175. # the photo directory — the membership check is not the only guard.
  176. library_file = await file_factory(photos=["../../secret.jpg"])
  177. (isolated_storage / "archive" / "secret.jpg").parent.mkdir(parents=True, exist_ok=True)
  178. (isolated_storage / "archive" / "secret.jpg").write_bytes(_jpeg_bytes())
  179. response = await async_client.get(
  180. f"/api/v1/library/files/{library_file.id}/photos/..%2F..%2Fsecret.jpg",
  181. )
  182. assert response.status_code in (400, 404)
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_wrong_extension_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
  186. library_file = await file_factory()
  187. response = await _upload(async_client, library_file.id, name="notes.txt", content=b"hello")
  188. assert response.status_code == 400
  189. assert not library_photos_dir(library_file.id).exists()
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_oversized_upload_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
  193. library_file = await file_factory()
  194. response = await _upload(async_client, library_file.id, content=b"\xff" * (10 * 1024 * 1024 + 1))
  195. assert response.status_code == 413
  196. assert not library_photos_dir(library_file.id).exists()
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_upload_to_missing_file_is_404(self, async_client: AsyncClient, isolated_storage):
  200. response = await _upload(async_client, 999999)
  201. assert response.status_code == 404
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_trash_purge_removes_photo_dir(
  205. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  206. ):
  207. library_file = await file_factory()
  208. upload = await _upload(async_client, library_file.id)
  209. assert upload.status_code == 200
  210. photos_dir = library_photos_dir(library_file.id)
  211. assert photos_dir.is_dir()
  212. trashed = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
  213. assert trashed.status_code == 200
  214. # Soft-delete keeps the photos, like the file bytes and thumbnail.
  215. assert photos_dir.is_dir()
  216. purged = await async_client.delete(f"/api/v1/library/trash/{library_file.id}")
  217. assert purged.status_code == 200
  218. assert not photos_dir.exists()
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_external_file_delete_removes_photo_dir(
  222. self, async_client: AsyncClient, file_factory, isolated_storage
  223. ):
  224. library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
  225. upload = await _upload(async_client, library_file.id)
  226. assert upload.status_code == 200
  227. photos_dir = library_photos_dir(library_file.id)
  228. response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
  229. assert response.status_code == 200
  230. assert response.json()["trashed"] is False
  231. assert not photos_dir.exists()
  232. class TestPhotoDirectoryCleanup:
  233. """Every path that hard-deletes a library row takes its photos with it.
  234. The upload/delete round-trip, the trash purge and the external single-file
  235. delete are covered above; these are the remaining ones — folder delete,
  236. bulk delete of files and of whole folders, the external-folder scan that
  237. drops rows for files that vanished from the share, and the admin user
  238. delete that takes the user's items with them.
  239. """
  240. @pytest.mark.asyncio
  241. @pytest.mark.integration
  242. async def test_folder_delete_removes_photo_dir(self, async_client: AsyncClient, file_factory, isolated_storage):
  243. folder = await async_client.post("/api/v1/library/folders", json={"name": "Brackets"})
  244. assert folder.status_code == 200
  245. folder_id = folder.json()["id"]
  246. library_file = await file_factory(folder_id=folder_id)
  247. assert (await _upload(async_client, library_file.id)).status_code == 200
  248. photos_dir = library_photos_dir(library_file.id)
  249. assert photos_dir.is_dir()
  250. response = await async_client.delete(f"/api/v1/library/folders/{folder_id}")
  251. assert response.status_code == 200
  252. assert not photos_dir.exists()
  253. @pytest.mark.asyncio
  254. @pytest.mark.integration
  255. async def test_bulk_delete_removes_photo_dir_of_hard_deleted_file(
  256. self, async_client: AsyncClient, file_factory, isolated_storage
  257. ):
  258. # External files bypass the trash, so bulk delete hard-deletes them;
  259. # a managed file is only soft-deleted and keeps its photos until the
  260. # sweeper runs.
  261. external = await file_factory(is_external=True, file_path="/mnt/nas/ext.stl", file_type="stl")
  262. managed = await file_factory()
  263. for library_file in (external, managed):
  264. assert (await _upload(async_client, library_file.id)).status_code == 200
  265. response = await async_client.post(
  266. "/api/v1/library/bulk-delete",
  267. json={"file_ids": [external.id, managed.id], "folder_ids": []},
  268. )
  269. assert response.status_code == 200
  270. assert response.json()["deleted_files"] == 2
  271. assert not library_photos_dir(external.id).exists()
  272. assert library_photos_dir(managed.id).is_dir()
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_bulk_delete_removes_photo_dirs_under_a_deleted_folder(
  276. self, async_client: AsyncClient, file_factory, isolated_storage
  277. ):
  278. # The folder branch of bulk-delete lets the cascade hard-delete every
  279. # row in the subtree, so it owes the same photo cleanup the file
  280. # branch above it does — including files nested a level down.
  281. parent = await async_client.post("/api/v1/library/folders", json={"name": "Jigs"})
  282. assert parent.status_code == 200
  283. parent_id = parent.json()["id"]
  284. child = await async_client.post("/api/v1/library/folders", json={"name": "V2", "parent_id": parent_id})
  285. assert child.status_code == 200
  286. top_file = await file_factory(folder_id=parent_id)
  287. nested_file = await file_factory(folder_id=child.json()["id"])
  288. for library_file in (top_file, nested_file):
  289. assert (await _upload(async_client, library_file.id)).status_code == 200
  290. assert library_photos_dir(library_file.id).is_dir()
  291. response = await async_client.post(
  292. "/api/v1/library/bulk-delete",
  293. json={"file_ids": [], "folder_ids": [parent_id]},
  294. )
  295. assert response.status_code == 200
  296. assert response.json()["deleted_folders"] == 1
  297. assert (await async_client.get(f"/api/v1/library/files/{top_file.id}")).status_code == 404
  298. assert not library_photos_dir(top_file.id).exists()
  299. assert not library_photos_dir(nested_file.id).exists()
  300. @pytest.mark.asyncio
  301. @pytest.mark.integration
  302. async def test_deleting_a_user_with_their_items_removes_photo_dirs(
  303. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  304. ):
  305. # DELETE /users/{id}?delete_items=true bulk-deletes the rows, which is
  306. # a hard delete like any other and owes the photos with it.
  307. owner = User(username="photo-owner", password_hash="x", role="user")
  308. db_session.add(owner)
  309. await db_session.commit()
  310. await db_session.refresh(owner)
  311. owned = await file_factory(created_by_id=owner.id)
  312. someone_elses = await file_factory()
  313. for library_file in (owned, someone_elses):
  314. assert (await _upload(async_client, library_file.id)).status_code == 200
  315. assert library_photos_dir(library_file.id).is_dir()
  316. response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
  317. assert response.status_code == 204
  318. assert (await async_client.get(f"/api/v1/library/files/{owned.id}")).status_code == 404
  319. assert not library_photos_dir(owned.id).exists()
  320. assert library_photos_dir(someone_elses.id).is_dir()
  321. @pytest.fixture
  322. def external_share(self, monkeypatch, tmp_path):
  323. """Bambuddy's data dir and an opted-in external share, as siblings.
  324. The share cannot live under ``base_dir`` — ``_validate_external_path``
  325. refuses to mount a Bambuddy-managed directory, and the module's
  326. ``isolated_storage`` points ``base_dir`` at ``tmp_path`` itself.
  327. """
  328. data_dir = tmp_path / "data"
  329. data_dir.mkdir()
  330. monkeypatch.setattr(app_settings, "base_dir", data_dir)
  331. monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
  332. share = tmp_path / "share"
  333. share.mkdir()
  334. monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
  335. return share
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_external_scan_removes_photo_dir_of_vanished_file(self, async_client: AsyncClient, external_share):
  339. share = external_share
  340. (share / "bracket.stl").write_bytes(b"fakestl")
  341. folder = await async_client.post(
  342. "/api/v1/library/folders/external",
  343. json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
  344. )
  345. assert folder.status_code == 200
  346. folder_id = folder.json()["id"]
  347. scan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
  348. assert scan.status_code == 200
  349. assert scan.json()["added"] == 1
  350. listing = await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")
  351. file_id = listing.json()[0]["id"]
  352. assert (await _upload(async_client, file_id)).status_code == 200
  353. photos_dir = library_photos_dir(file_id)
  354. assert photos_dir.is_dir()
  355. (share / "bracket.stl").unlink()
  356. rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
  357. assert rescan.status_code == 200
  358. assert rescan.json()["removed"] == 1
  359. assert not photos_dir.exists()