test_camera_stream_recheck.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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. with patch("backend.app.services.external_camera.generate_mjpeg_stream", frames):
  51. yield
  52. async def _open(printer_id: int, opener):
  53. response = await camera.camera_stream(printer_id, MagicMock(), fps=5, opener=opener)
  54. stream = response.body_iterator
  55. await anext(stream)
  56. return stream
  57. async def _ended(stream) -> bool:
  58. try:
  59. await asyncio.wait_for(anext(stream), 2)
  60. except StopAsyncIteration:
  61. return True
  62. return False
  63. async def _opener(token: str):
  64. access = await auth.camera_stream_access(token)
  65. assert access is not None
  66. return access[1]
  67. async def test_a_users_stream_ends_when_their_sessions_end(admin, printer, db_session):
  68. """A password change or reset, a 2FA removal or a restore."""
  69. token = await auth.create_camera_stream_token(username="camadmin")
  70. stream = await _open(printer, await _opener(token))
  71. await db_session.execute(
  72. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
  73. )
  74. await db_session.commit()
  75. assert await _ended(stream)
  76. async def test_a_deactivated_users_stream_ends(admin, printer, db_session):
  77. token = await auth.create_camera_stream_token(username="camadmin")
  78. stream = await _open(printer, await _opener(token))
  79. await db_session.execute(text("UPDATE users SET is_active = 0 WHERE id = :id"), {"id": admin})
  80. await db_session.commit()
  81. assert await _ended(stream)
  82. async def test_a_valid_stream_outlives_its_token(admin, printer, db_session):
  83. """The token lasts an hour; a stream opened with it may run longer."""
  84. token = await auth.create_camera_stream_token(username="camadmin")
  85. stream = await _open(printer, await _opener(token))
  86. await db_session.execute(text("DELETE FROM auth_ephemeral_tokens WHERE token = :t"), {"t": token})
  87. await db_session.commit()
  88. assert not await _ended(stream)
  89. assert not await _ended(stream)
  90. async def test_a_disabled_keys_stream_ends(admin, printer, db_session):
  91. key = APIKey(name="cam", key_hash="h", key_prefix="bb_cam", printer_ids=None)
  92. db_session.add(key)
  93. await db_session.commit()
  94. token = await auth.create_camera_stream_token(api_key_id=key.id)
  95. stream = await _open(printer, await _opener(token))
  96. await db_session.execute(text("UPDATE api_keys SET enabled = 0 WHERE id = :id"), {"id": key.id})
  97. await db_session.commit()
  98. assert await _ended(stream)
  99. async def test_a_revoked_cam_wall_tokens_stream_ends(admin, printer, db_session):
  100. from backend.app.services.long_lived_tokens import create_token
  101. created = await create_token(db_session, user_id=admin, name="wall", expires_in_days=30, scope="camwall")
  102. stream = await _open(printer, await _opener(created.plaintext))
  103. await db_session.execute(
  104. text("UPDATE long_lived_tokens SET revoked_at = :t WHERE id = :id"),
  105. {"t": datetime.now(timezone.utc).replace(tzinfo=None), "id": created.record.id},
  106. )
  107. await db_session.commit()
  108. assert await _ended(stream)
  109. 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
  110. stream = await _open(printer, auth.StreamOpener(auth_on=False))
  111. with patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)):
  112. assert await _ended(stream)
  113. async def test_a_stream_opened_as_the_session_ends_does_not_outlive_it(admin, printer, db_session):
  114. """The password change commits between the token check and the stream's
  115. start: its sessions ended after the token was minted, so the stream ends."""
  116. token = await auth.create_camera_stream_token(username="camadmin")
  117. await asyncio.sleep(0.01)
  118. await db_session.execute(
  119. text("UPDATE users SET password_changed_at = :t WHERE id = :id"), {"t": utcnow_naive(), "id": admin}
  120. )
  121. await db_session.commit()
  122. await asyncio.sleep(0.01)
  123. opener = await _opener(token) # the token row was still there when it was read
  124. response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=opener)
  125. assert await _ended(response.body_iterator)
  126. @pytest.fixture
  127. async def viewer(async_client: AsyncClient, admin) -> tuple[int, int, dict]:
  128. """A non-admin with camera:view through a group: (group id, user id, admin headers)."""
  129. login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
  130. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  131. group = await async_client.post(
  132. "/api/v1/groups/", headers=headers, json={"name": "Cams", "permissions": ["camera:view", "printers:read"]}
  133. )
  134. user = await async_client.post(
  135. "/api/v1/users/",
  136. headers=headers,
  137. json={"username": "viewer", "password": "Userpass1!", "role": "user", "group_ids": [group.json()["id"]]},
  138. )
  139. return group.json()["id"], user.json()["id"], headers
  140. async def _take_camera_view(client: AsyncClient, group: int, headers: dict) -> None:
  141. response = await client.patch(f"/api/v1/groups/{group}", headers=headers, json={"permissions": ["printers:read"]})
  142. assert response.status_code == 200, response.text
  143. async def test_losing_camera_view_ends_a_users_stream_and_token(async_client: AsyncClient, viewer, printer):
  144. group, _, headers = viewer
  145. token = await auth.create_camera_stream_token(username="viewer")
  146. stream = await _open(printer, await _opener(token))
  147. await _take_camera_view(async_client, group, headers)
  148. assert await _ended(stream)
  149. assert await auth.camera_stream_access(token) is None # no new stream or snapshot either
  150. async def test_a_key_losing_status_access_ends_its_stream(admin, printer, db_session):
  151. key = APIKey(name="cam2", key_hash="h", key_prefix="bb_cam2", printer_ids=None)
  152. db_session.add(key)
  153. await db_session.commit()
  154. token = await auth.create_camera_stream_token(api_key_id=key.id)
  155. stream = await _open(printer, await _opener(token))
  156. await db_session.execute(text("UPDATE api_keys SET can_read_status = 0 WHERE id = :id"), {"id": key.id})
  157. await db_session.commit()
  158. assert await _ended(stream)
  159. assert await auth.camera_stream_access(token) is None
  160. async def test_a_cam_wall_token_whose_owner_lost_camera_view_stops(
  161. async_client: AsyncClient, viewer, printer, db_session
  162. ):
  163. """It would otherwise stream for months, until it expires or is revoked."""
  164. from backend.app.services.long_lived_tokens import create_token
  165. group, user, headers = viewer
  166. created = await create_token(db_session, user_id=user, name="wall", expires_in_days=30, scope="camwall")
  167. stream = await _open(printer, await _opener(created.plaintext))
  168. await _take_camera_view(async_client, group, headers)
  169. assert await _ended(stream)
  170. assert await auth.camera_stream_access(created.plaintext) is None
  171. @pytest.mark.parametrize("kind", ["camwall", "overlay"])
  172. async def test_the_cam_wall_and_overlay_feeds_refuse_a_token_whose_owner_lost_camera_view(
  173. async_client: AsyncClient, viewer, db_session, kind
  174. ):
  175. """They list printers and name the file being printed; minting needed camera:view."""
  176. from backend.app.services.long_lived_tokens import create_token
  177. group, user, headers = viewer
  178. created = await create_token(db_session, user_id=user, name=kind, expires_in_days=30, scope=kind)
  179. verify = auth.verify_camwall_token if kind == "camwall" else auth.verify_overlay_token
  180. assert await verify(created.plaintext) is not None
  181. await _take_camera_view(async_client, group, headers)
  182. assert await verify(created.plaintext) is None