test_api_security.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. from __future__ import annotations
  2. import json
  3. from datetime import UTC, datetime
  4. from pathlib import Path
  5. from types import SimpleNamespace
  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 (
  12. PhaseAdvanceResult,
  13. ScriptMissionStartResult,
  14. )
  15. from script_build_host.domain.artifacts import (
  16. ArtifactKind,
  17. ArtifactState,
  18. ArtifactVersion,
  19. EvidenceRecordV1,
  20. )
  21. from script_build_host.domain.errors import ArtifactNotFound
  22. from script_build_host.domain.records import BuildStatus, Principal
  23. from script_build_host.infrastructure.outbound import OutboundPolicy
  24. async def _public_resolver(_host: str, _port: int) -> tuple[str, ...]:
  25. return ("93.184.216.34",)
  26. class _PrincipalProvider:
  27. def __init__(self, principal: Principal | None) -> None:
  28. self.value = principal
  29. async def current(self, request_context: object | None = None) -> Principal:
  30. assert request_context is not None
  31. if self.value is None:
  32. raise PermissionError("missing")
  33. return self.value
  34. class _Authorizer:
  35. async def require_source_access(self, principal: Principal, **_source: int) -> None:
  36. if principal.subject != "owner":
  37. raise PermissionError("forbidden")
  38. async def require_access(self, principal: Principal, script_build_id: int) -> None:
  39. if principal.subject != "owner" or script_build_id != 7:
  40. raise PermissionError("forbidden")
  41. class _MissionService:
  42. def __init__(self) -> None:
  43. self.command = None
  44. self.advanced = False
  45. async def start(self, command: object) -> ScriptMissionStartResult:
  46. self.command = command
  47. return ScriptMissionStartResult(7, BuildStatus.RUNNING, "root-7", "11")
  48. async def stop(self, script_build_id: int, principal: Principal) -> object:
  49. del principal
  50. return SimpleNamespace(
  51. script_build_id=script_build_id,
  52. status=BuildStatus.STOPPED,
  53. stopped_operation_ids=(),
  54. )
  55. async def advance_to_phase_two(
  56. self, script_build_id: int, principal: Principal
  57. ) -> PhaseAdvanceResult:
  58. assert principal.subject == "owner"
  59. self.advanced = True
  60. return PhaseAdvanceResult(script_build_id, BuildStatus.RUNNING, "root-7", "11")
  61. class _Bindings:
  62. async def get_by_build(self, script_build_id: int) -> object:
  63. return SimpleNamespace(script_build_id=script_build_id, root_trace_id="root-7")
  64. class _TaskStore:
  65. def __init__(self) -> None:
  66. criterion = SimpleNamespace(criterion_id="criterion-1", description="safe", hard=True)
  67. current_spec = SimpleNamespace(
  68. version=1,
  69. objective="bounded task",
  70. acceptance_criteria=(criterion,),
  71. context_refs=("script-build://task-kinds/paragraph",),
  72. )
  73. self.task = SimpleNamespace(
  74. task_id="task-7",
  75. parent_task_id="root-task",
  76. display_path="Root/task-7",
  77. status="completed",
  78. current_spec=current_spec,
  79. child_task_ids=(),
  80. attempt_ids=(),
  81. validation_ids=(),
  82. decision_ids=(),
  83. blocked_reason=None,
  84. superseded_by=None,
  85. created_at="2026-07-19T00:00:00Z",
  86. updated_at="2026-07-19T00:00:00Z",
  87. )
  88. async def load(self, root_trace_id: str) -> object:
  89. del root_trace_id
  90. return SimpleNamespace(
  91. tasks={"task-7": self.task},
  92. to_dict=lambda: {
  93. "root_trace_id": "root-7",
  94. "protected_context": {"token": "must-not-leak"},
  95. "command_records": [{"arguments": "secret"}],
  96. },
  97. )
  98. def _app(
  99. principal: Principal | None,
  100. *,
  101. trace_store: object | None = None,
  102. uploaded_topics: object | None = None,
  103. business_artifacts: object | None = None,
  104. ) -> tuple[object, _MissionService]:
  105. mission = _MissionService()
  106. app = create_app(
  107. mission_service=mission,
  108. security=ApiSecurity(
  109. _PrincipalProvider(principal),
  110. _Authorizer(),
  111. websocket_allowed_origins=("https://ui.example",),
  112. ),
  113. bindings=_Bindings(),
  114. business_artifacts=business_artifacts or SimpleNamespace(),
  115. publications=SimpleNamespace(),
  116. coordinator=SimpleNamespace(task_store=_TaskStore()),
  117. trace_store=trace_store or SimpleNamespace(),
  118. uploaded_topics=uploaded_topics,
  119. outbound_policy=OutboundPolicy(
  120. frozenset({"data.example"}),
  121. resolver=_public_resolver,
  122. ),
  123. )
  124. return app, mission
  125. @pytest.mark.asyncio
  126. async def test_start_requires_auth_and_preserves_old_response_fields() -> None:
  127. unauthenticated, _ = _app(None)
  128. async with httpx.AsyncClient(
  129. transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
  130. ) as client:
  131. response = await client.post(
  132. "/api/pattern/script_builds",
  133. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  134. )
  135. assert response.status_code == 401
  136. forbidden, _ = _app(Principal("intruder"))
  137. async with httpx.AsyncClient(
  138. transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
  139. ) as client:
  140. response = await client.post(
  141. "/api/pattern/script_builds",
  142. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  143. )
  144. assert response.status_code == 404
  145. authenticated, mission = _app(Principal("owner"))
  146. legacy_request = json.loads(
  147. (Path(__file__).parent / "fixtures" / "legacy_script_build_request.json").read_text(
  148. encoding="utf-8"
  149. )
  150. )
  151. async with httpx.AsyncClient(
  152. transport=httpx.ASGITransport(app=authenticated), base_url="http://test"
  153. ) as client:
  154. response = await client.post(
  155. "/api/pattern/script_builds",
  156. json=legacy_request,
  157. )
  158. assert response.status_code == 200, response.text
  159. body = response.json()
  160. assert body["success"] is True
  161. assert body["script_build_id"] == 7
  162. assert body["status"] == "running"
  163. assert mission.command.strategies_always_on == (13,)
  164. assert len(mission.command.prompt_requests) >= 8
  165. assert mission.command.runtime_prompt_manifest == ()
  166. secret_config = dict(legacy_request)
  167. secret_config["agent_config"] = {
  168. **legacy_request["agent_config"],
  169. "api_key": "must-not-persist",
  170. }
  171. secret_response = await client.post(
  172. "/api/pattern/script_builds",
  173. json=secret_config,
  174. )
  175. assert secret_response.status_code == 200
  176. assert "must-not-persist" not in str(mission.command.agent_config)
  177. safe = await client.post(
  178. "/api/pattern/script_builds",
  179. json={
  180. "execution_id": 1,
  181. "topic_build_id": 2,
  182. "topic_id": 3,
  183. "data_source_url": "https://data.example/input",
  184. },
  185. )
  186. assert safe.status_code == 200
  187. assert mission.command.data_source_url == "https://data.example/input"
  188. assert mission.command.datasource_manifest == {
  189. "data_source_host": "data.example",
  190. "scheme": "https",
  191. }
  192. @pytest.mark.asyncio
  193. async def test_observation_is_build_scoped_and_control_routes_are_not_mounted() -> None:
  194. app, _ = _app(Principal("owner"))
  195. paths = set(app.openapi()["paths"])
  196. assert not any(path.endswith("/operations") for path in paths)
  197. assert not any(path.startswith("/api/traces") for path in paths)
  198. async with httpx.AsyncClient(
  199. transport=httpx.ASGITransport(app=app), base_url="http://test"
  200. ) as client:
  201. allowed = await client.get("/api/pattern/script_builds/7/mission")
  202. assert allowed.status_code == 200
  203. assert allowed.json() == {"root_trace_id": "root-7"}
  204. assert "must-not-leak" not in allowed.text
  205. denied = await client.get("/api/pattern/script_builds/8/mission")
  206. assert denied.status_code == 404
  207. unsafe = await client.post(
  208. "/api/pattern/script_builds",
  209. json={
  210. "execution_id": 1,
  211. "topic_build_id": 2,
  212. "topic_id": 3,
  213. "data_source_url": "http://127.0.0.1/private",
  214. },
  215. )
  216. assert unsafe.status_code == 400
  217. secret_query = await client.post(
  218. "/api/pattern/script_builds",
  219. json={
  220. "execution_id": 1,
  221. "topic_build_id": 2,
  222. "topic_id": 3,
  223. "data_source_url": "https://data.example/private?token=secret",
  224. },
  225. )
  226. assert secret_query.status_code == 400
  227. @pytest.mark.asyncio
  228. async def test_phase_two_advance_auth_and_hidden_not_found() -> None:
  229. unauthenticated, _ = _app(None)
  230. async with httpx.AsyncClient(
  231. transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
  232. ) as client:
  233. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  234. assert response.status_code == 401
  235. forbidden, forbidden_mission = _app(Principal("intruder"))
  236. async with httpx.AsyncClient(
  237. transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
  238. ) as client:
  239. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  240. assert response.status_code == 404
  241. assert response.json()["detail"]["error_code"] == "BUILD_NOT_FOUND"
  242. assert forbidden_mission.advanced is False
  243. allowed, mission = _app(Principal("owner"))
  244. async with httpx.AsyncClient(
  245. transport=httpx.ASGITransport(app=allowed), base_url="http://test"
  246. ) as client:
  247. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  248. assert response.status_code == 200
  249. assert response.json() == {
  250. "success": True,
  251. "script_build_id": 7,
  252. "status": "running",
  253. "root_trace_id": "root-7",
  254. "input_snapshot_id": "11",
  255. }
  256. assert mission.advanced is True
  257. def test_websocket_rejects_unauthenticated_subscription_before_accept() -> None:
  258. app, _ = _app(None)
  259. with TestClient(app) as client:
  260. with pytest.raises(WebSocketDisconnect) as caught:
  261. with client.websocket_connect("/api/pattern/script_builds/7/traces/root-7/watch"):
  262. pass
  263. assert caught.value.code == 4404
  264. class _Trace:
  265. def __init__(self, trace_id: str, root_trace_id: str | None = None) -> None:
  266. self.trace_id = trace_id
  267. self.context = {"root_trace_id": root_trace_id} if root_trace_id is not None else {}
  268. def to_dict(self) -> dict[str, str]:
  269. return {"trace_id": self.trace_id}
  270. class _TraceStore:
  271. async def get_trace(self, trace_id: str) -> _Trace | None:
  272. values = {
  273. "root-7": _Trace("root-7"),
  274. "worker-7": _Trace("worker-7", "root-7"),
  275. "worker-8": _Trace("worker-8", "root-8"),
  276. }
  277. return values.get(trace_id)
  278. async def get_goal_tree(self, trace_id: str) -> None:
  279. del trace_id
  280. return None
  281. async def get_events(self, trace_id: str, since: int) -> list[dict[str, object]]:
  282. assert trace_id == "root-7"
  283. del since
  284. return []
  285. class _BusinessArtifacts:
  286. def __init__(self) -> None:
  287. now = datetime.now(UTC)
  288. evidence = EvidenceRecordV1(
  289. evidence_id="evidence-api-1",
  290. source_type="decode",
  291. tool_name="retrieve_decode",
  292. query={"query": "safe query"},
  293. source_refs=("decode:item:1",),
  294. raw_artifact_ref=None,
  295. summary="authorized phase-two artifact content",
  296. supports=("scope:opening",),
  297. confidence="high",
  298. limitations=(),
  299. content_sha256="sha256:" + "a" * 64,
  300. created_at=now,
  301. )
  302. self.value = ArtifactVersion(
  303. artifact_version_id=71,
  304. script_build_id=7,
  305. task_id="task-7",
  306. attempt_id="attempt-7",
  307. spec_version=1,
  308. artifact_type=ArtifactKind.EVIDENCE,
  309. canonical_sha256="sha256:" + "b" * 64,
  310. state=ArtifactState.FROZEN,
  311. artifact=evidence,
  312. created_at=now,
  313. frozen_at=now,
  314. )
  315. async def get_by_id(self, artifact_version_id: int, *, script_build_id: int) -> ArtifactVersion:
  316. if artifact_version_id != 71 or script_build_id != 7:
  317. raise ArtifactNotFound()
  318. return self.value
  319. def test_websocket_requires_origin_and_accepts_authorized_build_trace() -> None:
  320. app, _ = _app(Principal("owner"), trace_store=_TraceStore())
  321. with TestClient(app) as client:
  322. with pytest.raises(WebSocketDisconnect) as caught:
  323. with client.websocket_connect(
  324. "/api/pattern/script_builds/7/traces/root-7/watch",
  325. headers={"origin": "https://evil.example"},
  326. ):
  327. pass
  328. assert caught.value.code == 4404
  329. with client.websocket_connect(
  330. "/api/pattern/script_builds/7/traces/root-7/watch",
  331. headers={"origin": "https://ui.example"},
  332. ) as websocket:
  333. websocket.send_text("ping")
  334. assert websocket.receive_json() == {"event": "pong"}
  335. for foreign_trace_id in ("worker-8", "root-8"):
  336. with pytest.raises(WebSocketDisconnect) as caught:
  337. with client.websocket_connect(
  338. f"/api/pattern/script_builds/7/traces/{foreign_trace_id}/watch",
  339. headers={"origin": "https://ui.example"},
  340. ):
  341. pass
  342. assert caught.value.code == 4404
  343. @pytest.mark.asyncio
  344. async def test_phase_two_observation_hides_foreign_resources_and_returns_owned_artifact() -> None:
  345. app, _ = _app(
  346. Principal("owner"),
  347. trace_store=_TraceStore(),
  348. business_artifacts=_BusinessArtifacts(),
  349. )
  350. async with httpx.AsyncClient(
  351. transport=httpx.ASGITransport(app=app), base_url="http://test"
  352. ) as client:
  353. owned_task = await client.get("/api/pattern/script_builds/7/tasks/task-7")
  354. assert owned_task.status_code == 200
  355. assert owned_task.json()["task_id"] == "task-7"
  356. foreign_task = await client.get("/api/pattern/script_builds/7/tasks/task-8")
  357. assert foreign_task.status_code == 404
  358. assert foreign_task.json()["detail"]["error_code"] == "TASK_NOT_FOUND"
  359. owned_artifact = await client.get("/api/pattern/script_builds/7/artifacts/71")
  360. assert owned_artifact.status_code == 200
  361. assert (
  362. owned_artifact.json()["artifact"]["summary"] == "authorized phase-two artifact content"
  363. )
  364. foreign_artifact = await client.get("/api/pattern/script_builds/7/artifacts/81")
  365. assert foreign_artifact.status_code == 404
  366. assert foreign_artifact.json()["error_code"] == "ARTIFACT_NOT_FOUND"
  367. owned_trace = await client.get("/api/pattern/script_builds/7/traces/worker-7")
  368. assert owned_trace.status_code == 200
  369. assert owned_trace.json()["trace"]["trace_id"] == "worker-7"
  370. foreign_trace = await client.get("/api/pattern/script_builds/7/traces/worker-8")
  371. assert foreign_trace.status_code == 404
  372. assert foreign_trace.json()["detail"]["error_code"] == "TRACE_NOT_FOUND"
  373. forged_root = await client.get("/api/v2/script-builds/7/roots/root-8/snapshot")
  374. assert forged_root.status_code == 404
  375. assert forged_root.json()["detail"]["error_code"] == "ROOT_NOT_FOUND"
  376. class _UploadedTopics:
  377. async def parse(self, _value: object) -> dict[str, object]:
  378. return {
  379. "account_name": "acct",
  380. "topic_fusion": "topic",
  381. "points": [{"point_type": "关键点"}],
  382. "item_count": 1,
  383. }
  384. async def create(
  385. self, _parsed: dict[str, object], *, account_name: str | None
  386. ) -> dict[str, object]:
  387. return {
  388. "topic_build_id": 22,
  389. "topic_id": 33,
  390. "account_name": account_name or "acct",
  391. "item_count": 1,
  392. "point_count": 1,
  393. }
  394. @pytest.mark.asyncio
  395. async def test_upload_parse_and_start_routes_keep_legacy_response_contract() -> None:
  396. app, mission = _app(Principal("owner"), uploaded_topics=_UploadedTopics())
  397. payload = {"选题融合": "topic"}
  398. async with httpx.AsyncClient(
  399. transport=httpx.ASGITransport(app=app), base_url="http://test"
  400. ) as client:
  401. parsed = await client.post(
  402. "/api/pattern/script_builds/parse_topic_json",
  403. json={"json_data": payload},
  404. )
  405. assert parsed.status_code == 200
  406. assert parsed.json()["point_type_stats"] == {"关键点": 1}
  407. started = await client.post(
  408. "/api/pattern/script_builds/from_topic_json",
  409. json={"json_data": payload, "account_name": "acct"},
  410. )
  411. assert started.status_code == 200
  412. assert started.json()["topic_build_id"] == 22
  413. assert started.json()["topic_id"] == 33
  414. assert mission.command.execution_id == 0