test_queue_start_user_attribution.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """Regression tests for #1670: queue manual-start path lost user attribution.
  2. Before the fix, a VP-uploaded queue item (created over FTP, so unattributed)
  3. that was then started by an authenticated user via the `/start` button would
  4. land in the PrintLogEntry table with `created_by_username = NULL` because
  5. the scheduler dispatch path never set `current_print_user` and the `/start`
  6. route didn't record the clicker.
  7. The fix is two-sided:
  8. - `POST /queue/{id}/start` credits the clicker as `created_by_id` when
  9. no prior owner is set (does NOT overwrite an existing owner — a
  10. UI-added queue item's original uploader keeps attribution).
  11. - `PrintScheduler._start_print` propagates `item.created_by_id` into
  12. `printer_manager.set_current_print_user` so the print-complete callback
  13. can write the username into the PrintLogEntry row.
  14. These tests pin both halves so a future refactor can't silently regress
  15. either one back to "blank User column."
  16. """
  17. from __future__ import annotations
  18. import pytest
  19. from httpx import AsyncClient
  20. from sqlalchemy import select
  21. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  22. from backend.app.models.print_queue import PrintQueueItem
  23. async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
  24. """Fresh-session DB read. The `db_session` fixture's connection can
  25. look stale after a route call dispatches through its own session via
  26. `Depends(get_db)`, so verification reads use a new session against the
  27. same engine."""
  28. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  29. async with maker() as fresh:
  30. return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  31. async def _enable_auth_with_admin(async_client: AsyncClient) -> tuple[str, dict]:
  32. """Boot the app's auth setup and return (admin_token, admin_user)."""
  33. await async_client.post(
  34. "/api/v1/auth/setup",
  35. json={
  36. "auth_enabled": True,
  37. "admin_username": "queue1670admin",
  38. "admin_password": "AdminPass1!",
  39. },
  40. )
  41. login = await async_client.post(
  42. "/api/v1/auth/login",
  43. json={"username": "queue1670admin", "password": "AdminPass1!"},
  44. )
  45. body = login.json()
  46. return body["access_token"], body["user"]
  47. @pytest.fixture
  48. async def queue_item(db_session):
  49. """A pending, manual-start, UNATTRIBUTED queue item — mirrors what the
  50. VP-queue path produces (FTP upload has no user, manual_start is the
  51. Queue-mode default)."""
  52. from backend.app.models.archive import PrintArchive
  53. from backend.app.models.printer import Printer
  54. printer = Printer(
  55. name="P2S Test",
  56. ip_address="192.168.2.201",
  57. serial_number="00M00A1234567890",
  58. access_code="12345678",
  59. model="P2S",
  60. )
  61. db_session.add(printer)
  62. await db_session.commit()
  63. await db_session.refresh(printer)
  64. archive = PrintArchive(
  65. filename="Plate_1.gcode.3mf",
  66. print_name="Plate 1",
  67. file_path="/tmp/queue1670_plate.3mf", # nosec B108
  68. file_size=1024,
  69. content_hash="queue1670hash",
  70. status="completed",
  71. )
  72. db_session.add(archive)
  73. await db_session.commit()
  74. await db_session.refresh(archive)
  75. item = PrintQueueItem(
  76. printer_id=printer.id,
  77. archive_id=archive.id,
  78. status="pending",
  79. position=1,
  80. manual_start=True,
  81. created_by_id=None, # unattributed — VP-queue shape
  82. )
  83. db_session.add(item)
  84. await db_session.commit()
  85. await db_session.refresh(item)
  86. return item
  87. class TestStartCreditsTheClicker:
  88. """`/start` writes the clicker's id to `created_by_id` when none was set."""
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_start_writes_created_by_id_when_unattributed(
  92. self, async_client: AsyncClient, test_engine, queue_item
  93. ):
  94. admin_token, admin_user = await _enable_auth_with_admin(async_client)
  95. response = await async_client.post(
  96. f"/api/v1/queue/{queue_item.id}/start",
  97. headers={"Authorization": f"Bearer {admin_token}"},
  98. )
  99. assert response.status_code == 200
  100. refreshed = await _read_item(test_engine, queue_item.id)
  101. assert refreshed.created_by_id == admin_user["id"]
  102. assert refreshed.manual_start is False
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_start_preserves_existing_owner(self, async_client: AsyncClient, db_session, test_engine, queue_item):
  106. """A queue item that was created by user A and then started by user B
  107. keeps user A's attribution — the original uploader's claim is stronger
  108. than the dispatcher's. (Matches the standard ownership semantics in
  109. `auth.py::require_ownership_permission`.)"""
  110. admin_token, admin_user = await _enable_auth_with_admin(async_client)
  111. # Pre-set a different owner on the queue item using a fresh session
  112. # (the test's `db_session` is detached from the route's session pool).
  113. from backend.app.models.user import User
  114. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  115. async with maker() as fresh:
  116. prior_owner = User(username="prior-owner", password_hash="x")
  117. fresh.add(prior_owner)
  118. await fresh.flush()
  119. prior_owner_id = prior_owner.id
  120. item = (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == queue_item.id))).scalar_one()
  121. item.created_by_id = prior_owner_id
  122. await fresh.commit()
  123. response = await async_client.post(
  124. f"/api/v1/queue/{queue_item.id}/start",
  125. headers={"Authorization": f"Bearer {admin_token}"},
  126. )
  127. assert response.status_code == 200
  128. refreshed = await _read_item(test_engine, queue_item.id)
  129. # Prior owner survives — `/start` did not promote the clicker.
  130. assert refreshed.created_by_id == prior_owner_id
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_start_with_auth_disabled_leaves_created_by_id_null(
  134. self, async_client: AsyncClient, test_engine, queue_item
  135. ):
  136. """When auth is off the route's user dep returns None — the item stays
  137. unattributed (no synthetic 'system' user invented). Regression guard
  138. in case a future refactor accidentally invents a placeholder user id."""
  139. response = await async_client.post(f"/api/v1/queue/{queue_item.id}/start")
  140. assert response.status_code == 200
  141. refreshed = await _read_item(test_engine, queue_item.id)
  142. assert refreshed.created_by_id is None
  143. class TestSchedulerPropagatesOwnerToPrinterManager:
  144. """`PrintScheduler._propagate_owner_to_printer_manager` looks up the
  145. user row by `created_by_id` and forwards it into
  146. `printer_manager.set_current_print_user` so the print-complete callback
  147. can write the username into PrintLogEntry."""
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_propagates_when_created_by_id_resolves_to_user(self, db_session, queue_item, monkeypatch):
  151. from backend.app.models.user import User
  152. from backend.app.services import print_scheduler as scheduler_module
  153. from backend.app.services.print_scheduler import PrintScheduler
  154. user = User(username="clickeruser", password_hash="x", is_active=True)
  155. db_session.add(user)
  156. await db_session.commit()
  157. await db_session.refresh(user)
  158. queue_item.created_by_id = user.id
  159. db_session.add(queue_item)
  160. await db_session.commit()
  161. await db_session.refresh(queue_item)
  162. captured: list[tuple[int, int, str]] = []
  163. monkeypatch.setattr(
  164. scheduler_module.printer_manager,
  165. "set_current_print_user",
  166. lambda printer_id, uid, username: captured.append((printer_id, uid, username)),
  167. )
  168. await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
  169. assert captured == [(queue_item.printer_id, user.id, "clickeruser")]
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_noop_when_created_by_id_is_none(self, db_session, queue_item, monkeypatch):
  173. """VP-uploaded queue items that never got manual-started (e.g.
  174. auto-dispatch) carry no owner — the helper must stay silent rather
  175. than synthesise a placeholder user."""
  176. from backend.app.services import print_scheduler as scheduler_module
  177. from backend.app.services.print_scheduler import PrintScheduler
  178. assert queue_item.created_by_id is None
  179. captured: list = []
  180. monkeypatch.setattr(
  181. scheduler_module.printer_manager,
  182. "set_current_print_user",
  183. lambda *args: captured.append(args),
  184. )
  185. await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
  186. assert captured == []
  187. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_noop_when_user_row_missing(self, db_session, queue_item, monkeypatch):
  191. """`created_by_id` points at a user that's since been deleted —
  192. helper must not crash the dispatch. The print log row will just be
  193. un-credited for this run, same as auth-disabled."""
  194. from backend.app.services import print_scheduler as scheduler_module
  195. from backend.app.services.print_scheduler import PrintScheduler
  196. queue_item.created_by_id = 999_999 # no such user row
  197. db_session.add(queue_item)
  198. await db_session.commit()
  199. await db_session.refresh(queue_item)
  200. captured: list = []
  201. monkeypatch.setattr(
  202. scheduler_module.printer_manager,
  203. "set_current_print_user",
  204. lambda *args: captured.append(args),
  205. )
  206. # Must not raise — the dispatch loop would otherwise lose the whole
  207. # queue item to an exception trace for what's effectively a missing
  208. # foreign key.
  209. await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
  210. assert captured == []