test_ownership_permissions.py 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144
  1. """Integration tests for ownership-based permission system.
  2. Tests the ownership permission model where users can have:
  3. - *_all permissions: can modify any item
  4. - *_own permissions: can only modify items they created
  5. - Ownerless items (created_by_id = null) require *_all permission
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. class TestOwnershipPermissionsSetup:
  10. """Helper fixture class for ownership permission tests."""
  11. @pytest.fixture
  12. async def auth_setup(self, async_client: AsyncClient):
  13. """Setup auth with admin, create test users with different permission levels."""
  14. # Enable auth with admin user
  15. await async_client.post(
  16. "/api/v1/auth/setup",
  17. json={
  18. "auth_enabled": True,
  19. "admin_username": "ownershipadmin",
  20. "admin_password": "AdminPass1!",
  21. },
  22. )
  23. # Login as admin
  24. admin_login = await async_client.post(
  25. "/api/v1/auth/login",
  26. json={"username": "ownershipadmin", "password": "AdminPass1!"},
  27. )
  28. admin_token = admin_login.json()["access_token"]
  29. admin_user = admin_login.json()["user"]
  30. # Get group IDs
  31. groups_response = await async_client.get(
  32. "/api/v1/groups/",
  33. headers={"Authorization": f"Bearer {admin_token}"},
  34. )
  35. groups = groups_response.json()
  36. operators_group = next(g for g in groups if g["name"] == "Operators")
  37. viewers_group = next(g for g in groups if g["name"] == "Viewers")
  38. # Create operator user (has *_own permissions)
  39. operator_response = await async_client.post(
  40. "/api/v1/users/",
  41. headers={"Authorization": f"Bearer {admin_token}"},
  42. json={
  43. "username": "operator1",
  44. "password": "Operatorpass1!",
  45. "group_ids": [operators_group["id"]],
  46. },
  47. )
  48. operator_user = operator_response.json()
  49. # Login as operator
  50. operator_login = await async_client.post(
  51. "/api/v1/auth/login",
  52. json={"username": "operator1", "password": "Operatorpass1!"},
  53. )
  54. operator_token = operator_login.json()["access_token"]
  55. # Create second operator (for cross-user tests)
  56. operator2_response = await async_client.post(
  57. "/api/v1/users/",
  58. headers={"Authorization": f"Bearer {admin_token}"},
  59. json={
  60. "username": "operator2",
  61. "password": "Operatorpass1!",
  62. "group_ids": [operators_group["id"]],
  63. },
  64. )
  65. operator2_user = operator2_response.json()
  66. operator2_login = await async_client.post(
  67. "/api/v1/auth/login",
  68. json={"username": "operator2", "password": "Operatorpass1!"},
  69. )
  70. operator2_token = operator2_login.json()["access_token"]
  71. # Create viewer user (has no update/delete permissions)
  72. await async_client.post(
  73. "/api/v1/users/",
  74. headers={"Authorization": f"Bearer {admin_token}"},
  75. json={
  76. "username": "viewer1",
  77. "password": "Viewerpass1!",
  78. "group_ids": [viewers_group["id"]],
  79. },
  80. )
  81. viewer_login = await async_client.post(
  82. "/api/v1/auth/login",
  83. json={"username": "viewer1", "password": "Viewerpass1!"},
  84. )
  85. viewer_token = viewer_login.json()["access_token"]
  86. return {
  87. "admin_token": admin_token,
  88. "admin_user": admin_user,
  89. "operator_token": operator_token,
  90. "operator_user": operator_user,
  91. "operator2_token": operator2_token,
  92. "operator2_user": operator2_user,
  93. "viewer_token": viewer_token,
  94. }
  95. class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
  96. """Tests for archive ownership-based permissions."""
  97. # ========================================================================
  98. # DELETE permissions
  99. # ========================================================================
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_admin_can_delete_any_archive(
  103. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  104. ):
  105. """Admin with *_all permissions can delete any archive."""
  106. printer = await printer_factory()
  107. # Create archive owned by operator
  108. archive = await archive_factory(
  109. printer.id,
  110. print_name="Operator Archive",
  111. created_by_id=auth_setup["operator_user"]["id"],
  112. )
  113. # Admin deletes it
  114. response = await async_client.delete(
  115. f"/api/v1/archives/{archive.id}",
  116. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  117. )
  118. assert response.status_code == 200
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_operator_can_delete_own_archive(
  122. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  123. ):
  124. """Operator with *_own permissions can delete their own archive."""
  125. printer = await printer_factory()
  126. archive = await archive_factory(
  127. printer.id,
  128. print_name="My Archive",
  129. created_by_id=auth_setup["operator_user"]["id"],
  130. )
  131. response = await async_client.delete(
  132. f"/api/v1/archives/{archive.id}",
  133. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  134. )
  135. assert response.status_code == 200
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_operator_cannot_delete_others_archive(
  139. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  140. ):
  141. """Operator with *_own permissions cannot delete another user's archive."""
  142. printer = await printer_factory()
  143. # Archive created by operator2
  144. archive = await archive_factory(
  145. printer.id,
  146. print_name="Other's Archive",
  147. created_by_id=auth_setup["operator2_user"]["id"],
  148. )
  149. # operator1 tries to delete it
  150. response = await async_client.delete(
  151. f"/api/v1/archives/{archive.id}",
  152. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  153. )
  154. assert response.status_code == 403
  155. assert "your own" in response.json()["detail"].lower()
  156. @pytest.mark.asyncio
  157. @pytest.mark.integration
  158. async def test_operator_cannot_delete_ownerless_archive(
  159. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  160. ):
  161. """Operator with *_own permissions cannot delete ownerless archive."""
  162. printer = await printer_factory()
  163. # Archive with no owner (legacy data)
  164. archive = await archive_factory(
  165. printer.id,
  166. print_name="Ownerless Archive",
  167. created_by_id=None,
  168. )
  169. response = await async_client.delete(
  170. f"/api/v1/archives/{archive.id}",
  171. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  172. )
  173. assert response.status_code == 403
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_viewer_cannot_delete_archive(
  177. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  178. ):
  179. """Viewer with no delete permissions cannot delete any archive."""
  180. printer = await printer_factory()
  181. archive = await archive_factory(printer.id, print_name="Any Archive")
  182. response = await async_client.delete(
  183. f"/api/v1/archives/{archive.id}",
  184. headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
  185. )
  186. assert response.status_code == 403
  187. # ========================================================================
  188. # UPDATE permissions
  189. # ========================================================================
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_admin_can_update_any_archive(
  193. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  194. ):
  195. """Admin can update any archive."""
  196. printer = await printer_factory()
  197. archive = await archive_factory(
  198. printer.id,
  199. print_name="Original Name",
  200. created_by_id=auth_setup["operator_user"]["id"],
  201. )
  202. response = await async_client.patch(
  203. f"/api/v1/archives/{archive.id}",
  204. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  205. json={"print_name": "Admin Updated"},
  206. )
  207. assert response.status_code == 200
  208. assert response.json()["print_name"] == "Admin Updated"
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_operator_can_update_own_archive(
  212. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  213. ):
  214. """Operator can update their own archive."""
  215. printer = await printer_factory()
  216. archive = await archive_factory(
  217. printer.id,
  218. print_name="Original Name",
  219. created_by_id=auth_setup["operator_user"]["id"],
  220. )
  221. response = await async_client.patch(
  222. f"/api/v1/archives/{archive.id}",
  223. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  224. json={"print_name": "Operator Updated"},
  225. )
  226. assert response.status_code == 200
  227. assert response.json()["print_name"] == "Operator Updated"
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_operator_cannot_update_others_archive(
  231. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  232. ):
  233. """Operator cannot update another user's archive."""
  234. printer = await printer_factory()
  235. archive = await archive_factory(
  236. printer.id,
  237. print_name="Other's Archive",
  238. created_by_id=auth_setup["operator2_user"]["id"],
  239. )
  240. response = await async_client.patch(
  241. f"/api/v1/archives/{archive.id}",
  242. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  243. json={"print_name": "Attempted Update"},
  244. )
  245. assert response.status_code == 403
  246. # ========================================================================
  247. # Legacy reprint endpoint
  248. # ========================================================================
  249. @pytest.mark.asyncio
  250. @pytest.mark.integration
  251. async def test_reprint_endpoint_is_gone_for_all_callers(
  252. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  253. ):
  254. """Direct archive reprint no longer exists; callers must use the queue."""
  255. printer = await printer_factory()
  256. archive = await archive_factory(
  257. printer.id,
  258. created_by_id=auth_setup["operator2_user"]["id"],
  259. )
  260. response = await async_client.post(
  261. f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
  262. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  263. )
  264. assert response.status_code == 410
  265. # ========================================================================
  266. # Queue route — archives:reprint_* gate (#1625)
  267. # ========================================================================
  268. # The unified /queue/ route replaced the legacy /reprint endpoint; the
  269. # reprint permission gate must move with it. Without these checks a
  270. # caller with QUEUE_CREATE + ARCHIVES_READ_OWN could reprint their own
  271. # archives even if explicitly denied ARCHIVES_REPRINT_OWN.
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_queue_route_operator_can_reprint_own_archive(
  275. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  276. ):
  277. """Operator with REPRINT_OWN can queue their own archive."""
  278. printer = await printer_factory()
  279. archive = await archive_factory(
  280. printer.id,
  281. created_by_id=auth_setup["operator_user"]["id"],
  282. )
  283. response = await async_client.post(
  284. "/api/v1/queue/",
  285. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  286. json={"printer_id": printer.id, "archive_id": archive.id},
  287. )
  288. assert response.status_code == 200
  289. @pytest.mark.asyncio
  290. @pytest.mark.integration
  291. async def test_queue_route_user_without_reprint_gets_403(
  292. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  293. ):
  294. """User with QUEUE_CREATE + ARCHIVES_READ_OWN but no reprint perm → 403.
  295. Custom group mirrors a real operator policy where someone is allowed
  296. to enqueue freshly-uploaded library files but explicitly NOT allowed
  297. to re-run completed archives.
  298. """
  299. # Create custom group with queue:create + archives:read_own but no reprint perm.
  300. admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  301. group_resp = await async_client.post(
  302. "/api/v1/groups/",
  303. headers=admin_headers,
  304. json={
  305. "name": "QueueOnlyNoReprint",
  306. "description": "Test group: can queue library files but not reprint",
  307. "permissions": [
  308. "queue:create",
  309. "queue:read_own",
  310. "archives:read_own",
  311. "library:read_own",
  312. "library:upload",
  313. "printers:read",
  314. ],
  315. },
  316. )
  317. assert group_resp.status_code in (200, 201)
  318. group_id = group_resp.json()["id"]
  319. await async_client.post(
  320. "/api/v1/users/",
  321. headers=admin_headers,
  322. json={
  323. "username": "noreprint_user",
  324. "password": "NoreprintPass1!",
  325. "group_ids": [group_id],
  326. },
  327. )
  328. login = await async_client.post(
  329. "/api/v1/auth/login",
  330. json={"username": "noreprint_user", "password": "NoreprintPass1!"},
  331. )
  332. token = login.json()["access_token"]
  333. user_id = login.json()["user"]["id"]
  334. # Archive owned by the no-reprint user.
  335. printer = await printer_factory()
  336. archive = await archive_factory(printer.id, created_by_id=user_id)
  337. response = await async_client.post(
  338. "/api/v1/queue/",
  339. headers={"Authorization": f"Bearer {token}"},
  340. json={"printer_id": printer.id, "archive_id": archive.id},
  341. )
  342. assert response.status_code == 403
  343. assert "reprint" in response.json()["detail"].lower()
  344. @pytest.mark.asyncio
  345. @pytest.mark.integration
  346. async def test_batch_dispatch_allowed_for_own_archive_with_reprint_own(
  347. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  348. ):
  349. """The dispatch gate must not block the ordinary self-service case (#342)."""
  350. headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
  351. printer = await printer_factory()
  352. archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
  353. order = await async_client.post(
  354. "/api/v1/queue/batches",
  355. headers=headers,
  356. json={
  357. "name": "Own order",
  358. "archive_id": archive.id,
  359. "plates": [{"plate_id": 1, "quantity_target": 2}],
  360. },
  361. )
  362. assert order.status_code == 200
  363. batch_id = order.json()["id"]
  364. assert (
  365. await async_client.post(
  366. "/api/v1/queue/",
  367. headers=headers,
  368. json={
  369. "printer_id": printer.id,
  370. "archive_id": archive.id,
  371. "batch_id": batch_id,
  372. "plate_id": 1,
  373. },
  374. )
  375. ).status_code == 200
  376. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=headers, json={})
  377. assert response.status_code == 200
  378. assert response.json()["remaining_count"] == 0
  379. assert response.json()["pending_count"] == 2
  380. @pytest.mark.asyncio
  381. @pytest.mark.integration
  382. async def test_batch_dispatch_honours_the_reprint_gate(
  383. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  384. ):
  385. """Dispatching a batch order must not be a weaker door than POST /queue/ (#342).
  386. Dispatch clones existing queue items, so without the same source-file
  387. gate a caller holding queue:create and queue:update_all — but
  388. explicitly denied archives:reprint_* — could start prints of an
  389. archive that POST /queue/ would have refused them.
  390. """
  391. admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  392. group_resp = await async_client.post(
  393. "/api/v1/groups/",
  394. headers=admin_headers,
  395. json={
  396. "name": "BatchDispatchNoReprint",
  397. "description": "Test group: can manage the queue but not reprint archives",
  398. "permissions": [
  399. "queue:create",
  400. "queue:read_all",
  401. "queue:update_all",
  402. "archives:read_all",
  403. "printers:read",
  404. ],
  405. },
  406. )
  407. assert group_resp.status_code in (200, 201)
  408. await async_client.post(
  409. "/api/v1/users/",
  410. headers=admin_headers,
  411. json={
  412. "username": "batch_noreprint_user",
  413. "password": "BatchNoreprint1!",
  414. "group_ids": [group_resp.json()["id"]],
  415. },
  416. )
  417. login = await async_client.post(
  418. "/api/v1/auth/login",
  419. json={"username": "batch_noreprint_user", "password": "BatchNoreprint1!"},
  420. )
  421. token = login.json()["access_token"]
  422. # Admin builds an order with one dispatched run and two still owed.
  423. printer = await printer_factory()
  424. archive = await archive_factory(printer.id, created_by_id=auth_setup["admin_user"]["id"])
  425. order = await async_client.post(
  426. "/api/v1/queue/batches",
  427. headers=admin_headers,
  428. json={
  429. "name": "Gated order",
  430. "archive_id": archive.id,
  431. "plates": [{"plate_id": 1, "quantity_target": 3}],
  432. },
  433. )
  434. assert order.status_code == 200
  435. batch_id = order.json()["id"]
  436. seeded = await async_client.post(
  437. "/api/v1/queue/",
  438. headers=admin_headers,
  439. json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": batch_id, "plate_id": 1},
  440. )
  441. assert seeded.status_code == 200
  442. response = await async_client.post(
  443. f"/api/v1/queue/batches/{batch_id}/dispatch",
  444. headers={"Authorization": f"Bearer {token}"},
  445. json={},
  446. )
  447. assert response.status_code == 403
  448. assert "reprint" in response.json()["detail"].lower()
  449. # And nothing was queued behind the refusal.
  450. listing = await async_client.get(f"/api/v1/queue/batches/{batch_id}", headers=admin_headers)
  451. assert listing.json()["pending_count"] == 1
  452. @pytest.mark.asyncio
  453. @pytest.mark.integration
  454. async def test_queue_route_ownerless_archive_requires_reprint_all(
  455. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  456. ):
  457. """Ownerless archive (created_by_id=null) requires REPRINT_ALL.
  458. Pre-IDOR-fix legacy data has no creator; an operator with
  459. REPRINT_OWN can't fall back to "I own this" — fail-closed.
  460. The existing IDOR check returns 404 first (operator lacks
  461. READ_ALL and doesn't own the row), so this is also a regression
  462. guard against accidentally surfacing 403-instead-of-404 if the
  463. IDOR check is ever loosened.
  464. """
  465. printer = await printer_factory()
  466. archive = await archive_factory(printer.id, created_by_id=None)
  467. response = await async_client.post(
  468. "/api/v1/queue/",
  469. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  470. json={"printer_id": printer.id, "archive_id": archive.id},
  471. )
  472. # IDOR returns 404 before the new gate fires for this operator.
  473. assert response.status_code == 404
  474. class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):
  475. """Tests for print queue ownership-based permissions."""
  476. @pytest.fixture
  477. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  478. """Factory to create test queue items."""
  479. async def _create_item(**kwargs):
  480. from backend.app.models.print_queue import PrintQueueItem
  481. printer = await printer_factory()
  482. # Create an archive to link to the queue item
  483. archive = await archive_factory(printer.id)
  484. defaults = {
  485. "printer_id": printer.id,
  486. "archive_id": archive.id,
  487. "status": "pending",
  488. "position": 0,
  489. }
  490. defaults.update(kwargs)
  491. item = PrintQueueItem(**defaults)
  492. db_session.add(item)
  493. await db_session.commit()
  494. await db_session.refresh(item)
  495. return item
  496. return _create_item
  497. @pytest.mark.asyncio
  498. @pytest.mark.integration
  499. async def test_admin_can_delete_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  500. """Admin can delete any queue item."""
  501. item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
  502. response = await async_client.delete(
  503. f"/api/v1/queue/{item.id}",
  504. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  505. )
  506. assert response.status_code == 200
  507. @pytest.mark.asyncio
  508. @pytest.mark.integration
  509. async def test_operator_can_delete_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  510. """Operator can delete their own queue item."""
  511. item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
  512. response = await async_client.delete(
  513. f"/api/v1/queue/{item.id}",
  514. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  515. )
  516. assert response.status_code == 200
  517. @pytest.mark.asyncio
  518. @pytest.mark.integration
  519. async def test_operator_cannot_delete_others_queue_item(
  520. self, async_client: AsyncClient, auth_setup, queue_item_factory
  521. ):
  522. """Operator cannot delete another user's queue item."""
  523. item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
  524. response = await async_client.delete(
  525. f"/api/v1/queue/{item.id}",
  526. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  527. )
  528. assert response.status_code == 403
  529. @pytest.mark.asyncio
  530. @pytest.mark.integration
  531. async def test_operator_can_update_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  532. """Operator can update their own queue item."""
  533. item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
  534. response = await async_client.patch(
  535. f"/api/v1/queue/{item.id}",
  536. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  537. json={"position": 10},
  538. )
  539. assert response.status_code == 200
  540. @pytest.mark.asyncio
  541. @pytest.mark.integration
  542. async def test_operator_cannot_update_others_queue_item(
  543. self, async_client: AsyncClient, auth_setup, queue_item_factory
  544. ):
  545. """Operator cannot update another user's queue item."""
  546. item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
  547. response = await async_client.patch(
  548. f"/api/v1/queue/{item.id}",
  549. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  550. json={"position": 10},
  551. )
  552. assert response.status_code == 403
  553. @pytest.mark.asyncio
  554. @pytest.mark.integration
  555. async def test_operator_cannot_cancel_others_queue_item(
  556. self, async_client: AsyncClient, auth_setup, queue_item_factory
  557. ):
  558. """Operator cannot cancel another user's queue item."""
  559. item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
  560. response = await async_client.post(
  561. f"/api/v1/queue/{item.id}/cancel",
  562. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  563. )
  564. assert response.status_code == 403
  565. # ========================================================================
  566. # Start / Stop ownership gates (#1625-followup)
  567. # ========================================================================
  568. # Pre-fix /stop required QUEUE_UPDATE_ALL (admin-only) — operators saw the
  569. # Stop button in the queue UI but got 403 on click. /start required
  570. # QUEUE_UPDATE_OWN with no ownership check — operators could start anyone's
  571. # queue items via direct API. Both now use require_ownership_permission.
  572. @pytest.mark.asyncio
  573. @pytest.mark.integration
  574. async def test_operator_can_start_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  575. """Operator can start their own staged queue item."""
  576. item = await queue_item_factory(
  577. created_by_id=auth_setup["operator_user"]["id"],
  578. manual_start=True,
  579. )
  580. response = await async_client.post(
  581. f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
  582. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  583. )
  584. assert response.status_code == 200
  585. @pytest.mark.asyncio
  586. @pytest.mark.integration
  587. async def test_operator_cannot_start_others_queue_item(
  588. self, async_client: AsyncClient, auth_setup, queue_item_factory
  589. ):
  590. """Operator cannot start another user's queue item."""
  591. item = await queue_item_factory(
  592. created_by_id=auth_setup["operator2_user"]["id"],
  593. manual_start=True,
  594. )
  595. response = await async_client.post(
  596. f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
  597. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  598. )
  599. assert response.status_code == 403
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_operator_can_start_unowned_queue_item(
  603. self, async_client: AsyncClient, auth_setup, queue_item_factory, db_session
  604. ):
  605. """Operator can start a NULL-owner queue item (VP-uploaded, #1670)
  606. and claims ownership in the process.
  607. Stop and Cancel reject unowned items for _OWN holders (destructive,
  608. no "I own it" claim available), but Start is the entry point for the
  609. VP-import flow where attribution happens at click-time.
  610. """
  611. from backend.app.models.print_queue import PrintQueueItem
  612. item = await queue_item_factory(created_by_id=None, manual_start=True)
  613. response = await async_client.post(
  614. f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
  615. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  616. )
  617. assert response.status_code == 200
  618. # Ownership claimed: operator is now the item's owner.
  619. await db_session.refresh(item)
  620. refetch = await db_session.get(PrintQueueItem, item.id)
  621. assert refetch.created_by_id == auth_setup["operator_user"]["id"]
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_operator_can_stop_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  625. """Operator can stop their own currently-printing queue item."""
  626. item = await queue_item_factory(
  627. created_by_id=auth_setup["operator_user"]["id"],
  628. status="printing",
  629. )
  630. response = await async_client.post(
  631. f"/api/v1/queue/{item.id}/stop",
  632. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  633. )
  634. assert response.status_code == 200
  635. @pytest.mark.asyncio
  636. @pytest.mark.integration
  637. async def test_operator_cannot_stop_others_queue_item(
  638. self, async_client: AsyncClient, auth_setup, queue_item_factory
  639. ):
  640. """Operator cannot stop another user's printing queue item."""
  641. item = await queue_item_factory(
  642. created_by_id=auth_setup["operator2_user"]["id"],
  643. status="printing",
  644. )
  645. response = await async_client.post(
  646. f"/api/v1/queue/{item.id}/stop",
  647. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  648. )
  649. assert response.status_code == 403
  650. @pytest.mark.asyncio
  651. @pytest.mark.integration
  652. async def test_operator_cannot_stop_unowned_queue_item(
  653. self, async_client: AsyncClient, auth_setup, queue_item_factory
  654. ):
  655. """Operator cannot stop a NULL-owner printing queue item — stop mirrors
  656. cancel (destructive, no claim semantics). Admins with _ALL can still stop it.
  657. """
  658. item = await queue_item_factory(created_by_id=None, status="printing")
  659. response = await async_client.post(
  660. f"/api/v1/queue/{item.id}/stop",
  661. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  662. )
  663. assert response.status_code == 403
  664. @pytest.mark.asyncio
  665. @pytest.mark.integration
  666. async def test_admin_can_stop_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  667. """Admin with _ALL can stop any printing queue item including unowned."""
  668. item = await queue_item_factory(created_by_id=None, status="printing")
  669. response = await async_client.post(
  670. f"/api/v1/queue/{item.id}/stop",
  671. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  672. )
  673. assert response.status_code == 200
  674. @pytest.mark.asyncio
  675. @pytest.mark.integration
  676. async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory):
  677. """Bulk update only updates items the user owns."""
  678. # Create items owned by different users
  679. own_item = await queue_item_factory(
  680. created_by_id=auth_setup["operator_user"]["id"],
  681. )
  682. other_item = await queue_item_factory(
  683. created_by_id=auth_setup["operator2_user"]["id"],
  684. )
  685. response = await async_client.patch(
  686. "/api/v1/queue/bulk",
  687. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  688. json={
  689. "item_ids": [own_item.id, other_item.id],
  690. "manual_start": True,
  691. },
  692. )
  693. assert response.status_code == 200
  694. result = response.json()
  695. # Should only update the owned item
  696. assert result["updated_count"] == 1
  697. assert result["skipped_count"] == 1
  698. class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
  699. """Tests for library file ownership-based permissions."""
  700. @pytest.fixture
  701. async def library_file_factory(self, db_session):
  702. """Factory to create test library files."""
  703. _counter = [0]
  704. async def _create_file(**kwargs):
  705. from backend.app.models.library import LibraryFile
  706. _counter[0] += 1
  707. defaults = {
  708. "filename": f"test_{_counter[0]}.3mf",
  709. "file_path": f"library/test_{_counter[0]}.3mf",
  710. "file_type": "3mf",
  711. "file_size": 1024,
  712. }
  713. defaults.update(kwargs)
  714. file = LibraryFile(**defaults)
  715. db_session.add(file)
  716. await db_session.commit()
  717. await db_session.refresh(file)
  718. return file
  719. return _create_file
  720. @pytest.fixture
  721. async def library_folder_factory(self, db_session):
  722. """Factory to create test library folders."""
  723. _counter = [0]
  724. async def _create_folder(**kwargs):
  725. from backend.app.models.library import LibraryFolder
  726. _counter[0] += 1
  727. # Ownerless and shared: a folder from before #3201 after the
  728. # upgrade backfill, or one made with auth off.
  729. defaults = {
  730. "name": f"TestFolder_{_counter[0]}",
  731. "shared": True,
  732. }
  733. defaults.update(kwargs)
  734. folder = LibraryFolder(**defaults)
  735. db_session.add(folder)
  736. await db_session.commit()
  737. await db_session.refresh(folder)
  738. return folder
  739. return _create_folder
  740. @pytest.mark.asyncio
  741. @pytest.mark.integration
  742. async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory):
  743. """Admin can delete any library file."""
  744. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  745. response = await async_client.delete(
  746. f"/api/v1/library/files/{file.id}",
  747. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  748. )
  749. assert response.status_code == 200
  750. @pytest.mark.asyncio
  751. @pytest.mark.integration
  752. async def test_operator_can_delete_own_library_file(
  753. self, async_client: AsyncClient, auth_setup, library_file_factory
  754. ):
  755. """Operator can delete their own library file."""
  756. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  757. response = await async_client.delete(
  758. f"/api/v1/library/files/{file.id}",
  759. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  760. )
  761. assert response.status_code == 200
  762. @pytest.mark.asyncio
  763. @pytest.mark.integration
  764. async def test_operator_cannot_delete_others_library_file(
  765. self, async_client: AsyncClient, auth_setup, library_file_factory
  766. ):
  767. """Operator cannot delete another user's library file."""
  768. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  769. response = await async_client.delete(
  770. f"/api/v1/library/files/{file.id}",
  771. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  772. )
  773. assert response.status_code == 403
  774. @pytest.mark.asyncio
  775. @pytest.mark.integration
  776. async def test_operator_can_update_own_library_file(
  777. self, async_client: AsyncClient, auth_setup, library_file_factory
  778. ):
  779. """Operator can update their own library file."""
  780. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  781. response = await async_client.put(
  782. f"/api/v1/library/files/{file.id}",
  783. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  784. json={"filename": "renamed.3mf"},
  785. )
  786. assert response.status_code == 200
  787. @pytest.mark.asyncio
  788. @pytest.mark.integration
  789. async def test_operator_cannot_update_others_library_file(
  790. self, async_client: AsyncClient, auth_setup, library_file_factory
  791. ):
  792. """Operator cannot update another user's library file."""
  793. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  794. response = await async_client.put(
  795. f"/api/v1/library/files/{file.id}",
  796. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  797. json={"filename": "renamed.3mf"},
  798. )
  799. assert response.status_code == 403
  800. # ========================================================================
  801. # Photo routes (#3077). Upload and delete are gated on LIBRARY_UPDATE_*,
  802. # so a non-owner is refused with 403 exactly like ``update_file``. The
  803. # read path goes through ``_ensure_library_file_visible`` and answers 404
  804. # instead, so an id that exists tells an outsider nothing.
  805. # ========================================================================
  806. @pytest.fixture
  807. def photo_storage(self, monkeypatch, tmp_path):
  808. """Keep uploaded photos out of the real data directory."""
  809. from backend.app.core.config import settings as app_settings
  810. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  811. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  812. return tmp_path
  813. @staticmethod
  814. def _photo_upload():
  815. import io
  816. from PIL import Image
  817. buf = io.BytesIO()
  818. Image.new("RGB", (8, 8), "blue").save(buf, "JPEG")
  819. return {"file": ("result.jpg", buf.getvalue(), "image/jpeg")}
  820. @pytest.mark.asyncio
  821. @pytest.mark.integration
  822. async def test_operator_can_upload_photo_to_own_library_file(
  823. self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
  824. ):
  825. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  826. response = await async_client.post(
  827. f"/api/v1/library/files/{file.id}/photos",
  828. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  829. files=self._photo_upload(),
  830. )
  831. assert response.status_code == 200
  832. assert len(response.json()["photos"]) == 1
  833. @pytest.mark.asyncio
  834. @pytest.mark.integration
  835. async def test_operator_cannot_upload_photo_to_others_library_file(
  836. self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
  837. ):
  838. from backend.app.utils.library_paths import library_photos_dir
  839. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  840. response = await async_client.post(
  841. f"/api/v1/library/files/{file.id}/photos",
  842. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  843. files=self._photo_upload(),
  844. )
  845. assert response.status_code == 403
  846. assert not library_photos_dir(file.id).exists()
  847. @pytest.mark.asyncio
  848. @pytest.mark.integration
  849. async def test_operator_cannot_delete_photo_from_others_library_file(
  850. self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
  851. ):
  852. from backend.app.utils.library_paths import library_photos_dir
  853. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  854. upload = await async_client.post(
  855. f"/api/v1/library/files/{file.id}/photos",
  856. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  857. files=self._photo_upload(),
  858. )
  859. filename = upload.json()["filename"]
  860. response = await async_client.delete(
  861. f"/api/v1/library/files/{file.id}/photos/{filename}",
  862. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  863. )
  864. assert response.status_code == 403
  865. assert (library_photos_dir(file.id) / filename).is_file()
  866. @pytest.mark.asyncio
  867. @pytest.mark.integration
  868. async def test_operator_reading_others_library_file_photo_gets_404(
  869. self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
  870. ):
  871. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  872. upload = await async_client.post(
  873. f"/api/v1/library/files/{file.id}/photos",
  874. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  875. files=self._photo_upload(),
  876. )
  877. filename = upload.json()["filename"]
  878. owner = await async_client.get(
  879. f"/api/v1/library/files/{file.id}/photos/{filename}",
  880. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  881. )
  882. assert owner.status_code == 200
  883. stranger = await async_client.get(
  884. f"/api/v1/library/files/{file.id}/photos/{filename}",
  885. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  886. )
  887. assert stranger.status_code == 404
  888. # ========================================================================
  889. # Folder deletion (#1781): a folder without an owner may be deleted with
  890. # only library:delete_own when it is empty, non-external and non-linked.
  891. # Everything else still requires library:delete_all. Owned folders (#3201)
  892. # are covered in test_library_folder_ownership_3201.py.
  893. # ========================================================================
  894. @pytest.mark.asyncio
  895. @pytest.mark.integration
  896. async def test_operator_can_delete_empty_folder(
  897. self, async_client: AsyncClient, auth_setup, library_folder_factory
  898. ):
  899. """A user with library:delete_own can delete an empty folder (#1781)."""
  900. folder = await library_folder_factory(name="EmptyFolder")
  901. response = await async_client.delete(
  902. f"/api/v1/library/folders/{folder.id}",
  903. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  904. )
  905. assert response.status_code == 200
  906. @pytest.mark.asyncio
  907. @pytest.mark.integration
  908. async def test_viewer_cannot_delete_empty_folder(
  909. self, async_client: AsyncClient, auth_setup, library_folder_factory
  910. ):
  911. """No delete permission at all still means no folder deletion."""
  912. folder = await library_folder_factory(name="EmptyFolder")
  913. response = await async_client.delete(
  914. f"/api/v1/library/folders/{folder.id}",
  915. headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
  916. )
  917. assert response.status_code == 403
  918. @pytest.mark.asyncio
  919. @pytest.mark.integration
  920. async def test_operator_cannot_delete_folder_with_files(
  921. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  922. ):
  923. """Non-empty folders still require library:delete_all."""
  924. folder = await library_folder_factory(name="FullFolder")
  925. await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
  926. response = await async_client.delete(
  927. f"/api/v1/library/folders/{folder.id}",
  928. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  929. )
  930. assert response.status_code == 403
  931. @pytest.mark.asyncio
  932. @pytest.mark.integration
  933. async def test_operator_cannot_delete_folder_with_trashed_file(
  934. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  935. ):
  936. """Trashed files count as content: cascade would hard-drop them and
  937. silently break trash restore for their owner."""
  938. from datetime import datetime, timezone
  939. folder = await library_folder_factory(name="TrashedContentFolder")
  940. await library_file_factory(
  941. folder_id=folder.id,
  942. created_by_id=auth_setup["operator2_user"]["id"],
  943. deleted_at=datetime.now(timezone.utc),
  944. )
  945. response = await async_client.delete(
  946. f"/api/v1/library/folders/{folder.id}",
  947. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  948. )
  949. assert response.status_code == 403
  950. @pytest.mark.asyncio
  951. @pytest.mark.integration
  952. async def test_operator_cannot_delete_folder_with_subfolder(
  953. self, async_client: AsyncClient, auth_setup, library_folder_factory
  954. ):
  955. """A folder containing subfolders (even empty ones) is not empty."""
  956. parent = await library_folder_factory(name="ParentFolder")
  957. await library_folder_factory(name="ChildFolder", parent_id=parent.id)
  958. response = await async_client.delete(
  959. f"/api/v1/library/folders/{parent.id}",
  960. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  961. )
  962. assert response.status_code == 403
  963. @pytest.mark.asyncio
  964. @pytest.mark.integration
  965. async def test_operator_cannot_delete_external_folder(
  966. self, async_client: AsyncClient, auth_setup, library_folder_factory
  967. ):
  968. """Deleting an external folder unmounts an operator-configured mount
  969. for everyone — stays behind library:delete_all even when empty."""
  970. folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
  971. response = await async_client.delete(
  972. f"/api/v1/library/folders/{folder.id}",
  973. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  974. )
  975. assert response.status_code == 403
  976. @pytest.mark.asyncio
  977. @pytest.mark.integration
  978. async def test_operator_cannot_delete_linked_folder(
  979. self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
  980. ):
  981. """Project/archive links are created via update_all, so unlinking by
  982. deletion stays admin-only even for empty folders."""
  983. from backend.app.models.project import Project
  984. project = Project(name="LinkTestProject")
  985. db_session.add(project)
  986. await db_session.commit()
  987. await db_session.refresh(project)
  988. folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
  989. response = await async_client.delete(
  990. f"/api/v1/library/folders/{folder.id}",
  991. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  992. )
  993. assert response.status_code == 403
  994. @pytest.mark.asyncio
  995. @pytest.mark.integration
  996. async def test_admin_can_delete_folder_with_contents(
  997. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  998. ):
  999. """library:delete_all keeps full cascade deletion."""
  1000. folder = await library_folder_factory(name="AdminFolder")
  1001. await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
  1002. response = await async_client.delete(
  1003. f"/api/v1/library/folders/{folder.id}",
  1004. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1005. )
  1006. assert response.status_code == 200
  1007. @pytest.mark.asyncio
  1008. @pytest.mark.integration
  1009. async def test_bulk_delete_operator_folders_empty_only(
  1010. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  1011. ):
  1012. """Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
  1013. empty_folder = await library_folder_factory(name="BulkEmpty")
  1014. full_folder = await library_folder_factory(name="BulkFull")
  1015. await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
  1016. response = await async_client.post(
  1017. "/api/v1/library/bulk-delete",
  1018. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1019. json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
  1020. )
  1021. assert response.status_code == 200
  1022. result = response.json()
  1023. assert result["deleted_folders"] == 1
  1024. assert result["deleted_files"] == 0
  1025. @pytest.mark.asyncio
  1026. @pytest.mark.integration
  1027. async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
  1028. """Bulk delete only deletes files the user owns."""
  1029. own_file = await library_file_factory(
  1030. filename="own.3mf",
  1031. created_by_id=auth_setup["operator_user"]["id"],
  1032. )
  1033. other_file = await library_file_factory(
  1034. filename="other.3mf",
  1035. created_by_id=auth_setup["operator2_user"]["id"],
  1036. )
  1037. response = await async_client.post(
  1038. "/api/v1/library/bulk-delete",
  1039. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1040. json={"file_ids": [own_file.id, other_file.id], "folder_ids": []},
  1041. )
  1042. assert response.status_code == 200
  1043. result = response.json()
  1044. # Should only delete the owned file; other_file is skipped (but skipped count not in response)
  1045. assert result["deleted_files"] == 1
  1046. class TestAuthDisabledPermissions:
  1047. """Tests that verify all operations are allowed when auth is disabled."""
  1048. @pytest.mark.asyncio
  1049. @pytest.mark.integration
  1050. async def test_delete_archive_without_auth(
  1051. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1052. ):
  1053. """When auth is disabled, anyone can delete archives."""
  1054. printer = await printer_factory()
  1055. archive = await archive_factory(printer.id)
  1056. response = await async_client.delete(f"/api/v1/archives/{archive.id}")
  1057. assert response.status_code == 200
  1058. @pytest.mark.asyncio
  1059. @pytest.mark.integration
  1060. async def test_update_archive_without_auth(
  1061. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1062. ):
  1063. """When auth is disabled, anyone can update archives."""
  1064. printer = await printer_factory()
  1065. archive = await archive_factory(printer.id)
  1066. response = await async_client.patch(
  1067. f"/api/v1/archives/{archive.id}",
  1068. json={"print_name": "Updated Name"},
  1069. )
  1070. assert response.status_code == 200
  1071. class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
  1072. """Tests for user items count endpoint and deletion with items."""
  1073. @pytest.mark.asyncio
  1074. @pytest.mark.integration
  1075. async def test_get_user_items_count(
  1076. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1077. ):
  1078. """Verify items count endpoint returns correct counts."""
  1079. printer = await printer_factory()
  1080. user_id = auth_setup["operator_user"]["id"]
  1081. # Create some items for the operator
  1082. await archive_factory(printer.id, created_by_id=user_id)
  1083. await archive_factory(printer.id, created_by_id=user_id)
  1084. response = await async_client.get(
  1085. f"/api/v1/users/{user_id}/items-count",
  1086. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1087. )
  1088. assert response.status_code == 200
  1089. counts = response.json()
  1090. assert counts["archives"] >= 2
  1091. assert "queue_items" in counts
  1092. assert "library_files" in counts
  1093. @pytest.mark.asyncio
  1094. @pytest.mark.integration
  1095. async def test_delete_user_keeps_items(
  1096. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1097. ):
  1098. """Verify deleting user without delete_items keeps items (ownerless)."""
  1099. printer = await printer_factory()
  1100. user_id = auth_setup["operator2_user"]["id"]
  1101. # Create archive for operator2
  1102. archive = await archive_factory(printer.id, created_by_id=user_id)
  1103. archive_id = archive.id
  1104. # Delete user without deleting items
  1105. response = await async_client.delete(
  1106. f"/api/v1/users/{user_id}?delete_items=false",
  1107. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1108. )
  1109. assert response.status_code == 204
  1110. # Verify archive still exists but is now ownerless
  1111. archive_response = await async_client.get(
  1112. f"/api/v1/archives/{archive_id}",
  1113. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1114. )
  1115. assert archive_response.status_code == 200
  1116. assert archive_response.json()["created_by_id"] is None
  1117. @pytest.mark.asyncio
  1118. @pytest.mark.integration
  1119. async def test_delete_user_with_items(
  1120. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1121. ):
  1122. """Verify deleting user with delete_items=true removes their items."""
  1123. printer = await printer_factory()
  1124. # Create a new user with items
  1125. create_response = await async_client.post(
  1126. "/api/v1/users/",
  1127. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1128. json={
  1129. "username": "deletewithitems",
  1130. "password": "Password123!",
  1131. },
  1132. )
  1133. user_id = create_response.json()["id"]
  1134. # Create archive for this user
  1135. archive = await archive_factory(printer.id, created_by_id=user_id)
  1136. archive_id = archive.id
  1137. # Delete user WITH deleting items
  1138. response = await async_client.delete(
  1139. f"/api/v1/users/{user_id}?delete_items=true",
  1140. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1141. )
  1142. assert response.status_code == 204
  1143. # Verify archive was deleted
  1144. archive_response = await async_client.get(
  1145. f"/api/v1/archives/{archive_id}",
  1146. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1147. )
  1148. assert archive_response.status_code == 404
  1149. class TestReadIDORClosure(TestOwnershipPermissionsSetup):
  1150. """Regression tests pinning maziggy/bambuddy-security #2 — IDOR on
  1151. archives / library / queue read paths.
  1152. Before the fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat
  1153. "see everything" permissions even though the write side was split into
  1154. OWN/ALL. An operator with only ARCHIVES_READ could read, download, and
  1155. queue any user's archive via direct id reference. These tests pin the
  1156. bambuddy_archive_idor.py and bambuddy_archive_viewer_idor.py PoC paths
  1157. so the IDOR can't regress silently.
  1158. """
  1159. @pytest.mark.asyncio
  1160. @pytest.mark.integration
  1161. async def test_operator_get_others_archive_returns_404_not_200(
  1162. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1163. ):
  1164. """PoC #2 read path. operator1 GET /archives/{id} where id is admin's
  1165. archive must NOT leak the row. 404 (not 403) so the operator can't
  1166. enumerate which ids exist — same shape as a nonexistent id."""
  1167. printer = await printer_factory()
  1168. archive = await archive_factory(
  1169. printer.id,
  1170. print_name="Admin Archive",
  1171. created_by_id=auth_setup["admin_user"]["id"],
  1172. )
  1173. response = await async_client.get(
  1174. f"/api/v1/archives/{archive.id}",
  1175. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1176. )
  1177. assert response.status_code == 404
  1178. @pytest.mark.asyncio
  1179. @pytest.mark.integration
  1180. async def test_operator_download_others_archive_returns_404(
  1181. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1182. ):
  1183. """Viewer-IDOR PoC path: GET /archives/{id}/download on admin's archive.
  1184. Before the fix this streamed the 3MF body straight to a viewer-tier
  1185. token."""
  1186. printer = await printer_factory()
  1187. archive = await archive_factory(
  1188. printer.id,
  1189. print_name="Admin Archive 2",
  1190. created_by_id=auth_setup["admin_user"]["id"],
  1191. )
  1192. response = await async_client.get(
  1193. f"/api/v1/archives/{archive.id}/download",
  1194. headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
  1195. )
  1196. assert response.status_code == 404
  1197. @pytest.mark.asyncio
  1198. @pytest.mark.integration
  1199. async def test_operator_list_archives_excludes_others(
  1200. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1201. ):
  1202. """GET /archives/ must filter to own archives only for OWN-level callers."""
  1203. printer = await printer_factory()
  1204. own = await archive_factory(
  1205. printer.id, print_name="Operator's Own", created_by_id=auth_setup["operator_user"]["id"]
  1206. )
  1207. others = await archive_factory(printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"])
  1208. response = await async_client.get(
  1209. "/api/v1/archives/",
  1210. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1211. )
  1212. assert response.status_code == 200
  1213. returned_ids = {a["id"] for a in response.json()}
  1214. assert own.id in returned_ids
  1215. assert others.id not in returned_ids
  1216. @pytest.mark.asyncio
  1217. @pytest.mark.integration
  1218. async def test_admin_list_archives_includes_all(
  1219. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1220. ):
  1221. """ARCHIVES_READ_ALL → admin sees own + every user's archives."""
  1222. printer = await printer_factory()
  1223. admin_archive = await archive_factory(
  1224. printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"]
  1225. )
  1226. operator_archive = await archive_factory(
  1227. printer.id, print_name="Operator's", created_by_id=auth_setup["operator_user"]["id"]
  1228. )
  1229. response = await async_client.get(
  1230. "/api/v1/archives/",
  1231. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1232. )
  1233. assert response.status_code == 200
  1234. returned_ids = {a["id"] for a in response.json()}
  1235. assert admin_archive.id in returned_ids
  1236. assert operator_archive.id in returned_ids
  1237. @pytest.mark.asyncio
  1238. @pytest.mark.integration
  1239. async def test_operator_cannot_queue_others_archive(
  1240. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1241. ):
  1242. """PoC #2 queue path. POST /queue/ with admin's archive_id as
  1243. operator1 must return 404, not create a queue item. Before the fix
  1244. this returned 201 and queued the admin archive (Landon's CONFIRMED
  1245. line in the PoC)."""
  1246. printer = await printer_factory()
  1247. archive = await archive_factory(
  1248. printer.id,
  1249. print_name="Admin Archive (queue-target)",
  1250. created_by_id=auth_setup["admin_user"]["id"],
  1251. )
  1252. response = await async_client.post(
  1253. "/api/v1/queue/",
  1254. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1255. json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
  1256. )
  1257. assert response.status_code == 404
  1258. @pytest.mark.asyncio
  1259. @pytest.mark.integration
  1260. async def test_admin_can_queue_others_archive(
  1261. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1262. ):
  1263. """Belt-and-suspenders for the ALL path: admin (ARCHIVES_READ_ALL) can
  1264. queue a user's archive on their behalf — common workshop pattern."""
  1265. printer = await printer_factory()
  1266. archive = await archive_factory(
  1267. printer.id,
  1268. print_name="Operator's archive (queue by admin)",
  1269. created_by_id=auth_setup["operator_user"]["id"],
  1270. )
  1271. response = await async_client.post(
  1272. "/api/v1/queue/",
  1273. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1274. json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
  1275. )
  1276. assert response.status_code == 200
  1277. @pytest.mark.asyncio
  1278. @pytest.mark.integration
  1279. async def test_operator_get_others_library_file_returns_404(
  1280. self, async_client: AsyncClient, auth_setup, db_session
  1281. ):
  1282. """Library IDOR closure (same shape as archives — closed in the same PR
  1283. per maziggy/bambuddy-security #2)."""
  1284. from backend.app.models.library import LibraryFile
  1285. admin_file = LibraryFile(
  1286. filename="admin_secret.3mf",
  1287. file_path="library/admin_secret.3mf",
  1288. file_type="3mf",
  1289. file_size=2048,
  1290. created_by_id=auth_setup["admin_user"]["id"],
  1291. )
  1292. db_session.add(admin_file)
  1293. await db_session.commit()
  1294. await db_session.refresh(admin_file)
  1295. response = await async_client.get(
  1296. f"/api/v1/library/files/{admin_file.id}",
  1297. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1298. )
  1299. assert response.status_code == 404
  1300. @pytest.mark.asyncio
  1301. @pytest.mark.integration
  1302. async def test_operator_list_library_files_excludes_others(self, async_client: AsyncClient, auth_setup, db_session):
  1303. from backend.app.models.library import LibraryFile
  1304. own = LibraryFile(
  1305. filename="my_file.3mf",
  1306. file_path="library/my_file.3mf",
  1307. file_type="3mf",
  1308. file_size=1024,
  1309. created_by_id=auth_setup["operator_user"]["id"],
  1310. )
  1311. others = LibraryFile(
  1312. filename="admin_file.3mf",
  1313. file_path="library/admin_file.3mf",
  1314. file_type="3mf",
  1315. file_size=1024,
  1316. created_by_id=auth_setup["admin_user"]["id"],
  1317. )
  1318. db_session.add_all([own, others])
  1319. await db_session.commit()
  1320. await db_session.refresh(own)
  1321. await db_session.refresh(others)
  1322. response = await async_client.get(
  1323. "/api/v1/library/files",
  1324. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1325. )
  1326. assert response.status_code == 200
  1327. returned_ids = {f["id"] for f in response.json()}
  1328. assert own.id in returned_ids
  1329. assert others.id not in returned_ids
  1330. @pytest.mark.asyncio
  1331. @pytest.mark.integration
  1332. async def test_operator_queue_list_excludes_others_items(
  1333. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1334. ):
  1335. """GET /queue/ must filter to own queue items only for OWN callers —
  1336. same shape as the archive list."""
  1337. from backend.app.models.print_queue import PrintQueueItem
  1338. printer = await printer_factory()
  1339. archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["operator_user"]["id"])
  1340. own_item = PrintQueueItem(
  1341. archive_id=archive.id,
  1342. printer_id=printer.id,
  1343. status="pending",
  1344. position=1,
  1345. created_by_id=auth_setup["operator_user"]["id"],
  1346. )
  1347. admin_item = PrintQueueItem(
  1348. archive_id=archive.id,
  1349. printer_id=printer.id,
  1350. status="pending",
  1351. position=2,
  1352. created_by_id=auth_setup["admin_user"]["id"],
  1353. )
  1354. db_session.add_all([own_item, admin_item])
  1355. await db_session.commit()
  1356. await db_session.refresh(own_item)
  1357. await db_session.refresh(admin_item)
  1358. response = await async_client.get(
  1359. "/api/v1/queue/",
  1360. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1361. )
  1362. assert response.status_code == 200
  1363. returned_ids = {q["id"] for q in response.json()}
  1364. assert own_item.id in returned_ids
  1365. assert admin_item.id not in returned_ids
  1366. @pytest.mark.asyncio
  1367. @pytest.mark.integration
  1368. async def test_operator_get_others_queue_item_returns_404(
  1369. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1370. ):
  1371. """Direct-id queue item access — same enumeration risk as archive get."""
  1372. from backend.app.models.print_queue import PrintQueueItem
  1373. printer = await printer_factory()
  1374. archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["admin_user"]["id"])
  1375. admin_item = PrintQueueItem(
  1376. archive_id=archive.id,
  1377. printer_id=printer.id,
  1378. status="pending",
  1379. position=1,
  1380. created_by_id=auth_setup["admin_user"]["id"],
  1381. )
  1382. db_session.add(admin_item)
  1383. await db_session.commit()
  1384. await db_session.refresh(admin_item)
  1385. response = await async_client.get(
  1386. f"/api/v1/queue/{admin_item.id}",
  1387. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1388. )
  1389. assert response.status_code == 404
  1390. @pytest.mark.asyncio
  1391. @pytest.mark.integration
  1392. async def test_auth_disabled_preserves_single_tenant_read_all(
  1393. self, async_client: AsyncClient, archive_factory, printer_factory
  1394. ):
  1395. """With auth disabled, ARCHIVES_READ resolves to read-all (can_modify_all=True
  1396. in require_ownership_permission's auth-disabled branch). Existing
  1397. single-user installs see no behavior change."""
  1398. printer = await printer_factory()
  1399. archive = await archive_factory(printer.id, print_name="Anonymous", created_by_id=None)
  1400. # No Authorization header — auth-disabled mode.
  1401. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1402. # Either 200 (auth disabled in this test session) or 401 (auth enabled
  1403. # from a prior test) — both are acceptable; the IDOR closure does not
  1404. # change auth-enable/disable behavior. Pin not-404 to avoid masking a
  1405. # regression where auth-disabled callers would lose access.
  1406. assert response.status_code in (200, 401)
  1407. # Every archive WRITE sub-resource route: (id, http method, path suffix, request kwargs).
  1408. # The ownership gate (_ensure_archive_visible) fires immediately after the fetch,
  1409. # before any resource-specific logic, so a not-owned / ownerless row 404s regardless
  1410. # of whether the timelapse / photo / source / f3d actually exists. Upload routes still
  1411. # need a body so FastAPI reaches the handler instead of 422-ing on the missing File(...).
  1412. _WRITE_SUBRESOURCE_ROUTES = [
  1413. ("favorite", "post", "/favorite", {}),
  1414. ("timelapse_delete", "delete", "/timelapse", {}),
  1415. ("photo_upload", "post", "/photos", {"files": {"file": ("x.jpg", b"\x89PNG\r\n\x1a\n", "image/jpeg")}}),
  1416. ("photo_delete", "delete", "/photos/nonexistent.jpg", {}),
  1417. ("project_page", "patch", "/project-page", {"json": {"title": "hijacked"}}),
  1418. ("source_upload", "post", "/source", {"files": {"file": ("x.3mf", b"PK\x03\x04", "application/octet-stream")}}),
  1419. ("source_delete", "delete", "/source", {}),
  1420. ("f3d_upload", "post", "/f3d", {"files": {"file": ("x.f3d", b"f3d-bytes", "application/octet-stream")}}),
  1421. ("f3d_delete", "delete", "/f3d", {}),
  1422. ]
  1423. class TestWriteSubResourceIDORClosure(TestOwnershipPermissionsSetup):
  1424. """Regression tests for the archive write SUB-RESOURCE IDOR.
  1425. The read sub-resource routes were closed under maziggy/bambuddy-security #2
  1426. via ``_ensure_archive_visible``, but the *write* sub-resource routes
  1427. (favorite, timelapse, photos, project-page, source, f3d) were left gating
  1428. on the bare ``RequirePermissionIfAuthEnabled(ARCHIVES_*_OWN)`` scope and
  1429. fetched the row by id only — never comparing ``created_by_id`` to the
  1430. caller. An operator holding only ``ARCHIVES_*_OWN`` (or an API key with
  1431. ``can_manage_archives``) could delete/overwrite files on ANY user's
  1432. archive, most severely rewriting the project-page metadata inside another
  1433. user's ``.3mf`` on disk. Each route is now gated by
  1434. ``require_ownership_permission`` + ``_ensure_archive_visible`` → 404 (not
  1435. 403, to stay non-enumerable and match the read side) on a not-owned or
  1436. ownerless row.
  1437. """
  1438. @pytest.mark.parametrize(
  1439. "name,method,suffix,kwargs",
  1440. _WRITE_SUBRESOURCE_ROUTES,
  1441. ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
  1442. )
  1443. @pytest.mark.asyncio
  1444. @pytest.mark.integration
  1445. async def test_operator_cannot_write_others_archive_subresource(
  1446. self,
  1447. async_client: AsyncClient,
  1448. auth_setup,
  1449. archive_factory,
  1450. printer_factory,
  1451. db_session,
  1452. name,
  1453. method,
  1454. suffix,
  1455. kwargs,
  1456. ):
  1457. """SECURITY.md rule 4: right credentials, wrong ownership → 404.
  1458. operator1 (ARCHIVES_*_OWN) targeting a route on admin's archive.
  1459. """
  1460. printer = await printer_factory()
  1461. archive = await archive_factory(
  1462. printer.id,
  1463. print_name="Admin's Archive",
  1464. created_by_id=auth_setup["admin_user"]["id"],
  1465. )
  1466. response = await getattr(async_client, method)(
  1467. f"/api/v1/archives/{archive.id}{suffix}",
  1468. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1469. **kwargs,
  1470. )
  1471. assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
  1472. @pytest.mark.parametrize(
  1473. "name,method,suffix,kwargs",
  1474. _WRITE_SUBRESOURCE_ROUTES,
  1475. ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
  1476. )
  1477. @pytest.mark.asyncio
  1478. @pytest.mark.integration
  1479. async def test_operator_cannot_write_ownerless_archive_subresource(
  1480. self,
  1481. async_client: AsyncClient,
  1482. auth_setup,
  1483. archive_factory,
  1484. printer_factory,
  1485. db_session,
  1486. name,
  1487. method,
  1488. suffix,
  1489. kwargs,
  1490. ):
  1491. """Ownerless rows (created_by_id = null, legacy data) require *_ALL — an
  1492. operator with only *_OWN has no 'I own this' claim, so fail closed → 404."""
  1493. printer = await printer_factory()
  1494. archive = await archive_factory(
  1495. printer.id,
  1496. print_name="Ownerless Archive",
  1497. created_by_id=None,
  1498. )
  1499. response = await getattr(async_client, method)(
  1500. f"/api/v1/archives/{archive.id}{suffix}",
  1501. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1502. **kwargs,
  1503. )
  1504. assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
  1505. @pytest.mark.asyncio
  1506. @pytest.mark.integration
  1507. async def test_operator_can_favorite_own_archive(
  1508. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1509. ):
  1510. """Positive control: the owner still gets through the new gate. Favorite
  1511. is the one write sub-resource that needs no pre-existing file, so it
  1512. cleanly proves the *_OWN happy path returns 200 (not a false 404)."""
  1513. printer = await printer_factory()
  1514. archive = await archive_factory(
  1515. printer.id,
  1516. print_name="Operator's Own",
  1517. created_by_id=auth_setup["operator_user"]["id"],
  1518. )
  1519. response = await async_client.post(
  1520. f"/api/v1/archives/{archive.id}/favorite",
  1521. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1522. )
  1523. assert response.status_code == 200
  1524. assert response.json()["is_favorite"] is True
  1525. @pytest.mark.asyncio
  1526. @pytest.mark.integration
  1527. async def test_admin_can_favorite_any_archive(
  1528. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1529. ):
  1530. """Positive control for the *_ALL path: admin can act on a user's archive."""
  1531. printer = await printer_factory()
  1532. archive = await archive_factory(
  1533. printer.id,
  1534. print_name="Operator's Own",
  1535. created_by_id=auth_setup["operator_user"]["id"],
  1536. )
  1537. response = await async_client.post(
  1538. f"/api/v1/archives/{archive.id}/favorite",
  1539. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1540. )
  1541. assert response.status_code == 200
  1542. class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
  1543. """IDOR regression: slicing and slice-job polling must honour per-row ownership.
  1544. Before the fix, ``POST /library/files/{id}/slice`` and
  1545. ``POST /archives/{id}/slice`` gated only on ``LIBRARY_UPLOAD``, so a
  1546. READ_OWN operator could slice another user's model by raw id even though a
  1547. direct GET on that id returned 404 — the sliced output was then attributed
  1548. to and downloadable by the requester. ``GET /slice-jobs/{id}`` had no owner
  1549. scoping at all. ``POST /slicer-pipelines/{id}/run`` (and check-eligibility)
  1550. resolved the source by raw id with the same gap.
  1551. The slice route enforces the gate before touching the source bytes, so the
  1552. owner/READ_ALL "control" cases reach the later on-disk check (a distinct 404
  1553. detail) rather than a real slice — enough to prove the gate lets them past.
  1554. """
  1555. # Any preset triplet: the ownership 404 fires before preset resolution.
  1556. _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
  1557. @pytest.fixture
  1558. async def library_file_factory(self, db_session):
  1559. _counter = [0]
  1560. async def _create_file(**kwargs):
  1561. from backend.app.models.library import LibraryFile
  1562. _counter[0] += 1
  1563. defaults = {
  1564. "filename": f"slice_src_{_counter[0]}.3mf",
  1565. "file_path": f"library/slice_src_{_counter[0]}.3mf",
  1566. "file_type": "3mf",
  1567. "file_size": 1024,
  1568. }
  1569. defaults.update(kwargs)
  1570. row = LibraryFile(**defaults)
  1571. db_session.add(row)
  1572. await db_session.commit()
  1573. await db_session.refresh(row)
  1574. return row
  1575. return _create_file
  1576. # --- library file slice ------------------------------------------------
  1577. @pytest.mark.asyncio
  1578. @pytest.mark.integration
  1579. async def test_operator_cannot_slice_others_library_file(self, async_client, auth_setup, library_file_factory):
  1580. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1581. resp = await async_client.post(
  1582. f"/api/v1/library/files/{file.id}/slice",
  1583. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1584. json=self._SLICE_BODY,
  1585. )
  1586. assert resp.status_code == 404
  1587. # 404 (not 403) so a probing operator can't tell the id exists.
  1588. assert resp.json()["detail"] == "File not found"
  1589. @pytest.mark.asyncio
  1590. @pytest.mark.integration
  1591. async def test_operator_can_slice_own_library_file(self, async_client, auth_setup, library_file_factory):
  1592. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  1593. resp = await async_client.post(
  1594. f"/api/v1/library/files/{file.id}/slice",
  1595. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1596. json=self._SLICE_BODY,
  1597. )
  1598. # Past the ownership gate — only the on-disk source is missing in tests.
  1599. assert resp.status_code == 404
  1600. assert resp.json()["detail"] == "Source file missing on disk"
  1601. @pytest.mark.asyncio
  1602. @pytest.mark.integration
  1603. async def test_admin_can_slice_any_library_file(self, async_client, auth_setup, library_file_factory):
  1604. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1605. resp = await async_client.post(
  1606. f"/api/v1/library/files/{file.id}/slice",
  1607. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1608. json=self._SLICE_BODY,
  1609. )
  1610. # READ_ALL passes the gate even on another user's file.
  1611. assert resp.status_code == 404
  1612. assert resp.json()["detail"] == "Source file missing on disk"
  1613. # --- combine to 3MF ----------------------------------------------------
  1614. @pytest.mark.asyncio
  1615. @pytest.mark.integration
  1616. async def test_operator_cannot_combine_others_library_file(self, async_client, auth_setup, library_file_factory):
  1617. own = await library_file_factory(filename="own.stl", created_by_id=auth_setup["operator_user"]["id"])
  1618. other = await library_file_factory(filename="other.stl", created_by_id=auth_setup["operator2_user"]["id"])
  1619. resp = await async_client.post(
  1620. "/api/v1/library/files/combine",
  1621. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1622. json={"items": [{"file_id": own.id}, {"file_id": other.id}], "filename": "mix"},
  1623. )
  1624. assert resp.status_code == 404
  1625. assert resp.json()["detail"] == "File not found"
  1626. @pytest.mark.asyncio
  1627. @pytest.mark.integration
  1628. async def test_admin_can_combine_any_library_file(self, async_client, auth_setup, library_file_factory):
  1629. other = await library_file_factory(filename="other.stl", created_by_id=auth_setup["operator2_user"]["id"])
  1630. resp = await async_client.post(
  1631. "/api/v1/library/files/combine",
  1632. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1633. json={"items": [{"file_id": other.id}], "filename": "mix"},
  1634. )
  1635. # Past the ownership gate — only the on-disk source is missing in tests.
  1636. assert resp.status_code == 404
  1637. assert resp.json()["detail"].startswith("Source file missing on disk")
  1638. # --- archive slice -----------------------------------------------------
  1639. @pytest.mark.asyncio
  1640. @pytest.mark.integration
  1641. async def test_operator_cannot_slice_others_archive(
  1642. self, async_client, auth_setup, archive_factory, printer_factory
  1643. ):
  1644. printer = await printer_factory()
  1645. archive = await archive_factory(printer.id, created_by_id=auth_setup["operator2_user"]["id"])
  1646. resp = await async_client.post(
  1647. f"/api/v1/archives/{archive.id}/slice",
  1648. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1649. json=self._SLICE_BODY,
  1650. )
  1651. assert resp.status_code == 404
  1652. assert resp.json()["detail"] == "Archive not found"
  1653. @pytest.mark.asyncio
  1654. @pytest.mark.integration
  1655. async def test_operator_can_slice_own_archive(self, async_client, auth_setup, archive_factory, printer_factory):
  1656. printer = await printer_factory()
  1657. archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
  1658. resp = await async_client.post(
  1659. f"/api/v1/archives/{archive.id}/slice",
  1660. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1661. json=self._SLICE_BODY,
  1662. )
  1663. # Past the gate — the archive's source file isn't on disk in tests.
  1664. assert resp.status_code == 404
  1665. assert resp.json()["detail"] == "Archive source file missing on disk"
  1666. # --- slice-job polling -------------------------------------------------
  1667. @pytest.mark.asyncio
  1668. @pytest.mark.integration
  1669. async def test_slice_job_polling_is_owner_scoped(self, async_client, auth_setup):
  1670. from backend.app.services.slice_dispatch import slice_dispatch
  1671. async def _noop(_job_id):
  1672. return {}
  1673. job = await slice_dispatch.enqueue(
  1674. kind="library_file",
  1675. source_id=1,
  1676. source_name="secret_model.3mf",
  1677. owner_id=auth_setup["operator2_user"]["id"],
  1678. run=_noop,
  1679. )
  1680. # Non-owner without READ_ALL cannot see the job (404, not 403).
  1681. other = await async_client.get(
  1682. f"/api/v1/slice-jobs/{job.id}",
  1683. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1684. )
  1685. assert other.status_code == 404
  1686. # The owner and a READ_ALL admin can.
  1687. owner = await async_client.get(
  1688. f"/api/v1/slice-jobs/{job.id}",
  1689. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  1690. )
  1691. assert owner.status_code == 200
  1692. admin = await async_client.get(
  1693. f"/api/v1/slice-jobs/{job.id}",
  1694. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1695. )
  1696. assert admin.status_code == 200
  1697. # --- pipeline source resolution ----------------------------------------
  1698. @pytest.mark.asyncio
  1699. @pytest.mark.integration
  1700. async def test_pipeline_run_cannot_reference_others_library_file(
  1701. self, async_client, auth_setup, library_file_factory, db_session
  1702. ):
  1703. """A pipeline runner with READ_OWN cannot resolve another user's source.
  1704. The built-in Operators group has no pipeline permissions, so this uses a
  1705. custom group carrying PIPELINES_RUN + READ_OWN — the realistic shape of
  1706. the exposure. check-eligibility resolves the source before any
  1707. eligibility work, so the ownership gate is what returns 404.
  1708. """
  1709. from backend.app.models.slicer_pipeline import SlicerPipeline
  1710. admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  1711. group_resp = await async_client.post(
  1712. "/api/v1/groups/",
  1713. headers=admin_headers,
  1714. json={
  1715. "name": "pipeline_runners",
  1716. "permissions": [
  1717. "pipelines:read",
  1718. "pipelines:run",
  1719. "library:read_own",
  1720. "archives:read_own",
  1721. ],
  1722. },
  1723. )
  1724. assert group_resp.status_code == 201, group_resp.text
  1725. group_id = group_resp.json()["id"]
  1726. await async_client.post(
  1727. "/api/v1/users/",
  1728. headers=admin_headers,
  1729. json={"username": "runner1", "password": "Runnerpass1!", "group_ids": [group_id]},
  1730. )
  1731. runner_login = await async_client.post(
  1732. "/api/v1/auth/login",
  1733. json={"username": "runner1", "password": "Runnerpass1!"},
  1734. )
  1735. runner_token = runner_login.json()["access_token"]
  1736. pipeline = SlicerPipeline(
  1737. name="Cross-user pipeline",
  1738. printer_preset_source="local",
  1739. printer_preset_id="1",
  1740. process_preset_source="local",
  1741. process_preset_id="2",
  1742. filament_presets_json="[]",
  1743. target_kind="printer_class",
  1744. target_model_class="Bambu Lab X1 Carbon",
  1745. )
  1746. db_session.add(pipeline)
  1747. await db_session.commit()
  1748. await db_session.refresh(pipeline)
  1749. # Source owned by operator2, not the runner.
  1750. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1751. resp = await async_client.post(
  1752. f"/api/v1/slicer-pipelines/{pipeline.id}/check-eligibility",
  1753. headers={"Authorization": f"Bearer {runner_token}"},
  1754. json={"source_library_file_id": file.id},
  1755. )
  1756. assert resp.status_code == 404
  1757. assert resp.json()["detail"] == "File not found"
  1758. class TestLibraryAddToQueueOwnership(TestOwnershipPermissionsSetup):
  1759. """The bulk add-to-queue path must scope reads the way its siblings do.
  1760. ``POST /library/files/add-to-queue`` resolved its files by raw id and gated
  1761. only on QUEUE_CREATE, so a READ_OWN operator could queue -- and therefore
  1762. print, and then hold the archive of -- a file a direct GET on the same id
  1763. answers 404 for. Same shape as the slice path above.
  1764. An invisible row is dropped before the loop, so it reports as the plain
  1765. "File not found" an unknown id gets: the response must not say which ids
  1766. exist. With nothing added the route now answers 400, so the assertions read
  1767. the reasons out of ``detail``.
  1768. """
  1769. @pytest.fixture
  1770. async def library_file_factory(self, db_session):
  1771. _counter = [0]
  1772. async def _create_file(**kwargs):
  1773. from backend.app.models.library import LibraryFile
  1774. _counter[0] += 1
  1775. defaults = {
  1776. "filename": f"queue_src_{_counter[0]}.gcode.3mf",
  1777. "file_path": f"library/queue_src_{_counter[0]}.gcode.3mf",
  1778. "file_type": "3mf",
  1779. "file_size": 1024,
  1780. }
  1781. defaults.update(kwargs)
  1782. row = LibraryFile(**defaults)
  1783. db_session.add(row)
  1784. await db_session.commit()
  1785. await db_session.refresh(row)
  1786. return row
  1787. return _create_file
  1788. @pytest.mark.asyncio
  1789. @pytest.mark.integration
  1790. async def test_operator_cannot_queue_others_library_file(
  1791. self, async_client: AsyncClient, auth_setup, library_file_factory
  1792. ):
  1793. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1794. resp = await async_client.post(
  1795. "/api/v1/library/files/add-to-queue",
  1796. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1797. json={"file_ids": [file.id]},
  1798. )
  1799. assert resp.status_code == 400
  1800. errors = resp.json()["detail"]["errors"]
  1801. assert [e["error"] for e in errors] == ["File not found"]
  1802. # Indistinguishable from an id that was never there.
  1803. assert errors[0]["filename"] == "(not found)"
  1804. @pytest.mark.asyncio
  1805. @pytest.mark.integration
  1806. async def test_operator_can_queue_own_library_file(
  1807. self, async_client: AsyncClient, auth_setup, library_file_factory
  1808. ):
  1809. """Control: the gate lets the owner through to the on-disk check."""
  1810. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  1811. resp = await async_client.post(
  1812. "/api/v1/library/files/add-to-queue",
  1813. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1814. json={"file_ids": [file.id]},
  1815. )
  1816. assert resp.status_code == 400
  1817. errors = resp.json()["detail"]["errors"]
  1818. assert [e["error"] for e in errors] == ["File not found on disk"]
  1819. @pytest.mark.asyncio
  1820. @pytest.mark.integration
  1821. async def test_ownerless_file_needs_read_all(self, async_client: AsyncClient, auth_setup, library_file_factory):
  1822. """A row with no owner is not everyone's row -- fail closed.
  1823. Matches _ensure_library_file_visible, which the read routes use.
  1824. """
  1825. file = await library_file_factory(created_by_id=None)
  1826. resp = await async_client.post(
  1827. "/api/v1/library/files/add-to-queue",
  1828. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1829. json={"file_ids": [file.id]},
  1830. )
  1831. assert resp.status_code == 400
  1832. assert resp.json()["detail"]["errors"][0]["error"] == "File not found"
  1833. admin = await async_client.post(
  1834. "/api/v1/library/files/add-to-queue",
  1835. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1836. json={"file_ids": [file.id]},
  1837. )
  1838. # READ_ALL sees it and reaches the on-disk check.
  1839. assert admin.json()["detail"]["errors"][0]["error"] == "File not found on disk"