test_local_api_boundary.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """Trace API single-user local-development boundary."""
  2. import os
  3. import unittest
  4. from unittest.mock import AsyncMock, patch
  5. from fastapi.testclient import TestClient
  6. from starlette.websockets import WebSocketDisconnect
  7. import api_server
  8. from cyber_agent.trace import websocket as trace_websocket
  9. LOCAL_HOST = {"host": "127.0.0.1:8000"}
  10. LOCAL_ORIGIN = "http://127.0.0.1:3000"
  11. LOCAL_CLIENT = ("127.0.0.1", 50000)
  12. def make_client(peer=LOCAL_CLIENT):
  13. return TestClient(api_server.app, client=peer)
  14. class LocalApiBoundaryTest(unittest.TestCase):
  15. def setUp(self) -> None:
  16. self.list_patch = patch.object(
  17. api_server.trace_store,
  18. "list_traces",
  19. new=AsyncMock(return_value=[]),
  20. )
  21. self.list_patch.start()
  22. def tearDown(self) -> None:
  23. self.list_patch.stop()
  24. def test_health_declares_local_development_boundary(self) -> None:
  25. with make_client() as client:
  26. response = client.get("/health", headers=LOCAL_HOST)
  27. self.assertEqual(200, response.status_code)
  28. self.assertEqual("local_development", response.json()["deployment_mode"])
  29. self.assertEqual("loopback_only", response.json()["network_scope"])
  30. self.assertEqual("none", response.json()["authentication"])
  31. def test_http_rejects_non_local_host(self) -> None:
  32. for host in ("example.com", "api.internal.example"):
  33. with self.subTest(host=host), make_client() as client:
  34. response = client.get("/health", headers={"host": host})
  35. self.assertEqual(400, response.status_code)
  36. def test_http_rejects_non_loopback_peer_even_with_local_headers(self) -> None:
  37. with make_client(("192.0.2.10", 50000)) as client:
  38. response = client.get(
  39. "/health",
  40. headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
  41. )
  42. self.assertEqual(403, response.status_code)
  43. self.assertEqual("Loopback client required", response.json()["detail"])
  44. def test_http_accepts_ipv6_loopback_peer(self) -> None:
  45. with make_client(("::1", 50000)) as client:
  46. response = client.get("/health", headers=LOCAL_HOST)
  47. self.assertEqual(200, response.status_code)
  48. def test_cors_allows_only_declared_local_frontends(self) -> None:
  49. for origin in ("http://127.0.0.1:3000", "http://localhost:3000"):
  50. with self.subTest(origin=origin), make_client() as client:
  51. response = client.options(
  52. "/health",
  53. headers={
  54. **LOCAL_HOST,
  55. "origin": origin,
  56. "access-control-request-method": "GET",
  57. },
  58. )
  59. self.assertEqual(200, response.status_code)
  60. self.assertEqual(origin, response.headers["access-control-allow-origin"])
  61. self.assertNotIn("access-control-allow-credentials", response.headers)
  62. def test_cors_rejects_non_local_origin(self) -> None:
  63. with make_client() as client:
  64. response = client.options(
  65. "/health",
  66. headers={
  67. **LOCAL_HOST,
  68. "origin": "https://attacker.example",
  69. "access-control-request-method": "GET",
  70. },
  71. )
  72. self.assertEqual(403, response.status_code)
  73. with make_client() as client:
  74. simple = client.get(
  75. "/health",
  76. headers={
  77. **LOCAL_HOST,
  78. "origin": "https://attacker.example",
  79. },
  80. )
  81. self.assertEqual(403, simple.status_code)
  82. def test_websockets_reject_missing_or_non_local_origin(self) -> None:
  83. for path in (
  84. "/api/logs/watch",
  85. "/api/traces/not-a-real-trace/watch",
  86. "/ws_ping",
  87. ):
  88. for origin in (None, "https://attacker.example"):
  89. headers = dict(LOCAL_HOST)
  90. if origin is not None:
  91. headers["origin"] = origin
  92. with self.subTest(path=path, origin=origin), make_client() as client:
  93. with self.assertRaises(WebSocketDisconnect) as caught:
  94. with client.websocket_connect(path, headers=headers):
  95. pass
  96. self.assertEqual(1008, caught.exception.code)
  97. def test_websockets_reject_non_loopback_peer_even_with_local_headers(self) -> None:
  98. for path in (
  99. "/api/logs/watch",
  100. "/api/traces/not-a-real-trace/watch",
  101. "/ws_ping",
  102. ):
  103. with self.subTest(path=path), make_client(("192.0.2.10", 50000)) as client:
  104. with self.assertRaises(WebSocketDisconnect) as caught:
  105. with client.websocket_connect(
  106. path,
  107. headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
  108. ):
  109. pass
  110. self.assertEqual(1008, caught.exception.code)
  111. self.assertEqual("Loopback client required", caught.exception.reason)
  112. def test_local_logs_websocket_is_accepted(self) -> None:
  113. with make_client() as client:
  114. with client.websocket_connect(
  115. "/api/logs/watch",
  116. headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
  117. ) as websocket:
  118. message = websocket.receive_json()
  119. self.assertEqual("logs_websocket", message["name"])
  120. def test_local_trace_websocket_is_accepted(self) -> None:
  121. class _Trace:
  122. trace_id = "trace-1"
  123. last_event_id = 0
  124. status = "completed"
  125. parent_trace_id = None
  126. def to_dict(self):
  127. return {"trace_id": self.trace_id}
  128. class _Store:
  129. async def get_trace(self, trace_id):
  130. return _Trace() if trace_id == "trace-1" else None
  131. async def get_goal_tree(self, trace_id):
  132. return None
  133. async def list_traces(self, *, limit):
  134. return []
  135. async def get_events(self, trace_id, since_event_id):
  136. return []
  137. trace_websocket.set_trace_store(_Store())
  138. try:
  139. with make_client() as client:
  140. with client.websocket_connect(
  141. "/api/traces/trace-1/watch",
  142. headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
  143. ) as websocket:
  144. message = websocket.receive_json()
  145. finally:
  146. trace_websocket.set_trace_store(api_server.trace_store)
  147. self.assertEqual("connected", message["event"])
  148. def test_local_ping_websocket_is_accepted(self) -> None:
  149. with make_client() as client:
  150. with client.websocket_connect(
  151. "/ws_ping",
  152. headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
  153. ) as websocket:
  154. self.assertEqual("pong", websocket.receive_text())
  155. def test_reload_requires_explicit_true(self) -> None:
  156. for value, expected in (
  157. (None, False),
  158. ("true", True),
  159. ("1", True),
  160. ("yes", True),
  161. ("on", True),
  162. ("false", False),
  163. ("unexpected", False),
  164. ):
  165. environment = {} if value is None else {"TRACE_API_RELOAD": value}
  166. with self.subTest(value=value), patch.dict(
  167. os.environ,
  168. environment,
  169. clear=False,
  170. ):
  171. if value is None:
  172. os.environ.pop("TRACE_API_RELOAD", None)
  173. self.assertIs(expected, api_server._env_flag("TRACE_API_RELOAD"))
  174. if __name__ == "__main__":
  175. unittest.main()