test_api_security.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. from __future__ import annotations
  2. import json
  3. from pathlib import Path
  4. from types import SimpleNamespace
  5. from typing import ClassVar
  6. import httpx
  7. import pytest
  8. from fastapi.testclient import TestClient
  9. from starlette.websockets import WebSocketDisconnect
  10. from script_build_host.api import ApiSecurity, create_app
  11. from script_build_host.application.mission_service import ScriptMissionStartResult
  12. from script_build_host.domain.records import BuildStatus, Principal
  13. from script_build_host.infrastructure.outbound import OutboundPolicy
  14. async def _public_resolver(_host: str, _port: int) -> tuple[str, ...]:
  15. return ("93.184.216.34",)
  16. class _PrincipalProvider:
  17. def __init__(self, principal: Principal | None) -> None:
  18. self.value = principal
  19. async def current(self, request_context: object | None = None) -> Principal:
  20. assert request_context is not None
  21. if self.value is None:
  22. raise PermissionError("missing")
  23. return self.value
  24. class _Authorizer:
  25. async def require_source_access(self, principal: Principal, **_source: int) -> None:
  26. if principal.subject != "owner":
  27. raise PermissionError("forbidden")
  28. async def require_access(self, principal: Principal, script_build_id: int) -> None:
  29. if principal.subject != "owner" or script_build_id != 7:
  30. raise PermissionError("forbidden")
  31. class _MissionService:
  32. def __init__(self) -> None:
  33. self.command = None
  34. async def start(self, command: object) -> ScriptMissionStartResult:
  35. self.command = command
  36. return ScriptMissionStartResult(7, BuildStatus.RUNNING, "root-7", "11")
  37. async def stop(self, script_build_id: int, principal: Principal) -> object:
  38. del principal
  39. return SimpleNamespace(
  40. script_build_id=script_build_id,
  41. status=BuildStatus.STOPPED,
  42. stopped_operation_ids=(),
  43. )
  44. class _Bindings:
  45. async def get_by_build(self, script_build_id: int) -> object:
  46. return SimpleNamespace(script_build_id=script_build_id, root_trace_id="root-7")
  47. class _TaskStore:
  48. async def load(self, root_trace_id: str) -> object:
  49. del root_trace_id
  50. return SimpleNamespace(to_dict=lambda: {"root_trace_id": "root-7"})
  51. def _app(
  52. principal: Principal | None,
  53. *,
  54. trace_store: object | None = None,
  55. uploaded_topics: object | None = None,
  56. ) -> tuple[object, _MissionService]:
  57. mission = _MissionService()
  58. app = create_app(
  59. mission_service=mission,
  60. security=ApiSecurity(
  61. _PrincipalProvider(principal),
  62. _Authorizer(),
  63. websocket_allowed_origins=("https://ui.example",),
  64. ),
  65. bindings=_Bindings(),
  66. business_artifacts=SimpleNamespace(),
  67. publications=SimpleNamespace(),
  68. coordinator=SimpleNamespace(task_store=_TaskStore()),
  69. trace_store=trace_store or SimpleNamespace(),
  70. uploaded_topics=uploaded_topics,
  71. outbound_policy=OutboundPolicy(
  72. frozenset({"data.example"}),
  73. resolver=_public_resolver,
  74. ),
  75. )
  76. return app, mission
  77. @pytest.mark.asyncio
  78. async def test_start_requires_auth_and_preserves_old_response_fields() -> None:
  79. unauthenticated, _ = _app(None)
  80. async with httpx.AsyncClient(
  81. transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
  82. ) as client:
  83. response = await client.post(
  84. "/api/pattern/script_builds",
  85. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  86. )
  87. assert response.status_code == 401
  88. forbidden, _ = _app(Principal("intruder"))
  89. async with httpx.AsyncClient(
  90. transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
  91. ) as client:
  92. response = await client.post(
  93. "/api/pattern/script_builds",
  94. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  95. )
  96. assert response.status_code == 404
  97. authenticated, mission = _app(Principal("owner"))
  98. legacy_request = json.loads(
  99. (Path(__file__).parent / "fixtures" / "legacy_script_build_request.json").read_text(
  100. encoding="utf-8"
  101. )
  102. )
  103. async with httpx.AsyncClient(
  104. transport=httpx.ASGITransport(app=authenticated), base_url="http://test"
  105. ) as client:
  106. response = await client.post(
  107. "/api/pattern/script_builds",
  108. json=legacy_request,
  109. )
  110. assert response.status_code == 200, response.text
  111. body = response.json()
  112. assert body["success"] is True
  113. assert body["script_build_id"] == 7
  114. assert body["status"] == "running"
  115. assert mission.command.strategies_always_on == (13,)
  116. assert len(mission.command.runtime_prompt_manifest) == 8
  117. secret_config = dict(legacy_request)
  118. secret_config["agent_config"] = {
  119. **legacy_request["agent_config"],
  120. "api_key": "must-not-persist",
  121. }
  122. secret_response = await client.post(
  123. "/api/pattern/script_builds",
  124. json=secret_config,
  125. )
  126. assert secret_response.status_code == 200
  127. assert "must-not-persist" not in str(mission.command.agent_config)
  128. safe = await client.post(
  129. "/api/pattern/script_builds",
  130. json={
  131. "execution_id": 1,
  132. "topic_build_id": 2,
  133. "topic_id": 3,
  134. "data_source_url": "https://data.example/input",
  135. },
  136. )
  137. assert safe.status_code == 200
  138. assert mission.command.data_source_url == "https://data.example/input"
  139. assert mission.command.datasource_manifest == {
  140. "data_source_host": "data.example",
  141. "scheme": "https",
  142. }
  143. @pytest.mark.asyncio
  144. async def test_observation_is_build_scoped_and_control_routes_are_not_mounted() -> None:
  145. app, _ = _app(Principal("owner"))
  146. paths = set(app.openapi()["paths"])
  147. assert not any(path.endswith("/operations") for path in paths)
  148. assert not any(path.startswith("/api/traces") for path in paths)
  149. async with httpx.AsyncClient(
  150. transport=httpx.ASGITransport(app=app), base_url="http://test"
  151. ) as client:
  152. allowed = await client.get("/api/pattern/script_builds/7/mission")
  153. assert allowed.status_code == 200
  154. denied = await client.get("/api/pattern/script_builds/8/mission")
  155. assert denied.status_code == 404
  156. unsafe = await client.post(
  157. "/api/pattern/script_builds",
  158. json={
  159. "execution_id": 1,
  160. "topic_build_id": 2,
  161. "topic_id": 3,
  162. "data_source_url": "http://127.0.0.1/private",
  163. },
  164. )
  165. assert unsafe.status_code == 400
  166. secret_query = await client.post(
  167. "/api/pattern/script_builds",
  168. json={
  169. "execution_id": 1,
  170. "topic_build_id": 2,
  171. "topic_id": 3,
  172. "data_source_url": "https://data.example/private?token=secret",
  173. },
  174. )
  175. assert secret_query.status_code == 400
  176. def test_websocket_rejects_unauthenticated_subscription_before_accept() -> None:
  177. app, _ = _app(None)
  178. with TestClient(app) as client:
  179. with pytest.raises(WebSocketDisconnect) as caught:
  180. with client.websocket_connect("/api/pattern/script_builds/7/traces/root-7/watch"):
  181. pass
  182. assert caught.value.code == 4404
  183. class _Trace:
  184. trace_id = "root-7"
  185. context: ClassVar[dict[str, str]] = {}
  186. def to_dict(self) -> dict[str, str]:
  187. return {"trace_id": self.trace_id}
  188. class _TraceStore:
  189. async def get_trace(self, trace_id: str) -> _Trace | None:
  190. return _Trace() if trace_id == "root-7" else None
  191. async def get_events(self, trace_id: str, since: int) -> list[dict[str, object]]:
  192. assert trace_id == "root-7"
  193. del since
  194. return []
  195. def test_websocket_requires_origin_and_accepts_authorized_build_trace() -> None:
  196. app, _ = _app(Principal("owner"), trace_store=_TraceStore())
  197. with TestClient(app) as client:
  198. with pytest.raises(WebSocketDisconnect) as caught:
  199. with client.websocket_connect(
  200. "/api/pattern/script_builds/7/traces/root-7/watch",
  201. headers={"origin": "https://evil.example"},
  202. ):
  203. pass
  204. assert caught.value.code == 4404
  205. with client.websocket_connect(
  206. "/api/pattern/script_builds/7/traces/root-7/watch",
  207. headers={"origin": "https://ui.example"},
  208. ) as websocket:
  209. websocket.send_text("ping")
  210. assert websocket.receive_json() == {"event": "pong"}
  211. class _UploadedTopics:
  212. async def parse(self, _value: object) -> dict[str, object]:
  213. return {
  214. "account_name": "acct",
  215. "topic_fusion": "topic",
  216. "points": [{"point_type": "关键点"}],
  217. "item_count": 1,
  218. }
  219. async def create(
  220. self, _parsed: dict[str, object], *, account_name: str | None
  221. ) -> dict[str, object]:
  222. return {
  223. "topic_build_id": 22,
  224. "topic_id": 33,
  225. "account_name": account_name or "acct",
  226. "item_count": 1,
  227. "point_count": 1,
  228. }
  229. @pytest.mark.asyncio
  230. async def test_upload_parse_and_start_routes_keep_legacy_response_contract() -> None:
  231. app, mission = _app(Principal("owner"), uploaded_topics=_UploadedTopics())
  232. payload = {"选题融合": "topic"}
  233. async with httpx.AsyncClient(
  234. transport=httpx.ASGITransport(app=app), base_url="http://test"
  235. ) as client:
  236. parsed = await client.post(
  237. "/api/pattern/script_builds/parse_topic_json",
  238. json={"json_data": payload},
  239. )
  240. assert parsed.status_code == 200
  241. assert parsed.json()["point_type_stats"] == {"关键点": 1}
  242. started = await client.post(
  243. "/api/pattern/script_builds/from_topic_json",
  244. json={"json_data": payload, "account_name": "acct"},
  245. )
  246. assert started.status_code == 200
  247. assert started.json()["topic_build_id"] == 22
  248. assert started.json()["topic_id"] == 33
  249. assert mission.command.execution_id == 0