test_camwall_api.py 8.1 KB

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