test_octoeverywhere_api.py 21 KB

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