test_long_lived_tokens_api.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. """Integration tests for long-lived camera-stream token routes (#1108).
  2. Cover the auth gates, ownership rules, max-lifetime cap, token-shown-once
  3. contract, and the camera-stream auth fall-through (the existing 60-min
  4. ephemeral path still works AND a long-lived token is also accepted).
  5. """
  6. from __future__ import annotations
  7. import pytest
  8. from httpx import AsyncClient
  9. from backend.app.core.printer_scope import ALL_PRINTERS
  10. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  11. # ---------------------------------------------------------------------------
  12. # Helpers
  13. # ---------------------------------------------------------------------------
  14. async def _setup_admin(async_client: AsyncClient, *, suffix: str = "") -> str:
  15. """Create the first admin and return their JWT."""
  16. await async_client.post(
  17. "/api/v1/auth/setup",
  18. json={
  19. "auth_enabled": True,
  20. "admin_username": f"tokenadmin{suffix}",
  21. "admin_password": "AdminPass1!",
  22. },
  23. )
  24. login = await async_client.post(
  25. "/api/v1/auth/login",
  26. json={"username": f"tokenadmin{suffix}", "password": "AdminPass1!"},
  27. )
  28. return login.json()["access_token"]
  29. async def _create_user(async_client: AsyncClient, admin_token: str, username: str, *, role: str = "user") -> int:
  30. """Create a non-admin user via the admin API and return their id.
  31. The user is assigned to the seeded "Viewers" group so they hold
  32. ``CAMERA_VIEW`` — without that, regular users cannot create their own
  33. long-lived tokens (which is the same gate the existing 60-min ephemeral
  34. flow uses).
  35. """
  36. # Fetch Viewers group id so the new user inherits CAMERA_VIEW.
  37. groups_resp = await async_client.get("/api/v1/groups/", headers={"Authorization": f"Bearer {admin_token}"})
  38. viewers = next((g for g in groups_resp.json() if g["name"] == "Viewers"), None)
  39. assert viewers is not None, f"Viewers group not seeded: {groups_resp.text}"
  40. response = await async_client.post(
  41. "/api/v1/users/",
  42. headers={"Authorization": f"Bearer {admin_token}"},
  43. json={
  44. "username": username,
  45. "password": "UserPass1!",
  46. "role": role,
  47. "group_ids": [viewers["id"]],
  48. },
  49. )
  50. assert response.status_code in (200, 201), response.text
  51. return response.json()["id"]
  52. async def _login(async_client: AsyncClient, username: str) -> str:
  53. response = await async_client.post(
  54. "/api/v1/auth/login",
  55. json={"username": username, "password": "UserPass1!"},
  56. )
  57. body = response.json()
  58. token = body.get("access_token")
  59. assert token, f"login for {username!r} returned no access_token: {body}"
  60. return token
  61. # ---------------------------------------------------------------------------
  62. # Create
  63. # ---------------------------------------------------------------------------
  64. class TestCreateLongLivedToken:
  65. async def test_create_returns_plaintext_token_exactly_once(self, async_client: AsyncClient):
  66. token = await _setup_admin(async_client, suffix="_create_once")
  67. response = await async_client.post(
  68. "/api/v1/auth/tokens",
  69. headers={"Authorization": f"Bearer {token}"},
  70. json={"name": "Home Assistant", "expires_in_days": 30},
  71. )
  72. assert response.status_code == 201, response.text
  73. body = response.json()
  74. assert body["token"].startswith("bblt_")
  75. assert body["name"] == "Home Assistant"
  76. assert body["scope"] == "camera_stream"
  77. assert body["lookup_prefix"]
  78. token_id = body["id"]
  79. # Listing must NOT include the plaintext (shown-once contract).
  80. listing = await async_client.get(
  81. "/api/v1/auth/tokens",
  82. headers={"Authorization": f"Bearer {token}"},
  83. )
  84. assert listing.status_code == 200
  85. listed = next((t for t in listing.json() if t["id"] == token_id), None)
  86. assert listed is not None
  87. assert listed["token"] is None # plaintext gone forever
  88. async def test_create_rejects_expires_in_zero(self, async_client: AsyncClient):
  89. """Issue #1108: ``expire_in: 0`` (never) is explicitly forbidden."""
  90. token = await _setup_admin(async_client, suffix="_zero_expire")
  91. response = await async_client.post(
  92. "/api/v1/auth/tokens",
  93. headers={"Authorization": f"Bearer {token}"},
  94. json={"name": "x", "expires_in_days": 0},
  95. )
  96. assert response.status_code == 400
  97. assert "positive" in response.json()["detail"].lower()
  98. async def test_create_rejects_above_max(self, async_client: AsyncClient):
  99. token = await _setup_admin(async_client, suffix="_above_max")
  100. response = await async_client.post(
  101. "/api/v1/auth/tokens",
  102. headers={"Authorization": f"Bearer {token}"},
  103. json={"name": "x", "expires_in_days": 366},
  104. )
  105. assert response.status_code == 400
  106. assert "365" in response.json()["detail"]
  107. async def test_create_requires_auth(self, async_client: AsyncClient):
  108. await _setup_admin(async_client, suffix="_unauth")
  109. response = await async_client.post(
  110. "/api/v1/auth/tokens",
  111. json={"name": "x", "expires_in_days": 7},
  112. )
  113. assert response.status_code == 401
  114. # ---------------------------------------------------------------------------
  115. # List
  116. # ---------------------------------------------------------------------------
  117. class TestListLongLivedTokens:
  118. async def test_list_returns_only_callers_tokens_by_default(self, async_client: AsyncClient):
  119. admin_token = await _setup_admin(async_client, suffix="_list_default")
  120. bob_id = await _create_user(async_client, admin_token, "bob_list")
  121. bob_token = await _login(async_client, "bob_list")
  122. # Each user creates one token.
  123. await async_client.post(
  124. "/api/v1/auth/tokens",
  125. headers={"Authorization": f"Bearer {admin_token}"},
  126. json={"name": "admins", "expires_in_days": 7},
  127. )
  128. await async_client.post(
  129. "/api/v1/auth/tokens",
  130. headers={"Authorization": f"Bearer {bob_token}"},
  131. json={"name": "bobs", "expires_in_days": 7},
  132. )
  133. # Bob's listing should see only his.
  134. bob_listing = await async_client.get(
  135. "/api/v1/auth/tokens",
  136. headers={"Authorization": f"Bearer {bob_token}"},
  137. )
  138. names = {t["name"] for t in bob_listing.json()}
  139. assert names == {"bobs"}
  140. assert bob_id == bob_listing.json()[0]["user_id"]
  141. async def test_admin_can_filter_by_user_id(self, async_client: AsyncClient):
  142. admin_token = await _setup_admin(async_client, suffix="_admin_filter")
  143. bob_id = await _create_user(async_client, admin_token, "bob_filter")
  144. bob_token = await _login(async_client, "bob_filter")
  145. await async_client.post(
  146. "/api/v1/auth/tokens",
  147. headers={"Authorization": f"Bearer {bob_token}"},
  148. json={"name": "bobs", "expires_in_days": 7},
  149. )
  150. admin_view = await async_client.get(
  151. f"/api/v1/auth/tokens?user_id={bob_id}",
  152. headers={"Authorization": f"Bearer {admin_token}"},
  153. )
  154. assert admin_view.status_code == 200
  155. names = {t["name"] for t in admin_view.json()}
  156. assert names == {"bobs"}
  157. async def test_non_admin_cannot_see_other_users_tokens(self, async_client: AsyncClient):
  158. admin_token = await _setup_admin(async_client, suffix="_non_admin")
  159. await _create_user(async_client, admin_token, "alice_see")
  160. bob_id = await _create_user(async_client, admin_token, "bob_see")
  161. alice_token = await _login(async_client, "alice_see")
  162. forbidden = await async_client.get(
  163. f"/api/v1/auth/tokens?user_id={bob_id}",
  164. headers={"Authorization": f"Bearer {alice_token}"},
  165. )
  166. assert forbidden.status_code == 403
  167. # ---------------------------------------------------------------------------
  168. # Revoke
  169. # ---------------------------------------------------------------------------
  170. class TestRevokeLongLivedToken:
  171. async def test_owner_can_revoke_own_token(self, async_client: AsyncClient):
  172. token = await _setup_admin(async_client, suffix="_revoke_own")
  173. created = await async_client.post(
  174. "/api/v1/auth/tokens",
  175. headers={"Authorization": f"Bearer {token}"},
  176. json={"name": "x", "expires_in_days": 7},
  177. )
  178. token_id = created.json()["id"]
  179. revoke = await async_client.delete(
  180. f"/api/v1/auth/tokens/{token_id}",
  181. headers={"Authorization": f"Bearer {token}"},
  182. )
  183. assert revoke.status_code == 204
  184. # Now gone from the listing.
  185. listing = await async_client.get("/api/v1/auth/tokens", headers={"Authorization": f"Bearer {token}"})
  186. assert all(t["id"] != token_id for t in listing.json())
  187. async def test_admin_can_revoke_any_users_token(self, async_client: AsyncClient):
  188. admin_token = await _setup_admin(async_client, suffix="_revoke_any")
  189. await _create_user(async_client, admin_token, "bob_revoke")
  190. bob_token = await _login(async_client, "bob_revoke")
  191. created = await async_client.post(
  192. "/api/v1/auth/tokens",
  193. headers={"Authorization": f"Bearer {bob_token}"},
  194. json={"name": "bobs", "expires_in_days": 7},
  195. )
  196. token_id = created.json()["id"]
  197. admin_revoke = await async_client.delete(
  198. f"/api/v1/auth/tokens/{token_id}",
  199. headers={"Authorization": f"Bearer {admin_token}"},
  200. )
  201. assert admin_revoke.status_code == 204
  202. async def test_non_owner_non_admin_cannot_revoke(self, async_client: AsyncClient):
  203. admin_token = await _setup_admin(async_client, suffix="_revoke_other")
  204. await _create_user(async_client, admin_token, "alice_attack")
  205. await _create_user(async_client, admin_token, "bob_target")
  206. bob_token = await _login(async_client, "bob_target")
  207. alice_token = await _login(async_client, "alice_attack")
  208. created = await async_client.post(
  209. "/api/v1/auth/tokens",
  210. headers={"Authorization": f"Bearer {bob_token}"},
  211. json={"name": "bobs", "expires_in_days": 7},
  212. )
  213. token_id = created.json()["id"]
  214. forbidden = await async_client.delete(
  215. f"/api/v1/auth/tokens/{token_id}",
  216. headers={"Authorization": f"Bearer {alice_token}"},
  217. )
  218. assert forbidden.status_code == 403
  219. async def test_revoke_unknown_id_404(self, async_client: AsyncClient):
  220. token = await _setup_admin(async_client, suffix="_revoke_unknown")
  221. response = await async_client.delete(
  222. "/api/v1/auth/tokens/99999",
  223. headers={"Authorization": f"Bearer {token}"},
  224. )
  225. assert response.status_code == 404
  226. # ---------------------------------------------------------------------------
  227. # Auth fall-through: ``verify_camera_stream_token`` accepts both kinds
  228. # ---------------------------------------------------------------------------
  229. # The full /camera/stream HTTP integration would need a real ffmpeg / printer
  230. # socket to keep the StreamingResponse alive. Verifying the auth dependency
  231. # directly is a stronger check anyway: the route's only auth job is to call
  232. # ``verify_camera_stream_token``, which is what these tests exercise.
  233. class TestCameraStreamTokenVerification:
  234. async def test_long_lived_token_verifies_via_camera_stream_path(self, async_client: AsyncClient):
  235. """A freshly minted long-lived token must pass the same dependency
  236. the camera-stream route uses, after the ephemeral path would have
  237. rejected it.
  238. """
  239. from backend.app.core.auth import verify_camera_stream_token
  240. token = await _setup_admin(async_client, suffix="_verify_long")
  241. created = await async_client.post(
  242. "/api/v1/auth/tokens",
  243. headers={"Authorization": f"Bearer {token}"},
  244. json={"name": "kiosk", "expires_in_days": 90},
  245. )
  246. long_lived = created.json()["token"]
  247. assert await verify_camera_stream_token(long_lived) == ALL_PRINTERS # admin-owned: every printer (#1727)
  248. async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
  249. from backend.app.core.auth import verify_camera_stream_token
  250. token = await _setup_admin(async_client, suffix="_verify_revoke")
  251. created = await async_client.post(
  252. "/api/v1/auth/tokens",
  253. headers={"Authorization": f"Bearer {token}"},
  254. json={"name": "kiosk", "expires_in_days": 30},
  255. )
  256. long_lived = created.json()["token"]
  257. token_id = created.json()["id"]
  258. await async_client.delete(
  259. f"/api/v1/auth/tokens/{token_id}",
  260. headers={"Authorization": f"Bearer {token}"},
  261. )
  262. assert await verify_camera_stream_token(long_lived) is None
  263. async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
  264. from backend.app.core.auth import verify_camera_stream_token
  265. await _setup_admin(async_client, suffix="_verify_garbage")
  266. assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is None
  267. assert await verify_camera_stream_token("not-a-real-token") is None