test_camwall_api.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Integration tests for the token-authenticated Cam Wall feed (#2531).
  2. The feature's whole reason for existing as a separate endpoint (rather than
  3. letting a token through to ``GET /printers``) is that a kiosk URL is not a
  4. secret. So the tests that matter here are the negative ones: what a Cam Wall
  5. token *cannot* reach, and what the payload *does not* contain.
  6. """
  7. from __future__ import annotations
  8. import pytest
  9. from httpx import AsyncClient
  10. from backend.app.core.printer_scope import ALL_PRINTERS
  11. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  12. async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
  13. await async_client.post(
  14. "/api/v1/auth/setup",
  15. json={
  16. "auth_enabled": True,
  17. "admin_username": f"camwalladmin{suffix}",
  18. "admin_password": "AdminPass1!",
  19. },
  20. )
  21. login = await async_client.post(
  22. "/api/v1/auth/login",
  23. json={"username": f"camwalladmin{suffix}", "password": "AdminPass1!"},
  24. )
  25. return login.json()["access_token"]
  26. async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "kiosk") -> str:
  27. response = await async_client.post(
  28. "/api/v1/auth/tokens",
  29. headers={"Authorization": f"Bearer {jwt}"},
  30. json={"name": name, "expires_in_days": 30, "scope": scope},
  31. )
  32. assert response.status_code == 201, response.text
  33. assert response.json()["scope"] == scope
  34. return response.json()["token"]
  35. @pytest.fixture
  36. async def printer_row(db_session):
  37. """Insert the printer straight into the DB.
  38. POST /printers probes the real device before it will store a row, and there
  39. is no printer on the other end of a test run.
  40. """
  41. from backend.app.models.printer import Printer
  42. printer = Printer(
  43. name="Wall P1S",
  44. ip_address="192.168.1.77",
  45. access_code="12345678",
  46. serial_number="01P00A000000001",
  47. model="P1S",
  48. )
  49. db_session.add(printer)
  50. await db_session.commit()
  51. return printer
  52. class TestCamWallFeedAuth:
  53. async def test_no_token_is_rejected(self, async_client: AsyncClient):
  54. await _setup_admin(async_client, suffix="_notoken")
  55. response = await async_client.get("/api/v1/camwall/printers")
  56. assert response.status_code == 401
  57. async def test_garbage_token_is_rejected(self, async_client: AsyncClient):
  58. await _setup_admin(async_client, suffix="_garbage")
  59. response = await async_client.get("/api/v1/camwall/printers?token=bblt_aaaaaaaa_nope")
  60. assert response.status_code == 401
  61. async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient):
  62. """The point of the separate scope.
  63. ``camera_stream`` tokens are already in the wild, minted by users who
  64. agreed to hand out *video*. Shipping the Cam Wall must not retroactively
  65. grant them the ability to enumerate printers by name.
  66. """
  67. jwt = await _setup_admin(async_client, suffix="_wrongscope")
  68. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  69. response = await async_client.get(f"/api/v1/camwall/printers?token={stream_token}")
  70. assert response.status_code == 401
  71. async def test_camwall_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
  72. jwt = await _setup_admin(async_client, suffix="_rightscope")
  73. camwall_token = await _mint(async_client, jwt, scope="camwall")
  74. response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
  75. assert response.status_code == 200, response.text
  76. body = response.json()
  77. assert len(body) == 1
  78. assert body[0]["name"] == "Wall P1S"
  79. async def test_revoked_camwall_token_is_rejected(self, async_client: AsyncClient):
  80. jwt = await _setup_admin(async_client, suffix="_revoked")
  81. created = await async_client.post(
  82. "/api/v1/auth/tokens",
  83. headers={"Authorization": f"Bearer {jwt}"},
  84. json={"name": "kiosk", "expires_in_days": 30, "scope": "camwall"},
  85. )
  86. camwall_token = created.json()["token"]
  87. await async_client.delete(
  88. f"/api/v1/auth/tokens/{created.json()['id']}",
  89. headers={"Authorization": f"Bearer {jwt}"},
  90. )
  91. response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
  92. assert response.status_code == 401
  93. class TestCamWallFeedPayload:
  94. async def test_payload_withholds_secrets_and_filenames(self, async_client: AsyncClient, printer_row):
  95. """A URL taped to a TV must not disclose more than the picture does.
  96. Serial number and IP ride along on the ordinary printer list even for
  97. non-secret callers, and the filename names the customer's part. None of
  98. the three may appear here.
  99. """
  100. jwt = await _setup_admin(async_client, suffix="_payload")
  101. camwall_token = await _mint(async_client, jwt, scope="camwall")
  102. response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
  103. assert response.status_code == 200
  104. entry = response.json()[0]
  105. for leaked in ("serial_number", "ip_address", "access_code", "subtask_name", "gcode_file"):
  106. assert leaked not in entry, f"{leaked} must not be served to a kiosk token"
  107. assert set(entry) == {
  108. "id",
  109. "name",
  110. "camera_rotation",
  111. "connected",
  112. "state",
  113. "progress",
  114. "remaining_time",
  115. "layer_num",
  116. "total_layers",
  117. "hms_errors",
  118. }
  119. async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
  120. """No MQTT client is running in tests, so the printer has no state at
  121. all — the tile must render as offline rather than blank.
  122. """
  123. jwt = await _setup_admin(async_client, suffix="_offline")
  124. camwall_token = await _mint(async_client, jwt, scope="camwall")
  125. response = await async_client.get(f"/api/v1/camwall/printers?token={camwall_token}")
  126. entry = response.json()[0]
  127. assert entry["connected"] is False
  128. assert entry["state"] is None
  129. assert entry["hms_errors"] == []
  130. class TestCamWallTokenReachesTheVideo:
  131. """A wall that can list the tiles but not fill them is useless — the same
  132. token has to satisfy the camera-stream gate.
  133. """
  134. async def test_camwall_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
  135. from backend.app.core.auth import verify_camera_stream_token
  136. jwt = await _setup_admin(async_client, suffix="_video")
  137. camwall_token = await _mint(async_client, jwt, scope="camwall")
  138. assert await verify_camera_stream_token(camwall_token) == ALL_PRINTERS # admin-owned: every printer (#1727)
  139. async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
  140. """Regression guard on #1108: widening the accepted scopes must not have
  141. broken the tokens that were already working.
  142. """
  143. from backend.app.core.auth import verify_camera_stream_token
  144. jwt = await _setup_admin(async_client, suffix="_video_legacy")
  145. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  146. assert await verify_camera_stream_token(stream_token) == ALL_PRINTERS # admin-owned: every printer (#1727)
  147. async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
  148. from backend.app.core.auth import verify_camwall_token
  149. jwt = await _setup_admin(async_client, suffix="_gate_narrow")
  150. stream_token = await _mint(async_client, jwt, scope="camera_stream")
  151. assert await verify_camwall_token(stream_token) is None
  152. class TestScopeValidation:
  153. async def test_unknown_scope_is_rejected_at_mint(self, async_client: AsyncClient):
  154. jwt = await _setup_admin(async_client, suffix="_badscope")
  155. response = await async_client.post(
  156. "/api/v1/auth/tokens",
  157. headers={"Authorization": f"Bearer {jwt}"},
  158. json={"name": "x", "expires_in_days": 30, "scope": "printers_write"},
  159. )
  160. assert response.status_code == 400
  161. assert "unsupported scope" in response.json()["detail"].lower()