test_printer_delete_scopes.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. """Deleting a printer must not hand what was limited to it to other printers.
  2. A notification provider limited to a printer became one for all printers when
  3. the printer was deleted (printer_id set to NULL, which means all): every other
  4. printer's events and camera pictures went to it. An API key limited to the
  5. printer kept its id, which SQLite gives to the next printer created.
  6. """
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import select, text
  10. from backend.app.core.database import _drop_deleted_printers_from_limits, _repair_sqlite_orphans, load_all_models
  11. from backend.app.models.api_key import APIKey
  12. from backend.app.models.notification import NotificationProvider
  13. from backend.app.models.printer import Printer
  14. from backend.app.services.notification_service import notification_service
  15. pytestmark = [pytest.mark.integration]
  16. async def _printers(db) -> tuple[int, int]:
  17. a = Printer(name="A", serial_number="SCOPE-A", ip_address="192.0.2.21", access_code="x")
  18. b = Printer(name="B", serial_number="SCOPE-B", ip_address="192.0.2.22", access_code="x")
  19. db.add_all([a, b])
  20. await db.commit()
  21. return a.id, b.id
  22. async def _providers(db, a: int, b: int) -> None:
  23. db.add_all(
  24. [
  25. NotificationProvider(
  26. name=name,
  27. provider_type="webhook",
  28. config="{}",
  29. printer_id=printer_id,
  30. enabled=True,
  31. on_print_start=True,
  32. )
  33. for name, printer_id in (("only A", a), ("only B", b), ("all printers", None))
  34. ]
  35. )
  36. await db.commit()
  37. async def _keys(db, a: int, b: int) -> None:
  38. db.add_all(
  39. [
  40. APIKey(name=name, key_hash="h", key_prefix=f"bb_{name[:6]}", printer_ids=printer_ids)
  41. for name, printer_ids in (("A and B", [a, b]), ("only B", [b]), ("all printers", None))
  42. ]
  43. )
  44. await db.commit()
  45. async def _scopes(db) -> tuple[dict, dict]:
  46. providers = dict((await db.execute(select(NotificationProvider.name, NotificationProvider.printer_id))).all())
  47. statement = select(APIKey.name, APIKey.printer_ids).execution_options(populate_existing=True)
  48. keys = dict((await db.execute(statement)).all())
  49. return providers, keys
  50. async def test_deleting_a_printer_removes_what_was_limited_to_it(async_client: AsyncClient, db_session):
  51. a, b = await _printers(db_session)
  52. await _providers(db_session, a, b)
  53. await _keys(db_session, a, b)
  54. response = await async_client.delete(f"/api/v1/printers/{b}")
  55. assert response.status_code == 200, response.text
  56. providers, keys = await _scopes(db_session)
  57. assert providers == {"only A": a, "all printers": None}
  58. # A key limited to no printer left may use none ([] means none, None all).
  59. assert keys == {"A and B": [a], "only B": [], "all printers": None}
  60. recipients = await notification_service._get_providers_for_event(db_session, "on_print_start", a)
  61. assert sorted(p.name for p in recipients) == ["all printers", "only A"]
  62. async def test_the_startup_repair_takes_deleted_printers_out_of_api_keys(db_session):
  63. """SQLite installs that deleted a printer before this fix: keys kept its id.
  64. (Providers weren't left pointing at it; the delete made them all-printer
  65. ones, which nothing can tell from the intended ones.)
  66. """
  67. a, b = await _printers(db_session)
  68. await _keys(db_session, a, b)
  69. await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b}) # the old way
  70. await db_session.commit()
  71. await _repair_sqlite_orphans(await db_session.connection())
  72. await _drop_deleted_printers_from_limits(await db_session.connection())
  73. await db_session.commit()
  74. assert (await _scopes(db_session))[1] == {"A and B": [a], "only B": [], "all printers": None}
  75. def test_a_provider_goes_with_its_printer_on_postgresql_too():
  76. """SET NULL would make it a provider for all printers."""
  77. (fk,) = load_all_models().tables["notification_providers"].c.printer_id.foreign_keys
  78. assert fk.ondelete == "CASCADE"
  79. async def test_a_notify_provider_of_the_printer_is_retired_on_the_service_too(async_client: AsyncClient, db_session):
  80. """Deleted as the provider delete route does it: its phone widgets and live
  81. activities are cleaned up remotely, not left behind by a cascade."""
  82. from unittest.mock import AsyncMock, MagicMock, patch
  83. _, b = await _printers(db_session)
  84. provider = NotificationProvider(name="phones", provider_type="notify", config="{}", printer_id=b)
  85. db_session.add(provider)
  86. await db_session.commit()
  87. provider_id = provider.id
  88. routes = "backend.app.api.routes.notifications"
  89. with (
  90. patch(f"{routes}.notify_live_activities.schedule_cleanup", AsyncMock()) as activities,
  91. patch(f"{routes}.notify_widgets.schedule_cleanup", AsyncMock()) as widgets,
  92. patch(f"{routes}._notify_providers_changed", MagicMock()) as changed,
  93. ):
  94. response = await async_client.delete(f"/api/v1/printers/{b}")
  95. assert response.status_code == 200, response.text
  96. assert activities.await_args.args[0] == provider_id
  97. assert widgets.await_args.args[0] == provider_id
  98. changed.assert_called_once()
  99. assert (await _scopes(db_session))[0] == {}
  100. async def test_a_key_left_with_no_printer_stays_so_after_a_restart(db_session):
  101. """[] reaches no printer; a start-up step that turned it into NULL (all) undid that."""
  102. from backend.app.core.database import run_migrations
  103. db_session.add(APIKey(name="none", key_hash="h", key_prefix="bb_none", printer_ids=[]))
  104. await db_session.commit()
  105. await run_migrations(await db_session.connection())
  106. await db_session.commit()
  107. assert (await _scopes(db_session))[1] == {"none": []}
  108. async def test_open_connections_lose_the_deleted_printer(async_client: AsyncClient, db_session):
  109. """Their printer access is worked out at connect; SQLite gives the id to the next printer."""
  110. from unittest.mock import AsyncMock, patch
  111. _, b = await _printers(db_session)
  112. with patch("backend.app.api.routes.printers.ws_manager.refresh_printer_scopes", AsyncMock()) as refresh:
  113. response = await async_client.delete(f"/api/v1/printers/{b}")
  114. assert response.status_code == 200, response.text
  115. refresh.assert_awaited_once()
  116. async def test_a_virtual_printer_aimed_at_the_printer_goes_with_it(async_client: AsyncClient, db_session):
  117. """Without a target a queue-mode one takes jobs for any printer of its model."""
  118. from unittest.mock import AsyncMock, patch
  119. from backend.app.models.virtual_printer import VirtualPrinter
  120. a, b = await _printers(db_session)
  121. db_session.add_all(
  122. [
  123. VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800011"),
  124. VirtualPrinter(name="to A", mode="queue", target_printer_id=a, serial_suffix="391800012"),
  125. ]
  126. )
  127. await db_session.commit()
  128. manager = "backend.app.services.virtual_printer.virtual_printer_manager"
  129. with (
  130. patch(f"{manager}.remove_instance", AsyncMock()) as removed,
  131. patch(f"{manager}.sync_from_db", AsyncMock()),
  132. ):
  133. response = await async_client.delete(f"/api/v1/printers/{b}")
  134. assert response.status_code == 200, response.text
  135. left = (await db_session.execute(select(VirtualPrinter.name).execution_options(populate_existing=True))).all()
  136. assert [name for (name,) in left] == ["to A"]
  137. removed.assert_awaited_once()
  138. async def test_obico_detection_drops_the_deleted_printer(async_client: AsyncClient, db_session):
  139. """SQLite would otherwise enrol the next printer given its id."""
  140. from backend.app.models.settings import Settings
  141. a, b = await _printers(db_session)
  142. c = Printer(name="C", serial_number="SCOPE-C", ip_address="192.0.2.23", access_code="x")
  143. db_session.add(c)
  144. await db_session.commit()
  145. db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},{b},{c.id}]"))
  146. await db_session.commit()
  147. response = await async_client.delete(f"/api/v1/printers/{b}")
  148. assert response.status_code == 200, response.text
  149. statement = select(Settings.value).where(Settings.key == "obico_enabled_printers")
  150. # Written as the settings page writes it, or it reads as an unsaved change and saves again.
  151. assert (await db_session.execute(statement.execution_options(populate_existing=True))).scalar() == f"[{a},{c.id}]"
  152. @pytest.mark.sqlite_only # stages stale ids with a raw delete, which foreign keys refuse or null out
  153. async def test_the_startup_repair_drops_deleted_printers_from_obico_and_virtual_printers(db_session):
  154. from backend.app.models.settings import Settings
  155. from backend.app.models.virtual_printer import VirtualPrinter
  156. a, b = await _printers(db_session)
  157. db_session.add_all(
  158. [
  159. Settings(key="obico_enabled_printers", value=f"[{b}]"),
  160. VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800013"),
  161. VirtualPrinter(name="any", mode="queue", target_printer_id=None, serial_suffix="391800014"),
  162. ]
  163. )
  164. await db_session.commit()
  165. await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b}) # the old way
  166. await db_session.commit()
  167. await _repair_sqlite_orphans(await db_session.connection())
  168. await _drop_deleted_printers_from_limits(await db_session.connection())
  169. await db_session.commit()
  170. obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
  171. assert obico == "[]" # none, not all ("" would be all)
  172. left = (await db_session.execute(select(VirtualPrinter.name))).all()
  173. assert [name for (name,) in left] == ["any"]
  174. async def test_its_smart_plugs_go_with_it(async_client: AsyncClient, db_session):
  175. """Without a printer a plug is everyone's: users and keys limited to other
  176. printers could switch the hardware it still powers."""
  177. from datetime import datetime
  178. from unittest.mock import patch
  179. from backend.app.models.smart_plug import SmartPlug
  180. from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
  181. a, b = await _printers(db_session)
  182. plugs = [
  183. SmartPlug(name="B's", plug_type="mqtt", mqtt_topic="plugs/b", printer_id=b),
  184. SmartPlug(name="A's", plug_type="tasmota", ip_address="192.0.2.31", printer_id=a),
  185. SmartPlug(name="spare", plug_type="tasmota", ip_address="192.0.2.32", printer_id=None),
  186. ]
  187. db_session.add_all(plugs)
  188. await db_session.commit()
  189. b_plug = plugs[0].id
  190. db_session.add(SmartPlugEnergySnapshot(plug_id=b_plug, recorded_at=datetime(2026, 10, 1), lifetime_kwh=1.0))
  191. await db_session.commit()
  192. with patch("backend.app.api.routes.smart_plugs.mqtt_relay.smart_plug_service.unsubscribe") as unsubscribe:
  193. response = await async_client.delete(f"/api/v1/printers/{b}")
  194. assert response.status_code == 200, response.text
  195. left = (await db_session.execute(select(SmartPlug.name).execution_options(populate_existing=True))).all()
  196. assert sorted(name for (name,) in left) == ["A's", "spare"]
  197. assert (await db_session.execute(select(SmartPlugEnergySnapshot.id))).all() == []
  198. unsubscribe.assert_called_once_with(b_plug)
  199. def test_a_smart_plug_goes_with_its_printer_on_postgresql_too():
  200. (fk,) = load_all_models().tables["smart_plugs"].c.printer_id.foreign_keys
  201. assert fk.ondelete == "CASCADE"
  202. @pytest.mark.parametrize("method", ["create", "update"])
  203. async def test_an_api_key_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session, method):
  204. """SQLite would give that id to the next printer created, and the key with it."""
  205. a, _ = await _printers(db_session)
  206. if method == "create":
  207. response = await async_client.post("/api/v1/api-keys/", json={"name": "k", "printer_ids": [a, 999]})
  208. else:
  209. created = await async_client.post("/api/v1/api-keys/", json={"name": "k", "printer_ids": [a]})
  210. response = await async_client.patch(f"/api/v1/api-keys/{created.json()['id']}", json={"printer_ids": [999]})
  211. assert response.status_code == 400, response.text
  212. assert [ids for _, ids in (await _scopes(db_session))[1].items()] in ([], [[a]])
  213. @pytest.mark.parametrize("method", ["create", "update"])
  214. async def test_a_provider_cant_be_limited_to_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session, method):
  215. body = {"name": "p", "provider_type": "webhook", "config": {"webhook_url": "http://192.0.2.99/hook"}}
  216. if method == "create":
  217. response = await async_client.post("/api/v1/notifications/", json={**body, "printer_id": 999})
  218. else:
  219. created = await async_client.post("/api/v1/notifications/", json=body)
  220. response = await async_client.patch(f"/api/v1/notifications/{created.json()['id']}", json={"printer_id": 999})
  221. assert response.status_code == 400, response.text
  222. assert 999 not in (await _scopes(db_session))[0].values()
  223. async def test_obico_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session):
  224. """A settings page still open from before a delete would save the deleted id back."""
  225. a, _ = await _printers(db_session)
  226. response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a}, 999]"})
  227. assert response.status_code == 400, response.text
  228. async def test_a_slicer_pipeline_aimed_at_the_printer_loses_it(async_client: AsyncClient, db_session):
  229. """SQLite would otherwise run it on the next printer given the id."""
  230. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  231. from backend.app.models.slicer_pipeline import SlicerPipeline
  232. _, b = await _printers(db_session)
  233. pipeline = SlicerPipeline(
  234. name="p",
  235. printer_preset_source="local",
  236. printer_preset_id="1",
  237. process_preset_source="local",
  238. process_preset_id="2",
  239. filament_presets_json="[]",
  240. target_kind="specific_printer",
  241. target_printer_id=b,
  242. )
  243. db_session.add(pipeline)
  244. await db_session.commit()
  245. run = PipelineRun(pipeline_id=pipeline.id, copies=1)
  246. db_session.add(run)
  247. await db_session.commit()
  248. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, assigned_printer_id=b))
  249. await db_session.commit()
  250. response = await async_client.delete(f"/api/v1/printers/{b}")
  251. assert response.status_code == 200, response.text
  252. targets = select(SlicerPipeline.target_printer_id).execution_options(populate_existing=True)
  253. assert (await db_session.execute(targets)).scalar() is None
  254. assigned = select(PipelineJob.assigned_printer_id).execution_options(populate_existing=True)
  255. assert (await db_session.execute(assigned)).scalar() is None
  256. async def test_obico_ids_already_stored_dont_block_a_save(async_client: AsyncClient, db_session):
  257. """PostgreSQL kept the id of a printer deleted before deletes dropped it, and
  258. the settings page sends the whole list back: every save would be refused."""
  259. from backend.app.models.settings import Settings
  260. a, _ = await _printers(db_session)
  261. db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},999]"))
  262. await db_session.commit()
  263. response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},999]"})
  264. assert response.status_code == 200, response.text
  265. response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},998]"})
  266. assert response.status_code == 400, response.text
  267. @pytest.mark.sqlite_only # stages stale ids with a raw delete, which foreign keys refuse or null out
  268. async def test_the_startup_trim_of_printer_lists_runs_on_postgresql_too(db_session):
  269. """PostgreSQL holds stale ids too: kept by its own earlier deletes, or copied
  270. from a SQLite backup, after which its sequence hands out the deleted id again."""
  271. from unittest.mock import patch
  272. from backend.app.models.settings import Settings
  273. from backend.app.models.virtual_printer import VirtualPrinter
  274. a, b = await _printers(db_session)
  275. await _keys(db_session, a, b)
  276. db_session.add_all(
  277. [
  278. Settings(key="obico_enabled_printers", value=f"[{a},{b}]"),
  279. VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800016"),
  280. VirtualPrinter(name="to A", mode="queue", target_printer_id=a, serial_suffix="391800017"),
  281. ]
  282. )
  283. await db_session.commit()
  284. await db_session.execute(text("DELETE FROM printers WHERE id = :id"), {"id": b}) # the old way
  285. await db_session.commit()
  286. with patch("backend.app.core.database.is_sqlite", return_value=False):
  287. await _drop_deleted_printers_from_limits(await db_session.connection())
  288. await db_session.commit()
  289. assert (await _scopes(db_session))[1] == {"A and B": [a], "only B": [], "all printers": None}
  290. obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
  291. assert obico == f"[{a}]"
  292. left = (await db_session.execute(select(VirtualPrinter.name))).all()
  293. assert [name for (name,) in left] == ["to A"]
  294. async def test_a_deleted_virtual_printer_isnt_rebuilt_from_the_old_settings(db_session):
  295. """The move from the single virtual printer settings ran at every start with
  296. no virtual printers, rebuilding one aimed at the old target id."""
  297. from backend.app.core.database import run_migrations
  298. from backend.app.models.settings import Settings
  299. from backend.app.models.virtual_printer import VirtualPrinter
  300. _, b = await _printers(db_session)
  301. db_session.add_all(
  302. [
  303. Settings(key="virtual_printer_enabled", value="true"),
  304. Settings(key="virtual_printer_target_printer_id", value=str(b)),
  305. VirtualPrinter(name="to B", mode="proxy", target_printer_id=b, serial_suffix="391800015"),
  306. ]
  307. )
  308. await db_session.commit()
  309. await run_migrations(await db_session.connection())
  310. await db_session.commit()
  311. await db_session.execute(text("DELETE FROM virtual_printers"))
  312. await db_session.commit()
  313. await run_migrations(await db_session.connection())
  314. await db_session.commit()
  315. assert (await db_session.execute(select(VirtualPrinter.id))).all() == []
  316. async def test_a_pipeline_cant_be_aimed_at_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session):
  317. from backend.app.models.slicer_pipeline import SlicerPipeline
  318. a, _ = await _printers(db_session)
  319. pipeline = SlicerPipeline(
  320. name="p",
  321. printer_preset_source="local",
  322. printer_preset_id="1",
  323. process_preset_source="local",
  324. process_preset_id="2",
  325. filament_presets_json='[{"source": "local", "id": "3"}]',
  326. )
  327. db_session.add(pipeline)
  328. await db_session.commit()
  329. response = await async_client.put(f"/api/v1/slicer-pipelines/{pipeline.id}", json={"target_printer_id": 999})
  330. assert response.status_code == 400, response.text
  331. response = await async_client.put(f"/api/v1/slicer-pipelines/{pipeline.id}", json={"target_printer_id": a})
  332. assert response.status_code == 200, response.text
  333. async def test_a_settings_restore_drops_printers_that_are_gone_from_obico(db_session):
  334. """A backup taken before a printer delete still lists it."""
  335. from backend.app.models.settings import Settings
  336. from backend.app.services.github_restore import _CategoryTally, github_restore_service
  337. a, _ = await _printers(db_session)
  338. await github_restore_service._restore_settings(
  339. db_session, {"settings": {"obico_enabled_printers": f"[{a},999]"}}, overwrite=True, tally=_CategoryTally()
  340. )
  341. await db_session.commit()
  342. obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
  343. assert obico == f"[{a}]"
  344. @pytest.mark.parametrize("via", ["printer", "plug"])
  345. async def test_a_deleted_plugs_pending_auto_off_is_cancelled(async_client: AsyncClient, db_session, via):
  346. """It holds the plug's address and the printer id: it would switch the outlet
  347. later and, on SQLite, read and mark offline the next printer given the id."""
  348. import asyncio
  349. from backend.app.models.smart_plug import SmartPlug
  350. from backend.app.services.smart_plug_manager import smart_plug_manager
  351. _, b = await _printers(db_session)
  352. plug = SmartPlug(name="B's", plug_type="tasmota", ip_address="192.0.2.33", printer_id=b)
  353. db_session.add(plug)
  354. await db_session.commit()
  355. pending = asyncio.create_task(asyncio.sleep(3600))
  356. smart_plug_manager._pending_off[plug.id] = pending
  357. try:
  358. url = f"/api/v1/printers/{b}" if via == "printer" else f"/api/v1/smart-plugs/{plug.id}"
  359. response = await async_client.delete(url)
  360. assert response.status_code == 200, response.text
  361. await asyncio.sleep(0)
  362. assert pending.cancelled()
  363. assert plug.id not in smart_plug_manager._pending_off
  364. finally:
  365. pending.cancel()
  366. smart_plug_manager._pending_off.pop(plug.id, None)