| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- """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
|