test_api_key_queue_acts_as_owner.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. """An API key acts as its owner (#3256).
  2. A route's own checks on cost centers, archives, library files and folders run
  3. against the key's owner, with the owner's permissions narrowed to the key's
  4. scope flags. Where it applies, each case goes through the key and through the
  5. owner's session, which must agree. A key made before keys had owners owns
  6. nothing and may use no cost center.
  7. """
  8. from __future__ import annotations
  9. from pathlib import Path
  10. from uuid import uuid4
  11. import pytest
  12. from httpx import AsyncClient
  13. from sqlalchemy import select
  14. from backend.app.core.auth import generate_api_key, get_password_hash
  15. from backend.app.core.config import settings as app_settings
  16. from backend.app.models.api_key import APIKey
  17. from backend.app.models.archive import PrintArchive
  18. from backend.app.models.finance import CostCenter, CostCenterMember
  19. from backend.app.models.group import Group
  20. from backend.app.models.library import LibraryFile, LibraryFolder
  21. from backend.app.models.print_batch import PrintBatch
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.printer import Printer
  24. from backend.app.models.settings import Settings
  25. from backend.app.models.user import User
  26. PASSWORD = "Ownerpass1!"
  27. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  28. async def _set(db_session, key: str, value: str) -> None:
  29. row = await db_session.scalar(select(Settings).where(Settings.key == key))
  30. if row is None:
  31. db_session.add(Settings(key=key, value=value))
  32. else:
  33. row.value = value
  34. @pytest.fixture
  35. async def world(db_session):
  36. """Auth and billing on; a key owner who may queue and reprint only their
  37. own archives, another user, and one printer."""
  38. await _set(db_session, "auth_enabled", "true")
  39. await _set(db_session, "advanced_auth_enabled", "false")
  40. await _set(db_session, "billing_enabled", "true")
  41. group = Group(
  42. name="own-queuers",
  43. description="t",
  44. permissions=[
  45. "queue:create",
  46. "queue:read_own",
  47. "queue:update_own",
  48. "archives:read_own",
  49. "archives:reprint_own",
  50. "library:read_own",
  51. ],
  52. is_system=False,
  53. )
  54. db_session.add(group)
  55. await db_session.flush()
  56. owner = User(username="keyowner", password_hash=get_password_hash(PASSWORD), is_active=True, groups=[group])
  57. other = User(username="otheruser", password_hash=get_password_hash(PASSWORD), is_active=True)
  58. admin = User(username="adminowner", password_hash=get_password_hash(PASSWORD), role="admin", is_active=True)
  59. printer = Printer(
  60. name="P", ip_address="192.168.9.9", serial_number="00M00A3256000001", access_code="12345678", model="X1C"
  61. )
  62. db_session.add_all([owner, other, admin, printer])
  63. await db_session.commit()
  64. return {"owner": owner, "other": other, "admin": admin, "printer": printer}
  65. async def _archive(db_session, created_by_id: int | None, n: int) -> PrintArchive:
  66. archive = PrintArchive(
  67. filename=f"a{n}.3mf",
  68. print_name=f"a{n}",
  69. file_path=f"/tmp/a3256_{n}.3mf", # nosec B108
  70. file_size=1,
  71. content_hash=f"hash3256_{n}",
  72. status="completed",
  73. cost=1.25,
  74. filament_used_grams=50.0,
  75. created_by_id=created_by_id,
  76. )
  77. db_session.add(archive)
  78. await db_session.commit()
  79. await db_session.refresh(archive)
  80. return archive
  81. async def _center(db_session, *, owner_user_id: int | None = None, member_id: int | None = None) -> CostCenter:
  82. center = CostCenter(
  83. name=f"cc-{owner_user_id}-{member_id}",
  84. is_active=True,
  85. is_private=owner_user_id is not None,
  86. owner_user_id=owner_user_id,
  87. )
  88. db_session.add(center)
  89. await db_session.flush()
  90. if member_id is not None:
  91. db_session.add(CostCenterMember(cost_center_id=center.id, user_id=member_id, can_print=True))
  92. await db_session.commit()
  93. await db_session.refresh(center)
  94. return center
  95. async def _key(db_session, owner_id: int | None, *, can_queue: bool = True) -> dict[str, str]:
  96. full_key, key_hash, key_prefix = generate_api_key()
  97. db_session.add(
  98. APIKey(
  99. name="probe",
  100. key_hash=key_hash,
  101. key_prefix=key_prefix,
  102. user_id=owner_id,
  103. can_queue=can_queue,
  104. can_read_status=True,
  105. )
  106. )
  107. await db_session.commit()
  108. return {"X-API-Key": full_key}
  109. async def _login(client: AsyncClient, username: str) -> dict[str, str]:
  110. response = await client.post("/api/v1/auth/login", json={"username": username, "password": PASSWORD})
  111. assert response.status_code == 200, response.text
  112. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  113. async def _queue(client: AsyncClient, headers, printer: Printer, archive: PrintArchive, cost_center_id: int | None):
  114. return await client.post(
  115. "/api/v1/queue/",
  116. json={"printer_id": printer.id, "archive_id": archive.id, "cost_center_id": cost_center_id},
  117. headers=headers,
  118. )
  119. class TestCostCenters:
  120. async def test_another_users_private_cost_center_is_refused(self, async_client, db_session, world):
  121. archive = await _archive(db_session, world["owner"].id, 1)
  122. center = await _center(db_session, owner_user_id=world["other"].id)
  123. key = await _key(db_session, world["owner"].id)
  124. by_key = await _queue(async_client, key, world["printer"], archive, center.id)
  125. by_session = await _queue(
  126. async_client, await _login(async_client, "keyowner"), world["printer"], archive, center.id
  127. )
  128. assert by_key.status_code == by_session.status_code == 403
  129. assert await db_session.scalar(select(PrintQueueItem)) is None
  130. async def test_a_shared_cost_center_needs_membership(self, async_client, db_session, world):
  131. archive = await _archive(db_session, world["owner"].id, 1)
  132. center = await _center(db_session, member_id=world["other"].id)
  133. key = await _key(db_session, world["owner"].id)
  134. assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
  135. async def test_the_owners_own_cost_center_works_and_the_item_is_the_owners(self, async_client, db_session, world):
  136. archive = await _archive(db_session, world["owner"].id, 1)
  137. center = await _center(db_session, owner_user_id=world["owner"].id)
  138. key = await _key(db_session, world["owner"].id)
  139. response = await _queue(async_client, key, world["printer"], archive, center.id)
  140. assert response.status_code == 200, response.text
  141. item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
  142. assert item.cost_center_id == center.id
  143. # Credited to the owner, so the scheduler's check at print start has
  144. # someone to check, and the owner sees it under queue:read_own.
  145. assert item.created_by_id == world["owner"].id
  146. async def test_a_cost_center_the_owner_is_a_member_of_works(self, async_client, db_session, world):
  147. archive = await _archive(db_session, world["owner"].id, 1)
  148. center = await _center(db_session, member_id=world["owner"].id)
  149. key = await _key(db_session, world["owner"].id)
  150. assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
  151. async def test_an_admins_key_may_use_any_cost_center_like_the_admin(self, async_client, db_session, world):
  152. archive = await _archive(db_session, world["admin"].id, 1)
  153. center = await _center(db_session, owner_user_id=world["other"].id)
  154. key = await _key(db_session, world["admin"].id)
  155. assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 200
  156. async def test_a_key_without_an_owner_may_use_no_cost_center(self, async_client, db_session, world):
  157. archive = await _archive(db_session, None, 1)
  158. center = await _center(db_session, member_id=world["owner"].id)
  159. key = await _key(db_session, None)
  160. assert (await _queue(async_client, key, world["printer"], archive, center.id)).status_code == 403
  161. async def test_moving_an_item_to_a_forbidden_cost_center_is_refused(self, async_client, db_session, world):
  162. # PATCH through a key needs the owner to hold queue:update_all.
  163. group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
  164. group.permissions = [*group.permissions, "queue:update_all"]
  165. await db_session.commit()
  166. archive = await _archive(db_session, world["owner"].id, 1)
  167. allowed = await _center(db_session, owner_user_id=world["owner"].id)
  168. forbidden = await _center(db_session, owner_user_id=world["other"].id)
  169. key = await _key(db_session, world["owner"].id)
  170. created = await _queue(async_client, key, world["printer"], archive, allowed.id)
  171. assert created.status_code == 200, created.text
  172. response = await async_client.patch(
  173. f"/api/v1/queue/{created.json()['id']}", json={"cost_center_id": forbidden.id}, headers=key
  174. )
  175. assert response.status_code == 403
  176. item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == created.json()["id"]))
  177. await db_session.refresh(item)
  178. assert item.cost_center_id == allowed.id
  179. class TestListingCostCenters:
  180. async def test_a_key_lists_its_owners_cost_centers(self, async_client, db_session, world):
  181. mine = await _center(db_session, owner_user_id=world["owner"].id)
  182. shared = await _center(db_session, member_id=world["owner"].id)
  183. await _center(db_session, owner_user_id=world["other"].id)
  184. key = await _key(db_session, world["owner"].id)
  185. by_key = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
  186. by_session = await async_client.get(
  187. "/api/v1/finance/cost-centers/mine", headers=await _login(async_client, "keyowner")
  188. )
  189. assert by_key.status_code == 200, by_key.text
  190. assert {c["id"] for c in by_key.json()} == {mine.id, shared.id}
  191. assert by_key.json() == by_session.json()
  192. async def test_a_key_that_cannot_queue_gets_none(self, async_client, db_session, world):
  193. await _center(db_session, owner_user_id=world["owner"].id)
  194. key = await _key(db_session, world["owner"].id, can_queue=False)
  195. assert (await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)).status_code == 403
  196. async def test_a_key_without_an_owner_has_none(self, async_client, db_session, world):
  197. await _center(db_session, owner_user_id=world["owner"].id)
  198. key = await _key(db_session, None)
  199. response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=key)
  200. assert response.status_code == 200
  201. assert response.json() == []
  202. class TestSources:
  203. async def test_another_users_archive_is_not_found(self, async_client, db_session, world):
  204. await _set(db_session, "billing_enabled", "false")
  205. archive = await _archive(db_session, world["other"].id, 1)
  206. key = await _key(db_session, world["owner"].id)
  207. by_key = await _queue(async_client, key, world["printer"], archive, None)
  208. by_session = await _queue(async_client, await _login(async_client, "keyowner"), world["printer"], archive, None)
  209. assert by_key.status_code == by_session.status_code == 404
  210. async def test_another_users_library_file_is_not_found(self, async_client, db_session, world):
  211. await _set(db_session, "billing_enabled", "false")
  212. # Unique: the checkout's library folder is shared by every xdist worker.
  213. rel_path = f"archive/library/files/probe_3256_{uuid4().hex}.gcode.3mf"
  214. abs_path = Path(app_settings.base_dir) / rel_path
  215. abs_path.parent.mkdir(parents=True, exist_ok=True)
  216. abs_path.write_bytes(b"probe")
  217. library_file = LibraryFile(
  218. filename="probe_3256.gcode.3mf",
  219. file_path=rel_path,
  220. file_size=5,
  221. file_type="3mf",
  222. created_by_id=world["other"].id,
  223. )
  224. db_session.add(library_file)
  225. await db_session.commit()
  226. key = await _key(db_session, world["owner"].id)
  227. try:
  228. response = await async_client.post(
  229. "/api/v1/queue/",
  230. json={"printer_id": world["printer"].id, "library_file_id": library_file.id},
  231. headers=key,
  232. )
  233. finally:
  234. abs_path.unlink(missing_ok=True)
  235. assert response.status_code == 404
  236. async def test_the_owners_own_archive_still_queues(self, async_client, db_session, world):
  237. await _set(db_session, "billing_enabled", "false")
  238. archive = await _archive(db_session, world["owner"].id, 1)
  239. key = await _key(db_session, world["owner"].id)
  240. assert (await _queue(async_client, key, world["printer"], archive, None)).status_code == 200
  241. class TestBatches:
  242. async def test_another_users_batch_cannot_be_changed(self, async_client, db_session, world):
  243. await _set(db_session, "billing_enabled", "false")
  244. batch = PrintBatch(name="theirs", created_by_id=world["other"].id)
  245. db_session.add(batch)
  246. await db_session.commit()
  247. key = await _key(db_session, world["owner"].id)
  248. update = await async_client.patch(f"/api/v1/queue/batches/{batch.id}", json={"name": "mine"}, headers=key)
  249. ungroup = await async_client.post(f"/api/v1/queue/batches/{batch.id}/ungroup", headers=key)
  250. assert update.status_code == 404
  251. assert ungroup.status_code in (403, 404)
  252. await db_session.refresh(batch)
  253. assert batch.name == "theirs"
  254. class TestAnOwnOnlyOwner:
  255. """An owner who may change only their own queue items.
  256. Their key is refused at the permission gate (it needs the ``*_all``
  257. permission of its owner), so it changes no other user's item, and an
  258. owner's deactivation stops it.
  259. """
  260. async def _items(self, db_session, world) -> tuple[PrintQueueItem, PrintQueueItem]:
  261. await _set(db_session, "billing_enabled", "false")
  262. mine = await _archive(db_session, world["owner"].id, 1)
  263. theirs = await _archive(db_session, world["other"].id, 2)
  264. own = PrintQueueItem(
  265. printer_id=world["printer"].id, archive_id=mine.id, status="pending", created_by_id=world["owner"].id
  266. )
  267. other = PrintQueueItem(
  268. printer_id=world["printer"].id, archive_id=theirs.id, status="pending", created_by_id=world["other"].id
  269. )
  270. db_session.add_all([own, other])
  271. await db_session.commit()
  272. return own, other
  273. async def test_another_users_item_is_left_alone(self, async_client, db_session, world):
  274. own, other = await self._items(db_session, world)
  275. key = await _key(db_session, world["owner"].id)
  276. patched = await async_client.patch(f"/api/v1/queue/{other.id}", json={"manual_start": True}, headers=key)
  277. bulk = await async_client.patch(
  278. "/api/v1/queue/bulk", json={"item_ids": [own.id, other.id], "manual_start": True}, headers=key
  279. )
  280. deleted = await async_client.delete(f"/api/v1/queue/{other.id}", headers=key)
  281. assert patched.status_code in (403, 404)
  282. assert bulk.status_code in (200, 403), bulk.text
  283. assert deleted.status_code in (403, 404)
  284. await db_session.refresh(other)
  285. assert other.manual_start is False
  286. assert await db_session.get(PrintQueueItem, other.id) is not None
  287. async def test_a_deactivated_owners_key_changes_nothing(self, async_client, db_session, world):
  288. """An admin's key, so the deactivation alone is what refuses it."""
  289. own, _other = await self._items(db_session, world)
  290. key = await _key(db_session, world["admin"].id)
  291. world["admin"].is_active = False
  292. await db_session.commit()
  293. patched = await async_client.patch(f"/api/v1/queue/{own.id}", json={"manual_start": True}, headers=key)
  294. deleted = await async_client.delete(f"/api/v1/queue/{own.id}", headers=key)
  295. assert patched.status_code in (401, 403)
  296. assert deleted.status_code in (401, 403)
  297. await db_session.refresh(own)
  298. assert own.manual_start is False
  299. assert await db_session.get(PrintQueueItem, own.id) is not None
  300. @pytest.fixture
  301. async def library_world(db_session, world):
  302. """The same owner, who may also upload, read stats and pipelines, and a
  303. key with the library and status scopes as well."""
  304. await _set(db_session, "billing_enabled", "false")
  305. group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
  306. group.permissions = [*group.permissions, "library:upload", "stats:read", "pipelines:read"]
  307. full_key, key_hash, key_prefix = generate_api_key()
  308. db_session.add(
  309. APIKey(
  310. name="library probe",
  311. key_hash=key_hash,
  312. key_prefix=key_prefix,
  313. user_id=world["owner"].id,
  314. can_queue=True,
  315. can_read_status=True,
  316. can_manage_library=True,
  317. )
  318. )
  319. theirs = LibraryFolder(name="theirs", created_by_id=world["other"].id, shared=False)
  320. db_session.add(theirs)
  321. await db_session.commit()
  322. return {**world, "key": {"X-API-Key": full_key}, "their_folder": theirs}
  323. async def _their_file(db_session, world, filename: str = "theirs.stl") -> LibraryFile:
  324. row = LibraryFile(
  325. filename=filename,
  326. file_path=f"library/files/{filename}",
  327. file_type=filename.rsplit(".", 1)[-1],
  328. file_size=1024,
  329. created_by_id=world["other"].id,
  330. )
  331. db_session.add(row)
  332. await db_session.commit()
  333. await db_session.refresh(row)
  334. return row
  335. _SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
  336. class TestLibrary:
  337. async def test_no_folder_inside_another_users_private_folder(self, async_client, db_session, library_world):
  338. body = {"name": "sneaky", "parent_id": library_world["their_folder"].id}
  339. by_key = await async_client.post("/api/v1/library/folders", json=body, headers=library_world["key"])
  340. by_session = await async_client.post(
  341. "/api/v1/library/folders", json=body, headers=await _login(async_client, "keyowner")
  342. )
  343. assert by_key.status_code == by_session.status_code == 404
  344. async def test_a_keys_folder_is_its_owners_and_not_shared(self, async_client, db_session, library_world):
  345. response = await async_client.post(
  346. "/api/v1/library/folders", json={"name": "mine"}, headers=library_world["key"]
  347. )
  348. assert response.status_code == 200, response.text
  349. folder = await db_session.get(LibraryFolder, response.json()["id"])
  350. assert folder.created_by_id == library_world["owner"].id
  351. assert folder.shared is False
  352. async def test_no_upload_into_another_users_private_folder(self, async_client, db_session, library_world):
  353. response = await async_client.post(
  354. "/api/v1/library/files",
  355. params={"folder_id": library_world["their_folder"].id},
  356. files={"file": ("cube.stl", b"solid cube\nendsolid cube\n", "application/octet-stream")},
  357. headers=library_world["key"],
  358. )
  359. assert response.status_code == 404
  360. async def test_no_combining_another_users_file(self, async_client, db_session, library_world):
  361. theirs = await _their_file(db_session, library_world)
  362. response = await async_client.post(
  363. "/api/v1/library/files/combine",
  364. json={"items": [{"file_id": theirs.id}], "filename": "mix"},
  365. headers=library_world["key"],
  366. )
  367. assert response.status_code == 404
  368. assert response.json()["detail"] == "File not found"
  369. async def test_no_slicing_another_users_file(self, async_client, db_session, library_world):
  370. theirs = await _their_file(db_session, library_world)
  371. response = await async_client.post(
  372. f"/api/v1/library/files/{theirs.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
  373. )
  374. assert response.status_code == 404
  375. assert response.json()["detail"] == "File not found"
  376. async def test_no_queueing_another_users_file_from_the_library(self, async_client, db_session, library_world):
  377. theirs = await _their_file(db_session, library_world, "theirs.gcode.3mf")
  378. response = await async_client.post(
  379. "/api/v1/library/files/add-to-queue",
  380. json={"file_ids": [theirs.id], "printer_id": library_world["printer"].id},
  381. headers=library_world["key"],
  382. )
  383. # The same answer an unknown id gets
  384. assert response.status_code == 400
  385. assert response.json()["detail"]["errors"][0]["error"] == "File not found"
  386. assert await db_session.scalar(select(PrintQueueItem)) is None
  387. class TestArchivesAndPipelines:
  388. async def test_no_slicing_another_users_archive(self, async_client, db_session, library_world):
  389. archive = await _archive(db_session, library_world["other"].id, 1)
  390. response = await async_client.post(
  391. f"/api/v1/archives/{archive.id}/slice", json=_SLICE_BODY, headers=library_world["key"]
  392. )
  393. assert response.status_code == 404
  394. assert response.json()["detail"] == "Archive not found"
  395. async def test_no_per_user_stats_without_the_owners_permission(self, async_client, db_session, library_world):
  396. by_key = await async_client.get(
  397. "/api/v1/archives/stats",
  398. params={"created_by_id": library_world["other"].id},
  399. headers=library_world["key"],
  400. )
  401. by_session = await async_client.get(
  402. "/api/v1/archives/stats",
  403. params={"created_by_id": library_world["other"].id},
  404. headers=await _login(async_client, "keyowner"),
  405. )
  406. assert by_key.status_code == by_session.status_code == 403
  407. async def test_no_pipeline_on_another_users_file(self, async_client, db_session, library_world):
  408. theirs = await _their_file(db_session, library_world)
  409. created = await async_client.post(
  410. "/api/v1/slicer-pipelines/",
  411. json={
  412. "name": "Batch",
  413. "description": None,
  414. "printer_preset": {"source": "local", "id": "1"},
  415. "process_preset": {"source": "local", "id": "2"},
  416. "filament_presets": [{"source": "local", "id": "3"}],
  417. "bed_type": None,
  418. },
  419. headers=await _login(async_client, "adminowner"),
  420. )
  421. assert created.status_code == 201, created.text
  422. response = await async_client.post(
  423. f"/api/v1/slicer-pipelines/{created.json()['id']}/check-eligibility",
  424. json={"source_library_file_id": theirs.id},
  425. headers=library_world["key"],
  426. )
  427. # Refused by the ownership check, not later for the missing file
  428. assert response.status_code == 404
  429. assert response.json()["detail"] == "File not found"