test_printer_scope_1727.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859
  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 test_websocket_token_carries_its_minters_scope(self, async_client, printer_factory):
  236. from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
  237. from backend.app.core.database import async_session
  238. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  239. admin = await _admin_token(async_client)
  240. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  241. token = (await async_client.post("/api/v1/auth/ws-token", headers=_auth(jwt))).json()["token"]
  242. principal = await verify_websocket_token_principal(token)
  243. assert principal == ("member", None)
  244. async with async_session() as db:
  245. scope = await principal_printer_scope(db, *principal)
  246. assert scope.printer_ids == frozenset({a.id})
  247. async def test_websocket_token_minted_by_a_key_carries_the_keys_scope(self, async_client, printer_factory):
  248. from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
  249. from backend.app.core.database import async_session
  250. a, _b = await printer_factory(name="A"), await printer_factory(name="B")
  251. admin = await _admin_token(async_client)
  252. created = await async_client.post(
  253. "/api/v1/api-keys/",
  254. headers=_auth(admin),
  255. json={"name": "k", "can_read_status": True, "printer_ids": [a.id]},
  256. )
  257. key_headers = {"X-API-Key": created.json()["key"]}
  258. token = (await async_client.post("/api/v1/auth/ws-token", headers=key_headers)).json()["token"]
  259. principal = await verify_websocket_token_principal(token)
  260. assert principal == ("", created.json()["id"])
  261. async with async_session() as db:
  262. scope = await principal_printer_scope(db, *principal)
  263. assert scope.printer_ids == frozenset({a.id})
  264. class TestQueueAndHistory:
  265. async def test_queue_hides_and_refuses_other_printers(self, async_client, printer_factory, archive_factory):
  266. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  267. archive = await archive_factory(a.id)
  268. admin = await _admin_token(async_client)
  269. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  270. on_b = await async_client.post(
  271. "/api/v1/queue/", headers=_auth(admin), json={"archive_id": archive.id, "printer_id": b.id}
  272. )
  273. assert on_b.status_code == 200, on_b.text
  274. listed = await async_client.get("/api/v1/queue/", headers=_auth(jwt))
  275. assert on_b.json()["id"] not in {item["id"] for item in listed.json()}
  276. item = await async_client.get(f"/api/v1/queue/{on_b.json()['id']}", headers=_auth(jwt))
  277. assert item.status_code == 404
  278. refused = await async_client.post(
  279. "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "printer_id": b.id}
  280. )
  281. assert refused.status_code == 404
  282. async def test_limited_key_cannot_queue_to_any_printer_of_a_model(
  283. self, async_client, printer_factory, archive_factory
  284. ):
  285. """A key's jobs record no creator, so "any X1C" would escape its printers."""
  286. a = await printer_factory(name="A", model="X1C")
  287. await printer_factory(name="B", model="X1C")
  288. archive = await archive_factory(a.id)
  289. admin = await _admin_token(async_client)
  290. created = await async_client.post(
  291. "/api/v1/api-keys/",
  292. headers=_auth(admin),
  293. json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
  294. )
  295. key_headers = {"X-API-Key": created.json()["key"]}
  296. refused = await async_client.post(
  297. "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "target_model": "X1C"}
  298. )
  299. assert refused.status_code == 400
  300. pinned = await async_client.post(
  301. "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "printer_id": a.id}
  302. )
  303. assert pinned.status_code == 200, pinned.text
  304. async def test_library_add_to_queue_keeps_to_the_scope(self, async_client, printer_factory):
  305. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  306. admin = await _admin_token(async_client)
  307. created = await async_client.post(
  308. "/api/v1/api-keys/",
  309. headers=_auth(admin),
  310. json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
  311. )
  312. key_headers = {"X-API-Key": created.json()["key"]}
  313. # Both are refused for the whole request, before any file is looked at
  314. hidden = await async_client.post(
  315. "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1], "printer_id": b.id}
  316. )
  317. assert hidden.status_code == 404
  318. any_model = await async_client.post(
  319. "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1]}
  320. )
  321. assert any_model.status_code == 400
  322. async def test_limited_user_may_queue_to_any_printer_of_a_model(
  323. self, async_client, printer_factory, archive_factory
  324. ):
  325. """The job carries the user's id, so the scheduler keeps it to their printers."""
  326. a = await printer_factory(name="A", model="X1C")
  327. await printer_factory(name="B", model="X1C")
  328. archive = await archive_factory(a.id)
  329. admin = await _admin_token(async_client)
  330. jwt, user_id = await _team_member(async_client, admin, "member", [a.id])
  331. queued = await async_client.post(
  332. "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "target_model": "X1C"}
  333. )
  334. assert queued.status_code == 200, queued.text
  335. assert queued.json()["created_by_id"] == user_id
  336. async def test_archive_list_keeps_to_the_team_printers(self, async_client, printer_factory, archive_factory):
  337. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  338. on_a = await archive_factory(a.id, print_name="on-a")
  339. on_b = await archive_factory(b.id, print_name="on-b")
  340. admin = await _admin_token(async_client)
  341. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  342. listed = await async_client.get("/api/v1/archives/", headers=_auth(jwt))
  343. ids = {row["id"] for row in listed.json()}
  344. assert on_a.id in ids
  345. assert on_b.id not in ids
  346. class TestScheduler:
  347. async def test_model_matching_stays_within_the_scope(self, db_session, printer_factory):
  348. from backend.app.core.printer_scope import PrinterScope
  349. from backend.app.services.print_scheduler import PrintScheduler
  350. a = await printer_factory(name="A", model="X1C")
  351. await printer_factory(name="B", model="X1C")
  352. printers = await PrintScheduler()._printers_for_model(
  353. db_session, "X1C", printer_scope=PrinterScope(frozenset({a.id}))
  354. )
  355. assert [p.id for p in printers] == [a.id]
  356. async def test_deactivated_creator_reaches_no_printer(self, async_client, db_session, printer_factory):
  357. from backend.app.core.printer_scope import resolve_user_id_printer_scope
  358. await printer_factory(name="A")
  359. admin = await _admin_token(async_client)
  360. perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
  361. _jwt, user_id = await _user(async_client, admin, "gone", [perms])
  362. await async_client.patch(f"/api/v1/users/{user_id}", headers=_auth(admin), json={"is_active": False})
  363. scope = await resolve_user_id_printer_scope(db_session, user_id)
  364. assert scope.printer_ids == frozenset()
  365. class TestWebSocketRefresh:
  366. async def test_group_change_rescopes_open_sockets(self, async_client, printer_factory):
  367. from types import SimpleNamespace
  368. from backend.app.core.printer_scope import ALL_PRINTERS
  369. from backend.app.core.websocket import ws_manager
  370. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  371. admin = await _admin_token(async_client)
  372. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  373. groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  374. team_id = next(g["id"] for g in groups if g["name"] == "team_member")
  375. socket = SimpleNamespace(
  376. state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("member", None)),
  377. send_text=AsyncMock(),
  378. )
  379. ws_manager.active_connections.append(socket)
  380. try:
  381. await async_client.patch(f"/api/v1/groups/{team_id}", headers=_auth(admin), json={"printer_ids": [b.id]})
  382. assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({b.id})
  383. await ws_manager.send_printer_status(a.id, {})
  384. socket.send_text.assert_not_awaited()
  385. await ws_manager.send_printer_status(b.id, {})
  386. socket.send_text.assert_awaited_once()
  387. finally:
  388. ws_manager.active_connections.remove(socket)
  389. class TestByIdAndMedia:
  390. """Rows reached by id or by an ``<img>`` media token follow the same scope."""
  391. async def _archive_with_thumbnail(self, archive_factory, printer_id: int, name: str):
  392. import os
  393. from pathlib import Path
  394. from backend.app.core.config import settings
  395. rel = f"test_thumbs_1727_{os.getpid()}/{name}.png"
  396. thumb = Path(settings.base_dir) / rel
  397. thumb.parent.mkdir(parents=True, exist_ok=True)
  398. thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
  399. return await archive_factory(printer_id, thumbnail_path=rel)
  400. async def test_archives_by_id_and_by_media_token(self, async_client, printer_factory, archive_factory):
  401. import os
  402. import shutil
  403. from pathlib import Path
  404. from backend.app.core.config import settings
  405. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  406. on_a = await self._archive_with_thumbnail(archive_factory, a.id, "on_a")
  407. on_b = await self._archive_with_thumbnail(archive_factory, b.id, "on_b")
  408. admin = await _admin_token(async_client)
  409. jwt, _ = await _team_member(async_client, admin, "member", [a.id])
  410. try:
  411. assert (await async_client.get(f"/api/v1/archives/{on_a.id}", headers=_auth(jwt))).status_code == 200
  412. assert (await async_client.get(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))).status_code == 404
  413. deleted = await async_client.delete(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))
  414. assert deleted.status_code == 404
  415. # <img> requests carry a media token and no headers
  416. member_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(jwt))).json()["token"]
  417. admin_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(admin))).json()["token"]
  418. own = await async_client.get(f"/api/v1/archives/{on_a.id}/thumbnail?token={member_token}")
  419. assert own.status_code == 200
  420. hidden = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={member_token}")
  421. assert hidden.status_code == 404
  422. # An admin's thumbnails keep loading: no headers must not mean "no printers"
  423. admin_view = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={admin_token}")
  424. assert admin_view.status_code == 200
  425. finally:
  426. shutil.rmtree(Path(settings.base_dir) / f"test_thumbs_1727_{os.getpid()}", ignore_errors=True)
  427. async def test_camera_stop_is_scoped(self, async_client, printer_factory):
  428. _a, b = await printer_factory(name="A"), await printer_factory(name="B")
  429. admin = await _admin_token(async_client)
  430. jwt, _ = await _team_member(async_client, admin, "member", [_a.id])
  431. response = await async_client.post(f"/api/v1/printers/{b.id}/camera/stop", headers=_auth(jwt))
  432. assert response.status_code == 404
  433. async def test_clearing_the_print_log_keeps_other_printers_entries(
  434. self, async_client, printer_factory, archive_factory, db_session
  435. ):
  436. from sqlalchemy import select
  437. from backend.app.models.print_log import PrintLogEntry
  438. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  439. await archive_factory(a.id)
  440. await archive_factory(b.id)
  441. admin = await _admin_token(async_client)
  442. perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
  443. team = await _group(async_client, admin, "team", printer_ids=[a.id])
  444. jwt, _ = await _user(async_client, admin, "member", [perms, team])
  445. cleared = await async_client.delete("/api/v1/print-log/", headers=_auth(jwt))
  446. assert cleared.status_code == 200, cleared.text
  447. left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
  448. assert left == [b.id]
  449. async def _location_team(
  450. async_client: AsyncClient,
  451. admin_jwt: str,
  452. username: str,
  453. locations: list[str],
  454. *,
  455. printer_ids: list[int] | None = None,
  456. permissions: list[str] | None = None,
  457. ):
  458. """A member of a team group given *locations* (and optionally single printers)."""
  459. perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=permissions or TEAM_PERMISSIONS)
  460. response = await async_client.post(
  461. "/api/v1/groups/",
  462. headers=_auth(admin_jwt),
  463. json={
  464. "name": f"team_{username}",
  465. "restrict_printers": True,
  466. "printer_ids": printer_ids or [],
  467. "locations": locations,
  468. },
  469. )
  470. assert response.status_code == 201, response.text
  471. team = response.json()["id"]
  472. jwt, _ = await _user(async_client, admin_jwt, username, [perms, team])
  473. return jwt, team
  474. class TestLocations:
  475. async def test_location_grants_its_printers_plus_picked_ones(self, async_client, printer_factory):
  476. lab_1 = await printer_factory(name="L1", location="Lab A")
  477. lab_2 = await printer_factory(name="L2", location="Lab A")
  478. picked = await printer_factory(name="P", location="Lab B")
  479. await printer_factory(name="Other", location="Lab B")
  480. await printer_factory(name="Nowhere")
  481. admin = await _admin_token(async_client)
  482. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"], printer_ids=[picked.id])
  483. assert await _listed_ids(async_client, _auth(jwt)) == {lab_1.id, lab_2.id, picked.id}
  484. async def test_printer_added_to_a_location_is_reached_without_ticking_it(self, async_client, printer_factory):
  485. await printer_factory(name="L1", location="Lab A")
  486. admin = await _admin_token(async_client)
  487. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  488. later = await printer_factory(name="L2", location="Lab A")
  489. assert later.id in await _listed_ids(async_client, _auth(jwt))
  490. async def test_location_no_printer_has_grants_nothing(self, async_client, printer_factory):
  491. await printer_factory(name="A", location="Lab A")
  492. admin = await _admin_token(async_client)
  493. jwt, _ = await _location_team(async_client, admin, "lab", ["Basement"])
  494. assert await _listed_ids(async_client, _auth(jwt)) == set()
  495. async def test_locations_ignored_while_the_group_is_not_restricted(self, async_client, printer_factory):
  496. a = await printer_factory(name="A", location="Lab A")
  497. b = await printer_factory(name="B", location="Lab B")
  498. admin = await _admin_token(async_client)
  499. jwt, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  500. off = await async_client.patch(
  501. f"/api/v1/groups/{team}", headers=_auth(admin), json={"restrict_printers": False}
  502. )
  503. assert off.json()["locations"] == ["Lab A"]
  504. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  505. async def test_round_trip_trims_and_dedupes(self, async_client):
  506. admin = await _admin_token(async_client)
  507. created = await async_client.post(
  508. "/api/v1/groups/",
  509. headers=_auth(admin),
  510. json={"name": "team", "restrict_printers": True, "locations": [" Lab A ", "Lab A", "", "Lab B"]},
  511. )
  512. assert created.status_code == 201, created.text
  513. group_id = created.json()["id"]
  514. assert created.json()["locations"] == ["Lab A", "Lab B"]
  515. patched = await async_client.patch(
  516. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"locations": ["Lab C"]}
  517. )
  518. assert patched.json()["locations"] == ["Lab C"]
  519. detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
  520. assert detail["locations"] == ["Lab C"]
  521. listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
  522. assert next(g for g in listed if g["id"] == group_id)["locations"] == ["Lab C"]
  523. # Leaving the field out keeps it
  524. untouched = await async_client.patch(
  525. f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"description": "x"}
  526. )
  527. assert untouched.json()["locations"] == ["Lab C"]
  528. async def test_overlong_location_is_rejected(self, async_client):
  529. admin = await _admin_token(async_client)
  530. response = await async_client.post(
  531. "/api/v1/groups/",
  532. headers=_auth(admin),
  533. json={"name": "team", "restrict_printers": True, "locations": ["x" * 101]},
  534. )
  535. assert response.status_code == 400
  536. async def test_deleting_a_group_drops_its_location_rows(self, async_client, db_session):
  537. from sqlalchemy import select
  538. from backend.app.models.group import group_locations
  539. admin = await _admin_token(async_client)
  540. _, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  541. assert (await async_client.delete(f"/api/v1/groups/{team}", headers=_auth(admin))).status_code == 204
  542. rows = await db_session.execute(select(group_locations).where(group_locations.c.group_id == team))
  543. assert rows.first() is None
  544. class TestMovingPrinters:
  545. """A location grant turns a printer's location into an access setting."""
  546. async def test_non_admin_cannot_move_a_printer_out_of_a_granted_location(self, async_client, printer_factory):
  547. a = await printer_factory(name="A", location="Lab A")
  548. admin = await _admin_token(async_client)
  549. jwt, _ = await _location_team(
  550. async_client, admin, "lab", ["Lab A"], permissions=[*TEAM_PERMISSIONS, "printers:update"]
  551. )
  552. response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab B"})
  553. assert response.status_code == 403
  554. assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=_auth(admin))).json()["location"] == "Lab A"
  555. async def test_non_admin_cannot_move_a_printer_into_a_granted_location(self, async_client, printer_factory):
  556. a = await printer_factory(name="A", location="Lab B")
  557. admin = await _admin_token(async_client)
  558. await _location_team(async_client, admin, "lab", ["Lab A"])
  559. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  560. jwt, _ = await _user(async_client, admin, "editor", [perms])
  561. response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab A"})
  562. assert response.status_code == 403
  563. async def test_non_admin_may_move_between_locations_no_group_was_given(self, async_client, printer_factory):
  564. a = await printer_factory(name="A", location="Shelf 1")
  565. admin = await _admin_token(async_client)
  566. await _location_team(async_client, admin, "lab", ["Lab A"])
  567. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  568. jwt, _ = await _user(async_client, admin, "editor", [perms])
  569. response = await async_client.patch(
  570. f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": " Shelf 2 "}
  571. )
  572. assert response.status_code == 200, response.text
  573. # Trimmed, so it can match a location given to a group later
  574. assert response.json()["location"] == "Shelf 2"
  575. async def test_admin_move_rescopes_members_and_open_sockets(self, async_client, printer_factory):
  576. from types import SimpleNamespace
  577. from backend.app.core.printer_scope import ALL_PRINTERS
  578. from backend.app.core.websocket import ws_manager
  579. a = await printer_factory(name="A", location="Lab A")
  580. b = await printer_factory(name="B", location="Lab B")
  581. admin = await _admin_token(async_client)
  582. jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  583. socket = SimpleNamespace(
  584. state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("lab", None)),
  585. send_text=AsyncMock(),
  586. )
  587. ws_manager.active_connections.append(socket)
  588. try:
  589. moved = await async_client.patch(
  590. f"/api/v1/printers/{b.id}", headers=_auth(admin), json={"location": "Lab A"}
  591. )
  592. assert moved.status_code == 200, moved.text
  593. assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({a.id, b.id})
  594. finally:
  595. ws_manager.active_connections.remove(socket)
  596. assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
  597. class TestLocationsPage:
  598. """The Printer Locations page (#2962) moves printers too, so it keeps to the same rules."""
  599. async def _editor(self, async_client, admin):
  600. perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
  601. jwt, _ = await _user(async_client, admin, "editor", [perms])
  602. return jwt
  603. async def test_list_shows_a_limited_caller_only_their_locations(self, async_client, printer_factory):
  604. mine = await printer_factory(name="M", location="Lab A")
  605. await printer_factory(name="T", location="Lab A")
  606. await printer_factory(name="O", location="Lab B")
  607. admin = await _admin_token(async_client)
  608. await async_client.post("/api/v1/printer-locations/", headers=_auth(admin), json={"name": "Empty room"})
  609. jwt, _ = await _team_member(async_client, admin, "member", [mine.id])
  610. listed = (await async_client.get("/api/v1/printer-locations/", headers=_auth(jwt))).json()
  611. assert [(loc["name"], loc["printer_count"]) for loc in listed] == [("Lab A", 1)]
  612. everything = (await async_client.get("/api/v1/printer-locations/", headers=_auth(admin))).json()
  613. assert {loc["name"] for loc in everything} == {"Lab A", "Lab B", "Empty room"}
  614. async def test_rename_carries_the_grant_so_nobody_loses_access(self, async_client, printer_factory, db_session):
  615. from sqlalchemy import select
  616. from backend.app.models.group import group_locations
  617. a = await printer_factory(name="A", location="Lab A")
  618. admin = await _admin_token(async_client)
  619. member, team = await _location_team(async_client, admin, "lab", ["Lab A"])
  620. editor = await self._editor(async_client, admin)
  621. response = await async_client.patch(
  622. "/api/v1/printer-locations/", headers=_auth(editor), json={"name": "Lab A", "new_name": "Room 101"}
  623. )
  624. assert response.status_code == 200, response.text
  625. assert a.id in await _listed_ids(async_client, _auth(member))
  626. grants = (
  627. await db_session.execute(select(group_locations.c.location).where(group_locations.c.group_id == team))
  628. ).scalars()
  629. assert list(grants) == ["Room 101"]
  630. async def test_rename_onto_a_granted_name_is_for_admins(self, async_client, printer_factory):
  631. await printer_factory(name="A", location="Shelf")
  632. admin = await _admin_token(async_client)
  633. await _location_team(async_client, admin, "lab", ["Lab A"]) # granted, holds no printer yet
  634. editor = await self._editor(async_client, admin)
  635. body = {"name": "Shelf", "new_name": "Lab A"}
  636. response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(editor), json=body)
  637. assert response.status_code == 403
  638. response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(admin), json=body)
  639. assert response.status_code == 200, response.text
  640. async def test_delete_of_a_granted_location_is_for_admins_and_takes_the_grant(
  641. self, async_client, printer_factory, db_session
  642. ):
  643. from sqlalchemy import select
  644. from backend.app.models.group import group_locations
  645. await printer_factory(name="A", location="Lab A")
  646. admin = await _admin_token(async_client)
  647. member, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
  648. editor = await self._editor(async_client, admin)
  649. body = {"names": ["Lab A"]}
  650. assert (
  651. await async_client.post("/api/v1/printer-locations/delete", headers=_auth(editor), json=body)
  652. ).status_code == 403
  653. assert (
  654. await async_client.post("/api/v1/printer-locations/delete", headers=_auth(admin), json=body)
  655. ).status_code == 200
  656. assert (await db_session.execute(select(group_locations))).first() is None
  657. # A later location of the same name is not handed to the old group.
  658. later = await printer_factory(name="B", location="Lab A")
  659. assert later.id not in await _listed_ids(async_client, _auth(member))
  660. async def test_assign_into_or_out_of_a_granted_location_is_for_admins(self, async_client, printer_factory):
  661. inside = await printer_factory(name="In", location="Lab A")
  662. outside = await printer_factory(name="Out", location="Shelf")
  663. admin = await _admin_token(async_client)
  664. await _location_team(async_client, admin, "lab", ["Lab A"])
  665. editor = await self._editor(async_client, admin)
  666. for body in (
  667. {"printer_ids": [outside.id], "location": "Lab A"},
  668. {"printer_ids": [inside.id], "location": None},
  669. ):
  670. response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=body)
  671. assert response.status_code == 403, body
  672. ungranted = {"printer_ids": [outside.id], "location": "Shelf 2"}
  673. response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=ungranted)
  674. assert response.status_code == 200, response.text
  675. async def test_a_limited_caller_cannot_move_or_rename_what_they_cannot_see(self, async_client, printer_factory):
  676. mine = await printer_factory(name="M", location="Lab A")
  677. theirs = await printer_factory(name="T", location="Lab A")
  678. admin = await _admin_token(async_client)
  679. perms = await _group(
  680. async_client, admin, "perms", permissions=["printers:read", "printers:update", *TEAM_PERMISSIONS]
  681. )
  682. team = await _group(async_client, admin, "team", printer_ids=[mine.id])
  683. jwt, _ = await _user(async_client, admin, "member", [perms, team])
  684. moved = await async_client.post(
  685. "/api/v1/printer-locations/assign",
  686. headers=_auth(jwt),
  687. json={"printer_ids": [theirs.id], "location": "Elsewhere"},
  688. )
  689. assert moved.status_code == 404
  690. renamed = await async_client.patch(
  691. "/api/v1/printer-locations/", headers=_auth(jwt), json={"name": "Lab A", "new_name": "Lab Z"}
  692. )
  693. assert renamed.status_code == 403
  694. assert (await async_client.get(f"/api/v1/printers/{theirs.id}", headers=_auth(admin))).json()[
  695. "location"
  696. ] == "Lab A"