test_library_api.py 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018
  1. """Integration tests for Library API endpoints."""
  2. import io
  3. import tempfile
  4. import zipfile
  5. from pathlib import Path
  6. import pytest
  7. from httpx import AsyncClient
  8. from sqlalchemy import select
  9. from backend.app.core.config import settings as app_settings
  10. from backend.app.models.print_queue import PrintQueueItem
  11. async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
  12. """Re-read a queue row the route just committed through its own session."""
  13. db_session.expire_all()
  14. return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  15. class TestLibraryFoldersAPI:
  16. """Integration tests for library folders endpoints."""
  17. @pytest.fixture
  18. async def folder_factory(self, db_session):
  19. """Factory to create test folders."""
  20. _counter = [0]
  21. async def _create_folder(**kwargs):
  22. from backend.app.models.library import LibraryFolder
  23. _counter[0] += 1
  24. counter = _counter[0]
  25. defaults = {
  26. "name": f"Test Folder {counter}",
  27. }
  28. defaults.update(kwargs)
  29. folder = LibraryFolder(**defaults)
  30. db_session.add(folder)
  31. await db_session.commit()
  32. await db_session.refresh(folder)
  33. return folder
  34. return _create_folder
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_list_folders_empty(self, async_client: AsyncClient, db_session):
  38. """Verify empty folder list returns empty array."""
  39. response = await async_client.get("/api/v1/library/folders")
  40. assert response.status_code == 200
  41. assert response.json() == []
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_folder_tree_exposes_latest_activity_at_from_files(
  45. self, async_client: AsyncClient, folder_factory, db_session
  46. ):
  47. """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
  48. MAX(immediate-child file.updated_at)) so the frontend can sort by
  49. recent activity. Adding a file with a later updated_at must bubble it.
  50. """
  51. from datetime import datetime, timedelta
  52. from backend.app.models.library import LibraryFile
  53. folder = await folder_factory(name="Active Folder")
  54. # File whose updated_at is well after the folder's. Activity should
  55. # surface this timestamp, not the folder's stale one.
  56. future = datetime.utcnow() + timedelta(hours=24)
  57. db_session.add(
  58. LibraryFile(
  59. folder_id=folder.id,
  60. filename="model.3mf",
  61. file_path="library/model.3mf",
  62. file_type="3mf",
  63. file_size=123,
  64. updated_at=future,
  65. )
  66. )
  67. await db_session.commit()
  68. response = await async_client.get("/api/v1/library/folders")
  69. assert response.status_code == 200
  70. items = response.json()
  71. assert len(items) == 1
  72. item = items[0]
  73. assert item["id"] == folder.id
  74. assert item["latest_activity_at"] is not None
  75. # latest_activity_at should be at least the future stamp we set.
  76. assert item["latest_activity_at"] >= future.isoformat()
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
  80. self, async_client: AsyncClient, folder_factory, db_session
  81. ):
  82. """#1770: a folder with no files reports its own updated_at, not null —
  83. otherwise the activity sort would dump every empty folder to one end."""
  84. await folder_factory(name="Empty Folder")
  85. response = await async_client.get("/api/v1/library/folders")
  86. assert response.status_code == 200
  87. items = response.json()
  88. assert len(items) == 1
  89. item = items[0]
  90. # latest_activity_at == folder.updated_at when there are no files
  91. assert item["latest_activity_at"] is not None
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_create_folder(self, async_client: AsyncClient, db_session):
  95. """Verify folder can be created."""
  96. data = {"name": "New Folder"}
  97. response = await async_client.post("/api/v1/library/folders", json=data)
  98. assert response.status_code == 200
  99. result = response.json()
  100. assert result["name"] == "New Folder"
  101. assert result["id"] is not None
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):
  105. """Verify nested folder can be created."""
  106. parent = await folder_factory(name="Parent")
  107. data = {"name": "Child", "parent_id": parent.id}
  108. response = await async_client.post("/api/v1/library/folders", json=data)
  109. assert response.status_code == 200
  110. result = response.json()
  111. assert result["name"] == "Child"
  112. assert result["parent_id"] == parent.id
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):
  116. """Verify single folder can be retrieved."""
  117. folder = await folder_factory(name="Test Folder")
  118. response = await async_client.get(f"/api/v1/library/folders/{folder.id}")
  119. assert response.status_code == 200
  120. result = response.json()
  121. assert result["id"] == folder.id
  122. assert result["name"] == "Test Folder"
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):
  126. """Verify 404 for non-existent folder."""
  127. response = await async_client.get("/api/v1/library/folders/9999")
  128. assert response.status_code == 404
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):
  132. """Verify folder can be updated."""
  133. folder = await folder_factory(name="Old Name")
  134. data = {"name": "New Name"}
  135. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data)
  136. assert response.status_code == 200
  137. result = response.json()
  138. assert result["name"] == "New Name"
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):
  142. """Verify folder can be deleted."""
  143. folder = await folder_factory()
  144. response = await async_client.delete(f"/api/v1/library/folders/{folder.id}")
  145. assert response.status_code == 200
  146. result = response.json()
  147. assert result.get("message") or result.get("success", True)
  148. class TestLibraryFilesAPI:
  149. """Integration tests for library files endpoints."""
  150. @pytest.fixture
  151. async def folder_factory(self, db_session):
  152. """Factory to create test folders."""
  153. _counter = [0]
  154. async def _create_folder(**kwargs):
  155. from backend.app.models.library import LibraryFolder
  156. _counter[0] += 1
  157. counter = _counter[0]
  158. defaults = {"name": f"Test Folder {counter}"}
  159. defaults.update(kwargs)
  160. folder = LibraryFolder(**defaults)
  161. db_session.add(folder)
  162. await db_session.commit()
  163. await db_session.refresh(folder)
  164. return folder
  165. return _create_folder
  166. @pytest.fixture
  167. async def file_factory(self, db_session):
  168. """Factory to create test files."""
  169. _counter = [0]
  170. async def _create_file(**kwargs):
  171. from backend.app.models.library import LibraryFile
  172. _counter[0] += 1
  173. counter = _counter[0]
  174. defaults = {
  175. "filename": f"test_file_{counter}.3mf",
  176. "file_path": f"/test/path/test_file_{counter}.3mf",
  177. "file_size": 1024,
  178. "file_type": "3mf",
  179. }
  180. defaults.update(kwargs)
  181. lib_file = LibraryFile(**defaults)
  182. db_session.add(lib_file)
  183. await db_session.commit()
  184. await db_session.refresh(lib_file)
  185. return lib_file
  186. return _create_file
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_list_files_empty(self, async_client: AsyncClient, db_session):
  190. """Verify empty file list returns empty array."""
  191. response = await async_client.get("/api/v1/library/files")
  192. assert response.status_code == 200
  193. assert response.json() == []
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  197. """Verify files can be filtered by folder."""
  198. folder = await folder_factory()
  199. file1 = await file_factory(folder_id=folder.id)
  200. await file_factory() # File in root (no folder)
  201. response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}")
  202. assert response.status_code == 200
  203. result = response.json()
  204. assert len(result) == 1
  205. assert result[0]["id"] == file1.id
  206. @pytest.mark.asyncio
  207. @pytest.mark.integration
  208. async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  209. """#932: project_id filter returns files across all folders linked to the project.
  210. Replaces the prior N+1 pattern where the frontend fired one request per
  211. linked folder. A single JOIN query must return every file in folders whose
  212. project_id matches, while excluding files from unlinked folders.
  213. """
  214. from backend.app.models.project import Project
  215. project = Project(name="Test Project for Files", color="#00ff00")
  216. db_session.add(project)
  217. await db_session.commit()
  218. await db_session.refresh(project)
  219. folder_a = await folder_factory(name="Folder A", project_id=project.id)
  220. folder_b = await folder_factory(name="Folder B", project_id=project.id)
  221. other_folder = await folder_factory(name="Unlinked")
  222. linked_a = await file_factory(folder_id=folder_a.id, filename="a.3mf")
  223. linked_b = await file_factory(folder_id=folder_b.id, filename="b.3mf")
  224. await file_factory(folder_id=other_folder.id, filename="unlinked.3mf")
  225. await file_factory(filename="root.3mf") # no folder → not part of any project
  226. response = await async_client.get(f"/api/v1/library/files?project_id={project.id}")
  227. assert response.status_code == 200
  228. result = response.json()
  229. ids = {f["id"] for f in result}
  230. assert ids == {linked_a.id, linked_b.id}
  231. @pytest.mark.asyncio
  232. @pytest.mark.integration
  233. async def test_list_files_folder_id_takes_precedence_over_project_id(
  234. self, async_client: AsyncClient, folder_factory, file_factory, db_session
  235. ):
  236. """When both folder_id and project_id are passed, folder_id wins.
  237. Documented precedence in list_files(): folder_id > project_id > include_root.
  238. This guards the behavior so a future refactor can't silently flip it.
  239. """
  240. from backend.app.models.project import Project
  241. project = Project(name="Precedence Project")
  242. db_session.add(project)
  243. await db_session.commit()
  244. await db_session.refresh(project)
  245. folder_linked = await folder_factory(name="Linked", project_id=project.id)
  246. folder_other = await folder_factory(name="Other")
  247. await file_factory(folder_id=folder_linked.id, filename="linked.3mf")
  248. other_file = await file_factory(folder_id=folder_other.id, filename="other.3mf")
  249. # folder_id points at a folder that is NOT in the project — must return
  250. # that folder's contents and ignore project_id entirely.
  251. response = await async_client.get(f"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}")
  252. assert response.status_code == 200
  253. result = response.json()
  254. assert len(result) == 1
  255. assert result[0]["id"] == other_file.id
  256. @pytest.mark.asyncio
  257. @pytest.mark.integration
  258. @pytest.mark.parametrize("direction", ["into", "out of"])
  259. async def test_an_edit_does_not_move_a_file_across_the_external_boundary(
  260. self, async_client: AsyncClient, folder_factory, file_factory, db_session, direction
  261. ):
  262. """F8: the edit route only changed folder_id -- no byte move, no
  263. read-only check -- so the file showed in a folder its bytes are not
  264. in. Such a move goes through POST /library/files/move."""
  265. internal = await folder_factory(name="Mine")
  266. nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  267. if direction == "into":
  268. file = await file_factory(folder_id=internal.id, filename="a.3mf", is_external=False)
  269. target = nas.id
  270. else:
  271. file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
  272. target = internal.id
  273. response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": target})
  274. assert response.status_code == 400
  275. assert "/library/files/move" in response.json()["detail"]
  276. await db_session.refresh(file)
  277. assert file.folder_id != target
  278. @pytest.mark.asyncio
  279. @pytest.mark.integration
  280. async def test_an_external_file_sent_with_its_own_folder_is_no_move(
  281. self, async_client: AsyncClient, folder_factory, file_factory
  282. ):
  283. nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  284. file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
  285. response = await async_client.put(
  286. f"/api/v1/library/files/{file.id}", json={"folder_id": nas.id, "notes": "kept"}
  287. )
  288. assert response.status_code == 200
  289. @pytest.mark.asyncio
  290. @pytest.mark.integration
  291. async def test_an_edit_still_moves_a_file_between_its_own_folders(
  292. self, async_client: AsyncClient, folder_factory, file_factory, db_session
  293. ):
  294. first = await folder_factory(name="One")
  295. second = await folder_factory(name="Two")
  296. file = await file_factory(folder_id=first.id, filename="a.3mf", is_external=False)
  297. response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": second.id})
  298. assert response.status_code == 200
  299. await db_session.refresh(file)
  300. assert file.folder_id == second.id
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  304. """#1621: `internal_only=true` restricts the listing to files in managed
  305. storage (`is_external=False`) so a linked NAS with hundreds of files
  306. doesn't drown the user's own uploads in the "All Files" sidebar view."""
  307. internal_folder = await folder_factory(name="My uploads")
  308. external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  309. internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  310. await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
  311. root_file = await file_factory(filename="root.3mf", is_external=False) # Root-uploaded is always internal.
  312. response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
  313. assert response.status_code == 200
  314. ids = {f["id"] for f in response.json()}
  315. assert ids == {internal_file.id, root_file.id}
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  319. """#1621 symmetric: `external_only=true` returns the combined view
  320. across every linked external folder so users with several mounts can
  321. see all external content in one place without clicking each folder."""
  322. internal_folder = await folder_factory(name="My uploads")
  323. nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
  324. nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
  325. await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  326. ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
  327. ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
  328. response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
  329. assert response.status_code == 200
  330. ids = {f["id"] for f in response.json()}
  331. assert ids == {ext_a.id, ext_b.id}
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
  335. """Both flags together is a caller bug — fail loud (400) rather than
  336. silently picking one, so a frontend regression is caught immediately."""
  337. response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
  338. assert response.status_code == 400
  339. assert "mutually exclusive" in response.json()["detail"]
  340. @pytest.mark.asyncio
  341. @pytest.mark.integration
  342. async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
  343. """Verify single file can be retrieved."""
  344. lib_file = await file_factory(filename="test.3mf")
  345. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  346. assert response.status_code == 200
  347. result = response.json()
  348. assert result["id"] == lib_file.id
  349. assert result["filename"] == "test.3mf"
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
  353. """Verify 404 for non-existent file."""
  354. response = await async_client.get("/api/v1/library/files/9999")
  355. assert response.status_code == 404
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
  359. """Verify file can be deleted."""
  360. lib_file = await file_factory()
  361. response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
  362. assert response.status_code == 200
  363. result = response.json()
  364. assert result.get("message") or result.get("success", True)
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
  368. """Verify file can be renamed."""
  369. lib_file = await file_factory(filename="old_name.3mf")
  370. data = {"filename": "new_name.3mf"}
  371. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  372. assert response.status_code == 200
  373. result = response.json()
  374. assert result["filename"] == "new_name.3mf"
  375. @pytest.mark.asyncio
  376. @pytest.mark.integration
  377. async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
  378. """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
  379. lib_file = await file_factory(filename="test.3mf")
  380. data = {"filename": "path/to/file.3mf"}
  381. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  382. assert response.status_code == 400
  383. assert "invalid character" in response.json()["detail"].lower()
  384. assert "/" in response.json()["detail"]
  385. @pytest.mark.asyncio
  386. @pytest.mark.integration
  387. async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
  388. """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
  389. lib_file = await file_factory(filename="test.3mf")
  390. data = {"filename": "path\\to\\file.3mf"}
  391. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  392. assert response.status_code == 400
  393. assert "invalid character" in response.json()["detail"].lower()
  394. assert "\\" in response.json()["detail"]
  395. @pytest.mark.asyncio
  396. @pytest.mark.integration
  397. async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  398. """Verify library stats endpoint returns counts."""
  399. await folder_factory()
  400. await folder_factory()
  401. await file_factory()
  402. response = await async_client.get("/api/v1/library/stats")
  403. assert response.status_code == 200
  404. result = response.json()
  405. assert result["total_folders"] == 2
  406. assert result["total_files"] == 1
  407. @pytest.mark.asyncio
  408. @pytest.mark.integration
  409. async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  410. """Verify file list response includes user tracking fields (Issue #206)."""
  411. lib_file = await file_factory(filename="test.3mf")
  412. response = await async_client.get("/api/v1/library/files?include_root=false")
  413. assert response.status_code == 200
  414. result = response.json()
  415. assert len(result) >= 1
  416. # Find our test file
  417. test_file = next((f for f in result if f["id"] == lib_file.id), None)
  418. assert test_file is not None
  419. # User tracking fields should be present (even if null)
  420. assert "created_by_id" in test_file
  421. assert "created_by_username" in test_file
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  425. """Verify file detail response includes user tracking fields (Issue #206)."""
  426. lib_file = await file_factory(filename="test_detail.3mf")
  427. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  428. assert response.status_code == 200
  429. result = response.json()
  430. # User tracking fields should be present (even if null)
  431. assert "created_by_id" in result
  432. assert "created_by_username" in result
  433. @pytest.mark.asyncio
  434. @pytest.mark.integration
  435. async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
  436. """Verify file created with user shows username in response (Issue #206)."""
  437. from backend.app.models.library import LibraryFile
  438. from backend.app.models.user import User
  439. # Create a test user
  440. user = User(username="testuploader", password_hash="fakehash", role="user")
  441. db_session.add(user)
  442. await db_session.flush()
  443. # Create a file with created_by_id set
  444. lib_file = LibraryFile(
  445. filename="user_uploaded.3mf",
  446. file_path="/test/user_uploaded.3mf",
  447. file_size=2048,
  448. file_type="3mf",
  449. created_by_id=user.id,
  450. )
  451. db_session.add(lib_file)
  452. await db_session.commit()
  453. await db_session.refresh(lib_file)
  454. # Verify file detail shows username
  455. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  456. assert response.status_code == 200
  457. result = response.json()
  458. assert result["created_by_id"] == user.id
  459. assert result["created_by_username"] == "testuploader"
  460. # Verify file list also shows username
  461. response = await async_client.get("/api/v1/library/files?include_root=false")
  462. assert response.status_code == 200
  463. files = response.json()
  464. test_file = next((f for f in files if f["id"] == lib_file.id), None)
  465. assert test_file is not None
  466. assert test_file["created_by_id"] == user.id
  467. assert test_file["created_by_username"] == "testuploader"
  468. @pytest.mark.asyncio
  469. @pytest.mark.integration
  470. async def test_list_files_recursive_includes_subfolders(
  471. self, async_client: AsyncClient, folder_factory, file_factory
  472. ):
  473. """#1268: ?recursive=true with folder_id must include every descendant.
  474. Tree:
  475. toys/ ← f_toys, direct file "robot_top.3mf"
  476. cars/ ← child of toys, file "robot_car.3mf"
  477. race/ ← grandchild, file "robot_race.3mf"
  478. other/ ← unrelated, file "robot_other.3mf" (must NOT appear)
  479. """
  480. toys = await folder_factory(name="toys")
  481. cars = await folder_factory(name="cars", parent_id=toys.id)
  482. race = await folder_factory(name="race", parent_id=cars.id)
  483. other = await folder_factory(name="other")
  484. top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
  485. mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
  486. deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
  487. await file_factory(folder_id=other.id, filename="robot_other.3mf")
  488. # Non-recursive: only the file directly under toys.
  489. r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
  490. assert r.status_code == 200
  491. assert {f["id"] for f in r.json()} == {top.id}
  492. # Recursive: toys + cars + race files, but NOT other/.
  493. r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
  494. assert r.status_code == 200
  495. assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
  496. @pytest.mark.asyncio
  497. @pytest.mark.integration
  498. async def test_list_files_recursive_without_folder_id_is_noop(
  499. self, async_client: AsyncClient, folder_factory, file_factory
  500. ):
  501. """recursive=true is meaningful only with folder_id — without it the
  502. existing include_root branch handles scoping. Just confirming the new
  503. param doesn't shadow that path."""
  504. folder = await folder_factory()
  505. f_in = await file_factory(folder_id=folder.id)
  506. f_root = await file_factory()
  507. r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
  508. assert r.status_code == 200
  509. assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
  510. @pytest.mark.asyncio
  511. @pytest.mark.integration
  512. async def test_get_folder_readme_returns_first_markdown(
  513. self, async_client: AsyncClient, folder_factory, file_factory
  514. ):
  515. """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
  516. folder = await folder_factory()
  517. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  518. f.write("# Robot\n\nA cute little robot.")
  519. md_path = f.name
  520. try:
  521. await file_factory(
  522. folder_id=folder.id,
  523. filename="README.md",
  524. file_path=md_path,
  525. file_type="md",
  526. file_size=Path(md_path).stat().st_size,
  527. )
  528. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  529. assert r.status_code == 200
  530. body = r.json()
  531. assert body["filename"] == "README.md"
  532. assert body["content"] == "# Robot\n\nA cute little robot."
  533. assert body["truncated"] is False
  534. finally:
  535. import os
  536. os.unlink(md_path)
  537. @pytest.mark.asyncio
  538. @pytest.mark.integration
  539. async def test_get_folder_readme_prefers_readme_over_other_md(
  540. self, async_client: AsyncClient, folder_factory, file_factory
  541. ):
  542. """When the folder has multiple .md files, README.md / description.md
  543. wins regardless of insertion order or filename case."""
  544. folder = await folder_factory()
  545. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  546. f.write("notes notes notes")
  547. notes_path = f.name
  548. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  549. f.write("the real one")
  550. readme_path = f.name
  551. try:
  552. # notes.md inserted FIRST — naive ordering would pick this one.
  553. await file_factory(
  554. folder_id=folder.id,
  555. filename="notes.md",
  556. file_path=notes_path,
  557. file_type="md",
  558. )
  559. await file_factory(
  560. folder_id=folder.id,
  561. filename="readme.md", # lowercase to confirm case-insensitive match
  562. file_path=readme_path,
  563. file_type="md",
  564. )
  565. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  566. assert r.status_code == 200
  567. assert r.json()["filename"] == "readme.md"
  568. assert r.json()["content"] == "the real one"
  569. finally:
  570. import os
  571. os.unlink(notes_path)
  572. os.unlink(readme_path)
  573. @pytest.mark.asyncio
  574. @pytest.mark.integration
  575. async def test_get_folder_readme_404_when_no_markdown(
  576. self, async_client: AsyncClient, folder_factory, file_factory
  577. ):
  578. """No .md in the folder → 404 so the FE can hide the side panel."""
  579. folder = await folder_factory()
  580. await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
  581. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  582. assert r.status_code == 404
  583. @pytest.mark.asyncio
  584. @pytest.mark.integration
  585. async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
  586. r = await async_client.get("/api/v1/library/folders/999999/readme")
  587. assert r.status_code == 404
  588. class TestLibraryAddToQueueAPI:
  589. """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
  590. @pytest.fixture
  591. async def printer_factory(self, db_session):
  592. """Factory to create test printers."""
  593. _counter = [0]
  594. async def _create_printer(**kwargs):
  595. from backend.app.models.printer import Printer
  596. _counter[0] += 1
  597. counter = _counter[0]
  598. defaults = {
  599. "name": f"Test Printer {counter}",
  600. "ip_address": f"192.168.1.{100 + counter}",
  601. "serial_number": f"TESTSERIAL{counter:04d}",
  602. "access_code": "12345678",
  603. "model": "X1C",
  604. }
  605. defaults.update(kwargs)
  606. printer = Printer(**defaults)
  607. db_session.add(printer)
  608. await db_session.commit()
  609. await db_session.refresh(printer)
  610. return printer
  611. return _create_printer
  612. @pytest.fixture
  613. async def library_file_factory(self, db_session):
  614. """Factory to create test library files."""
  615. _counter = [0]
  616. async def _create_library_file(**kwargs):
  617. from backend.app.models.library import LibraryFile
  618. _counter[0] += 1
  619. counter = _counter[0]
  620. defaults = {
  621. "filename": f"test_file_{counter}.gcode.3mf",
  622. "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
  623. "file_size": 1024,
  624. "file_type": "3mf",
  625. }
  626. defaults.update(kwargs)
  627. lib_file = LibraryFile(**defaults)
  628. db_session.add(lib_file)
  629. await db_session.commit()
  630. await db_session.refresh(lib_file)
  631. return lib_file
  632. return _create_library_file
  633. @pytest.fixture
  634. async def on_disk_file_factory(self, library_file_factory):
  635. """A library file whose bytes exist, so the route gets past its disk check."""
  636. written: list[Path] = []
  637. async def _create(**kwargs):
  638. counter = len(written) + 1
  639. rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{counter}.gcode.3mf")
  640. abs_path = Path(app_settings.base_dir) / rel_path
  641. abs_path.parent.mkdir(parents=True, exist_ok=True)
  642. abs_path.write_bytes(b"probe")
  643. written.append(abs_path)
  644. kwargs.setdefault("filename", f"queue_probe_{counter}.gcode.3mf")
  645. return await library_file_factory(file_path=rel_path, **kwargs)
  646. yield _create
  647. for path in written:
  648. path.unlink(missing_ok=True)
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
  652. """Nothing queued is not a success (#3112).
  653. This used to assert 200: the caller got an OK for a call that created
  654. nothing, with the reason in a body it had no cause to read. The reason
  655. is still reported, now where a failed call puts it.
  656. """
  657. await printer_factory()
  658. data = {"file_ids": [9999]}
  659. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  660. assert response.status_code == 400
  661. errors = response.json()["detail"]["errors"]
  662. assert len(errors) == 1
  663. assert errors[0]["file_id"] == 9999
  664. @pytest.mark.asyncio
  665. @pytest.mark.integration
  666. async def test_add_non_sliced_file_to_queue_fails(
  667. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  668. ):
  669. """Verify non-sliced file cannot be added to queue."""
  670. await printer_factory()
  671. lib_file = await library_file_factory(
  672. filename="model.stl",
  673. file_path="/test/path/model.stl",
  674. file_type="stl",
  675. )
  676. data = {"file_ids": [lib_file.id]}
  677. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  678. assert response.status_code == 400
  679. errors = response.json()["detail"]["errors"]
  680. assert len(errors) == 1
  681. assert "sliced" in errors[0]["error"].lower()
  682. @pytest.mark.asyncio
  683. @pytest.mark.integration
  684. async def test_partial_success_still_returns_200(
  685. self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
  686. ):
  687. """Items really were created, so the call succeeded (#3112).
  688. The per-file errors ride along with them, which is the whole point of a
  689. bulk endpoint. Only a call that produced nothing is a failed call.
  690. """
  691. await printer_factory()
  692. good = await on_disk_file_factory()
  693. bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
  694. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
  695. assert response.status_code == 200
  696. result = response.json()
  697. assert [a["file_id"] for a in result["added"]] == [good.id]
  698. assert [e["file_id"] for e in result["errors"]] == [bad.id]
  699. @pytest.mark.asyncio
  700. @pytest.mark.integration
  701. async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
  702. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  703. ):
  704. """#3112: an item with no printer and no target model is inert.
  705. The scheduler dispatches on `item.printer_id` or on
  706. `item.target_model or item.variants`; a row with neither matches no
  707. branch and waits forever. With an active X1C present, a file that says
  708. it was sliced for one is aimed at it.
  709. """
  710. await printer_factory(model="X1C")
  711. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  712. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  713. assert response.status_code == 200
  714. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  715. assert item.printer_id is None
  716. assert item.target_model == "X1C"
  717. @pytest.mark.asyncio
  718. @pytest.mark.integration
  719. async def test_no_printer_of_that_model_leaves_the_item_unassigned(
  720. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  721. ):
  722. """Aiming an item at hardware nobody owns would only look like progress.
  723. Having no H2D is the user's situation, not their mistake, so the file
  724. is still queued -- as the unassigned row it has always been.
  725. """
  726. await printer_factory(model="X1C")
  727. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
  728. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  729. assert response.status_code == 200
  730. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  731. assert item.printer_id is None
  732. assert item.target_model is None
  733. @pytest.mark.asyncio
  734. @pytest.mark.integration
  735. async def test_explicit_printer_wins_over_the_files_own_model(
  736. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  737. ):
  738. printer = await printer_factory(model="X1C")
  739. # Read before the queue row is re-read: that expires the session, and
  740. # a lazy refresh of this row would then happen outside the greenlet.
  741. printer_id = printer.id
  742. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  743. response = await async_client.post(
  744. "/api/v1/library/files/add-to-queue",
  745. json={"file_ids": [lib_file.id], "printer_id": printer_id},
  746. )
  747. assert response.status_code == 200
  748. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  749. assert item.printer_id == printer_id
  750. assert item.target_model is None
  751. @pytest.mark.asyncio
  752. @pytest.mark.integration
  753. async def test_incompatible_target_model_is_refused_per_file(
  754. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  755. ):
  756. """The same cross-model gate POST /queue/ applies (#2578).
  757. The scheduler hands model-based items to hardware with no human in the
  758. loop, so a file sliced for one model must not be aimed at another.
  759. """
  760. await printer_factory(model="A1")
  761. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  762. response = await async_client.post(
  763. "/api/v1/library/files/add-to-queue",
  764. json={"file_ids": [lib_file.id], "target_model": "A1"},
  765. )
  766. assert response.status_code == 400
  767. assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
  768. @pytest.mark.asyncio
  769. @pytest.mark.integration
  770. async def test_target_model_without_an_active_printer_is_refused(
  771. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  772. ):
  773. await printer_factory(model="X1C")
  774. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
  775. response = await async_client.post(
  776. "/api/v1/library/files/add-to-queue",
  777. json={"file_ids": [lib_file.id], "target_model": "H2D"},
  778. )
  779. assert response.status_code == 400
  780. assert "No active printers" in response.json()["detail"]
  781. @pytest.mark.asyncio
  782. @pytest.mark.integration
  783. async def test_printer_and_target_model_together_are_refused(
  784. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  785. ):
  786. printer = await printer_factory(model="X1C")
  787. lib_file = await on_disk_file_factory()
  788. response = await async_client.post(
  789. "/api/v1/library/files/add-to-queue",
  790. json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
  791. )
  792. assert response.status_code == 400
  793. assert "both" in response.json()["detail"].lower()
  794. @pytest.mark.asyncio
  795. @pytest.mark.integration
  796. async def test_unknown_printer_is_refused(
  797. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  798. ):
  799. await printer_factory(model="X1C")
  800. lib_file = await on_disk_file_factory()
  801. response = await async_client.post(
  802. "/api/v1/library/files/add-to-queue",
  803. json={"file_ids": [lib_file.id], "printer_id": 999999},
  804. )
  805. assert response.status_code == 400
  806. assert response.json()["detail"] == "Printer not found"
  807. @pytest.mark.asyncio
  808. @pytest.mark.integration
  809. async def test_a_filename_the_printer_cannot_store_is_refused(
  810. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  811. ):
  812. """The Bambu SD card is FAT32; an illegal character 553s at upload.
  813. POST /queue/ has rejected these at queue time since #1540. This route
  814. did not, so the mistake surfaced as a print that failed later.
  815. """
  816. await printer_factory(model="X1C")
  817. lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
  818. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  819. assert response.status_code == 400
  820. assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
  821. class TestLibraryZipExtractAPI:
  822. """Integration tests for ZIP extraction endpoint."""
  823. @pytest.mark.asyncio
  824. @pytest.mark.integration
  825. async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
  826. """Verify non-ZIP files are rejected."""
  827. # Create a fake file that's not a ZIP
  828. files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
  829. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  830. assert response.status_code == 400
  831. assert "ZIP" in response.json()["detail"]
  832. @pytest.mark.asyncio
  833. @pytest.mark.integration
  834. async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
  835. """Verify basic ZIP extraction works."""
  836. import io
  837. # Create a simple ZIP file in memory
  838. zip_buffer = io.BytesIO()
  839. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  840. zf.writestr("test1.txt", "Content of file 1")
  841. zf.writestr("test2.txt", "Content of file 2")
  842. zip_buffer.seek(0)
  843. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  844. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  845. assert response.status_code == 200
  846. result = response.json()
  847. assert result["extracted"] == 2
  848. assert len(result["files"]) == 2
  849. assert len(result["errors"]) == 0
  850. @pytest.mark.asyncio
  851. @pytest.mark.integration
  852. async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
  853. """Verify ZIP extraction preserves folder structure."""
  854. import io
  855. # Create a ZIP file with folder structure
  856. zip_buffer = io.BytesIO()
  857. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  858. zf.writestr("folder1/file1.txt", "Content 1")
  859. zf.writestr("folder1/subfolder/file2.txt", "Content 2")
  860. zf.writestr("folder2/file3.txt", "Content 3")
  861. zip_buffer.seek(0)
  862. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  863. params = {"preserve_structure": "true"}
  864. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  865. assert response.status_code == 200
  866. result = response.json()
  867. assert result["extracted"] == 3
  868. assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
  869. @pytest.mark.asyncio
  870. @pytest.mark.integration
  871. async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
  872. """Verify ZIP extraction can extract flat (no folders)."""
  873. import io
  874. # Create a ZIP file with folder structure
  875. zip_buffer = io.BytesIO()
  876. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  877. zf.writestr("folder/file1.txt", "Content 1")
  878. zf.writestr("folder/file2.txt", "Content 2")
  879. zip_buffer.seek(0)
  880. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  881. params = {"preserve_structure": "false"}
  882. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  883. assert response.status_code == 200
  884. result = response.json()
  885. assert result["extracted"] == 2
  886. assert result["folders_created"] == 0 # No folders created when flat
  887. @pytest.mark.asyncio
  888. @pytest.mark.integration
  889. async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
  890. """Verify ZIP extraction skips __MACOSX and hidden files."""
  891. import io
  892. # Create a ZIP file with macOS junk files
  893. zip_buffer = io.BytesIO()
  894. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  895. zf.writestr("real_file.txt", "Real content")
  896. zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
  897. zf.writestr(".hidden_file", "Hidden content")
  898. zip_buffer.seek(0)
  899. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  900. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  901. assert response.status_code == 200
  902. result = response.json()
  903. assert result["extracted"] == 1 # Only real_file.txt
  904. assert result["files"][0]["filename"] == "real_file.txt"
  905. @pytest.mark.asyncio
  906. @pytest.mark.integration
  907. async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
  908. """Verify ZIP extraction creates a folder from the ZIP filename."""
  909. import io
  910. # Create a ZIP file with some files
  911. zip_buffer = io.BytesIO()
  912. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  913. zf.writestr("file1.txt", "Content 1")
  914. zf.writestr("file2.txt", "Content 2")
  915. zip_buffer.seek(0)
  916. files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
  917. params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
  918. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  919. assert response.status_code == 200
  920. result = response.json()
  921. assert result["extracted"] == 2
  922. assert result["folders_created"] == 1 # MyProject folder created
  923. # Verify the files are in a folder
  924. assert result["files"][0]["folder_id"] is not None
  925. assert result["files"][1]["folder_id"] is not None
  926. # Both files should be in the same folder
  927. assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
  928. # Verify the folder was created with the right name
  929. folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
  930. assert folder_response.status_code == 200
  931. folder = folder_response.json()
  932. assert folder["name"] == "MyProject"
  933. class TestLibraryStlThumbnailAPI:
  934. """Integration tests for STL thumbnail generation endpoints."""
  935. @pytest.fixture
  936. async def file_factory(self, db_session):
  937. """Factory to create test files."""
  938. _counter = [0]
  939. async def _create_file(**kwargs):
  940. from backend.app.models.library import LibraryFile
  941. _counter[0] += 1
  942. counter = _counter[0]
  943. defaults = {
  944. "filename": f"test_model_{counter}.stl",
  945. "file_path": f"/test/path/test_model_{counter}.stl",
  946. "file_size": 1024,
  947. "file_type": "stl",
  948. }
  949. defaults.update(kwargs)
  950. lib_file = LibraryFile(**defaults)
  951. db_session.add(lib_file)
  952. await db_session.commit()
  953. await db_session.refresh(lib_file)
  954. return lib_file
  955. return _create_file
  956. @pytest.mark.asyncio
  957. @pytest.mark.integration
  958. async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
  959. """Verify batch thumbnail generation with no files."""
  960. data = {"all_missing": True}
  961. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  962. assert response.status_code == 200
  963. result = response.json()
  964. assert result["processed"] == 0
  965. assert result["succeeded"] == 0
  966. assert result["failed"] == 0
  967. assert result["results"] == []
  968. @pytest.mark.asyncio
  969. @pytest.mark.integration
  970. async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
  971. """Verify batch thumbnail generation with no criteria returns empty."""
  972. data = {}
  973. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  974. assert response.status_code == 200
  975. result = response.json()
  976. assert result["processed"] == 0
  977. @pytest.mark.asyncio
  978. @pytest.mark.integration
  979. async def test_batch_generate_thumbnails_file_not_on_disk(
  980. self, async_client: AsyncClient, file_factory, db_session
  981. ):
  982. """Verify batch thumbnail generation handles missing files gracefully."""
  983. # Create a file in DB but not on disk
  984. stl_file = await file_factory(
  985. filename="missing.stl",
  986. file_path="/nonexistent/path/missing.stl",
  987. thumbnail_path=None,
  988. )
  989. data = {"file_ids": [stl_file.id]}
  990. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  991. assert response.status_code == 200
  992. result = response.json()
  993. assert result["processed"] == 1
  994. assert result["succeeded"] == 0
  995. assert result["failed"] == 1
  996. assert result["results"][0]["success"] is False
  997. assert "not found" in result["results"][0]["error"].lower()
  998. @pytest.mark.asyncio
  999. @pytest.mark.integration
  1000. async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
  1001. """Verify batch thumbnail generation with a real STL file."""
  1002. from backend.app.models.library import LibraryFile
  1003. # Create a simple ASCII STL cube
  1004. stl_content = """solid cube
  1005. facet normal 0 0 -1
  1006. outer loop
  1007. vertex 0 0 0
  1008. vertex 1 0 0
  1009. vertex 1 1 0
  1010. endloop
  1011. endfacet
  1012. facet normal 0 0 1
  1013. outer loop
  1014. vertex 0 0 1
  1015. vertex 1 1 1
  1016. vertex 1 0 1
  1017. endloop
  1018. endfacet
  1019. endsolid cube"""
  1020. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
  1021. f.write(stl_content)
  1022. stl_path = f.name
  1023. try:
  1024. # Create file in DB pointing to real STL
  1025. lib_file = LibraryFile(
  1026. filename="test_cube.stl",
  1027. file_path=stl_path,
  1028. file_size=len(stl_content),
  1029. file_type="stl",
  1030. thumbnail_path=None,
  1031. )
  1032. db_session.add(lib_file)
  1033. await db_session.commit()
  1034. await db_session.refresh(lib_file)
  1035. data = {"file_ids": [lib_file.id]}
  1036. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1037. assert response.status_code == 200
  1038. result = response.json()
  1039. assert result["processed"] == 1
  1040. # Result depends on whether trimesh/matplotlib are installed
  1041. # Either succeeds or fails gracefully
  1042. assert result["succeeded"] + result["failed"] == 1
  1043. finally:
  1044. import os
  1045. if os.path.exists(stl_path):
  1046. os.unlink(stl_path)
  1047. @pytest.mark.asyncio
  1048. @pytest.mark.integration
  1049. async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  1050. """Verify file upload accepts generate_stl_thumbnails parameter."""
  1051. # Create a simple STL file
  1052. stl_content = b"solid test\nendsolid test"
  1053. files = {"file": ("test.stl", stl_content, "application/octet-stream")}
  1054. params = {"generate_stl_thumbnails": "false"}
  1055. response = await async_client.post("/api/v1/library/files", files=files, params=params)
  1056. assert response.status_code == 200
  1057. result = response.json()
  1058. assert result["filename"] == "test.stl"
  1059. assert result["file_type"] == "stl"
  1060. # No thumbnail should be generated when disabled
  1061. assert result["thumbnail_path"] is None
  1062. @pytest.mark.asyncio
  1063. @pytest.mark.integration
  1064. async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
  1065. """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
  1066. from PIL import Image
  1067. buffer = io.BytesIO()
  1068. Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
  1069. files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
  1070. response = await async_client.post("/api/v1/library/files", files=files)
  1071. assert response.status_code == 200
  1072. result = response.json()
  1073. assert result["file_type"] == "png"
  1074. assert result["thumbnail_path"]
  1075. from backend.app.api.routes.library import to_absolute_path
  1076. thumb = to_absolute_path(result["thumbnail_path"])
  1077. assert thumb is not None and thumb.exists()
  1078. with Image.open(thumb) as thumbnail:
  1079. assert thumbnail.format == "PNG"
  1080. assert max(thumbnail.size) <= 256
  1081. @pytest.mark.asyncio
  1082. @pytest.mark.integration
  1083. async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  1084. """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
  1085. # Create a ZIP file containing an STL
  1086. stl_content = b"solid test\nendsolid test"
  1087. zip_buffer = io.BytesIO()
  1088. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1089. zf.writestr("model.stl", stl_content)
  1090. zip_buffer.seek(0)
  1091. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  1092. params = {"generate_stl_thumbnails": "false"}
  1093. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  1094. assert response.status_code == 200
  1095. result = response.json()
  1096. assert result["extracted"] == 1
  1097. assert result["files"][0]["filename"] == "model.stl"
  1098. @pytest.mark.asyncio
  1099. @pytest.mark.integration
  1100. async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
  1101. """Verify batch thumbnail generation can filter by folder."""
  1102. from backend.app.models.library import LibraryFolder
  1103. # Create a folder
  1104. folder = LibraryFolder(name="STL Folder")
  1105. db_session.add(folder)
  1106. await db_session.commit()
  1107. await db_session.refresh(folder)
  1108. # Create STL file in folder (no thumbnail)
  1109. stl_in_folder = await file_factory(
  1110. filename="in_folder.stl",
  1111. folder_id=folder.id,
  1112. thumbnail_path=None,
  1113. )
  1114. # Create STL file at root (no thumbnail)
  1115. _stl_at_root = await file_factory(
  1116. filename="at_root.stl",
  1117. folder_id=None,
  1118. thumbnail_path=None,
  1119. )
  1120. # Request thumbnails only for files in folder
  1121. data = {"folder_id": folder.id, "all_missing": True}
  1122. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1123. assert response.status_code == 200
  1124. result = response.json()
  1125. # Should only process the file in the folder
  1126. assert result["processed"] == 1
  1127. assert result["results"][0]["file_id"] == stl_in_folder.id
  1128. @pytest.mark.asyncio
  1129. @pytest.mark.integration
  1130. async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
  1131. """Verify batch thumbnail generation finds all STL files missing thumbnails."""
  1132. # Create files with and without thumbnails
  1133. _stl_with_thumb = await file_factory(
  1134. filename="with_thumb.stl",
  1135. thumbnail_path="/some/path/thumb.png",
  1136. )
  1137. stl_without_thumb1 = await file_factory(
  1138. filename="without_thumb1.stl",
  1139. thumbnail_path=None,
  1140. )
  1141. stl_without_thumb2 = await file_factory(
  1142. filename="without_thumb2.stl",
  1143. thumbnail_path=None,
  1144. )
  1145. data = {"all_missing": True}
  1146. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1147. assert response.status_code == 200
  1148. result = response.json()
  1149. # Should only process files without thumbnails
  1150. assert result["processed"] == 2
  1151. file_ids = {r["file_id"] for r in result["results"]}
  1152. assert stl_without_thumb1.id in file_ids
  1153. assert stl_without_thumb2.id in file_ids
  1154. class TestLibraryPathHelpers:
  1155. """Tests for path handling utilities used for backup portability."""
  1156. def test_to_relative_path_converts_absolute(self):
  1157. """Verify absolute paths are converted to relative paths."""
  1158. from backend.app.api.routes.library import to_relative_path
  1159. from backend.app.core.config import settings
  1160. base_dir = str(settings.base_dir)
  1161. abs_path = f"{base_dir}/archive/library/files/test.3mf"
  1162. rel_path = to_relative_path(abs_path)
  1163. assert not rel_path.startswith("/")
  1164. assert rel_path == "archive/library/files/test.3mf"
  1165. def test_to_relative_path_handles_path_object(self):
  1166. """Verify Path objects are handled correctly."""
  1167. from pathlib import Path
  1168. from backend.app.api.routes.library import to_relative_path
  1169. from backend.app.core.config import settings
  1170. abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
  1171. rel_path = to_relative_path(abs_path)
  1172. assert not rel_path.startswith("/")
  1173. assert rel_path == "archive/test.3mf"
  1174. def test_to_relative_path_returns_empty_for_empty_input(self):
  1175. """Verify empty input returns empty string."""
  1176. from backend.app.api.routes.library import to_relative_path
  1177. assert to_relative_path("") == ""
  1178. assert to_relative_path(None) == ""
  1179. def test_to_absolute_path_converts_relative(self):
  1180. """Verify relative paths are converted to absolute paths."""
  1181. from backend.app.api.routes.library import to_absolute_path
  1182. from backend.app.core.config import settings
  1183. rel_path = "archive/library/files/test.3mf"
  1184. abs_path = to_absolute_path(rel_path)
  1185. assert abs_path is not None
  1186. assert abs_path.is_absolute()
  1187. assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
  1188. def test_to_absolute_path_handles_already_absolute(self):
  1189. """Verify already absolute paths are returned as-is (for backwards compatibility)."""
  1190. from backend.app.api.routes.library import to_absolute_path
  1191. abs_path_str = "/data/archive/test.3mf"
  1192. result = to_absolute_path(abs_path_str)
  1193. assert result is not None
  1194. assert str(result) == abs_path_str
  1195. def test_to_absolute_path_returns_none_for_empty(self):
  1196. """Verify None/empty input returns None."""
  1197. from backend.app.api.routes.library import to_absolute_path
  1198. assert to_absolute_path(None) is None
  1199. assert to_absolute_path("") is None
  1200. class TestLibraryPermissions:
  1201. """Tests for library permission enforcement."""
  1202. @pytest.fixture
  1203. async def auth_setup(self, db_session):
  1204. """Set up auth with users of different permission levels."""
  1205. from backend.app.core.auth import create_access_token, get_password_hash
  1206. from backend.app.models.group import Group
  1207. from backend.app.models.settings import Settings
  1208. from backend.app.models.user import User
  1209. # Enable auth
  1210. settings = Settings(key="auth_enabled", value="true")
  1211. db_session.add(settings)
  1212. await db_session.commit()
  1213. # Groups are auto-seeded during db init, but we need to commit them
  1214. await db_session.commit()
  1215. # Get groups
  1216. from sqlalchemy import select
  1217. admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
  1218. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  1219. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  1220. password_hash = get_password_hash("password")
  1221. # Create users
  1222. admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
  1223. admin_user.groups.append(admin_group)
  1224. operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
  1225. operator_user.groups.append(operator_group)
  1226. viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
  1227. viewer_user.groups.append(viewer_group)
  1228. db_session.add_all([admin_user, operator_user, viewer_user])
  1229. await db_session.commit()
  1230. # Create tokens
  1231. admin_token = create_access_token(data={"sub": admin_user.username})
  1232. operator_token = create_access_token(data={"sub": operator_user.username})
  1233. viewer_token = create_access_token(data={"sub": viewer_user.username})
  1234. return {
  1235. "admin_user": admin_user,
  1236. "operator_user": operator_user,
  1237. "viewer_user": viewer_user,
  1238. "admin_token": admin_token,
  1239. "operator_token": operator_token,
  1240. "viewer_token": viewer_token,
  1241. }
  1242. @pytest.fixture
  1243. async def test_file(self, db_session, auth_setup):
  1244. """Create a test file owned by the operator user."""
  1245. from backend.app.models.library import LibraryFile
  1246. operator_user = auth_setup["operator_user"]
  1247. lib_file = LibraryFile(
  1248. filename="test.txt",
  1249. file_path="data/archive/library/files/test.txt",
  1250. file_type="txt",
  1251. file_size=100,
  1252. created_by_id=operator_user.id,
  1253. )
  1254. db_session.add(lib_file)
  1255. await db_session.commit()
  1256. await db_session.refresh(lib_file)
  1257. return lib_file
  1258. @pytest.mark.asyncio
  1259. @pytest.mark.integration
  1260. async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
  1261. """Verify list_files requires library:read permission."""
  1262. viewer_token = auth_setup["viewer_token"]
  1263. # Viewers have library:read, should succeed
  1264. response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
  1265. assert response.status_code == 200
  1266. @pytest.mark.asyncio
  1267. @pytest.mark.integration
  1268. async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
  1269. """Verify list_files denied without auth when auth is enabled."""
  1270. from backend.app.models.settings import Settings
  1271. # Enable auth
  1272. settings = Settings(key="auth_enabled", value="true")
  1273. db_session.add(settings)
  1274. await db_session.commit()
  1275. # Request without token should fail
  1276. response = await async_client.get("/api/v1/library/files")
  1277. assert response.status_code == 401
  1278. @pytest.mark.asyncio
  1279. @pytest.mark.integration
  1280. async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  1281. """Verify operator can delete their own files."""
  1282. from pathlib import Path
  1283. # Create actual file on disk so delete doesn't fail
  1284. from backend.app.core.config import settings as app_settings
  1285. file_path = Path(app_settings.base_dir) / test_file.file_path
  1286. file_path.parent.mkdir(parents=True, exist_ok=True)
  1287. file_path.write_text("test content")
  1288. operator_token = auth_setup["operator_token"]
  1289. response = await async_client.delete(
  1290. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  1291. )
  1292. assert response.status_code == 200
  1293. @pytest.mark.asyncio
  1294. @pytest.mark.integration
  1295. async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
  1296. """Verify operator cannot delete files owned by others."""
  1297. # Create another operator user with a file
  1298. from sqlalchemy import select
  1299. from backend.app.core.auth import create_access_token
  1300. from backend.app.models.group import Group
  1301. from backend.app.models.library import LibraryFile
  1302. from backend.app.models.user import User
  1303. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  1304. from backend.app.core.auth import get_password_hash as get_pw_hash
  1305. other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
  1306. other_user.groups.append(operator_group)
  1307. db_session.add(other_user)
  1308. await db_session.commit()
  1309. await db_session.refresh(other_user)
  1310. # Create file owned by other user
  1311. other_file = LibraryFile(
  1312. filename="other.txt",
  1313. file_path="data/archive/library/files/other.txt",
  1314. file_type="txt",
  1315. file_size=100,
  1316. created_by_id=other_user.id,
  1317. )
  1318. db_session.add(other_file)
  1319. await db_session.commit()
  1320. await db_session.refresh(other_file)
  1321. # Original operator should not be able to delete it
  1322. operator_token = auth_setup["operator_token"]
  1323. response = await async_client.delete(
  1324. f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  1325. )
  1326. assert response.status_code == 403
  1327. assert "your own files" in response.json()["detail"].lower()
  1328. @pytest.mark.asyncio
  1329. @pytest.mark.integration
  1330. async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
  1331. """Verify admin can delete any file."""
  1332. from pathlib import Path
  1333. from backend.app.core.config import settings as app_settings
  1334. from backend.app.models.library import LibraryFile
  1335. # Create file owned by operator
  1336. operator_user = auth_setup["operator_user"]
  1337. lib_file = LibraryFile(
  1338. filename="admin_can_delete.txt",
  1339. file_path="data/archive/library/files/admin_can_delete.txt",
  1340. file_type="txt",
  1341. file_size=100,
  1342. created_by_id=operator_user.id,
  1343. )
  1344. db_session.add(lib_file)
  1345. await db_session.commit()
  1346. await db_session.refresh(lib_file)
  1347. # Create actual file on disk
  1348. file_path = Path(app_settings.base_dir) / lib_file.file_path
  1349. file_path.parent.mkdir(parents=True, exist_ok=True)
  1350. file_path.write_text("test content")
  1351. # Admin should be able to delete it
  1352. admin_token = auth_setup["admin_token"]
  1353. response = await async_client.delete(
  1354. f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
  1355. )
  1356. assert response.status_code == 200
  1357. @pytest.mark.asyncio
  1358. @pytest.mark.integration
  1359. async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  1360. """Verify viewer cannot delete any files."""
  1361. viewer_token = auth_setup["viewer_token"]
  1362. response = await async_client.delete(
  1363. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
  1364. )
  1365. # Viewers don't have delete_own or delete_all permissions
  1366. assert response.status_code == 403
  1367. # ---------- #1832: API-key curation under can_manage_library ----------
  1368. #
  1369. # require_ownership_permission gates API keys on `all_perm`, but the
  1370. # library deliberately split UPDATE_OWN/DELETE_OWN (allowed under
  1371. # can_manage_library) from UPDATE_ALL/DELETE_ALL (previously denied).
  1372. # That made the entire curation surface (DELETE, PUT rename, POST move)
  1373. # unreachable for API keys, including for files the key's owner uploaded.
  1374. # The fix folds UPDATE_ALL/DELETE_ALL into can_manage_library so the
  1375. # checker passes; LIBRARY_PURGE stays admin-only.
  1376. @pytest.fixture
  1377. async def manage_library_key(self, db_session, auth_setup):
  1378. """Mint an API key owned by the admin user with can_manage_library."""
  1379. from backend.app.core.auth import generate_api_key
  1380. from backend.app.models.api_key import APIKey
  1381. admin = auth_setup["admin_user"]
  1382. full_key, key_hash, key_prefix = generate_api_key()
  1383. row = APIKey(
  1384. name="lib-curation",
  1385. key_hash=key_hash,
  1386. key_prefix=key_prefix,
  1387. user_id=admin.id,
  1388. can_manage_library=True,
  1389. )
  1390. db_session.add(row)
  1391. await db_session.commit()
  1392. return full_key
  1393. @pytest.mark.asyncio
  1394. @pytest.mark.integration
  1395. async def test_apikey_with_manage_library_can_delete_file(
  1396. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1397. ):
  1398. """Pre-#1832 this 403'd with "administrative operations" because
  1399. LIBRARY_DELETE_ALL wasn't in _APIKEY_SCOPE_BY_PERMISSION."""
  1400. from pathlib import Path
  1401. from backend.app.core.config import settings as app_settings
  1402. # Materialise the file on disk so the delete handler doesn't 500 on
  1403. # the path it tries to unlink.
  1404. file_path = Path(app_settings.base_dir) / test_file.file_path
  1405. file_path.parent.mkdir(parents=True, exist_ok=True)
  1406. file_path.write_text("test content")
  1407. response = await async_client.delete(
  1408. f"/api/v1/library/files/{test_file.id}",
  1409. headers={"X-API-Key": manage_library_key},
  1410. )
  1411. assert response.status_code == 200, response.text
  1412. @pytest.mark.asyncio
  1413. @pytest.mark.integration
  1414. async def test_apikey_with_manage_library_can_rename_file(
  1415. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1416. ):
  1417. """PUT /library/files/{id} is gated on LIBRARY_UPDATE_ALL/OWN. Same
  1418. #1832 path as delete."""
  1419. response = await async_client.put(
  1420. f"/api/v1/library/files/{test_file.id}",
  1421. headers={"X-API-Key": manage_library_key},
  1422. json={"filename": "renamed.txt"},
  1423. )
  1424. assert response.status_code == 200, response.text
  1425. assert response.json()["filename"] == "renamed.txt"
  1426. @pytest.mark.asyncio
  1427. @pytest.mark.integration
  1428. async def test_apikey_with_manage_library_can_move_file(
  1429. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1430. ):
  1431. """POST /library/files/move (bulk) is gated on LIBRARY_UPDATE_ALL/OWN
  1432. — same checker, same #1832 path."""
  1433. # Create a target folder the move can land in.
  1434. from backend.app.models.library import LibraryFolder
  1435. folder = LibraryFolder(name="target")
  1436. db_session.add(folder)
  1437. await db_session.commit()
  1438. await db_session.refresh(folder)
  1439. response = await async_client.post(
  1440. "/api/v1/library/files/move",
  1441. headers={"X-API-Key": manage_library_key},
  1442. json={"file_ids": [test_file.id], "folder_id": folder.id},
  1443. )
  1444. assert response.status_code == 200, response.text
  1445. @pytest.mark.asyncio
  1446. @pytest.mark.integration
  1447. async def test_apikey_without_manage_library_still_blocked(
  1448. self, async_client: AsyncClient, db_session, auth_setup, test_file
  1449. ):
  1450. """Regression guard: a key WITHOUT can_manage_library must still get
  1451. 403 — the fix widens the allowed-permission set, it doesn't bypass
  1452. the per-key scope check."""
  1453. from backend.app.core.auth import generate_api_key
  1454. from backend.app.models.api_key import APIKey
  1455. admin = auth_setup["admin_user"]
  1456. full_key, key_hash, key_prefix = generate_api_key()
  1457. row = APIKey(
  1458. name="read-only",
  1459. key_hash=key_hash,
  1460. key_prefix=key_prefix,
  1461. user_id=admin.id,
  1462. can_read_status=True,
  1463. can_manage_library=False,
  1464. )
  1465. db_session.add(row)
  1466. await db_session.commit()
  1467. response = await async_client.delete(
  1468. f"/api/v1/library/files/{test_file.id}",
  1469. headers={"X-API-Key": full_key},
  1470. )
  1471. assert response.status_code == 403
  1472. @pytest.mark.asyncio
  1473. @pytest.mark.integration
  1474. async def test_apikey_with_manage_library_still_cannot_purge(
  1475. self, async_client: AsyncClient, db_session, auth_setup, manage_library_key
  1476. ):
  1477. """LIBRARY_PURGE deliberately stays in _APIKEY_DENIED_PERMISSIONS as
  1478. a genuinely destructive op that bypasses the soft-delete window.
  1479. can_manage_library does NOT grant it."""
  1480. response = await async_client.post(
  1481. "/api/v1/library/purge",
  1482. headers={"X-API-Key": manage_library_key},
  1483. json={"days_in_trash": 30},
  1484. )
  1485. assert response.status_code == 403
  1486. class TestPrintFileUploadValidation:
  1487. """#1401: pre-flight rejection of unprintable uploads at the library +
  1488. archive routes. Smoke tests the shared ``validate_print_file_upload``
  1489. helper through both surfaces a user can reach with a drag-drop."""
  1490. def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
  1491. """Build a minimal-but-real zip with the gcode-3mf magic in it so
  1492. the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
  1493. buf = io.BytesIO()
  1494. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1495. zf.writestr(name, "; G-code\nG28\n")
  1496. return buf.getvalue()
  1497. @pytest.mark.asyncio
  1498. @pytest.mark.integration
  1499. async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
  1500. """``Foo.gcode`` direct uploads are blocked at the library route —
  1501. the dispatcher would otherwise append ``.3mf`` and ship raw gcode
  1502. to the printer as a fake 3MF."""
  1503. files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
  1504. response = await async_client.post("/api/v1/library/files", files=files)
  1505. assert response.status_code == 400
  1506. # Error message must name the actual remedy, not just say "invalid".
  1507. assert "gcode.3mf" in response.json()["detail"]
  1508. @pytest.mark.asyncio
  1509. @pytest.mark.integration
  1510. async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
  1511. """A ``.3mf`` upload whose body isn't a zip is rejected — covers
  1512. raw gcode renamed to .3mf, corrupted downloads, etc."""
  1513. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1514. response = await async_client.post("/api/v1/library/files", files=files)
  1515. assert response.status_code == 400
  1516. assert "ZIP container" in response.json()["detail"]
  1517. @pytest.mark.asyncio
  1518. @pytest.mark.integration
  1519. async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1520. """The compound-extension ``.gcode.3mf`` case is gated by the same
  1521. zip-magic check — splitext returns just ``.3mf``, but the suffix
  1522. match covers both."""
  1523. files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1524. response = await async_client.post("/api/v1/library/files", files=files)
  1525. assert response.status_code == 400
  1526. assert "ZIP container" in response.json()["detail"]
  1527. @pytest.mark.asyncio
  1528. @pytest.mark.integration
  1529. async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1530. """A real ``.gcode.3mf`` zip uploads successfully — the existing
  1531. happy path is not regressed by the new validation."""
  1532. files = {
  1533. "file": (
  1534. "plate_1.gcode.3mf",
  1535. self._valid_3mf_bytes(),
  1536. "application/zip",
  1537. )
  1538. }
  1539. response = await async_client.post("/api/v1/library/files", files=files)
  1540. assert response.status_code == 200
  1541. result = response.json()
  1542. assert result["filename"] == "plate_1.gcode.3mf"
  1543. @pytest.mark.asyncio
  1544. @pytest.mark.integration
  1545. async def test_library_upload_classifies_gcode_3mf_as_compound(self, async_client: AsyncClient, db_session):
  1546. """#1600 follow-up: upload path used to strip to the trailing
  1547. extension and store ``file_type='3mf'`` for sliced outputs, while
  1548. the external-folder scan stored ``file_type='gcode.3mf'``. Now
  1549. every ingest path goes through ``classify_file_type`` and
  1550. produces the canonical compound name."""
  1551. files = {
  1552. "file": (
  1553. "sliced.gcode.3mf",
  1554. self._valid_3mf_bytes(),
  1555. "application/zip",
  1556. )
  1557. }
  1558. response = await async_client.post("/api/v1/library/files", files=files)
  1559. assert response.status_code == 200
  1560. assert response.json()["file_type"] == "gcode.3mf"
  1561. @pytest.mark.asyncio
  1562. @pytest.mark.integration
  1563. async def test_library_get_gcode_endpoint_accepts_compound_file_type(self, async_client: AsyncClient, db_session):
  1564. """#1600 follow-up: pre-fix, ``GET /files/{id}/gcode`` only handled
  1565. ``file_type`` of ``gcode`` or ``3mf`` and 400'd on a row whose
  1566. ``file_type`` was ``gcode.3mf`` — exactly the rows the external-
  1567. folder scan was creating. The gate now treats both as 3MF and
  1568. unzips the embedded gcode the same way."""
  1569. from backend.app.models.library import LibraryFile
  1570. # Persist a real `.gcode.3mf` zip under file_type='gcode.3mf' so
  1571. # the endpoint hits the new branch.
  1572. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1573. tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
  1574. tmp_path = tmp.name
  1575. lib_file = LibraryFile(
  1576. filename="sliced.gcode.3mf",
  1577. file_path=tmp_path,
  1578. file_type="gcode.3mf",
  1579. file_size=Path(tmp_path).stat().st_size,
  1580. )
  1581. db_session.add(lib_file)
  1582. await db_session.commit()
  1583. await db_session.refresh(lib_file)
  1584. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1585. assert response.status_code == 200
  1586. assert b"G28" in response.content
  1587. @pytest.mark.asyncio
  1588. @pytest.mark.integration
  1589. async def test_library_get_gcode_recovers_legacy_gcode_type_for_3mf(self, async_client: AsyncClient, db_session):
  1590. """#1709 regression guard. Before the fix, ``slice_and_persist``
  1591. wrote a `.gcode.3mf` ZIP container to disk but stored the row with
  1592. ``file_type='gcode'`` — the preview endpoint then streamed the
  1593. ZIP body as ``text/plain`` and the embedded G-code viewer saw
  1594. ``PK\\x03\\x04...`` instead of the toolpath. New sliced rows now
  1595. store ``file_type='gcode.3mf'``; rows already written under the
  1596. bug self-heal because the endpoint also detects the ZIP via the
  1597. ``.gcode.3mf`` filename suffix when the column is still legacy."""
  1598. from backend.app.models.library import LibraryFile
  1599. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1600. tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
  1601. tmp_path = tmp.name
  1602. lib_file = LibraryFile(
  1603. filename="legacy-sliced.gcode.3mf",
  1604. file_path=tmp_path,
  1605. file_type="gcode",
  1606. file_size=Path(tmp_path).stat().st_size,
  1607. )
  1608. db_session.add(lib_file)
  1609. await db_session.commit()
  1610. await db_session.refresh(lib_file)
  1611. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1612. assert response.status_code == 200
  1613. assert response.headers["content-type"].startswith("text/plain")
  1614. assert b"G28" in response.content
  1615. # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer.
  1616. assert not response.content.startswith(b"PK")
  1617. async def _multi_plate_file(self, db_session):
  1618. """A two-plate `.gcode.3mf` written plate 2 first, as Bambu Studio does.
  1619. The member order is copied from the file this was reported on — taking
  1620. the first `.gcode` in the zip opened plate 2.
  1621. """
  1622. from backend.app.models.library import LibraryFile
  1623. buf = io.BytesIO()
  1624. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1625. zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
  1626. zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
  1627. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1628. tmp.write(buf.getvalue())
  1629. tmp_path = tmp.name
  1630. lib_file = LibraryFile(
  1631. filename="two-plates.gcode.3mf",
  1632. file_path=tmp_path,
  1633. file_type="gcode.3mf",
  1634. file_size=Path(tmp_path).stat().st_size,
  1635. )
  1636. db_session.add(lib_file)
  1637. await db_session.commit()
  1638. await db_session.refresh(lib_file)
  1639. return lib_file
  1640. @pytest.mark.asyncio
  1641. @pytest.mark.integration
  1642. async def test_library_gcode_serves_the_requested_plate(self, async_client: AsyncClient, db_session):
  1643. """The viewer has always sent ``?plate=``; this route took no such
  1644. parameter, and FastAPI drops unknown query parameters without a word —
  1645. so picking a plate did nothing at all."""
  1646. lib_file = await self._multi_plate_file(db_session)
  1647. first = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=1")
  1648. second = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=2")
  1649. assert first.status_code == 200
  1650. assert b"plate one" in first.content
  1651. assert second.status_code == 200
  1652. assert b"plate two" in second.content
  1653. @pytest.mark.asyncio
  1654. @pytest.mark.integration
  1655. async def test_library_gcode_without_a_plate_serves_the_first_plate(self, async_client: AsyncClient, db_session):
  1656. """Not the first member in the zip — that is plate 2 in this file, and
  1657. opening a multi-plate file from the File Manager passes no plate."""
  1658. lib_file = await self._multi_plate_file(db_session)
  1659. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1660. assert response.status_code == 200
  1661. assert b"plate one" in response.content
  1662. @pytest.mark.asyncio
  1663. @pytest.mark.integration
  1664. async def test_library_gcode_rejects_a_plate_the_file_does_not_hold(self, async_client: AsyncClient, db_session):
  1665. """404 rather than quietly rendering some other plate."""
  1666. lib_file = await self._multi_plate_file(db_session)
  1667. missing = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=3")
  1668. zeroth = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=0")
  1669. assert missing.status_code == 404
  1670. assert zeroth.status_code == 400
  1671. @pytest.mark.asyncio
  1672. @pytest.mark.integration
  1673. async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
  1674. """STL / image / other non-print uploads bypass the validator
  1675. entirely — Bambuddy is also a library, not just a print dispatcher."""
  1676. files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
  1677. response = await async_client.post(
  1678. "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
  1679. )
  1680. assert response.status_code == 200
  1681. @pytest.mark.asyncio
  1682. @pytest.mark.integration
  1683. async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
  1684. """``POST /archives/upload`` shares the same validator — covers the
  1685. manual archive-upload entry point too."""
  1686. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1687. response = await async_client.post("/api/v1/archives/upload", files=files)
  1688. assert response.status_code == 400
  1689. assert "ZIP container" in response.json()["detail"]
  1690. @pytest.mark.asyncio
  1691. @pytest.mark.integration
  1692. async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
  1693. """The bulk-archive route reports validation failures per file and
  1694. continues processing the remaining items — one bad upload in a
  1695. 10-file drag-drop must not abort the whole batch."""
  1696. good = self._valid_3mf_bytes()
  1697. bad = b"; raw gcode\nG28\n"
  1698. # httpx multipart with a list-of-tuples preserves order + same field name.
  1699. files = [
  1700. ("files", ("good.3mf", good, "application/zip")),
  1701. ("files", ("bad.3mf", bad, "application/octet-stream")),
  1702. ]
  1703. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  1704. assert response.status_code == 200
  1705. body = response.json()
  1706. # The bulk route's archive_print may still reject the "good" file
  1707. # downstream (no printer match, etc.) — we don't care about that
  1708. # here; what matters is the bad file lands in `errors` with the
  1709. # validator's message and the route didn't 500.
  1710. assert body["failed"] >= 1
  1711. bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
  1712. assert bad_errors, body
  1713. assert "ZIP container" in bad_errors[0]["error"]