test_external_folders_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. """Integration tests for External Folder API endpoints."""
  2. import os
  3. import tempfile
  4. from pathlib import Path
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestExternalFolderCreation:
  8. """Tests for POST /library/folders/external."""
  9. @pytest.fixture
  10. def external_dir(self, tmp_path):
  11. """Create a temporary directory to act as an external folder."""
  12. ext_dir = tmp_path / "nas_share"
  13. ext_dir.mkdir()
  14. # Add some test files
  15. (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
  16. (ext_dir / "bracket.stl").write_bytes(b"fakestl")
  17. (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
  18. (ext_dir / "readme.txt").write_text("not a print file")
  19. (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
  20. return ext_dir
  21. @pytest.fixture
  22. def nested_external_dir(self, external_dir):
  23. """Create a nested subdirectory in the external folder."""
  24. sub = external_dir / "subfolder"
  25. sub.mkdir()
  26. (sub / "nested_part.stl").write_bytes(b"nestedstl")
  27. return external_dir
  28. @pytest.mark.asyncio
  29. @pytest.mark.integration
  30. async def test_create_external_folder(self, async_client: AsyncClient, db_session, external_dir):
  31. """Verify external folder can be created with valid path."""
  32. data = {
  33. "name": "NAS Prints",
  34. "external_path": str(external_dir),
  35. "readonly": True,
  36. "show_hidden": False,
  37. }
  38. response = await async_client.post("/api/v1/library/folders/external", json=data)
  39. assert response.status_code == 200
  40. result = response.json()
  41. assert result["name"] == "NAS Prints"
  42. assert result["is_external"] is True
  43. assert result["external_readonly"] is True
  44. assert result["external_show_hidden"] is False
  45. assert result["external_path"] == str(external_dir.resolve())
  46. @pytest.mark.asyncio
  47. @pytest.mark.integration
  48. async def test_create_external_folder_nonexistent_path(self, async_client: AsyncClient, db_session):
  49. """Verify 400 for non-existent path."""
  50. data = {
  51. "name": "Bad Path",
  52. "external_path": "/nonexistent/path/that/does/not/exist",
  53. }
  54. response = await async_client.post("/api/v1/library/folders/external", json=data)
  55. assert response.status_code == 400
  56. assert "does not exist" in response.json()["detail"]
  57. @pytest.mark.asyncio
  58. @pytest.mark.integration
  59. async def test_create_external_folder_system_dir_blocked(self, async_client: AsyncClient, db_session):
  60. """Verify system directories are blocked."""
  61. data = {
  62. "name": "System",
  63. "external_path": "/proc",
  64. }
  65. response = await async_client.post("/api/v1/library/folders/external", json=data)
  66. assert response.status_code == 400
  67. assert "system directory" in response.json()["detail"].lower()
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_create_external_folder_file_not_dir(self, async_client: AsyncClient, db_session, tmp_path):
  71. """Verify 400 when path is a file, not directory."""
  72. file_path = tmp_path / "not_a_dir.txt"
  73. file_path.write_text("hello")
  74. data = {
  75. "name": "Not A Dir",
  76. "external_path": str(file_path),
  77. }
  78. response = await async_client.post("/api/v1/library/folders/external", json=data)
  79. assert response.status_code == 400
  80. assert "not a directory" in response.json()["detail"].lower()
  81. @pytest.mark.asyncio
  82. @pytest.mark.integration
  83. async def test_create_external_folder_duplicate_path(self, async_client: AsyncClient, db_session, external_dir):
  84. """Verify 409 when same path already linked."""
  85. data = {
  86. "name": "First",
  87. "external_path": str(external_dir),
  88. }
  89. response = await async_client.post("/api/v1/library/folders/external", json=data)
  90. assert response.status_code == 200
  91. data["name"] = "Duplicate"
  92. response = await async_client.post("/api/v1/library/folders/external", json=data)
  93. assert response.status_code == 409
  94. assert "already exists" in response.json()["detail"]
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_external_folder_appears_in_tree(self, async_client: AsyncClient, db_session, external_dir):
  98. """Verify external folder shows up in folder tree with external fields."""
  99. data = {
  100. "name": "My NAS",
  101. "external_path": str(external_dir),
  102. "readonly": True,
  103. }
  104. await async_client.post("/api/v1/library/folders/external", json=data)
  105. response = await async_client.get("/api/v1/library/folders")
  106. assert response.status_code == 200
  107. folders = response.json()
  108. ext_folder = next((f for f in folders if f["name"] == "My NAS"), None)
  109. assert ext_folder is not None
  110. assert ext_folder["is_external"] is True
  111. assert ext_folder["external_readonly"] is True
  112. class TestExternalFolderScan:
  113. """Tests for POST /library/folders/{id}/scan."""
  114. @pytest.fixture
  115. def external_dir(self, tmp_path):
  116. """Create a temporary directory with test files."""
  117. ext_dir = tmp_path / "prints"
  118. ext_dir.mkdir()
  119. (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
  120. (ext_dir / "bracket.stl").write_bytes(b"fakestl")
  121. (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
  122. (ext_dir / "readme.txt").write_text("not a print file")
  123. (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
  124. sub = ext_dir / "subfolder"
  125. sub.mkdir()
  126. (sub / "nested.stl").write_bytes(b"nested")
  127. return ext_dir
  128. @pytest.fixture
  129. async def external_folder(self, async_client, db_session, external_dir):
  130. """Create an external folder via API."""
  131. data = {
  132. "name": "Scan Test",
  133. "external_path": str(external_dir),
  134. "readonly": True,
  135. "show_hidden": False,
  136. }
  137. response = await async_client.post("/api/v1/library/folders/external", json=data)
  138. return response.json()
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
  142. """Verify scan discovers supported files."""
  143. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  144. assert response.status_code == 200
  145. result = response.json()
  146. # Should find: benchy.3mf, bracket.stl, print.gcode, subfolder/nested.stl
  147. # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
  148. assert result["added"] == 4
  149. assert result["removed"] == 0
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
  153. """Verify hidden files are skipped by default."""
  154. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  155. # List files in folder
  156. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  157. assert response.status_code == 200
  158. files = response.json()
  159. filenames = [f["filename"] for f in files]
  160. assert ".hidden.3mf" not in filenames
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
  164. """Verify hidden files found when show_hidden=True."""
  165. data = {
  166. "name": "Show Hidden Test",
  167. "external_path": str(external_dir),
  168. "show_hidden": True,
  169. }
  170. response = await async_client.post("/api/v1/library/folders/external", json=data)
  171. folder = response.json()
  172. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  173. result = response.json()
  174. # Now should also find .hidden.3mf → 5 total
  175. assert result["added"] == 5
  176. @pytest.mark.asyncio
  177. @pytest.mark.integration
  178. async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
  179. """Verify scanning twice doesn't duplicate files."""
  180. response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  181. assert response1.json()["added"] == 4
  182. response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  183. assert response2.json()["added"] == 0
  184. assert response2.json()["removed"] == 0
  185. @pytest.mark.asyncio
  186. @pytest.mark.integration
  187. async def test_scan_removes_deleted_files(
  188. self, async_client: AsyncClient, db_session, external_folder, external_dir
  189. ):
  190. """Verify scan removes entries for files no longer on disk."""
  191. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  192. # Delete a file from disk
  193. (external_dir / "bracket.stl").unlink()
  194. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  195. result = response.json()
  196. assert result["removed"] == 1
  197. assert result["added"] == 0
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
  201. """Verify scan fails on regular (non-external) folder."""
  202. # Create a regular folder
  203. data = {"name": "Regular Folder"}
  204. response = await async_client.post("/api/v1/library/folders", json=data)
  205. folder = response.json()
  206. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  207. assert response.status_code == 400
  208. assert "not an external" in response.json()["detail"].lower()
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
  212. """Verify scanned files have is_external=True."""
  213. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  214. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  215. files = response.json()
  216. assert len(files) > 0
  217. for f in files:
  218. assert f["is_external"] is True
  219. class TestExternalFolderProtections:
  220. """Tests for read-only protections on external folders."""
  221. @pytest.fixture
  222. def external_dir(self, tmp_path):
  223. ext_dir = tmp_path / "readonly_share"
  224. ext_dir.mkdir()
  225. (ext_dir / "test.stl").write_bytes(b"fakestl")
  226. return ext_dir
  227. @pytest.fixture
  228. async def readonly_folder(self, async_client, db_session, external_dir):
  229. """Create a read-only external folder with files scanned."""
  230. data = {
  231. "name": "Read Only",
  232. "external_path": str(external_dir),
  233. "readonly": True,
  234. }
  235. response = await async_client.post("/api/v1/library/folders/external", json=data)
  236. folder = response.json()
  237. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  238. return folder
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  242. """Verify uploads to read-only external folders are blocked."""
  243. import io
  244. file_content = io.BytesIO(b"test content")
  245. response = await async_client.post(
  246. f"/api/v1/library/files?folder_id={readonly_folder['id']}",
  247. files={"file": ("test.gcode", file_content, "application/octet-stream")},
  248. )
  249. assert response.status_code == 403
  250. assert "read-only" in response.json()["detail"].lower()
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  254. """Verify moving files to read-only external folder is blocked."""
  255. from backend.app.models.library import LibraryFile
  256. # Create a regular file
  257. lib_file = LibraryFile(
  258. filename="regular.3mf",
  259. file_path="/test/regular.3mf",
  260. file_size=1024,
  261. file_type="3mf",
  262. )
  263. db_session.add(lib_file)
  264. await db_session.commit()
  265. await db_session.refresh(lib_file)
  266. data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
  267. response = await async_client.post("/api/v1/library/files/move", json=data)
  268. assert response.status_code == 403
  269. assert "read-only" in response.json()["detail"].lower()
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
  273. """Verify external files can't be moved to other folders."""
  274. # Get the external file ID
  275. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  276. files = response.json()
  277. assert len(files) > 0
  278. ext_file_id = files[0]["id"]
  279. # Try to move to root
  280. data = {"file_ids": [ext_file_id], "folder_id": None}
  281. response = await async_client.post("/api/v1/library/files/move", json=data)
  282. assert response.status_code == 200
  283. # File should be skipped, not moved
  284. result = response.json()
  285. assert result["moved"] == 0
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_delete_external_file_removes_db_only(
  289. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  290. ):
  291. """Verify deleting an external file only removes DB entry, not the file on disk."""
  292. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  293. files = response.json()
  294. ext_file_id = files[0]["id"]
  295. ext_filename = files[0]["filename"]
  296. # Delete via API
  297. response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
  298. assert response.status_code == 200
  299. # File should still exist on disk
  300. assert (external_dir / ext_filename).exists()
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_delete_external_folder_preserves_files(
  304. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  305. ):
  306. """Verify deleting an external folder doesn't delete files from disk."""
  307. response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
  308. assert response.status_code == 200
  309. # Files should still exist on disk
  310. assert (external_dir / "test.stl").exists()
  311. @pytest.mark.asyncio
  312. @pytest.mark.integration
  313. async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  314. """Verify ZIP extraction to read-only external folder is blocked."""
  315. import io
  316. import zipfile
  317. # Create a minimal zip
  318. buf = io.BytesIO()
  319. with zipfile.ZipFile(buf, "w") as zf:
  320. zf.writestr("test.stl", b"fakestl")
  321. buf.seek(0)
  322. response = await async_client.post(
  323. f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
  324. files={"file": ("test.zip", buf, "application/zip")},
  325. )
  326. assert response.status_code == 403
  327. assert "read-only" in response.json()["detail"].lower()