test_octoeverywhere_api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. """Settings, permissions, and status routes for OctoEverywhere detection."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
  5. from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service as service
  6. @pytest.fixture(autouse=True)
  7. def clear_detection_state():
  8. service._states.clear()
  9. service._history.clear()
  10. service._last_error = None
  11. service._last_error_code = None
  12. yield
  13. service._states.clear()
  14. service._history.clear()
  15. service._last_error = None
  16. service._last_error_code = None
  17. @pytest.mark.integration
  18. async def test_settings_round_trip_and_provider_switch(async_client):
  19. settings = (await async_client.get("/api/v1/settings/")).json()
  20. assert settings["octoeverywhere_enabled"] is False
  21. assert settings["octoeverywhere_api_key"] == ""
  22. assert settings["octoeverywhere_api_key_configured"] is False
  23. assert settings["octoeverywhere_confidence"] == "medium"
  24. assert settings["octoeverywhere_poll_interval"] == 20
  25. assert settings["octoeverywhere_action"] == "notify"
  26. await async_client.put("/api/v1/settings/", json={"obico_enabled": True})
  27. response = await async_client.put(
  28. "/api/v1/settings/",
  29. json={
  30. "octoeverywhere_enabled": True,
  31. "octoeverywhere_api_key": " test-key ",
  32. "octoeverywhere_confidence": "high",
  33. "octoeverywhere_action": "pause",
  34. "octoeverywhere_enabled_printers": "[3, 1]",
  35. "octoeverywhere_poll_interval": 15,
  36. },
  37. )
  38. assert response.status_code == 200
  39. assert response.json()["octoeverywhere_api_key"] == ""
  40. assert response.json()["octoeverywhere_api_key_configured"] is True
  41. assert response.json()["octoeverywhere_poll_interval"] == 15
  42. assert response.json()["obico_enabled"] is False
  43. status = (await async_client.get("/api/v1/octoeverywhere/status")).json()
  44. assert status["enabled"] is True
  45. assert status["api_key_configured"] is True
  46. assert status["confidence"] == "high"
  47. assert status["action"] == "pause"
  48. assert status["poll_interval"] == 15
  49. assert "test-key" not in str(status)
  50. cards = (await async_client.get("/api/v1/octoeverywhere/printer-status")).json()
  51. assert cards["monitored_printers"] == [1, 3]
  52. for key in ("api_key", "api_key_configured", "action", "history", "confidence"):
  53. assert key not in cards
  54. response = await async_client.put("/api/v1/settings/", json={"obico_enabled": True})
  55. assert response.json()["octoeverywhere_enabled"] is False
  56. assert response.json()["octoeverywhere_api_key"] == ""
  57. assert response.json()["octoeverywhere_api_key_configured"] is True
  58. assert response.json()["octoeverywhere_poll_interval"] == 15
  59. @pytest.mark.integration
  60. async def test_write_only_key_preserved_replaced_and_removed(async_client):
  61. await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": "first-key"})
  62. for payload in ({"octoeverywhere_confidence": "low"}, {"octoeverywhere_api_key": None}):
  63. response = await async_client.put("/api/v1/settings/", json=payload)
  64. assert response.json()["octoeverywhere_api_key"] == ""
  65. assert response.json()["octoeverywhere_api_key_configured"] is True
  66. assert (await service._load_settings())["api_key"] == "first-key"
  67. await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": "replacement-key"})
  68. settings = (await async_client.get("/api/v1/settings/")).json()
  69. assert settings["octoeverywhere_api_key"] == ""
  70. assert settings["octoeverywhere_api_key_configured"] is True
  71. assert (await service._load_settings())["api_key"] == "replacement-key"
  72. response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": ""})
  73. assert response.json()["octoeverywhere_api_key_configured"] is False
  74. assert (await service._load_settings())["api_key"] == ""
  75. @pytest.mark.integration
  76. async def test_fixed_interval_round_trip(async_client):
  77. for interval in (5, 30, 20):
  78. response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_poll_interval": interval})
  79. assert response.status_code == 200
  80. assert response.json()["octoeverywhere_poll_interval"] == interval
  81. settings = (await async_client.get("/api/v1/settings/")).json()
  82. assert settings["octoeverywhere_poll_interval"] == interval
  83. assert (await service._load_settings())["poll_interval"] == interval
  84. @pytest.mark.integration
  85. @pytest.mark.parametrize("confidence", ["lowest", "low", "medium", "high", "highest"])
  86. async def test_confidence_round_trip_and_connection_validation(async_client, confidence):
  87. response = await async_client.put(
  88. "/api/v1/settings/", json={"octoeverywhere_api_key": "saved-key", "octoeverywhere_confidence": confidence}
  89. )
  90. assert response.status_code == 200
  91. assert response.json()["octoeverywhere_confidence"] == confidence
  92. assert (await async_client.get("/api/v1/settings/")).json()["octoeverywhere_confidence"] == confidence
  93. assert (await async_client.get("/api/v1/octoeverywhere/status")).json()["confidence"] == confidence
  94. assert (await service._load_settings())["confidence"] == confidence
  95. with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
  96. check.return_value = {"ok": True, "status_code": 200, "error": None, "error_code": None}
  97. for payload in ({}, {"confidence": confidence}):
  98. response = await async_client.post("/api/v1/octoeverywhere/test-connection", json=payload)
  99. assert response.status_code == 200
  100. assert response.json()["ok"] is True
  101. check.assert_awaited_once_with("saved-key", confidence)
  102. check.reset_mock()
  103. @pytest.mark.integration
  104. @pytest.mark.parametrize("confidence", ["maximum", "Lowest", "", 5, True])
  105. async def test_invalid_confidence_rejected_by_settings_and_connection(async_client, confidence):
  106. response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_confidence": confidence})
  107. assert response.status_code == 422
  108. with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
  109. response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={"confidence": confidence})
  110. assert response.status_code == 422
  111. check.assert_not_awaited()
  112. @pytest.mark.integration
  113. @pytest.mark.parametrize("sensitivity", ["lowest", "highest"])
  114. async def test_obico_rejects_octoeverywhere_only_levels(async_client, sensitivity):
  115. response = await async_client.put("/api/v1/settings/", json={"obico_sensitivity": sensitivity})
  116. assert response.status_code == 422
  117. @pytest.mark.integration
  118. @pytest.mark.parametrize("sensitivity", ["low", "medium", "high"])
  119. async def test_obico_existing_sensitivity_levels_remain_supported(async_client, sensitivity):
  120. response = await async_client.put("/api/v1/settings/", json={"obico_sensitivity": sensitivity})
  121. assert response.status_code == 200
  122. assert response.json()["obico_sensitivity"] == sensitivity
  123. @pytest.mark.integration
  124. async def test_rejects_two_enabled_providers(async_client):
  125. response = await async_client.put("/api/v1/settings/", json={"obico_enabled": True, "octoeverywhere_enabled": True})
  126. assert response.status_code == 400
  127. @pytest.mark.integration
  128. @pytest.mark.parametrize(
  129. "payload",
  130. [
  131. {"octoeverywhere_confidence": "maximum"},
  132. {"octoeverywhere_action": "stop"},
  133. {"octoeverywhere_enabled_printers": "{}"},
  134. {"octoeverywhere_enabled_printers": "[true]"},
  135. {"octoeverywhere_enabled_printers": "[-1]"},
  136. {"octoeverywhere_enabled_printers": '["1"]'},
  137. {"octoeverywhere_api_key": "key\r\nheader"},
  138. {"octoeverywhere_poll_interval": 0},
  139. {"octoeverywhere_poll_interval": 4},
  140. {"octoeverywhere_poll_interval": 31},
  141. {"octoeverywhere_poll_interval": 5.5},
  142. {"octoeverywhere_poll_interval": True},
  143. {"octoeverywhere_poll_interval": None},
  144. {"octoeverywhere_poll_interval": "20"},
  145. ],
  146. )
  147. async def test_invalid_settings_rejected(async_client, payload):
  148. response = await async_client.put("/api/v1/settings/", json=payload)
  149. assert response.status_code == 422
  150. @pytest.mark.integration
  151. async def test_connection_uses_saved_or_explicit_key(async_client):
  152. await async_client.put(
  153. "/api/v1/settings/", json={"octoeverywhere_api_key": "saved-key", "octoeverywhere_confidence": "low"}
  154. )
  155. with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
  156. check.return_value = {"ok": True, "status_code": 200, "error": None, "error_code": None}
  157. response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={})
  158. assert response.json()["ok"] is True
  159. check.assert_awaited_once_with("saved-key", "low")
  160. check.reset_mock()
  161. await async_client.post("/api/v1/octoeverywhere/test-connection", json={"api_key": ""})
  162. check.assert_awaited_once_with("", "low")
  163. @pytest.mark.parametrize("can_see_error", [False, True])
  164. async def test_printer_status_respects_error_permissions(can_see_error):
  165. from backend.app.api.routes.octoeverywhere import get_printer_status
  166. user = MagicMock()
  167. user.has_permission.return_value = can_see_error
  168. service._last_error = "private diagnostic"
  169. service._last_error_code = "OE_FREE_USAGE_LIMIT_REACHED"
  170. entry = {
  171. "class": "error",
  172. "print_quality": None,
  173. "frame_count": 0,
  174. "error": "private diagnostic",
  175. "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
  176. }
  177. with (
  178. patch.object(service, "_load_settings", return_value={"enabled": True, "enabled_printers": None}),
  179. patch.object(service, "get_per_printer", return_value={1: entry}),
  180. ):
  181. result = await get_printer_status(user=user, printer_scope=ALL_PRINTERS, actor=user)
  182. assert result["per_printer"][1]["class"] == "error"
  183. assert result["per_printer"][1]["error"] == ("private diagnostic" if can_see_error else None)
  184. assert result["per_printer"][1]["error_code"] == ("OE_FREE_USAGE_LIMIT_REACHED" if can_see_error else None)
  185. assert result["last_error"] == ("private diagnostic" if can_see_error else None)
  186. assert result["last_error_code"] == ("OE_FREE_USAGE_LIMIT_REACHED" if can_see_error else None)
  187. @pytest.mark.integration
  188. async def test_printer_status_hides_diagnostics_from_api_keys(async_client):
  189. from backend.app.core.auth import ApiKeyActor, get_request_actor
  190. from backend.app.main import app
  191. from backend.app.models.api_key import APIKey
  192. # Permission dependencies return user=None for API keys too. Their actor
  193. # must still enforce settings:read when deciding whether to expose errors.
  194. actor = ApiKeyActor(APIKey(can_read_status=True), owner=None)
  195. service._last_error = "private diagnostic"
  196. service._last_error_code = "OE_FREE_USAGE_LIMIT_REACHED"
  197. entry = {
  198. "class": "error",
  199. "print_quality": None,
  200. "frame_count": 0,
  201. "error": "private diagnostic",
  202. "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
  203. }
  204. app.dependency_overrides[get_request_actor] = lambda: actor
  205. try:
  206. with (
  207. patch.object(service, "_load_settings", return_value={"enabled": True, "enabled_printers": None}),
  208. patch.object(service, "get_per_printer", return_value={1: entry}),
  209. ):
  210. response = await async_client.get("/api/v1/octoeverywhere/printer-status")
  211. assert response.status_code == 200
  212. result = response.json()
  213. assert result["per_printer"]["1"]["class"] == "error"
  214. assert result["per_printer"]["1"]["error"] is None
  215. assert result["per_printer"]["1"]["error_code"] is None
  216. assert result["last_error"] is None
  217. assert result["last_error_code"] is None
  218. finally:
  219. app.dependency_overrides.pop(get_request_actor, None)
  220. @pytest.mark.integration
  221. @pytest.mark.parametrize("allowed_ids", [frozenset({1}), frozenset()])
  222. @pytest.mark.parametrize("enabled_printers", [None, {1, 2}])
  223. async def test_printer_status_hides_printers_outside_caller_scope(async_client, allowed_ids, enabled_printers):
  224. from backend.app.core.auth import get_printer_scope_if_auth_enabled
  225. from backend.app.main import app
  226. entry = {"class": "safe", "print_quality": 95, "frame_count": 1, "error": None, "error_code": None}
  227. app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(allowed_ids)
  228. try:
  229. with (
  230. patch.object(
  231. service, "_load_settings", return_value={"enabled": True, "enabled_printers": enabled_printers}
  232. ),
  233. patch.object(service, "get_per_printer", return_value={1: entry, 2: entry}),
  234. ):
  235. response = await async_client.get("/api/v1/octoeverywhere/printer-status")
  236. assert response.status_code == 200
  237. result = response.json()
  238. assert set(result["per_printer"]) == {str(pid) for pid in allowed_ids}
  239. assert result["monitored_printers"] == (None if enabled_printers is None else sorted(allowed_ids))
  240. finally:
  241. app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
  242. @pytest.mark.integration
  243. async def test_usage_limit_code_reaches_settings_and_printer_status(async_client):
  244. from backend.app.services.octoeverywhere_detection import _PrintState
  245. code = "OE_FREE_USAGE_LIMIT_REACHED"
  246. message = "Usage limit reached. Set up billing to continue."
  247. service._states[1] = _PrintState(print_id="job", error=message, error_code=code)
  248. service._last_error = message
  249. service._last_error_code = code
  250. for endpoint in ("status", "printer-status"):
  251. response = await async_client.get(f"/api/v1/octoeverywhere/{endpoint}")
  252. assert response.status_code == 200
  253. assert response.json()["last_error_code"] == code
  254. assert response.json()["per_printer"]["1"]["error_code"] == code
  255. assert response.json()["per_printer"]["1"]["class"] == "error"
  256. @pytest.mark.integration
  257. async def test_connection_preserves_usage_limit_code(async_client):
  258. result = {
  259. "ok": False,
  260. "status_code": 429,
  261. "error": "Usage limit reached. Set up billing to continue.",
  262. "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
  263. }
  264. with patch.object(service, "test_connection", return_value=result):
  265. response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={})
  266. assert response.status_code == 200
  267. assert response.json() == result
  268. @pytest.mark.parametrize("is_api_key", [True, False])
  269. async def test_settings_response_always_redacts_oe_key(db_session, is_api_key):
  270. from backend.app.api.routes.settings import _build_settings_response, set_setting
  271. await set_setting(db_session, "octoeverywhere_api_key", "secret-key")
  272. await db_session.commit()
  273. settings = await _build_settings_response(db_session, is_api_key=is_api_key)
  274. assert settings.octoeverywhere_api_key == ""
  275. assert settings.octoeverywhere_api_key_configured is True
  276. @pytest.mark.integration
  277. @pytest.mark.parametrize(
  278. ("provider_settings", "scope", "configured", "uncovered"),
  279. [
  280. ([], "", False, [1, 2]),
  281. ([{"enabled": False}], "", False, [1, 2]),
  282. ([{"on_ai_failure_detection": False}], "", False, [1, 2]),
  283. ([{}], "", True, []),
  284. ([{"printer_id": 1}], "", True, [2]),
  285. ([{"printer_id": 1}], "[1]", True, []),
  286. ([{"printer_id": 1}], "[2]", True, [2]),
  287. ([{"printer_id": 1}, {"printer_id": 2}], "", True, []),
  288. ([], "[]", False, []),
  289. ],
  290. )
  291. async def test_notification_readiness_matches_subscriptions(
  292. async_client, db_session, provider_settings, scope, configured, uncovered
  293. ):
  294. from backend.app.models.notification import NotificationProvider
  295. from backend.app.models.printer import Printer
  296. for pid in (1, 2, 3):
  297. db_session.add(
  298. Printer(
  299. id=pid,
  300. name=f"Printer {pid}",
  301. serial_number=f"TEST{pid}",
  302. ip_address="192.0.2.1",
  303. access_code="12345678",
  304. is_active=pid != 3,
  305. )
  306. )
  307. await db_session.flush()
  308. for overrides in provider_settings:
  309. values = {"enabled": True, "on_ai_failure_detection": True, **overrides}
  310. db_session.add(NotificationProvider(name="Alerts", provider_type="ntfy", config='{"token":"secret"}', **values))
  311. await db_session.commit()
  312. await async_client.put("/api/v1/settings/", json={"octoeverywhere_enabled_printers": scope})
  313. response = await async_client.get("/api/v1/octoeverywhere/status")
  314. assert response.status_code == 200
  315. assert response.json()["notifications"] == {"configured": configured, "uncovered_printers": uncovered}
  316. assert "secret" not in response.text
  317. async def test_routes_require_existing_permissions():
  318. from fastapi import HTTPException
  319. from backend.app.api.routes.octoeverywhere import get_printer_status, get_status, test_connection
  320. from backend.app.core.permissions import Permission
  321. for route, permission in (
  322. (get_status, Permission.SETTINGS_READ),
  323. (get_printer_status, Permission.PRINTERS_READ),
  324. (test_connection, Permission.SETTINGS_UPDATE),
  325. ):
  326. with (
  327. patch("backend.app.core.auth.is_auth_enabled", return_value=True),
  328. patch("backend.app.core.auth._validate_api_key", return_value=MagicMock()),
  329. patch("backend.app.core.auth.authorize_api_key", side_effect=HTTPException(403)) as authorize,
  330. pytest.raises(HTTPException) as exc,
  331. ):
  332. await route.__defaults__[0].dependency(x_api_key="bb_test")
  333. assert exc.value.status_code == 403
  334. assert authorize.await_args.args[2] == [permission.value]