test_obico_api.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  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. import pytest
  7. from httpx import AsyncClient
  8. from backend.app.core.printer_scope import ALL_PRINTERS
  9. from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
  10. from backend.app.services.obico_smoothing import PrintState
  11. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  12. @pytest.fixture(autouse=True)
  13. def clear_cache():
  14. _frame_cache.clear()
  15. yield
  16. _frame_cache.clear()
  17. class TestObicoCachedFrame:
  18. @pytest.mark.asyncio
  19. @pytest.mark.integration
  20. async def test_valid_nonce_returns_jpeg(self, async_client: AsyncClient):
  21. """A stashed nonce returns the stored JPEG bytes with image/jpeg."""
  22. nonce = await stash_frame(FAKE_JPEG)
  23. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  24. assert response.status_code == 200
  25. assert response.headers["content-type"] == "image/jpeg"
  26. assert response.content == FAKE_JPEG
  27. @pytest.mark.asyncio
  28. @pytest.mark.integration
  29. async def test_unknown_nonce_is_404(self, async_client: AsyncClient):
  30. """An unguessable URL must not leak that the endpoint exists — return 404."""
  31. response = await async_client.get("/api/v1/obico/cached-frame/definitely-not-a-real-nonce")
  32. assert response.status_code == 404
  33. @pytest.mark.asyncio
  34. @pytest.mark.integration
  35. async def test_nonce_is_single_use(self, async_client: AsyncClient):
  36. """A second fetch with the same nonce returns 404 — prevents replay."""
  37. nonce = await stash_frame(FAKE_JPEG)
  38. first = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  39. assert first.status_code == 200
  40. second = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  41. assert second.status_code == 404
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_endpoint_is_public(self, async_client: AsyncClient):
  45. """Obico's ML API can't send auth headers, so the nonce IS the credential.
  46. The path must be in PUBLIC_API_PATTERNS (no auth wall)."""
  47. nonce = await stash_frame(FAKE_JPEG)
  48. # Intentionally omit any auth headers even if the fixture would normally inject them
  49. response = await async_client.get(
  50. f"/api/v1/obico/cached-frame/{nonce}",
  51. headers={}, # no Authorization header
  52. )
  53. assert response.status_code == 200
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_response_is_not_cached(self, async_client: AsyncClient):
  57. """Browsers/proxies must not hold onto the image after Obico consumes it."""
  58. nonce = await stash_frame(FAKE_JPEG)
  59. response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
  60. assert response.status_code == 200
  61. assert "no-store" in response.headers.get("cache-control", "")
  62. class TestObicoPrinterStatus:
  63. """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
  64. @pytest.fixture(autouse=True)
  65. def clear_detection_state(self):
  66. obico_detection_service._states.clear()
  67. obico_detection_service._last_class.clear()
  68. obico_detection_service._errors.clear()
  69. obico_detection_service._last_error = None
  70. yield
  71. obico_detection_service._states.clear()
  72. obico_detection_service._last_class.clear()
  73. obico_detection_service._errors.clear()
  74. obico_detection_service._last_error = None
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_returns_per_printer_classification(self, async_client: AsyncClient):
  78. state = PrintState()
  79. state.update(0.5)
  80. obico_detection_service._states[1] = state
  81. obico_detection_service._last_class[1] = "warning"
  82. response = await async_client.get("/api/v1/obico/printer-status")
  83. assert response.status_code == 200
  84. data = response.json()
  85. assert "enabled" in data
  86. # None = all printers monitored (no obico_enabled_printers subset configured)
  87. assert data["monitored_printers"] is None
  88. entry = data["per_printer"]["1"]
  89. assert entry["class"] == "warning"
  90. assert entry["frame_count"] == 1
  91. assert isinstance(entry["score"], float)
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
  95. response = await async_client.get("/api/v1/obico/printer-status")
  96. assert response.status_code == 200
  97. assert response.json()["per_printer"] == {}
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
  101. """A configured obico_enabled_printers subset surfaces (as a sorted list) so
  102. the frontend can show the idle badge only on monitored printers."""
  103. update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
  104. assert update.status_code == 200
  105. try:
  106. response = await async_client.get("/api/v1/obico/printer-status")
  107. assert response.json()["monitored_printers"] == [1, 3]
  108. finally:
  109. await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
  110. @pytest.mark.asyncio
  111. @pytest.mark.integration
  112. async def test_last_error_is_surfaced(self, async_client: AsyncClient):
  113. """The badge modal shows the service's last error (auth disabled in the
  114. test env, so the settings:read gate on the field is open)."""
  115. obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
  116. response = await async_client.get("/api/v1/obico/printer-status")
  117. assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_does_not_leak_settings(self, async_client: AsyncClient):
  121. """Unlike /obico/status, this endpoint is readable with printers:read only,
  122. so it must not expose the ML URL or other configuration."""
  123. response = await async_client.get("/api/v1/obico/printer-status")
  124. data = response.json()
  125. for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
  126. assert key not in data
  127. class TestObicoPrinterStatusNoVerdict:
  128. """A printer whose detection is not working must not read as monitored (#2952)."""
  129. @pytest.fixture(autouse=True)
  130. def clear_detection_state(self):
  131. obico_detection_service._states.clear()
  132. obico_detection_service._last_class.clear()
  133. obico_detection_service._errors.clear()
  134. obico_detection_service._last_error = None
  135. yield
  136. obico_detection_service._states.clear()
  137. obico_detection_service._last_class.clear()
  138. obico_detection_service._errors.clear()
  139. obico_detection_service._last_error = None
  140. @pytest.mark.asyncio
  141. @pytest.mark.integration
  142. async def test_error_class_and_reason_reach_the_card(self, async_client: AsyncClient):
  143. obico_detection_service._states[1] = PrintState()
  144. obico_detection_service._errors[1] = "Obico ML API rejected the token (401)."
  145. response = await async_client.get("/api/v1/obico/printer-status")
  146. entry = response.json()["per_printer"]["1"]
  147. assert entry["class"] == "error"
  148. assert entry["error"] == "Obico ML API rejected the token (401)."
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_monitored_but_no_result_yet_is_unknown_not_safe(self, async_client: AsyncClient):
  152. obico_detection_service._states[1] = PrintState()
  153. response = await async_client.get("/api/v1/obico/printer-status")
  154. entry = response.json()["per_printer"]["1"]
  155. assert entry["class"] == "unknown"
  156. assert entry["error"] is None
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_reason_is_withheld_without_settings_read_but_the_class_is_not(self):
  160. """The reason can name the ML API base or the External URL, so it stays
  161. behind settings:read. Whether the print is being watched is not
  162. configuration, so a printers:read user still gets the class."""
  163. from unittest.mock import AsyncMock, MagicMock, patch
  164. from backend.app.api.routes.obico import get_printer_status
  165. obico_detection_service._states[1] = PrintState()
  166. obico_detection_service._errors[1] = "ML API call failed: http://192.168.8.9:3333 refused"
  167. user = MagicMock()
  168. user.has_permission.return_value = False
  169. # The route calls _load_settings for the enabled/monitored fields; the
  170. # redaction under test is independent of them.
  171. loaded = {"enabled": True, "enabled_printers": None}
  172. with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
  173. data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS)
  174. entry = data["per_printer"][1]
  175. assert entry["class"] == "error"
  176. assert entry["error"] is None
  177. assert data["last_error"] is None
  178. assert "192.168.8.9" not in str(data)