test_printer_scope_1727.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. """Printer-scoped access (#1727).
  2. A group with ``restrict_printers`` set limits its members to the printers it
  3. lists. These tests pin the contract end to end:
  4. * a member sees and acts only on the team's printers; any other printer reads
  5. as missing (404), never as forbidden, so its id isn't confirmed;
  6. * groups without the flag narrow nothing, so a user in no restricted group
  7. keeps every printer (upgrades are a no-op) and a permission group combined
  8. with a team group doesn't widen the team;
  9. * a restricted group with no printers grants none -- it does not fall back to
  10. every printer;
  11. * API keys, camera-stream, Cam Wall and WebSocket tokens all carry the scope of
  12. whoever created them.
  13. """
  14. from __future__ import annotations
  15. from unittest.mock import AsyncMock, patch
  16. import pytest
  17. from httpx import AsyncClient
  18. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  19. TEAM_PERMISSIONS = [
  20. "printers:read",
  21. "printers:control",
  22. "camera:view",
  23. "queue:read_all",
  24. "queue:create",
  25. "archives:read_all",
  26. "archives:reprint_all",
  27. "archives:delete_all",
  28. "websocket:connect",
  29. "api_keys:create",
  30. ]
  31. def _auth(jwt: str) -> dict[str, str]:
  32. return {"Authorization": f"Bearer {jwt}"}
  33. async def _admin_token(async_client: AsyncClient) -> str:
  34. await async_client.post(
  35. "/api/v1/auth/setup",
  36. json={"auth_enabled": True, "admin_username": "scopeadmin", "admin_password": "AdminPass1!"},
  37. )
  38. login = await async_client.post("/api/v1/auth/login", json={"username": "scopeadmin", "password": "AdminPass1!"})
  39. assert login.status_code == 200, login.text
  40. return login.json()["access_token"]
  41. async def _group(
  42. async_client: AsyncClient,
  43. admin_jwt: str,
  44. name: str,
  45. *,
  46. permissions: list[str] | None = None,
  47. printer_ids: list[int] | None = None,
  48. ) -> int:
  49. body: dict = {"name": name, "permissions": permissions or []}
  50. if printer_ids is not None:
  51. body.update(restrict_printers=True, printer_ids=printer_ids)
  52. response = await async_client.post("/api/v1/groups/", headers=_auth(admin_jwt), json=body)
  53. assert response.status_code == 201, response.text
  54. return response.json()["id"]
  55. async def _user(async_client: AsyncClient, admin_jwt: str, username: str, group_ids: list[int]) -> tuple[str, int]:
  56. created = await async_client.post(
  57. "/api/v1/users/",
  58. headers=_auth(admin_jwt),
  59. json={"username": username, "password": "UserPass1!", "group_ids": group_ids},
  60. )
  61. assert created.status_code in (200, 201), created.text
  62. login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  63. assert login.status_code == 200, login.text
  64. return login.json()["access_token"], created.json()["id"]
  65. async def _team_member(async_client: AsyncClient, admin_jwt: str, username: str, printer_ids: list[int]):
  66. """A user in a permission group plus a team group restricted to *printer_ids*."""
  67. perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=TEAM_PERMISSIONS)
  68. team = await _group(async_client, admin_jwt, f"team_{username}", printer_ids=printer_ids)
  69. return await _user(async_client, admin_jwt, username, [perms, team])
  70. async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set[int]:
  71. response = await async_client.get("/api/v1/printers/", headers=headers)
  72. assert response.status_code == 200, response.text
  73. return {p["id"] for p in response.json()}
  74. class TestVisibility:
  75. async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
  76. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  77. admin = await _admin_token(async_client)
  78. perms = await _group(async_client, admin, "plain", permissions=TEAM_PERMISSIONS)
  79. jwt, _ = await _user(async_client, admin, "plain_user", [perms])
  80. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  81. async def test_team_member_sees_only_team_printers(self, async_client, printer_factory):
  82. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  83. admin = await _admin_token(async_client)
  84. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  85. # The permission group (no flag) must not widen the team group
  86. assert await _listed_ids(async_client, _auth(jwt)) == {a.id}
  87. async def test_hidden_printer_reads_as_missing(self, async_client, printer_factory, mock_printer_manager):
  88. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  89. admin = await _admin_token(async_client)
  90. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  91. headers = _auth(jwt)
  92. assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=headers)).status_code == 404
  93. assert (await async_client.get(f"/api/v1/printers/{b.id}/status", headers=headers)).status_code == 404
  94. with patch("backend.app.api.routes.printers.printer_manager") as manager:
  95. stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=headers)
  96. assert stop.status_code == 404
  97. manager.stop_print.assert_not_called()
  98. # The same 404 a printer id that doesn't exist gets
  99. missing = await async_client.get("/api/v1/printers/999999", headers=headers)
  100. assert missing.status_code == 404
  101. assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=headers)).status_code == 200
  102. async def test_scope_is_the_union_of_restricted_groups(self, async_client, printer_factory):
  103. a = await printer_factory(name="A")
  104. b = await printer_factory(name="B")
  105. await printer_factory(name="C")
  106. admin = await _admin_token(async_client)
  107. perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
  108. team_a = await _group(async_client, admin, "team_a", printer_ids=[a.id])
  109. team_b = await _group(async_client, admin, "team_b", printer_ids=[b.id])
  110. jwt, _ = await _user(async_client, admin, "both", [perms, team_a, team_b])
  111. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  112. async def test_restricted_group_without_printers_grants_none(self, async_client, printer_factory):
  113. await printer_factory(name="A")
  114. admin = await _admin_token(async_client)
  115. jwt, _ = await _team_member(async_client, admin, "locked_out", [])
  116. assert await _listed_ids(async_client, _auth(jwt)) == set()
  117. async def test_admin_sees_every_printer(self, async_client, printer_factory):
  118. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  119. admin = await _admin_token(async_client)
  120. await _team_member(async_client, admin, "member", [a.id])
  121. assert await _listed_ids(async_client, _auth(admin)) == {a.id, b.id}
  122. class TestGroupApi:
  123. async def test_round_trip(self, async_client, printer_factory):
  124. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  125. admin = await _admin_token(async_client)
  126. group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
  127. detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
  128. assert detail["restrict_printers"] is True
  129. assert detail["printer_ids"] == [a.id]
  130. patched = await async_client.patch(
  131. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"printer_ids": [a.id, b.id]}
  132. )
  133. assert patched.status_code == 200, patched.text
  134. assert patched.json()["printer_ids"] == [a.id, b.id]
  135. listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  136. assert next(g for g in listed if g["id"] == group_id)["printer_ids"] == [a.id, b.id]
  137. # Turning the flag off keeps the selection
  138. off = await async_client.patch(
  139. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"restrict_printers": False}
  140. )
  141. assert off.json()["restrict_printers"] is False
  142. assert off.json()["printer_ids"] == [a.id, b.id]
  143. async def test_unknown_printer_is_rejected(self, async_client, printer_factory):
  144. await printer_factory(name="A")
  145. admin = await _admin_token(async_client)
  146. response = await async_client.post(
  147. "/api/v1/groups/",
  148. headers=_auth(admin),
  149. json={"name": "team", "restrict_printers": True, "printer_ids": [424242]},
  150. )
  151. assert response.status_code == 400
  152. assert "424242" in response.json()["detail"]
  153. async def test_administrators_cannot_be_restricted(self, async_client):
  154. admin = await _admin_token(async_client)
  155. groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  156. admins = next(g for g in groups if g["name"] == "Administrators")
  157. response = await async_client.patch(
  158. f"/api/v1/groups/{admins['id']}", headers=_auth(admin), json={"restrict_printers": True}
  159. )
  160. assert response.status_code == 400
  161. async def test_deleting_a_printer_drops_it_from_groups(self, async_client, printer_factory):
  162. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  163. admin = await _admin_token(async_client)
  164. group_id = await _group(async_client, admin, "team", printer_ids=[a.id, b.id])
  165. with patch("backend.app.api.routes.printers.printer_manager"):
  166. deleted = await async_client.delete(f"/api/v1/printers/{b.id}", headers=_auth(admin))
  167. assert deleted.status_code == 200, deleted.text
  168. detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
  169. assert detail["printer_ids"] == [a.id]
  170. async def test_deleting_a_group_drops_its_printer_rows(self, async_client, printer_factory, db_session):
  171. from sqlalchemy import select
  172. from backend.app.models.group import group_printers
  173. a = await printer_factory(name="A")
  174. admin = await _admin_token(async_client)
  175. group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
  176. assert (await async_client.delete(f"/api/v1/groups/{group_id}", headers=_auth(admin))).status_code == 204
  177. rows = await db_session.execute(select(group_printers).where(group_printers.c.group_id == group_id))
  178. assert rows.first() is None
  179. class TestApiKeys:
  180. async def test_key_is_narrowed_to_its_owners_printers(self, async_client, printer_factory):
  181. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  182. admin = await _admin_token(async_client)
  183. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  184. # The key itself is unrestricted, but it can't out-rank its owner
  185. created = await async_client.post(
  186. "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_read_status": True}
  187. )
  188. assert created.status_code == 200, created.text
  189. key_headers = {"X-API-Key": created.json()["key"]}
  190. assert await _listed_ids(async_client, key_headers) == {a.id}
  191. assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=key_headers)).status_code == 404
  192. webhook = await async_client.get(f"/api/v1/webhook/printer/{b.id}/status", headers=key_headers)
  193. assert webhook.status_code == 404
  194. async def test_key_printer_ids_now_bind_every_printer_route(self, async_client, printer_factory):
  195. """``printer_ids`` used to be checked by the file routes and webhooks only."""
  196. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  197. admin = await _admin_token(async_client)
  198. created = await async_client.post(
  199. "/api/v1/api-keys/",
  200. headers=_auth(admin),
  201. json={"name": "k", "can_read_status": True, "can_control_printer": True, "printer_ids": [a.id]},
  202. )
  203. key_headers = {"X-API-Key": created.json()["key"]}
  204. assert await _listed_ids(async_client, key_headers) == {a.id}
  205. with patch("backend.app.api.routes.printers.printer_manager") as manager:
  206. stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=key_headers)
  207. assert stop.status_code == 404
  208. manager.stop_print.assert_not_called()
  209. class TestTokens:
  210. async def test_camera_stream_token_carries_its_minters_scope(self, async_client, printer_factory):
  211. from backend.app.core.auth import verify_camera_stream_token
  212. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  213. admin = await _admin_token(async_client)
  214. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  215. minted = await async_client.post("/api/v1/printers/camera/stream-token", headers=_auth(jwt))
  216. token = minted.json()["token"]
  217. scope = await verify_camera_stream_token(token)
  218. assert scope is not None and scope.printer_ids == frozenset({a.id})
  219. snapshot = await async_client.get(f"/api/v1/printers/{b.id}/camera/snapshot?token={token}")
  220. assert snapshot.status_code == 404
  221. async def test_camwall_token_lists_only_its_owners_printers(self, async_client, printer_factory):
  222. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  223. admin = await _admin_token(async_client)
  224. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  225. created = await async_client.post(
  226. "/api/v1/auth/tokens",
  227. headers=_auth(jwt),
  228. json={"name": "wall", "expires_in_days": 30, "scope": "camwall"},
  229. )
  230. assert created.status_code in (200, 201), created.text
  231. token = created.json()["token"]
  232. wall = await async_client.get(f"/api/v1/camwall/printers?token={token}")
  233. assert wall.status_code == 200, wall.text
  234. assert [p["id"] for p in wall.json()] == [a.id]
  235. async def _long_lived(self, async_client, jwt: str, scope: str) -> str:
  236. created = await async_client.post(
  237. "/api/v1/auth/tokens", headers=_auth(jwt), json={"name": scope, "expires_in_days": 30, "scope": scope}
  238. )
  239. assert created.status_code in (200, 201), created.text
  240. return created.json()["token"]
  241. async def test_long_lived_overlay_token_keeps_to_its_owners_printers(
  242. self, async_client, printer_factory, mock_printer_manager
  243. ):
  244. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  245. admin = await _admin_token(async_client)
  246. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  247. token = await self._long_lived(async_client, jwt, "overlay")
  248. hidden = await async_client.get(f"/api/v1/printers/{b.id}/overlay-status?token={token}")
  249. own = await async_client.get(f"/api/v1/printers/{a.id}/overlay-status?token={token}")
  250. assert hidden.status_code == 404
  251. assert own.status_code == 200, own.text
  252. async def test_long_lived_camera_token_keeps_to_its_owners_printers(self, async_client, printer_factory):
  253. from backend.app.core.auth import verify_camera_stream_token
  254. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  255. admin = await _admin_token(async_client)
  256. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  257. token = await self._long_lived(async_client, jwt, "camera_stream")
  258. scope = await verify_camera_stream_token(token)
  259. assert scope is not None and scope.printer_ids == frozenset({a.id})
  260. snapshot = await async_client.get(f"/api/v1/printers/{b.id}/camera/snapshot?token={token}")
  261. assert snapshot.status_code == 404
  262. async def test_websocket_token_carries_its_minters_scope(self, async_client, printer_factory):
  263. from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
  264. from backend.app.core.database import async_session
  265. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  266. admin = await _admin_token(async_client)
  267. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  268. token = (await async_client.post("/api/v1/auth/ws-token", headers=_auth(jwt))).json()["token"]
  269. principal = await verify_websocket_token_principal(token)
  270. assert principal == ("member", None)
  271. async with async_session() as db:
  272. scope = await principal_printer_scope(db, *principal)
  273. assert scope.printer_ids == frozenset({a.id})
  274. async def test_websocket_token_minted_by_a_key_carries_the_keys_scope(self, async_client, printer_factory):
  275. from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
  276. from backend.app.core.database import async_session
  277. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  278. admin = await _admin_token(async_client)
  279. created = await async_client.post(
  280. "/api/v1/api-keys/",
  281. headers=_auth(admin),
  282. json={"name": "k", "can_read_status": True, "printer_ids": [a.id]},
  283. )
  284. key_headers = {"X-API-Key": created.json()["key"]}
  285. token = (await async_client.post("/api/v1/auth/ws-token", headers=key_headers)).json()["token"]
  286. principal = await verify_websocket_token_principal(token)
  287. assert principal == ("", created.json()["id"])
  288. async with async_session() as db:
  289. scope = await principal_printer_scope(db, *principal)
  290. assert scope.printer_ids == frozenset({a.id})
  291. class TestQueueAndHistory:
  292. async def test_queue_hides_and_refuses_other_printers(self, async_client, printer_factory, archive_factory):
  293. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  294. archive = await archive_factory(a.id)
  295. admin = await _admin_token(async_client)
  296. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  297. on_b = await async_client.post(
  298. "/api/v1/queue/", headers=_auth(admin), json={"archive_id": archive.id, "printer_id": b.id}
  299. )
  300. assert on_b.status_code == 200, on_b.text
  301. listed = await async_client.get("/api/v1/queue/", headers=_auth(jwt))
  302. assert on_b.json()["id"] not in {item["id"] for item in listed.json()}
  303. item = await async_client.get(f"/api/v1/queue/{on_b.json()['id']}", headers=_auth(jwt))
  304. assert item.status_code == 404
  305. refused = await async_client.post(
  306. "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "printer_id": b.id}
  307. )
  308. assert refused.status_code == 404
  309. async def test_limited_key_cannot_queue_to_any_printer_of_a_model(
  310. self, async_client, printer_factory, archive_factory
  311. ):
  312. """A key's jobs record no creator, so "any X1C" would escape its printers."""
  313. a = await printer_factory(name="A", model="X1C")
  314. await printer_factory(name="B", model="X1C")
  315. archive = await archive_factory(a.id)
  316. admin = await _admin_token(async_client)
  317. created = await async_client.post(
  318. "/api/v1/api-keys/",
  319. headers=_auth(admin),
  320. json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
  321. )
  322. key_headers = {"X-API-Key": created.json()["key"]}
  323. refused = await async_client.post(
  324. "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "target_model": "X1C"}
  325. )
  326. assert refused.status_code == 400
  327. pinned = await async_client.post(
  328. "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "printer_id": a.id}
  329. )
  330. assert pinned.status_code == 200, pinned.text
  331. async def test_library_add_to_queue_keeps_to_the_scope(self, async_client, printer_factory):
  332. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  333. admin = await _admin_token(async_client)
  334. created = await async_client.post(
  335. "/api/v1/api-keys/",
  336. headers=_auth(admin),
  337. json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
  338. )
  339. key_headers = {"X-API-Key": created.json()["key"]}
  340. # Both are refused for the whole request, before any file is looked at
  341. hidden = await async_client.post(
  342. "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1], "printer_id": b.id}
  343. )
  344. assert hidden.status_code == 404
  345. any_model = await async_client.post(
  346. "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1]}
  347. )
  348. assert any_model.status_code == 400
  349. async def test_limited_user_may_queue_to_any_printer_of_a_model(
  350. self, async_client, printer_factory, archive_factory
  351. ):
  352. """The job carries the user's id, so the scheduler keeps it to their printers."""
  353. a = await printer_factory(name="A", model="X1C")
  354. await printer_factory(name="B", model="X1C")
  355. archive = await archive_factory(a.id)
  356. admin = await _admin_token(async_client)
  357. jwt, user_id = await _team_member(async_client, admin, "member", [a.id])
  358. queued = await async_client.post(
  359. "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "target_model": "X1C"}
  360. )
  361. assert queued.status_code == 200, queued.text
  362. assert queued.json()["created_by_id"] == user_id
  363. async def test_archive_list_keeps_to_the_team_printers(self, async_client, printer_factory, archive_factory):
  364. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  365. on_a = await archive_factory(a.id, print_name="on-a")
  366. on_b = await archive_factory(b.id, print_name="on-b")
  367. admin = await _admin_token(async_client)
  368. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  369. listed = await async_client.get("/api/v1/archives/", headers=_auth(jwt))
  370. ids = {row["id"] for row in listed.json()}
  371. assert on_a.id in ids
  372. assert on_b.id not in ids
  373. class TestModelTargetsOfALimitedKey:
  374. """A limited key may not reach "any printer of a model" by any route."""
  375. async def _key(self, async_client, admin: str, printer_id: int) -> dict[str, str]:
  376. created = await async_client.post(
  377. "/api/v1/api-keys/",
  378. headers=_auth(admin),
  379. json={"name": "k", "can_queue": True, "can_manage_library": True, "printer_ids": [printer_id]},
  380. )
  381. assert created.status_code == 200, created.text
  382. return {"X-API-Key": created.json()["key"]}
  383. async def test_editing_a_job_onto_a_model(self, async_client, printer_factory, archive_factory, db_session):
  384. from backend.app.models.print_queue import PrintQueueItem
  385. a = await printer_factory(name="A", model="X1C")
  386. await printer_factory(name="B", model="X1C")
  387. archive = await archive_factory(a.id)
  388. admin = await _admin_token(async_client)
  389. key = await self._key(async_client, admin, a.id)
  390. item = await async_client.post(
  391. "/api/v1/queue/", headers=key, json={"archive_id": archive.id, "printer_id": a.id}
  392. )
  393. assert item.status_code == 200, item.text
  394. moved = await async_client.patch(
  395. f"/api/v1/queue/{item.json()['id']}", headers=key, json={"printer_id": None, "target_model": "X1C"}
  396. )
  397. assert moved.status_code == 400, moved.text
  398. assert "specific printer" in moved.json()["detail"]
  399. row = await db_session.get(PrintQueueItem, item.json()["id"])
  400. await db_session.refresh(row)
  401. assert (row.printer_id, row.target_model) == (a.id, None)
  402. async def test_dispatching_a_batch_of_model_jobs(self, async_client, printer_factory, archive_factory):
  403. a = await printer_factory(name="A", model="X1C")
  404. await printer_factory(name="B", model="X1C")
  405. archive = await archive_factory(a.id)
  406. admin = await _admin_token(async_client)
  407. batch = await async_client.post(
  408. "/api/v1/queue/batches",
  409. headers=_auth(admin),
  410. json={"name": "o", "archive_id": archive.id, "plates": [{"plate_id": None, "quantity_target": 3}]},
  411. )
  412. assert batch.status_code in (200, 201), batch.text
  413. batch_id = batch.json()["id"]
  414. first = await async_client.post(
  415. "/api/v1/queue/",
  416. headers=_auth(admin),
  417. json={"archive_id": archive.id, "target_model": "X1C", "batch_id": batch_id},
  418. )
  419. assert first.status_code == 200, first.text
  420. key = await self._key(async_client, admin, a.id)
  421. dispatched = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=key, json={})
  422. assert dispatched.status_code == 400, dispatched.text
  423. assert "specific printer" in dispatched.json()["detail"]
  424. listed = await async_client.get("/api/v1/queue/", headers=_auth(admin))
  425. assert len(listed.json()) == 1
  426. async def test_running_a_pipeline_aimed_at_a_model(self, async_client, printer_factory, db_session, request):
  427. import uuid
  428. from pathlib import Path
  429. from backend.app.core.config import settings as app_settings
  430. from backend.app.models.library import LibraryFile
  431. a = await printer_factory(name="A", model="X1C")
  432. admin = await _admin_token(async_client)
  433. pipeline = await async_client.post(
  434. "/api/v1/slicer-pipelines/",
  435. headers=_auth(admin),
  436. json={
  437. "name": "p",
  438. "printer_preset": {"source": "local", "id": "1"},
  439. "process_preset": {"source": "local", "id": "2"},
  440. "filament_presets": [{"source": "local", "id": "3"}],
  441. },
  442. )
  443. assert pipeline.status_code == 201, pipeline.text
  444. aimed = await async_client.put(
  445. f"/api/v1/slicer-pipelines/{pipeline.json()['id']}",
  446. headers=_auth(admin),
  447. json={"target_kind": "printer_class", "target_model_class": "X1C"},
  448. )
  449. assert aimed.status_code == 200, aimed.text
  450. name = f"scope_pipeline_{uuid.uuid4().hex}.3mf"
  451. source_path = Path(app_settings.base_dir) / name
  452. source_path.write_bytes(b"")
  453. request.addfinalizer(lambda: source_path.unlink(missing_ok=True))
  454. source = LibraryFile(
  455. filename="cube.3mf",
  456. file_path=name,
  457. file_type="3mf",
  458. file_size=0,
  459. file_hash="scope-pipeline",
  460. source_type="uploaded",
  461. )
  462. db_session.add(source)
  463. await db_session.commit()
  464. key = await self._key(async_client, admin, a.id)
  465. run = await async_client.post(
  466. f"/api/v1/slicer-pipelines/{pipeline.json()['id']}/run",
  467. headers=key,
  468. json={"source_library_file_id": source.id},
  469. )
  470. assert run.status_code == 400, run.text
  471. assert "specific printer" in run.json()["detail"]
  472. class TestScheduler:
  473. async def test_model_matching_stays_within_the_scope(self, db_session, printer_factory):
  474. from backend.app.core.printer_scope import PrinterScope
  475. from backend.app.services.print_scheduler import PrintScheduler
  476. a = await printer_factory(name="A", model="X1C")
  477. await printer_factory(name="B", model="X1C")
  478. printers = await PrintScheduler()._printers_for_model(
  479. db_session, "X1C", printer_scope=PrinterScope(frozenset({a.id}))
  480. )
  481. assert [p.id for p in printers] == [a.id]
  482. async def test_deactivated_creator_reaches_no_printer(self, async_client, db_session, printer_factory):
  483. from backend.app.core.printer_scope import resolve_user_id_printer_scope
  484. await printer_factory(name="A")
  485. admin = await _admin_token(async_client)
  486. perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
  487. _jwt, user_id = await _user(async_client, admin, "gone", [perms])
  488. await async_client.patch(f"/api/v1/users/{user_id}", headers=_auth(admin), json={"is_active": False})
  489. scope = await resolve_user_id_printer_scope(db_session, user_id)
  490. assert scope.printer_ids == frozenset()
  491. class TestWebSocketRefresh:
  492. async def test_group_change_rescopes_open_sockets(self, async_client, printer_factory):
  493. from types import SimpleNamespace
  494. from backend.app.core.printer_scope import ALL_PRINTERS
  495. from backend.app.core.websocket import ws_manager
  496. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  497. admin = await _admin_token(async_client)
  498. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  499. groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  500. team_id = next(g["id"] for g in groups if g["name"] == "team_member")
  501. socket = SimpleNamespace(
  502. state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("member", None)),
  503. send_text=AsyncMock(),
  504. )
  505. ws_manager.active_connections.append(socket)
  506. try:
  507. await async_client.patch(f"/api/v1/groups/{team_id}", headers=_auth(admin), json={"printer_ids": [b.id]})
  508. assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({b.id})
  509. await ws_manager.send_printer_status(a.id, {})
  510. socket.send_text.assert_not_awaited()
  511. await ws_manager.send_printer_status(b.id, {})
  512. socket.send_text.assert_awaited_once()
  513. finally:
  514. ws_manager.active_connections.remove(socket)
  515. class TestByIdAndMedia:
  516. """Rows reached by id or by an ``<img>`` media token follow the same scope."""
  517. async def _archive_with_thumbnail(self, archive_factory, printer_id: int, name: str):
  518. import os
  519. from pathlib import Path
  520. from backend.app.core.config import settings
  521. rel = f"test_thumbs_1727_{os.getpid()}/{name}.png"
  522. thumb = Path(settings.base_dir) / rel
  523. thumb.parent.mkdir(parents=True, exist_ok=True)
  524. thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
  525. return await archive_factory(printer_id, thumbnail_path=rel)
  526. async def test_archives_by_id_and_by_media_token(self, async_client, printer_factory, archive_factory):
  527. import os
  528. import shutil
  529. from pathlib import Path
  530. from backend.app.core.config import settings
  531. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  532. on_a = await self._archive_with_thumbnail(archive_factory, a.id, "on_a")
  533. on_b = await self._archive_with_thumbnail(archive_factory, b.id, "on_b")
  534. admin = await _admin_token(async_client)
  535. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  536. try:
  537. assert (await async_client.get(f"/api/v1/archives/{on_a.id}", headers=_auth(jwt))).status_code == 200
  538. assert (await async_client.get(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))).status_code == 404
  539. deleted = await async_client.delete(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))
  540. assert deleted.status_code == 404
  541. # <img> requests carry a media token and no headers
  542. member_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(jwt))).json()["token"]
  543. admin_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(admin))).json()["token"]
  544. own = await async_client.get(f"/api/v1/archives/{on_a.id}/thumbnail?token={member_token}")
  545. assert own.status_code == 200
  546. hidden = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={member_token}")
  547. assert hidden.status_code == 404
  548. # An admin's thumbnails keep loading: no headers must not mean "no printers"
  549. admin_view = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={admin_token}")
  550. assert admin_view.status_code == 200
  551. finally:
  552. shutil.rmtree(Path(settings.base_dir) / f"test_thumbs_1727_{os.getpid()}", ignore_errors=True)
  553. async def test_camera_stop_is_scoped(self, async_client, printer_factory):
  554. _a, b = await printer_factory(name="A"), await printer_factory(name="B")
  555. admin = await _admin_token(async_client)
  556. jwt, _ = await _team_member(async_client, admin, "member", [_a.id])
  557. response = await async_client.post(f"/api/v1/printers/{b.id}/camera/stop", headers=_auth(jwt))
  558. assert response.status_code == 404
  559. async def test_clearing_the_print_log_keeps_other_printers_entries(
  560. self, async_client, printer_factory, archive_factory, db_session
  561. ):
  562. from sqlalchemy import select
  563. from backend.app.models.print_log import PrintLogEntry
  564. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  565. await archive_factory(a.id)
  566. await archive_factory(b.id)
  567. admin = await _admin_token(async_client)
  568. perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
  569. team = await _group(async_client, admin, "team", printer_ids=[a.id])
  570. jwt, _ = await _user(async_client, admin, "member", [perms, team])
  571. cleared = await async_client.delete("/api/v1/print-log/", headers=_auth(jwt))
  572. assert cleared.status_code == 200, cleared.text
  573. left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
  574. assert left == [b.id]
  575. async def _location_team(
  576. async_client: AsyncClient,
  577. admin_jwt: str,
  578. username: str,
  579. locations: list[str],
  580. *,
  581. printer_ids: list[int] | None = None,
  582. permissions: list[str] | None = None,
  583. ):
  584. """A member of a team group given *locations* (and optionally single printers)."""
  585. perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=permissions or TEAM_PERMISSIONS)
  586. response = await async_client.post(
  587. "/api/v1/groups/",
  588. headers=_auth(admin_jwt),
  589. json={
  590. "name": f"team_{username}",
  591. "restrict_printers": True,
  592. "printer_ids": printer_ids or [],
  593. "locations": locations,
  594. },
  595. )
  596. assert response.status_code == 201, response.text
  597. team = response.json()["id"]
  598. jwt, _ = await _user(async_client, admin_jwt, username, [perms, team])
  599. return jwt, team
  600. class TestLocations:
  601. async def test_location_grants_its_printers_plus_picked_ones(self, async_client, printer_factory):
  602. lab_1 = await printer_factory(name="L1", location="Lab A")
  603. lab_2 = await printer_factory(name="L2", location="Lab A")
  604. picked = await printer_factory(name="P", location="Lab B")
  605. await printer_factory(name="Other", location="Lab B")
  606. await printer_factory(name="Nowhere")
  607. admin = await _admin_token(async_client)
  608. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"], printer_ids=[picked.id])
  609. assert await _listed_ids(async_client, _auth(jwt)) == {lab_1.id, lab_2.id, picked.id}
  610. async def test_printer_added_to_a_location_is_reached_without_ticking_it(self, async_client, printer_factory):
  611. await printer_factory(name="L1", location="Lab A")
  612. admin = await _admin_token(async_client)
  613. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  614. later = await printer_factory(name="L2", location="Lab A")
  615. assert later.id in await _listed_ids(async_client, _auth(jwt))
  616. async def test_location_no_printer_has_grants_nothing(self, async_client, printer_factory):
  617. await printer_factory(name="A", location="Lab A")
  618. admin = await _admin_token(async_client)
  619. jwt, _ = await _location_team(async_client, admin, "lab", ["Basement"])
  620. assert await _listed_ids(async_client, _auth(jwt)) == set()
  621. async def test_locations_ignored_while_the_group_is_not_restricted(self, async_client, printer_factory):
  622. a = await printer_factory(name="A", location="Lab A")
  623. b = await printer_factory(name="B", location="Lab B")
  624. admin = await _admin_token(async_client)
  625. jwt, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  626. off = await async_client.patch(
  627. f"/api/v1/groups/{team}", headers=_auth(admin), json={"restrict_printers": False}
  628. )
  629. assert off.json()["locations"] == ["Lab A"]
  630. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  631. async def test_round_trip_trims_and_dedupes(self, async_client):
  632. admin = await _admin_token(async_client)
  633. created = await async_client.post(
  634. "/api/v1/groups/",
  635. headers=_auth(admin),
  636. json={"name": "team", "restrict_printers": True, "locations": [" Lab A ", "Lab A", "", "Lab B"]},
  637. )
  638. assert created.status_code == 201, created.text
  639. group_id = created.json()["id"]
  640. assert created.json()["locations"] == ["Lab A", "Lab B"]
  641. patched = await async_client.patch(
  642. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"locations": ["Lab C"]}
  643. )
  644. assert patched.json()["locations"] == ["Lab C"]
  645. detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
  646. assert detail["locations"] == ["Lab C"]
  647. listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  648. assert next(g for g in listed if g["id"] == group_id)["locations"] == ["Lab C"]
  649. # Leaving the field out keeps it
  650. untouched = await async_client.patch(
  651. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"description": "x"}
  652. )
  653. assert untouched.json()["locations"] == ["Lab C"]
  654. async def test_overlong_location_is_rejected(self, async_client):
  655. admin = await _admin_token(async_client)
  656. response = await async_client.post(
  657. "/api/v1/groups/",
  658. headers=_auth(admin),
  659. json={"name": "team", "restrict_printers": True, "locations": ["x" * 101]},
  660. )
  661. assert response.status_code == 400
  662. async def test_deleting_a_group_drops_its_location_rows(self, async_client, db_session):
  663. from sqlalchemy import select
  664. from backend.app.models.group import group_locations
  665. admin = await _admin_token(async_client)
  666. _, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  667. assert (await async_client.delete(f"/api/v1/groups/{team}", headers=_auth(admin))).status_code == 204
  668. rows = await db_session.execute(select(group_locations).where(group_locations.c.group_id == team))
  669. assert rows.first() is None
  670. class TestMovingPrinters:
  671. """A location grant turns a printer's location into an access setting."""
  672. async def test_non_admin_cannot_move_a_printer_out_of_a_granted_location(self, async_client, printer_factory):
  673. a = await printer_factory(name="A", location="Lab A")
  674. admin = await _admin_token(async_client)
  675. jwt, _ = await _location_team(
  676. async_client, admin, "lab", ["Lab A"], permissions=[*TEAM_PERMISSIONS, "printers:update"]
  677. )
  678. response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab B"})
  679. assert response.status_code == 403
  680. assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=_auth(admin))).json()["location"] == "Lab A"
  681. async def test_non_admin_cannot_move_a_printer_into_a_granted_location(self, async_client, printer_factory):
  682. a = await printer_factory(name="A", location="Lab B")
  683. admin = await _admin_token(async_client)
  684. await _location_team(async_client, admin, "lab", ["Lab A"])
  685. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  686. jwt, _ = await _user(async_client, admin, "editor", [perms])
  687. response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab A"})
  688. assert response.status_code == 403
  689. async def test_non_admin_may_move_between_locations_no_group_was_given(self, async_client, printer_factory):
  690. a = await printer_factory(name="A", location="Shelf 1")
  691. admin = await _admin_token(async_client)
  692. await _location_team(async_client, admin, "lab", ["Lab A"])
  693. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  694. jwt, _ = await _user(async_client, admin, "editor", [perms])
  695. response = await async_client.patch(
  696. f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": " Shelf 2 "}
  697. )
  698. assert response.status_code == 200, response.text
  699. # Trimmed, so it can match a location given to a group later
  700. assert response.json()["location"] == "Shelf 2"
  701. async def test_admin_move_rescopes_members_and_open_sockets(self, async_client, printer_factory):
  702. from types import SimpleNamespace
  703. from backend.app.core.printer_scope import ALL_PRINTERS
  704. from backend.app.core.websocket import ws_manager
  705. a = await printer_factory(name="A", location="Lab A")
  706. b = await printer_factory(name="B", location="Lab B")
  707. admin = await _admin_token(async_client)
  708. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  709. socket = SimpleNamespace(
  710. state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("lab", None)),
  711. send_text=AsyncMock(),
  712. )
  713. ws_manager.active_connections.append(socket)
  714. try:
  715. moved = await async_client.patch(
  716. f"/api/v1/printers/{b.id}", headers=_auth(admin), json={"location": "Lab A"}
  717. )
  718. assert moved.status_code == 200, moved.text
  719. assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({a.id, b.id})
  720. finally:
  721. ws_manager.active_connections.remove(socket)
  722. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  723. class TestLocationsPage:
  724. """The Printer Locations page (#2962) moves printers too, so it keeps to the same rules."""
  725. async def _editor(self, async_client, admin):
  726. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  727. jwt, _ = await _user(async_client, admin, "editor", [perms])
  728. return jwt
  729. async def test_list_shows_a_limited_caller_only_their_locations(self, async_client, printer_factory):
  730. mine = await printer_factory(name="M", location="Lab A")
  731. await printer_factory(name="T", location="Lab A")
  732. await printer_factory(name="O", location="Lab B")
  733. admin = await _admin_token(async_client)
  734. await async_client.post("/api/v1/printer-locations/", headers=_auth(admin), json={"name": "Empty room"})
  735. jwt, _ = await _team_member(async_client, admin, "member", [mine.id])
  736. listed = (await async_client.get("/api/v1/printer-locations/", headers=_auth(jwt))).json()
  737. assert [(loc["name"], loc["printer_count"]) for loc in listed] == [("Lab A", 1)]
  738. everything = (await async_client.get("/api/v1/printer-locations/", headers=_auth(admin))).json()
  739. assert {loc["name"] for loc in everything} == {"Lab A", "Lab B", "Empty room"}
  740. async def test_rename_carries_the_grant_so_nobody_loses_access(self, async_client, printer_factory, db_session):
  741. from sqlalchemy import select
  742. from backend.app.models.group import group_locations
  743. a = await printer_factory(name="A", location="Lab A")
  744. admin = await _admin_token(async_client)
  745. member, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  746. editor = await self._editor(async_client, admin)
  747. response = await async_client.patch(
  748. "/api/v1/printer-locations/", headers=_auth(editor), json={"name": "Lab A", "new_name": "Room 101"}
  749. )
  750. assert response.status_code == 200, response.text
  751. assert a.id in await _listed_ids(async_client, _auth(member))
  752. grants = (
  753. await db_session.execute(select(group_locations.c.location).where(group_locations.c.group_id == team))
  754. ).scalars()
  755. assert list(grants) == ["Room 101"]
  756. async def test_rename_onto_a_granted_name_is_for_admins(self, async_client, printer_factory):
  757. await printer_factory(name="A", location="Shelf")
  758. admin = await _admin_token(async_client)
  759. await _location_team(async_client, admin, "lab", ["Lab A"]) # granted, holds no printer yet
  760. editor = await self._editor(async_client, admin)
  761. body = {"name": "Shelf", "new_name": "Lab A"}
  762. response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(editor), json=body)
  763. assert response.status_code == 403
  764. response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(admin), json=body)
  765. assert response.status_code == 200, response.text
  766. async def test_delete_of_a_granted_location_is_for_admins_and_takes_the_grant(
  767. self, async_client, printer_factory, db_session
  768. ):
  769. from sqlalchemy import select
  770. from backend.app.models.group import group_locations
  771. await printer_factory(name="A", location="Lab A")
  772. admin = await _admin_token(async_client)
  773. member, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  774. editor = await self._editor(async_client, admin)
  775. body = {"names": ["Lab A"]}
  776. assert (
  777. await async_client.post("/api/v1/printer-locations/delete", headers=_auth(editor), json=body)
  778. ).status_code == 403
  779. assert (
  780. await async_client.post("/api/v1/printer-locations/delete", headers=_auth(admin), json=body)
  781. ).status_code == 200
  782. assert (await db_session.execute(select(group_locations))).first() is None
  783. # A later location of the same name is not handed to the old group.
  784. later = await printer_factory(name="B", location="Lab A")
  785. assert later.id not in await _listed_ids(async_client, _auth(member))
  786. async def test_assign_into_or_out_of_a_granted_location_is_for_admins(self, async_client, printer_factory):
  787. inside = await printer_factory(name="In", location="Lab A")
  788. outside = await printer_factory(name="Out", location="Shelf")
  789. admin = await _admin_token(async_client)
  790. await _location_team(async_client, admin, "lab", ["Lab A"])
  791. editor = await self._editor(async_client, admin)
  792. for body in (
  793. {"printer_ids": [outside.id], "location": "Lab A"},
  794. {"printer_ids": [inside.id], "location": None},
  795. ):
  796. response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=body)
  797. assert response.status_code == 403, body
  798. ungranted = {"printer_ids": [outside.id], "location": "Shelf 2"}
  799. response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=ungranted)
  800. assert response.status_code == 200, response.text
  801. async def test_a_limited_caller_cannot_move_or_rename_what_they_cannot_see(self, async_client, printer_factory):
  802. mine = await printer_factory(name="M", location="Lab A")
  803. theirs = await printer_factory(name="T", location="Lab A")
  804. admin = await _admin_token(async_client)
  805. perms = await _group(
  806. async_client, admin, "perms", permissions=["printers:read", "printers:update", *TEAM_PERMISSIONS]
  807. )
  808. team = await _group(async_client, admin, "team", printer_ids=[mine.id])
  809. jwt, _ = await _user(async_client, admin, "member", [perms, team])
  810. moved = await async_client.post(
  811. "/api/v1/printer-locations/assign",
  812. headers=_auth(jwt),
  813. json={"printer_ids": [theirs.id], "location": "Elsewhere"},
  814. )
  815. assert moved.status_code == 404
  816. renamed = await async_client.patch(
  817. "/api/v1/printer-locations/", headers=_auth(jwt), json={"name": "Lab A", "new_name": "Lab Z"}
  818. )
  819. assert renamed.status_code == 403
  820. assert (await async_client.get(f"/api/v1/printers/{theirs.id}", headers=_auth(admin))).json()[
  821. "location"
  822. ] == "Lab A"
  823. class TestProjectTimeline:
  824. async def test_timeline_hides_other_printers(self, async_client, printer_factory, archive_factory, db_session):
  825. """Like the project's archive and queue lists, the timeline shows only the
  826. member's printers: neither a finished print nor a queued job on another
  827. printer appears, by name or by id."""
  828. from backend.app.models.print_queue import PrintQueueItem
  829. from backend.app.models.project import Project
  830. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  831. project = Project(name="Shared")
  832. db_session.add(project)
  833. await db_session.commit()
  834. project_id = project.id
  835. await archive_factory(a.id, project_id=project_id, print_name="Mine done")
  836. await archive_factory(b.id, project_id=project_id, print_name="Theirs done")
  837. # The queued jobs' archives belong to no project, so their names can only
  838. # reach the timeline through the queue events.
  839. mine = await archive_factory(a.id, print_name="Mine queued", status="archived", with_run=False)
  840. theirs = await archive_factory(b.id, print_name="Theirs queued", status="archived", with_run=False)
  841. db_session.add_all(
  842. [
  843. PrintQueueItem(project_id=project_id, printer_id=a.id, archive_id=mine.id, status="pending"),
  844. PrintQueueItem(project_id=project_id, printer_id=b.id, archive_id=theirs.id, status="pending"),
  845. ]
  846. )
  847. await db_session.commit()
  848. admin = await _admin_token(async_client)
  849. perms = await _group(async_client, admin, "perms_timeline", permissions=[*TEAM_PERMISSIONS, "projects:read"])
  850. team = await _group(async_client, admin, "team_timeline", printer_ids=[a.id])
  851. jwt, _ = await _user(async_client, admin, "timeline_member", [perms, team])
  852. def events(response) -> set[tuple[str, str | None]]:
  853. assert response.status_code == 200, response.text
  854. return {(e["event_type"], e.get("description")) for e in response.json()}
  855. member = events(await async_client.get(f"/api/v1/projects/{project_id}/timeline", headers=_auth(jwt)))
  856. assert {("print_completed", "Mine done"), ("queued", "Mine queued")} <= member
  857. assert not {d for _, d in member} & {"Theirs done", "Theirs queued"}
  858. full = events(await async_client.get(f"/api/v1/projects/{project_id}/timeline", headers=_auth(admin)))
  859. assert {("print_completed", "Theirs done"), ("queued", "Theirs queued")} <= full