test_api_key_queue_acts_as_owner.py 20 KB

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