Files
hunjing/backend/tests/integrations/sandbox/test_e2e.py
T
2026-08-21 16:00:13 +08:00

313 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""跨端集成测试:起真实 Rust 沙箱进程,验证后端 SandboxClient 全链路。
覆盖 mock(respx)测试无法触及的真实跨语言契约:HMAC 授权(body_hash 两端一致)、
同步 exec、超时 SIGKILL 进程组、流式 SSE(exec_stream)、cancel。
前置:沙箱二进制需存在;fixture 检测不到时自动 ``cargo build``,失败则 skip。
fixture 为 session 级,多测试共享一个沙箱进程(各用独立 trace_id / 临时工作目录)。
"""
from __future__ import annotations
import asyncio
import base64
import os
import secrets
import shutil
import socket
import subprocess
import tempfile
import time
from pathlib import Path
import httpx
import pytest
from app.core.config import SandboxSettings
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.exceptions import SandboxBadRequest
from app.integrations.sandbox.models import (
ExecRequest,
FileListRequest,
FileReadRequest,
FileStatRequest,
FileWriteRequest,
)
# backend/tests/integrations/sandbox/test_e2e.py → parents[4] = 项目根
PROJECT_ROOT = Path(__file__).resolve().parents[4]
SANDBOX_BIN = PROJECT_ROOT / "sandbox" / "target" / "debug" / "hunjing-sandbox"
def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def _wait_health(base: str, timeout: float = 20.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
if httpx.get(f"{base}/healthz", timeout=1).status_code == 200:
return True
except httpx.HTTPError:
pass
time.sleep(0.2)
return False
@pytest.fixture(scope="session")
def sandbox_server():
"""编译(若需)并启动真实沙箱进程,yield (base_url, secret)。"""
if not SANDBOX_BIN.exists():
try:
subprocess.run(
["cargo", "build"],
cwd=PROJECT_ROOT / "sandbox",
check=True,
timeout=180,
capture_output=True,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pytest.skip("沙箱二进制不可用且 cargo build 失败,跳过跨端 E2E")
secret = secrets.token_hex(16)
port = _free_port()
workdir = tempfile.mkdtemp(prefix="sbx-e2e-")
env = {
**os.environ,
"SANDBOX_AUTH_ENABLED": "true",
"SANDBOX_AUTH_SECRET": secret,
"SANDBOX_PORT": str(port),
"SANDBOX_HOST": "127.0.0.1",
"SANDBOX_BASE_WORKDIR": workdir,
"RUST_LOG": "warn",
}
proc = subprocess.Popen([str(SANDBOX_BIN)], env=env)
base = f"http://127.0.0.1:{port}"
try:
if not _wait_health(base):
pytest.skip("沙箱启动超时,跳过跨端 E2E")
yield base, secret
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
shutil.rmtree(workdir, ignore_errors=True)
def _settings(base: str, secret: str) -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url=base,
sandbox_auth_enabled=True,
sandbox_auth_secret=secret,
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ctx(trace_id: str = "trace_e2e", tool: str = "shell") -> AuthorizationContext:
return AuthorizationContext(trace_id=trace_id, risk_level="L1", tool=tool)
async def test_e2e_exec_echo(sandbox_server: tuple[str, str]) -> None:
"""同步 exec:HMAC + body_hash 授权链路,echo 输出正确捕获。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(argv=["sh", "-c", "echo hello-e2e"], trace_id="t-echo"),
_ctx("t-echo", "echo"),
)
assert resp.ok
assert resp.exit_code == 0
assert resp.stdout.strip() == "hello-e2e"
async def test_e2e_exec_timeout(sandbox_server: tuple[str, str]) -> None:
"""超时:到点 SIGKILL 进程组,timeout=True 且快速返回。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(argv=["sh", "-c", "sleep 30"], timeout_ms=200, trace_id="t-to"),
_ctx("t-to"),
)
assert resp.timeout
assert resp.duration_ms < 5000
async def test_e2e_exec_stream(sandbox_server: tuple[str, str]) -> None:
"""流式 SSE:真实 stdout 增量 + done 结束事件。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
events = [
ev
async for ev in client.exec_stream(
ExecRequest(argv=["sh", "-c", "echo line-a; echo line-b"], trace_id="t-stream"),
_ctx("t-stream"),
)
]
kinds = [e.kind for e in events]
assert "stdout" in kinds
assert kinds[-1] == "done"
assert events[-1].payload["exit_code"] == 0
async def test_e2e_cancel_running_exec(sandbox_server: tuple[str, str]) -> None:
"""取消:并发 cancel 命中运行中任务,exec 以 cancelled=True 返回。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
async def run() -> object:
return await client.exec(
ExecRequest(argv=["sh", "-c", "sleep 30"], trace_id="t-cancel"),
_ctx("t-cancel"),
)
task = asyncio.create_task(run())
await asyncio.sleep(0.6) # 等任务注册到沙箱 running 表
cancelled = await client.cancel("t-cancel", _ctx("t-cancel", "cancel"))
assert cancelled
result = await task
assert result.cancelled # type: ignore[attr-defined]
assert result.duration_ms < 5000 # type: ignore[attr-defined]
# ---- 文件 IO 跨端(真起 Rust 沙箱:file_op_hash HMAC 一致 + 路径校验 + 反重放) ----
def _b64url(content: bytes) -> str:
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
def _b64url_decode(text: str) -> bytes:
pad = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + pad)
async def test_e2e_file_write_then_read(sandbox_server: tuple[str, str]) -> None:
"""文件读写往返:write → read 回原内容,验证 file_op_hash 两端字节级一致。"""
base, secret = sandbox_server
content = "乾坤一炁\n".encode()
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
w = await client.file_write(
FileWriteRequest(path="notes/draft.md", content_b64=_b64url(content)),
_ctx("t-fw", "file_write"),
)
assert w.bytes_written == len(content)
r = await client.file_read(
FileReadRequest(path="notes/draft.md"), _ctx("t-fr", "file_read")
)
assert r.size == len(content)
assert r.returned == len(content)
assert _b64url_decode(r.content_b64) == content
async def test_e2e_file_list_and_stat(sandbox_server: tuple[str, str]) -> None:
"""list 列出目录条目,stat 返回文件元信息。"""
base, secret = sandbox_server
content = b"stat-target"
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
await client.file_write(
FileWriteRequest(path="lsbox/target.txt", content_b64=_b64url(content)),
_ctx("t-fp", "file_write"),
)
listing = await client.file_list(FileListRequest(path="lsbox"), _ctx("t-fl", "file_list"))
stat = await client.file_stat(
FileStatRequest(path="lsbox/target.txt"), _ctx("t-fs", "file_stat")
)
assert "target.txt" in [e.name for e in listing.entries]
assert stat.kind == "file"
assert stat.size == len(content)
async def test_e2e_file_read_rejects_traversal(sandbox_server: tuple[str, str]) -> None:
"""路径越界被拒:file_op_hash 跨端一致(凭证通过)+ 词法预检查先行 → 400。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
with pytest.raises(SandboxBadRequest):
await client.file_read(
FileReadRequest(path="../../etc/passwd"), _ctx("t-trav", "file_read")
)
async def test_e2e_file_op_hash_mismatch_rejected(sandbox_server: tuple[str, str]) -> None:
"""反重放:read a.txt 的凭证不能用于读 b.txt → 401(file_op_hash 校验优先于文件存在性)。"""
from app.integrations.sandbox.auth import sign_authorization
base, secret = sandbox_server
ctx = _ctx("t-mismatch", "file_read")
# 签 read a.txt 的凭证,却请求读 b.txt
token = sign_authorization(secret, ctx, None, None, file_op=("read", "a.txt"))
async with httpx.AsyncClient() as http:
resp = await http.post(
f"{base}/v1/file/read",
json={"path": "b.txt"},
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
assert resp.status_code == 401
# ---- code 模式跨端(P0:转译为 argv 走进程路径 + body_hash_code 字节级一致) ----
async def test_e2e_code_mode_executes_python(sandbox_server: tuple[str, str]) -> None:
"""code 模式跨端:Rust 与 Python body_hash 字节级一致(401 即不一致),python3 实际执行。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(code="import sys\nprint(sys.version_info[0])"),
_ctx("trace_code_1", "python"),
)
assert resp.ok, f"code 模式执行失败: {resp.stderr}"
assert resp.stdout.strip() == "3"
assert resp.isolation_mode == "process"
async def test_e2e_code_mode_rejects_when_argv_also_given(sandbox_server: tuple[str, str]) -> None:
"""互斥校验在沙箱端兜底:argv+code 同时给出应非 200(client 模型已挡,e2e 兜底)。"""
base, _ = sandbox_server
async with httpx.AsyncClient() as http:
# 绕过 client 模型校验,直接裸 POST 验证沙箱端拒绝
resp = await http.post(
f"{base}/v1/exec",
json={"argv": ["python3"], "code": "print(1)"},
timeout=10.0,
)
# 未带授权头 → 401 先于 400;此处断言非 200,证明沙箱拒绝
assert resp.status_code in (400, 401)
async def test_e2e_isolation_mode_container_downgrades_to_process(
sandbox_server: tuple[str, str],
) -> None:
"""P0 过渡态:司命签发 container,沙箱降级 process 并在响应回显 process。"""
base, secret = sandbox_server
async with httpx.AsyncClient() as http:
client = SandboxClient(http, _settings(base, secret))
resp = await client.exec(
ExecRequest(code="print(1)"),
AuthorizationContext(
trace_id="trace_iso",
risk_level="L3",
tool="shell",
isolation_mode="container",
),
)
assert resp.ok
assert resp.isolation_mode == "process"