初始化提交
This commit is contained in:
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"
|
||||
Reference in new issue
Block a user