test_printer_kill_switch.py 19 KB

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