test_printer_scope_1727.py 53 KB

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