初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,312 @@
|
||||
"""跨端集成测试:起真实 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"
|
||||
Reference in new issue
Block a user