test_overlay_status_api.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  14. async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
  15. await async_client.post(
  16. "/api/v1/auth/setup",
  17. json={
  18. "auth_enabled": True,
  19. "admin_username": f"overlayadmin{suffix}",
  20. "admin_password": "AdminPass1!",
  21. },
  22. )
  23. login = await async_client.post(
  24. "/api/v1/auth/login",
  25. json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
  26. )
  27. return login.json()["access_token"]
  28. async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
  29. response = await async_client.post(
  30. "/api/v1/auth/tokens",
  31. headers={"Authorization": f"Bearer {jwt}"},
  32. json={"name": name, "expires_in_days": 30, "scope": scope},
  33. )
  34. assert response.status_code == 201, response.text
  35. assert response.json()["scope"] == scope
  36. return response.json()["token"]
  37. @pytest.fixture
  38. async def printer_row(db_session):
  39. """Insert the printer straight into the DB.
  40. POST /printers probes the real device before it will store a row, and there
  41. is no printer on the other end of a test run.
  42. """
  43. from backend.app.models.printer import Printer
  44. printer = Printer(
  45. name="Stream P1S",
  46. ip_address="192.168.1.88",
  47. access_code="12345678",
  48. serial_number="01P00A000000002",
  49. model="P1S",
  50. )
  51. db_session.add(printer)
  52. await db_session.commit()
  53. return printer
  54. class TestOverlayFeedAuth:
  55. async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
  56. await _setup_admin(async_client, suffix="_notoken")
  57. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
  58. assert response.status_code == 401
  59. async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
  60. await _setup_admin(async_client, suffix="_garbage")
  61. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
  62. assert response.status_code == 401
  63. async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
  64. """A ``camera_stream`` token was handed out for video alone — it must not
  65. acquire the live print status (and filename) just because a new feature
  66. shipped.
  67. """
  68. jwt = await _setup_admin(async_client, suffix="_streamscope")
  69. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  70. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
  71. assert response.status_code == 401
  72. async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
  73. """The crux of a *separate* scope from camwall.
  74. A Cam Wall token is trusted precisely because it can never name the part
  75. being printed. The overlay feed does name it, so a camwall token must be
  76. rejected here — otherwise every wall token silently gains filename
  77. visibility.
  78. """
  79. jwt = await _setup_admin(async_client, suffix="_camwallscope")
  80. camwall_token = await _mint(async_client, jwt, scope="camwall")
  81. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
  82. assert response.status_code == 401
  83. async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
  84. jwt = await _setup_admin(async_client, suffix="_rightscope")
  85. overlay_token = await _mint(async_client, jwt, scope="overlay")
  86. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  87. assert response.status_code == 200, response.text
  88. assert response.json()["name"] == "Stream P1S"
  89. async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
  90. jwt = await _setup_admin(async_client, suffix="_revoked")
  91. created = await async_client.post(
  92. "/api/v1/auth/tokens",
  93. headers={"Authorization": f"Bearer {jwt}"},
  94. json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
  95. )
  96. overlay_token = created.json()["token"]
  97. await async_client.delete(
  98. f"/api/v1/auth/tokens/{created.json()['id']}",
  99. headers={"Authorization": f"Bearer {jwt}"},
  100. )
  101. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  102. assert response.status_code == 401
  103. class TestOverlayFeedPayload:
  104. async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
  105. """Unlike the Cam Wall, the overlay *does* carry the filename fields —
  106. that is what distinguishes the scope. Assert the exact key set so the
  107. payload can't silently grow to leak more than the overlay draws.
  108. """
  109. jwt = await _setup_admin(async_client, suffix="_payload")
  110. overlay_token = await _mint(async_client, jwt, scope="overlay")
  111. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  112. assert response.status_code == 200
  113. entry = response.json()
  114. # Never the secrets — the URL is on a public stream.
  115. for leaked in ("serial_number", "ip_address", "access_code"):
  116. assert leaked not in entry, f"{leaked} must not be served to an overlay token"
  117. assert set(entry) == {
  118. "id",
  119. "name",
  120. "camera_rotation",
  121. "connected",
  122. "state",
  123. "current_print",
  124. "gcode_file",
  125. "progress",
  126. "remaining_time",
  127. "layer_num",
  128. "total_layers",
  129. "stg_cur_name",
  130. "time_format",
  131. }
  132. async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
  133. """No MQTT client runs in tests, so the printer has no state — the
  134. overlay must render its offline state rather than erroring.
  135. """
  136. jwt = await _setup_admin(async_client, suffix="_offline")
  137. overlay_token = await _mint(async_client, jwt, scope="overlay")
  138. response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
  139. entry = response.json()
  140. assert entry["connected"] is False
  141. assert entry["state"] is None
  142. assert entry["current_print"] is None
  143. async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
  144. """A valid token for a printer id that doesn't exist is a 404 — the token
  145. passed the gate, the resource simply isn't there.
  146. """
  147. jwt = await _setup_admin(async_client, suffix="_404")
  148. overlay_token = await _mint(async_client, jwt, scope="overlay")
  149. response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
  150. assert response.status_code == 404
  151. class TestOverlayTokenReachesTheVideo:
  152. """The overlay draws the camera feed, so the same token has to satisfy the
  153. camera-stream gate.
  154. """
  155. async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
  156. from backend.app.core.auth import verify_camera_stream_token
  157. jwt = await _setup_admin(async_client, suffix="_video")
  158. overlay_token = await _mint(async_client, jwt, scope="overlay")
  159. assert await verify_camera_stream_token(overlay_token) is True
  160. async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
  161. from backend.app.core.auth import verify_overlay_token
  162. jwt = await _setup_admin(async_client, suffix="_gate")
  163. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  164. camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
  165. assert await verify_overlay_token(stream_token) is False
  166. assert await verify_overlay_token(camwall_token) is False
  167. async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
  168. """Symmetric guard: the new scope must not widen the Cam Wall either."""
  169. from backend.app.core.auth import verify_camwall_token
  170. jwt = await _setup_admin(async_client, suffix="_gate_camwall")
  171. overlay_token = await _mint(async_client, jwt, scope="overlay")
  172. assert await verify_camwall_token(overlay_token) is False