test_ownership_permissions.py 79 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004
  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. defaults = {
  728. "name": f"TestFolder_{_counter[0]}",
  729. }
  730. defaults.update(kwargs)
  731. folder = LibraryFolder(**defaults)
  732. db_session.add(folder)
  733. await db_session.commit()
  734. await db_session.refresh(folder)
  735. return folder
  736. return _create_folder
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory):
  740. """Admin can delete any library file."""
  741. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  742. response = await async_client.delete(
  743. f"/api/v1/library/files/{file.id}",
  744. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  745. )
  746. assert response.status_code == 200
  747. @pytest.mark.asyncio
  748. @pytest.mark.integration
  749. async def test_operator_can_delete_own_library_file(
  750. self, async_client: AsyncClient, auth_setup, library_file_factory
  751. ):
  752. """Operator can delete their own library file."""
  753. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  754. response = await async_client.delete(
  755. f"/api/v1/library/files/{file.id}",
  756. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  757. )
  758. assert response.status_code == 200
  759. @pytest.mark.asyncio
  760. @pytest.mark.integration
  761. async def test_operator_cannot_delete_others_library_file(
  762. self, async_client: AsyncClient, auth_setup, library_file_factory
  763. ):
  764. """Operator cannot delete another user's library file."""
  765. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  766. response = await async_client.delete(
  767. f"/api/v1/library/files/{file.id}",
  768. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  769. )
  770. assert response.status_code == 403
  771. @pytest.mark.asyncio
  772. @pytest.mark.integration
  773. async def test_operator_can_update_own_library_file(
  774. self, async_client: AsyncClient, auth_setup, library_file_factory
  775. ):
  776. """Operator can update their own library file."""
  777. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  778. response = await async_client.put(
  779. f"/api/v1/library/files/{file.id}",
  780. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  781. json={"filename": "renamed.3mf"},
  782. )
  783. assert response.status_code == 200
  784. @pytest.mark.asyncio
  785. @pytest.mark.integration
  786. async def test_operator_cannot_update_others_library_file(
  787. self, async_client: AsyncClient, auth_setup, library_file_factory
  788. ):
  789. """Operator cannot update another user's library file."""
  790. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  791. response = await async_client.put(
  792. f"/api/v1/library/files/{file.id}",
  793. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  794. json={"filename": "renamed.3mf"},
  795. )
  796. assert response.status_code == 403
  797. # ========================================================================
  798. # Folder deletion (#1781): folders have no ownership tracking, so users
  799. # with only library:delete_own may delete empty, non-external, non-linked
  800. # folders. Everything else still requires library:delete_all.
  801. # ========================================================================
  802. @pytest.mark.asyncio
  803. @pytest.mark.integration
  804. async def test_operator_can_delete_empty_folder(
  805. self, async_client: AsyncClient, auth_setup, library_folder_factory
  806. ):
  807. """A user with library:delete_own can delete an empty folder (#1781)."""
  808. folder = await library_folder_factory(name="EmptyFolder")
  809. response = await async_client.delete(
  810. f"/api/v1/library/folders/{folder.id}",
  811. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  812. )
  813. assert response.status_code == 200
  814. @pytest.mark.asyncio
  815. @pytest.mark.integration
  816. async def test_viewer_cannot_delete_empty_folder(
  817. self, async_client: AsyncClient, auth_setup, library_folder_factory
  818. ):
  819. """No delete permission at all still means no folder deletion."""
  820. folder = await library_folder_factory(name="EmptyFolder")
  821. response = await async_client.delete(
  822. f"/api/v1/library/folders/{folder.id}",
  823. headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
  824. )
  825. assert response.status_code == 403
  826. @pytest.mark.asyncio
  827. @pytest.mark.integration
  828. async def test_operator_cannot_delete_folder_with_files(
  829. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  830. ):
  831. """Non-empty folders still require library:delete_all."""
  832. folder = await library_folder_factory(name="FullFolder")
  833. await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
  834. response = await async_client.delete(
  835. f"/api/v1/library/folders/{folder.id}",
  836. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  837. )
  838. assert response.status_code == 403
  839. @pytest.mark.asyncio
  840. @pytest.mark.integration
  841. async def test_operator_cannot_delete_folder_with_trashed_file(
  842. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  843. ):
  844. """Trashed files count as content: cascade would hard-drop them and
  845. silently break trash restore for their owner."""
  846. from datetime import datetime, timezone
  847. folder = await library_folder_factory(name="TrashedContentFolder")
  848. await library_file_factory(
  849. folder_id=folder.id,
  850. created_by_id=auth_setup["operator2_user"]["id"],
  851. deleted_at=datetime.now(timezone.utc),
  852. )
  853. response = await async_client.delete(
  854. f"/api/v1/library/folders/{folder.id}",
  855. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  856. )
  857. assert response.status_code == 403
  858. @pytest.mark.asyncio
  859. @pytest.mark.integration
  860. async def test_operator_cannot_delete_folder_with_subfolder(
  861. self, async_client: AsyncClient, auth_setup, library_folder_factory
  862. ):
  863. """A folder containing subfolders (even empty ones) is not empty."""
  864. parent = await library_folder_factory(name="ParentFolder")
  865. await library_folder_factory(name="ChildFolder", parent_id=parent.id)
  866. response = await async_client.delete(
  867. f"/api/v1/library/folders/{parent.id}",
  868. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  869. )
  870. assert response.status_code == 403
  871. @pytest.mark.asyncio
  872. @pytest.mark.integration
  873. async def test_operator_cannot_delete_external_folder(
  874. self, async_client: AsyncClient, auth_setup, library_folder_factory
  875. ):
  876. """Deleting an external folder unmounts an operator-configured mount
  877. for everyone — stays behind library:delete_all even when empty."""
  878. folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
  879. response = await async_client.delete(
  880. f"/api/v1/library/folders/{folder.id}",
  881. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  882. )
  883. assert response.status_code == 403
  884. @pytest.mark.asyncio
  885. @pytest.mark.integration
  886. async def test_operator_cannot_delete_linked_folder(
  887. self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
  888. ):
  889. """Project/archive links are created via update_all, so unlinking by
  890. deletion stays admin-only even for empty folders."""
  891. from backend.app.models.project import Project
  892. project = Project(name="LinkTestProject")
  893. db_session.add(project)
  894. await db_session.commit()
  895. await db_session.refresh(project)
  896. folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
  897. response = await async_client.delete(
  898. f"/api/v1/library/folders/{folder.id}",
  899. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  900. )
  901. assert response.status_code == 403
  902. @pytest.mark.asyncio
  903. @pytest.mark.integration
  904. async def test_admin_can_delete_folder_with_contents(
  905. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  906. ):
  907. """library:delete_all keeps full cascade deletion."""
  908. folder = await library_folder_factory(name="AdminFolder")
  909. await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
  910. response = await async_client.delete(
  911. f"/api/v1/library/folders/{folder.id}",
  912. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  913. )
  914. assert response.status_code == 200
  915. @pytest.mark.asyncio
  916. @pytest.mark.integration
  917. async def test_bulk_delete_operator_folders_empty_only(
  918. self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
  919. ):
  920. """Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
  921. empty_folder = await library_folder_factory(name="BulkEmpty")
  922. full_folder = await library_folder_factory(name="BulkFull")
  923. await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
  924. response = await async_client.post(
  925. "/api/v1/library/bulk-delete",
  926. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  927. json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
  928. )
  929. assert response.status_code == 200
  930. result = response.json()
  931. assert result["deleted_folders"] == 1
  932. assert result["deleted_files"] == 0
  933. @pytest.mark.asyncio
  934. @pytest.mark.integration
  935. async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
  936. """Bulk delete only deletes files the user owns."""
  937. own_file = await library_file_factory(
  938. filename="own.3mf",
  939. created_by_id=auth_setup["operator_user"]["id"],
  940. )
  941. other_file = await library_file_factory(
  942. filename="other.3mf",
  943. created_by_id=auth_setup["operator2_user"]["id"],
  944. )
  945. response = await async_client.post(
  946. "/api/v1/library/bulk-delete",
  947. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  948. json={"file_ids": [own_file.id, other_file.id], "folder_ids": []},
  949. )
  950. assert response.status_code == 200
  951. result = response.json()
  952. # Should only delete the owned file; other_file is skipped (but skipped count not in response)
  953. assert result["deleted_files"] == 1
  954. class TestAuthDisabledPermissions:
  955. """Tests that verify all operations are allowed when auth is disabled."""
  956. @pytest.mark.asyncio
  957. @pytest.mark.integration
  958. async def test_delete_archive_without_auth(
  959. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  960. ):
  961. """When auth is disabled, anyone can delete archives."""
  962. printer = await printer_factory()
  963. archive = await archive_factory(printer.id)
  964. response = await async_client.delete(f"/api/v1/archives/{archive.id}")
  965. assert response.status_code == 200
  966. @pytest.mark.asyncio
  967. @pytest.mark.integration
  968. async def test_update_archive_without_auth(
  969. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  970. ):
  971. """When auth is disabled, anyone can update archives."""
  972. printer = await printer_factory()
  973. archive = await archive_factory(printer.id)
  974. response = await async_client.patch(
  975. f"/api/v1/archives/{archive.id}",
  976. json={"print_name": "Updated Name"},
  977. )
  978. assert response.status_code == 200
  979. class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
  980. """Tests for user items count endpoint and deletion with items."""
  981. @pytest.mark.asyncio
  982. @pytest.mark.integration
  983. async def test_get_user_items_count(
  984. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  985. ):
  986. """Verify items count endpoint returns correct counts."""
  987. printer = await printer_factory()
  988. user_id = auth_setup["operator_user"]["id"]
  989. # Create some items for the operator
  990. await archive_factory(printer.id, created_by_id=user_id)
  991. await archive_factory(printer.id, created_by_id=user_id)
  992. response = await async_client.get(
  993. f"/api/v1/users/{user_id}/items-count",
  994. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  995. )
  996. assert response.status_code == 200
  997. counts = response.json()
  998. assert counts["archives"] >= 2
  999. assert "queue_items" in counts
  1000. assert "library_files" in counts
  1001. @pytest.mark.asyncio
  1002. @pytest.mark.integration
  1003. async def test_delete_user_keeps_items(
  1004. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1005. ):
  1006. """Verify deleting user without delete_items keeps items (ownerless)."""
  1007. printer = await printer_factory()
  1008. user_id = auth_setup["operator2_user"]["id"]
  1009. # Create archive for operator2
  1010. archive = await archive_factory(printer.id, created_by_id=user_id)
  1011. archive_id = archive.id
  1012. # Delete user without deleting items
  1013. response = await async_client.delete(
  1014. f"/api/v1/users/{user_id}?delete_items=false",
  1015. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1016. )
  1017. assert response.status_code == 204
  1018. # Verify archive still exists but is now ownerless
  1019. archive_response = await async_client.get(
  1020. f"/api/v1/archives/{archive_id}",
  1021. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1022. )
  1023. assert archive_response.status_code == 200
  1024. assert archive_response.json()["created_by_id"] is None
  1025. @pytest.mark.asyncio
  1026. @pytest.mark.integration
  1027. async def test_delete_user_with_items(
  1028. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1029. ):
  1030. """Verify deleting user with delete_items=true removes their items."""
  1031. printer = await printer_factory()
  1032. # Create a new user with items
  1033. create_response = await async_client.post(
  1034. "/api/v1/users/",
  1035. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1036. json={
  1037. "username": "deletewithitems",
  1038. "password": "Password123!",
  1039. },
  1040. )
  1041. user_id = create_response.json()["id"]
  1042. # Create archive for this user
  1043. archive = await archive_factory(printer.id, created_by_id=user_id)
  1044. archive_id = archive.id
  1045. # Delete user WITH deleting items
  1046. response = await async_client.delete(
  1047. f"/api/v1/users/{user_id}?delete_items=true",
  1048. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1049. )
  1050. assert response.status_code == 204
  1051. # Verify archive was deleted
  1052. archive_response = await async_client.get(
  1053. f"/api/v1/archives/{archive_id}",
  1054. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1055. )
  1056. assert archive_response.status_code == 404
  1057. class TestReadIDORClosure(TestOwnershipPermissionsSetup):
  1058. """Regression tests pinning maziggy/bambuddy-security #2 — IDOR on
  1059. archives / library / queue read paths.
  1060. Before the fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat
  1061. "see everything" permissions even though the write side was split into
  1062. OWN/ALL. An operator with only ARCHIVES_READ could read, download, and
  1063. queue any user's archive via direct id reference. These tests pin the
  1064. bambuddy_archive_idor.py and bambuddy_archive_viewer_idor.py PoC paths
  1065. so the IDOR can't regress silently.
  1066. """
  1067. @pytest.mark.asyncio
  1068. @pytest.mark.integration
  1069. async def test_operator_get_others_archive_returns_404_not_200(
  1070. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1071. ):
  1072. """PoC #2 read path. operator1 GET /archives/{id} where id is admin's
  1073. archive must NOT leak the row. 404 (not 403) so the operator can't
  1074. enumerate which ids exist — same shape as a nonexistent id."""
  1075. printer = await printer_factory()
  1076. archive = await archive_factory(
  1077. printer.id,
  1078. print_name="Admin Archive",
  1079. created_by_id=auth_setup["admin_user"]["id"],
  1080. )
  1081. response = await async_client.get(
  1082. f"/api/v1/archives/{archive.id}",
  1083. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1084. )
  1085. assert response.status_code == 404
  1086. @pytest.mark.asyncio
  1087. @pytest.mark.integration
  1088. async def test_operator_download_others_archive_returns_404(
  1089. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1090. ):
  1091. """Viewer-IDOR PoC path: GET /archives/{id}/download on admin's archive.
  1092. Before the fix this streamed the 3MF body straight to a viewer-tier
  1093. token."""
  1094. printer = await printer_factory()
  1095. archive = await archive_factory(
  1096. printer.id,
  1097. print_name="Admin Archive 2",
  1098. created_by_id=auth_setup["admin_user"]["id"],
  1099. )
  1100. response = await async_client.get(
  1101. f"/api/v1/archives/{archive.id}/download",
  1102. headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
  1103. )
  1104. assert response.status_code == 404
  1105. @pytest.mark.asyncio
  1106. @pytest.mark.integration
  1107. async def test_operator_list_archives_excludes_others(
  1108. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1109. ):
  1110. """GET /archives/ must filter to own archives only for OWN-level callers."""
  1111. printer = await printer_factory()
  1112. own = await archive_factory(
  1113. printer.id, print_name="Operator's Own", created_by_id=auth_setup["operator_user"]["id"]
  1114. )
  1115. others = await archive_factory(printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"])
  1116. response = await async_client.get(
  1117. "/api/v1/archives/",
  1118. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1119. )
  1120. assert response.status_code == 200
  1121. returned_ids = {a["id"] for a in response.json()}
  1122. assert own.id in returned_ids
  1123. assert others.id not in returned_ids
  1124. @pytest.mark.asyncio
  1125. @pytest.mark.integration
  1126. async def test_admin_list_archives_includes_all(
  1127. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1128. ):
  1129. """ARCHIVES_READ_ALL → admin sees own + every user's archives."""
  1130. printer = await printer_factory()
  1131. admin_archive = await archive_factory(
  1132. printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"]
  1133. )
  1134. operator_archive = await archive_factory(
  1135. printer.id, print_name="Operator's", created_by_id=auth_setup["operator_user"]["id"]
  1136. )
  1137. response = await async_client.get(
  1138. "/api/v1/archives/",
  1139. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1140. )
  1141. assert response.status_code == 200
  1142. returned_ids = {a["id"] for a in response.json()}
  1143. assert admin_archive.id in returned_ids
  1144. assert operator_archive.id in returned_ids
  1145. @pytest.mark.asyncio
  1146. @pytest.mark.integration
  1147. async def test_operator_cannot_queue_others_archive(
  1148. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1149. ):
  1150. """PoC #2 queue path. POST /queue/ with admin's archive_id as
  1151. operator1 must return 404, not create a queue item. Before the fix
  1152. this returned 201 and queued the admin archive (Landon's CONFIRMED
  1153. line in the PoC)."""
  1154. printer = await printer_factory()
  1155. archive = await archive_factory(
  1156. printer.id,
  1157. print_name="Admin Archive (queue-target)",
  1158. created_by_id=auth_setup["admin_user"]["id"],
  1159. )
  1160. response = await async_client.post(
  1161. "/api/v1/queue/",
  1162. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1163. json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
  1164. )
  1165. assert response.status_code == 404
  1166. @pytest.mark.asyncio
  1167. @pytest.mark.integration
  1168. async def test_admin_can_queue_others_archive(
  1169. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1170. ):
  1171. """Belt-and-suspenders for the ALL path: admin (ARCHIVES_READ_ALL) can
  1172. queue a user's archive on their behalf — common workshop pattern."""
  1173. printer = await printer_factory()
  1174. archive = await archive_factory(
  1175. printer.id,
  1176. print_name="Operator's archive (queue by admin)",
  1177. created_by_id=auth_setup["operator_user"]["id"],
  1178. )
  1179. response = await async_client.post(
  1180. "/api/v1/queue/",
  1181. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1182. json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
  1183. )
  1184. assert response.status_code == 200
  1185. @pytest.mark.asyncio
  1186. @pytest.mark.integration
  1187. async def test_operator_get_others_library_file_returns_404(
  1188. self, async_client: AsyncClient, auth_setup, db_session
  1189. ):
  1190. """Library IDOR closure (same shape as archives — closed in the same PR
  1191. per maziggy/bambuddy-security #2)."""
  1192. from backend.app.models.library import LibraryFile
  1193. admin_file = LibraryFile(
  1194. filename="admin_secret.3mf",
  1195. file_path="library/admin_secret.3mf",
  1196. file_type="3mf",
  1197. file_size=2048,
  1198. created_by_id=auth_setup["admin_user"]["id"],
  1199. )
  1200. db_session.add(admin_file)
  1201. await db_session.commit()
  1202. await db_session.refresh(admin_file)
  1203. response = await async_client.get(
  1204. f"/api/v1/library/files/{admin_file.id}",
  1205. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1206. )
  1207. assert response.status_code == 404
  1208. @pytest.mark.asyncio
  1209. @pytest.mark.integration
  1210. async def test_operator_list_library_files_excludes_others(self, async_client: AsyncClient, auth_setup, db_session):
  1211. from backend.app.models.library import LibraryFile
  1212. own = LibraryFile(
  1213. filename="my_file.3mf",
  1214. file_path="library/my_file.3mf",
  1215. file_type="3mf",
  1216. file_size=1024,
  1217. created_by_id=auth_setup["operator_user"]["id"],
  1218. )
  1219. others = LibraryFile(
  1220. filename="admin_file.3mf",
  1221. file_path="library/admin_file.3mf",
  1222. file_type="3mf",
  1223. file_size=1024,
  1224. created_by_id=auth_setup["admin_user"]["id"],
  1225. )
  1226. db_session.add_all([own, others])
  1227. await db_session.commit()
  1228. await db_session.refresh(own)
  1229. await db_session.refresh(others)
  1230. response = await async_client.get(
  1231. "/api/v1/library/files",
  1232. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1233. )
  1234. assert response.status_code == 200
  1235. returned_ids = {f["id"] for f in response.json()}
  1236. assert own.id in returned_ids
  1237. assert others.id not in returned_ids
  1238. @pytest.mark.asyncio
  1239. @pytest.mark.integration
  1240. async def test_operator_queue_list_excludes_others_items(
  1241. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1242. ):
  1243. """GET /queue/ must filter to own queue items only for OWN callers —
  1244. same shape as the archive list."""
  1245. from backend.app.models.print_queue import PrintQueueItem
  1246. printer = await printer_factory()
  1247. archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["operator_user"]["id"])
  1248. own_item = PrintQueueItem(
  1249. archive_id=archive.id,
  1250. printer_id=printer.id,
  1251. status="pending",
  1252. position=1,
  1253. created_by_id=auth_setup["operator_user"]["id"],
  1254. )
  1255. admin_item = PrintQueueItem(
  1256. archive_id=archive.id,
  1257. printer_id=printer.id,
  1258. status="pending",
  1259. position=2,
  1260. created_by_id=auth_setup["admin_user"]["id"],
  1261. )
  1262. db_session.add_all([own_item, admin_item])
  1263. await db_session.commit()
  1264. await db_session.refresh(own_item)
  1265. await db_session.refresh(admin_item)
  1266. response = await async_client.get(
  1267. "/api/v1/queue/",
  1268. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1269. )
  1270. assert response.status_code == 200
  1271. returned_ids = {q["id"] for q in response.json()}
  1272. assert own_item.id in returned_ids
  1273. assert admin_item.id not in returned_ids
  1274. @pytest.mark.asyncio
  1275. @pytest.mark.integration
  1276. async def test_operator_get_others_queue_item_returns_404(
  1277. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1278. ):
  1279. """Direct-id queue item access — same enumeration risk as archive get."""
  1280. from backend.app.models.print_queue import PrintQueueItem
  1281. printer = await printer_factory()
  1282. archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["admin_user"]["id"])
  1283. admin_item = PrintQueueItem(
  1284. archive_id=archive.id,
  1285. printer_id=printer.id,
  1286. status="pending",
  1287. position=1,
  1288. created_by_id=auth_setup["admin_user"]["id"],
  1289. )
  1290. db_session.add(admin_item)
  1291. await db_session.commit()
  1292. await db_session.refresh(admin_item)
  1293. response = await async_client.get(
  1294. f"/api/v1/queue/{admin_item.id}",
  1295. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1296. )
  1297. assert response.status_code == 404
  1298. @pytest.mark.asyncio
  1299. @pytest.mark.integration
  1300. async def test_auth_disabled_preserves_single_tenant_read_all(
  1301. self, async_client: AsyncClient, archive_factory, printer_factory
  1302. ):
  1303. """With auth disabled, ARCHIVES_READ resolves to read-all (can_modify_all=True
  1304. in require_ownership_permission's auth-disabled branch). Existing
  1305. single-user installs see no behavior change."""
  1306. printer = await printer_factory()
  1307. archive = await archive_factory(printer.id, print_name="Anonymous", created_by_id=None)
  1308. # No Authorization header — auth-disabled mode.
  1309. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1310. # Either 200 (auth disabled in this test session) or 401 (auth enabled
  1311. # from a prior test) — both are acceptable; the IDOR closure does not
  1312. # change auth-enable/disable behavior. Pin not-404 to avoid masking a
  1313. # regression where auth-disabled callers would lose access.
  1314. assert response.status_code in (200, 401)
  1315. # Every archive WRITE sub-resource route: (id, http method, path suffix, request kwargs).
  1316. # The ownership gate (_ensure_archive_visible) fires immediately after the fetch,
  1317. # before any resource-specific logic, so a not-owned / ownerless row 404s regardless
  1318. # of whether the timelapse / photo / source / f3d actually exists. Upload routes still
  1319. # need a body so FastAPI reaches the handler instead of 422-ing on the missing File(...).
  1320. _WRITE_SUBRESOURCE_ROUTES = [
  1321. ("favorite", "post", "/favorite", {}),
  1322. ("timelapse_delete", "delete", "/timelapse", {}),
  1323. ("photo_upload", "post", "/photos", {"files": {"file": ("x.jpg", b"\x89PNG\r\n\x1a\n", "image/jpeg")}}),
  1324. ("photo_delete", "delete", "/photos/nonexistent.jpg", {}),
  1325. ("project_page", "patch", "/project-page", {"json": {"title": "hijacked"}}),
  1326. ("source_upload", "post", "/source", {"files": {"file": ("x.3mf", b"PK\x03\x04", "application/octet-stream")}}),
  1327. ("source_delete", "delete", "/source", {}),
  1328. ("f3d_upload", "post", "/f3d", {"files": {"file": ("x.f3d", b"f3d-bytes", "application/octet-stream")}}),
  1329. ("f3d_delete", "delete", "/f3d", {}),
  1330. ]
  1331. class TestWriteSubResourceIDORClosure(TestOwnershipPermissionsSetup):
  1332. """Regression tests for the archive write SUB-RESOURCE IDOR.
  1333. The read sub-resource routes were closed under maziggy/bambuddy-security #2
  1334. via ``_ensure_archive_visible``, but the *write* sub-resource routes
  1335. (favorite, timelapse, photos, project-page, source, f3d) were left gating
  1336. on the bare ``RequirePermissionIfAuthEnabled(ARCHIVES_*_OWN)`` scope and
  1337. fetched the row by id only — never comparing ``created_by_id`` to the
  1338. caller. An operator holding only ``ARCHIVES_*_OWN`` (or an API key with
  1339. ``can_manage_archives``) could delete/overwrite files on ANY user's
  1340. archive, most severely rewriting the project-page metadata inside another
  1341. user's ``.3mf`` on disk. Each route is now gated by
  1342. ``require_ownership_permission`` + ``_ensure_archive_visible`` → 404 (not
  1343. 403, to stay non-enumerable and match the read side) on a not-owned or
  1344. ownerless row.
  1345. """
  1346. @pytest.mark.parametrize(
  1347. "name,method,suffix,kwargs",
  1348. _WRITE_SUBRESOURCE_ROUTES,
  1349. ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
  1350. )
  1351. @pytest.mark.asyncio
  1352. @pytest.mark.integration
  1353. async def test_operator_cannot_write_others_archive_subresource(
  1354. self,
  1355. async_client: AsyncClient,
  1356. auth_setup,
  1357. archive_factory,
  1358. printer_factory,
  1359. db_session,
  1360. name,
  1361. method,
  1362. suffix,
  1363. kwargs,
  1364. ):
  1365. """SECURITY.md rule 4: right credentials, wrong ownership → 404.
  1366. operator1 (ARCHIVES_*_OWN) targeting a route on admin's archive.
  1367. """
  1368. printer = await printer_factory()
  1369. archive = await archive_factory(
  1370. printer.id,
  1371. print_name="Admin's Archive",
  1372. created_by_id=auth_setup["admin_user"]["id"],
  1373. )
  1374. response = await getattr(async_client, method)(
  1375. f"/api/v1/archives/{archive.id}{suffix}",
  1376. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1377. **kwargs,
  1378. )
  1379. assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
  1380. @pytest.mark.parametrize(
  1381. "name,method,suffix,kwargs",
  1382. _WRITE_SUBRESOURCE_ROUTES,
  1383. ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
  1384. )
  1385. @pytest.mark.asyncio
  1386. @pytest.mark.integration
  1387. async def test_operator_cannot_write_ownerless_archive_subresource(
  1388. self,
  1389. async_client: AsyncClient,
  1390. auth_setup,
  1391. archive_factory,
  1392. printer_factory,
  1393. db_session,
  1394. name,
  1395. method,
  1396. suffix,
  1397. kwargs,
  1398. ):
  1399. """Ownerless rows (created_by_id = null, legacy data) require *_ALL — an
  1400. operator with only *_OWN has no 'I own this' claim, so fail closed → 404."""
  1401. printer = await printer_factory()
  1402. archive = await archive_factory(
  1403. printer.id,
  1404. print_name="Ownerless Archive",
  1405. created_by_id=None,
  1406. )
  1407. response = await getattr(async_client, method)(
  1408. f"/api/v1/archives/{archive.id}{suffix}",
  1409. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1410. **kwargs,
  1411. )
  1412. assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
  1413. @pytest.mark.asyncio
  1414. @pytest.mark.integration
  1415. async def test_operator_can_favorite_own_archive(
  1416. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1417. ):
  1418. """Positive control: the owner still gets through the new gate. Favorite
  1419. is the one write sub-resource that needs no pre-existing file, so it
  1420. cleanly proves the *_OWN happy path returns 200 (not a false 404)."""
  1421. printer = await printer_factory()
  1422. archive = await archive_factory(
  1423. printer.id,
  1424. print_name="Operator's Own",
  1425. created_by_id=auth_setup["operator_user"]["id"],
  1426. )
  1427. response = await async_client.post(
  1428. f"/api/v1/archives/{archive.id}/favorite",
  1429. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1430. )
  1431. assert response.status_code == 200
  1432. assert response.json()["is_favorite"] is True
  1433. @pytest.mark.asyncio
  1434. @pytest.mark.integration
  1435. async def test_admin_can_favorite_any_archive(
  1436. self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
  1437. ):
  1438. """Positive control for the *_ALL path: admin can act on a user's archive."""
  1439. printer = await printer_factory()
  1440. archive = await archive_factory(
  1441. printer.id,
  1442. print_name="Operator's Own",
  1443. created_by_id=auth_setup["operator_user"]["id"],
  1444. )
  1445. response = await async_client.post(
  1446. f"/api/v1/archives/{archive.id}/favorite",
  1447. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1448. )
  1449. assert response.status_code == 200
  1450. class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
  1451. """IDOR regression: slicing and slice-job polling must honour per-row ownership.
  1452. Before the fix, ``POST /library/files/{id}/slice`` and
  1453. ``POST /archives/{id}/slice`` gated only on ``LIBRARY_UPLOAD``, so a
  1454. READ_OWN operator could slice another user's model by raw id even though a
  1455. direct GET on that id returned 404 — the sliced output was then attributed
  1456. to and downloadable by the requester. ``GET /slice-jobs/{id}`` had no owner
  1457. scoping at all. ``POST /slicer-pipelines/{id}/run`` (and check-eligibility)
  1458. resolved the source by raw id with the same gap.
  1459. The slice route enforces the gate before touching the source bytes, so the
  1460. owner/READ_ALL "control" cases reach the later on-disk check (a distinct 404
  1461. detail) rather than a real slice — enough to prove the gate lets them past.
  1462. """
  1463. # Any preset triplet: the ownership 404 fires before preset resolution.
  1464. _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
  1465. @pytest.fixture
  1466. async def library_file_factory(self, db_session):
  1467. _counter = [0]
  1468. async def _create_file(**kwargs):
  1469. from backend.app.models.library import LibraryFile
  1470. _counter[0] += 1
  1471. defaults = {
  1472. "filename": f"slice_src_{_counter[0]}.3mf",
  1473. "file_path": f"library/slice_src_{_counter[0]}.3mf",
  1474. "file_type": "3mf",
  1475. "file_size": 1024,
  1476. }
  1477. defaults.update(kwargs)
  1478. row = LibraryFile(**defaults)
  1479. db_session.add(row)
  1480. await db_session.commit()
  1481. await db_session.refresh(row)
  1482. return row
  1483. return _create_file
  1484. # --- library file slice ------------------------------------------------
  1485. @pytest.mark.asyncio
  1486. @pytest.mark.integration
  1487. async def test_operator_cannot_slice_others_library_file(self, async_client, auth_setup, library_file_factory):
  1488. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1489. resp = await async_client.post(
  1490. f"/api/v1/library/files/{file.id}/slice",
  1491. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1492. json=self._SLICE_BODY,
  1493. )
  1494. assert resp.status_code == 404
  1495. # 404 (not 403) so a probing operator can't tell the id exists.
  1496. assert resp.json()["detail"] == "File not found"
  1497. @pytest.mark.asyncio
  1498. @pytest.mark.integration
  1499. async def test_operator_can_slice_own_library_file(self, async_client, auth_setup, library_file_factory):
  1500. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  1501. resp = await async_client.post(
  1502. f"/api/v1/library/files/{file.id}/slice",
  1503. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1504. json=self._SLICE_BODY,
  1505. )
  1506. # Past the ownership gate — only the on-disk source is missing in tests.
  1507. assert resp.status_code == 404
  1508. assert resp.json()["detail"] == "Source file missing on disk"
  1509. @pytest.mark.asyncio
  1510. @pytest.mark.integration
  1511. async def test_admin_can_slice_any_library_file(self, async_client, auth_setup, library_file_factory):
  1512. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1513. resp = await async_client.post(
  1514. f"/api/v1/library/files/{file.id}/slice",
  1515. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1516. json=self._SLICE_BODY,
  1517. )
  1518. # READ_ALL passes the gate even on another user's file.
  1519. assert resp.status_code == 404
  1520. assert resp.json()["detail"] == "Source file missing on disk"
  1521. # --- archive slice -----------------------------------------------------
  1522. @pytest.mark.asyncio
  1523. @pytest.mark.integration
  1524. async def test_operator_cannot_slice_others_archive(
  1525. self, async_client, auth_setup, archive_factory, printer_factory
  1526. ):
  1527. printer = await printer_factory()
  1528. archive = await archive_factory(printer.id, created_by_id=auth_setup["operator2_user"]["id"])
  1529. resp = await async_client.post(
  1530. f"/api/v1/archives/{archive.id}/slice",
  1531. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1532. json=self._SLICE_BODY,
  1533. )
  1534. assert resp.status_code == 404
  1535. assert resp.json()["detail"] == "Archive not found"
  1536. @pytest.mark.asyncio
  1537. @pytest.mark.integration
  1538. async def test_operator_can_slice_own_archive(self, async_client, auth_setup, archive_factory, printer_factory):
  1539. printer = await printer_factory()
  1540. archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
  1541. resp = await async_client.post(
  1542. f"/api/v1/archives/{archive.id}/slice",
  1543. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1544. json=self._SLICE_BODY,
  1545. )
  1546. # Past the gate — the archive's source file isn't on disk in tests.
  1547. assert resp.status_code == 404
  1548. assert resp.json()["detail"] == "Archive source file missing on disk"
  1549. # --- slice-job polling -------------------------------------------------
  1550. @pytest.mark.asyncio
  1551. @pytest.mark.integration
  1552. async def test_slice_job_polling_is_owner_scoped(self, async_client, auth_setup):
  1553. from backend.app.services.slice_dispatch import slice_dispatch
  1554. async def _noop(_job_id):
  1555. return {}
  1556. job = await slice_dispatch.enqueue(
  1557. kind="library_file",
  1558. source_id=1,
  1559. source_name="secret_model.3mf",
  1560. owner_id=auth_setup["operator2_user"]["id"],
  1561. run=_noop,
  1562. )
  1563. # Non-owner without READ_ALL cannot see the job (404, not 403).
  1564. other = await async_client.get(
  1565. f"/api/v1/slice-jobs/{job.id}",
  1566. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1567. )
  1568. assert other.status_code == 404
  1569. # The owner and a READ_ALL admin can.
  1570. owner = await async_client.get(
  1571. f"/api/v1/slice-jobs/{job.id}",
  1572. headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
  1573. )
  1574. assert owner.status_code == 200
  1575. admin = await async_client.get(
  1576. f"/api/v1/slice-jobs/{job.id}",
  1577. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1578. )
  1579. assert admin.status_code == 200
  1580. # --- pipeline source resolution ----------------------------------------
  1581. @pytest.mark.asyncio
  1582. @pytest.mark.integration
  1583. async def test_pipeline_run_cannot_reference_others_library_file(
  1584. self, async_client, auth_setup, library_file_factory, db_session
  1585. ):
  1586. """A pipeline runner with READ_OWN cannot resolve another user's source.
  1587. The built-in Operators group has no pipeline permissions, so this uses a
  1588. custom group carrying PIPELINES_RUN + READ_OWN — the realistic shape of
  1589. the exposure. check-eligibility resolves the source before any
  1590. eligibility work, so the ownership gate is what returns 404.
  1591. """
  1592. from backend.app.models.slicer_pipeline import SlicerPipeline
  1593. admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
  1594. group_resp = await async_client.post(
  1595. "/api/v1/groups/",
  1596. headers=admin_headers,
  1597. json={
  1598. "name": "pipeline_runners",
  1599. "permissions": [
  1600. "pipelines:read",
  1601. "pipelines:run",
  1602. "library:read_own",
  1603. "archives:read_own",
  1604. ],
  1605. },
  1606. )
  1607. assert group_resp.status_code == 201, group_resp.text
  1608. group_id = group_resp.json()["id"]
  1609. await async_client.post(
  1610. "/api/v1/users/",
  1611. headers=admin_headers,
  1612. json={"username": "runner1", "password": "Runnerpass1!", "group_ids": [group_id]},
  1613. )
  1614. runner_login = await async_client.post(
  1615. "/api/v1/auth/login",
  1616. json={"username": "runner1", "password": "Runnerpass1!"},
  1617. )
  1618. runner_token = runner_login.json()["access_token"]
  1619. pipeline = SlicerPipeline(
  1620. name="Cross-user pipeline",
  1621. printer_preset_source="local",
  1622. printer_preset_id="1",
  1623. process_preset_source="local",
  1624. process_preset_id="2",
  1625. filament_presets_json="[]",
  1626. target_kind="printer_class",
  1627. target_model_class="Bambu Lab X1 Carbon",
  1628. )
  1629. db_session.add(pipeline)
  1630. await db_session.commit()
  1631. await db_session.refresh(pipeline)
  1632. # Source owned by operator2, not the runner.
  1633. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1634. resp = await async_client.post(
  1635. f"/api/v1/slicer-pipelines/{pipeline.id}/check-eligibility",
  1636. headers={"Authorization": f"Bearer {runner_token}"},
  1637. json={"source_library_file_id": file.id},
  1638. )
  1639. assert resp.status_code == 404
  1640. assert resp.json()["detail"] == "File not found"
  1641. class TestLibraryAddToQueueOwnership(TestOwnershipPermissionsSetup):
  1642. """The bulk add-to-queue path must scope reads the way its siblings do.
  1643. ``POST /library/files/add-to-queue`` resolved its files by raw id and gated
  1644. only on QUEUE_CREATE, so a READ_OWN operator could queue -- and therefore
  1645. print, and then hold the archive of -- a file a direct GET on the same id
  1646. answers 404 for. Same shape as the slice path above.
  1647. An invisible row is dropped before the loop, so it reports as the plain
  1648. "File not found" an unknown id gets: the response must not say which ids
  1649. exist. With nothing added the route now answers 400, so the assertions read
  1650. the reasons out of ``detail``.
  1651. """
  1652. @pytest.fixture
  1653. async def library_file_factory(self, db_session):
  1654. _counter = [0]
  1655. async def _create_file(**kwargs):
  1656. from backend.app.models.library import LibraryFile
  1657. _counter[0] += 1
  1658. defaults = {
  1659. "filename": f"queue_src_{_counter[0]}.gcode.3mf",
  1660. "file_path": f"library/queue_src_{_counter[0]}.gcode.3mf",
  1661. "file_type": "3mf",
  1662. "file_size": 1024,
  1663. }
  1664. defaults.update(kwargs)
  1665. row = LibraryFile(**defaults)
  1666. db_session.add(row)
  1667. await db_session.commit()
  1668. await db_session.refresh(row)
  1669. return row
  1670. return _create_file
  1671. @pytest.mark.asyncio
  1672. @pytest.mark.integration
  1673. async def test_operator_cannot_queue_others_library_file(
  1674. self, async_client: AsyncClient, auth_setup, library_file_factory
  1675. ):
  1676. file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
  1677. resp = await async_client.post(
  1678. "/api/v1/library/files/add-to-queue",
  1679. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1680. json={"file_ids": [file.id]},
  1681. )
  1682. assert resp.status_code == 400
  1683. errors = resp.json()["detail"]["errors"]
  1684. assert [e["error"] for e in errors] == ["File not found"]
  1685. # Indistinguishable from an id that was never there.
  1686. assert errors[0]["filename"] == "(not found)"
  1687. @pytest.mark.asyncio
  1688. @pytest.mark.integration
  1689. async def test_operator_can_queue_own_library_file(
  1690. self, async_client: AsyncClient, auth_setup, library_file_factory
  1691. ):
  1692. """Control: the gate lets the owner through to the on-disk check."""
  1693. file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
  1694. resp = await async_client.post(
  1695. "/api/v1/library/files/add-to-queue",
  1696. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1697. json={"file_ids": [file.id]},
  1698. )
  1699. assert resp.status_code == 400
  1700. errors = resp.json()["detail"]["errors"]
  1701. assert [e["error"] for e in errors] == ["File not found on disk"]
  1702. @pytest.mark.asyncio
  1703. @pytest.mark.integration
  1704. async def test_ownerless_file_needs_read_all(self, async_client: AsyncClient, auth_setup, library_file_factory):
  1705. """A row with no owner is not everyone's row -- fail closed.
  1706. Matches _ensure_library_file_visible, which the read routes use.
  1707. """
  1708. file = await library_file_factory(created_by_id=None)
  1709. resp = await async_client.post(
  1710. "/api/v1/library/files/add-to-queue",
  1711. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  1712. json={"file_ids": [file.id]},
  1713. )
  1714. assert resp.status_code == 400
  1715. assert resp.json()["detail"]["errors"][0]["error"] == "File not found"
  1716. admin = await async_client.post(
  1717. "/api/v1/library/files/add-to-queue",
  1718. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  1719. json={"file_ids": [file.id]},
  1720. )
  1721. # READ_ALL sees it and reaches the on-disk check.
  1722. assert admin.json()["detail"]["errors"][0]["error"] == "File not found on disk"