test_queue_creation_attribution.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """Regression tests: queue items created outside POST /queue/ lost their owner.
  2. `PrintQueueItem.created_by_id` is what the `queue:read_own` / `queue:update_own` /
  3. `queue:delete_own` permissions filter on (`api/routes/print_queue.py`). Two
  4. creation paths never set it, so the rows they produced were ownerless:
  5. - `POST /library/files/add-to-queue` — the bulk "Add to queue" action on the
  6. Library page. The route already required `Permission.QUEUE_CREATE` but bound
  7. the dependency to `_` and threw the user away, so a non-admin who queued
  8. files from the Library could not then see them in their own queue.
  9. - `POST /webhook/queue/add` — API-key inbound. `APIKey.user_id` records the
  10. key's owner, which is the acting identity for everything else the key does.
  11. Ownerless rows are still legitimate for callers with no user behind them (auth
  12. disabled, virtual-printer FTP uploads, legacy keys minted before per-user
  13. ownership), so the tests pin those cases too — the fix must not invent a
  14. placeholder user id. Compare `test_queue_start_user_attribution.py`, which pins
  15. the same NULL-is-meaningful contract on the `/start` path.
  16. """
  17. from __future__ import annotations
  18. from pathlib import Path
  19. from uuid import uuid4
  20. import pytest
  21. from httpx import AsyncClient
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  24. from backend.app.core.auth import generate_api_key
  25. from backend.app.core.config import settings as app_settings
  26. from backend.app.models.api_key import APIKey
  27. from backend.app.models.group import Group
  28. from backend.app.models.print_queue import PrintQueueItem
  29. from backend.app.models.user import User
  30. async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
  31. """Fresh-session DB read — the `db_session` fixture's connection can look
  32. stale after a route call dispatches through its own `Depends(get_db)`
  33. session. Same helper shape as test_queue_start_user_attribution.py."""
  34. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  35. async with maker() as fresh:
  36. return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  37. async def _enable_auth_with_admin(client: AsyncClient, username: str) -> tuple[str, dict]:
  38. """Boot auth setup and return (bearer_token, user_dict)."""
  39. await client.post(
  40. "/api/v1/auth/setup",
  41. json={
  42. "auth_enabled": True,
  43. "admin_username": username,
  44. "admin_password": "AdminPass1!",
  45. },
  46. )
  47. login = await client.post(
  48. "/api/v1/auth/login",
  49. json={"username": username, "password": "AdminPass1!"},
  50. )
  51. body = login.json()
  52. return body["access_token"], body["user"]
  53. @pytest.fixture
  54. async def sliced_library_file(db_session):
  55. """A library file that passes both of add-to-queue's gates: the filename
  56. must look sliced, and the bytes must actually exist under `base_dir` (the
  57. route rejects rows whose file is missing from disk)."""
  58. from backend.app.models.library import LibraryFile
  59. # Unique: the checkout's library folder is shared by every xdist worker.
  60. rel_path = f"archive/library/files/attribution_probe_{uuid4().hex}.gcode.3mf"
  61. abs_path = Path(app_settings.base_dir) / rel_path
  62. abs_path.parent.mkdir(parents=True, exist_ok=True)
  63. abs_path.write_bytes(b"probe")
  64. lib_file = LibraryFile(
  65. filename="attribution_probe.gcode.3mf",
  66. file_path=rel_path,
  67. file_size=5,
  68. file_type="3mf",
  69. )
  70. db_session.add(lib_file)
  71. await db_session.commit()
  72. await db_session.refresh(lib_file)
  73. yield lib_file
  74. abs_path.unlink(missing_ok=True)
  75. class TestLibraryAddToQueueAttribution:
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_credits_the_authenticated_user(self, async_client: AsyncClient, test_engine, sliced_library_file):
  79. """The Library bulk-add is the path a user reaches for when queueing
  80. many files at once — exactly the case where losing attribution hurts."""
  81. token, user = await _enable_auth_with_admin(async_client, "libqueueadmin")
  82. response = await async_client.post(
  83. "/api/v1/library/files/add-to-queue",
  84. json={"file_ids": [sliced_library_file.id]},
  85. headers={"Authorization": f"Bearer {token}"},
  86. )
  87. assert response.status_code == 200
  88. added = response.json()["added"]
  89. assert len(added) == 1
  90. item = await _read_item(test_engine, added[0]["queue_item_id"])
  91. assert item.created_by_id == user["id"]
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_auth_disabled_leaves_item_ownerless(
  95. self, async_client: AsyncClient, test_engine, sliced_library_file
  96. ):
  97. """With auth off the permission dep yields None. The row must stay
  98. NULL rather than gaining a synthetic owner."""
  99. response = await async_client.post(
  100. "/api/v1/library/files/add-to-queue",
  101. json={"file_ids": [sliced_library_file.id]},
  102. )
  103. assert response.status_code == 200
  104. added = response.json()["added"]
  105. assert len(added) == 1
  106. item = await _read_item(test_engine, added[0]["queue_item_id"])
  107. assert item.created_by_id is None
  108. class TestWebhookQueueAddAttribution:
  109. @pytest.fixture
  110. async def printer_and_archive(self, db_session):
  111. from backend.app.models.archive import PrintArchive
  112. from backend.app.models.printer import Printer
  113. printer = Printer(
  114. name="Webhook Target",
  115. ip_address="192.168.2.202",
  116. serial_number="00M00A9876543210",
  117. access_code="12345678",
  118. model="P1S",
  119. )
  120. archive = PrintArchive(
  121. filename="Plate_1.gcode.3mf",
  122. print_name="Plate 1",
  123. file_path="/tmp/webhook_attribution.3mf", # nosec B108
  124. file_size=1024,
  125. content_hash="webhookattributionhash",
  126. status="completed",
  127. )
  128. db_session.add_all([printer, archive])
  129. await db_session.commit()
  130. await db_session.refresh(printer)
  131. await db_session.refresh(archive)
  132. return printer, archive
  133. async def _mint_key(self, db_session, owner_id: int | None) -> str:
  134. full_key, key_hash, key_prefix = generate_api_key()
  135. db_session.add(
  136. APIKey(
  137. name="attribution probe",
  138. key_hash=key_hash,
  139. key_prefix=key_prefix,
  140. user_id=owner_id,
  141. can_queue=True,
  142. # Queueing an archive someone else made needs read access to it.
  143. can_read_status=True,
  144. )
  145. )
  146. await db_session.commit()
  147. return full_key
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
  151. printer, archive = printer_and_archive
  152. # The owner needs the queue and archive permissions in their own right:
  153. # a key is capped by its owner's permissions (#1894), so a bare account
  154. # with no groups cannot queue through a key however its scope flags are
  155. # set. This test is about who the row is credited to, not about the gate.
  156. group = Group(
  157. name="queue-writers",
  158. description="t",
  159. permissions=["queue:create", "archives:read_all", "archives:reprint_all"],
  160. is_system=False,
  161. )
  162. db_session.add(group)
  163. await db_session.flush()
  164. owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group])
  165. db_session.add(owner)
  166. await db_session.commit()
  167. await db_session.refresh(owner)
  168. key = await self._mint_key(db_session, owner.id)
  169. response = await async_client.post(
  170. "/api/v1/webhook/queue/add",
  171. json={"printer_id": printer.id, "archive_id": archive.id},
  172. headers={"X-API-Key": key},
  173. )
  174. assert response.status_code == 200
  175. item = await _read_item(test_engine, response.json()["id"])
  176. assert item.created_by_id == owner.id
  177. @pytest.mark.asyncio
  178. @pytest.mark.integration
  179. async def test_legacy_ownerless_key_leaves_item_ownerless(
  180. self, async_client: AsyncClient, db_session, test_engine, printer_and_archive
  181. ):
  182. """`APIKey.user_id` is nullable only for keys minted before per-user
  183. ownership existed. Those must produce an ownerless row, not a crash."""
  184. printer, archive = printer_and_archive
  185. key = await self._mint_key(db_session, None)
  186. response = await async_client.post(
  187. "/api/v1/webhook/queue/add",
  188. json={"printer_id": printer.id, "archive_id": archive.id},
  189. headers={"X-API-Key": key},
  190. )
  191. assert response.status_code == 200
  192. item = await _read_item(test_engine, response.json()["id"])
  193. assert item.created_by_id is None