test_slicer_plugin_api.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. """The slicer plugin's upload API (``api/routes/slicer_plugin.py``).
  2. The route is a thin layer over two things with their own tests -- the library
  3. ingest and ``POST /queue/`` -- so these pin what the layer itself adds: the
  4. manifest, the idempotency claim, the up-front refusals that must leave nothing
  5. behind, and the permission split between uploading and queueing.
  6. """
  7. from __future__ import annotations
  8. import io
  9. import json
  10. import uuid
  11. import zipfile
  12. from datetime import datetime, timedelta, timezone
  13. import pytest
  14. from httpx import AsyncClient
  15. from sqlalchemy import func, select
  16. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  17. from backend.app.core.auth import generate_api_key
  18. from backend.app.models.api_key import APIKey
  19. from backend.app.models.library import LibraryFile
  20. from backend.app.models.print_batch import PrintBatch
  21. from backend.app.models.print_queue import PrintQueueItem
  22. from backend.app.models.settings import Settings
  23. from backend.app.models.slicer_plugin_upload import SlicerPluginUpload
  24. from backend.app.models.user import User
  25. UPLOADS = "/api/v1/slicer-plugin/uploads"
  26. INFO = "/api/v1/slicer-plugin/info"
  27. def _sliced_3mf(plates=(1,), model_id: str = "C11") -> bytes:
  28. """A minimal sliced 3MF: one G-code member per plate, and a slice_info that
  29. names the printer it was sliced for (C11 = X1C)."""
  30. buf = io.BytesIO()
  31. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  32. for n in plates:
  33. zf.writestr(f"Metadata/plate_{n}.gcode", "; G-code\nG28\n")
  34. zf.writestr(
  35. "Metadata/slice_info.config",
  36. f'<config><plate><metadata key="index" value="{plates[0]}"/>'
  37. f'<metadata key="printer_model_id" value="{model_id}"/></plate></config>',
  38. )
  39. return buf.getvalue()
  40. def _manifest(upload_id: str | None = None, **intent) -> dict:
  41. return {
  42. "schema_version": 1,
  43. "upload_id": upload_id or str(uuid.uuid4()),
  44. "source": {
  45. "slicer": "OrcaSlicer",
  46. "slicer_version": "2.4.0-nightly",
  47. "plugin_version": "0.1.0",
  48. "project_name": "Bracket v3",
  49. "presets": {
  50. "printer": "Bambu Lab X1 Carbon 0.4 nozzle",
  51. "process": "0.20mm Standard",
  52. "filaments": ["PLA"],
  53. },
  54. "model_fingerprint": "sha256:" + "a" * 64,
  55. },
  56. "intent": intent,
  57. }
  58. async def _send(
  59. client: AsyncClient, manifest: dict, content: bytes | None = None, headers=None, name="Bracket.gcode.3mf"
  60. ):
  61. return await client.post(
  62. UPLOADS,
  63. files={"file": (name, content if content is not None else _sliced_3mf(), "application/zip")},
  64. data={"manifest": json.dumps(manifest)},
  65. headers=headers or {},
  66. )
  67. async def _count(test_engine, model, *where) -> int:
  68. """Read through a fresh session -- the fixture's own can look stale after a
  69. route call commits through its own ``get_db`` session."""
  70. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  71. async with maker() as fresh:
  72. return (await fresh.execute(select(func.count()).select_from(model).where(*where))).scalar_one()
  73. async def _claims(test_engine) -> list[SlicerPluginUpload]:
  74. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  75. async with maker() as fresh:
  76. return list((await fresh.execute(select(SlicerPluginUpload))).scalars())
  77. async def _admin_key(client: AsyncClient, db_session, **scopes) -> str:
  78. """Turn auth on and mint a key owned by the admin, so the key's scope flags
  79. alone decide what it may do."""
  80. await client.post(
  81. "/api/v1/auth/setup",
  82. json={"auth_enabled": True, "admin_username": "plugadmin", "admin_password": "PlugPass1!"},
  83. )
  84. admin = (await db_session.execute(select(User).where(User.username == "plugadmin"))).scalar_one()
  85. flags = {"can_manage_library": True, "can_queue": True, "can_read_status": True}
  86. flags.update(scopes)
  87. full_key, key_hash, key_prefix = generate_api_key()
  88. db_session.add(
  89. APIKey(
  90. name=f"plugin-{key_prefix}",
  91. key_hash=key_hash,
  92. key_prefix=key_prefix,
  93. enabled=True,
  94. user_id=admin.id,
  95. **flags,
  96. )
  97. )
  98. await db_session.commit()
  99. return full_key
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. class TestLibraryUpload:
  103. async def test_stores_the_file_and_its_provenance(self, async_client, test_engine):
  104. manifest = _manifest()
  105. response = await _send(async_client, manifest)
  106. assert response.status_code == 200, response.text
  107. body = response.json()
  108. assert body["replayed"] is False
  109. assert body["queue"] is None and body["queue_error"] is None
  110. assert body["library_file"]["filename"] == "Bracket.gcode.3mf"
  111. [claim] = await _claims(test_engine)
  112. assert claim.upload_id == manifest["upload_id"]
  113. assert claim.status == "done"
  114. assert claim.caller == "anonymous"
  115. assert claim.library_file_id == body["library_file"]["id"]
  116. assert claim.model_fingerprint == manifest["source"]["model_fingerprint"]
  117. assert json.loads(claim.presets)["process"] == "0.20mm Standard"
  118. assert claim.project_name == "Bracket v3"
  119. async def test_a_retry_returns_the_first_answer_instead_of_a_second_file(self, async_client, test_engine):
  120. manifest = _manifest()
  121. first = (await _send(async_client, manifest)).json()
  122. second = await _send(async_client, manifest)
  123. assert second.status_code == 200
  124. assert second.json()["replayed"] is True
  125. assert second.json()["library_file"] == first["library_file"]
  126. assert await _count(test_engine, LibraryFile) == 1
  127. async def test_reports_an_identical_file_already_in_the_library(self, async_client):
  128. first = (await _send(async_client, _manifest())).json()
  129. second = (await _send(async_client, _manifest())).json()
  130. assert second["library_file"]["duplicate_of"] == first["library_file"]["id"]
  131. assert any(f"#{first['library_file']['id']}" in w for w in second["warnings"])
  132. @pytest.mark.asyncio
  133. @pytest.mark.integration
  134. class TestQueueUpload:
  135. async def test_queues_the_chosen_plate_as_a_batch_of_copies(self, async_client, printer_factory, test_engine):
  136. printer = await printer_factory(model="X1C")
  137. response = await _send(
  138. async_client,
  139. _manifest(action="queue", printer_id=printer.id, plate_id=2, copies=3, manual_start=True),
  140. _sliced_3mf(plates=(1, 2)),
  141. )
  142. assert response.status_code == 200, response.text
  143. queue = response.json()["queue"]
  144. assert len(queue["queue_item_ids"]) == 3
  145. assert queue["batch_id"] is not None
  146. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  147. async with maker() as fresh:
  148. items = list(
  149. (
  150. await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(queue["queue_item_ids"])))
  151. ).scalars()
  152. )
  153. assert {(i.printer_id, i.plate_id, i.manual_start, i.batch_id) for i in items} == {
  154. (printer.id, 2, True, queue["batch_id"])
  155. }
  156. async def test_a_retry_does_not_queue_the_print_again(self, async_client, printer_factory, test_engine):
  157. """The reason the endpoint is idempotent at all: a send whose response
  158. was lost is retried, and a second queue insert is a second print."""
  159. printer = await printer_factory(model="X1C")
  160. manifest = _manifest(action="queue", printer_id=printer.id, copies=2)
  161. first = (await _send(async_client, manifest)).json()
  162. second = (await _send(async_client, manifest)).json()
  163. assert second["replayed"] is True
  164. assert second["queue"] == first["queue"]
  165. assert await _count(test_engine, PrintQueueItem) == 2
  166. async def test_a_plate_the_file_does_not_have_is_refused_before_anything_is_stored(
  167. self, async_client, printer_factory, test_engine
  168. ):
  169. printer = await printer_factory(model="X1C")
  170. response = await _send(async_client, _manifest(action="queue", printer_id=printer.id, plate_id=3))
  171. assert response.status_code == 400
  172. assert "plate 3" in response.json()["detail"]
  173. assert await _count(test_engine, LibraryFile) == 0
  174. assert await _claims(test_engine) == []
  175. async def test_a_refused_queue_keeps_the_file_and_says_why(self, async_client, test_engine):
  176. """No active printer of the target model: POST /queue/ refuses. The file
  177. stays in the library -- the user can still queue it from there -- and
  178. nothing of the half-made queue insert survives."""
  179. response = await _send(async_client, _manifest(action="queue", target_model="H2D", copies=2))
  180. assert response.status_code == 200, response.text
  181. body = response.json()
  182. assert body["queue"] is None
  183. assert "No active printers" in body["queue_error"]
  184. assert await _count(test_engine, LibraryFile) == 1
  185. assert await _count(test_engine, PrintQueueItem) == 0
  186. assert await _count(test_engine, PrintBatch) == 0
  187. async def test_a_refusal_after_the_batch_was_made_leaves_no_batch_behind(
  188. self, async_client, db_session, printer_factory, test_engine
  189. ):
  190. """With billing on and no cost centre, POST /queue/ refuses only after it
  191. has already written the batch for the copies. Without the rollback the
  192. request's own commit would keep that batch: a batch of nothing."""
  193. db_session.add(Settings(key="billing_enabled", value="true"))
  194. await db_session.commit()
  195. printer = await printer_factory(model="X1C")
  196. body = (await _send(async_client, _manifest(action="queue", printer_id=printer.id, copies=2))).json()
  197. assert "Cost center is required" in body["queue_error"]
  198. assert await _count(test_engine, PrintBatch) == 0
  199. assert await _count(test_engine, PrintQueueItem) == 0
  200. async def test_the_cost_center_reaches_the_queue(self, async_client, db_session, printer_factory):
  201. db_session.add(Settings(key="billing_enabled", value="true"))
  202. await db_session.commit()
  203. printer = await printer_factory(model="X1C")
  204. body = (
  205. await _send(async_client, _manifest(action="queue", printer_id=printer.id, cost_center_id=424242))
  206. ).json()
  207. # Past the "is required" gate, so the id was passed on; refused only
  208. # because this one does not exist.
  209. assert body["queue_error"] in ("Cost center not found", "Estimated cost is required for cost center prints")
  210. async def test_warns_when_the_named_printer_is_not_the_model_it_was_sliced_for(self, async_client, printer_factory):
  211. printer = await printer_factory(model="H2D", name="Big one")
  212. body = (await _send(async_client, _manifest(action="queue", printer_id=printer.id))).json()
  213. assert body["queue"] is not None
  214. assert any("Sliced for X1C" in w and "Big one" in w for w in body["warnings"])
  215. @pytest.mark.asyncio
  216. @pytest.mark.integration
  217. class TestManifest:
  218. async def test_an_unknown_manifest_version_is_refused_by_name(self, async_client):
  219. manifest = _manifest()
  220. manifest["schema_version"] = 2
  221. response = await _send(async_client, manifest)
  222. assert response.status_code == 400
  223. assert "[1]" in response.json()["detail"]
  224. async def test_malformed_manifest_is_a_422(self, async_client):
  225. response = await async_client.post(
  226. UPLOADS,
  227. files={"file": ("a.gcode.3mf", _sliced_3mf(), "application/zip")},
  228. data={"manifest": "{not json"},
  229. )
  230. assert response.status_code == 422
  231. async def test_printer_and_target_model_together_are_refused(self, async_client):
  232. response = await _send(async_client, _manifest(action="queue", printer_id=1, target_model="P1S"))
  233. assert response.status_code == 422
  234. async def test_unknown_keys_are_ignored_so_a_newer_plugin_still_works(self, async_client):
  235. manifest = _manifest()
  236. manifest["source"]["something_new"] = {"x": 1}
  237. assert (await _send(async_client, manifest)).status_code == 200
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. class TestClaim:
  241. async def test_a_failed_upload_releases_its_id_for_the_retry(self, async_client, test_engine):
  242. manifest = _manifest()
  243. bad = await _send(async_client, manifest, content=b"not a zip")
  244. assert bad.status_code == 400
  245. assert await _claims(test_engine) == []
  246. good = await _send(async_client, manifest)
  247. assert good.status_code == 200
  248. assert good.json()["replayed"] is False
  249. async def test_an_id_still_being_processed_is_refused(self, async_client, db_session):
  250. upload_id = str(uuid.uuid4())
  251. db_session.add(SlicerPluginUpload(upload_id=upload_id, caller="anonymous", status="processing"))
  252. await db_session.commit()
  253. response = await _send(async_client, _manifest(upload_id))
  254. assert response.status_code == 409
  255. assert "still being processed" in response.json()["detail"]
  256. async def test_an_abandoned_claim_is_taken_over(self, async_client, db_session, test_engine):
  257. upload_id = str(uuid.uuid4())
  258. long_ago = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1)
  259. db_session.add(
  260. SlicerPluginUpload(upload_id=upload_id, caller="anonymous", status="processing", created_at=long_ago)
  261. )
  262. await db_session.commit()
  263. response = await _send(async_client, _manifest(upload_id))
  264. assert response.status_code == 200, response.text
  265. [claim] = await _claims(test_engine)
  266. assert claim.status == "done"
  267. @pytest.mark.asyncio
  268. @pytest.mark.integration
  269. class TestPermissions:
  270. async def test_info_without_auth(self, async_client):
  271. body = (await async_client.get(INFO)).json()
  272. assert body["manifest_versions"] == [1]
  273. assert body["can_upload"] is True and body["can_queue"] is True
  274. async def test_a_key_that_may_not_queue_is_told_so_and_refused_before_anything_is_stored(
  275. self, async_client, db_session, printer_factory, test_engine
  276. ):
  277. key = await _admin_key(async_client, db_session, can_queue=False)
  278. headers = {"X-API-Key": key}
  279. printer = await printer_factory(model="X1C")
  280. info = await async_client.get(INFO, headers=headers)
  281. assert info.status_code == 200
  282. assert info.json()["can_queue"] is False
  283. refused = await _send(async_client, _manifest(action="queue", printer_id=printer.id), headers=headers)
  284. assert refused.status_code == 403
  285. assert await _count(test_engine, LibraryFile) == 0
  286. assert await _claims(test_engine) == []
  287. stored = await _send(async_client, _manifest(), headers=headers)
  288. assert stored.status_code == 200
  289. async def test_a_key_without_library_access_cannot_connect(self, async_client, db_session):
  290. key = await _admin_key(async_client, db_session, can_manage_library=False)
  291. assert (await async_client.get(INFO, headers={"X-API-Key": key})).status_code == 403
  292. async def test_an_upload_id_does_not_replay_to_another_caller(self, async_client, db_session, test_engine):
  293. key_a = await _admin_key(async_client, db_session)
  294. full_b, hash_b, prefix_b = generate_api_key()
  295. owner_id = (await db_session.execute(select(APIKey.user_id))).scalars().first()
  296. db_session.add(
  297. APIKey(
  298. name="plugin-b",
  299. key_hash=hash_b,
  300. key_prefix=prefix_b,
  301. enabled=True,
  302. user_id=owner_id,
  303. can_manage_library=True,
  304. can_queue=True,
  305. )
  306. )
  307. await db_session.commit()
  308. manifest = _manifest()
  309. assert (await _send(async_client, manifest, headers={"X-API-Key": key_a})).status_code == 200
  310. other = await _send(async_client, manifest, headers={"Authorization": f"Bearer {full_b}"})
  311. assert other.status_code == 409
  312. assert "library_file" not in other.text
  313. [claim] = await _claims(test_engine)
  314. assert claim.caller.startswith("apikey:")