Files
strix/tests/tools/test_sandbox_dispatch.py
T

207 lines
6.3 KiB
Python
Raw Normal View History

"""Phase 2.1 smoke tests for the sandbox dispatch helper."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import httpx
import pytest
from strix.tools._sandbox_dispatch import post_to_sandbox
@dataclass
class _Ctx:
"""Stand-in for ``RunContextWrapper``. Only ``.context`` is touched."""
context: dict[str, Any] = field(default_factory=dict)
def _ok_ctx(**overrides: Any) -> _Ctx:
base: dict[str, Any] = {
"tool_server_host_port": 48081,
"sandbox_token": "test-bearer",
"agent_id": "agent-1",
}
base.update(overrides)
return _Ctx(context=base)
@pytest.mark.asyncio
async def test_missing_context_returns_error() -> None:
"""If ``ctx.context`` isn't a dict, return error — never raise."""
ctx = _Ctx(context="not a dict")
result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"})
assert "error" in result
assert "context" in result["error"].lower()
@pytest.mark.asyncio
async def test_missing_port_or_token_returns_error() -> None:
ctx = _Ctx(context={"sandbox_token": "tok"}) # no port
result = await post_to_sandbox(ctx, "x", {})
assert "error" in result
assert "tool server port" in result["error"].lower()
@pytest.mark.asyncio
async def test_successful_response_returned_as_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, Any] = {}
async def fake_post(
self: httpx.AsyncClient,
url: str,
*,
json: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> httpx.Response:
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return httpx.Response(
status_code=200,
json={"result": "ok"},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"})
assert result == {"result": "ok"}
assert captured["url"] == "http://127.0.0.1:48081/execute"
assert captured["json"] == {
"agent_id": "agent-1",
"tool_name": "terminal_execute",
"kwargs": {"command": "ls"},
}
assert captured["headers"]["Authorization"] == "Bearer test-bearer"
@pytest.mark.asyncio
async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=401,
text="forbidden",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "authorization" in result["error"].lower()
@pytest.mark.asyncio
async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=503,
text="server fell over",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
assert "error" in result
assert "503" in result["error"]
@pytest.mark.asyncio
async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
raise httpx.ReadTimeout("read timeout")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "python_action", {})
assert "error" in result
assert "timed out" in result["error"].lower()
@pytest.mark.asyncio
async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
raise httpx.ConnectError("refused")
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "connection" in result["error"].lower()
@pytest.mark.asyncio
async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None:
"""C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM."""
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
# Construct a response with a >50MB body. Use a string trick —
# httpx.Response stores .content directly; we make it look huge.
big = b"x" * (51 * 1024 * 1024)
return httpx.Response(
status_code=200,
content=big,
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
assert "error" in result
assert "too large" in result["error"].lower()
@pytest.mark.asyncio
async def test_non_json_response_returns_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=200,
text="hello not json",
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "non-json" in result["error"].lower()
@pytest.mark.asyncio
async def test_non_object_json_returns_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_post(
self: httpx.AsyncClient,
url: str,
**_: Any,
) -> httpx.Response:
return httpx.Response(
status_code=200,
json=["a", "list", "not", "an", "object"],
request=httpx.Request("POST", url),
)
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
result = await post_to_sandbox(_ok_ctx(), "x", {})
assert "error" in result
assert "non-object" in result["error"].lower()