313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""跨端集成测试:起真实 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"
|