test_queue_review_1620.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. """Jobs that wait for review (#1620).
  2. A user without ``queue:start_unreviewed`` may still queue, but every job they
  3. queue waits until someone with ``queue:update_all`` starts it. These tests pin
  4. every way a job can be created or set going:
  5. * POST /queue/, the library's add-to-queue, a batch dispatch, an API key and the
  6. webhook all leave such a user's job waiting, whatever the request asked for;
  7. * the start button, clearing "wait for manual start" in the editor or the bulk
  8. editor, and the webhook start all refuse them, also for ownerless
  9. virtual-printer jobs a user with the permission may claim by starting;
  10. * staff can start them, and users with the permission keep today's behaviour;
  11. * the upgrade grants the permission once, so removing it from a group sticks.
  12. """
  13. from __future__ import annotations
  14. from pathlib import Path
  15. import pytest
  16. from httpx import AsyncClient
  17. from sqlalchemy import select
  18. from backend.app.core import database as _database_module
  19. from backend.app.core.config import settings as app_settings
  20. from backend.app.core.database import seed_default_groups
  21. from backend.app.models.group import Group
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.settings import Settings
  24. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  25. # What a student needs to queue an archive and look after their own jobs
  26. STUDENT = [
  27. "printers:read",
  28. "archives:read_all",
  29. "archives:reprint_all",
  30. "queue:read_own",
  31. "queue:create",
  32. "queue:update_own",
  33. "queue:delete_own",
  34. "api_keys:create",
  35. ]
  36. STAFF = [*STUDENT, "queue:read_all", "queue:update_all", "queue:delete_all"]
  37. def _auth(jwt: str) -> dict[str, str]:
  38. return {"Authorization": f"Bearer {jwt}"}
  39. async def _admin_token(async_client: AsyncClient) -> str:
  40. await async_client.post(
  41. "/api/v1/auth/setup",
  42. json={"auth_enabled": True, "admin_username": "reviewadmin", "admin_password": "AdminPass1!"},
  43. )
  44. login = await async_client.post("/api/v1/auth/login", json={"username": "reviewadmin", "password": "AdminPass1!"})
  45. assert login.status_code == 200, login.text
  46. return login.json()["access_token"]
  47. async def _user(async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str]) -> tuple[str, int]:
  48. group = await async_client.post(
  49. "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"g_{username}", "permissions": permissions}
  50. )
  51. assert group.status_code == 201, group.text
  52. created = await async_client.post(
  53. "/api/v1/users/",
  54. headers=_auth(admin_jwt),
  55. json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  56. )
  57. assert created.status_code in (200, 201), created.text
  58. login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  59. assert login.status_code == 200, login.text
  60. return login.json()["access_token"], created.json()["id"]
  61. async def _queue(async_client: AsyncClient, headers: dict, printer_id: int, archive_id: int, **extra) -> dict:
  62. response = await async_client.post(
  63. "/api/v1/queue/", headers=headers, json={"printer_id": printer_id, "archive_id": archive_id, **extra}
  64. )
  65. assert response.status_code == 200, response.text
  66. return response.json()
  67. async def _manual_start(item_id: int) -> bool:
  68. async with _database_module.async_session() as session:
  69. item = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  70. return item.manual_start
  71. @pytest.fixture
  72. async def setup(async_client, printer_factory, archive_factory):
  73. printer = await printer_factory(name="Lab")
  74. archive = await archive_factory(printer.id)
  75. admin = await _admin_token(async_client)
  76. student, student_id = await _user(async_client, admin, "student", STUDENT)
  77. staff, _ = await _user(async_client, admin, "staff", STAFF)
  78. trusted, _ = await _user(async_client, admin, "trusted", [*STUDENT, "queue:start_unreviewed"])
  79. return {
  80. "printer": printer,
  81. "archive": archive,
  82. "admin": admin,
  83. "student": student,
  84. "student_id": student_id,
  85. "staff": staff,
  86. "trusted": trusted,
  87. }
  88. class TestQueueing:
  89. async def test_a_students_job_waits_whatever_they_asked_for(self, async_client, setup):
  90. item = await _queue(
  91. async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id, manual_start=False
  92. )
  93. assert item["manual_start"] is True
  94. async def test_a_trusted_users_job_starts_on_its_own(self, async_client, setup):
  95. item = await _queue(async_client, _auth(setup["trusted"]), setup["printer"].id, setup["archive"].id)
  96. assert item["manual_start"] is False
  97. async def test_staff_jobs_do_not_wait_without_the_permission(self, async_client, setup):
  98. """Whoever may start every job is the reviewer; their own jobs don't wait."""
  99. item = await _queue(async_client, _auth(setup["staff"]), setup["printer"].id, setup["archive"].id)
  100. assert item["manual_start"] is False
  101. async def test_auth_off_changes_nothing(self, async_client, printer_factory, archive_factory):
  102. printer = await printer_factory()
  103. archive = await archive_factory(printer.id)
  104. item = await _queue(async_client, {}, printer.id, archive.id)
  105. assert item["manual_start"] is False
  106. async def test_library_add_to_queue_waits(self, async_client, setup, db_session):
  107. from backend.app.models.library import LibraryFile
  108. rel_path = "archive/library/files/review_probe.gcode.3mf"
  109. abs_path = Path(app_settings.base_dir) / rel_path
  110. abs_path.parent.mkdir(parents=True, exist_ok=True)
  111. abs_path.write_bytes(b"probe")
  112. try:
  113. lib_file = LibraryFile(
  114. filename="review_probe.gcode.3mf",
  115. file_path=rel_path,
  116. file_size=5,
  117. file_type="3mf",
  118. created_by_id=setup["student_id"],
  119. )
  120. db_session.add(lib_file)
  121. await db_session.commit()
  122. response = await async_client.post(
  123. "/api/v1/library/files/add-to-queue",
  124. headers=_auth(setup["student"]),
  125. json={"file_ids": [lib_file.id], "printer_id": setup["printer"].id},
  126. )
  127. assert response.status_code == 200, response.text
  128. added = response.json()["added"]
  129. assert len(added) == 1
  130. assert await _manual_start(added[0]["queue_item_id"]) is True
  131. finally:
  132. abs_path.unlink(missing_ok=True)
  133. async def test_dispatching_more_of_an_order_waits_again(self, async_client, setup):
  134. """The clones copy the template's flag, which is off once staff started it."""
  135. student = _auth(setup["student"])
  136. order = await async_client.post(
  137. "/api/v1/queue/batches",
  138. headers=student,
  139. json={
  140. "name": "Order",
  141. "archive_id": setup["archive"].id,
  142. "plates": [{"plate_id": 1, "quantity_target": 3}],
  143. },
  144. )
  145. assert order.status_code == 200, order.text
  146. first = await _queue(
  147. async_client, student, setup["printer"].id, setup["archive"].id, batch_id=order.json()["id"], plate_id=1
  148. )
  149. started = await async_client.post(f"/api/v1/queue/{first['id']}/start", headers=_auth(setup["staff"]))
  150. assert started.status_code == 200, started.text
  151. dispatched = await async_client.post(
  152. f"/api/v1/queue/batches/{order.json()['id']}/dispatch", headers=student, json={}
  153. )
  154. assert dispatched.status_code == 200, dispatched.text
  155. async with _database_module.async_session() as session:
  156. clones = (
  157. (
  158. await session.execute(
  159. select(PrintQueueItem).where(
  160. PrintQueueItem.batch_id == order.json()["id"], PrintQueueItem.id != first["id"]
  161. )
  162. )
  163. )
  164. .scalars()
  165. .all()
  166. )
  167. assert len(clones) == 2
  168. assert all(clone.manual_start for clone in clones)
  169. class TestStarting:
  170. async def test_a_student_cannot_start_their_own_waiting_job(self, async_client, setup):
  171. item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
  172. response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
  173. assert response.status_code == 403
  174. assert "review" in response.json()["detail"]
  175. assert await _manual_start(item["id"]) is True
  176. async def test_staff_start_it(self, async_client, setup):
  177. item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
  178. response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["staff"]))
  179. assert response.status_code == 200, response.text
  180. assert await _manual_start(item["id"]) is False
  181. async def test_a_trusted_user_still_starts_their_own_staged_job(self, async_client, setup):
  182. trusted = _auth(setup["trusted"])
  183. item = await _queue(async_client, trusted, setup["printer"].id, setup["archive"].id, manual_start=True)
  184. response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=trusted)
  185. assert response.status_code == 200, response.text
  186. async def test_an_ownerless_job_is_not_claimable_by_a_student(self, async_client, setup):
  187. """Virtual-printer uploads arrive without an owner and are claimed by starting them (#1670)."""
  188. item = await _queue(async_client, _auth(setup["admin"]), setup["printer"].id, setup["archive"].id)
  189. async with _database_module.async_session() as session:
  190. row = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item["id"]))).scalar_one()
  191. row.created_by_id = None
  192. row.manual_start = True
  193. await session.commit()
  194. refused = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
  195. assert refused.status_code == 403
  196. claimed = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["trusted"]))
  197. assert claimed.status_code == 200, claimed.text
  198. class TestEditing:
  199. async def test_clearing_wait_in_the_editor_is_refused(self, async_client, setup):
  200. student = _auth(setup["student"])
  201. item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
  202. response = await async_client.patch(
  203. f"/api/v1/queue/{item['id']}", headers=student, json={"manual_start": False}
  204. )
  205. assert response.status_code == 403
  206. assert await _manual_start(item["id"]) is True
  207. async def test_other_edits_still_work(self, async_client, setup):
  208. student = _auth(setup["student"])
  209. item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
  210. response = await async_client.patch(
  211. f"/api/v1/queue/{item['id']}",
  212. headers=student,
  213. json={"manual_start": True, "require_previous_success": True},
  214. )
  215. assert response.status_code == 200, response.text
  216. async def test_staff_may_clear_it_in_the_editor(self, async_client, setup):
  217. item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
  218. response = await async_client.patch(
  219. f"/api/v1/queue/{item['id']}", headers=_auth(setup["staff"]), json={"manual_start": False}
  220. )
  221. assert response.status_code == 200, response.text
  222. assert await _manual_start(item["id"]) is False
  223. async def test_the_bulk_editor_skips_it(self, async_client, setup):
  224. student = _auth(setup["student"])
  225. item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
  226. response = await async_client.patch(
  227. "/api/v1/queue/bulk", headers=student, json={"item_ids": [item["id"]], "manual_start": False}
  228. )
  229. assert response.status_code == 200, response.text
  230. assert response.json()["updated_count"] == 0
  231. assert await _manual_start(item["id"]) is True
  232. class TestApiKeys:
  233. async def _key(self, async_client, jwt: str, **flags) -> dict[str, str]:
  234. created = await async_client.post(
  235. "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_queue": True, **flags}
  236. )
  237. assert created.status_code in (200, 201), created.text
  238. return {"X-API-Key": created.json()["key"]}
  239. async def test_a_students_key_queues_jobs_that_wait(self, async_client, setup):
  240. key = await self._key(async_client, setup["student"])
  241. item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
  242. assert item["manual_start"] is True
  243. async def test_a_trusted_users_key_does_not(self, async_client, setup):
  244. key = await self._key(async_client, setup["trusted"])
  245. item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
  246. assert item["manual_start"] is False
  247. async def test_webhook_queue_add_waits(self, async_client, setup):
  248. key = await self._key(async_client, setup["student"])
  249. response = await async_client.post(
  250. "/api/v1/webhook/queue/add",
  251. headers=key,
  252. json={"printer_id": setup["printer"].id, "archive_id": setup["archive"].id},
  253. )
  254. assert response.status_code == 200, response.text
  255. assert await _manual_start(response.json()["id"]) is True
  256. async def test_webhook_start_refuses_a_key_whose_owner_needs_review(self, async_client, setup):
  257. admin = setup["admin"]
  258. # Printer control, but their own jobs wait: they can't release anyone's
  259. controller, _ = await _user(async_client, admin, "controller", [*STUDENT, "printers:control"])
  260. item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
  261. key = await self._key(async_client, controller, can_control_printer=True)
  262. refused = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=key)
  263. assert refused.status_code == 403
  264. assert await _manual_start(item["id"]) is True
  265. staff_key = await self._key(async_client, admin, can_control_printer=True)
  266. started = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=staff_key)
  267. assert started.status_code == 200, started.text
  268. assert await _manual_start(item["id"]) is False
  269. class TestUpgrade:
  270. async def test_granted_once_to_groups_that_could_print(self, async_client):
  271. async with _database_module.async_session() as session:
  272. session.add_all(
  273. [
  274. Group(name="queuers", permissions=["queue:create"], is_system=False),
  275. Group(name="controllers", permissions=["printers:control"], is_system=False),
  276. # Could start their own staged jobs, or run pipelines, without queue:create
  277. Group(name="starters", permissions=["queue:read_all", "queue:update_own"], is_system=False),
  278. Group(name="pipeliners", permissions=["pipelines:run"], is_system=False),
  279. Group(name="readers", permissions=["queue:read_own"], is_system=False),
  280. ]
  281. )
  282. flag = (
  283. await session.execute(
  284. select(Settings).where(Settings.key == "_backfill_1620_queue_start_unreviewed_done")
  285. )
  286. ).scalar_one_or_none()
  287. # Seeding already ran once for this database; make it an upgrade again
  288. if flag is not None:
  289. await session.delete(flag)
  290. await session.commit()
  291. await seed_default_groups()
  292. async with _database_module.async_session() as session:
  293. groups = {g.name: g for g in (await session.execute(select(Group))).scalars().all()}
  294. assert "queue:start_unreviewed" in groups["queuers"].permissions
  295. assert "queue:start_unreviewed" in groups["controllers"].permissions
  296. assert "queue:start_unreviewed" in groups["starters"].permissions
  297. assert "queue:start_unreviewed" in groups["pipeliners"].permissions
  298. assert "queue:start_unreviewed" not in groups["readers"].permissions
  299. # An admin takes it away from the students...
  300. groups["queuers"].permissions = ["queue:create"]
  301. await session.commit()
  302. # ...and a restart doesn't hand it back
  303. await seed_default_groups()
  304. async with _database_module.async_session() as session:
  305. queuers = (await session.execute(select(Group).where(Group.name == "queuers"))).scalar_one()
  306. assert "queue:start_unreviewed" not in queuers.permissions