285 lines
9.1 KiB
Python
285 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import app.services.openclaw.gateway_rpc as gateway_rpc
|
|
from app.services.openclaw.gateway_rpc import (
|
|
CONTROL_UI_CLIENT_ID,
|
|
CONTROL_UI_CLIENT_MODE,
|
|
DEFAULT_GATEWAY_CLIENT_ID,
|
|
DEFAULT_GATEWAY_CLIENT_MODE,
|
|
GATEWAY_OPERATOR_SCOPES,
|
|
GatewayConfig,
|
|
OpenClawGatewayError,
|
|
_build_connect_params,
|
|
_build_control_ui_origin,
|
|
openclaw_call,
|
|
)
|
|
|
|
|
|
def test_build_connect_params_defaults_to_device_pairing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
expected_device_payload = {
|
|
"id": "device-id",
|
|
"publicKey": "public-key",
|
|
"signature": "signature",
|
|
"signedAt": 1,
|
|
}
|
|
|
|
def _fake_build_device_connect_payload(
|
|
*,
|
|
client_id: str,
|
|
client_mode: str,
|
|
role: str,
|
|
scopes: list[str],
|
|
auth_token: str | None,
|
|
connect_nonce: str | None,
|
|
) -> dict[str, object]:
|
|
captured["client_id"] = client_id
|
|
captured["client_mode"] = client_mode
|
|
captured["role"] = role
|
|
captured["scopes"] = scopes
|
|
captured["auth_token"] = auth_token
|
|
captured["connect_nonce"] = connect_nonce
|
|
return expected_device_payload
|
|
|
|
monkeypatch.setattr(
|
|
gateway_rpc,
|
|
"_build_device_connect_payload",
|
|
_fake_build_device_connect_payload,
|
|
)
|
|
|
|
params = _build_connect_params(GatewayConfig(url="ws://gateway.example/ws"))
|
|
|
|
assert params["role"] == "operator"
|
|
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
|
|
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
|
|
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
|
|
assert params["device"] == expected_device_payload
|
|
assert "auth" not in params
|
|
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
|
|
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
|
|
assert captured["role"] == "operator"
|
|
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
|
|
assert captured["auth_token"] is None
|
|
assert captured["connect_nonce"] is None
|
|
|
|
|
|
def test_build_connect_params_uses_control_ui_when_pairing_disabled() -> None:
|
|
params = _build_connect_params(
|
|
GatewayConfig(
|
|
url="ws://gateway.example/ws",
|
|
token="secret-token",
|
|
disable_device_pairing=True,
|
|
),
|
|
)
|
|
|
|
assert params["auth"] == {"token": "secret-token"}
|
|
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
|
|
assert params["client"]["id"] == CONTROL_UI_CLIENT_ID
|
|
assert params["client"]["mode"] == CONTROL_UI_CLIENT_MODE
|
|
assert "device" not in params
|
|
|
|
|
|
def test_build_connect_params_passes_nonce_to_device_payload(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_build_device_connect_payload(
|
|
*,
|
|
client_id: str,
|
|
client_mode: str,
|
|
role: str,
|
|
scopes: list[str],
|
|
auth_token: str | None,
|
|
connect_nonce: str | None,
|
|
) -> dict[str, object]:
|
|
captured["client_id"] = client_id
|
|
captured["client_mode"] = client_mode
|
|
captured["role"] = role
|
|
captured["scopes"] = scopes
|
|
captured["auth_token"] = auth_token
|
|
captured["connect_nonce"] = connect_nonce
|
|
return {"id": "device-id", "nonce": connect_nonce}
|
|
|
|
monkeypatch.setattr(
|
|
gateway_rpc,
|
|
"_build_device_connect_payload",
|
|
_fake_build_device_connect_payload,
|
|
)
|
|
|
|
params = _build_connect_params(
|
|
GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
|
|
connect_nonce="nonce-xyz",
|
|
)
|
|
|
|
assert params["auth"] == {"token": "secret-token"}
|
|
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
|
|
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
|
|
assert params["device"] == {"id": "device-id", "nonce": "nonce-xyz"}
|
|
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
|
|
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
|
|
assert captured["role"] == "operator"
|
|
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
|
|
assert captured["auth_token"] == "secret-token"
|
|
assert captured["connect_nonce"] == "nonce-xyz"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("gateway_url", "expected_origin"),
|
|
[
|
|
("ws://gateway.example/ws", "http://gateway.example"),
|
|
("wss://gateway.example/ws", "https://gateway.example"),
|
|
("ws://gateway.example:8080/ws", "http://gateway.example:8080"),
|
|
("wss://gateway.example:8443/ws", "https://gateway.example:8443"),
|
|
("ws://[::1]:8000/ws", "http://[::1]:8000"),
|
|
],
|
|
)
|
|
def test_build_control_ui_origin(gateway_url: str, expected_origin: str) -> None:
|
|
assert _build_control_ui_origin(gateway_url) == expected_origin
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openclaw_call_uses_single_connect_attempt(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
call_count = 0
|
|
|
|
async def _fake_call_once(
|
|
method: str,
|
|
params: dict[str, object] | None,
|
|
*,
|
|
config: GatewayConfig,
|
|
gateway_url: str,
|
|
) -> object:
|
|
nonlocal call_count
|
|
del method, params, config, gateway_url
|
|
call_count += 1
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
|
|
|
|
payload = await openclaw_call(
|
|
"status",
|
|
config=GatewayConfig(url="ws://gateway.example/ws"),
|
|
)
|
|
|
|
assert payload == {"ok": True}
|
|
assert call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openclaw_call_surfaces_scope_error_without_device_fallback(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _fake_call_once(
|
|
method: str,
|
|
params: dict[str, object] | None,
|
|
*,
|
|
config: GatewayConfig,
|
|
gateway_url: str,
|
|
) -> object:
|
|
del method, params, config, gateway_url
|
|
raise OpenClawGatewayError("missing scope: operator.read")
|
|
|
|
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
|
|
|
|
with pytest.raises(OpenClawGatewayError, match="missing scope: operator.read"):
|
|
await openclaw_call(
|
|
"status",
|
|
config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
|
|
)
|
|
|
|
|
|
class _FakeConnectContext:
|
|
async def __aenter__(self) -> object:
|
|
return object()
|
|
|
|
async def __aexit__(self, _exc_type: object, _exc: object, _tb: object) -> bool:
|
|
return False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openclaw_call_once_does_not_pass_ssl_none_for_wss(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext:
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return _FakeConnectContext()
|
|
|
|
async def _fake_recv_first(_ws: object) -> None:
|
|
return None
|
|
|
|
async def _fake_ensure_connected(
|
|
_ws: object, _first_message: object, _config: GatewayConfig
|
|
) -> None:
|
|
return None
|
|
|
|
async def _fake_send_request(_ws: object, _method: str, _params: object) -> object:
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect)
|
|
monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first)
|
|
monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected)
|
|
monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request)
|
|
|
|
payload = await gateway_rpc._openclaw_call_once(
|
|
"status",
|
|
None,
|
|
config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=False),
|
|
gateway_url="wss://gateway.example/ws",
|
|
)
|
|
|
|
assert payload == {"ok": True}
|
|
assert captured["url"] == "wss://gateway.example/ws"
|
|
kwargs = captured["kwargs"]
|
|
assert isinstance(kwargs, dict)
|
|
assert "ssl" not in kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openclaw_call_once_passes_ssl_context_for_insecure_wss(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_connect(url: str, **kwargs: object) -> _FakeConnectContext:
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return _FakeConnectContext()
|
|
|
|
async def _fake_recv_first(_ws: object) -> None:
|
|
return None
|
|
|
|
async def _fake_ensure_connected(
|
|
_ws: object, _first_message: object, _config: GatewayConfig
|
|
) -> None:
|
|
return None
|
|
|
|
async def _fake_send_request(_ws: object, _method: str, _params: object) -> object:
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(gateway_rpc.websockets, "connect", _fake_connect)
|
|
monkeypatch.setattr(gateway_rpc, "_recv_first_message_or_none", _fake_recv_first)
|
|
monkeypatch.setattr(gateway_rpc, "_ensure_connected", _fake_ensure_connected)
|
|
monkeypatch.setattr(gateway_rpc, "_send_request", _fake_send_request)
|
|
|
|
payload = await gateway_rpc._openclaw_call_once(
|
|
"status",
|
|
None,
|
|
config=GatewayConfig(url="wss://gateway.example/ws", allow_insecure_tls=True),
|
|
gateway_url="wss://gateway.example/ws",
|
|
)
|
|
|
|
assert payload == {"ok": True}
|
|
assert captured["url"] == "wss://gateway.example/ws"
|
|
kwargs = captured["kwargs"]
|
|
assert isinstance(kwargs, dict)
|
|
assert kwargs.get("ssl") is not None
|