test_api_key_own_rows.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. """An API key whose owner may act on their own rows only acts on those rows.
  2. The ownership routes (queue, archives, library) used to refuse such a key
  3. outright: they asked the owner for the ``*_all`` permission. The key now gets
  4. what its owner gets, its own rows and nobody else's, still narrowed to the
  5. key's scope flags and printers. A key whose owner holds ``*_all`` and a key
  6. without an owner keep reaching every row.
  7. """
  8. from __future__ import annotations
  9. from datetime import datetime, timezone
  10. import pytest
  11. from sqlalchemy import select
  12. from backend.app.core.auth import generate_api_key
  13. from backend.app.models.api_key import APIKey
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.group import Group
  16. from backend.app.models.library import LibraryFile
  17. from backend.app.models.print_queue import PrintQueueItem
  18. from backend.tests.integration import test_api_key_queue_acts_as_owner as owner_tests
  19. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  20. world = owner_tests.world
  21. _OWN_ONLY = [
  22. "queue:read_own",
  23. "queue:delete_own",
  24. "queue:start_unreviewed",
  25. "archives:update_own",
  26. "archives:delete_own",
  27. "library:update_own",
  28. "library:delete_own",
  29. ]
  30. async def _key(db_session, owner_id: int | None, **scopes) -> dict[str, str]:
  31. full_key, key_hash, key_prefix = generate_api_key()
  32. flags = {
  33. "can_queue": True,
  34. "can_read_status": True,
  35. "can_manage_archives": True,
  36. "can_manage_library": True,
  37. **scopes,
  38. }
  39. db_session.add(APIKey(name="own rows", key_hash=key_hash, key_prefix=key_prefix, user_id=owner_id, **flags))
  40. await db_session.commit()
  41. return {"X-API-Key": full_key}
  42. @pytest.fixture
  43. async def rows(db_session, world):
  44. """The own-only owner's rows and another user's, one of each kind."""
  45. await owner_tests._set(db_session, "billing_enabled", "false")
  46. group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
  47. group.permissions = [*group.permissions, *_OWN_ONLY]
  48. mine = await owner_tests._archive(db_session, world["owner"].id, 11)
  49. theirs = await owner_tests._archive(db_session, world["other"].id, 12)
  50. own_item = PrintQueueItem(
  51. printer_id=world["printer"].id, archive_id=mine.id, status="pending", created_by_id=world["owner"].id
  52. )
  53. other_item = PrintQueueItem(
  54. printer_id=world["printer"].id, archive_id=theirs.id, status="pending", created_by_id=world["other"].id
  55. )
  56. own_file = LibraryFile(
  57. filename="mine.stl",
  58. file_path="library/files/mine.stl",
  59. file_type="stl",
  60. file_size=1,
  61. created_by_id=world["owner"].id,
  62. )
  63. other_file = LibraryFile(
  64. filename="theirs.stl",
  65. file_path="library/files/theirs.stl",
  66. file_type="stl",
  67. file_size=1,
  68. created_by_id=world["other"].id,
  69. )
  70. db_session.add_all([own_item, other_item, own_file, other_file])
  71. await db_session.commit()
  72. return {
  73. **world,
  74. "mine": mine,
  75. "theirs": theirs,
  76. "own_item": own_item,
  77. "other_item": other_item,
  78. "own_file": own_file,
  79. "other_file": other_file,
  80. }
  81. class TestQueue:
  82. async def test_the_owners_own_item_can_be_read_and_changed(self, async_client, db_session, rows):
  83. key = await _key(db_session, rows["owner"].id)
  84. item = rows["own_item"]
  85. got = await async_client.get(f"/api/v1/queue/{item.id}", headers=key)
  86. patched = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True}, headers=key)
  87. assert got.status_code == 200, got.text
  88. assert patched.status_code == 200, patched.text
  89. await db_session.refresh(item)
  90. assert item.manual_start is True
  91. async def test_bulk_changes_the_owners_items_and_skips_the_rest(self, async_client, db_session, rows):
  92. key = await _key(db_session, rows["owner"].id)
  93. own, other = rows["own_item"], rows["other_item"]
  94. bulk = await async_client.patch(
  95. "/api/v1/queue/bulk", json={"item_ids": [own.id, other.id], "manual_start": True}, headers=key
  96. )
  97. assert bulk.status_code == 200, bulk.text
  98. assert (bulk.json()["updated_count"], bulk.json()["skipped_count"]) == (1, 1)
  99. await db_session.refresh(own)
  100. await db_session.refresh(other)
  101. assert own.manual_start is True
  102. assert other.manual_start is False
  103. async def test_the_list_holds_only_the_owners_items(self, async_client, db_session, rows):
  104. key = await _key(db_session, rows["owner"].id)
  105. listed = await async_client.get("/api/v1/queue/", headers=key)
  106. assert listed.status_code == 200, listed.text
  107. assert [i["id"] for i in listed.json()] == [rows["own_item"].id]
  108. async def test_another_users_item_is_neither_seen_nor_changed(self, async_client, db_session, rows):
  109. key = await _key(db_session, rows["owner"].id)
  110. other = rows["other_item"]
  111. got = await async_client.get(f"/api/v1/queue/{other.id}", headers=key)
  112. patched = await async_client.patch(f"/api/v1/queue/{other.id}", json={"manual_start": True}, headers=key)
  113. cancelled = await async_client.post(f"/api/v1/queue/{other.id}/cancel", headers=key)
  114. deleted = await async_client.delete(f"/api/v1/queue/{other.id}", headers=key)
  115. assert got.status_code == 404
  116. assert patched.status_code == 403
  117. assert cancelled.status_code == 403
  118. assert deleted.status_code == 403
  119. await db_session.refresh(other)
  120. assert (other.status, other.manual_start) == ("pending", False)
  121. async def test_the_owners_own_item_can_be_cancelled_and_deleted(self, async_client, db_session, rows):
  122. key = await _key(db_session, rows["owner"].id)
  123. item_id = rows["own_item"].id
  124. cancelled = await async_client.post(f"/api/v1/queue/{item_id}/cancel", headers=key)
  125. deleted = await async_client.delete(f"/api/v1/queue/{item_id}", headers=key)
  126. assert cancelled.status_code == 200, cancelled.text
  127. assert deleted.status_code == 200, deleted.text
  128. db_session.expire_all()
  129. assert await db_session.get(PrintQueueItem, item_id) is None
  130. async def test_a_key_without_the_queue_scope_is_still_refused(self, async_client, db_session, rows):
  131. key = await _key(db_session, rows["owner"].id, can_queue=False)
  132. patched = await async_client.patch(
  133. f"/api/v1/queue/{rows['own_item'].id}", json={"manual_start": True}, headers=key
  134. )
  135. assert patched.status_code == 403
  136. async def test_the_owner_can_claim_and_start_a_job_without_an_owner(self, async_client, db_session, rows):
  137. """As the owner's session can: a virtual-printer upload has no owner (#1670)."""
  138. key = await _key(db_session, rows["owner"].id)
  139. item = PrintQueueItem(
  140. printer_id=rows["printer"].id, archive_id=rows["mine"].id, status="pending", manual_start=True
  141. )
  142. db_session.add(item)
  143. await db_session.commit()
  144. started = await async_client.post(f"/api/v1/queue/{item.id}/start", headers=key)
  145. assert started.status_code == 200, started.text
  146. await db_session.refresh(item)
  147. assert (item.manual_start, item.created_by_id) == (False, rows["owner"].id)
  148. class TestAKeyLimitedToFewerPrinters:
  149. """The scheduler sends an "any printer of a model" job to any of its
  150. creator's printers, so a key limited to fewer printers than its owner may
  151. neither make nor start one."""
  152. @pytest.fixture
  153. async def limited(self, db_session, rows):
  154. full_key, key_hash, key_prefix = generate_api_key()
  155. db_session.add(
  156. APIKey(
  157. name="one printer",
  158. key_hash=key_hash,
  159. key_prefix=key_prefix,
  160. user_id=rows["owner"].id,
  161. can_queue=True,
  162. can_read_status=True,
  163. printer_ids=[rows["printer"].id],
  164. )
  165. )
  166. model_job = PrintQueueItem(
  167. archive_id=rows["mine"].id,
  168. target_model="X1C",
  169. status="pending",
  170. manual_start=True,
  171. created_by_id=rows["owner"].id,
  172. )
  173. db_session.add(model_job)
  174. await db_session.commit()
  175. return {"key": {"X-API-Key": full_key}, "model_job": model_job}
  176. async def test_it_cannot_start_a_model_job(self, async_client, db_session, rows, limited):
  177. job = limited["model_job"]
  178. started = await async_client.post(f"/api/v1/queue/{job.id}/start", headers=limited["key"])
  179. patched = await async_client.patch(
  180. f"/api/v1/queue/{job.id}", json={"manual_start": False}, headers=limited["key"]
  181. )
  182. bulk = await async_client.patch(
  183. "/api/v1/queue/bulk", json={"item_ids": [job.id], "manual_start": False}, headers=limited["key"]
  184. )
  185. assert started.status_code == 400, started.text
  186. assert "specific printer" in started.json()["detail"]
  187. assert patched.status_code == 400, patched.text
  188. assert "specific printer" in patched.json()["detail"]
  189. assert bulk.status_code == 200, bulk.text
  190. assert (bulk.json()["updated_count"], bulk.json()["skipped_count"]) == (0, 1)
  191. await db_session.refresh(job)
  192. assert job.manual_start is True
  193. async def test_a_null_does_not_start_a_model_job(self, async_client, db_session, rows, limited):
  194. """A null for the not-null column is dropped, so nothing starts."""
  195. job = limited["model_job"]
  196. await async_client.patch(f"/api/v1/queue/{job.id}", json={"manual_start": None}, headers=limited["key"])
  197. await async_client.patch(
  198. "/api/v1/queue/bulk", json={"item_ids": [job.id], "manual_start": None}, headers=limited["key"]
  199. )
  200. await db_session.refresh(job)
  201. assert job.manual_start is True
  202. async def test_it_cannot_unpin_a_job_that_keeps_its_model(self, async_client, db_session, rows, limited):
  203. """A job the scheduler held on a printer keeps its model (watchdog, filament hold)."""
  204. job = limited["model_job"]
  205. job.printer_id = rows["printer"].id
  206. job.manual_start = False
  207. await db_session.commit()
  208. patched = await async_client.patch(f"/api/v1/queue/{job.id}", json={"printer_id": None}, headers=limited["key"])
  209. bulk = await async_client.patch(
  210. "/api/v1/queue/bulk", json={"item_ids": [job.id], "printer_id": None}, headers=limited["key"]
  211. )
  212. assert patched.status_code == 400, patched.text
  213. assert bulk.json()["skipped_count"] == 1, bulk.text
  214. await db_session.refresh(job)
  215. assert job.printer_id == rows["printer"].id
  216. async def test_an_unlimited_key_can_start_it(self, async_client, db_session, rows, limited):
  217. key = await _key(db_session, rows["owner"].id)
  218. job = limited["model_job"]
  219. patched = await async_client.patch(f"/api/v1/queue/{job.id}", json={"manual_start": False}, headers=key)
  220. assert patched.status_code == 200, patched.text
  221. await db_session.refresh(job)
  222. assert job.manual_start is False
  223. async def test_it_cannot_retarget_a_job_to_a_model(self, async_client, db_session, rows, limited):
  224. patched = await async_client.patch(
  225. f"/api/v1/queue/{rows['own_item'].id}",
  226. json={"printer_id": None, "target_model": "X1C"},
  227. headers=limited["key"],
  228. )
  229. assert patched.status_code == 400, patched.text
  230. assert "specific printer" in patched.json()["detail"]
  231. await db_session.refresh(rows["own_item"])
  232. assert rows["own_item"].target_model is None
  233. class TestArchives:
  234. async def test_the_owners_archives_only(self, async_client, db_session, rows):
  235. key = await _key(db_session, rows["owner"].id)
  236. listed = await async_client.get("/api/v1/archives/", headers=key)
  237. mine = await async_client.get(f"/api/v1/archives/{rows['mine'].id}", headers=key)
  238. theirs = await async_client.get(f"/api/v1/archives/{rows['theirs'].id}", headers=key)
  239. assert listed.status_code == 200, listed.text
  240. assert [a["id"] for a in listed.json()] == [rows["mine"].id]
  241. assert mine.status_code == 200, mine.text
  242. assert theirs.status_code == 404
  243. async def test_only_the_owners_archive_can_be_changed(self, async_client, db_session, rows):
  244. key = await _key(db_session, rows["owner"].id)
  245. mine = await async_client.patch(f"/api/v1/archives/{rows['mine'].id}", json={"notes": "k"}, headers=key)
  246. theirs = await async_client.patch(f"/api/v1/archives/{rows['theirs'].id}", json={"notes": "k"}, headers=key)
  247. assert mine.status_code == 200, mine.text
  248. assert theirs.status_code == 403
  249. theirs_row = await db_session.get(PrintArchive, rows["theirs"].id)
  250. await db_session.refresh(theirs_row)
  251. assert theirs_row.notes != "k"
  252. class TestLibrary:
  253. async def test_the_owners_files_only(self, async_client, db_session, rows):
  254. key = await _key(db_session, rows["owner"].id)
  255. listed = await async_client.get("/api/v1/library/files", headers=key)
  256. mine = await async_client.get(f"/api/v1/library/files/{rows['own_file'].id}", headers=key)
  257. theirs = await async_client.get(f"/api/v1/library/files/{rows['other_file'].id}", headers=key)
  258. assert listed.status_code == 200, listed.text
  259. assert [f["id"] for f in listed.json()] == [rows["own_file"].id]
  260. assert mine.status_code == 200, mine.text
  261. assert theirs.status_code == 404
  262. async def test_the_trash_holds_only_the_owners_files(self, async_client, db_session, rows):
  263. key = await _key(db_session, rows["owner"].id)
  264. for row in (rows["own_file"], rows["other_file"]):
  265. row.deleted_at = datetime.now(timezone.utc)
  266. await db_session.commit()
  267. listed = await async_client.get("/api/v1/library/trash", headers=key)
  268. assert listed.status_code == 200, listed.text
  269. assert [f["id"] for f in listed.json()["items"]] == [rows["own_file"].id]
  270. async def test_another_users_file_is_not_deleted(self, async_client, db_session, rows):
  271. key = await _key(db_session, rows["owner"].id)
  272. deleted = await async_client.delete(f"/api/v1/library/files/{rows['other_file'].id}", headers=key)
  273. assert deleted.status_code == 403
  274. await db_session.refresh(rows["other_file"])
  275. assert rows["other_file"].deleted_at is None
  276. class TestKeysThatReachEveryRow:
  277. async def test_an_admins_key_still_changes_another_users_item(self, async_client, db_session, rows):
  278. key = await _key(db_session, rows["admin"].id)
  279. patched = await async_client.patch(
  280. f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
  281. )
  282. assert patched.status_code == 200, patched.text
  283. async def test_a_key_without_an_owner_still_changes_any_item(self, async_client, db_session, rows):
  284. key = await _key(db_session, None)
  285. patched = await async_client.patch(
  286. f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
  287. )
  288. assert patched.status_code == 200, patched.text
  289. async def test_a_deactivated_own_only_owners_key_is_refused(self, async_client, db_session, rows):
  290. key = await _key(db_session, rows["owner"].id)
  291. rows["owner"].is_active = False
  292. await db_session.commit()
  293. patched = await async_client.patch(
  294. f"/api/v1/queue/{rows['own_item'].id}", json={"manual_start": True}, headers=key
  295. )
  296. assert patched.status_code in (401, 403)
  297. async def test_a_key_without_an_owner_still_needs_the_queue_scope(self, async_client, db_session, rows):
  298. key = await _key(db_session, None, can_queue=False)
  299. patched = await async_client.patch(
  300. f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
  301. )
  302. assert patched.status_code == 403
  303. class TestQueueHistoryClear:
  304. async def test_a_key_that_reads_own_runs_clears_only_those(self, async_client, db_session, rows):
  305. """Its owner may delete every run but read only their own: the History tab
  306. it clears is theirs, as for the owner signed in."""
  307. group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
  308. group.permissions = [*group.permissions, "queue:delete_all"]
  309. for item in (rows["own_item"], rows["other_item"]):
  310. item.status = "completed"
  311. await db_session.commit()
  312. own_id, other_id = rows["own_item"].id, rows["other_item"].id
  313. key = await _key(db_session, rows["owner"].id)
  314. response = await async_client.post("/api/v1/queue/history/clear", headers=key)
  315. assert response.status_code == 200, response.text
  316. assert response.json() == {"cleared": 1, "kept": 0}
  317. db_session.expire_all()
  318. assert await db_session.get(PrintQueueItem, own_id) is None
  319. assert await db_session.get(PrintQueueItem, other_id) is not None