test_external_folders_api.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968
  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. def find_folder_in_tree(folders: list, name: str) -> dict | None:
  113. """Recursively search a folder tree for a folder by name."""
  114. for f in folders:
  115. if f["name"] == name:
  116. return f
  117. result = find_folder_in_tree(f.get("children", []), name)
  118. if result:
  119. return result
  120. return None
  121. def collect_folder_names(folders: list) -> list[str]:
  122. """Recursively collect all folder names from a tree."""
  123. names = []
  124. for f in folders:
  125. names.append(f["name"])
  126. names.extend(collect_folder_names(f.get("children", [])))
  127. return names
  128. class TestExternalFolderScan:
  129. """Tests for POST /library/folders/{id}/scan."""
  130. @pytest.fixture
  131. def external_dir(self, tmp_path):
  132. """Create a temporary directory with test files."""
  133. ext_dir = tmp_path / "prints"
  134. ext_dir.mkdir()
  135. (ext_dir / "benchy.3mf").write_bytes(b"fake3mf")
  136. (ext_dir / "bracket.stl").write_bytes(b"fakestl")
  137. (ext_dir / "print.gcode").write_text("G28\nG1 X10 Y10")
  138. (ext_dir / "readme.txt").write_text("not a print file")
  139. (ext_dir / ".hidden.3mf").write_bytes(b"hidden")
  140. sub = ext_dir / "subfolder"
  141. sub.mkdir()
  142. (sub / "nested.stl").write_bytes(b"nested")
  143. return ext_dir
  144. @pytest.fixture
  145. async def external_folder(self, async_client, db_session, external_dir):
  146. """Create an external folder via API."""
  147. data = {
  148. "name": "Scan Test",
  149. "external_path": str(external_dir),
  150. "readonly": True,
  151. "show_hidden": False,
  152. }
  153. response = await async_client.post("/api/v1/library/folders/external", json=data)
  154. return response.json()
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_scan_discovers_files(self, async_client: AsyncClient, db_session, external_folder):
  158. """Verify scan discovers supported files and creates subfolders."""
  159. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  160. assert response.status_code == 200
  161. result = response.json()
  162. # Should find: benchy.3mf, bracket.stl, print.gcode (root) + subfolder/nested.stl
  163. # Should skip: readme.txt (unsupported), .hidden.3mf (hidden)
  164. assert result["added"] == 4
  165. assert result["removed"] == 0
  166. # Root folder should have 3 files (nested.stl is in subfolder)
  167. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  168. root_files = response.json()
  169. assert len(root_files) == 3
  170. root_filenames = {f["filename"] for f in root_files}
  171. assert root_filenames == {"benchy.3mf", "bracket.stl", "print.gcode"}
  172. # Subfolder should exist in the tree and contain nested.stl
  173. response = await async_client.get("/api/v1/library/folders")
  174. folders = response.json()
  175. subfolder = find_folder_in_tree(folders, "subfolder")
  176. assert subfolder is not None
  177. assert subfolder["is_external"] is True
  178. assert subfolder["parent_id"] == external_folder["id"]
  179. response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
  180. sub_files = response.json()
  181. assert len(sub_files) == 1
  182. assert sub_files[0]["filename"] == "nested.stl"
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_scan_skips_hidden_files(self, async_client: AsyncClient, db_session, external_folder):
  186. """Verify hidden files are skipped by default."""
  187. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  188. # List files in root folder
  189. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  190. assert response.status_code == 200
  191. files = response.json()
  192. filenames = [f["filename"] for f in files]
  193. assert ".hidden.3mf" not in filenames
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_scan_shows_hidden_when_enabled(self, async_client: AsyncClient, db_session, external_dir):
  197. """Verify hidden files found when show_hidden=True."""
  198. data = {
  199. "name": "Show Hidden Test",
  200. "external_path": str(external_dir),
  201. "show_hidden": True,
  202. }
  203. response = await async_client.post("/api/v1/library/folders/external", json=data)
  204. folder = response.json()
  205. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  206. result = response.json()
  207. # Now should also find .hidden.3mf → 5 total
  208. assert result["added"] == 5
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_scan_idempotent(self, async_client: AsyncClient, db_session, external_folder):
  212. """Verify scanning twice doesn't duplicate files."""
  213. response1 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  214. assert response1.json()["added"] == 4
  215. response2 = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  216. assert response2.json()["added"] == 0
  217. assert response2.json()["removed"] == 0
  218. @pytest.mark.asyncio
  219. @pytest.mark.integration
  220. async def test_scan_removes_deleted_files(
  221. self, async_client: AsyncClient, db_session, external_folder, external_dir
  222. ):
  223. """Verify scan removes entries for files no longer on disk."""
  224. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  225. # Delete a file from disk
  226. (external_dir / "bracket.stl").unlink()
  227. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  228. result = response.json()
  229. assert result["removed"] == 1
  230. assert result["added"] == 0
  231. @pytest.mark.asyncio
  232. @pytest.mark.integration
  233. async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
  234. """Verify scan fails on regular (non-external) folder."""
  235. # Create a regular folder
  236. data = {"name": "Regular Folder"}
  237. response = await async_client.post("/api/v1/library/folders", json=data)
  238. folder = response.json()
  239. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  240. assert response.status_code == 400
  241. assert "not an external" in response.json()["detail"].lower()
  242. @pytest.mark.asyncio
  243. @pytest.mark.integration
  244. async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
  245. """Verify scanned files have is_external=True in root and subfolders."""
  246. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  247. # Check root folder files
  248. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  249. files = response.json()
  250. assert len(files) > 0
  251. for f in files:
  252. assert f["is_external"] is True
  253. # Check subfolder files
  254. response = await async_client.get("/api/v1/library/folders")
  255. folders = response.json()
  256. subfolder = find_folder_in_tree(folders, "subfolder")
  257. assert subfolder is not None
  258. response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
  259. sub_files = response.json()
  260. for f in sub_files:
  261. assert f["is_external"] is True
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_scan_creates_nested_subfolders(self, async_client: AsyncClient, db_session, external_dir):
  265. """Verify deeply nested directories create correct folder hierarchy."""
  266. # Create nested structure: deep/nested/dir/model.stl
  267. deep = external_dir / "deep" / "nested" / "dir"
  268. deep.mkdir(parents=True)
  269. (deep / "model.stl").write_bytes(b"deepstl")
  270. data = {
  271. "name": "Nested Test",
  272. "external_path": str(external_dir),
  273. "readonly": True,
  274. "show_hidden": False,
  275. }
  276. response = await async_client.post("/api/v1/library/folders/external", json=data)
  277. root = response.json()
  278. response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
  279. assert response.status_code == 200
  280. # Verify folder chain: root -> deep -> nested -> dir
  281. response = await async_client.get("/api/v1/library/folders")
  282. all_folders = response.json()
  283. deep = find_folder_in_tree(all_folders, "deep")
  284. assert deep is not None
  285. assert deep["parent_id"] == root["id"]
  286. assert deep["is_external"] is True
  287. nested = find_folder_in_tree(all_folders, "nested")
  288. assert nested is not None
  289. assert nested["parent_id"] == deep["id"]
  290. dir_folder = find_folder_in_tree(all_folders, "dir")
  291. assert dir_folder is not None
  292. assert dir_folder["parent_id"] == nested["id"]
  293. # model.stl should be in the "dir" folder
  294. response = await async_client.get(f"/api/v1/library/files?folder_id={dir_folder['id']}")
  295. files = response.json()
  296. assert len(files) == 1
  297. assert files[0]["filename"] == "model.stl"
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_scan_skips_hidden_directories(self, async_client: AsyncClient, db_session, external_dir):
  301. """Verify hidden directories are skipped when show_hidden=False."""
  302. hidden_dir = external_dir / ".hidden_dir"
  303. hidden_dir.mkdir()
  304. (hidden_dir / "secret.stl").write_bytes(b"secret")
  305. data = {
  306. "name": "Hidden Dir Test",
  307. "external_path": str(external_dir),
  308. "readonly": True,
  309. "show_hidden": False,
  310. }
  311. response = await async_client.post("/api/v1/library/folders/external", json=data)
  312. root = response.json()
  313. response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
  314. result = response.json()
  315. # Should find 4 files (root 3 + subfolder/nested.stl) but NOT .hidden_dir/secret.stl
  316. assert result["added"] == 4
  317. # No ".hidden_dir" folder should be created
  318. response = await async_client.get("/api/v1/library/folders")
  319. folder_names = collect_folder_names(response.json())
  320. assert ".hidden_dir" not in folder_names
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_scan_removes_deleted_subfolder(
  324. self, async_client: AsyncClient, db_session, external_folder, external_dir
  325. ):
  326. """Verify scan removes empty subfolder entries when directory deleted from disk."""
  327. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  328. # Verify subfolder exists
  329. response = await async_client.get("/api/v1/library/folders")
  330. subfolder = find_folder_in_tree(response.json(), "subfolder")
  331. assert subfolder is not None
  332. # Delete the subfolder from disk
  333. import shutil
  334. shutil.rmtree(external_dir / "subfolder")
  335. # Re-scan
  336. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  337. result = response.json()
  338. assert result["removed"] == 1 # nested.stl removed
  339. # Subfolder should be cleaned up (empty + directory gone)
  340. response = await async_client.get("/api/v1/library/folders")
  341. subfolder = find_folder_in_tree(response.json(), "subfolder")
  342. assert subfolder is None
  343. @pytest.mark.asyncio
  344. @pytest.mark.integration
  345. async def test_scan_subfolder_inherits_readonly(
  346. self, async_client: AsyncClient, db_session, external_folder, external_dir
  347. ):
  348. """Verify created subfolders inherit external_readonly from parent."""
  349. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  350. response = await async_client.get("/api/v1/library/folders")
  351. subfolder = find_folder_in_tree(response.json(), "subfolder")
  352. assert subfolder is not None
  353. assert subfolder["external_readonly"] is True
  354. class TestExternalFolderProtections:
  355. """Tests for read-only protections on external folders."""
  356. @pytest.fixture
  357. def external_dir(self, tmp_path):
  358. ext_dir = tmp_path / "readonly_share"
  359. ext_dir.mkdir()
  360. (ext_dir / "test.stl").write_bytes(b"fakestl")
  361. return ext_dir
  362. @pytest.fixture
  363. async def readonly_folder(self, async_client, db_session, external_dir):
  364. """Create a read-only external folder with files scanned."""
  365. data = {
  366. "name": "Read Only",
  367. "external_path": str(external_dir),
  368. "readonly": True,
  369. }
  370. response = await async_client.post("/api/v1/library/folders/external", json=data)
  371. folder = response.json()
  372. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  373. return folder
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  377. """Verify uploads to read-only external folders are blocked."""
  378. import io
  379. file_content = io.BytesIO(b"test content")
  380. response = await async_client.post(
  381. f"/api/v1/library/files?folder_id={readonly_folder['id']}",
  382. files={"file": ("test.gcode", file_content, "application/octet-stream")},
  383. )
  384. assert response.status_code == 403
  385. assert "read-only" in response.json()["detail"].lower()
  386. @pytest.mark.asyncio
  387. @pytest.mark.integration
  388. async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  389. """Verify moving files to read-only external folder is blocked."""
  390. from backend.app.models.library import LibraryFile
  391. # Create a regular file
  392. lib_file = LibraryFile(
  393. filename="regular.3mf",
  394. file_path="/test/regular.3mf",
  395. file_size=1024,
  396. file_type="3mf",
  397. )
  398. db_session.add(lib_file)
  399. await db_session.commit()
  400. await db_session.refresh(lib_file)
  401. data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
  402. response = await async_client.post("/api/v1/library/files/move", json=data)
  403. assert response.status_code == 403
  404. assert "read-only" in response.json()["detail"].lower()
  405. @pytest.mark.asyncio
  406. @pytest.mark.integration
  407. async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
  408. """Verify external files can't be moved to other folders."""
  409. # Get the external file ID
  410. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  411. files = response.json()
  412. assert len(files) > 0
  413. ext_file_id = files[0]["id"]
  414. # Try to move to root
  415. data = {"file_ids": [ext_file_id], "folder_id": None}
  416. response = await async_client.post("/api/v1/library/files/move", json=data)
  417. assert response.status_code == 200
  418. # File should be skipped, not moved
  419. result = response.json()
  420. assert result["moved"] == 0
  421. @pytest.mark.asyncio
  422. @pytest.mark.integration
  423. async def test_delete_external_file_removes_db_only(
  424. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  425. ):
  426. """Verify deleting an external file only removes DB entry, not the file on disk."""
  427. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  428. files = response.json()
  429. ext_file_id = files[0]["id"]
  430. ext_filename = files[0]["filename"]
  431. # Delete via API
  432. response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
  433. assert response.status_code == 200
  434. # File should still exist on disk
  435. assert (external_dir / ext_filename).exists()
  436. @pytest.mark.asyncio
  437. @pytest.mark.integration
  438. async def test_delete_external_folder_preserves_files(
  439. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  440. ):
  441. """Verify deleting an external folder doesn't delete files from disk."""
  442. response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
  443. assert response.status_code == 200
  444. # Files should still exist on disk
  445. assert (external_dir / "test.stl").exists()
  446. @pytest.mark.asyncio
  447. @pytest.mark.integration
  448. async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  449. """Verify ZIP extraction to read-only external folder is blocked."""
  450. import io
  451. import zipfile
  452. # Create a minimal zip
  453. buf = io.BytesIO()
  454. with zipfile.ZipFile(buf, "w") as zf:
  455. zf.writestr("test.stl", b"fakestl")
  456. buf.seek(0)
  457. response = await async_client.post(
  458. f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
  459. files={"file": ("test.zip", buf, "application/zip")},
  460. )
  461. assert response.status_code == 403
  462. assert "read-only" in response.json()["detail"].lower()
  463. class TestExternalFolderWritableUpload:
  464. """Tests for upload write-through to writable external folders (#1112).
  465. Before the fix, uploads to writable external folders silently landed in the
  466. internal library dir while the DB row pointed at the external folder —
  467. files were invisible when the mount was viewed from another machine.
  468. """
  469. @pytest.fixture
  470. def external_dir(self, tmp_path):
  471. ext_dir = tmp_path / "writable_share"
  472. ext_dir.mkdir()
  473. return ext_dir
  474. @pytest.fixture
  475. async def writable_folder(self, async_client, db_session, external_dir):
  476. data = {
  477. "name": "Writable NAS",
  478. "external_path": str(external_dir),
  479. "readonly": False,
  480. }
  481. response = await async_client.post("/api/v1/library/folders/external", json=data)
  482. assert response.status_code == 200
  483. return response.json()
  484. @pytest.mark.asyncio
  485. @pytest.mark.integration
  486. async def test_upload_lands_on_external_mount(
  487. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  488. ):
  489. """Bytes are written to ``<external_path>/<filename>``, not the internal library dir."""
  490. import io
  491. content = b"hello-external-world"
  492. response = await async_client.post(
  493. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  494. files={"file": ("upload.stl", io.BytesIO(content), "application/octet-stream")},
  495. )
  496. assert response.status_code == 200, response.text
  497. on_disk = external_dir / "upload.stl"
  498. assert on_disk.exists(), "file must be written to the external mount"
  499. assert on_disk.read_bytes() == content
  500. @pytest.mark.asyncio
  501. @pytest.mark.integration
  502. async def test_upload_persists_correct_db_shape(
  503. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  504. ):
  505. """DB row must have ``is_external=True`` and ``file_path`` = absolute external path,
  506. so scan-dedupe and deletion behaviour match scanned files."""
  507. import io
  508. import zipfile
  509. from backend.app.models.library import LibraryFile
  510. # #1401 hardened the library upload route to reject .3mf files that
  511. # aren't valid ZIP containers. This test asserts external-folder
  512. # DB shape, not the upload validator, so feed it a minimal real zip
  513. # rather than placeholder bytes.
  514. zip_buf = io.BytesIO()
  515. with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
  516. zf.writestr("placeholder.txt", "")
  517. zip_buf.seek(0)
  518. response = await async_client.post(
  519. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  520. files={"file": ("model.3mf", zip_buf, "application/octet-stream")},
  521. )
  522. assert response.status_code == 200
  523. file_id = response.json()["id"]
  524. row = await db_session.get(LibraryFile, file_id)
  525. await db_session.refresh(row)
  526. assert row.is_external is True
  527. assert row.file_path == str((external_dir / "model.3mf").resolve())
  528. @pytest.mark.asyncio
  529. @pytest.mark.integration
  530. async def test_upload_filename_collision_returns_409(
  531. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  532. ):
  533. """Re-uploading a filename that already exists on the mount must 409,
  534. not silently overwrite — matches scan's treatment of external files as
  535. externally-owned bytes."""
  536. import io
  537. (external_dir / "already.stl").write_bytes(b"prior")
  538. response = await async_client.post(
  539. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  540. files={"file": ("already.stl", io.BytesIO(b"new"), "application/octet-stream")},
  541. )
  542. assert response.status_code == 409
  543. assert (external_dir / "already.stl").read_bytes() == b"prior"
  544. @pytest.mark.asyncio
  545. @pytest.mark.integration
  546. async def test_upload_to_missing_external_path_returns_400(
  547. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  548. ):
  549. """If the external mount has gone away between folder-create and
  550. upload, fail loud rather than silently misroute to internal storage."""
  551. import io
  552. import shutil
  553. shutil.rmtree(external_dir)
  554. response = await async_client.post(
  555. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  556. files={"file": ("x.stl", io.BytesIO(b"x"), "application/octet-stream")},
  557. )
  558. assert response.status_code == 400
  559. assert "not accessible" in response.json()["detail"].lower()
  560. @pytest.mark.asyncio
  561. @pytest.mark.integration
  562. async def test_upload_rejects_path_traversal_filename(
  563. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  564. ):
  565. """A malicious filename like ``../escape.stl`` must not write outside
  566. the external folder. Defence-in-depth — FastAPI already strips these
  567. on parse, but the resolve-and-relative_to guard is the final gate."""
  568. import io
  569. response = await async_client.post(
  570. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  571. files={"file": ("../escape.stl", io.BytesIO(b"x"), "application/octet-stream")},
  572. )
  573. # Either a 400 from our traversal guard or a 200 with basename-stripped
  574. # filename inside the external dir — both prove nothing escaped.
  575. if response.status_code == 200:
  576. assert not (external_dir.parent / "escape.stl").exists()
  577. assert (external_dir / "escape.stl").exists() or (external_dir / "..escape.stl").exists()
  578. else:
  579. assert response.status_code in (400, 422)
  580. assert not (external_dir.parent / "escape.stl").exists()
  581. @pytest.mark.asyncio
  582. @pytest.mark.integration
  583. async def test_zip_to_writable_external_folder_rejected(
  584. self, async_client: AsyncClient, db_session, writable_folder
  585. ):
  586. """Extract-zip into writable external folders isn't supported (nested
  587. subfolder creation on the mount is a separate design). Users are
  588. pointed at the Scan flow instead."""
  589. import io
  590. import zipfile
  591. buf = io.BytesIO()
  592. with zipfile.ZipFile(buf, "w") as zf:
  593. zf.writestr("a/b/c.stl", b"x")
  594. buf.seek(0)
  595. response = await async_client.post(
  596. f"/api/v1/library/files/extract-zip?folder_id={writable_folder['id']}",
  597. files={"file": ("test.zip", buf, "application/zip")},
  598. )
  599. assert response.status_code == 400
  600. assert "scan" in response.json()["detail"].lower()
  601. @pytest.mark.asyncio
  602. @pytest.mark.integration
  603. async def test_non_external_upload_unchanged(self, async_client: AsyncClient, db_session):
  604. """Uploads with no folder_id (root) keep the existing internal-storage behaviour."""
  605. import io
  606. from backend.app.models.library import LibraryFile
  607. response = await async_client.post(
  608. "/api/v1/library/files",
  609. files={"file": ("root.stl", io.BytesIO(b"x"), "application/octet-stream")},
  610. )
  611. assert response.status_code == 200
  612. file_id = response.json()["id"]
  613. row = await db_session.get(LibraryFile, file_id)
  614. await db_session.refresh(row)
  615. assert row.is_external is False
  616. # Internal storage: file_path is UUID-scoped, stored as a relative path.
  617. assert not row.file_path.startswith("/")
  618. class TestCrossBoundaryMove:
  619. """#1112 follow-up: moving files between managed and external folders
  620. must physically relocate the bytes, not just shuffle the DB ``folder_id``.
  621. Pre-fix symptom (reported by @Carter3DP after testing 0.2.4b1): a file
  622. moved from a managed folder to a NAS-backed external folder showed up
  623. in Bambuddy's UI under the external folder but was never written to
  624. the NAS — so the SMB mount and Bambuddy disagreed about what was
  625. actually there.
  626. """
  627. @pytest.fixture
  628. def external_dir(self, tmp_path):
  629. ext_dir = tmp_path / "writable_share"
  630. ext_dir.mkdir()
  631. return ext_dir
  632. @pytest.fixture
  633. async def writable_folder(self, async_client, db_session, external_dir):
  634. data = {"name": "Writable NAS", "external_path": str(external_dir), "readonly": False}
  635. response = await async_client.post("/api/v1/library/folders/external", json=data)
  636. assert response.status_code == 200
  637. return response.json()
  638. @pytest.fixture
  639. async def readonly_folder(self, async_client, db_session, tmp_path):
  640. ro_dir = tmp_path / "ro_share"
  641. ro_dir.mkdir()
  642. (ro_dir / "stranded.gcode").write_text("G28")
  643. data = {"name": "Read-only NAS", "external_path": str(ro_dir), "readonly": True}
  644. response = await async_client.post("/api/v1/library/folders/external", json=data)
  645. assert response.status_code == 200
  646. # Populate via scan so the file gets a DB row with is_external=True.
  647. scan = await async_client.post(f"/api/v1/library/folders/{response.json()['id']}/scan")
  648. assert scan.status_code == 200
  649. return response.json()
  650. @pytest.mark.asyncio
  651. @pytest.mark.integration
  652. async def test_managed_to_external_relocates_bytes(
  653. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  654. ):
  655. """The actual #1112 fix: managed → external must write the bytes
  656. to the NAS mount AND drop them from internal storage. Pre-fix the
  657. DB row flipped to the new folder but the bytes stayed put."""
  658. import io
  659. from backend.app.api.routes.library import to_absolute_path
  660. from backend.app.models.library import LibraryFile
  661. upload = await async_client.post(
  662. "/api/v1/library/files",
  663. files={"file": ("ship_me.stl", io.BytesIO(b"original-bytes"), "application/octet-stream")},
  664. )
  665. assert upload.status_code == 200
  666. file_id = upload.json()["id"]
  667. # Snapshot the pre-move on-disk path so we can verify it's gone after.
  668. pre = await db_session.get(LibraryFile, file_id)
  669. await db_session.refresh(pre)
  670. managed_disk_path = to_absolute_path(pre.file_path)
  671. assert managed_disk_path is not None and managed_disk_path.exists()
  672. response = await async_client.post(
  673. "/api/v1/library/files/move",
  674. json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
  675. )
  676. assert response.status_code == 200, response.text
  677. body = response.json()
  678. assert body["moved"] == 1
  679. assert body["skipped"] == 0
  680. # Bytes are on the NAS mount.
  681. on_nas = external_dir / "ship_me.stl"
  682. assert on_nas.exists()
  683. assert on_nas.read_bytes() == b"original-bytes"
  684. # Internal copy is gone.
  685. assert not managed_disk_path.exists(), "managed source must be removed after the move"
  686. # DB row matches reality.
  687. await db_session.refresh(pre)
  688. assert pre.is_external is True
  689. assert pre.folder_id == writable_folder["id"]
  690. assert pre.file_path == str(on_nas.resolve())
  691. @pytest.mark.asyncio
  692. @pytest.mark.integration
  693. async def test_external_to_managed_relocates_bytes(
  694. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  695. ):
  696. """Symmetric direction: external → managed copies the bytes into
  697. internal storage with a UUID name, deletes the source on the
  698. mount, and recomputes the file hash (since scan stores
  699. ``file_hash=None`` for external rows)."""
  700. import io
  701. from backend.app.models.library import LibraryFile
  702. # Plant a file on the writable mount and let upload give it a row.
  703. upload = await async_client.post(
  704. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  705. files={"file": ("relocate_me.stl", io.BytesIO(b"nas-bytes"), "application/octet-stream")},
  706. )
  707. assert upload.status_code == 200
  708. file_id = upload.json()["id"]
  709. ext_disk = external_dir / "relocate_me.stl"
  710. assert ext_disk.exists()
  711. response = await async_client.post(
  712. "/api/v1/library/files/move",
  713. json={"file_ids": [file_id], "folder_id": None},
  714. )
  715. assert response.status_code == 200
  716. assert response.json()["moved"] == 1
  717. db_session.expire_all()
  718. row = await db_session.get(LibraryFile, file_id)
  719. assert row.is_external is False
  720. assert row.folder_id is None
  721. assert not row.file_path.startswith("/"), "managed file_path must be relative"
  722. assert not ext_disk.exists(), "external source must be removed after the move"
  723. # Hash filled in for the now-managed row so future dedup works.
  724. assert row.file_hash is not None and len(row.file_hash) == 64
  725. @pytest.mark.asyncio
  726. @pytest.mark.integration
  727. async def test_managed_to_external_collision_skips_with_reason(
  728. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  729. ):
  730. """A name collision on the target external mount must skip the
  731. move with a structured reason — not silently overwrite a file
  732. that's already on the NAS."""
  733. import io
  734. # Pre-existing file on the mount with the same name as the upload.
  735. (external_dir / "duplicate.stl").write_bytes(b"pre-existing")
  736. upload = await async_client.post(
  737. "/api/v1/library/files",
  738. files={"file": ("duplicate.stl", io.BytesIO(b"new-bytes"), "application/octet-stream")},
  739. )
  740. assert upload.status_code == 200
  741. file_id = upload.json()["id"]
  742. response = await async_client.post(
  743. "/api/v1/library/files/move",
  744. json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
  745. )
  746. assert response.status_code == 200
  747. body = response.json()
  748. assert body["moved"] == 0
  749. assert body["skipped"] == 1
  750. reasons = body["skipped_reasons"]
  751. assert len(reasons) == 1
  752. assert reasons[0]["file_id"] == file_id
  753. assert reasons[0]["code"] == "name_collision"
  754. # Pre-existing target file is intact.
  755. assert (external_dir / "duplicate.stl").read_bytes() == b"pre-existing"
  756. @pytest.mark.asyncio
  757. @pytest.mark.integration
  758. async def test_external_readonly_source_skips(self, async_client: AsyncClient, db_session, readonly_folder):
  759. """A read-only mount allows reading but not deletes, and a move
  760. is semantically a delete on the source. Skip with
  761. ``source_readonly`` so the file isn't duplicated by half-moving."""
  762. listing = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  763. assert listing.status_code == 200
  764. ext_file_id = listing.json()[0]["id"]
  765. response = await async_client.post(
  766. "/api/v1/library/files/move",
  767. json={"file_ids": [ext_file_id], "folder_id": None},
  768. )
  769. assert response.status_code == 200
  770. body = response.json()
  771. assert body["moved"] == 0
  772. assert body["skipped"] == 1
  773. assert body["skipped_reasons"][0]["code"] == "source_readonly"
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_managed_to_managed_remains_db_only(self, async_client: AsyncClient, db_session):
  777. """Same-boundary moves (managed → managed) keep the existing
  778. DB-only fast path — no shutil.copy, no UUID rename. The original
  779. file_path stays the same, only ``folder_id`` changes."""
  780. import io
  781. from backend.app.models.library import LibraryFile
  782. sub = await async_client.post(
  783. "/api/v1/library/folders",
  784. json={"name": "subfolder", "parent_id": None},
  785. )
  786. assert sub.status_code == 200
  787. target_id = sub.json()["id"]
  788. upload = await async_client.post(
  789. "/api/v1/library/files",
  790. files={"file": ("part.stl", io.BytesIO(b"x"), "application/octet-stream")},
  791. )
  792. assert upload.status_code == 200
  793. file_id = upload.json()["id"]
  794. pre = await db_session.get(LibraryFile, file_id)
  795. await db_session.refresh(pre)
  796. original_path = pre.file_path
  797. response = await async_client.post(
  798. "/api/v1/library/files/move",
  799. json={"file_ids": [file_id], "folder_id": target_id},
  800. )
  801. assert response.status_code == 200
  802. assert response.json()["moved"] == 1
  803. db_session.expire_all()
  804. post = await db_session.get(LibraryFile, file_id)
  805. assert post.folder_id == target_id
  806. assert post.is_external is False
  807. assert post.file_path == original_path # bytes never moved
  808. @pytest.mark.asyncio
  809. @pytest.mark.integration
  810. async def test_skipped_reasons_field_present_even_when_empty(self, async_client: AsyncClient, db_session):
  811. """Backwards-compatible response shape: ``skipped_reasons`` is
  812. always present (empty list when nothing skipped) so frontend
  813. code can treat it as the source of truth without optional-chain
  814. gymnastics."""
  815. import io
  816. upload = await async_client.post(
  817. "/api/v1/library/files",
  818. files={"file": ("trivial.stl", io.BytesIO(b"x"), "application/octet-stream")},
  819. )
  820. assert upload.status_code == 200
  821. file_id = upload.json()["id"]
  822. response = await async_client.post(
  823. "/api/v1/library/files/move",
  824. json={"file_ids": [file_id], "folder_id": None},
  825. )
  826. assert response.status_code == 200
  827. body = response.json()
  828. assert "skipped_reasons" in body
  829. assert body["skipped_reasons"] == []