test_queue_source_gate.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. """Every way of queueing an existing archive applies the same source checks.
  2. The queue, batch dispatch and webhook routes decide whether a caller may turn an
  3. archive into a print. They now agree: an archive the caller may not read, or
  4. one from a printer outside the caller's printers, reads as missing, and a
  5. missing archive or library file answers the same way as a hidden one.
  6. """
  7. from __future__ import annotations
  8. import pytest
  9. from httpx import AsyncClient
  10. from backend.tests.integration.test_printer_scope_1727 import TEAM_PERMISSIONS, _admin_token, _auth, _group, _user
  11. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  12. OWN_ONLY = [
  13. "printers:read",
  14. "queue:create",
  15. "queue:read_own",
  16. "archives:read_own",
  17. "archives:reprint_own",
  18. "api_keys:create",
  19. ]
  20. async def _key(client: AsyncClient, headers: dict[str, str], **fields) -> dict[str, str]:
  21. created = await client.post("/api/v1/api-keys/", headers=headers, json={"name": "k", "can_queue": True, **fields})
  22. assert created.status_code in (200, 201), created.text
  23. return {"X-API-Key": created.json()["key"]}
  24. async def _webhook(client: AsyncClient, key: dict[str, str], archive_id: int, printer_id: int):
  25. return await client.post(
  26. "/api/v1/webhook/queue/add", headers=key, json={"archive_id": archive_id, "printer_id": printer_id}
  27. )
  28. async def test_the_webhook_refuses_an_archive_its_owner_cannot_read(
  29. async_client: AsyncClient, printer_factory, archive_factory
  30. ):
  31. printer = await printer_factory(name="A")
  32. admin = await _admin_token(async_client)
  33. jwt, user_id = await _user(
  34. async_client, admin, "user", [await _group(async_client, admin, "g", permissions=OWN_ONLY)]
  35. )
  36. key = await _key(async_client, _auth(jwt))
  37. someone_elses = await archive_factory(printer.id)
  38. own = await archive_factory(printer.id, created_by_id=user_id)
  39. refused = await _webhook(async_client, key, someone_elses.id, printer.id)
  40. allowed = await _webhook(async_client, key, own.id, printer.id)
  41. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  42. assert allowed.status_code == 200, allowed.text
  43. async def test_the_webhook_refuses_an_archive_from_another_printer(
  44. async_client: AsyncClient, printer_factory, archive_factory
  45. ):
  46. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  47. admin = await _admin_token(async_client)
  48. key = await _key(async_client, _auth(admin), can_read_status=True, printer_ids=[a.id])
  49. on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
  50. refused = await _webhook(async_client, key, on_b.id, a.id)
  51. allowed = await _webhook(async_client, key, on_a.id, a.id)
  52. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  53. assert allowed.status_code == 200, allowed.text
  54. async def test_queueing_an_archive_from_a_printer_outside_the_team_is_refused(
  55. async_client: AsyncClient, printer_factory, archive_factory
  56. ):
  57. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  58. admin = await _admin_token(async_client)
  59. perms = await _group(async_client, admin, "perms", permissions=[*TEAM_PERMISSIONS, "archives:reprint_all"])
  60. team = await _group(async_client, admin, "team", printer_ids=[a.id])
  61. member = _auth((await _user(async_client, admin, "member", [perms, team]))[0])
  62. on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
  63. refused = await async_client.post(
  64. "/api/v1/queue/", headers=member, json={"archive_id": on_b.id, "printer_id": a.id}
  65. )
  66. allowed = await async_client.post(
  67. "/api/v1/queue/", headers=member, json={"archive_id": on_a.id, "printer_id": a.id}
  68. )
  69. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  70. assert allowed.status_code == 200, allowed.text
  71. async def test_dispatching_a_batch_checks_the_archive_printer(
  72. async_client: AsyncClient, printer_factory, archive_factory
  73. ):
  74. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  75. admin = await _admin_token(async_client)
  76. on_b = await archive_factory(b.id)
  77. # An order for archive B, printed on A, with one more run owed.
  78. batch = await async_client.post(
  79. "/api/v1/queue/batches",
  80. headers=_auth(admin),
  81. json={"name": "order", "archive_id": on_b.id, "plates": [{"plate_id": 1, "quantity_target": 2}]},
  82. )
  83. assert batch.status_code == 200, batch.text
  84. batch_id = batch.json()["id"]
  85. queued = await async_client.post(
  86. "/api/v1/queue/",
  87. headers=_auth(admin),
  88. json={"archive_id": on_b.id, "printer_id": a.id, "batch_id": batch_id, "plate_id": 1},
  89. )
  90. assert queued.status_code == 200, queued.text
  91. perms = await _group(
  92. async_client, admin, "perms", permissions=[*TEAM_PERMISSIONS, "archives:reprint_all", "queue:update_all"]
  93. )
  94. team = await _group(async_client, admin, "team", printer_ids=[a.id])
  95. member = _auth((await _user(async_client, admin, "member", [perms, team]))[0])
  96. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=member, json={})
  97. assert response.status_code == 404, response.text
  98. assert response.json()["detail"] == "Archive not found"
  99. @pytest.mark.parametrize(
  100. ("field", "detail"), [("archive_id", "Archive not found"), ("library_file_id", "Library file not found")]
  101. )
  102. async def test_a_missing_source_answers_like_a_hidden_one(async_client: AsyncClient, printer_factory, field, detail):
  103. printer = await printer_factory()
  104. response = await async_client.post("/api/v1/queue/", json={field: 9999, "printer_id": printer.id})
  105. assert (response.status_code, response.json()["detail"]) == (404, detail)
  106. async def test_a_key_without_an_owner_needs_read_access_for_an_ownerless_archive(
  107. async_client: AsyncClient, db_session, printer_factory, archive_factory
  108. ):
  109. from backend.app.core.auth import generate_api_key
  110. from backend.app.models.api_key import APIKey
  111. printer = await printer_factory()
  112. await _admin_token(async_client)
  113. ownerless = await archive_factory(printer.id)
  114. keys = {}
  115. for name, read in (("queue-only", False), ("queue-and-read", True)):
  116. full_key, key_hash, key_prefix = generate_api_key()
  117. db_session.add(
  118. APIKey(name=name, key_hash=key_hash, key_prefix=key_prefix, can_queue=True, can_read_status=read)
  119. )
  120. keys[name] = {"X-API-Key": full_key}
  121. await db_session.commit()
  122. refused = await _webhook(async_client, keys["queue-only"], ownerless.id, printer.id)
  123. allowed = await _webhook(async_client, keys["queue-and-read"], ownerless.id, printer.id)
  124. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  125. assert allowed.status_code == 200, allowed.text
  126. async def test_with_auth_off_the_webhook_queues_like_the_queue_route(
  127. async_client: AsyncClient, db_session, printer_factory, archive_factory
  128. ):
  129. # No owner to act for: only the key's printers apply, as on POST /queue/.
  130. from backend.app.core.auth import generate_api_key
  131. from backend.app.models.api_key import APIKey
  132. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  133. on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
  134. full_key, key_hash, key_prefix = generate_api_key()
  135. db_session.add(
  136. APIKey(
  137. name="k",
  138. key_hash=key_hash,
  139. key_prefix=key_prefix,
  140. can_queue=True,
  141. can_read_status=False,
  142. printer_ids=[a.id],
  143. )
  144. )
  145. await db_session.commit()
  146. key = {"X-API-Key": full_key}
  147. allowed = await _webhook(async_client, key, on_a.id, a.id)
  148. refused = await _webhook(async_client, key, on_b.id, a.id)
  149. assert allowed.status_code == 200, allowed.text
  150. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  151. async def test_a_key_without_an_owner_needs_read_access_for_an_ownerless_library_file(
  152. async_client: AsyncClient, db_session, printer_factory
  153. ):
  154. from backend.app.core.auth import generate_api_key
  155. from backend.app.models.api_key import APIKey
  156. from backend.app.models.library import LibraryFile
  157. printer = await printer_factory()
  158. await _admin_token(async_client)
  159. row = LibraryFile(filename="part.gcode.3mf", file_path="library/part.gcode.3mf", file_size=5, file_type="3mf")
  160. db_session.add(row)
  161. keys = {}
  162. for name, read in (("queue-only", False), ("queue-and-read", True)):
  163. full_key, key_hash, key_prefix = generate_api_key()
  164. db_session.add(
  165. APIKey(name=name, key_hash=key_hash, key_prefix=key_prefix, can_queue=True, can_read_status=read)
  166. )
  167. keys[name] = {"X-API-Key": full_key}
  168. await db_session.commit()
  169. body = {"library_file_id": row.id, "printer_id": printer.id}
  170. refused = await async_client.post("/api/v1/queue/", headers=keys["queue-only"], json=body)
  171. allowed = await async_client.post("/api/v1/queue/", headers=keys["queue-and-read"], json=body)
  172. assert (refused.status_code, refused.json()["detail"]) == (404, "Library file not found")
  173. assert allowed.status_code == 200, allowed.text
  174. async def test_a_deleted_archive_reads_as_missing(
  175. async_client: AsyncClient, db_session, printer_factory, archive_factory
  176. ):
  177. from datetime import datetime, timezone
  178. printer = await printer_factory()
  179. archive = await archive_factory(printer.id)
  180. archive.deleted_at = datetime.now(timezone.utc)
  181. await db_session.commit()
  182. response = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "printer_id": printer.id})
  183. assert (response.status_code, response.json()["detail"]) == (404, "Archive not found")
  184. async def test_a_batch_cannot_name_an_archive_from_another_printer(
  185. async_client: AsyncClient, printer_factory, archive_factory
  186. ):
  187. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  188. admin = await _admin_token(async_client)
  189. perms = await _group(async_client, admin, "perms", permissions=[*TEAM_PERMISSIONS, "archives:reprint_all"])
  190. team = await _group(async_client, admin, "team", printer_ids=[a.id])
  191. member = _auth((await _user(async_client, admin, "member", [perms, team]))[0])
  192. on_a, on_b = await archive_factory(a.id), await archive_factory(b.id)
  193. refused = await async_client.post(
  194. "/api/v1/queue/batches", headers=member, json={"name": "x", "archive_id": on_b.id}
  195. )
  196. allowed = await async_client.post(
  197. "/api/v1/queue/batches", headers=member, json={"name": "x", "archive_id": on_a.id}
  198. )
  199. assert (refused.status_code, refused.json()["detail"]) == (404, "Archive not found")
  200. assert allowed.status_code == 200, allowed.text
  201. async def test_running_a_pipeline_on_an_archive_needs_the_right_to_reprint_it(
  202. async_client: AsyncClient, printer_factory, archive_factory
  203. ):
  204. printer = await printer_factory()
  205. admin = await _admin_token(async_client)
  206. pipeline = await async_client.post(
  207. "/api/v1/slicer-pipelines/",
  208. headers=_auth(admin),
  209. json={
  210. "name": "P",
  211. "description": None,
  212. "printer_preset": {"source": "local", "id": "1"},
  213. "process_preset": {"source": "local", "id": "2"},
  214. "filament_presets": [{"source": "local", "id": "3"}],
  215. "bed_type": None,
  216. },
  217. )
  218. assert pipeline.status_code == 201, pipeline.text
  219. archive = await archive_factory(printer.id)
  220. # Sees every archive and may run pipelines, but not reprint.
  221. base = ["printers:read", "queue:create", "archives:read_all", "pipelines:read", "pipelines:run"]
  222. without = _auth(
  223. (await _user(async_client, admin, "without", [await _group(async_client, admin, "g1", permissions=base)]))[0]
  224. )
  225. with_reprint = _auth(
  226. (
  227. await _user(
  228. async_client,
  229. admin,
  230. "with",
  231. [await _group(async_client, admin, "g2", permissions=[*base, "archives:reprint_all"])],
  232. )
  233. )[0]
  234. )
  235. url = f"/api/v1/slicer-pipelines/{pipeline.json()['id']}/run"
  236. refused = await async_client.post(url, headers=without, json={"source_archive_id": archive.id})
  237. passed_gate = await async_client.post(url, headers=with_reprint, json={"source_archive_id": archive.id})
  238. assert refused.status_code == 403, refused.text
  239. # Past the gate, the run stops at the archive file this test does not create.
  240. assert (passed_gate.status_code, passed_gate.json()["detail"]) == (404, "Archive source file missing on disk")