初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

@@ -0,0 +1,338 @@
"""沙箱客户端测试:用 respx 模拟 httpx,验证授权头注入、事件回写与取消。"""
from __future__ import annotations
import base64
import httpx
import pytest
import respx
from app.core.config import SandboxSettings
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.events import SandboxEvent
from app.integrations.sandbox.exceptions import (
SandboxBadRequest,
SandboxNotFound,
SandboxUnauthorized,
SandboxUnavailable,
)
from app.integrations.sandbox.models import (
ExecRequest,
FileListRequest,
FileReadRequest,
FileStatRequest,
FileWriteRequest,
)
def _settings(auth_enabled: bool = True) -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=auth_enabled,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ctx() -> AuthorizationContext:
return AuthorizationContext(trace_id="trace_1", risk_level="L1", tool="shell")
class _RecordingSink:
def __init__(self) -> None:
self.events: list[SandboxEvent] = []
async def emit(self, event: SandboxEvent) -> None:
self.events.append(event)
@respx.mock
async def test_exec_success_sends_bearer_and_emits_events() -> None:
route = respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 4,
"timeout": False,
"truncated": False,
"trace_id": "trace_1",
},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
resp = await sandbox.exec(ExecRequest(argv=["sh", "-c", "echo $((6*7))"]), _ctx())
assert route.called
assert resp.ok
assert resp.stdout == "42\n"
auth_header = route.calls.last.request.headers.get("authorization", "")
assert auth_header.startswith("Bearer ")
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
@respx.mock
async def test_exec_unauthorized_emits_failed_event() -> None:
respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
401,
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxUnauthorized):
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
assert sink.events[-1].type == "tool.failed"
assert sink.events[-1].payload["error_code"] == "sandbox_unauthorized"
@respx.mock
async def test_exec_network_error_raises_unavailable() -> None:
respx.post("http://sandbox.test/v1/exec").mock(side_effect=httpx.ConnectError("boom"))
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxUnavailable):
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
assert sink.events[-1].type == "tool.failed"
assert sink.events[-1].payload["error_code"] == "sandbox_unavailable"
@respx.mock
async def test_cancel_returns_true_on_hit() -> None:
respx.post("http://sandbox.test/v1/cancel").mock(
return_value=httpx.Response(200, json={"cancelled": True, "trace_id": "trace_1"})
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
assert await sandbox.cancel("trace_1", _ctx()) is True
@respx.mock
async def test_cancel_raises_not_found() -> None:
respx.post("http://sandbox.test/v1/cancel").mock(
return_value=httpx.Response(
404,
json={"error": {"code": "not_found", "message": "任务不存在"}},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
with pytest.raises(SandboxNotFound):
await sandbox.cancel("trace_1", _ctx())
@respx.mock
async def test_exec_stream_yields_stdout_then_done() -> None:
sse = (
b"event: stdout\n"
b"data: aGVsbG8K\n" # base64(urlsafe-no-pad) 片段
b"\n"
b"event: done\n"
b'data: {"exit_code":0,"duration_ms":5}\n'
b"\n"
)
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(200, content=sse)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
events = [
ev
async for ev in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "echo hi"]), _ctx())
]
assert [e.kind for e in events] == ["stdout", "done"]
assert events[0].payload == "aGVsbG8K"
assert events[1].payload["exit_code"] == 0
@respx.mock
async def test_exec_stream_propagates_unauthorized() -> None:
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(
401,
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
with pytest.raises(SandboxUnauthorized):
async for _ in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "x"]), _ctx()):
pass
async def test_auth_enabled_requires_secret() -> None:
settings = SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
with pytest.raises(ValueError):
SandboxClient(httpx.AsyncClient(), settings)
# ---- 文件 IO(file_op 凭证注入 + 事件回写 + 错误映射) ----
def _b64url(content: bytes) -> str:
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
@respx.mock
async def test_file_read_sends_credential_and_decodes_content() -> None:
"""read:注入 file_op 凭证 + started/completed 事件 + base64 解码回原内容。"""
content = b"hello-sandbox"
route = respx.post("http://sandbox.test/v1/file/read").mock(
return_value=httpx.Response(
200,
json={
"content_b64": _b64url(content),
"size": len(content),
"offset": 0,
"returned": len(content),
"truncated": False,
},
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
resp = await sandbox.file_read(FileReadRequest(path="data/note.txt"), _ctx())
assert route.called
assert resp.returned == len(content)
assert base64.urlsafe_b64decode(resp.content_b64 + "==") == content
assert route.calls.last.request.headers.get("authorization", "").startswith("Bearer ")
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
# argv 记 [file:read, path],供审计 input_summary
assert sink.events[0].payload["argv"] == ["file:read", "data/note.txt"]
@respx.mock
async def test_file_write_returns_bytes_written() -> None:
"""write:二进制安全(含 UTF-8 多字节),返回写入字节数。"""
content = "乾坤一炁".encode()
respx.post("http://sandbox.test/v1/file/write").mock(
return_value=httpx.Response(200, json={"bytes_written": len(content)})
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_write(
FileWriteRequest(path="out.txt", content_b64=_b64url(content)), _ctx()
)
assert resp.bytes_written == len(content)
@respx.mock
async def test_file_list_returns_entries() -> None:
respx.post("http://sandbox.test/v1/file/list").mock(
return_value=httpx.Response(
200,
json={"entries": [{"name": "note.txt", "kind": "file", "size": 12}]},
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_list(FileListRequest(), _ctx())
assert resp.entries[0].name == "note.txt"
assert resp.entries[0].kind == "file"
@respx.mock
async def test_file_stat_returns_metadata() -> None:
respx.post("http://sandbox.test/v1/file/stat").mock(
return_value=httpx.Response(
200, json={"kind": "file", "size": 42, "modified_unix": 1_700_000_000}
)
)
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings())
resp = await sandbox.file_stat(FileStatRequest(path="data/note.txt"), _ctx())
assert resp.kind == "file"
assert resp.size == 42
@respx.mock
async def test_file_read_maps_bad_request_to_exception() -> None:
"""路径越界/类型不符 → 400 → SandboxBadRequest + failed 事件。"""
respx.post("http://sandbox.test/v1/file/read").mock(
return_value=httpx.Response(
400, json={"error": {"code": "bad_request", "message": "路径越界"}}
)
)
sink = _RecordingSink()
async with httpx.AsyncClient() as client:
sandbox = SandboxClient(client, _settings(), sink)
with pytest.raises(SandboxBadRequest):
await sandbox.file_read(FileReadRequest(path="../../etc/passwd"), _ctx())
assert sink.events[-1].type == "tool.failed"
@respx.mock
async def test_exec_code_mode_signs_with_body_hash_code() -> None:
"""code 模式:授权凭证 body_hash 按 {language,code,stdin} 算,与 argv 模式不同。"""
from app.integrations.sandbox.auth import compute_body_hash_code, verify_authorization
route = respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "2\n",
"stderr": "",
"duration_ms": 4,
"timeout": False,
"truncated": False,
"trace_id": "trace_1",
"isolation_mode": "process",
},
)
)
async with httpx.AsyncClient() as client:
sc = SandboxClient(client, _settings(), None)
await sc.exec(ExecRequest(code="print(1+1)", language="python"), _ctx())
assert route.called
token = route.calls[0].request.headers["Authorization"].removeprefix("Bearer ")
payload = verify_authorization("shared-secret", token)
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
@respx.mock
async def test_exec_code_mode_response_carries_isolation_mode() -> None:
respx.post("http://sandbox.test/v1/exec").mock(
return_value=httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "",
"stderr": "",
"duration_ms": 1,
"timeout": False,
"truncated": False,
"trace_id": "t",
"isolation_mode": "process",
},
)
)
async with httpx.AsyncClient() as client:
sc = SandboxClient(client, _settings(), None)
resp = await sc.exec(ExecRequest(code="print(1)"), _ctx())
assert resp.isolation_mode == "process"