test_printer_kill_switch.py 20 KB

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