test_api_security.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  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(
  64. script_build_id=script_build_id,
  65. root_trace_id="root-7",
  66. input_snapshot_id=11,
  67. )
  68. _CONTRACT_REF = "script-build://task-contracts/sha256/" + "c" * 64
  69. class _TaskStore:
  70. def __init__(self) -> None:
  71. criterion = SimpleNamespace(criterion_id="criterion-1", description="safe", hard=True)
  72. current_spec = SimpleNamespace(
  73. version=1,
  74. objective="bounded task",
  75. acceptance_criteria=(criterion,),
  76. context_refs=("script-build://task-kinds/paragraph", _CONTRACT_REF),
  77. )
  78. self.task = SimpleNamespace(
  79. task_id="task-7",
  80. parent_task_id="root-task",
  81. display_path="Root/task-7",
  82. status="completed",
  83. current_spec=current_spec,
  84. current_spec_version=1,
  85. specs=[current_spec],
  86. child_task_ids=(),
  87. attempt_ids=(),
  88. validation_ids=(),
  89. decision_ids=(),
  90. blocked_reason=None,
  91. superseded_by=None,
  92. created_at="2026-07-19T00:00:00Z",
  93. updated_at="2026-07-19T00:00:00Z",
  94. )
  95. async def load(self, root_trace_id: str) -> object:
  96. del root_trace_id
  97. return SimpleNamespace(
  98. tasks={"task-7": self.task},
  99. to_dict=lambda: {
  100. "root_trace_id": "root-7",
  101. "protected_context": {"token": "must-not-leak"},
  102. "command_records": [{"arguments": "secret"}],
  103. },
  104. )
  105. class _Contract:
  106. def to_payload(self) -> dict[str, object]:
  107. return {
  108. "schema_version": "script-task-contract/v1",
  109. "task_kind": "paragraph",
  110. "scope_ref": "script-build://scopes/body",
  111. "intent_class": "explore",
  112. "objective": "write the evidence paragraph",
  113. "input_decision_refs": [],
  114. "base_artifact_ref": None,
  115. "write_scope": ["script-build://scopes/body"],
  116. "gap_ref": None,
  117. "output_schema": "paragraph-artifact/v1",
  118. "criteria": [
  119. {"criterion_id": "grounded", "description": "grounded", "hard": True}
  120. ],
  121. "budget": {"max_tokens": 1},
  122. "goal_ids": ["goal-1"],
  123. "supersedes_decision_ids": [],
  124. "candidate_closure_decision_refs": [],
  125. "adopted_decision_ids": [],
  126. "held_or_rejected_decision_ids": [],
  127. "compose_order": [],
  128. "comparison_decision_refs": [],
  129. }
  130. class _Contracts:
  131. async def read(self, root_trace_id: str, uri: str) -> object:
  132. assert root_trace_id == "root-7"
  133. assert uri == _CONTRACT_REF
  134. return SimpleNamespace(
  135. uri=uri,
  136. digest="sha256:" + "c" * 64,
  137. contract=_Contract(),
  138. )
  139. class _InputSnapshots:
  140. async def get(self, snapshot_id: str, *, script_build_id: int) -> object:
  141. assert snapshot_id == "11"
  142. assert script_build_id == 7
  143. return SimpleNamespace(
  144. snapshot_id="11",
  145. script_build_id=7,
  146. execution_id=1,
  147. topic_build_id=2,
  148. topic_id=3,
  149. topic={"topic": {"result": "How one reversal changes a belief"}},
  150. account={"account_name": "acct", "resolved_account_name": "acct"},
  151. persona_points=({"point_type": "voice"},),
  152. section_patterns=({}, {}),
  153. strategies=(
  154. {
  155. "id": 5,
  156. "name": "contrast",
  157. "description": "show the turn clearly",
  158. "content": "private strategy body",
  159. },
  160. ),
  161. canonical_sha256="sha256:" + "d" * 64,
  162. )
  163. def _app(
  164. principal: Principal | None,
  165. *,
  166. trace_store: object | None = None,
  167. uploaded_topics: object | None = None,
  168. business_artifacts: object | None = None,
  169. task_contract_store: object | None = None,
  170. input_snapshots: object | None = None,
  171. ) -> tuple[object, _MissionService]:
  172. mission = _MissionService()
  173. app = create_app(
  174. mission_service=mission,
  175. security=ApiSecurity(
  176. _PrincipalProvider(principal),
  177. _Authorizer(),
  178. websocket_allowed_origins=("https://ui.example",),
  179. ),
  180. bindings=_Bindings(),
  181. business_artifacts=business_artifacts or SimpleNamespace(),
  182. publications=SimpleNamespace(),
  183. coordinator=SimpleNamespace(task_store=_TaskStore()),
  184. trace_store=trace_store or SimpleNamespace(),
  185. uploaded_topics=uploaded_topics,
  186. outbound_policy=OutboundPolicy(
  187. frozenset({"data.example"}),
  188. resolver=_public_resolver,
  189. ),
  190. task_contract_store=task_contract_store,
  191. input_snapshots=input_snapshots,
  192. )
  193. return app, mission
  194. @pytest.mark.asyncio
  195. async def test_start_requires_auth_and_preserves_old_response_fields() -> None:
  196. unauthenticated, _ = _app(None)
  197. async with httpx.AsyncClient(
  198. transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
  199. ) as client:
  200. response = await client.post(
  201. "/api/pattern/script_builds",
  202. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  203. )
  204. assert response.status_code == 401
  205. forbidden, _ = _app(Principal("intruder"))
  206. async with httpx.AsyncClient(
  207. transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
  208. ) as client:
  209. response = await client.post(
  210. "/api/pattern/script_builds",
  211. json={"execution_id": 1, "topic_build_id": 2, "topic_id": 3},
  212. )
  213. assert response.status_code == 404
  214. authenticated, mission = _app(Principal("owner"))
  215. legacy_request = json.loads(
  216. (Path(__file__).parent / "fixtures" / "legacy_script_build_request.json").read_text(
  217. encoding="utf-8"
  218. )
  219. )
  220. async with httpx.AsyncClient(
  221. transport=httpx.ASGITransport(app=authenticated), base_url="http://test"
  222. ) as client:
  223. response = await client.post(
  224. "/api/pattern/script_builds",
  225. json=legacy_request,
  226. )
  227. assert response.status_code == 200, response.text
  228. body = response.json()
  229. assert body["success"] is True
  230. assert body["script_build_id"] == 7
  231. assert body["status"] == "running"
  232. assert mission.command.strategies_always_on == (13,)
  233. assert len(mission.command.prompt_requests) >= 8
  234. assert mission.command.runtime_prompt_manifest == ()
  235. secret_config = dict(legacy_request)
  236. secret_config["agent_config"] = {
  237. **legacy_request["agent_config"],
  238. "api_key": "must-not-persist",
  239. }
  240. secret_response = await client.post(
  241. "/api/pattern/script_builds",
  242. json=secret_config,
  243. )
  244. assert secret_response.status_code == 200
  245. assert "must-not-persist" not in str(mission.command.agent_config)
  246. safe = await client.post(
  247. "/api/pattern/script_builds",
  248. json={
  249. "execution_id": 1,
  250. "topic_build_id": 2,
  251. "topic_id": 3,
  252. "data_source_url": "https://data.example/input",
  253. },
  254. )
  255. assert safe.status_code == 200
  256. assert mission.command.data_source_url == "https://data.example/input"
  257. assert mission.command.datasource_manifest == {
  258. "data_source_host": "data.example",
  259. "scheme": "https",
  260. }
  261. @pytest.mark.asyncio
  262. async def test_observation_is_build_scoped_and_control_routes_are_not_mounted() -> None:
  263. app, _ = _app(Principal("owner"))
  264. paths = set(app.openapi()["paths"])
  265. assert not any(path.endswith("/operations") for path in paths)
  266. assert not any(path.startswith("/api/traces") for path in paths)
  267. assert (
  268. "patch" in app.openapi()["paths"]["/api/pattern/script_builds/{script_build_id}/favorite"]
  269. )
  270. assert "/api/pattern/script_builds/overview/{topic_build_id}" in paths
  271. assert "/api/pattern/script_builds/{script_build_id}/trace_messages" in paths
  272. assert "/script_build_detail/{script_build_id}" in paths
  273. async with httpx.AsyncClient(
  274. transport=httpx.ASGITransport(app=app), base_url="http://test"
  275. ) as client:
  276. allowed = await client.get("/api/pattern/script_builds/7/mission")
  277. assert allowed.status_code == 200
  278. assert allowed.json() == {"root_trace_id": "root-7"}
  279. assert "must-not-leak" not in allowed.text
  280. denied = await client.get("/api/pattern/script_builds/8/mission")
  281. assert denied.status_code == 404
  282. unsafe = await client.post(
  283. "/api/pattern/script_builds",
  284. json={
  285. "execution_id": 1,
  286. "topic_build_id": 2,
  287. "topic_id": 3,
  288. "data_source_url": "http://127.0.0.1/private",
  289. },
  290. )
  291. assert unsafe.status_code == 400
  292. secret_query = await client.post(
  293. "/api/pattern/script_builds",
  294. json={
  295. "execution_id": 1,
  296. "topic_build_id": 2,
  297. "topic_id": 3,
  298. "data_source_url": "https://data.example/private?token=secret",
  299. },
  300. )
  301. assert secret_query.status_code == 400
  302. @pytest.mark.asyncio
  303. async def test_phase_two_advance_auth_and_hidden_not_found() -> None:
  304. unauthenticated, _ = _app(None)
  305. async with httpx.AsyncClient(
  306. transport=httpx.ASGITransport(app=unauthenticated), base_url="http://test"
  307. ) as client:
  308. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  309. assert response.status_code == 401
  310. forbidden, forbidden_mission = _app(Principal("intruder"))
  311. async with httpx.AsyncClient(
  312. transport=httpx.ASGITransport(app=forbidden), base_url="http://test"
  313. ) as client:
  314. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  315. assert response.status_code == 404
  316. assert response.json()["detail"]["error_code"] == "BUILD_NOT_FOUND"
  317. assert forbidden_mission.advanced is False
  318. allowed, mission = _app(Principal("owner"))
  319. async with httpx.AsyncClient(
  320. transport=httpx.ASGITransport(app=allowed), base_url="http://test"
  321. ) as client:
  322. response = await client.post("/api/pattern/script_builds/7/phase-two/advance")
  323. assert response.status_code == 200
  324. assert response.json() == {
  325. "success": True,
  326. "script_build_id": 7,
  327. "status": "running",
  328. "root_trace_id": "root-7",
  329. "input_snapshot_id": "11",
  330. }
  331. assert mission.advanced is True
  332. def test_websocket_rejects_unauthenticated_subscription_before_accept() -> None:
  333. app, _ = _app(None)
  334. with TestClient(app) as client:
  335. with pytest.raises(WebSocketDisconnect) as caught:
  336. with client.websocket_connect("/api/pattern/script_builds/7/traces/root-7/watch"):
  337. pass
  338. assert caught.value.code == 4404
  339. class _Trace:
  340. def __init__(self, trace_id: str, root_trace_id: str | None = None) -> None:
  341. self.trace_id = trace_id
  342. self.context = {"root_trace_id": root_trace_id} if root_trace_id is not None else {}
  343. def to_dict(self) -> dict[str, str]:
  344. return {"trace_id": self.trace_id}
  345. class _TraceStore:
  346. async def get_trace(self, trace_id: str) -> _Trace | None:
  347. values = {
  348. "root-7": _Trace("root-7"),
  349. "worker-7": _Trace("worker-7", "root-7"),
  350. "worker-8": _Trace("worker-8", "root-8"),
  351. }
  352. return values.get(trace_id)
  353. async def get_goal_tree(self, trace_id: str) -> None:
  354. del trace_id
  355. return None
  356. async def get_events(self, trace_id: str, since: int) -> list[dict[str, object]]:
  357. assert trace_id == "root-7"
  358. del since
  359. return []
  360. async def get_trace_messages(self, trace_id: str) -> list[object]:
  361. assert trace_id in {"root-7", "worker-7"}
  362. return [
  363. SimpleNamespace(sequence=value, to_dict=lambda value=value: {"sequence": value})
  364. for value in (1, 2, 3)
  365. ]
  366. class _BusinessArtifacts:
  367. def __init__(self) -> None:
  368. now = datetime.now(UTC)
  369. evidence = EvidenceRecordV1(
  370. evidence_id="evidence-api-1",
  371. source_type="decode",
  372. tool_name="retrieve_decode",
  373. query={"query": "safe query"},
  374. source_refs=("decode:item:1",),
  375. raw_artifact_ref=None,
  376. summary="authorized phase-two artifact content",
  377. supports=("scope:opening",),
  378. confidence="high",
  379. limitations=(),
  380. content_sha256="sha256:" + "a" * 64,
  381. created_at=now,
  382. )
  383. self.value = ArtifactVersion(
  384. artifact_version_id=71,
  385. script_build_id=7,
  386. task_id="task-7",
  387. attempt_id="attempt-7",
  388. spec_version=1,
  389. artifact_type=ArtifactKind.EVIDENCE,
  390. canonical_sha256="sha256:" + "b" * 64,
  391. state=ArtifactState.FROZEN,
  392. artifact=evidence,
  393. created_at=now,
  394. frozen_at=now,
  395. )
  396. async def get_by_id(self, artifact_version_id: int, *, script_build_id: int) -> ArtifactVersion:
  397. if artifact_version_id != 71 or script_build_id != 7:
  398. raise ArtifactNotFound()
  399. return self.value
  400. def test_websocket_requires_origin_and_accepts_authorized_build_trace() -> None:
  401. app, _ = _app(Principal("owner"), trace_store=_TraceStore())
  402. with TestClient(app) as client:
  403. with pytest.raises(WebSocketDisconnect) as caught:
  404. with client.websocket_connect(
  405. "/api/pattern/script_builds/7/traces/root-7/watch",
  406. headers={"origin": "https://evil.example"},
  407. ):
  408. pass
  409. assert caught.value.code == 4404
  410. with client.websocket_connect(
  411. "/api/pattern/script_builds/7/traces/root-7/watch",
  412. headers={"origin": "https://ui.example"},
  413. ) as websocket:
  414. websocket.send_text("ping")
  415. assert websocket.receive_json() == {"event": "pong"}
  416. for foreign_trace_id in ("worker-8", "root-8"):
  417. with pytest.raises(WebSocketDisconnect) as caught:
  418. with client.websocket_connect(
  419. f"/api/pattern/script_builds/7/traces/{foreign_trace_id}/watch",
  420. headers={"origin": "https://ui.example"},
  421. ):
  422. pass
  423. assert caught.value.code == 4404
  424. @pytest.mark.asyncio
  425. async def test_phase_two_observation_hides_foreign_resources_and_returns_owned_artifact() -> None:
  426. app, _ = _app(
  427. Principal("owner"),
  428. trace_store=_TraceStore(),
  429. business_artifacts=_BusinessArtifacts(),
  430. )
  431. async with httpx.AsyncClient(
  432. transport=httpx.ASGITransport(app=app), base_url="http://test"
  433. ) as client:
  434. owned_task = await client.get("/api/pattern/script_builds/7/tasks/task-7")
  435. assert owned_task.status_code == 200
  436. assert owned_task.json()["task_id"] == "task-7"
  437. foreign_task = await client.get("/api/pattern/script_builds/7/tasks/task-8")
  438. assert foreign_task.status_code == 404
  439. assert foreign_task.json()["detail"]["error_code"] == "TASK_NOT_FOUND"
  440. owned_artifact = await client.get("/api/pattern/script_builds/7/artifacts/71")
  441. assert owned_artifact.status_code == 200
  442. assert (
  443. owned_artifact.json()["artifact"]["summary"] == "authorized phase-two artifact content"
  444. )
  445. foreign_artifact = await client.get("/api/pattern/script_builds/7/artifacts/81")
  446. assert foreign_artifact.status_code == 404
  447. assert foreign_artifact.json()["error_code"] == "ARTIFACT_NOT_FOUND"
  448. owned_trace = await client.get("/api/pattern/script_builds/7/traces/worker-7")
  449. assert owned_trace.status_code == 200
  450. assert owned_trace.json()["trace"]["trace_id"] == "worker-7"
  451. assert owned_trace.json()["goal_tree"] is None
  452. incremental_messages = await client.get(
  453. "/api/pattern/script_builds/7/traces/worker-7/messages?after_sequence=1"
  454. )
  455. assert incremental_messages.status_code == 200
  456. assert incremental_messages.json() == {
  457. "messages": [{"sequence": 2}, {"sequence": 3}]
  458. }
  459. foreign_trace = await client.get("/api/pattern/script_builds/7/traces/worker-8")
  460. assert foreign_trace.status_code == 404
  461. assert foreign_trace.json()["detail"]["error_code"] == "TRACE_NOT_FOUND"
  462. forged_root = await client.get("/api/v2/script-builds/7/roots/root-8/snapshot")
  463. assert forged_root.status_code == 404
  464. assert forged_root.json()["detail"]["error_code"] == "ROOT_NOT_FOUND"
  465. @pytest.mark.asyncio
  466. async def test_safe_contract_and_input_summaries_are_build_scoped() -> None:
  467. app, _ = _app(
  468. Principal("owner"),
  469. task_contract_store=_Contracts(),
  470. input_snapshots=_InputSnapshots(),
  471. )
  472. async with httpx.AsyncClient(
  473. transport=httpx.ASGITransport(app=app), base_url="http://test"
  474. ) as client:
  475. contract = await client.get(
  476. "/api/pattern/script_builds/7/tasks/task-7/contract"
  477. )
  478. assert contract.status_code == 200, contract.text
  479. assert contract.json()["task_kind"] == "paragraph"
  480. assert contract.json()["goal_ids"] == ["goal-1"]
  481. assert contract.json()["current_spec_version"] == 1
  482. assert len(contract.json()["versions"]) == 1
  483. assert "budget" not in contract.json()
  484. summary = await client.get("/api/pattern/script_builds/7/input-summary")
  485. assert summary.status_code == 200, summary.text
  486. payload = summary.json()
  487. assert payload["topic"] == "How one reversal changes a belief"
  488. assert payload["persona"]["point_types"] == {"voice": 1}
  489. assert payload["strategies"][0]["name"] == "contrast"
  490. assert "private strategy body" not in summary.text
  491. denied = await client.get("/api/pattern/script_builds/8/input-summary")
  492. assert denied.status_code == 404
  493. class _UploadedTopics:
  494. async def parse(self, _value: object) -> dict[str, object]:
  495. return {
  496. "account_name": "acct",
  497. "topic_fusion": "topic",
  498. "points": [{"point_type": "关键点"}],
  499. "item_count": 1,
  500. }
  501. async def create(
  502. self, _parsed: dict[str, object], *, account_name: str | None
  503. ) -> dict[str, object]:
  504. return {
  505. "topic_build_id": 22,
  506. "topic_id": 33,
  507. "account_name": account_name or "acct",
  508. "item_count": 1,
  509. "point_count": 1,
  510. }
  511. @pytest.mark.asyncio
  512. async def test_upload_parse_and_start_routes_keep_legacy_response_contract() -> None:
  513. app, mission = _app(Principal("owner"), uploaded_topics=_UploadedTopics())
  514. payload = {"选题融合": "topic"}
  515. async with httpx.AsyncClient(
  516. transport=httpx.ASGITransport(app=app), base_url="http://test"
  517. ) as client:
  518. parsed = await client.post(
  519. "/api/pattern/script_builds/parse_topic_json",
  520. json={"json_data": payload},
  521. )
  522. assert parsed.status_code == 200
  523. assert parsed.json()["point_type_stats"] == {"关键点": 1}
  524. started = await client.post(
  525. "/api/pattern/script_builds/from_topic_json",
  526. json={"json_data": payload, "account_name": "acct"},
  527. )
  528. assert started.status_code == 200
  529. assert started.json()["topic_build_id"] == 22
  530. assert started.json()["topic_id"] == 33
  531. assert mission.command.execution_id == 0