test_media_token_3025.py 20 KB

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