test_external_folders_api.py 41 KB

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