test_obico_api.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """Integration tests for Obico API endpoints (#172 follow-up).
  2. Verifies the /obico/cached-frame/{nonce} endpoint used by Obico's ML API to fetch
  3. pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
  4. hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
  5. """
  6. from unittest.mock import AsyncMock, MagicMock, patch
  7. import pytest
  8. from httpx import AsyncClient, Request, Response
  9. from backend.app.core.printer_scope import ALL_PRINTERS
  10. from backend.app.services.obico_detection import (
  11. ObicoDetectionService,
  12. _frame_cache,
  13. obico_detection_service,
  14. stash_frame,
  15. )
  16. from backend.app.services.obico_smoothing import PrintState
  17. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  18. @pytest.fixture(autouse=True)
  19. def clear_cache():
  20. _frame_cache.clear()
  21. yield
  22. _frame_cache.clear()
  23. class TestObicoCachedFrame:
  24. @pytest.mark.asyncio
  25. @pytest.mark.integration
  26. async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):
  27. """A stashed nonce returns the stored JPEG bytes with image/jpeg."""
  28. nonce = await stash_frame(FAKE_JPEG)
  29. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  30. assert response.status_code == 200
  31. assert response.headers["content-type"] == "image/jpeg"
  32. assert response.content == FAKE_JPEG
  33. @pytest.mark.asyncio
  34. @pytest.mark.integration
  35. async def test_unknown_nonce_is_404(self, async_client: AsyncClient):
  36. """An unguessable URL must not leak that the endpoint exists — return 404."""
  37. response = await async_client.get("/api/v1/obico/cached-frame/definitely-not-a-real-nonce")
  38. assert response.status_code == 404
  39. @pytest.mark.asyncio
  40. @pytest.mark.integration
  41. async def test_nonce_is_single_use(self, async_client: AsyncClient):
  42. """A second fetch with the same nonce returns 404 — prevents replay."""
  43. nonce = await stash_frame(FAKE_JPEG)
  44. first = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  45. assert first.status_code == 200
  46. second = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  47. assert second.status_code == 404
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_endpoint_is_public(self, async_client: AsyncClient):
  51. """Obico's ML API can't send auth headers, so the nonce IS the credential.
  52. The path must be in PUBLIC_API_PATTERNS (no auth wall)."""
  53. nonce = await stash_frame(FAKE_JPEG)
  54. # Intentionally omit any auth headers even if the fixture would normally inject them
  55. response = await async_client.get(
  56. f"/api/v1/obico/cached-frame/{nonce}",
  57. headers={}, # no Authorization header
  58. )
  59. assert response.status_code == 200
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_response_is_not_cached(self, async_client: AsyncClient):
  63. """Browsers/proxies must not hold onto the image after Obico consumes it."""
  64. nonce = await stash_frame(FAKE_JPEG)
  65. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  66. assert response.status_code == 200
  67. assert "no-store" in response.headers.get("cache-control", "")
  68. class TestBambuddyInternalUrl:
  69. @pytest.mark.asyncio
  70. @pytest.mark.integration
  71. @pytest.mark.parametrize(
  72. ("bambuddy_internal_url", "external_url", "expected_base"),
  73. [
  74. (None, "https://bambuddy.example.com", "https://bambuddy.example.com"),
  75. ("", "https://bambuddy.example.com/", "https://bambuddy.example.com"),
  76. ("http://bambuddy:8000/", "https://bambuddy.example.com", "http://bambuddy:8000"),
  77. ("http://192.168.1.20:8000", "", "http://192.168.1.20:8000"),
  78. ],
  79. )
  80. async def test_saved_url_controls_snapshot_callback(
  81. self, async_client: AsyncClient, bambuddy_internal_url, external_url, expected_base
  82. ):
  83. updates = {"external_url": external_url, "obico_ml_url": "http://obico:3333"}
  84. if bambuddy_internal_url is not None:
  85. updates["bambuddy_internal_url"] = bambuddy_internal_url
  86. response = await async_client.put("/api/v1/settings/", json=updates)
  87. assert response.status_code == 200
  88. saved = (await async_client.get("/api/v1/settings/")).json()
  89. assert saved["external_url"] == external_url
  90. assert saved["bambuddy_internal_url"] == (bambuddy_internal_url or "")
  91. status = (await async_client.get("/api/v1/obico/status")).json()
  92. assert status["external_url_configured"] is True
  93. async def fetch_snapshot(url, *, params, headers):
  94. assert url == "http://obico:3333/p/"
  95. assert params["img"].startswith(f"{expected_base}/api/v1/obico/cached-frame/")
  96. frame = await async_client.get(params["img"])
  97. assert frame.status_code == 200
  98. assert frame.content == FAKE_JPEG
  99. return Response(200, json={"detections": []}, request=Request("GET", url))
  100. svc = ObicoDetectionService()
  101. settings = await svc._load_settings()
  102. mock_client = MagicMock()
  103. mock_client.get = AsyncMock(side_effect=fetch_snapshot)
  104. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  105. mock_client.__aexit__ = AsyncMock(return_value=False)
  106. with (
  107. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  108. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  109. ):
  110. await svc._check_printer(1, MagicMock(state="RUNNING", task_name="job", subtask_name=""), settings)
  111. mock_client.get.assert_awaited_once()
  112. assert svc.get_per_printer()[1]["class"] == "safe"
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_empty_value_clears_the_bambuddy_internal_url(self, async_client: AsyncClient):
  116. response = await async_client.put(
  117. "/api/v1/settings/",
  118. json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
  119. )
  120. assert response.status_code == 200
  121. response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": ""})
  122. assert response.status_code == 200
  123. assert response.json()["bambuddy_internal_url"] == ""
  124. assert response.json()["external_url"] == "https://bambuddy.example.com"
  125. assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. @pytest.mark.parametrize(
  129. "value",
  130. ["bambuddy:8000", "192.168.1.20:8000", "ftp://bambuddy", "http://", "http://bam buddy:8000", "http://h:99999"],
  131. )
  132. async def test_address_without_http_scheme_is_rejected(self, async_client: AsyncClient, value):
  133. response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": value})
  134. assert response.status_code == 422
  135. saved = (await async_client.get("/api/v1/settings/")).json()
  136. assert saved["bambuddy_internal_url"] == ""
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_null_clears_instead_of_storing_none(self, async_client: AsyncClient):
  140. await async_client.put(
  141. "/api/v1/settings/",
  142. json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
  143. )
  144. response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": None})
  145. assert response.status_code == 200
  146. assert response.json()["bambuddy_internal_url"] == ""
  147. assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_surrounding_whitespace_is_stripped(self, async_client: AsyncClient):
  151. response = await async_client.put(
  152. "/api/v1/settings/", json={"bambuddy_internal_url": " http://bambuddy:8000/ "}
  153. )
  154. assert response.status_code == 200
  155. assert response.json()["bambuddy_internal_url"] == "http://bambuddy:8000/"
  156. assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "http://bambuddy:8000"
  157. class TestObicoPrinterStatus:
  158. """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
  159. @pytest.fixture(autouse=True)
  160. def clear_detection_state(self):
  161. obico_detection_service._states.clear()
  162. obico_detection_service._last_class.clear()
  163. obico_detection_service._errors.clear()
  164. obico_detection_service._last_error = None
  165. yield
  166. obico_detection_service._states.clear()
  167. obico_detection_service._last_class.clear()
  168. obico_detection_service._errors.clear()
  169. obico_detection_service._last_error = None
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_returns_per_printer_classification(self, async_client: AsyncClient):
  173. state = PrintState()
  174. state.update(0.5)
  175. obico_detection_service._states[1] = state
  176. obico_detection_service._last_class[1] = "warning"
  177. response = await async_client.get("/api/v1/obico/printer-status")
  178. assert response.status_code == 200
  179. data = response.json()
  180. assert "enabled" in data
  181. # None = all printers monitored (no obico_enabled_printers subset configured)
  182. assert data["monitored_printers"] is None
  183. entry = data["per_printer"]["1"]
  184. assert entry["class"] == "warning"
  185. assert entry["frame_count"] == 1
  186. assert isinstance(entry["score"], float)
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
  190. response = await async_client.get("/api/v1/obico/printer-status")
  191. assert response.status_code == 200
  192. assert response.json()["per_printer"] == {}
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
  196. """A configured obico_enabled_printers subset surfaces (as a sorted list) so
  197. the frontend can show the idle badge only on monitored printers."""
  198. update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
  199. assert update.status_code == 200
  200. try:
  201. response = await async_client.get("/api/v1/obico/printer-status")
  202. assert response.json()["monitored_printers"] == [1, 3]
  203. finally:
  204. await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
  205. @pytest.mark.asyncio
  206. @pytest.mark.integration
  207. async def test_last_error_is_surfaced(self, async_client: AsyncClient):
  208. """The badge modal shows the service's last error (auth disabled in the
  209. test env, so the settings:read gate on the field is open)."""
  210. obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
  211. response = await async_client.get("/api/v1/obico/printer-status")
  212. assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_does_not_leak_settings(self, async_client: AsyncClient):
  216. """Unlike /obico/status, this endpoint is readable with printers:read only,
  217. so it must not expose the ML URL or other configuration."""
  218. response = await async_client.get("/api/v1/obico/printer-status")
  219. data = response.json()
  220. for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
  221. assert key not in data
  222. class TestObicoPrinterStatusNoVerdict:
  223. """A printer whose detection is not working must not read as monitored (#2952)."""
  224. @pytest.fixture(autouse=True)
  225. def clear_detection_state(self):
  226. obico_detection_service._states.clear()
  227. obico_detection_service._last_class.clear()
  228. obico_detection_service._errors.clear()
  229. obico_detection_service._last_error = None
  230. yield
  231. obico_detection_service._states.clear()
  232. obico_detection_service._last_class.clear()
  233. obico_detection_service._errors.clear()
  234. obico_detection_service._last_error = None
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_error_class_and_reason_reach_the_card(self, async_client: AsyncClient):
  238. obico_detection_service._states[1] = PrintState()
  239. obico_detection_service._errors[1] = "Obico ML API rejected the token (401)."
  240. response = await async_client.get("/api/v1/obico/printer-status")
  241. entry = response.json()["per_printer"]["1"]
  242. assert entry["class"] == "error"
  243. assert entry["error"] == "Obico ML API rejected the token (401)."
  244. @pytest.mark.asyncio
  245. @pytest.mark.integration
  246. async def test_monitored_but_no_result_yet_is_unknown_not_safe(self, async_client: AsyncClient):
  247. obico_detection_service._states[1] = PrintState()
  248. response = await async_client.get("/api/v1/obico/printer-status")
  249. entry = response.json()["per_printer"]["1"]
  250. assert entry["class"] == "unknown"
  251. assert entry["error"] is None
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_reason_is_withheld_without_settings_read_but_the_class_is_not(self):
  255. """The reason can name the ML API base or the External URL, so it stays
  256. behind settings:read. Whether the print is being watched is not
  257. configuration, so a printers:read user still gets the class."""
  258. from unittest.mock import AsyncMock, MagicMock, patch
  259. from backend.app.api.routes.obico import get_printer_status
  260. obico_detection_service._states[1] = PrintState()
  261. obico_detection_service._errors[1] = "ML API call failed: http://192.168.8.9:3333 refused"
  262. user = MagicMock()
  263. user.has_permission.return_value = False
  264. # The route calls _load_settings for the enabled/monitored fields; the
  265. # redaction under test is independent of them.
  266. loaded = {"enabled": True, "enabled_printers": None}
  267. with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
  268. data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS, actor=user)
  269. entry = data["per_printer"][1]
  270. assert entry["class"] == "error"
  271. assert entry["error"] is None
  272. assert data["last_error"] is None
  273. assert "192.168.8.9" not in str(data)