|
|
@@ -0,0 +1,368 @@
|
|
|
+"""Jobs that wait for review (#1620).
|
|
|
+
|
|
|
+A user without ``queue:start_unreviewed`` may still queue, but every job they
|
|
|
+queue waits until someone with ``queue:update_all`` starts it. These tests pin
|
|
|
+every way a job can be created or set going:
|
|
|
+
|
|
|
+* POST /queue/, the library's add-to-queue, a batch dispatch, an API key and the
|
|
|
+ webhook all leave such a user's job waiting, whatever the request asked for;
|
|
|
+* the start button, clearing "wait for manual start" in the editor or the bulk
|
|
|
+ editor, and the webhook start all refuse them, also for ownerless
|
|
|
+ virtual-printer jobs a user with the permission may claim by starting;
|
|
|
+* staff can start them, and users with the permission keep today's behaviour;
|
|
|
+* the upgrade grants the permission once, so removing it from a group sticks.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+import pytest
|
|
|
+from httpx import AsyncClient
|
|
|
+from sqlalchemy import select
|
|
|
+
|
|
|
+from backend.app.core import database as _database_module
|
|
|
+from backend.app.core.config import settings as app_settings
|
|
|
+from backend.app.core.database import seed_default_groups
|
|
|
+from backend.app.models.group import Group
|
|
|
+from backend.app.models.print_queue import PrintQueueItem
|
|
|
+from backend.app.models.settings import Settings
|
|
|
+
|
|
|
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
|
|
+
|
|
|
+# What a student needs to queue an archive and look after their own jobs
|
|
|
+STUDENT = [
|
|
|
+ "printers:read",
|
|
|
+ "archives:read_all",
|
|
|
+ "archives:reprint_all",
|
|
|
+ "queue:read_own",
|
|
|
+ "queue:create",
|
|
|
+ "queue:update_own",
|
|
|
+ "queue:delete_own",
|
|
|
+ "api_keys:create",
|
|
|
+]
|
|
|
+STAFF = [*STUDENT, "queue:read_all", "queue:update_all", "queue:delete_all"]
|
|
|
+
|
|
|
+
|
|
|
+def _auth(jwt: str) -> dict[str, str]:
|
|
|
+ return {"Authorization": f"Bearer {jwt}"}
|
|
|
+
|
|
|
+
|
|
|
+async def _admin_token(async_client: AsyncClient) -> str:
|
|
|
+ await async_client.post(
|
|
|
+ "/api/v1/auth/setup",
|
|
|
+ json={"auth_enabled": True, "admin_username": "reviewadmin", "admin_password": "AdminPass1!"},
|
|
|
+ )
|
|
|
+ login = await async_client.post("/api/v1/auth/login", json={"username": "reviewadmin", "password": "AdminPass1!"})
|
|
|
+ assert login.status_code == 200, login.text
|
|
|
+ return login.json()["access_token"]
|
|
|
+
|
|
|
+
|
|
|
+async def _user(async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str]) -> tuple[str, int]:
|
|
|
+ group = await async_client.post(
|
|
|
+ "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"g_{username}", "permissions": permissions}
|
|
|
+ )
|
|
|
+ assert group.status_code == 201, group.text
|
|
|
+ created = await async_client.post(
|
|
|
+ "/api/v1/users/",
|
|
|
+ headers=_auth(admin_jwt),
|
|
|
+ json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
|
|
|
+ )
|
|
|
+ assert created.status_code in (200, 201), created.text
|
|
|
+ login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
|
|
|
+ assert login.status_code == 200, login.text
|
|
|
+ return login.json()["access_token"], created.json()["id"]
|
|
|
+
|
|
|
+
|
|
|
+async def _queue(async_client: AsyncClient, headers: dict, printer_id: int, archive_id: int, **extra) -> dict:
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/queue/", headers=headers, json={"printer_id": printer_id, "archive_id": archive_id, **extra}
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ return response.json()
|
|
|
+
|
|
|
+
|
|
|
+async def _manual_start(item_id: int) -> bool:
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ item = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
|
|
|
+ return item.manual_start
|
|
|
+
|
|
|
+
|
|
|
+@pytest.fixture
|
|
|
+async def setup(async_client, printer_factory, archive_factory):
|
|
|
+ printer = await printer_factory(name="Lab")
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
+ admin = await _admin_token(async_client)
|
|
|
+ student, student_id = await _user(async_client, admin, "student", STUDENT)
|
|
|
+ staff, _ = await _user(async_client, admin, "staff", STAFF)
|
|
|
+ trusted, _ = await _user(async_client, admin, "trusted", [*STUDENT, "queue:start_unreviewed"])
|
|
|
+ return {
|
|
|
+ "printer": printer,
|
|
|
+ "archive": archive,
|
|
|
+ "admin": admin,
|
|
|
+ "student": student,
|
|
|
+ "student_id": student_id,
|
|
|
+ "staff": staff,
|
|
|
+ "trusted": trusted,
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+class TestQueueing:
|
|
|
+ async def test_a_students_job_waits_whatever_they_asked_for(self, async_client, setup):
|
|
|
+ item = await _queue(
|
|
|
+ async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id, manual_start=False
|
|
|
+ )
|
|
|
+ assert item["manual_start"] is True
|
|
|
+
|
|
|
+ async def test_a_trusted_users_job_starts_on_its_own(self, async_client, setup):
|
|
|
+ item = await _queue(async_client, _auth(setup["trusted"]), setup["printer"].id, setup["archive"].id)
|
|
|
+ assert item["manual_start"] is False
|
|
|
+
|
|
|
+ async def test_staff_jobs_do_not_wait_without_the_permission(self, async_client, setup):
|
|
|
+ """Whoever may start every job is the reviewer; their own jobs don't wait."""
|
|
|
+ item = await _queue(async_client, _auth(setup["staff"]), setup["printer"].id, setup["archive"].id)
|
|
|
+ assert item["manual_start"] is False
|
|
|
+
|
|
|
+ async def test_auth_off_changes_nothing(self, async_client, printer_factory, archive_factory):
|
|
|
+ printer = await printer_factory()
|
|
|
+ archive = await archive_factory(printer.id)
|
|
|
+ item = await _queue(async_client, {}, printer.id, archive.id)
|
|
|
+ assert item["manual_start"] is False
|
|
|
+
|
|
|
+ async def test_library_add_to_queue_waits(self, async_client, setup, db_session):
|
|
|
+ from backend.app.models.library import LibraryFile
|
|
|
+
|
|
|
+ rel_path = "archive/library/files/review_probe.gcode.3mf"
|
|
|
+ abs_path = Path(app_settings.base_dir) / rel_path
|
|
|
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ abs_path.write_bytes(b"probe")
|
|
|
+ try:
|
|
|
+ lib_file = LibraryFile(
|
|
|
+ filename="review_probe.gcode.3mf",
|
|
|
+ file_path=rel_path,
|
|
|
+ file_size=5,
|
|
|
+ file_type="3mf",
|
|
|
+ created_by_id=setup["student_id"],
|
|
|
+ )
|
|
|
+ db_session.add(lib_file)
|
|
|
+ await db_session.commit()
|
|
|
+
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/library/files/add-to-queue",
|
|
|
+ headers=_auth(setup["student"]),
|
|
|
+ json={"file_ids": [lib_file.id], "printer_id": setup["printer"].id},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ added = response.json()["added"]
|
|
|
+ assert len(added) == 1
|
|
|
+ assert await _manual_start(added[0]["queue_item_id"]) is True
|
|
|
+ finally:
|
|
|
+ abs_path.unlink(missing_ok=True)
|
|
|
+
|
|
|
+ async def test_dispatching_more_of_an_order_waits_again(self, async_client, setup):
|
|
|
+ """The clones copy the template's flag, which is off once staff started it."""
|
|
|
+ student = _auth(setup["student"])
|
|
|
+ order = await async_client.post(
|
|
|
+ "/api/v1/queue/batches",
|
|
|
+ headers=student,
|
|
|
+ json={
|
|
|
+ "name": "Order",
|
|
|
+ "archive_id": setup["archive"].id,
|
|
|
+ "plates": [{"plate_id": 1, "quantity_target": 3}],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert order.status_code == 200, order.text
|
|
|
+ first = await _queue(
|
|
|
+ async_client, student, setup["printer"].id, setup["archive"].id, batch_id=order.json()["id"], plate_id=1
|
|
|
+ )
|
|
|
+ started = await async_client.post(f"/api/v1/queue/{first['id']}/start", headers=_auth(setup["staff"]))
|
|
|
+ assert started.status_code == 200, started.text
|
|
|
+
|
|
|
+ dispatched = await async_client.post(
|
|
|
+ f"/api/v1/queue/batches/{order.json()['id']}/dispatch", headers=student, json={}
|
|
|
+ )
|
|
|
+ assert dispatched.status_code == 200, dispatched.text
|
|
|
+
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ clones = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(PrintQueueItem).where(
|
|
|
+ PrintQueueItem.batch_id == order.json()["id"], PrintQueueItem.id != first["id"]
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .scalars()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ assert len(clones) == 2
|
|
|
+ assert all(clone.manual_start for clone in clones)
|
|
|
+
|
|
|
+
|
|
|
+class TestStarting:
|
|
|
+ async def test_a_student_cannot_start_their_own_waiting_job(self, async_client, setup):
|
|
|
+ item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
|
|
|
+ assert response.status_code == 403
|
|
|
+ assert "review" in response.json()["detail"]
|
|
|
+ assert await _manual_start(item["id"]) is True
|
|
|
+
|
|
|
+ async def test_staff_start_it(self, async_client, setup):
|
|
|
+ item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["staff"]))
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ assert await _manual_start(item["id"]) is False
|
|
|
+
|
|
|
+ async def test_a_trusted_user_still_starts_their_own_staged_job(self, async_client, setup):
|
|
|
+ trusted = _auth(setup["trusted"])
|
|
|
+ item = await _queue(async_client, trusted, setup["printer"].id, setup["archive"].id, manual_start=True)
|
|
|
+
|
|
|
+ response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=trusted)
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+
|
|
|
+ async def test_an_ownerless_job_is_not_claimable_by_a_student(self, async_client, setup):
|
|
|
+ """Virtual-printer uploads arrive without an owner and are claimed by starting them (#1670)."""
|
|
|
+ item = await _queue(async_client, _auth(setup["admin"]), setup["printer"].id, setup["archive"].id)
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ row = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item["id"]))).scalar_one()
|
|
|
+ row.created_by_id = None
|
|
|
+ row.manual_start = True
|
|
|
+ await session.commit()
|
|
|
+
|
|
|
+ refused = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
|
|
|
+ assert refused.status_code == 403
|
|
|
+ claimed = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["trusted"]))
|
|
|
+ assert claimed.status_code == 200, claimed.text
|
|
|
+
|
|
|
+
|
|
|
+class TestEditing:
|
|
|
+ async def test_clearing_wait_in_the_editor_is_refused(self, async_client, setup):
|
|
|
+ student = _auth(setup["student"])
|
|
|
+ item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.patch(
|
|
|
+ f"/api/v1/queue/{item['id']}", headers=student, json={"manual_start": False}
|
|
|
+ )
|
|
|
+ assert response.status_code == 403
|
|
|
+ assert await _manual_start(item["id"]) is True
|
|
|
+
|
|
|
+ async def test_other_edits_still_work(self, async_client, setup):
|
|
|
+ student = _auth(setup["student"])
|
|
|
+ item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.patch(
|
|
|
+ f"/api/v1/queue/{item['id']}",
|
|
|
+ headers=student,
|
|
|
+ json={"manual_start": True, "require_previous_success": True},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+
|
|
|
+ async def test_staff_may_clear_it_in_the_editor(self, async_client, setup):
|
|
|
+ item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.patch(
|
|
|
+ f"/api/v1/queue/{item['id']}", headers=_auth(setup["staff"]), json={"manual_start": False}
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ assert await _manual_start(item["id"]) is False
|
|
|
+
|
|
|
+ async def test_the_bulk_editor_skips_it(self, async_client, setup):
|
|
|
+ student = _auth(setup["student"])
|
|
|
+ item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
|
|
|
+
|
|
|
+ response = await async_client.patch(
|
|
|
+ "/api/v1/queue/bulk", headers=student, json={"item_ids": [item["id"]], "manual_start": False}
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ assert response.json()["updated_count"] == 0
|
|
|
+ assert await _manual_start(item["id"]) is True
|
|
|
+
|
|
|
+
|
|
|
+class TestApiKeys:
|
|
|
+ async def _key(self, async_client, jwt: str, **flags) -> dict[str, str]:
|
|
|
+ created = await async_client.post(
|
|
|
+ "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_queue": True, **flags}
|
|
|
+ )
|
|
|
+ assert created.status_code in (200, 201), created.text
|
|
|
+ return {"X-API-Key": created.json()["key"]}
|
|
|
+
|
|
|
+ async def test_a_students_key_queues_jobs_that_wait(self, async_client, setup):
|
|
|
+ key = await self._key(async_client, setup["student"])
|
|
|
+ item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
|
|
|
+ assert item["manual_start"] is True
|
|
|
+
|
|
|
+ async def test_a_trusted_users_key_does_not(self, async_client, setup):
|
|
|
+ key = await self._key(async_client, setup["trusted"])
|
|
|
+ item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
|
|
|
+ assert item["manual_start"] is False
|
|
|
+
|
|
|
+ async def test_webhook_queue_add_waits(self, async_client, setup):
|
|
|
+ key = await self._key(async_client, setup["student"])
|
|
|
+ response = await async_client.post(
|
|
|
+ "/api/v1/webhook/queue/add",
|
|
|
+ headers=key,
|
|
|
+ json={"printer_id": setup["printer"].id, "archive_id": setup["archive"].id},
|
|
|
+ )
|
|
|
+ assert response.status_code == 200, response.text
|
|
|
+ assert await _manual_start(response.json()["id"]) is True
|
|
|
+
|
|
|
+ async def test_webhook_start_refuses_a_key_whose_owner_needs_review(self, async_client, setup):
|
|
|
+ admin = setup["admin"]
|
|
|
+ # Printer control, but their own jobs wait: they can't release anyone's
|
|
|
+ controller, _ = await _user(async_client, admin, "controller", [*STUDENT, "printers:control"])
|
|
|
+ item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
|
|
|
+ key = await self._key(async_client, controller, can_control_printer=True)
|
|
|
+
|
|
|
+ refused = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=key)
|
|
|
+ assert refused.status_code == 403
|
|
|
+ assert await _manual_start(item["id"]) is True
|
|
|
+
|
|
|
+ staff_key = await self._key(async_client, admin, can_control_printer=True)
|
|
|
+ started = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=staff_key)
|
|
|
+ assert started.status_code == 200, started.text
|
|
|
+ assert await _manual_start(item["id"]) is False
|
|
|
+
|
|
|
+
|
|
|
+class TestUpgrade:
|
|
|
+ async def test_granted_once_to_groups_that_could_print(self, async_client):
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ session.add_all(
|
|
|
+ [
|
|
|
+ Group(name="queuers", permissions=["queue:create"], is_system=False),
|
|
|
+ Group(name="controllers", permissions=["printers:control"], is_system=False),
|
|
|
+ # Could start their own staged jobs, or run pipelines, without queue:create
|
|
|
+ Group(name="starters", permissions=["queue:read_all", "queue:update_own"], is_system=False),
|
|
|
+ Group(name="pipeliners", permissions=["pipelines:run"], is_system=False),
|
|
|
+ Group(name="readers", permissions=["queue:read_own"], is_system=False),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ flag = (
|
|
|
+ await session.execute(
|
|
|
+ select(Settings).where(Settings.key == "_backfill_1620_queue_start_unreviewed_done")
|
|
|
+ )
|
|
|
+ ).scalar_one_or_none()
|
|
|
+ # Seeding already ran once for this database; make it an upgrade again
|
|
|
+ if flag is not None:
|
|
|
+ await session.delete(flag)
|
|
|
+ await session.commit()
|
|
|
+
|
|
|
+ await seed_default_groups()
|
|
|
+
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ groups = {g.name: g for g in (await session.execute(select(Group))).scalars().all()}
|
|
|
+ assert "queue:start_unreviewed" in groups["queuers"].permissions
|
|
|
+ assert "queue:start_unreviewed" in groups["controllers"].permissions
|
|
|
+ assert "queue:start_unreviewed" in groups["starters"].permissions
|
|
|
+ assert "queue:start_unreviewed" in groups["pipeliners"].permissions
|
|
|
+ assert "queue:start_unreviewed" not in groups["readers"].permissions
|
|
|
+ # An admin takes it away from the students...
|
|
|
+ groups["queuers"].permissions = ["queue:create"]
|
|
|
+ await session.commit()
|
|
|
+
|
|
|
+ # ...and a restart doesn't hand it back
|
|
|
+ await seed_default_groups()
|
|
|
+ async with _database_module.async_session() as session:
|
|
|
+ queuers = (await session.execute(select(Group).where(Group.name == "queuers"))).scalar_one()
|
|
|
+ assert "queue:start_unreviewed" not in queuers.permissions
|