test_overlay_status_api.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. """Integration tests for the token-authenticated streaming-overlay feed (#2613).
  2. Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
  3. route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
  4. than the Cam Wall: it names the file being printed (the overlay draws the part
  5. on screen). So the tests that matter are the scope boundaries — an overlay
  6. token must not reach the Cam Wall feed and vice versa, a camwall token must not
  7. reach the overlay feed (that would leak the filename it is trusted to hide) —
  8. plus the positive path and the disconnected-printer shape.
  9. """
  10. from __future__ import annotations
  11. import pytest
  12. from httpx import AsyncClient
  13. from backend.app.core.printer_scope import ALL_PRINTERS
  14. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  15. async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
  16. await async_client.post(
  17. "/api/v1/auth/setup",
  18. json={
  19. "auth_enabled": True,
  20. "admin_username": f"overlayadmin{suffix}",
  21. "admin_password": "AdminPass1!",
  22. },
  23. )
  24. login = await async_client.post(
  25. "/api/v1/auth/login",
  26. json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
  27. )
  28. return login.json()["access_token"]
  29. async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
  30. response = await async_client.post(
  31. "/api/v1/auth/tokens",
  32. headers={"Authorization": f"Bearer {jwt}"},
  33. json={"name": name, "expires_in_days": 30, "scope": scope},
  34. )
  35. assert response.status_code == 201, response.text
  36. assert response.json()["scope"] == scope
  37. return response.json()["token"]
  38. @pytest.fixture
  39. async def printer_row(db_session):
  40. """Insert the printer straight into the DB.
  41. POST /printers probes the real device before it will store a row, and there
  42. is no printer on the other end of a test run.
  43. """
  44. from backend.app.models.printer import Printer
  45. printer = Printer(
  46. name="Stream P1S",
  47. ip_address="192.168.1.88",
  48. access_code="12345678",
  49. serial_number="01P00A000000002",
  50. model="P1S",
  51. )
  52. db_session.add(printer)
  53. await db_session.commit()
  54. return printer
  55. class TestOverlayFeedAuth:
  56. async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
  57. await _setup_admin(async_client, suffix="_notoken")
  58. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
  59. assert response.status_code == 401
  60. async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
  61. await _setup_admin(async_client, suffix="_garbage")
  62. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
  63. assert response.status_code == 401
  64. async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
  65. """A ``camera_stream`` token was handed out for video alone — it must not
  66. acquire the live print status (and filename) just because a new feature
  67. shipped.
  68. """
  69. jwt = await _setup_admin(async_client, suffix="_streamscope")
  70. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  71. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
  72. assert response.status_code == 401
  73. async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
  74. """The crux of a *separate* scope from camwall.
  75. A Cam Wall token is trusted precisely because it can never name the part
  76. being printed. The overlay feed does name it, so a camwall token must be
  77. rejected here — otherwise every wall token silently gains filename
  78. visibility.
  79. """
  80. jwt = await _setup_admin(async_client, suffix="_camwallscope")
  81. camwall_token = await _mint(async_client, jwt, scope="camwall")
  82. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
  83. assert response.status_code == 401
  84. async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
  85. jwt = await _setup_admin(async_client, suffix="_rightscope")
  86. overlay_token = await _mint(async_client, jwt, scope="overlay")
  87. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  88. assert response.status_code == 200, response.text
  89. assert response.json()["name"] == "Stream P1S"
  90. async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
  91. jwt = await _setup_admin(async_client, suffix="_revoked")
  92. created = await async_client.post(
  93. "/api/v1/auth/tokens",
  94. headers={"Authorization": f"Bearer {jwt}"},
  95. json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
  96. )
  97. overlay_token = created.json()["token"]
  98. await async_client.delete(
  99. f"/api/v1/auth/tokens/{created.json()['id']}",
  100. headers={"Authorization": f"Bearer {jwt}"},
  101. )
  102. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  103. assert response.status_code == 401
  104. class TestOverlayFeedPayload:
  105. async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
  106. """Unlike the Cam Wall, the overlay *does* carry the filename fields —
  107. that is what distinguishes the scope. Assert the exact key set so the
  108. payload can't silently grow to leak more than the overlay draws.
  109. """
  110. jwt = await _setup_admin(async_client, suffix="_payload")
  111. overlay_token = await _mint(async_client, jwt, scope="overlay")
  112. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  113. assert response.status_code == 200
  114. entry = response.json()
  115. # Never the secrets — the URL is on a public stream.
  116. for leaked in ("serial_number", "ip_address", "access_code"):
  117. assert leaked not in entry, f"{leaked} must not be served to an overlay token"
  118. assert set(entry) == {
  119. "id",
  120. "name",
  121. "model",
  122. "camera_rotation",
  123. "connected",
  124. "state",
  125. "current_print",
  126. "gcode_file",
  127. "progress",
  128. "remaining_time",
  129. "layer_num",
  130. "total_layers",
  131. "stg_cur_name",
  132. "temperatures",
  133. "time_format",
  134. }
  135. async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
  136. """No MQTT client runs in tests, so the printer has no state — the
  137. overlay must render its offline state rather than erroring.
  138. """
  139. jwt = await _setup_admin(async_client, suffix="_offline")
  140. overlay_token = await _mint(async_client, jwt, scope="overlay")
  141. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  142. entry = response.json()
  143. assert entry["model"] == "P1S"
  144. assert entry["connected"] is False
  145. assert entry["state"] is None
  146. assert entry["current_print"] is None
  147. # Present but empty rather than absent (#1422): the overlay reads the
  148. # key unconditionally, and an offline printer simply has no readings.
  149. assert entry["temperatures"] == {}
  150. async def test_temperatures_are_filtered_not_passed_through(
  151. self, async_client: AsyncClient, printer_row, monkeypatch
  152. ):
  153. """#1422 — the overlay can draw nozzle/bed/chamber, so the feed carries
  154. them. It sends only the readings it draws: `state.temperatures` is also
  155. the MQTT client's working memory and holds private bookkeeping and
  156. derived heater flags that an overlay token has no business seeing.
  157. """
  158. from backend.app.services import printer_manager as pm
  159. class _FakeState:
  160. connected = True
  161. state = "RUNNING"
  162. current_print = "bracket.3mf"
  163. gcode_file = "/data/Metadata/plate_1.gcode"
  164. progress = 42.0
  165. remaining_time = 30
  166. layer_num = 10
  167. total_layers = 100
  168. stg_cur = -1
  169. temperatures = {
  170. "nozzle": 219.7,
  171. "nozzle_target": 220.0,
  172. "bed": 60.0,
  173. "bed_target": 60.0,
  174. "chamber": 38.0,
  175. "nozzle_heating": True,
  176. "_nozzle_target_set_time": 1754300000.0,
  177. }
  178. monkeypatch.setattr(pm.printer_manager, "get_status", lambda _pid: _FakeState())
  179. jwt = await _setup_admin(async_client, suffix="_temps")
  180. overlay_token = await _mint(async_client, jwt, scope="overlay")
  181. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  182. assert response.json()["model"] == "P1S"
  183. temps = response.json()["temperatures"]
  184. assert temps["nozzle"] == 219.7
  185. assert temps["nozzle_target"] == 220.0
  186. assert temps["bed"] == 60.0
  187. # The fixture printer is a P1S — no real chamber sensor, so the
  188. # meaningless reading is dropped rather than drawn on a live stream.
  189. assert "chamber" not in temps
  190. assert "nozzle_heating" not in temps
  191. assert "_nozzle_target_set_time" not in temps
  192. async def test_unknown_model_is_null(self, async_client: AsyncClient, printer_row, db_session):
  193. printer_row.model = None
  194. await db_session.commit()
  195. jwt = await _setup_admin(async_client, suffix="_nomodel")
  196. overlay_token = await _mint(async_client, jwt, scope="overlay")
  197. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  198. assert response.status_code == 200
  199. assert response.json()["model"] is None
  200. async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
  201. """A valid token for a printer id that doesn't exist is a 404 — the token
  202. passed the gate, the resource simply isn't there.
  203. """
  204. jwt = await _setup_admin(async_client, suffix="_404")
  205. overlay_token = await _mint(async_client, jwt, scope="overlay")
  206. response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
  207. assert response.status_code == 404
  208. class TestOverlayTokenReachesTheVideo:
  209. """The overlay draws the camera feed, so the same token has to satisfy the
  210. camera-stream gate.
  211. """
  212. async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
  213. from backend.app.core.auth import verify_camera_stream_token
  214. jwt = await _setup_admin(async_client, suffix="_video")
  215. overlay_token = await _mint(async_client, jwt, scope="overlay")
  216. assert await verify_camera_stream_token(overlay_token) == ALL_PRINTERS # admin-owned: every printer (#1727)
  217. async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
  218. from backend.app.core.auth import verify_overlay_token
  219. jwt = await _setup_admin(async_client, suffix="_gate")
  220. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  221. camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
  222. assert await verify_overlay_token(stream_token) is None
  223. assert await verify_overlay_token(camwall_token) is None
  224. async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
  225. """Symmetric guard: the new scope must not widen the Cam Wall either."""
  226. from backend.app.core.auth import verify_camwall_token
  227. jwt = await _setup_admin(async_client, suffix="_gate_camwall")
  228. overlay_token = await _mint(async_client, jwt, scope="overlay")
  229. assert await verify_camwall_token(overlay_token) is None