test_library_api.py 85 KB

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