test_media_token_3025.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. """Integration tests for the media token (#3025).
  2. Thirteen non-camera media routes -- library and archive thumbnails, plate
  3. previews, timelapses, print photos, QR codes, project covers, link icons --
  4. were gated by the *camera stream* token. That had two consequences, and these
  5. tests pin both fixes:
  6. 1. ``camera:view`` was a prerequisite for every image in the app. A user given
  7. library access to their own job folder saw broken thumbnails until they were
  8. also handed the live feed of the room the printer is in.
  9. 2. A camera stream token records no principal, so those routes had no identity
  10. to scope by and returned any row to any holder.
  11. The media token is the replacement: minted behind plain authentication, and
  12. identified, so each route applies the same permission and ownership rules as
  13. its header-authenticated siblings.
  14. """
  15. from __future__ import annotations
  16. import shutil
  17. from pathlib import Path
  18. import pytest
  19. from httpx import AsyncClient
  20. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  21. # library:read_own + archives:read_own, and deliberately NOT camera:view --
  22. # the reporter's exact group in #3025.
  23. NO_CAMERA_PERMISSIONS = [
  24. "library:read_own",
  25. "library:upload",
  26. "archives:read_own",
  27. "projects:read",
  28. "external_links:read",
  29. "printers:read",
  30. ]
  31. async def _admin_token(async_client: AsyncClient, suffix: str) -> str:
  32. await async_client.post(
  33. "/api/v1/auth/setup",
  34. json={
  35. "auth_enabled": True,
  36. "admin_username": f"mediaadmin{suffix}",
  37. "admin_password": "AdminPass1!",
  38. },
  39. )
  40. login = await async_client.post(
  41. "/api/v1/auth/login",
  42. json={"username": f"mediaadmin{suffix}", "password": "AdminPass1!"},
  43. )
  44. assert login.status_code == 200, login.text
  45. return login.json()["access_token"]
  46. async def _make_user(
  47. async_client: AsyncClient,
  48. admin_jwt: str,
  49. *,
  50. username: str,
  51. permissions: list[str],
  52. ) -> tuple[str, int]:
  53. """Create a user in a fresh group holding exactly *permissions*."""
  54. group = await async_client.post(
  55. "/api/v1/groups/",
  56. headers={"Authorization": f"Bearer {admin_jwt}"},
  57. json={"name": f"grp_{username}", "permissions": permissions},
  58. )
  59. assert group.status_code in (200, 201), group.text
  60. created = await async_client.post(
  61. "/api/v1/users/",
  62. headers={"Authorization": f"Bearer {admin_jwt}"},
  63. json={
  64. "username": username,
  65. "password": "UserPass1!",
  66. "group_ids": [group.json()["id"]],
  67. },
  68. )
  69. assert created.status_code in (200, 201), created.text
  70. login = await async_client.post(
  71. "/api/v1/auth/login",
  72. json={"username": username, "password": "UserPass1!"},
  73. )
  74. assert login.status_code == 200, login.text
  75. return login.json()["access_token"], created.json()["id"]
  76. async def _mint_media_token(async_client: AsyncClient, jwt: str) -> str:
  77. response = await async_client.post(
  78. "/api/v1/auth/media-token",
  79. headers={"Authorization": f"Bearer {jwt}"},
  80. )
  81. assert response.status_code == 200, response.text
  82. return response.json()["token"]
  83. async def _mint_camera_token(async_client: AsyncClient, jwt: str) -> str:
  84. response = await async_client.post(
  85. "/api/v1/printers/camera/stream-token",
  86. headers={"Authorization": f"Bearer {jwt}"},
  87. )
  88. assert response.status_code == 200, response.text
  89. return response.json()["token"]
  90. # The routes resolve thumbnails relative to ``settings.base_dir``, so the
  91. # fixtures have to write there rather than into tmp_path. Keep them in one
  92. # subdirectory and delete it after every test so a run leaves the tree clean.
  93. _THUMB_DIR = "test_thumbs_3025"
  94. @pytest.fixture(autouse=True)
  95. def _clean_thumbs():
  96. from backend.app.core.config import settings
  97. yield
  98. shutil.rmtree(Path(settings.base_dir) / _THUMB_DIR, ignore_errors=True)
  99. async def _library_file(db_session, owner_id: int | None, name: str) -> int:
  100. """Insert a library row with a real thumbnail on disk."""
  101. from backend.app.core.config import settings
  102. from backend.app.models.library import LibraryFile
  103. thumb = Path(settings.base_dir) / _THUMB_DIR / f"{name}.png"
  104. thumb.parent.mkdir(parents=True, exist_ok=True)
  105. thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
  106. row = LibraryFile(
  107. filename=f"{name}.3mf",
  108. file_path=f"library/files/{name}.3mf",
  109. thumbnail_path=f"{_THUMB_DIR}/{thumb.name}",
  110. file_type="3mf",
  111. file_size=1234,
  112. created_by_id=owner_id,
  113. )
  114. db_session.add(row)
  115. await db_session.commit()
  116. await db_session.refresh(row)
  117. return row.id
  118. class TestTheUserWhoCouldNotSeeTheirOwnThumbnails:
  119. """The reported fault: camera:view was load-bearing for every image."""
  120. async def test_a_user_without_camera_view_can_mint_a_media_token(self, async_client: AsyncClient):
  121. admin = await _admin_token(async_client, "_mint")
  122. jwt, _ = await _make_user(async_client, admin, username="nocamera_mint", permissions=NO_CAMERA_PERMISSIONS)
  123. response = await async_client.post("/api/v1/auth/media-token", headers={"Authorization": f"Bearer {jwt}"})
  124. assert response.status_code == 200, response.text
  125. assert response.json()["token"]
  126. async def test_the_camera_token_is_still_out_of_reach_for_them(self, async_client: AsyncClient):
  127. """The permission split is real, not cosmetic: the media token does not
  128. smuggle in camera access, and minting a camera token still costs
  129. camera:view."""
  130. admin = await _admin_token(async_client, "_nocam")
  131. jwt, _ = await _make_user(async_client, admin, username="nocamera_still", permissions=NO_CAMERA_PERMISSIONS)
  132. response = await async_client.post(
  133. "/api/v1/printers/camera/stream-token", headers={"Authorization": f"Bearer {jwt}"}
  134. )
  135. assert response.status_code == 403
  136. async def test_they_can_load_their_own_library_thumbnail(self, async_client: AsyncClient, db_session):
  137. admin = await _admin_token(async_client, "_own")
  138. jwt, user_id = await _make_user(async_client, admin, username="nocamera_own", permissions=NO_CAMERA_PERMISSIONS)
  139. file_id = await _library_file(db_session, user_id, "own")
  140. token = await _mint_media_token(async_client, jwt)
  141. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
  142. assert response.status_code == 200, response.text
  143. assert response.content.startswith(b"\x89PNG")
  144. class TestTheBoundaryBetweenTheTwoTokens:
  145. """Neither token is accepted where the other belongs."""
  146. async def test_a_camera_stream_token_is_refused_on_a_media_route(self, async_client: AsyncClient, db_session):
  147. """The inverse of verify_camwall_token's rule. A camera-stream token is
  148. anonymous, so honouring it here would reinstate the unowned read."""
  149. admin = await _admin_token(async_client, "_xcam")
  150. file_id = await _library_file(db_session, None, "xcam")
  151. camera_token = await _mint_camera_token(async_client, admin)
  152. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={camera_token}")
  153. assert response.status_code == 401
  154. async def test_a_media_token_is_refused_on_the_live_camera(self, async_client: AsyncClient):
  155. admin = await _admin_token(async_client, "_xmedia")
  156. media_token = await _mint_media_token(async_client, admin)
  157. response = await async_client.get(f"/api/v1/printers/1/camera/snapshot?token={media_token}")
  158. assert response.status_code == 401
  159. async def test_no_token_at_all_is_refused(self, async_client: AsyncClient, db_session):
  160. await _admin_token(async_client, "_notok")
  161. file_id = await _library_file(db_session, None, "notok")
  162. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
  163. assert response.status_code == 401
  164. async def test_a_garbage_token_is_refused(self, async_client: AsyncClient, db_session):
  165. await _admin_token(async_client, "_garbage")
  166. file_id = await _library_file(db_session, None, "garbage")
  167. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token=not-a-real-token")
  168. assert response.status_code == 401
  169. class TestWhoseRowsAMediaTokenCanRead:
  170. """The unreported half: the old guard had no principal, so it had nothing
  171. to scope by. These fail against the camera-token implementation."""
  172. async def test_it_cannot_read_another_users_library_thumbnail(self, async_client: AsyncClient, db_session):
  173. admin = await _admin_token(async_client, "_cross")
  174. _, alice_id = await _make_user(async_client, admin, username="alice_lib", permissions=NO_CAMERA_PERMISSIONS)
  175. bob_jwt, _ = await _make_user(async_client, admin, username="bob_lib", permissions=NO_CAMERA_PERMISSIONS)
  176. alice_file = await _library_file(db_session, alice_id, "alice")
  177. bob_token = await _mint_media_token(async_client, bob_jwt)
  178. response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={bob_token}")
  179. # 404 rather than 403 -- the same id-enumeration-proof answer
  180. # _ensure_library_file_visible gives on every other library route.
  181. assert response.status_code == 404
  182. async def test_an_ownerless_file_needs_read_all(self, async_client: AsyncClient, db_session):
  183. """Fail-closed, matching _ensure_library_file_visible."""
  184. admin = await _admin_token(async_client, "_orphan")
  185. jwt, _ = await _make_user(async_client, admin, username="orphan_reader", permissions=NO_CAMERA_PERMISSIONS)
  186. file_id = await _library_file(db_session, None, "orphan")
  187. token = await _mint_media_token(async_client, jwt)
  188. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
  189. assert response.status_code == 404
  190. async def test_an_admin_with_read_all_still_sees_everything(self, async_client: AsyncClient, db_session):
  191. """The gate must not over-correct into breaking legitimate access."""
  192. admin = await _admin_token(async_client, "_readall")
  193. _, alice_id = await _make_user(async_client, admin, username="alice_readall", permissions=NO_CAMERA_PERMISSIONS)
  194. alice_file = await _library_file(db_session, alice_id, "readall")
  195. admin_token = await _mint_media_token(async_client, admin)
  196. response = await async_client.get(f"/api/v1/library/files/{alice_file}/thumbnail?token={admin_token}")
  197. assert response.status_code == 200
  198. class TestWhatTheTokenStillRequires:
  199. """A media token is authentication, not authorisation -- each route keeps
  200. asking for the permission its resource is governed by."""
  201. async def test_a_user_without_library_permission_is_refused(self, async_client: AsyncClient, db_session):
  202. admin = await _admin_token(async_client, "_noperm")
  203. jwt, user_id = await _make_user(async_client, admin, username="noperm_user", permissions=["printers:read"])
  204. file_id = await _library_file(db_session, user_id, "noperm")
  205. token = await _mint_media_token(async_client, jwt)
  206. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
  207. assert response.status_code == 403
  208. async def test_a_deactivated_users_token_stops_working(self, async_client: AsyncClient, db_session):
  209. """The token outlives the session it was minted in, so the principal is
  210. re-resolved on every request rather than trusted from mint time."""
  211. admin = await _admin_token(async_client, "_deact")
  212. jwt, user_id = await _make_user(async_client, admin, username="deact_user", permissions=NO_CAMERA_PERMISSIONS)
  213. file_id = await _library_file(db_session, user_id, "deact")
  214. token = await _mint_media_token(async_client, jwt)
  215. assert (await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")).status_code == 200
  216. deactivate = await async_client.patch(
  217. f"/api/v1/users/{user_id}",
  218. headers={"Authorization": f"Bearer {admin}"},
  219. json={"is_active": False},
  220. )
  221. assert deactivate.status_code == 200, deactivate.text
  222. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail?token={token}")
  223. assert response.status_code == 401
  224. class TestTheHeaderPathStillWorks:
  225. """A media route is reachable with ordinary credentials too, so a fetch()
  226. or an API-keyed integration does not need a token at all."""
  227. async def test_a_bearer_jwt_reaches_a_media_route_without_any_token(self, async_client: AsyncClient, db_session):
  228. admin = await _admin_token(async_client, "_bearer")
  229. jwt, user_id = await _make_user(async_client, admin, username="bearer_user", permissions=NO_CAMERA_PERMISSIONS)
  230. file_id = await _library_file(db_session, user_id, "bearer")
  231. response = await async_client.get(
  232. f"/api/v1/library/files/{file_id}/thumbnail",
  233. headers={"Authorization": f"Bearer {jwt}"},
  234. )
  235. assert response.status_code == 200
  236. async def test_the_header_path_is_ownership_scoped_too(self, async_client: AsyncClient, db_session):
  237. admin = await _admin_token(async_client, "_bearerx")
  238. _, alice_id = await _make_user(async_client, admin, username="alice_bearer", permissions=NO_CAMERA_PERMISSIONS)
  239. bob_jwt, _ = await _make_user(async_client, admin, username="bob_bearer", permissions=NO_CAMERA_PERMISSIONS)
  240. alice_file = await _library_file(db_session, alice_id, "alicebearer")
  241. response = await async_client.get(
  242. f"/api/v1/library/files/{alice_file}/thumbnail",
  243. headers={"Authorization": f"Bearer {bob_jwt}"},
  244. )
  245. assert response.status_code == 404
  246. class TestAuthDisabled:
  247. async def test_media_routes_stay_open_when_auth_is_off(self, async_client: AsyncClient, db_session):
  248. """No setup call -- auth is off, and the routes must not start
  249. demanding a token that an unauthenticated install cannot mint."""
  250. file_id = await _library_file(db_session, None, "authoff")
  251. response = await async_client.get(f"/api/v1/library/files/{file_id}/thumbnail")
  252. assert response.status_code == 200
  253. async def _archive(db_session, owner_id: int | None, name: str) -> int:
  254. """Insert an archive with a real thumbnail and timelapse on disk."""
  255. from backend.app.core.config import settings
  256. from backend.app.models.archive import PrintArchive
  257. base = Path(settings.base_dir) / _THUMB_DIR
  258. base.mkdir(parents=True, exist_ok=True)
  259. (base / f"{name}_thumb.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
  260. (base / f"{name}_tl.mp4").write_bytes(b"\x00\x00\x00 ftypisom" + b"0" * 32)
  261. row = PrintArchive(
  262. filename=f"{name}.3mf",
  263. file_path=f"archives/{name}.3mf",
  264. file_size=1234,
  265. thumbnail_path=f"{_THUMB_DIR}/{name}_thumb.png",
  266. timelapse_path=f"{_THUMB_DIR}/{name}_tl.mp4",
  267. created_by_id=owner_id,
  268. )
  269. db_session.add(row)
  270. await db_session.commit()
  271. await db_session.refresh(row)
  272. return row.id
  273. class TestTheArchiveMediaRoutes:
  274. """The seven archive routes are where the sensitive content lives -- a
  275. timelapse and the finish photos are a video of someone's room. They are
  276. covered separately from library because the existing integration suite runs
  277. with auth disabled, so nothing else exercises them with auth on."""
  278. async def test_an_owner_can_load_their_archive_thumbnail(self, async_client: AsyncClient, db_session):
  279. admin = await _admin_token(async_client, "_arcown")
  280. jwt, uid = await _make_user(async_client, admin, username="arc_owner", permissions=NO_CAMERA_PERMISSIONS)
  281. archive_id = await _archive(db_session, uid, "arcown")
  282. token = await _mint_media_token(async_client, jwt)
  283. response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={token}")
  284. assert response.status_code == 200, response.text
  285. async def test_another_user_cannot_load_that_thumbnail(self, async_client: AsyncClient, db_session):
  286. admin = await _admin_token(async_client, "_arcx")
  287. _, alice_id = await _make_user(async_client, admin, username="alice_arc", permissions=NO_CAMERA_PERMISSIONS)
  288. bob_jwt, _ = await _make_user(async_client, admin, username="bob_arc", permissions=NO_CAMERA_PERMISSIONS)
  289. archive_id = await _archive(db_session, alice_id, "arcx")
  290. bob_token = await _mint_media_token(async_client, bob_jwt)
  291. response = await async_client.get(f"/api/v1/archives/{archive_id}/thumbnail?token={bob_token}")
  292. assert response.status_code == 404
  293. async def test_another_user_cannot_load_that_timelapse(self, async_client: AsyncClient, db_session):
  294. """The one that matters most: a timelapse is footage of the room the
  295. printer is in."""
  296. admin = await _admin_token(async_client, "_arctl")
  297. _, alice_id = await _make_user(async_client, admin, username="alice_tl", permissions=NO_CAMERA_PERMISSIONS)
  298. bob_jwt, _ = await _make_user(async_client, admin, username="bob_tl", permissions=NO_CAMERA_PERMISSIONS)
  299. archive_id = await _archive(db_session, alice_id, "arctl")
  300. bob_token = await _mint_media_token(async_client, bob_jwt)
  301. assert (await async_client.get(f"/api/v1/archives/{archive_id}/timelapse?token={bob_token}")).status_code == 404
  302. async def test_a_camera_token_reaches_no_archive_media(self, async_client: AsyncClient, db_session):
  303. admin = await _admin_token(async_client, "_arccam")
  304. archive_id = await _archive(db_session, None, "arccam")
  305. camera_token = await _mint_camera_token(async_client, admin)
  306. for path in ("thumbnail", "timelapse", "plate-preview", "qrcode"):
  307. response = await async_client.get(f"/api/v1/archives/{archive_id}/{path}?token={camera_token}")
  308. assert response.status_code == 401, f"{path} accepted a camera token: {response.status_code}"
  309. class TestTheFlatPermissionMediaRoutes:
  310. """printers/{id}/cover, external-links/{id}/icon and projects/{id}/cover-image
  311. have no per-row owner, so they gate on the resource's read permission."""
  312. async def test_the_link_icon_needs_external_links_read(self, async_client: AsyncClient):
  313. admin = await _admin_token(async_client, "_icon")
  314. jwt, _ = await _make_user(async_client, admin, username="icon_user", permissions=["printers:read"])
  315. token = await _mint_media_token(async_client, jwt)
  316. response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
  317. assert response.status_code == 403
  318. async def test_the_link_icon_is_reachable_with_that_permission(self, async_client: AsyncClient):
  319. admin = await _admin_token(async_client, "_icon2")
  320. jwt, _ = await _make_user(async_client, admin, username="icon_user2", permissions=NO_CAMERA_PERMISSIONS)
  321. token = await _mint_media_token(async_client, jwt)
  322. # 404 because no such link exists -- the point is that it is not 401/403.
  323. response = await async_client.get(f"/api/v1/external-links/1/icon?token={token}")
  324. assert response.status_code == 404
  325. async def test_the_printer_cover_needs_printers_read(self, async_client: AsyncClient):
  326. admin = await _admin_token(async_client, "_cover")
  327. jwt, _ = await _make_user(async_client, admin, username="cover_user", permissions=["external_links:read"])
  328. token = await _mint_media_token(async_client, jwt)
  329. response = await async_client.get(f"/api/v1/printers/1/cover?token={token}")
  330. assert response.status_code == 403
  331. async def test_the_project_cover_needs_projects_read(self, async_client: AsyncClient):
  332. admin = await _admin_token(async_client, "_pcover")
  333. jwt, _ = await _make_user(async_client, admin, username="pcover_user", permissions=["printers:read"])
  334. token = await _mint_media_token(async_client, jwt)
  335. response = await async_client.get(f"/api/v1/projects/1/cover-image?token={token}")
  336. assert response.status_code == 403