test_queue_review_1620.py 17 KB

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