test_owned_rows_elsewhere.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """Pipeline runs, the webhook and live archive events keep to what each user may see.
  2. A pipeline run's queue jobs and source belong to the user who started it. The
  3. run shows another user neither its source nor its queue jobs, and only someone
  4. who may change those jobs cancels or clears it. A webhook key acts for its
  5. owner: it starts only a job its owner could start, and lists only the jobs its
  6. owner sees. A live archive event says only that an archive on a printer
  7. changed.
  8. """
  9. from __future__ import annotations
  10. import json
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import select
  16. from backend.app.core import websocket
  17. from backend.app.core.printer_scope import ALL_PRINTERS
  18. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  19. from backend.app.models.print_queue import PrintQueueItem
  20. from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth
  21. from backend.tests.integration.test_project_batch_scope import _member
  22. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  23. OPERATOR = [
  24. "printers:read",
  25. "printers:control",
  26. "api_keys:create",
  27. "archives:read_own",
  28. "archives:update_own",
  29. "queue:read_own",
  30. "queue:update_own",
  31. "library:read_own",
  32. "makerworld:view",
  33. "pipelines:read",
  34. "pipelines:write",
  35. "pipelines:run",
  36. ]
  37. async def _job(db_session, printer_id: int, archive_id: int, owner: int | None, position: int = 1) -> int:
  38. item = PrintQueueItem(
  39. printer_id=printer_id, archive_id=archive_id, created_by_id=owner, status="pending", position=position
  40. )
  41. db_session.add(item)
  42. await db_session.commit()
  43. return item.id
  44. @pytest.fixture
  45. async def two(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  46. printer = await printer_factory(name="A")
  47. admin = await _admin_token(async_client)
  48. first, first_id = await _member(async_client, admin, "first", None, OPERATOR)
  49. second, second_id = await _member(async_client, admin, "second", None, OPERATOR)
  50. return SimpleNamespace(
  51. printer=printer, admin=_auth(admin), first=first, first_id=first_id, second=second, second_id=second_id
  52. )
  53. async def _run(db_session, archive_factory, two, owner_id: int, status: str = "in_progress"):
  54. archive = await archive_factory(two.printer.id, created_by_id=owner_id, print_name="Their part")
  55. entry = await _job(db_session, two.printer.id, archive.id, owner_id)
  56. run = PipelineRun(source_archive_id=archive.id, status=status, copies=1, created_by=owner_id)
  57. db_session.add(run)
  58. await db_session.commit()
  59. db_session.add(
  60. PipelineJob(pipeline_run_id=run.id, assigned_printer_id=two.printer.id, queue_entry_id=entry, status="queued")
  61. )
  62. await db_session.commit()
  63. return run.id, archive.id, entry
  64. async def _entry_status(db_session, entry: int) -> str:
  65. db_session.expire_all()
  66. return (await db_session.execute(select(PrintQueueItem.status).where(PrintQueueItem.id == entry))).scalar_one()
  67. class TestPipelineRuns:
  68. async def test_another_users_run_names_nothing_of_theirs(
  69. self, async_client: AsyncClient, two, archive_factory, db_session
  70. ):
  71. run, archive, entry = await _run(db_session, archive_factory, two, two.second_id)
  72. theirs = (await async_client.get(f"/api/v1/pipeline-runs/{run}", headers=two.first)).json()
  73. listed = (await async_client.get("/api/v1/pipeline-runs", headers=two.first)).json()["runs"]
  74. own = (await async_client.get(f"/api/v1/pipeline-runs/{run}", headers=two.second)).json()
  75. for seen in (theirs, *listed):
  76. assert (seen["source_archive_id"], seen["source_filename"]) == (None, None)
  77. assert [j["queue_entry_id"] for j in seen["jobs"]] == [None]
  78. assert (own["source_archive_id"], own["source_filename"]) == (archive, "Their part")
  79. assert [j["queue_entry_id"] for j in own["jobs"]] == [entry]
  80. async def test_nor_a_library_file_of_theirs(self, async_client: AsyncClient, two, db_session):
  81. from backend.app.models.library import LibraryFile
  82. source = LibraryFile(
  83. filename="their.3mf",
  84. file_path="their.3mf",
  85. file_type="3mf",
  86. file_size=0,
  87. file_hash="h",
  88. source_type="uploaded",
  89. created_by_id=two.second_id,
  90. )
  91. db_session.add(source)
  92. await db_session.commit()
  93. run = PipelineRun(source_library_file_id=source.id, status="completed", copies=1, created_by=two.second_id)
  94. db_session.add(run)
  95. await db_session.commit()
  96. theirs = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}", headers=two.first)).json()
  97. own = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}", headers=two.second)).json()
  98. assert (theirs["source_library_file_id"], theirs["source_filename"]) == (None, None)
  99. assert (own["source_library_file_id"], own["source_filename"]) == (source.id, "their.3mf")
  100. async def test_only_its_starter_cancels_it(self, async_client: AsyncClient, two, archive_factory, db_session):
  101. run, _, entry = await _run(db_session, archive_factory, two, two.second_id)
  102. refused = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.first)
  103. assert refused.status_code == 403
  104. assert await _entry_status(db_session, entry) == "pending"
  105. cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.second)
  106. assert cancelled.status_code == 200, cancelled.text
  107. assert await _entry_status(db_session, entry) == "cancelled"
  108. async def test_or_someone_who_may_change_every_job(
  109. self, async_client: AsyncClient, two, archive_factory, db_session
  110. ):
  111. run, _, entry = await _run(db_session, archive_factory, two, two.second_id)
  112. cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run}/cancel", headers=two.admin)
  113. assert cancelled.status_code == 200, cancelled.text
  114. assert await _entry_status(db_session, entry) == "cancelled"
  115. async def test_clearing_finished_runs_clears_only_ones_own(
  116. self, async_client: AsyncClient, two, archive_factory, db_session
  117. ):
  118. mine, *_ = await _run(db_session, archive_factory, two, two.first_id, status="completed")
  119. theirs, *_ = await _run(db_session, archive_factory, two, two.second_id, status="completed")
  120. cleared = await async_client.post("/api/v1/pipeline-runs/clear", headers=two.first)
  121. assert cleared.json() == {"deleted": 1}
  122. db_session.expire_all()
  123. assert list((await db_session.execute(select(PipelineRun.id))).scalars()) == [theirs]
  124. assert mine != theirs
  125. async def _key(client: AsyncClient, headers: dict, printer_id: int) -> dict:
  126. created = await client.post(
  127. "/api/v1/api-keys/",
  128. headers=headers,
  129. json={"name": "k", "can_control_printer": True, "can_read_status": True, "printer_ids": [printer_id]},
  130. )
  131. assert created.status_code in (200, 201), created.text
  132. return {"X-API-Key": created.json()["key"]}
  133. class TestWebhook:
  134. async def test_start_takes_the_next_job_its_owner_could_start(
  135. self, async_client: AsyncClient, two, archive_factory, db_session
  136. ):
  137. archive = await archive_factory(two.printer.id)
  138. await _job(db_session, two.printer.id, archive.id, two.second_id, position=1)
  139. mine = await _job(db_session, two.printer.id, archive.id, two.first_id, position=2)
  140. key = await _key(async_client, two.first, two.printer.id)
  141. started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
  142. assert started.status_code == 200, started.text
  143. assert started.json()["queue_item_id"] == mine
  144. async def test_a_job_nobody_owns_is_started_and_becomes_the_owners(
  145. self, async_client: AsyncClient, two, archive_factory, db_session
  146. ):
  147. archive = await archive_factory(two.printer.id)
  148. unowned = await _job(db_session, two.printer.id, archive.id, None, position=1)
  149. key = await _key(async_client, two.first, two.printer.id)
  150. started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
  151. assert started.json()["queue_item_id"] == unowned
  152. db_session.expire_all()
  153. owner = await db_session.scalar(select(PrintQueueItem.created_by_id).where(PrintQueueItem.id == unowned))
  154. assert owner == two.first_id
  155. async def test_and_none_when_only_others_wait(self, async_client: AsyncClient, two, archive_factory, db_session):
  156. archive = await archive_factory(two.printer.id)
  157. await _job(db_session, two.printer.id, archive.id, two.second_id)
  158. key = await _key(async_client, two.first, two.printer.id)
  159. started = await async_client.post(f"/api/v1/webhook/printer/{two.printer.id}/start", headers=key)
  160. assert started.status_code == 404
  161. async def test_queue_status_lists_only_the_owners_jobs(
  162. self, async_client: AsyncClient, two, archive_factory, db_session
  163. ):
  164. archive = await archive_factory(two.printer.id)
  165. await _job(db_session, two.printer.id, archive.id, two.second_id, position=1)
  166. mine = await _job(db_session, two.printer.id, archive.id, two.first_id, position=2)
  167. key = await _key(async_client, two.first, two.printer.id)
  168. (status,) = (await async_client.get("/api/v1/webhook/queue", headers=key)).json()
  169. assert status["pending"] == 2
  170. assert [item["id"] for item in status["items"]] == [mine]
  171. class TestLiveArchiveEvents:
  172. @pytest.mark.parametrize("send", ["send_archive_created", "send_archive_updated"])
  173. async def test_an_event_names_only_the_printer(self, send):
  174. manager = websocket.ConnectionManager()
  175. socket = SimpleNamespace(state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS), send_text=AsyncMock())
  176. manager.active_connections = [socket]
  177. await getattr(manager, send)(
  178. {"id": 9, "printer_id": 1, "filename": "part.3mf", "print_name": "Part", "status": "printing", "cost": 2}
  179. )
  180. message = json.loads(socket.send_text.await_args.args[0])
  181. assert message["data"] == {"printer_id": 1}
  182. def _user_socket(user_id: int | None):
  183. return SimpleNamespace(
  184. state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_principal_user_id=user_id),
  185. send_text=AsyncMock(),
  186. )
  187. class TestOutcomePrompt:
  188. async def test_who_may_answer_is_asked(self):
  189. manager = websocket.ConnectionManager()
  190. owner, other = _user_socket(7), _user_socket(8)
  191. manager.active_connections = [owner, other]
  192. await manager.send_print_confirm_request(1, {"archive_id": 9, "print_name": "Part"}, user_ids={7})
  193. owner.send_text.assert_awaited_once()
  194. other.send_text.assert_not_awaited()
  195. async def test_an_archive_nobody_owns_asks_everyone(self):
  196. manager = websocket.ConnectionManager()
  197. sockets = [_user_socket(7), _user_socket(8)]
  198. manager.active_connections = list(sockets)
  199. await manager.send_print_confirm_request(1, {"archive_id": 9, "print_name": "Part"})
  200. for socket in sockets:
  201. socket.send_text.assert_awaited_once()
  202. class TestMakerWorldRecentImports:
  203. async def test_only_ones_own_imports_are_listed(self, async_client: AsyncClient, two, db_session):
  204. from backend.app.models.library import LibraryFile
  205. rows = {}
  206. for who, owner in (("first", two.first_id), ("second", two.second_id)):
  207. row = LibraryFile(
  208. filename=f"{who}.3mf",
  209. file_path=f"{who}.3mf",
  210. file_type="3mf",
  211. file_size=0,
  212. file_hash=who,
  213. source_type="makerworld",
  214. source_url=f"https://makerworld.com/models/{who}",
  215. created_by_id=owner,
  216. )
  217. db_session.add(row)
  218. await db_session.commit()
  219. rows[who] = row.id
  220. listed = await async_client.get("/api/v1/makerworld/recent-imports", headers=two.first)
  221. assert listed.status_code == 200, listed.text
  222. assert [r["library_file_id"] for r in listed.json()] == [rows["first"]]
  223. class TestASecondCopyOnAMount:
  224. async def test_is_stored_beside_a_colleagues_under_a_free_name(self, db_session, tmp_path):
  225. from backend.app.api.routes.library import save_3mf_bytes_to_library
  226. from backend.app.models.library import LibraryFile, LibraryFolder
  227. mount = tmp_path / "nas"
  228. mount.mkdir()
  229. (mount / "benchy.stl").write_bytes(b"theirs")
  230. folder = LibraryFolder(name="NAS", is_external=True, external_path=str(mount), external_readonly=False)
  231. db_session.add(folder)
  232. await db_session.commit()
  233. db_session.add(
  234. LibraryFile(
  235. folder_id=folder.id,
  236. is_external=True,
  237. filename="benchy.stl",
  238. file_path=str(mount / "benchy.stl"),
  239. file_type="stl",
  240. file_size=6,
  241. file_hash="t",
  242. source_type="manyfold",
  243. source_url="manyfold:m1/f1",
  244. )
  245. )
  246. await db_session.commit()
  247. mine, was_existing = await save_3mf_bytes_to_library(
  248. db_session,
  249. file_bytes=b"solid x\nendsolid x\n",
  250. filename="benchy.stl",
  251. folder_id=folder.id,
  252. source_type="manyfold",
  253. source_url="manyfold:m1/f1",
  254. dedupe_within=None,
  255. )
  256. assert was_existing is False
  257. assert mine.filename == "benchy (2).stl"
  258. assert (mount / "benchy.stl").read_bytes() == b"theirs"