| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- """Trace API single-user local-development boundary."""
- import os
- import unittest
- from unittest.mock import AsyncMock, patch
- from fastapi.testclient import TestClient
- from starlette.websockets import WebSocketDisconnect
- import api_server
- from cyber_agent.trace import websocket as trace_websocket
- LOCAL_HOST = {"host": "127.0.0.1:8000"}
- LOCAL_ORIGIN = "http://127.0.0.1:3000"
- LOCAL_CLIENT = ("127.0.0.1", 50000)
- def make_client(peer=LOCAL_CLIENT):
- return TestClient(api_server.app, client=peer)
- class LocalApiBoundaryTest(unittest.TestCase):
- def setUp(self) -> None:
- self.list_patch = patch.object(
- api_server.trace_store,
- "list_traces",
- new=AsyncMock(return_value=[]),
- )
- self.list_patch.start()
- def tearDown(self) -> None:
- self.list_patch.stop()
- def test_health_declares_local_development_boundary(self) -> None:
- with make_client() as client:
- response = client.get("/health", headers=LOCAL_HOST)
- self.assertEqual(200, response.status_code)
- self.assertEqual("local_development", response.json()["deployment_mode"])
- self.assertEqual("loopback_only", response.json()["network_scope"])
- self.assertEqual("none", response.json()["authentication"])
- def test_http_rejects_non_local_host(self) -> None:
- for host in ("example.com", "api.internal.example"):
- with self.subTest(host=host), make_client() as client:
- response = client.get("/health", headers={"host": host})
- self.assertEqual(400, response.status_code)
- def test_http_rejects_non_loopback_peer_even_with_local_headers(self) -> None:
- with make_client(("192.0.2.10", 50000)) as client:
- response = client.get(
- "/health",
- headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
- )
- self.assertEqual(403, response.status_code)
- self.assertEqual("Loopback client required", response.json()["detail"])
- def test_http_accepts_ipv6_loopback_peer(self) -> None:
- with make_client(("::1", 50000)) as client:
- response = client.get("/health", headers=LOCAL_HOST)
- self.assertEqual(200, response.status_code)
- def test_cors_allows_only_declared_local_frontends(self) -> None:
- for origin in ("http://127.0.0.1:3000", "http://localhost:3000"):
- with self.subTest(origin=origin), make_client() as client:
- response = client.options(
- "/health",
- headers={
- **LOCAL_HOST,
- "origin": origin,
- "access-control-request-method": "GET",
- },
- )
- self.assertEqual(200, response.status_code)
- self.assertEqual(origin, response.headers["access-control-allow-origin"])
- self.assertNotIn("access-control-allow-credentials", response.headers)
- def test_cors_rejects_non_local_origin(self) -> None:
- with make_client() as client:
- response = client.options(
- "/health",
- headers={
- **LOCAL_HOST,
- "origin": "https://attacker.example",
- "access-control-request-method": "GET",
- },
- )
- self.assertEqual(403, response.status_code)
- with make_client() as client:
- simple = client.get(
- "/health",
- headers={
- **LOCAL_HOST,
- "origin": "https://attacker.example",
- },
- )
- self.assertEqual(403, simple.status_code)
- def test_websockets_reject_missing_or_non_local_origin(self) -> None:
- for path in (
- "/api/logs/watch",
- "/api/traces/not-a-real-trace/watch",
- "/ws_ping",
- ):
- for origin in (None, "https://attacker.example"):
- headers = dict(LOCAL_HOST)
- if origin is not None:
- headers["origin"] = origin
- with self.subTest(path=path, origin=origin), make_client() as client:
- with self.assertRaises(WebSocketDisconnect) as caught:
- with client.websocket_connect(path, headers=headers):
- pass
- self.assertEqual(1008, caught.exception.code)
- def test_websockets_reject_non_loopback_peer_even_with_local_headers(self) -> None:
- for path in (
- "/api/logs/watch",
- "/api/traces/not-a-real-trace/watch",
- "/ws_ping",
- ):
- with self.subTest(path=path), make_client(("192.0.2.10", 50000)) as client:
- with self.assertRaises(WebSocketDisconnect) as caught:
- with client.websocket_connect(
- path,
- headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
- ):
- pass
- self.assertEqual(1008, caught.exception.code)
- self.assertEqual("Loopback client required", caught.exception.reason)
- def test_local_logs_websocket_is_accepted(self) -> None:
- with make_client() as client:
- with client.websocket_connect(
- "/api/logs/watch",
- headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
- ) as websocket:
- message = websocket.receive_json()
- self.assertEqual("logs_websocket", message["name"])
- def test_local_trace_websocket_is_accepted(self) -> None:
- class _Trace:
- trace_id = "trace-1"
- last_event_id = 0
- status = "completed"
- parent_trace_id = None
- def to_dict(self):
- return {"trace_id": self.trace_id}
- class _Store:
- async def get_trace(self, trace_id):
- return _Trace() if trace_id == "trace-1" else None
- async def get_goal_tree(self, trace_id):
- return None
- async def list_traces(self, *, limit):
- return []
- async def get_events(self, trace_id, since_event_id):
- return []
- trace_websocket.set_trace_store(_Store())
- try:
- with make_client() as client:
- with client.websocket_connect(
- "/api/traces/trace-1/watch",
- headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
- ) as websocket:
- message = websocket.receive_json()
- finally:
- trace_websocket.set_trace_store(api_server.trace_store)
- self.assertEqual("connected", message["event"])
- def test_local_ping_websocket_is_accepted(self) -> None:
- with make_client() as client:
- with client.websocket_connect(
- "/ws_ping",
- headers={**LOCAL_HOST, "origin": LOCAL_ORIGIN},
- ) as websocket:
- self.assertEqual("pong", websocket.receive_text())
- def test_reload_requires_explicit_true(self) -> None:
- for value, expected in (
- (None, False),
- ("true", True),
- ("1", True),
- ("yes", True),
- ("on", True),
- ("false", False),
- ("unexpected", False),
- ):
- environment = {} if value is None else {"TRACE_API_RELOAD": value}
- with self.subTest(value=value), patch.dict(
- os.environ,
- environment,
- clear=False,
- ):
- if value is None:
- os.environ.pop("TRACE_API_RELOAD", None)
- self.assertIs(expected, api_server._env_flag("TRACE_API_RELOAD"))
- if __name__ == "__main__":
- unittest.main()
|