| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- """Pipeline runs, the webhook and live archive events keep to what each user may see.
- A pipeline run's queue jobs and source belong to the user who started it. The
- run shows another user neither its source nor its queue jobs, and only someone
- who may change those jobs cancels or clears it. A webhook key acts for its
- owner: it starts only a job its owner could start, and lists only the jobs its
- owner sees. A live archive event says only that an archive on a printer
- changed.
- """
- from __future__ import annotations
- import json
- from types import SimpleNamespace
- from unittest.mock import AsyncMock
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from backend.app.core import websocket
- from backend.app.core.printer_scope import ALL_PRINTERS
- from backend.app.models.pipeline_run import PipelineJob, PipelineRun
- from backend.app.models.print_queue import PrintQueueItem
- from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth
- from backend.tests.integration.test_project_batch_scope import _member
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- OPERATOR = [
- "printers:read",
- "printers:control",
- "api_keys:create",
- "archives:read_own",
- "archives:update_own",
- "queue:read_own",
- "queue:update_own",
- "library:read_own",
- "makerworld:view",
- "pipelines:read",
- "pipelines:write",
- "pipelines:run",
- ]
- async def _job(db_session, printer_id: int, archive_id: int, owner: int | None, position: int = 1) -> int:
- item = PrintQueueItem(
- printer_id=printer_id, archive_id=archive_id, created_by_id=owner, status="pending", position=position
- )
- db_session.add(item)
- await db_session.commit()
- return item.id
- @pytest.fixture
- async def two(async_client: AsyncClient, printer_factory, archive_factory, db_session):
- printer = await printer_factory(name="A")
- admin = await _admin_token(async_client)
- first, first_id = await _member(async_client, admin, "first", None, OPERATOR)
- second, second_id = await _member(async_client, admin, "second", None, OPERATOR)
- return SimpleNamespace(
- printer=printer, admin=_auth(admin), first=first, first_id=first_id, second=second, second_id=second_id
- )
- async def _run(db_session, archive_factory, two, owner_id: int, status: str = "in_progress"):
- archive = await archive_factory(two.printer.id, created_by_id=owner_id, print_name="Their part")
- entry = await _job(db_session, two.printer.id, archive.id, owner_id)
- run = PipelineRun(source_archive_id=archive.id, status=status, copies=1, created_by=owner_id)
- db_session.add(run)
- await db_session.commit()
- db_session.add(
- PipelineJob(pipeline_run_id=run.id, assigned_printer_id=two.printer.id, queue_entry_id=entry, status="queued")
- )
- await db_session.commit()
- return run.id, archive.id, entry
- async def _entry_status(db_session, entry: int) -> str:
- db_session.expire_all()
- return (await db_session.execute(select(PrintQueueItem.status).where(PrintQueueItem.id == entry))).scalar_one()
- class TestPipelineRuns:
- async def test_another_users_run_names_nothing_of_theirs(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- run, archive, entry = await _run(db_session, archive_factory, two, two.second_id)
- theirs = (await async_client.get(f"/api/v1/pipeline-runs/{run}", headers=two.first)).json()
- listed = (await async_client.get("/api/v1/pipeline-runs", headers=two.first)).json()["runs"]
- own = (await async_client.get(f"/api/v1/pipeline-runs/{run}", headers=two.second)).json()
- for seen in (theirs, *listed):
- assert (seen["source_archive_id"], seen["source_filename"]) == (None, None)
- assert [j["queue_entry_id"] for j in seen["jobs"]] == [None]
- assert (own["source_archive_id"], own["source_filename"]) == (archive, "Their part")
- assert [j["queue_entry_id"] for j in own["jobs"]] == [entry]
- async def test_nor_a_library_file_of_theirs(self, async_client: AsyncClient, two, db_session):
- from backend.app.models.library import LibraryFile
- source = LibraryFile(
- filename="their.3mf",
- file_path="their.3mf",
- file_type="3mf",
- file_size=0,
- file_hash="h",
- source_type="uploaded",
- created_by_id=two.second_id,
- )
- db_session.add(source)
- await db_session.commit()
- run = PipelineRun(source_library_file_id=source.id, status="completed", copies=1, created_by=two.second_id)
- db_session.add(run)
- await db_session.commit()
- theirs = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}", headers=two.first)).json()
- own = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}", headers=two.second)).json()
- assert (theirs["source_library_file_id"], theirs["source_filename"]) == (None, None)
- assert (own["source_library_file_id"], own["source_filename"]) == (source.id, "their.3mf")
- async def test_only_its_starter_cancels_it(self, async_client: AsyncClient, two, archive_factory, db_session):
- run, _, entry = await _run(db_session, archive_factory, two, two.second_id)
- refused = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.first)
- assert refused.status_code == 403
- assert await _entry_status(db_session, entry) == "pending"
- cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.second)
- assert cancelled.status_code == 200, cancelled.text
- assert await _entry_status(db_session, entry) == "cancelled"
- async def test_or_someone_who_may_change_every_job(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- run, _, entry = await _run(db_session, archive_factory, two, two.second_id)
- cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.admin)
- assert cancelled.status_code == 200, cancelled.text
- assert await _entry_status(db_session, entry) == "cancelled"
- async def test_clearing_finished_runs_clears_only_ones_own(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- mine, *_ = await _run(db_session, archive_factory, two, two.first_id, status="completed")
- theirs, *_ = await _run(db_session, archive_factory, two, two.second_id, status="completed")
- cleared = await async_client.post("/api/v1/pipeline-runs/clear", headers=two.first)
- assert cleared.json() == {"deleted": 1}
- db_session.expire_all()
- assert list((await db_session.execute(select(PipelineRun.id))).scalars()) == [theirs]
- assert mine != theirs
- async def _key(client: AsyncClient, headers: dict, printer_id: int) -> dict:
- created = await client.post(
- "/api/v1/api-keys/",
- headers=headers,
- json={"name": "k", "can_control_printer": True, "can_read_status": True, "printer_ids": [printer_id]},
- )
- assert created.status_code in (200, 201), created.text
- return {"X-API-Key": created.json()["key"]}
- class TestWebhook:
- async def test_start_takes_the_next_job_its_owner_could_start(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- archive = await archive_factory(two.printer.id)
- await _job(db_session, two.printer.id, archive.id, two.second_id, position=1)
- mine = await _job(db_session, two.printer.id, archive.id, two.first_id, position=2)
- key = await _key(async_client, two.first, two.printer.id)
- started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
- assert started.status_code == 200, started.text
- assert started.json()["queue_item_id"] == mine
- async def test_a_job_nobody_owns_is_started_and_becomes_the_owners(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- archive = await archive_factory(two.printer.id)
- unowned = await _job(db_session, two.printer.id, archive.id, None, position=1)
- key = await _key(async_client, two.first, two.printer.id)
- started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
- assert started.json()["queue_item_id"] == unowned
- db_session.expire_all()
- owner = await db_session.scalar(select(PrintQueueItem.created_by_id).where(PrintQueueItem.id == unowned))
- assert owner == two.first_id
- async def test_and_none_when_only_others_wait(self, async_client: AsyncClient, two, archive_factory, db_session):
- archive = await archive_factory(two.printer.id)
- await _job(db_session, two.printer.id, archive.id, two.second_id)
- key = await _key(async_client, two.first, two.printer.id)
- started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
- assert started.status_code == 404
- async def test_queue_status_lists_only_the_owners_jobs(
- self, async_client: AsyncClient, two, archive_factory, db_session
- ):
- archive = await archive_factory(two.printer.id)
- await _job(db_session, two.printer.id, archive.id, two.second_id, position=1)
- mine = await _job(db_session, two.printer.id, archive.id, two.first_id, position=2)
- key = await _key(async_client, two.first, two.printer.id)
- (status,) = (await async_client.get("/api/v1/webhook/queue", headers=key)).json()
- assert status["pending"] == 2
- assert [item["id"] for item in status["items"]] == [mine]
- class TestLiveArchiveEvents:
- @pytest.mark.parametrize("send", ["send_archive_created", "send_archive_updated"])
- async def test_an_event_names_only_the_printer(self, send):
- manager = websocket.ConnectionManager()
- socket = SimpleNamespace(state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS), send_text=AsyncMock())
- manager.active_connections = [socket]
- await getattr(manager, send)(
- {"id": 9, "printer_id": 1, "filename": "part.3mf", "print_name": "Part", "status": "printing", "cost": 2}
- )
- message = json.loads(socket.send_text.await_args.args[0])
- assert message["data"] == {"printer_id": 1}
- def _user_socket(user_id: int | None):
- return SimpleNamespace(
- state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_principal_user_id=user_id),
- send_text=AsyncMock(),
- )
- class TestOutcomePrompt:
- async def test_who_may_answer_is_asked(self):
- manager = websocket.ConnectionManager()
- owner, other = _user_socket(7), _user_socket(8)
- manager.active_connections = [owner, other]
- await manager.send_print_confirm_request(1, {"archive_id": 9, "print_name": "Part"}, user_ids={7})
- owner.send_text.assert_awaited_once()
- other.send_text.assert_not_awaited()
- async def test_an_archive_nobody_owns_asks_everyone(self):
- manager = websocket.ConnectionManager()
- sockets = [_user_socket(7), _user_socket(8)]
- manager.active_connections = list(sockets)
- await manager.send_print_confirm_request(1, {"archive_id": 9, "print_name": "Part"})
- for socket in sockets:
- socket.send_text.assert_awaited_once()
- class TestMakerWorldRecentImports:
- async def test_only_ones_own_imports_are_listed(self, async_client: AsyncClient, two, db_session):
- from backend.app.models.library import LibraryFile
- rows = {}
- for who, owner in (("first", two.first_id), ("second", two.second_id)):
- row = LibraryFile(
- filename=f"{who}.3mf",
- file_path=f"{who}.3mf",
- file_type="3mf",
- file_size=0,
- file_hash=who,
- source_type="makerworld",
- source_url=f"https://makerworld.com/models/{who}",
- created_by_id=owner,
- )
- db_session.add(row)
- await db_session.commit()
- rows[who] = row.id
- listed = await async_client.get("/api/v1/makerworld/recent-imports", headers=two.first)
- assert listed.status_code == 200, listed.text
- assert [r["library_file_id"] for r in listed.json()] == [rows["first"]]
- class TestASecondCopyOnAMount:
- async def test_is_stored_beside_a_colleagues_under_a_free_name(self, db_session, tmp_path):
- from backend.app.api.routes.library import save_3mf_bytes_to_library
- from backend.app.models.library import LibraryFile, LibraryFolder
- mount = tmp_path / "nas"
- mount.mkdir()
- (mount / "benchy.stl").write_bytes(b"theirs")
- folder = LibraryFolder(name="NAS", is_external=True, external_path=str(mount), external_readonly=False)
- db_session.add(folder)
- await db_session.commit()
- db_session.add(
- LibraryFile(
- folder_id=folder.id,
- is_external=True,
- filename="benchy.stl",
- file_path=str(mount / "benchy.stl"),
- file_type="stl",
- file_size=6,
- file_hash="t",
- source_type="manyfold",
- source_url="manyfold:m1/f1",
- )
- )
- await db_session.commit()
- mine, was_existing = await save_3mf_bytes_to_library(
- db_session,
- file_bytes=b"solid x\nendsolid x\n",
- filename="benchy.stl",
- folder_id=folder.id,
- source_type="manyfold",
- source_url="manyfold:m1/f1",
- dedupe_within=None,
- )
- assert was_existing is False
- assert mine.filename == "benchy (2).stl"
- assert (mount / "benchy.stl").read_bytes() == b"theirs"
|