test_camera_stream_recheck.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426
  1. """An open camera stream ends once whoever opened it may no longer watch.
  2. The token was checked once, when the stream started, and a live view then ran
  3. for hours: after a password change or reset, a deactivation, a disabled key or
  4. a revoked Cam Wall token it kept sending video. The stream now re-checks its
  5. opener while it runs (every 30 s; at once here).
  6. """
  7. import asyncio
  8. from datetime import datetime, timezone
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from sqlalchemy import text
  13. from backend.app.api.routes import camera
  14. from backend.app.core import auth
  15. from backend.app.models.api_key import APIKey
  16. from backend.app.models.printer import Printer
  17. from backend.app.utils.local_time import utcnow_naive
  18. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  19. @pytest.fixture
  20. async def admin(async_client: AsyncClient) -> int:
  21. await async_client.post(
  22. "/api/v1/auth/setup",
  23. json={"auth_enabled": True, "admin_username": "camadmin", "admin_password": "AdminPass1!"},
  24. )
  25. login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
  26. return login.json()["user"]["id"]
  27. @pytest.fixture
  28. async def printer(db_session) -> int:
  29. row = Printer(
  30. name="Cam",
  31. serial_number="CAM-RECHECK",
  32. ip_address="192.0.2.61",
  33. access_code="x",
  34. external_camera_enabled=True,
  35. external_camera_url="http://192.0.2.62/mjpeg",
  36. external_camera_type="mjpeg",
  37. )
  38. db_session.add(row)
  39. await db_session.commit()
  40. return row.id
  41. @pytest.fixture(autouse=True)
  42. def _frames(monkeypatch):
  43. """An endless camera, and a re-check before every frame."""
  44. async def frames(*_args, on_frame=None, **_kwargs):
  45. while True:
  46. on_frame(b"\xff\xd8f\xff\xd9")
  47. yield b"--frame\r\n"
  48. await asyncio.sleep(0)
  49. monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
  50. monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.0)
  51. with patch("backend.app.services.external_camera.generate_mjpeg_stream", frames):
  52. yield
  53. async def _open(printer_id: int, opener):
  54. response = await camera.camera_stream(printer_id, MagicMock(), fps=5, opener=opener)
  55. stream = response.body_iterator
  56. await anext(stream)
  57. return stream
  58. async def _ended(stream) -> bool:
  59. try:
  60. await asyncio.wait_for(anext(stream), 2)
  61. except StopAsyncIteration:
  62. return True
  63. return False
  64. async def _opener(token: str):
  65. access = await auth.camera_stream_access(token)
  66. assert access is not None
  67. return access[1]
  68. async def test_a_users_stream_ends_when_their_sessions_end(admin, printer, db_session):
  69. """A password change or reset, a 2FA removal or a restore."""
  70. token = await auth.create_camera_stream_token(username="camadmin")
  71. stream = await _open(printer, await _opener(token))
  72. await db_session.execute(
  73. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
  74. )
  75. await db_session.commit()
  76. assert await _ended(stream)
  77. async def test_a_deactivated_users_stream_ends(admin, printer, db_session):
  78. token = await auth.create_camera_stream_token(username="camadmin")
  79. stream = await _open(printer, await _opener(token))
  80. await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
  81. await db_session.commit()
  82. assert await _ended(stream)
  83. async def test_a_valid_stream_outlives_its_token(admin, printer, db_session):
  84. """The token lasts an hour; a stream opened with it may run longer."""
  85. token = await auth.create_camera_stream_token(username="camadmin")
  86. stream = await _open(printer, await _opener(token))
  87. await db_session.execute(text("DELETE FROM auth_ephemeral_tokens WHERE token = :t"), {"t": token})
  88. await db_session.commit()
  89. assert not await _ended(stream)
  90. assert not await _ended(stream)
  91. async def test_a_disabled_keys_stream_ends(admin, printer, db_session):
  92. key = APIKey(name="cam", key_hash="h", key_prefix="bb_cam", printer_ids=None)
  93. db_session.add(key)
  94. await db_session.commit()
  95. token = await auth.create_camera_stream_token(api_key_id=key.id)
  96. stream = await _open(printer, await _opener(token))
  97. await db_session.execute(text("UPDATE api_keys SET enabled = 0 WHERE id = :id"), {"id": key.id})
  98. await db_session.commit()
  99. assert await _ended(stream)
  100. async def test_a_revoked_cam_wall_tokens_stream_ends(admin, printer, db_session):
  101. from backend.app.services.long_lived_tokens import create_token
  102. created = await create_token(db_session, user_id=admin, name="wall", expires_in_days=30, scope="camwall")
  103. stream = await _open(printer, await _opener(created.plaintext))
  104. await db_session.execute(
  105. text("UPDATE long_lived_tokens SET revoked_at = :t WHERE id = :id"),
  106. {"t": datetime.now(timezone.utc).replace(tzinfo=None), "id": created.record.id},
  107. )
  108. await db_session.commit()
  109. assert await _ended(stream)
  110. async def test_a_stream_opened_with_auth_off_ends_when_it_is_turned_on(async_client: AsyncClient, printer): # noqa: ARG001 - patches the session factory
  111. stream = await _open(printer, auth.StreamOpener(auth_on=False))
  112. with patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)):
  113. assert await _ended(stream)
  114. async def test_a_stream_opened_as_the_session_ends_does_not_outlive_it(admin, printer, db_session):
  115. """The password change commits between the token check and the stream's
  116. start: its sessions ended after the token was minted, so the stream ends.
  117. (A token minted before the session began is refused outright as well.)"""
  118. token = await auth.create_camera_stream_token(username="camadmin")
  119. opener = await _opener(token) # checked just before the change commits
  120. await asyncio.sleep(0.01)
  121. await db_session.execute(
  122. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
  123. )
  124. await db_session.commit()
  125. response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=opener)
  126. assert await _ended(response.body_iterator)
  127. assert await auth.camera_stream_access(token) is None
  128. @pytest.fixture
  129. async def viewer(async_client: AsyncClient, admin) -> tuple[int, int, dict]:
  130. """A non-admin with camera:view through a group: (group id, user id, admin headers)."""
  131. login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
  132. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  133. group = await async_client.post(
  134. "/api/v1/groups/", headers=headers, json={"name": "Cams", "permissions": ["camera:view", "printers:read"]}
  135. )
  136. user = await async_client.post(
  137. "/api/v1/users/",
  138. headers=headers,
  139. json={"username": "viewer", "password": "Userpass1!", "role": "user", "group_ids": [group.json()["id"]]},
  140. )
  141. return group.json()["id"], user.json()["id"], headers
  142. async def _take_camera_view(client: AsyncClient, group: int, headers: dict) -> None:
  143. response = await client.patch(f"/api/v1/groups/{group}", headers=headers, json={"permissions": ["printers:read"]})
  144. assert response.status_code == 200, response.text
  145. async def test_losing_camera_view_ends_a_users_stream_and_token(async_client: AsyncClient, viewer, printer):
  146. group, _, headers = viewer
  147. token = await auth.create_camera_stream_token(username="viewer")
  148. stream = await _open(printer, await _opener(token))
  149. await _take_camera_view(async_client, group, headers)
  150. assert await _ended(stream)
  151. assert await auth.camera_stream_access(token) is None # no new stream or snapshot either
  152. async def test_a_key_losing_status_access_ends_its_stream(admin, printer, db_session):
  153. key = APIKey(name="cam2", key_hash="h", key_prefix="bb_cam2", printer_ids=None)
  154. db_session.add(key)
  155. await db_session.commit()
  156. token = await auth.create_camera_stream_token(api_key_id=key.id)
  157. stream = await _open(printer, await _opener(token))
  158. await db_session.execute(text("UPDATE api_keys SET can_read_status = 0 WHERE id = :id"), {"id": key.id})
  159. await db_session.commit()
  160. assert await _ended(stream)
  161. assert await auth.camera_stream_access(token) is None
  162. async def test_a_cam_wall_token_whose_owner_lost_camera_view_stops(
  163. async_client: AsyncClient, viewer, printer, db_session
  164. ):
  165. """It would otherwise stream for months, until it expires or is revoked."""
  166. from backend.app.services.long_lived_tokens import create_token
  167. group, user, headers = viewer
  168. created = await create_token(db_session, user_id=user, name="wall", expires_in_days=30, scope="camwall")
  169. stream = await _open(printer, await _opener(created.plaintext))
  170. await _take_camera_view(async_client, group, headers)
  171. assert await _ended(stream)
  172. assert await auth.camera_stream_access(created.plaintext) is None
  173. @pytest.mark.parametrize("kind", ["camwall", "overlay"])
  174. async def test_the_cam_wall_and_overlay_feeds_refuse_a_token_whose_owner_lost_camera_view(
  175. async_client: AsyncClient, viewer, db_session, kind
  176. ):
  177. """They list printers and name the file being printed; minting needed camera:view."""
  178. from backend.app.services.long_lived_tokens import create_token
  179. group, user, headers = viewer
  180. created = await create_token(db_session, user_id=user, name=kind, expires_in_days=30, scope=kind)
  181. verify = auth.verify_camwall_token if kind == "camwall" else auth.verify_overlay_token
  182. assert await verify(created.plaintext) is not None
  183. await _take_camera_view(async_client, group, headers)
  184. assert await verify(created.plaintext) is None
  185. def _session_failing(times: int):
  186. """The re-check's database session, failing its first `times` uses."""
  187. real = auth.async_session
  188. calls = {"n": 0}
  189. def session():
  190. calls["n"] += 1
  191. if calls["n"] <= times:
  192. raise RuntimeError("database briefly unavailable")
  193. return real()
  194. return patch.object(auth, "async_session", session)
  195. async def test_one_failed_re_check_does_not_end_a_valid_stream(admin, printer):
  196. """A database hiccup froze every open camera view: browsers fire no event
  197. when an MJPEG stream ends, so a Cam Wall tile kept its last frame."""
  198. token = await auth.create_camera_stream_token(username="camadmin")
  199. stream = await _open(printer, await _opener(token))
  200. with _session_failing(1):
  201. assert not await _ended(stream)
  202. assert not await _ended(stream)
  203. assert not await _ended(stream)
  204. async def test_a_re_check_failing_twice_in_a_row_ends_the_stream(admin, printer):
  205. """Still fail-closed: an opener that cannot be checked does not keep watching."""
  206. token = await auth.create_camera_stream_token(username="camadmin")
  207. stream = await _open(printer, await _opener(token))
  208. with _session_failing(1000):
  209. ended = [await _ended(stream) for _ in range(3)]
  210. assert True in ended
  211. async def test_a_definite_no_ends_the_stream_without_a_retry(admin, printer, db_session, monkeypatch):
  212. monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 3600.0)
  213. token = await auth.create_camera_stream_token(username="camadmin")
  214. stream = await _open(printer, await _opener(token))
  215. await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
  216. await db_session.commit()
  217. assert await _ended(stream)
  218. async def test_a_re_check_that_cannot_be_read_raises(admin):
  219. """Fail-closed in auth code (SECURITY.md section 2): no permissive value from
  220. a catch-all; the stream decides how long it may run unchecked."""
  221. token = await auth.create_camera_stream_token(username="camadmin")
  222. opener = await _opener(token)
  223. with _session_failing(1), pytest.raises(RuntimeError):
  224. await auth.stream_opener_still_allowed(opener, 1)
  225. def _session_failing_slowly(seconds: float):
  226. """The re-check's database session, failing every use after `seconds` (a pool timeout)."""
  227. class _Slow:
  228. async def __aenter__(self):
  229. await asyncio.sleep(seconds)
  230. raise RuntimeError("pool timeout")
  231. async def __aexit__(self, *_exc):
  232. return False
  233. return patch.object(auth, "async_session", lambda: _Slow())
  234. async def test_a_slow_failing_re_check_sends_no_frame_past_the_retry_deadline(admin, printer, monkeypatch):
  235. """SECURITY.md: no frame more than about 35 s after the last check that allowed
  236. the stream. The retry deadline counts from when the failed check started."""
  237. monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.01)
  238. token = await auth.create_camera_stream_token(username="camadmin")
  239. stream = await _open(printer, await _opener(token))
  240. with _session_failing_slowly(0.05):
  241. assert await _ended(stream)
  242. async def _status(printer_id: int) -> bool:
  243. return (await camera.camera_status(printer_id, None))["active"]
  244. async def test_one_viewer_leaving_keeps_a_shared_external_camera_active(admin, printer):
  245. """A Cam Wall tile renews its stream every minute; the printer's other open
  246. viewer must not read the camera as stopped and reconnect."""
  247. token = await auth.create_camera_stream_token(username="camadmin")
  248. tile = await _open(printer, await _opener(token))
  249. viewer = await _open(printer, await _opener(token))
  250. await tile.aclose()
  251. assert await _status(printer)
  252. await viewer.aclose()
  253. assert not await _status(printer)
  254. async def test_a_re_check_failing_again_at_the_retry_ends_the_stream_after_frames_meanwhile(
  255. admin, printer, monkeypatch
  256. ):
  257. """The usual production path: the check fails fast, frames go on until the
  258. retry is due, and the retry failing too ends the stream (never deferred again)."""
  259. monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.2)
  260. token = await auth.create_camera_stream_token(username="camadmin")
  261. stream = await _open(printer, await _opener(token))
  262. loop = asyncio.get_running_loop()
  263. start = loop.time()
  264. frames = 0
  265. with _session_failing(1000), pytest.raises(StopAsyncIteration):
  266. while loop.time() - start < 2:
  267. await asyncio.wait_for(anext(stream), 1)
  268. frames += 1
  269. assert frames > 1 # it waited for the retry rather than ending at once
  270. async def test_a_stream_whose_body_never_starts_leaves_the_camera_inactive(admin, printer):
  271. """The client went away before the first chunk: no finally runs, so the
  272. count must only go up once the stream really runs."""
  273. token = await auth.create_camera_stream_token(username="camadmin")
  274. response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token))
  275. await response.body_iterator.aclose()
  276. assert not await _status(printer)
  277. async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(admin, printer):
  278. """Without it /camera/status has no stream_uptime, so the remaining viewer can
  279. never read the camera as stalled and would not reconnect if it stops."""
  280. token = await auth.create_camera_stream_token(username="camadmin")
  281. tile = await _open(printer, await _opener(token))
  282. viewer = await _open(printer, await _opener(token))
  283. await tile.aclose()
  284. status = await camera.camera_status(printer, None)
  285. assert status["stream_uptime"] is not None
  286. assert printer in camera._last_frames
  287. await viewer.aclose()
  288. assert (await camera.camera_status(printer, None))["stream_uptime"] is None
  289. assert printer not in camera._last_frames
  290. async def test_an_external_stream_remembers_the_viewer_that_opened_it(admin, printer):
  291. """So that viewer's /camera/stop ends it and leaves the others' running."""
  292. token = await auth.create_camera_stream_token(username="camadmin")
  293. response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token), viewer="tile-a")
  294. stream = response.body_iterator
  295. await anext(stream)
  296. owned = [sid for sid, viewer in camera._stream_viewers.items() if sid.startswith(f"{printer}-ext-")]
  297. assert len(owned) == 1 and camera._stream_viewers[owned[0]] == "tile-a"
  298. await stream.aclose()
  299. assert owned[0] not in camera._stream_viewers
  300. async def test_a_viewer_id_is_a_short_token(async_client: AsyncClient, admin, printer):
  301. login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
  302. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  303. bad = await async_client.post(f"/api/v1/printers/{printer}/camera/stop?viewer=a%20b%0Ac", headers=headers)
  304. good = await async_client.post(f"/api/v1/printers/{printer}/camera/stop?viewer=k3j9x2ab", headers=headers)
  305. assert bad.status_code == 422
  306. assert good.status_code == 200