test_user_delete_in_flight_work.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. """Work a deleted user started must not be credited to whoever gets their id.
  2. A running print, a slice, a pipeline run and an open WebSocket all keep the
  3. user id they started with, in memory, for minutes or hours. Written after the
  4. delete, that id is a dangling reference, and on SQLite, which gives a freed id
  5. to the next user created, it is someone else: they are credited with the print
  6. and billed for it, own the sliced file, see the slice job and receive the
  7. deleted user's notifications.
  8. """
  9. from pathlib import Path
  10. from types import SimpleNamespace
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. from httpx import AsyncClient
  14. from sqlalchemy import delete, select
  15. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  16. from backend.app import main
  17. from backend.app.api.routes.library import slice_and_persist, slice_and_persist_as_archive
  18. from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
  19. from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
  20. from backend.app.core.websocket import ws_manager
  21. from backend.app.models.archive import PrintArchive
  22. from backend.app.models.library import LibraryFile
  23. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  24. from backend.app.models.print_queue import PrintQueueItem
  25. from backend.app.models.printer import Printer
  26. from backend.app.models.slicer_pipeline import SlicerPipeline
  27. from backend.app.models.user import User
  28. from backend.app.schemas.slicer import SliceRequest, SliceResponse
  29. from backend.app.services.printer_manager import printer_manager
  30. from backend.app.services.slice_dispatch import SliceJob, slice_dispatch
  31. from backend.app.services.slicer_api import SliceResult
  32. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  33. async def _user(db, name: str, user_id: int | None = None) -> int:
  34. user = User(id=user_id, username=name, password_hash="x", role="user")
  35. db.add(user)
  36. await db.commit()
  37. return user.id
  38. async def _replace_user(db, user_id: int) -> None:
  39. """Delete the user and create the next one on the same id.
  40. AUTOINCREMENT (core/database) now stops SQLite reusing ids; the id is given
  41. by hand, as databases from before gave it, to keep the delete's own cleanup
  42. covered.
  43. """
  44. await db.execute(delete(User).where(User.id == user_id))
  45. await db.commit()
  46. assert await _user(db, "bob", user_id) == user_id
  47. def _slice_job(owner_id: int) -> SliceJob:
  48. job = SliceJob(id=987_654, kind="library_file", source_id=1, source_name="a.3mf", owner_id=owner_id)
  49. slice_dispatch._jobs[job.id] = job
  50. return job
  51. def _slicer_that_runs_while(during):
  52. async def slice_(*args, **kwargs):
  53. await during()
  54. return SliceResult(b"PK\x03\x04 not-a-real-3mf", 3600, 12.5, 4200.0), False
  55. return patch("backend.app.api.routes.library._run_slicer_with_fallback", AsyncMock(side_effect=slice_))
  56. async def test_deleting_a_user_forgets_what_the_app_holds_for_them(async_client: AsyncClient, db_session):
  57. alice = await _user(db_session, "alice")
  58. printer_manager.set_current_print_user(4242, alice, "alice")
  59. main._expected_print_creators[(4242, "a.3mf")] = alice
  60. job = _slice_job(alice)
  61. socket = SimpleNamespace(
  62. state=SimpleNamespace(
  63. bambuddy_principal_user_id=alice,
  64. bambuddy_scope_principal=("alice", None),
  65. bambuddy_printer_scope=None,
  66. ),
  67. close=AsyncMock(),
  68. send_text=AsyncMock(),
  69. )
  70. ws_manager.active_connections.append(socket)
  71. try:
  72. response = await async_client.delete(f"/api/v1/users/{alice}")
  73. assert response.status_code == 204, response.text
  74. # The print ends later; nobody is credited with it.
  75. assert printer_manager.get_current_print_user(4242) is None
  76. # Nor is the print-start email sent to whoever is given the id.
  77. assert (4242, "a.3mf") not in main._expected_print_creators
  78. # The slice job is no longer theirs, so the next user given the id can't poll it.
  79. assert job.owner_id is None
  80. # Their open dashboard is closed, not left to receive the next user's messages.
  81. socket.close.assert_awaited_once_with(code=4401)
  82. assert socket not in ws_manager.active_connections
  83. finally:
  84. printer_manager.clear_current_print_user(4242)
  85. main._expected_print_creators.pop((4242, "a.3mf"), None)
  86. slice_dispatch._jobs.pop(job.id, None)
  87. if socket in ws_manager.active_connections:
  88. ws_manager.active_connections.remove(socket)
  89. async def test_a_slice_finishing_after_its_user_was_deleted_belongs_to_nobody(db_session):
  90. alice = await _user(db_session, "alice")
  91. job = _slice_job(alice)
  92. try:
  93. async def alice_is_deleted_and_bob_created():
  94. slice_dispatch.forget_owner(alice) # what deleting her does
  95. await _replace_user(db_session, alice)
  96. with _slicer_that_runs_while(alice_is_deleted_and_bob_created):
  97. response = await slice_and_persist(
  98. db_session,
  99. model_bytes=b"model",
  100. model_filename="a.3mf",
  101. folder_id=None,
  102. extra_metadata=None,
  103. request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
  104. current_user_id=alice,
  105. job_id=job.id,
  106. )
  107. finally:
  108. slice_dispatch._jobs.pop(job.id, None)
  109. sliced = await db_session.get(LibraryFile, response.library_file_id)
  110. assert sliced.created_by_id is None
  111. async def test_an_archive_slice_finishing_after_its_user_was_deleted_belongs_to_nobody(
  112. db_session, monkeypatch, tmp_path
  113. ):
  114. from backend.app.core.config import settings
  115. monkeypatch.setattr(settings, "base_dir", tmp_path)
  116. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  117. alice = await _user(db_session, "alice")
  118. source = PrintArchive(filename="a.3mf", file_path="archive/a.3mf", file_size=1, status="completed")
  119. db_session.add(source)
  120. await db_session.commit()
  121. job = _slice_job(alice)
  122. try:
  123. async def alice_is_deleted_and_bob_created():
  124. slice_dispatch.forget_owner(alice)
  125. await _replace_user(db_session, alice)
  126. with _slicer_that_runs_while(alice_is_deleted_and_bob_created):
  127. response = await slice_and_persist_as_archive(
  128. db_session,
  129. model_bytes=b"model",
  130. model_filename="a.3mf",
  131. request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
  132. source_archive=source,
  133. current_user_id=alice,
  134. job_id=job.id,
  135. )
  136. finally:
  137. slice_dispatch._jobs.pop(job.id, None)
  138. sliced = await db_session.get(PrintArchive, response.archive_id)
  139. assert sliced.created_by_id is None
  140. async def _run_pipeline_while_its_creator_is_replaced(
  141. db_session, test_engine, tmp_path, *, specific_printer: bool, printer_scope=ALL_PRINTERS
  142. ) -> list[PrintQueueItem]:
  143. alice = await _user(db_session, "alice")
  144. printer = Printer(name="P", serial_number="SPIPE1", ip_address="192.0.2.9", access_code="x", model="X1C")
  145. source = LibraryFile(filename="a.3mf", file_path="library/a.3mf", file_type="3mf", file_size=1)
  146. pipeline = SlicerPipeline(
  147. name="p",
  148. printer_preset_source="local",
  149. printer_preset_id="1",
  150. process_preset_source="local",
  151. process_preset_id="2",
  152. filament_presets_json='[{"source": "local", "id": "3"}]',
  153. )
  154. db_session.add_all([printer, source, pipeline])
  155. await db_session.commit()
  156. if specific_printer:
  157. pipeline.target_kind, pipeline.target_printer_id = "specific_printer", printer.id
  158. else:
  159. pipeline.target_kind, pipeline.target_model_class = "printer_class", "X1C"
  160. run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=1, created_by=alice)
  161. db_session.add(run)
  162. await db_session.commit()
  163. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0))
  164. await db_session.commit()
  165. model = tmp_path / "a.3mf"
  166. model.write_bytes(b"model")
  167. async def slice_and_persist_while_alice_is_replaced(session, **kwargs):
  168. await _replace_user(db_session, alice)
  169. sliced = LibraryFile(filename="a.gcode.3mf", file_path="library/a.gcode.3mf", file_type="3mf", file_size=1)
  170. session.add(sliced)
  171. await session.flush()
  172. return SliceResponse(
  173. library_file_id=sliced.id, name="a", print_time_seconds=1, filament_used_g=1.0, filament_used_mm=1.0
  174. )
  175. orchestrate = _make_orchestration_callable(
  176. run_id=run.id,
  177. pipeline_id=pipeline.id,
  178. src_kind="library_file",
  179. src_id=source.id,
  180. src_filename="a.3mf",
  181. src_path=Path(model),
  182. creator_user_id=alice,
  183. copies=1,
  184. printer_scope=printer_scope,
  185. )
  186. with (
  187. patch(
  188. "backend.app.api.routes.pipeline_runs.async_session",
  189. async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
  190. ),
  191. patch("backend.app.api.routes.library.slice_and_persist", new=slice_and_persist_while_alice_is_replaced),
  192. patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
  193. ):
  194. await orchestrate(1)
  195. statement = select(PrintQueueItem).execution_options(populate_existing=True)
  196. return list((await db_session.execute(statement)).scalars().all())
  197. async def test_a_pipeline_run_finishing_after_its_user_was_deleted_queues_ownerless_copies(
  198. db_session, test_engine, tmp_path
  199. ):
  200. copies = await _run_pipeline_while_its_creator_is_replaced(db_session, test_engine, tmp_path, specific_printer=True)
  201. assert [copy.created_by_id for copy in copies] == [None]
  202. async def test_any_model_copies_of_a_deleted_restricted_user_wait_for_an_admin(db_session, test_engine, tmp_path):
  203. """As delete_user does with such jobs that already exist: without an owner the
  204. scheduler would run them on any printer of the model, not only on theirs."""
  205. copies = await _run_pipeline_while_its_creator_is_replaced(
  206. db_session, test_engine, tmp_path, specific_printer=False, printer_scope=PrinterScope(frozenset({1}))
  207. )
  208. assert [(copy.printer_id, copy.created_by_id, copy.manual_start) for copy in copies] == [(None, None, True)]
  209. async def test_an_ownerless_run_is_not_announced_to_everyone(db_session):
  210. """broadcast_to_user(None) goes to every connection; with auth on, a run whose
  211. user was deleted is nobody's to watch (the runs page polls)."""
  212. from backend.app.api.routes.pipeline_runs import _publish_run_event
  213. from backend.app.api.routes.settings import set_setting
  214. run = PipelineRun(copies=1, created_by=None)
  215. db_session.add(run)
  216. await db_session.commit()
  217. with patch("backend.app.api.routes.pipeline_runs.ws_manager.broadcast_to_user", new=AsyncMock()) as sent:
  218. await _publish_run_event(db_session, run)
  219. assert sent.await_count == 1 # auth off: one user, everything is theirs
  220. await set_setting(db_session, "auth_enabled", "true")
  221. await db_session.commit()
  222. await _publish_run_event(db_session, run)
  223. assert sent.await_count == 1
  224. async def test_a_finished_print_is_credited_to_its_user_unless_they_were_deleted(db_session):
  225. from backend.app.main import _print_user_id_if_still_there
  226. alice = await _user(db_session, "alice")
  227. # Renamed while the print ran: still theirs.
  228. assert await _print_user_id_if_still_there(db_session, {"user_id": alice, "username": "old name"}) == alice
  229. await db_session.execute(delete(User).where(User.id == alice))
  230. await db_session.commit()
  231. assert await _print_user_id_if_still_there(db_session, {"user_id": alice, "username": "alice"}) is None
  232. async def test_a_queue_print_whose_user_was_deleted_during_the_upload_is_nobodys(db_session, test_engine, monkeypatch):
  233. """The scheduler loaded the user and the item before the upload; after it,
  234. neither may bring the deleted user back (and with them, whoever gets the id)."""
  235. from backend.app.services import print_scheduler as scheduler_module
  236. from backend.app.services.print_scheduler import PrintScheduler
  237. alice = await _user(db_session, "alice")
  238. printer = Printer(name="P", serial_number="SUPL1", ip_address="192.0.2.10", access_code="x")
  239. db_session.add(printer)
  240. await db_session.commit()
  241. item = PrintQueueItem(printer_id=printer.id, position=1, created_by_id=alice)
  242. db_session.add(item)
  243. await db_session.commit()
  244. assert await db_session.get(User, alice) is not None # held by the dispatch, as _start_print does
  245. other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  246. async with other() as admin_db: # meanwhile, through the API
  247. await admin_db.execute(delete(User).where(User.id == alice))
  248. await admin_db.commit()
  249. admin_db.add(User(username="bob", password_hash="x", role="user"))
  250. await admin_db.commit()
  251. credited: list = []
  252. monkeypatch.setattr(scheduler_module.printer_manager, "set_current_print_user", lambda *args: credited.append(args))
  253. await PrintScheduler()._propagate_owner_to_printer_manager(db_session, item)
  254. assert credited == []