test_external_folders_api.py 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235
  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_indexes_pre_existing_markdown(
  260. self, async_client: AsyncClient, db_session, external_folder, external_dir
  261. ):
  262. """Scan should index a README.md already on disk (#2520 item 1).
  263. Markdown dropped into the folder by external tools (not the Upload
  264. dialog) must be picked up so the Folder Readme panel can show it.
  265. """
  266. (external_dir / "README.md").write_text("# Fishing Floats\n\nDescription.")
  267. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  268. assert response.status_code == 200
  269. # 4 supported files from the fixture + the new README.md
  270. assert response.json()["added"] == 5
  271. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  272. root_filenames = {f["filename"] for f in response.json()}
  273. assert "README.md" in root_filenames
  274. # Readme panel can now resolve it.
  275. response = await async_client.get(f"/api/v1/library/folders/{external_folder['id']}/readme")
  276. assert response.status_code == 200
  277. assert response.json()["filename"] == "README.md"
  278. assert "Fishing Floats" in response.json()["content"]
  279. @pytest.mark.asyncio
  280. @pytest.mark.integration
  281. async def test_scan_preserves_uploaded_markdown(self, async_client: AsyncClient, db_session, tmp_path):
  282. """Scanning must not delete an uploaded README.md (#2520 destructive-cleanup bug).
  283. Before the fix, .md was absent from _SCANNABLE_EXTENSIONS, so an
  284. uploaded markdown record was never re-found during the walk and the
  285. cleanup pass purged it — the Readme panel then 404'd and hid.
  286. """
  287. import io
  288. writable_dir = tmp_path / "writable"
  289. writable_dir.mkdir()
  290. response = await async_client.post(
  291. "/api/v1/library/folders/external",
  292. json={"name": "Writable", "external_path": str(writable_dir), "readonly": False},
  293. )
  294. folder = response.json()
  295. upload = await async_client.post(
  296. f"/api/v1/library/files?folder_id={folder['id']}",
  297. files={"file": ("README.md", io.BytesIO(b"# Model\n\nHello"), "text/markdown")},
  298. )
  299. assert upload.status_code in (200, 201)
  300. # Panel works before the scan.
  301. readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
  302. assert readme.status_code == 200
  303. # The scan that used to nuke the record.
  304. scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  305. assert scan.status_code == 200
  306. assert scan.json()["removed"] == 0
  307. # Record and panel survive.
  308. readme = await async_client.get(f"/api/v1/library/folders/{folder['id']}/readme")
  309. assert readme.status_code == 200
  310. assert readme.json()["filename"] == "README.md"
  311. @pytest.mark.asyncio
  312. @pytest.mark.integration
  313. async def test_scan_preserves_non_scannable_file_on_disk(self, async_client: AsyncClient, db_session, tmp_path):
  314. """Cleanup must gate on disk presence, not scannable-extension membership (#2520).
  315. Any uploaded file whose extension is outside _SCANNABLE_EXTENSIONS
  316. (here a .txt) stays on disk, so its DB record must survive a scan
  317. rather than being treated as deleted.
  318. """
  319. import io
  320. writable_dir = tmp_path / "writable_txt"
  321. writable_dir.mkdir()
  322. response = await async_client.post(
  323. "/api/v1/library/folders/external",
  324. json={"name": "Writable Txt", "external_path": str(writable_dir), "readonly": False},
  325. )
  326. folder = response.json()
  327. upload = await async_client.post(
  328. f"/api/v1/library/files?folder_id={folder['id']}",
  329. files={"file": ("notes.txt", io.BytesIO(b"keep me"), "text/plain")},
  330. )
  331. assert upload.status_code in (200, 201)
  332. scan = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  333. assert scan.status_code == 200
  334. assert scan.json()["removed"] == 0
  335. files = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
  336. assert "notes.txt" in {f["filename"] for f in files.json()}
  337. @pytest.mark.asyncio
  338. @pytest.mark.integration
  339. async def test_scan_non_external_folder_fails(self, async_client: AsyncClient, db_session):
  340. """Verify scan fails on regular (non-external) folder."""
  341. # Create a regular folder
  342. data = {"name": "Regular Folder"}
  343. response = await async_client.post("/api/v1/library/folders", json=data)
  344. folder = response.json()
  345. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  346. assert response.status_code == 400
  347. assert "not an external" in response.json()["detail"].lower()
  348. @pytest.mark.asyncio
  349. @pytest.mark.integration
  350. async def test_scan_files_marked_external(self, async_client: AsyncClient, db_session, external_folder):
  351. """Verify scanned files have is_external=True in root and subfolders."""
  352. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  353. # Check root folder files
  354. response = await async_client.get(f"/api/v1/library/files?folder_id={external_folder['id']}")
  355. files = response.json()
  356. assert len(files) > 0
  357. for f in files:
  358. assert f["is_external"] is True
  359. # Check subfolder files
  360. response = await async_client.get("/api/v1/library/folders")
  361. folders = response.json()
  362. subfolder = find_folder_in_tree(folders, "subfolder")
  363. assert subfolder is not None
  364. response = await async_client.get(f"/api/v1/library/files?folder_id={subfolder['id']}")
  365. sub_files = response.json()
  366. for f in sub_files:
  367. assert f["is_external"] is True
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_scan_creates_nested_subfolders(self, async_client: AsyncClient, db_session, external_dir):
  371. """Verify deeply nested directories create correct folder hierarchy."""
  372. # Create nested structure: deep/nested/dir/model.stl
  373. deep = external_dir / "deep" / "nested" / "dir"
  374. deep.mkdir(parents=True)
  375. (deep / "model.stl").write_bytes(b"deepstl")
  376. data = {
  377. "name": "Nested Test",
  378. "external_path": str(external_dir),
  379. "readonly": True,
  380. "show_hidden": False,
  381. }
  382. response = await async_client.post("/api/v1/library/folders/external", json=data)
  383. root = response.json()
  384. response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
  385. assert response.status_code == 200
  386. # Verify folder chain: root -> deep -> nested -> dir
  387. response = await async_client.get("/api/v1/library/folders")
  388. all_folders = response.json()
  389. deep = find_folder_in_tree(all_folders, "deep")
  390. assert deep is not None
  391. assert deep["parent_id"] == root["id"]
  392. assert deep["is_external"] is True
  393. nested = find_folder_in_tree(all_folders, "nested")
  394. assert nested is not None
  395. assert nested["parent_id"] == deep["id"]
  396. dir_folder = find_folder_in_tree(all_folders, "dir")
  397. assert dir_folder is not None
  398. assert dir_folder["parent_id"] == nested["id"]
  399. # model.stl should be in the "dir" folder
  400. response = await async_client.get(f"/api/v1/library/files?folder_id={dir_folder['id']}")
  401. files = response.json()
  402. assert len(files) == 1
  403. assert files[0]["filename"] == "model.stl"
  404. @pytest.mark.asyncio
  405. @pytest.mark.integration
  406. async def test_scan_skips_hidden_directories(self, async_client: AsyncClient, db_session, external_dir):
  407. """Verify hidden directories are skipped when show_hidden=False."""
  408. hidden_dir = external_dir / ".hidden_dir"
  409. hidden_dir.mkdir()
  410. (hidden_dir / "secret.stl").write_bytes(b"secret")
  411. data = {
  412. "name": "Hidden Dir Test",
  413. "external_path": str(external_dir),
  414. "readonly": True,
  415. "show_hidden": False,
  416. }
  417. response = await async_client.post("/api/v1/library/folders/external", json=data)
  418. root = response.json()
  419. response = await async_client.post(f"/api/v1/library/folders/{root['id']}/scan")
  420. result = response.json()
  421. # Should find 4 files (root 3 + subfolder/nested.stl) but NOT .hidden_dir/secret.stl
  422. assert result["added"] == 4
  423. # No ".hidden_dir" folder should be created
  424. response = await async_client.get("/api/v1/library/folders")
  425. folder_names = collect_folder_names(response.json())
  426. assert ".hidden_dir" not in folder_names
  427. @pytest.mark.asyncio
  428. @pytest.mark.integration
  429. async def test_scan_removes_deleted_subfolder(
  430. self, async_client: AsyncClient, db_session, external_folder, external_dir
  431. ):
  432. """Verify scan removes empty subfolder entries when directory deleted from disk."""
  433. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  434. # Verify subfolder exists
  435. response = await async_client.get("/api/v1/library/folders")
  436. subfolder = find_folder_in_tree(response.json(), "subfolder")
  437. assert subfolder is not None
  438. # Delete the subfolder from disk
  439. import shutil
  440. shutil.rmtree(external_dir / "subfolder")
  441. # Re-scan
  442. response = await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  443. result = response.json()
  444. assert result["removed"] == 1 # nested.stl removed
  445. # Subfolder should be cleaned up (empty + directory gone)
  446. response = await async_client.get("/api/v1/library/folders")
  447. subfolder = find_folder_in_tree(response.json(), "subfolder")
  448. assert subfolder is None
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_scan_subfolder_inherits_readonly(
  452. self, async_client: AsyncClient, db_session, external_folder, external_dir
  453. ):
  454. """Verify created subfolders inherit external_readonly from parent."""
  455. await async_client.post(f"/api/v1/library/folders/{external_folder['id']}/scan")
  456. response = await async_client.get("/api/v1/library/folders")
  457. subfolder = find_folder_in_tree(response.json(), "subfolder")
  458. assert subfolder is not None
  459. assert subfolder["external_readonly"] is True
  460. class TestExternalFolderModifiedTime:
  461. """Filesystem mtime capture + recursive activity sort (#2680).
  462. The folder tree's "sort by recent activity" and the file pane's date sort
  463. must track the real on-disk mtime (``ls -t``), not the DB ``updated_at`` (the
  464. scan instant, identical across a bulk scan).
  465. """
  466. @staticmethod
  467. def _set_mtime(path: Path, epoch: float) -> None:
  468. os.utime(path, (epoch, epoch))
  469. @pytest.fixture
  470. async def make_folder(self, async_client, db_session):
  471. async def _make(ext_dir: Path, name: str = "MTime Test") -> dict:
  472. data = {
  473. "name": name,
  474. "external_path": str(ext_dir),
  475. "readonly": True,
  476. "show_hidden": False,
  477. }
  478. resp = await async_client.post("/api/v1/library/folders/external", json=data)
  479. assert resp.status_code == 200
  480. return resp.json()
  481. return _make
  482. @pytest.mark.asyncio
  483. @pytest.mark.integration
  484. async def test_scan_captures_file_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
  485. """Each scanned file carries its real on-disk mtime, not the scan time."""
  486. ext = tmp_path / "prints"
  487. ext.mkdir()
  488. old = ext / "old.3mf"
  489. new = ext / "new.3mf"
  490. old.write_bytes(b"a")
  491. new.write_bytes(b"b")
  492. # old.3mf modified 2021-01-01, new.3mf modified 2024-01-01.
  493. self._set_mtime(old, 1609459200.0) # 2021-01-01T00:00:00Z
  494. self._set_mtime(new, 1704067200.0) # 2024-01-01T00:00:00Z
  495. folder = await make_folder(ext)
  496. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  497. resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
  498. files = {f["filename"]: f for f in resp.json()}
  499. assert files["old.3mf"]["fs_modified_at"] is not None
  500. assert files["new.3mf"]["fs_modified_at"] is not None
  501. # The real mtime, not "now": the 2021 file must predate the 2024 file.
  502. assert files["old.3mf"]["fs_modified_at"] < files["new.3mf"]["fs_modified_at"]
  503. assert files["old.3mf"]["fs_modified_at"].startswith("2021")
  504. assert files["new.3mf"]["fs_modified_at"].startswith("2024")
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_rescan_refreshes_changed_file_mtime(self, async_client, db_session, tmp_path, make_folder):
  508. """A file edited over the mount re-sorts on the next scan (#2680)."""
  509. ext = tmp_path / "prints"
  510. ext.mkdir()
  511. f = ext / "part.3mf"
  512. f.write_bytes(b"a")
  513. self._set_mtime(f, 1609459200.0) # 2021
  514. folder = await make_folder(ext)
  515. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  516. # File touched later (samba edit); re-scan must pick up the new mtime.
  517. self._set_mtime(f, 1704067200.0) # 2024
  518. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  519. resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
  520. got = resp.json()[0]
  521. assert got["fs_modified_at"].startswith("2024")
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_recursive_activity_bubbles_deep_file_to_root(self, async_client, db_session, tmp_path, make_folder):
  525. """A freshly-added deep file lifts every ancestor's activity (#2680).
  526. ``a`` holds only an OLD file directly but a NEW file three levels down;
  527. ``b`` holds a MIDDLE-aged file directly. Recursive bubble must rank ``a``
  528. (newest descendant) ahead of ``b`` even though a's own direct file and
  529. directory are older.
  530. """
  531. root = tmp_path / "root"
  532. deep = root / "a" / "x" / "y"
  533. deep.mkdir(parents=True)
  534. (root / "b").mkdir()
  535. a_direct = root / "a" / "shallow.3mf"
  536. deep_file = deep / "deep.3mf"
  537. b_direct = root / "b" / "mid.3mf"
  538. for p, data in ((a_direct, b"1"), (deep_file, b"2"), (b_direct, b"3")):
  539. p.write_bytes(data)
  540. self._set_mtime(a_direct, 1609459200.0) # 2021 (oldest)
  541. self._set_mtime(b_direct, 1656633600.0) # 2022-07 (middle)
  542. self._set_mtime(deep_file, 1704067200.0) # 2024 (newest, deep under a)
  543. # Directory mtimes are all old so only the deep FILE can lift branch a.
  544. for d in (root, root / "a", root / "a" / "x", deep, root / "b"):
  545. self._set_mtime(d, 1609459200.0)
  546. folder = await make_folder(root)
  547. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  548. tree = (await async_client.get("/api/v1/library/folders")).json()
  549. top = find_folder_in_tree(tree, folder["name"])
  550. assert top is not None
  551. children = {c["name"]: c for c in top["children"]}
  552. assert "a" in children and "b" in children
  553. # Branch a's activity == the deep 2024 file; b's == its 2022 file.
  554. assert children["a"]["latest_activity_at"] > children["b"]["latest_activity_at"]
  555. assert children["a"]["latest_activity_at"].startswith("2024")
  556. # The root itself bubbles up to the newest descendant anywhere inside it.
  557. assert top["latest_activity_at"].startswith("2024")
  558. @pytest.mark.asyncio
  559. @pytest.mark.integration
  560. async def test_scan_captures_folder_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
  561. """An empty-but-recently-touched subfolder still carries a real mtime."""
  562. root = tmp_path / "root"
  563. sub = root / "sub"
  564. sub.mkdir(parents=True)
  565. # A file so the subfolder survives the empty-subfolder cleanup.
  566. (sub / "keep.3mf").write_bytes(b"a")
  567. self._set_mtime(sub / "keep.3mf", 1609459200.0) # 2021
  568. self._set_mtime(sub, 1704067200.0) # dir touched 2024
  569. folder = await make_folder(root)
  570. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  571. tree = (await async_client.get("/api/v1/library/folders")).json()
  572. subfolder = find_folder_in_tree(tree, "sub")
  573. assert subfolder is not None
  574. # Dir mtime (2024) beats the single 2021 file → folder activity is 2024.
  575. assert subfolder["latest_activity_at"].startswith("2024")
  576. class TestExternalFolderProtections:
  577. """Tests for read-only protections on external folders."""
  578. @pytest.fixture
  579. def external_dir(self, tmp_path):
  580. ext_dir = tmp_path / "readonly_share"
  581. ext_dir.mkdir()
  582. (ext_dir / "test.stl").write_bytes(b"fakestl")
  583. return ext_dir
  584. @pytest.fixture
  585. async def readonly_folder(self, async_client, db_session, external_dir):
  586. """Create a read-only external folder with files scanned."""
  587. data = {
  588. "name": "Read Only",
  589. "external_path": str(external_dir),
  590. "readonly": True,
  591. }
  592. response = await async_client.post("/api/v1/library/folders/external", json=data)
  593. folder = response.json()
  594. await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  595. return folder
  596. @pytest.mark.asyncio
  597. @pytest.mark.integration
  598. async def test_upload_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  599. """Verify uploads to read-only external folders are blocked."""
  600. import io
  601. file_content = io.BytesIO(b"test content")
  602. response = await async_client.post(
  603. f"/api/v1/library/files?folder_id={readonly_folder['id']}",
  604. files={"file": ("test.gcode", file_content, "application/octet-stream")},
  605. )
  606. assert response.status_code == 403
  607. assert "read-only" in response.json()["detail"].lower()
  608. @pytest.mark.asyncio
  609. @pytest.mark.integration
  610. async def test_move_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  611. """Verify moving files to read-only external folder is blocked."""
  612. from backend.app.models.library import LibraryFile
  613. # Create a regular file
  614. lib_file = LibraryFile(
  615. filename="regular.3mf",
  616. file_path="/test/regular.3mf",
  617. file_size=1024,
  618. file_type="3mf",
  619. )
  620. db_session.add(lib_file)
  621. await db_session.commit()
  622. await db_session.refresh(lib_file)
  623. data = {"file_ids": [lib_file.id], "folder_id": readonly_folder["id"]}
  624. response = await async_client.post("/api/v1/library/files/move", json=data)
  625. assert response.status_code == 403
  626. assert "read-only" in response.json()["detail"].lower()
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_external_files_cannot_be_moved_out(self, async_client: AsyncClient, db_session, readonly_folder):
  630. """Verify external files can't be moved to other folders."""
  631. # Get the external file ID
  632. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  633. files = response.json()
  634. assert len(files) > 0
  635. ext_file_id = files[0]["id"]
  636. # Try to move to root
  637. data = {"file_ids": [ext_file_id], "folder_id": None}
  638. response = await async_client.post("/api/v1/library/files/move", json=data)
  639. assert response.status_code == 200
  640. # File should be skipped, not moved
  641. result = response.json()
  642. assert result["moved"] == 0
  643. @pytest.mark.asyncio
  644. @pytest.mark.integration
  645. async def test_delete_external_file_removes_db_only(
  646. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  647. ):
  648. """Verify deleting an external file only removes DB entry, not the file on disk."""
  649. response = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  650. files = response.json()
  651. ext_file_id = files[0]["id"]
  652. ext_filename = files[0]["filename"]
  653. # Delete via API
  654. response = await async_client.delete(f"/api/v1/library/files/{ext_file_id}")
  655. assert response.status_code == 200
  656. # File should still exist on disk
  657. assert (external_dir / ext_filename).exists()
  658. @pytest.mark.asyncio
  659. @pytest.mark.integration
  660. async def test_delete_external_folder_preserves_files(
  661. self, async_client: AsyncClient, db_session, readonly_folder, external_dir
  662. ):
  663. """Verify deleting an external folder doesn't delete files from disk."""
  664. response = await async_client.delete(f"/api/v1/library/folders/{readonly_folder['id']}")
  665. assert response.status_code == 200
  666. # Files should still exist on disk
  667. assert (external_dir / "test.stl").exists()
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_zip_to_readonly_folder_blocked(self, async_client: AsyncClient, db_session, readonly_folder):
  671. """Verify ZIP extraction to read-only external folder is blocked."""
  672. import io
  673. import zipfile
  674. # Create a minimal zip
  675. buf = io.BytesIO()
  676. with zipfile.ZipFile(buf, "w") as zf:
  677. zf.writestr("test.stl", b"fakestl")
  678. buf.seek(0)
  679. response = await async_client.post(
  680. f"/api/v1/library/files/extract-zip?folder_id={readonly_folder['id']}",
  681. files={"file": ("test.zip", buf, "application/zip")},
  682. )
  683. assert response.status_code == 403
  684. assert "read-only" in response.json()["detail"].lower()
  685. class TestExternalFolderWritableUpload:
  686. """Tests for upload write-through to writable external folders (#1112).
  687. Before the fix, uploads to writable external folders silently landed in the
  688. internal library dir while the DB row pointed at the external folder —
  689. files were invisible when the mount was viewed from another machine.
  690. """
  691. @pytest.fixture
  692. def external_dir(self, tmp_path):
  693. ext_dir = tmp_path / "writable_share"
  694. ext_dir.mkdir()
  695. return ext_dir
  696. @pytest.fixture
  697. async def writable_folder(self, async_client, db_session, external_dir):
  698. data = {
  699. "name": "Writable NAS",
  700. "external_path": str(external_dir),
  701. "readonly": False,
  702. }
  703. response = await async_client.post("/api/v1/library/folders/external", json=data)
  704. assert response.status_code == 200
  705. return response.json()
  706. @pytest.mark.asyncio
  707. @pytest.mark.integration
  708. async def test_upload_lands_on_external_mount(
  709. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  710. ):
  711. """Bytes are written to ``<external_path>/<filename>``, not the internal library dir."""
  712. import io
  713. content = b"hello-external-world"
  714. response = await async_client.post(
  715. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  716. files={"file": ("upload.stl", io.BytesIO(content), "application/octet-stream")},
  717. )
  718. assert response.status_code == 200, response.text
  719. on_disk = external_dir / "upload.stl"
  720. assert on_disk.exists(), "file must be written to the external mount"
  721. assert on_disk.read_bytes() == content
  722. @pytest.mark.asyncio
  723. @pytest.mark.integration
  724. async def test_upload_persists_correct_db_shape(
  725. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  726. ):
  727. """DB row must have ``is_external=True`` and ``file_path`` = absolute external path,
  728. so scan-dedupe and deletion behaviour match scanned files."""
  729. import io
  730. import zipfile
  731. from backend.app.models.library import LibraryFile
  732. # #1401 hardened the library upload route to reject .3mf files that
  733. # aren't valid ZIP containers. This test asserts external-folder
  734. # DB shape, not the upload validator, so feed it a minimal real zip
  735. # rather than placeholder bytes.
  736. zip_buf = io.BytesIO()
  737. with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
  738. zf.writestr("placeholder.txt", "")
  739. zip_buf.seek(0)
  740. response = await async_client.post(
  741. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  742. files={"file": ("model.3mf", zip_buf, "application/octet-stream")},
  743. )
  744. assert response.status_code == 200
  745. file_id = response.json()["id"]
  746. row = await db_session.get(LibraryFile, file_id)
  747. await db_session.refresh(row)
  748. assert row.is_external is True
  749. assert row.file_path == str((external_dir / "model.3mf").resolve())
  750. @pytest.mark.asyncio
  751. @pytest.mark.integration
  752. async def test_upload_filename_collision_returns_409(
  753. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  754. ):
  755. """Re-uploading a filename that already exists on the mount must 409,
  756. not silently overwrite — matches scan's treatment of external files as
  757. externally-owned bytes."""
  758. import io
  759. (external_dir / "already.stl").write_bytes(b"prior")
  760. response = await async_client.post(
  761. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  762. files={"file": ("already.stl", io.BytesIO(b"new"), "application/octet-stream")},
  763. )
  764. assert response.status_code == 409
  765. assert (external_dir / "already.stl").read_bytes() == b"prior"
  766. @pytest.mark.asyncio
  767. @pytest.mark.integration
  768. async def test_upload_to_missing_external_path_returns_400(
  769. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  770. ):
  771. """If the external mount has gone away between folder-create and
  772. upload, fail loud rather than silently misroute to internal storage."""
  773. import io
  774. import shutil
  775. shutil.rmtree(external_dir)
  776. response = await async_client.post(
  777. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  778. files={"file": ("x.stl", io.BytesIO(b"x"), "application/octet-stream")},
  779. )
  780. assert response.status_code == 400
  781. assert "not accessible" in response.json()["detail"].lower()
  782. @pytest.mark.asyncio
  783. @pytest.mark.integration
  784. async def test_upload_rejects_path_traversal_filename(
  785. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  786. ):
  787. """A malicious filename like ``../escape.stl`` must not write outside
  788. the external folder. Defence-in-depth — FastAPI already strips these
  789. on parse, but the resolve-and-relative_to guard is the final gate."""
  790. import io
  791. response = await async_client.post(
  792. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  793. files={"file": ("../escape.stl", io.BytesIO(b"x"), "application/octet-stream")},
  794. )
  795. # Either a 400 from our traversal guard or a 200 with basename-stripped
  796. # filename inside the external dir — both prove nothing escaped.
  797. if response.status_code == 200:
  798. assert not (external_dir.parent / "escape.stl").exists()
  799. assert (external_dir / "escape.stl").exists() or (external_dir / "..escape.stl").exists()
  800. else:
  801. assert response.status_code in (400, 422)
  802. assert not (external_dir.parent / "escape.stl").exists()
  803. @pytest.mark.asyncio
  804. @pytest.mark.integration
  805. async def test_zip_to_writable_external_folder_rejected(
  806. self, async_client: AsyncClient, db_session, writable_folder
  807. ):
  808. """Extract-zip into writable external folders isn't supported (nested
  809. subfolder creation on the mount is a separate design). Users are
  810. pointed at the Scan flow instead."""
  811. import io
  812. import zipfile
  813. buf = io.BytesIO()
  814. with zipfile.ZipFile(buf, "w") as zf:
  815. zf.writestr("a/b/c.stl", b"x")
  816. buf.seek(0)
  817. response = await async_client.post(
  818. f"/api/v1/library/files/extract-zip?folder_id={writable_folder['id']}",
  819. files={"file": ("test.zip", buf, "application/zip")},
  820. )
  821. assert response.status_code == 400
  822. assert "scan" in response.json()["detail"].lower()
  823. @pytest.mark.asyncio
  824. @pytest.mark.integration
  825. async def test_non_external_upload_unchanged(self, async_client: AsyncClient, db_session):
  826. """Uploads with no folder_id (root) keep the existing internal-storage behaviour."""
  827. import io
  828. from backend.app.models.library import LibraryFile
  829. response = await async_client.post(
  830. "/api/v1/library/files",
  831. files={"file": ("root.stl", io.BytesIO(b"x"), "application/octet-stream")},
  832. )
  833. assert response.status_code == 200
  834. file_id = response.json()["id"]
  835. row = await db_session.get(LibraryFile, file_id)
  836. await db_session.refresh(row)
  837. assert row.is_external is False
  838. # Internal storage: file_path is UUID-scoped, stored as a relative path.
  839. assert not row.file_path.startswith("/")
  840. class TestCrossBoundaryMove:
  841. """#1112 follow-up: moving files between managed and external folders
  842. must physically relocate the bytes, not just shuffle the DB ``folder_id``.
  843. Pre-fix symptom (reported by @Carter3DP after testing 0.2.4b1): a file
  844. moved from a managed folder to a NAS-backed external folder showed up
  845. in Bambuddy's UI under the external folder but was never written to
  846. the NAS — so the SMB mount and Bambuddy disagreed about what was
  847. actually there.
  848. """
  849. @pytest.fixture
  850. def external_dir(self, tmp_path):
  851. ext_dir = tmp_path / "writable_share"
  852. ext_dir.mkdir()
  853. return ext_dir
  854. @pytest.fixture
  855. async def writable_folder(self, async_client, db_session, external_dir):
  856. data = {"name": "Writable NAS", "external_path": str(external_dir), "readonly": False}
  857. response = await async_client.post("/api/v1/library/folders/external", json=data)
  858. assert response.status_code == 200
  859. return response.json()
  860. @pytest.fixture
  861. async def readonly_folder(self, async_client, db_session, tmp_path):
  862. ro_dir = tmp_path / "ro_share"
  863. ro_dir.mkdir()
  864. (ro_dir / "stranded.gcode").write_text("G28")
  865. data = {"name": "Read-only NAS", "external_path": str(ro_dir), "readonly": True}
  866. response = await async_client.post("/api/v1/library/folders/external", json=data)
  867. assert response.status_code == 200
  868. # Populate via scan so the file gets a DB row with is_external=True.
  869. scan = await async_client.post(f"/api/v1/library/folders/{response.json()['id']}/scan")
  870. assert scan.status_code == 200
  871. return response.json()
  872. @pytest.mark.asyncio
  873. @pytest.mark.integration
  874. async def test_managed_to_external_relocates_bytes(
  875. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  876. ):
  877. """The actual #1112 fix: managed → external must write the bytes
  878. to the NAS mount AND drop them from internal storage. Pre-fix the
  879. DB row flipped to the new folder but the bytes stayed put."""
  880. import io
  881. from backend.app.api.routes.library import to_absolute_path
  882. from backend.app.models.library import LibraryFile
  883. upload = await async_client.post(
  884. "/api/v1/library/files",
  885. files={"file": ("ship_me.stl", io.BytesIO(b"original-bytes"), "application/octet-stream")},
  886. )
  887. assert upload.status_code == 200
  888. file_id = upload.json()["id"]
  889. # Snapshot the pre-move on-disk path so we can verify it's gone after.
  890. pre = await db_session.get(LibraryFile, file_id)
  891. await db_session.refresh(pre)
  892. managed_disk_path = to_absolute_path(pre.file_path)
  893. assert managed_disk_path is not None and managed_disk_path.exists()
  894. response = await async_client.post(
  895. "/api/v1/library/files/move",
  896. json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
  897. )
  898. assert response.status_code == 200, response.text
  899. body = response.json()
  900. assert body["moved"] == 1
  901. assert body["skipped"] == 0
  902. # Bytes are on the NAS mount.
  903. on_nas = external_dir / "ship_me.stl"
  904. assert on_nas.exists()
  905. assert on_nas.read_bytes() == b"original-bytes"
  906. # Internal copy is gone.
  907. assert not managed_disk_path.exists(), "managed source must be removed after the move"
  908. # DB row matches reality.
  909. await db_session.refresh(pre)
  910. assert pre.is_external is True
  911. assert pre.folder_id == writable_folder["id"]
  912. assert pre.file_path == str(on_nas.resolve())
  913. @pytest.mark.asyncio
  914. @pytest.mark.integration
  915. async def test_external_to_managed_relocates_bytes(
  916. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  917. ):
  918. """Symmetric direction: external → managed copies the bytes into
  919. internal storage with a UUID name, deletes the source on the
  920. mount, and recomputes the file hash (since scan stores
  921. ``file_hash=None`` for external rows)."""
  922. import io
  923. from backend.app.models.library import LibraryFile
  924. # Plant a file on the writable mount and let upload give it a row.
  925. upload = await async_client.post(
  926. f"/api/v1/library/files?folder_id={writable_folder['id']}",
  927. files={"file": ("relocate_me.stl", io.BytesIO(b"nas-bytes"), "application/octet-stream")},
  928. )
  929. assert upload.status_code == 200
  930. file_id = upload.json()["id"]
  931. ext_disk = external_dir / "relocate_me.stl"
  932. assert ext_disk.exists()
  933. response = await async_client.post(
  934. "/api/v1/library/files/move",
  935. json={"file_ids": [file_id], "folder_id": None},
  936. )
  937. assert response.status_code == 200
  938. assert response.json()["moved"] == 1
  939. db_session.expire_all()
  940. row = await db_session.get(LibraryFile, file_id)
  941. assert row.is_external is False
  942. assert row.folder_id is None
  943. assert not row.file_path.startswith("/"), "managed file_path must be relative"
  944. assert not ext_disk.exists(), "external source must be removed after the move"
  945. # Hash filled in for the now-managed row so future dedup works.
  946. assert row.file_hash is not None and len(row.file_hash) == 64
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_managed_to_external_collision_skips_with_reason(
  950. self, async_client: AsyncClient, db_session, writable_folder, external_dir
  951. ):
  952. """A name collision on the target external mount must skip the
  953. move with a structured reason — not silently overwrite a file
  954. that's already on the NAS."""
  955. import io
  956. # Pre-existing file on the mount with the same name as the upload.
  957. (external_dir / "duplicate.stl").write_bytes(b"pre-existing")
  958. upload = await async_client.post(
  959. "/api/v1/library/files",
  960. files={"file": ("duplicate.stl", io.BytesIO(b"new-bytes"), "application/octet-stream")},
  961. )
  962. assert upload.status_code == 200
  963. file_id = upload.json()["id"]
  964. response = await async_client.post(
  965. "/api/v1/library/files/move",
  966. json={"file_ids": [file_id], "folder_id": writable_folder["id"]},
  967. )
  968. assert response.status_code == 200
  969. body = response.json()
  970. assert body["moved"] == 0
  971. assert body["skipped"] == 1
  972. reasons = body["skipped_reasons"]
  973. assert len(reasons) == 1
  974. assert reasons[0]["file_id"] == file_id
  975. assert reasons[0]["code"] == "name_collision"
  976. # Pre-existing target file is intact.
  977. assert (external_dir / "duplicate.stl").read_bytes() == b"pre-existing"
  978. @pytest.mark.asyncio
  979. @pytest.mark.integration
  980. async def test_external_readonly_source_skips(self, async_client: AsyncClient, db_session, readonly_folder):
  981. """A read-only mount allows reading but not deletes, and a move
  982. is semantically a delete on the source. Skip with
  983. ``source_readonly`` so the file isn't duplicated by half-moving."""
  984. listing = await async_client.get(f"/api/v1/library/files?folder_id={readonly_folder['id']}")
  985. assert listing.status_code == 200
  986. ext_file_id = listing.json()[0]["id"]
  987. response = await async_client.post(
  988. "/api/v1/library/files/move",
  989. json={"file_ids": [ext_file_id], "folder_id": None},
  990. )
  991. assert response.status_code == 200
  992. body = response.json()
  993. assert body["moved"] == 0
  994. assert body["skipped"] == 1
  995. assert body["skipped_reasons"][0]["code"] == "source_readonly"
  996. @pytest.mark.asyncio
  997. @pytest.mark.integration
  998. async def test_managed_to_managed_remains_db_only(self, async_client: AsyncClient, db_session):
  999. """Same-boundary moves (managed → managed) keep the existing
  1000. DB-only fast path — no shutil.copy, no UUID rename. The original
  1001. file_path stays the same, only ``folder_id`` changes."""
  1002. import io
  1003. from backend.app.models.library import LibraryFile
  1004. sub = await async_client.post(
  1005. "/api/v1/library/folders",
  1006. json={"name": "subfolder", "parent_id": None},
  1007. )
  1008. assert sub.status_code == 200
  1009. target_id = sub.json()["id"]
  1010. upload = await async_client.post(
  1011. "/api/v1/library/files",
  1012. files={"file": ("part.stl", io.BytesIO(b"x"), "application/octet-stream")},
  1013. )
  1014. assert upload.status_code == 200
  1015. file_id = upload.json()["id"]
  1016. pre = await db_session.get(LibraryFile, file_id)
  1017. await db_session.refresh(pre)
  1018. original_path = pre.file_path
  1019. response = await async_client.post(
  1020. "/api/v1/library/files/move",
  1021. json={"file_ids": [file_id], "folder_id": target_id},
  1022. )
  1023. assert response.status_code == 200
  1024. assert response.json()["moved"] == 1
  1025. db_session.expire_all()
  1026. post = await db_session.get(LibraryFile, file_id)
  1027. assert post.folder_id == target_id
  1028. assert post.is_external is False
  1029. assert post.file_path == original_path # bytes never moved
  1030. @pytest.mark.asyncio
  1031. @pytest.mark.integration
  1032. async def test_skipped_reasons_field_present_even_when_empty(self, async_client: AsyncClient, db_session):
  1033. """Backwards-compatible response shape: ``skipped_reasons`` is
  1034. always present (empty list when nothing skipped) so frontend
  1035. code can treat it as the source of truth without optional-chain
  1036. gymnastics."""
  1037. import io
  1038. upload = await async_client.post(
  1039. "/api/v1/library/files",
  1040. files={"file": ("trivial.stl", io.BytesIO(b"x"), "application/octet-stream")},
  1041. )
  1042. assert upload.status_code == 200
  1043. file_id = upload.json()["id"]
  1044. response = await async_client.post(
  1045. "/api/v1/library/files/move",
  1046. json={"file_ids": [file_id], "folder_id": None},
  1047. )
  1048. assert response.status_code == 200
  1049. body = response.json()
  1050. assert "skipped_reasons" in body
  1051. assert body["skipped_reasons"] == []