test_virtual_printer_api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. """Integration tests for Virtual Printer API endpoints.
  2. Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestVirtualPrinterSettingsAPI:
  8. """Integration tests for /api/v1/settings/virtual-printer endpoints."""
  9. # ========================================================================
  10. # Get settings
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
  15. """Verify virtual printer settings can be retrieved."""
  16. response = await async_client.get("/api/v1/settings/virtual-printer")
  17. assert response.status_code == 200
  18. result = response.json()
  19. assert "enabled" in result
  20. assert "access_code_set" in result
  21. assert "mode" in result
  22. assert "status" in result
  23. @pytest.mark.asyncio
  24. @pytest.mark.integration
  25. async def test_get_settings_has_status(self, async_client: AsyncClient):
  26. """Verify settings include status details."""
  27. response = await async_client.get("/api/v1/settings/virtual-printer")
  28. assert response.status_code == 200
  29. result = response.json()
  30. status = result["status"]
  31. assert "enabled" in status
  32. assert "running" in status
  33. assert "mode" in status
  34. assert "name" in status
  35. assert "serial" in status
  36. assert "pending_files" in status
  37. # ========================================================================
  38. # Update settings
  39. # ========================================================================
  40. @pytest.mark.asyncio
  41. @pytest.mark.integration
  42. async def test_update_mode(self, async_client: AsyncClient):
  43. """Verify mode can be updated."""
  44. response = await async_client.put("/api/v1/settings/virtual-printer?mode=review")
  45. assert response.status_code == 200
  46. result = response.json()
  47. assert result["mode"] == "review"
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_update_mode_to_queue(self, async_client: AsyncClient):
  51. """Verify mode can be set to the canonical 'queue' value."""
  52. response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
  53. assert response.status_code == 200
  54. result = response.json()
  55. assert result["mode"] == "queue"
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_update_mode_legacy_print_queue_normalises_to_queue(self, async_client: AsyncClient):
  59. """Legacy `print_queue` is accepted on input and translated to `queue` on
  60. storage so the UI button label and the support-bundle field agree
  61. (#1429 mode-label discrepancy)."""
  62. response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
  63. assert response.status_code == 200
  64. result = response.json()
  65. assert result["mode"] == "queue"
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_update_mode_legacy_immediate_normalises_to_archive(self, async_client: AsyncClient):
  69. """Legacy `immediate` is accepted on input and translated to `archive`
  70. on storage (#1429 mode-label discrepancy)."""
  71. response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
  72. assert response.status_code == 200
  73. result = response.json()
  74. assert result["mode"] == "archive"
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_update_mode_to_archive(self, async_client: AsyncClient):
  78. """Verify mode can be set to the canonical 'archive' value."""
  79. response = await async_client.put("/api/v1/settings/virtual-printer?mode=archive")
  80. assert response.status_code == 200
  81. result = response.json()
  82. assert result["mode"] == "archive"
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_update_access_code(self, async_client: AsyncClient):
  86. """Verify access code can be set."""
  87. response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
  88. assert response.status_code == 200
  89. result = response.json()
  90. assert result["access_code_set"] is True
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
  94. """Verify access code validation for length."""
  95. response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
  96. # Should fail validation
  97. assert response.status_code == 400
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_enable_without_access_code(self, async_client: AsyncClient):
  101. """Verify enabling fails without access code set."""
  102. # First ensure no access code is set by checking current state
  103. # Then try to enable
  104. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
  105. # If access code wasn't set, this should fail
  106. # If it was already set, it will succeed
  107. # Both are valid test outcomes
  108. assert response.status_code in [200, 400]
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_enable_with_access_code(self, async_client: AsyncClient):
  112. """Verify enabling succeeds when access code is set."""
  113. # First set access code
  114. await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
  115. # Then enable (this will start the servers which may fail in test env)
  116. # We mock the manager to avoid actually starting servers
  117. with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
  118. mock_manager.configure = AsyncMock()
  119. mock_manager.get_status = MagicMock(
  120. return_value={
  121. "enabled": True,
  122. "running": True,
  123. "mode": "archive",
  124. "name": "Bambuddy",
  125. "serial": "00M09A391800001",
  126. "pending_files": 0,
  127. }
  128. )
  129. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
  130. assert response.status_code == 200
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_disable_virtual_printer(self, async_client: AsyncClient):
  134. """Verify virtual printer can be disabled."""
  135. with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
  136. mock_manager.configure = AsyncMock()
  137. mock_manager.get_status = MagicMock(
  138. return_value={
  139. "enabled": False,
  140. "running": False,
  141. "mode": "archive",
  142. "name": "Bambuddy",
  143. "serial": "00M09A391800001",
  144. "pending_files": 0,
  145. }
  146. )
  147. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
  148. assert response.status_code == 200
  149. result = response.json()
  150. assert result["enabled"] is False
  151. class TestPendingUploadsAPI:
  152. """Integration tests for /api/v1/pending-uploads/ endpoints."""
  153. @pytest.fixture
  154. def mock_pending_uploads(self, db_session):
  155. """Create mock pending uploads in database."""
  156. async def _create_pending(filename: str = "test.3mf"):
  157. from datetime import datetime
  158. from backend.app.models.pending_upload import PendingUpload
  159. upload = PendingUpload(
  160. filename=filename,
  161. file_path=f"/tmp/{filename}",
  162. file_size=1024,
  163. source_ip="192.168.1.100",
  164. status="pending",
  165. )
  166. db_session.add(upload)
  167. await db_session.commit()
  168. await db_session.refresh(upload)
  169. return upload
  170. return _create_pending
  171. # ========================================================================
  172. # List pending uploads
  173. # ========================================================================
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
  177. """Verify empty list is returned when no pending uploads."""
  178. response = await async_client.get("/api/v1/pending-uploads/")
  179. assert response.status_code == 200
  180. result = response.json()
  181. assert isinstance(result, list)
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_get_pending_uploads_count(self, async_client: AsyncClient):
  185. """Verify count endpoint returns correct count."""
  186. response = await async_client.get("/api/v1/pending-uploads/count")
  187. assert response.status_code == 200
  188. result = response.json()
  189. assert "count" in result
  190. assert isinstance(result["count"], int)
  191. # ========================================================================
  192. # Archive pending upload
  193. # ========================================================================
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
  197. """Verify archiving non-existent upload returns 404."""
  198. response = await async_client.post("/api/v1/pending-uploads/99999/archive")
  199. assert response.status_code == 404
  200. # ========================================================================
  201. # Discard pending upload
  202. # ========================================================================
  203. @pytest.mark.asyncio
  204. @pytest.mark.integration
  205. async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
  206. """Verify discarding non-existent upload returns 404."""
  207. response = await async_client.delete("/api/v1/pending-uploads/99999")
  208. assert response.status_code == 404
  209. # ========================================================================
  210. # Bulk operations
  211. # ========================================================================
  212. @pytest.mark.asyncio
  213. @pytest.mark.integration
  214. async def test_archive_all_empty(self, async_client: AsyncClient):
  215. """Verify archive all with no pending uploads."""
  216. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  217. assert response.status_code == 200
  218. result = response.json()
  219. assert "archived" in result
  220. assert "failed" in result
  221. @pytest.mark.asyncio
  222. @pytest.mark.integration
  223. async def test_discard_all_empty(self, async_client: AsyncClient):
  224. """Verify discard all with no pending uploads."""
  225. response = await async_client.delete("/api/v1/pending-uploads/discard-all")
  226. assert response.status_code == 200
  227. result = response.json()
  228. assert "discarded" in result
  229. class TestVirtualPrinterAutoDispatchAPI:
  230. """Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
  231. @pytest.mark.asyncio
  232. @pytest.mark.integration
  233. async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
  234. """Verify creating a VP without auto_dispatch defaults to true."""
  235. response = await async_client.post(
  236. "/api/v1/virtual-printers",
  237. json={
  238. "name": "TestDefaultDispatch",
  239. "mode": "queue",
  240. "access_code": "12345678",
  241. },
  242. )
  243. assert response.status_code == 200
  244. result = response.json()
  245. assert result["auto_dispatch"] is True
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
  249. """Verify creating a VP with auto_dispatch=false persists correctly."""
  250. response = await async_client.post(
  251. "/api/v1/virtual-printers",
  252. json={
  253. "name": "TestManualDispatch",
  254. "mode": "queue",
  255. "access_code": "12345678",
  256. "auto_dispatch": False,
  257. },
  258. )
  259. assert response.status_code == 200
  260. result = response.json()
  261. assert result["auto_dispatch"] is False
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
  265. """Verify auto_dispatch can be toggled via PUT and persists."""
  266. # Create with auto_dispatch=True (default)
  267. create_resp = await async_client.post(
  268. "/api/v1/virtual-printers",
  269. json={
  270. "name": "TestToggleDispatch",
  271. "mode": "queue",
  272. "access_code": "12345678",
  273. },
  274. )
  275. assert create_resp.status_code == 200
  276. vp_id = create_resp.json()["id"]
  277. # Update to auto_dispatch=False
  278. update_resp = await async_client.put(
  279. f"/api/v1/virtual-printers/{vp_id}",
  280. json={"auto_dispatch": False},
  281. )
  282. assert update_resp.status_code == 200
  283. assert update_resp.json()["auto_dispatch"] is False
  284. # Verify it persists by fetching
  285. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  286. assert get_resp.status_code == 200
  287. assert get_resp.json()["auto_dispatch"] is False
  288. class TestVirtualPrinterTailscaleToggleAPI:
  289. """The Tailscale toggle is informational — toggling either way always succeeds.
  290. There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
  291. back when the toggle controlled LE cert provisioning. That path was removed:
  292. the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
  293. system trust store, so even an LE cert wouldn't be accepted. The toggle now
  294. only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
  295. irrelevant to whether the toggle can be flipped.
  296. """
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
  300. """PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
  301. create_resp = await async_client.post(
  302. "/api/v1/virtual-printers",
  303. json={
  304. "name": "TestTailscaleToggle",
  305. "mode": "archive",
  306. "access_code": "12345678",
  307. },
  308. )
  309. assert create_resp.status_code == 200
  310. vp_id = create_resp.json()["id"]
  311. assert create_resp.json()["tailscale_disabled"] is True
  312. with patch(
  313. "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
  314. new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
  315. ):
  316. enable_resp = await async_client.put(
  317. f"/api/v1/virtual-printers/{vp_id}",
  318. json={"tailscale_disabled": False},
  319. )
  320. disable_resp = await async_client.put(
  321. f"/api/v1/virtual-printers/{vp_id}",
  322. json={"tailscale_disabled": True},
  323. )
  324. assert enable_resp.status_code == 200
  325. assert enable_resp.json()["tailscale_disabled"] is False
  326. assert disable_resp.status_code == 200
  327. assert disable_resp.json()["tailscale_disabled"] is True
  328. class TestVirtualPrinterCaCertificateAPI:
  329. """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
  333. """The shared CA certificate is returned as PEM with identifying metadata."""
  334. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  335. assert response.status_code == 200
  336. result = response.json()
  337. assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
  338. assert "PRIVATE KEY" not in result["pem"] # never expose the CA key
  339. assert len(result["fingerprint_sha256"].split(":")) == 32
  340. assert result["not_valid_after"]
  341. @pytest.mark.asyncio
  342. @pytest.mark.integration
  343. async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
  344. """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
  345. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  346. # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
  347. assert response.status_code == 200
  348. class TestVirtualPrinterDiagnosticAPI:
  349. """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
  353. response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
  354. assert response.status_code == 404
  355. @pytest.mark.asyncio
  356. @pytest.mark.integration
  357. async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
  358. """A freshly created (disabled) VP fails the 'enabled' check."""
  359. create_resp = await async_client.post(
  360. "/api/v1/virtual-printers",
  361. json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
  362. )
  363. assert create_resp.status_code == 200
  364. vp_id = create_resp.json()["id"]
  365. response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
  366. assert response.status_code == 200
  367. result = response.json()
  368. assert result["vp_id"] == vp_id
  369. assert result["overall"] == "problems"
  370. by_id = {c["id"]: c["status"] for c in result["checks"]}
  371. assert by_id["enabled"] == "fail"
  372. assert by_id["running"] == "skip"