test_printer_kill_switch.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. from types import SimpleNamespace
  2. from unittest.mock import AsyncMock
  3. import pytest
  4. from backend.app import main as main_module
  5. @pytest.fixture(autouse=True)
  6. def clear_kill_switch_state():
  7. main_module._kill_switch_setting_cache = None
  8. main_module._unauthorized_print_kill_sent.clear()
  9. main_module._kill_switch_notification_tasks.clear()
  10. main_module._expected_prints.clear()
  11. main_module._active_prints.clear()
  12. main_module._expected_print_registered_at.clear()
  13. main_module._printer_reconciled_since_connect.clear()
  14. yield
  15. for task in main_module._kill_switch_notification_tasks.values():
  16. if not task.done():
  17. task.cancel()
  18. main_module._unauthorized_print_kill_sent.clear()
  19. main_module._kill_switch_notification_tasks.clear()
  20. main_module._expected_prints.clear()
  21. main_module._active_prints.clear()
  22. main_module._expected_print_registered_at.clear()
  23. main_module._printer_reconciled_since_connect.clear()
  24. main_module._kill_switch_setting_cache = None
  25. def test_gcode_3mf_status_filename_matches_registered_expected_print():
  26. state = SimpleNamespace(
  27. current_print=None,
  28. subtask_name="",
  29. gcode_file="foreign_job.gcode.3mf",
  30. )
  31. keys = main_module._build_status_print_keys(7, state)
  32. assert (7, "foreign_job.gcode.3mf") in keys
  33. assert (7, "foreign_job.gcode") in keys
  34. @pytest.mark.asyncio
  35. async def test_unauthorized_active_print_triggers_stop(monkeypatch):
  36. stop_calls: list[int] = []
  37. broadcast = AsyncMock()
  38. provider_notification = AsyncMock(return_value=True)
  39. async def fake_status(*args, **kwargs):
  40. return None
  41. async def kill_switch_enabled(_db):
  42. return True
  43. unauthorized = AsyncMock(return_value=False)
  44. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  45. monkeypatch.setattr(
  46. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  47. )
  48. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  49. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  50. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  51. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  52. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  53. monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
  54. monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
  55. monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
  56. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  57. state = SimpleNamespace(
  58. connected=True,
  59. state="RUNNING",
  60. progress=0,
  61. remaining_time=0,
  62. layer_num=0,
  63. total_layers=0,
  64. temperatures={},
  65. nozzles=[],
  66. raw_data={},
  67. stg_cur=0,
  68. # Real PrinterState always carries these; the status-broadcast dedup
  69. # key reads them so a Filament Track Switch rebind reaches the card.
  70. fila_switch=None,
  71. ams_switch_inlet={},
  72. extruder_slots={},
  73. cooling_fan_speed=None,
  74. big_fan1_speed=None,
  75. big_fan2_speed=None,
  76. chamber_light=False,
  77. active_extruder=0,
  78. tray_now=255,
  79. door_open=False,
  80. ams_filament_backup=False,
  81. current_print=None,
  82. subtask_name="foreign_job",
  83. subtask_id="external-task-1",
  84. gcode_file="foreign_job.gcode",
  85. )
  86. await main_module.on_printer_status_change(7, state)
  87. await main_module.on_printer_status_change(7, state)
  88. assert stop_calls == [7]
  89. unauthorized.assert_awaited_once()
  90. assert 7 in main_module._unauthorized_print_kill_sent
  91. broadcast.assert_awaited_once_with(
  92. {
  93. "type": "kill_switch_triggered",
  94. "printer_id": 7,
  95. "printer_name": "Printer 7",
  96. "filename": "foreign_job",
  97. "reason": "unauthorized_print",
  98. }
  99. )
  100. notification_task = main_module._kill_switch_notification_tasks[7]
  101. assert await notification_task is True
  102. provider_notification.assert_awaited_once_with(
  103. 7,
  104. "Printer 7",
  105. {
  106. "status": "stopped",
  107. "filename": "foreign_job.gcode",
  108. "subtask_name": "foreign_job",
  109. "progress": 0,
  110. "reason": "unauthorized_print",
  111. },
  112. )
  113. @pytest.mark.asyncio
  114. async def test_failed_immediate_notification_allows_completion_retry():
  115. task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
  116. assert await main_module._kill_switch_notification_already_sent(task) is False
  117. async def _return_false():
  118. return False
  119. @pytest.mark.asyncio
  120. async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
  121. monkeypatch.setitem(main_module._expected_prints, (7, "foreign_job"), 123)
  122. stop_calls: list[int] = []
  123. async def fake_status(*args, **kwargs):
  124. return None
  125. kill_switch_enabled = AsyncMock(return_value=True)
  126. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  127. monkeypatch.setattr(
  128. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  129. )
  130. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  131. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  132. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  133. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  134. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  135. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  136. state = SimpleNamespace(
  137. connected=True,
  138. state="RUNNING",
  139. progress=0,
  140. remaining_time=0,
  141. layer_num=0,
  142. total_layers=0,
  143. temperatures={},
  144. nozzles=[],
  145. raw_data={},
  146. stg_cur=0,
  147. # Real PrinterState always carries these; the status-broadcast dedup
  148. # key reads them so a Filament Track Switch rebind reaches the card.
  149. fila_switch=None,
  150. ams_switch_inlet={},
  151. extruder_slots={},
  152. cooling_fan_speed=None,
  153. big_fan1_speed=None,
  154. big_fan2_speed=None,
  155. chamber_light=False,
  156. active_extruder=0,
  157. tray_now=255,
  158. door_open=False,
  159. ams_filament_backup=False,
  160. current_print=None,
  161. subtask_name="foreign_job",
  162. gcode_file="foreign_job.gcode",
  163. )
  164. await main_module.on_printer_status_change(7, state)
  165. assert stop_calls == []
  166. assert 7 not in main_module._unauthorized_print_kill_sent
  167. kill_switch_enabled.assert_not_awaited()
  168. @pytest.mark.asyncio
  169. async def test_kill_switch_setting_is_cached(monkeypatch):
  170. kill_switch_enabled = AsyncMock(return_value=True)
  171. class FakeSessionContext:
  172. async def __aenter__(self):
  173. return SimpleNamespace()
  174. async def __aexit__(self, *_args):
  175. return False
  176. monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
  177. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  178. assert await main_module._is_printer_kill_switch_enabled_cached() is True
  179. assert await main_module._is_printer_kill_switch_enabled_cached() is True
  180. kill_switch_enabled.assert_awaited_once()
  181. @pytest.mark.asyncio
  182. async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
  183. stop_calls: list[int] = []
  184. async def fake_status(*args, **kwargs):
  185. return None
  186. async def kill_switch_enabled(_db):
  187. return True
  188. async def unauthorized(*_args):
  189. return False
  190. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  191. monkeypatch.setattr(
  192. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  193. )
  194. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  195. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  196. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  197. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  198. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  199. monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
  200. # The stop spawns this as a background task that reads the notification
  201. # providers through a real session. Left real, the test ends with its
  202. # query still running on aiosqlite's thread, which then posts the result
  203. # to a closed event loop and warns under whichever test runs next.
  204. monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", AsyncMock(return_value=True))
  205. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  206. active_state = SimpleNamespace(
  207. connected=True,
  208. state="RUNNING",
  209. progress=0,
  210. remaining_time=0,
  211. layer_num=0,
  212. total_layers=0,
  213. temperatures={},
  214. nozzles=[],
  215. raw_data={},
  216. stg_cur=0,
  217. # Real PrinterState always carries these; the status-broadcast dedup
  218. # key reads them so a Filament Track Switch rebind reaches the card.
  219. fila_switch=None,
  220. ams_switch_inlet={},
  221. extruder_slots={},
  222. cooling_fan_speed=None,
  223. big_fan1_speed=None,
  224. big_fan2_speed=None,
  225. chamber_light=False,
  226. active_extruder=0,
  227. tray_now=255,
  228. door_open=False,
  229. ams_filament_backup=False,
  230. current_print=None,
  231. subtask_name="foreign_job",
  232. subtask_id="external-task-1",
  233. gcode_file="foreign_job.gcode",
  234. )
  235. idle_state = SimpleNamespace(
  236. connected=True,
  237. state="IDLE",
  238. progress=0,
  239. remaining_time=0,
  240. layer_num=0,
  241. total_layers=0,
  242. temperatures={},
  243. nozzles=[],
  244. raw_data={},
  245. stg_cur=0,
  246. # Real PrinterState always carries these; the status-broadcast dedup
  247. # key reads them so a Filament Track Switch rebind reaches the card.
  248. fila_switch=None,
  249. ams_switch_inlet={},
  250. extruder_slots={},
  251. cooling_fan_speed=None,
  252. big_fan1_speed=None,
  253. big_fan2_speed=None,
  254. chamber_light=False,
  255. active_extruder=0,
  256. tray_now=255,
  257. door_open=False,
  258. ams_filament_backup=False,
  259. current_print=None,
  260. subtask_name="",
  261. subtask_id=None,
  262. gcode_file=None,
  263. )
  264. await main_module.on_printer_status_change(7, active_state)
  265. assert stop_calls == [7]
  266. assert 7 in main_module._unauthorized_print_kill_sent
  267. await main_module.on_printer_status_change(7, idle_state)
  268. assert 7 not in main_module._unauthorized_print_kill_sent
  269. @pytest.mark.asyncio
  270. @pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
  271. async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
  272. # billing_run_id is the marker the scheduler stamps on its own dispatches;
  273. # an archive without one proves only that Bambuddy watched the print.
  274. archive = SimpleNamespace(
  275. id=123,
  276. filename="owned_job.gcode.3mf",
  277. billing_run_id="d7c1f0b2-0000-4000-8000-000000000001",
  278. created_by_id=None,
  279. )
  280. query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
  281. db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
  282. class FakeSessionContext:
  283. async def __aenter__(self):
  284. return db
  285. async def __aexit__(self, *_args):
  286. return False
  287. stop_calls: list[int] = []
  288. async def fake_status(*args, **kwargs):
  289. return None
  290. async def kill_switch_enabled(_db):
  291. return True
  292. def discard_background_task(coro, **_kwargs):
  293. coro.close()
  294. monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
  295. monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
  296. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  297. monkeypatch.setattr(
  298. main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
  299. )
  300. monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
  301. monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
  302. monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
  303. monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
  304. monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
  305. monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
  306. state = SimpleNamespace(
  307. connected=True,
  308. state=printer_state,
  309. progress=42,
  310. remaining_time=600,
  311. layer_num=50,
  312. total_layers=100,
  313. temperatures={},
  314. nozzles=[],
  315. raw_data={},
  316. stg_cur=0,
  317. # Real PrinterState always carries these; the status-broadcast dedup
  318. # key reads them so a Filament Track Switch rebind reaches the card.
  319. fila_switch=None,
  320. ams_switch_inlet={},
  321. extruder_slots={},
  322. cooling_fan_speed=None,
  323. big_fan1_speed=None,
  324. big_fan2_speed=None,
  325. chamber_light=False,
  326. active_extruder=0,
  327. tray_now=255,
  328. door_open=False,
  329. ams_filament_backup=False,
  330. current_print=None,
  331. subtask_name="owned_job",
  332. subtask_id="bambuddy-task-123",
  333. gcode_file="owned_job.gcode.3mf",
  334. )
  335. await main_module.on_printer_status_change(7, state)
  336. assert stop_calls == []
  337. assert (7, "owned_job.gcode.3mf") in main_module._active_prints
  338. assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
  339. assert 7 not in main_module._unauthorized_print_kill_sent
  340. @pytest.mark.asyncio
  341. async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
  342. state = SimpleNamespace(
  343. current_print=None,
  344. subtask_name="owned_job",
  345. subtask_id=None,
  346. gcode_file="owned_job.gcode.3mf",
  347. )
  348. db = SimpleNamespace(execute=AsyncMock())
  349. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  350. authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
  351. assert authorization is None
  352. db.execute.assert_not_awaited()
  353. def _authorization_db(archive, dispatched_queue_item_id=None):
  354. """Fake session answering the two lookups `_is_bambuddy_authorized_print` makes."""
  355. query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
  356. return SimpleNamespace(
  357. execute=AsyncMock(return_value=query_result),
  358. scalar=AsyncMock(return_value=dispatched_queue_item_id),
  359. )
  360. def _running_state(subtask_id="external-task-9"):
  361. return SimpleNamespace(
  362. current_print=None,
  363. subtask_name="some_job",
  364. subtask_id=subtask_id,
  365. gcode_file="some_job.gcode.3mf",
  366. )
  367. @pytest.mark.asyncio
  368. async def test_archive_without_a_dispatch_marker_is_not_authorization(monkeypatch):
  369. """on_print_start archives prints started from Studio or Handy too.
  370. Those rows carry the same status and subtask_id as Bambuddy's own, so treating
  371. the row's existence as proof would switch the feature off a few seconds into
  372. every foreign print — as soon as the 3MF finished downloading.
  373. """
  374. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  375. observed_only = SimpleNamespace(
  376. id=55,
  377. filename="some_job.gcode.3mf",
  378. billing_run_id=None,
  379. created_by_id=None,
  380. )
  381. db = _authorization_db(observed_only, dispatched_queue_item_id=None)
  382. assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False
  383. assert (9, "some_job.gcode.3mf") not in main_module._active_prints
  384. @pytest.mark.asyncio
  385. @pytest.mark.parametrize(
  386. "marker",
  387. [
  388. {"billing_run_id": "9f0c2b6e-0000-4000-8000-00000000abcd", "created_by_id": None},
  389. {"billing_run_id": None, "created_by_id": 4},
  390. ],
  391. ids=["billing_run_id", "created_by_id"],
  392. )
  393. async def test_either_dispatch_marker_authorizes_after_a_restart(monkeypatch, marker):
  394. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  395. archive = SimpleNamespace(id=77, filename="some_job.gcode.3mf", **marker)
  396. db = _authorization_db(archive, dispatched_queue_item_id=None)
  397. assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is True
  398. assert main_module._active_prints[(9, "some_job.gcode.3mf")] == 77
  399. # The fast path is rehydrated, so the queue is never consulted.
  400. db.scalar.assert_not_awaited()
  401. @pytest.mark.asyncio
  402. async def test_defers_while_bambuddy_has_a_job_running_on_that_printer(monkeypatch):
  403. """A library-file dispatch has no archive at send time, and the row created for
  404. it moments later by on_print_start carries neither marker. The queue row is the
  405. only durable trace, and it cannot be tied to a subtask_id — so it defers."""
  406. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  407. unmarked = SimpleNamespace(id=56, filename="some_job.gcode.3mf", billing_run_id=None, created_by_id=None)
  408. db = _authorization_db(unmarked, dispatched_queue_item_id=310)
  409. assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
  410. # Deferring must not authorize the print for every later frame.
  411. assert (9, "some_job.gcode.3mf") not in main_module._active_prints
  412. @pytest.mark.asyncio
  413. async def test_defers_when_the_dispatch_has_not_been_archived_yet(monkeypatch):
  414. """Restart during the window between the MQTT send and the 3MF download."""
  415. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  416. db = _authorization_db(None, dispatched_queue_item_id=311)
  417. assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is None
  418. @pytest.mark.asyncio
  419. async def test_foreign_print_with_no_archive_and_no_dispatch_is_unauthorized(monkeypatch):
  420. monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
  421. db = _authorization_db(None, dispatched_queue_item_id=None)
  422. assert await main_module._is_bambuddy_authorized_print(9, _running_state(), db) is False