"""Unit tests for the OpenClaw bridge integration.""" from __future__ import annotations import os import subprocess from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError from integrations.catalog import classify_integrations as _classify_integrations from integrations.openclaw import ( OpenClawConfig, _tool_result_to_dict, build_openclaw_config, describe_openclaw_error, list_openclaw_tools, openclaw_config_from_env, openclaw_runtime_unavailable_reason, validate_openclaw_config, ) from integrations.openclaw.tools.openclaw_mcp_tool import ( call_openclaw_bridge_tool, search_openclaw_conversations, ) # --------------------------------------------------------------------------- # OpenClawConfig # --------------------------------------------------------------------------- class TestOpenClawConfig: def test_default_mode(self) -> None: # mode defaults to streamable-http; without url the validator should reject with pytest.raises(ValidationError): OpenClawConfig() def test_streamable_http_requires_url(self) -> None: with pytest.raises(ValidationError, match="requires a non-empty url"): OpenClawConfig(mode="streamable-http", url="") def test_sse_requires_url(self) -> None: with pytest.raises(ValidationError, match="requires a non-empty url"): OpenClawConfig(mode="sse", url="") def test_stdio_requires_command(self) -> None: with pytest.raises(ValidationError, match="requires a non-empty command"): OpenClawConfig(mode="stdio", command="") def test_valid_streamable_http(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp") assert config.mode == "streamable-http" assert config.url == "https://openclaw.example.com/mcp" assert config.is_configured is True def test_valid_sse(self) -> None: config = OpenClawConfig(mode="sse", url="https://openclaw.example.com/sse") assert config.mode == "sse" assert config.is_configured is True def test_valid_stdio(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw-mcp") assert config.mode == "stdio" assert config.command == "openclaw-mcp" assert config.is_configured is True def test_url_trailing_slash_stripped(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp/") assert config.url == "https://openclaw.example.com/mcp" def test_auth_token_bearer_prefix_stripped(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp", auth_token="Bearer tok123") assert config.auth_token == "tok123" def test_mode_normalized_to_lowercase(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp", mode="Streamable-HTTP") assert config.mode == "streamable-http" def test_args_tuple_normalized(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw-mcp", args=["--port", "8080"]) assert config.args == ("--port", "8080") def test_args_empty_strings_filtered(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw-mcp", args=[" ", "flag", ""]) assert config.args == ("flag",) def test_headers_non_dict_becomes_empty(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp", headers="bad") # type: ignore[arg-type] assert config.headers == {} def test_request_headers_injects_bearer(self) -> None: config = OpenClawConfig( url="https://openclaw.example.com/mcp", auth_token="mytoken", ) assert config.request_headers["Authorization"] == "Bearer mytoken" def test_request_headers_no_duplicate_authorization(self) -> None: config = OpenClawConfig( url="https://openclaw.example.com/mcp", auth_token="mytoken", headers={"Authorization": "Bearer override"}, ) assert config.request_headers["Authorization"] == "Bearer override" def test_is_configured_false_when_no_url_no_command(self) -> None: # Build via dict to bypass model_validator (mode=stdio, no command) # The only way to get is_configured=False at runtime is an unconfigured stdio # We can test the property branch by building a valid stdio and checking command=False config = OpenClawConfig(mode="stdio", command="openclaw-mcp") assert config.is_configured is True # Simulate cleared command (property reads self.command) assert bool("") is False # sanity def test_timeout_must_be_positive(self) -> None: with pytest.raises(ValidationError): OpenClawConfig(url="https://openclaw.example.com/mcp", timeout_seconds=0) def test_integration_id_defaults_empty(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp") assert config.integration_id == "" # --------------------------------------------------------------------------- # build_openclaw_config # --------------------------------------------------------------------------- class TestBuildOpenClawConfig: def test_none_raises_for_unconfigured(self) -> None: # Empty dict triggers the model validator with pytest.raises(ValidationError): build_openclaw_config(None) def test_basic_http(self) -> None: config = build_openclaw_config( {"url": "https://openclaw.example.com/mcp", "auth_token": "tok"} ) assert config.url == "https://openclaw.example.com/mcp" assert config.auth_token == "tok" def test_stdio_config(self) -> None: config = build_openclaw_config( {"mode": "stdio", "command": "openclaw-mcp", "args": ["--port", "8080"]} ) assert config.mode == "stdio" assert config.command == "openclaw-mcp" assert config.args == ("--port", "8080") def test_integration_id_round_trip(self) -> None: config = build_openclaw_config( { "url": "https://openclaw.example.com/mcp", "integration_id": "int-42", } ) assert config.integration_id == "int-42" def test_helper_metadata_is_ignored(self) -> None: config = build_openclaw_config( { "url": "https://openclaw.example.com/mcp", "connection_verified": True, "search_query": "checkout-api", } ) assert config.url == "https://openclaw.example.com/mcp" # --------------------------------------------------------------------------- # openclaw_config_from_env # --------------------------------------------------------------------------- class TestOpenClawConfigFromEnv: def test_returns_none_when_no_url_or_command(self) -> None: with patch.dict(os.environ, {}, clear=False): for var in ("OPENCLAW_MCP_URL", "OPENCLAW_MCP_COMMAND"): os.environ.pop(var, None) assert openclaw_config_from_env() is None @patch.dict( os.environ, { "OPENCLAW_MCP_URL": "https://openclaw.example.com/mcp", "OPENCLAW_MCP_AUTH_TOKEN": "env-token", "OPENCLAW_MCP_MODE": "streamable-http", }, ) def test_streamable_http_from_env(self) -> None: config = openclaw_config_from_env() assert config is not None assert config.url == "https://openclaw.example.com/mcp" assert config.auth_token == "env-token" assert config.mode == "streamable-http" @patch.dict( os.environ, { "OPENCLAW_MCP_MODE": "stdio", "OPENCLAW_MCP_COMMAND": "openclaw-mcp", "OPENCLAW_MCP_ARGS": "--port 8080", }, ) def test_stdio_from_env(self) -> None: config = openclaw_config_from_env() assert config is not None assert config.mode == "stdio" assert config.command == "openclaw-mcp" assert config.args == ("--port", "8080") @patch.dict(os.environ, {"OPENCLAW_MCP_MODE": "stdio"}) def test_stdio_without_command_returns_none(self) -> None: os.environ.pop("OPENCLAW_MCP_COMMAND", None) assert openclaw_config_from_env() is None # --------------------------------------------------------------------------- # validate_openclaw_config # --------------------------------------------------------------------------- class TestValidateOpenClawConfig: def _http_config(self) -> OpenClawConfig: return OpenClawConfig(url="https://openclaw.example.com/mcp") def test_ok_result_on_success(self) -> None: config = self._http_config() mock_tools = [{"name": "conversations_get"}, {"name": "conversations_list"}] with patch("integrations.openclaw.list_openclaw_tools", return_value=mock_tools): result = validate_openclaw_config(config) assert result.ok is True assert result.detail == ( "OpenClaw bridge connected via streamable-http (https://openclaw.example.com/mcp); " "discovered 2 tool(s)." ) assert result.tool_names == ("conversations_get", "conversations_list") def test_failed_result_on_exception(self) -> None: config = self._http_config() with patch( "integrations.openclaw.list_openclaw_tools", side_effect=RuntimeError("connection refused"), ): result = validate_openclaw_config(config) assert result.ok is False assert "connection refused" in result.detail def test_local_dashboard_url_returns_stdio_hint(self) -> None: config = OpenClawConfig(mode="streamable-http", url="http://127.0.0.1:18789/") result = validate_openclaw_config(config) assert result.ok is False assert "Control UI/Gateway" in result.detail assert "openclaw" in result.detail assert "mcp serve" in result.detail def test_exception_group_is_unwrapped(self) -> None: config = self._http_config() nested = ExceptionGroup( "unhandled errors in a TaskGroup", [RuntimeError("HTTP 404 from POST https://openclaw.example.com/mcp")], ) with patch("integrations.openclaw.list_openclaw_tools", side_effect=nested): result = validate_openclaw_config(config) assert result.ok is False assert "HTTP 404" in result.detail assert "TaskGroup" not in result.detail def test_unconfigured_returns_error_without_connecting(self) -> None: # Manually create an object that bypasses the model_validator for testing config = OpenClawConfig.__new__(OpenClawConfig) object.__setattr__(config, "mode", "streamable-http") object.__setattr__(config, "url", "") object.__setattr__(config, "command", "") object.__setattr__(config, "auth_token", "") object.__setattr__(config, "args", ()) object.__setattr__(config, "headers", {}) object.__setattr__(config, "timeout_seconds", 15.0) object.__setattr__(config, "integration_id", "") result = validate_openclaw_config(config) assert result.ok is False assert "not configured" in result.detail.lower() def test_stdio_endpoint_uses_command_in_detail(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw-mcp") mock_tools = [{"name": "conversations_list"}] with ( patch("integrations.openclaw.shutil.which", return_value="/tmp/openclaw-mcp"), patch("integrations.openclaw.list_openclaw_tools", return_value=mock_tools), ): result = validate_openclaw_config(config) assert result.ok is True assert "openclaw-mcp" in result.detail def test_stdio_missing_binary_fails_before_listing_tools(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw") with ( patch("integrations.openclaw.shutil.which", return_value=None), patch("integrations.openclaw.list_openclaw_tools") as mock_list_tools, ): result = validate_openclaw_config(config) assert result.ok is False assert "Command not found" in result.detail mock_list_tools.assert_not_called() def test_stdio_node_requirement_fails_before_listing_tools(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw", args=["mcp", "serve"]) completed = subprocess.CompletedProcess( args=["openclaw", "--help"], returncode=1, stdout="", stderr="openclaw: Node.js v22.12+ is required (current: v22.1.0).", ) with ( patch("integrations.openclaw.shutil.which", return_value="/opt/homebrew/bin/openclaw"), patch("integrations.openclaw.subprocess.run", return_value=completed), patch("integrations.openclaw.list_openclaw_tools") as mock_list_tools, ): result = validate_openclaw_config(config) assert result.ok is False assert "Node.js v22.12+" in result.detail mock_list_tools.assert_not_called() # --------------------------------------------------------------------------- # _tool_result_to_dict # --------------------------------------------------------------------------- class TestToolResultToDict: def _make_result( self, *, is_error: bool = False, content_items: list | None = None, ) -> MagicMock: result = MagicMock() result.isError = is_error result.content = content_items or [] result.structuredContent = None return result def test_empty_content(self) -> None: result = self._make_result() parsed = _tool_result_to_dict(result) assert parsed["is_error"] is False assert parsed["text"] == "" assert parsed["content"] == [] def test_text_content_extracted(self) -> None: from mcp import types # type: ignore[import-not-found] text_item = MagicMock(spec=types.TextContent) text_item.text = "Hello from OpenClaw" result = self._make_result(content_items=[text_item]) parsed = _tool_result_to_dict(result) assert "Hello from OpenClaw" in parsed["text"] assert parsed["content"][0]["type"] == "text" def test_is_error_propagated(self) -> None: result = self._make_result(is_error=True) parsed = _tool_result_to_dict(result) assert parsed["is_error"] is True class TestDescribeOpenClawError: def test_legacy_command_receives_bridge_hint(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw-mcp") detail = describe_openclaw_error( FileNotFoundError(2, "No such file", "openclaw-mcp"), config ) assert "Command not found" in detail assert "openclaw mcp serve" in detail def test_gateway_unavailable_receives_gateway_hint(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw", args=["mcp", "serve"]) completed = subprocess.CompletedProcess( args=["openclaw", "--help"], returncode=0, stdout="OpenClaw CLI help", stderr="", ) with patch("integrations.openclaw.subprocess.run", return_value=completed): detail = describe_openclaw_error(RuntimeError("Connection closed"), config) assert "openclaw gateway status" in detail assert "openclaw gateway run" in detail def test_runtime_unavailable_reason_detects_missing_stdio_command(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw") with patch("integrations.openclaw.shutil.which", return_value=None): detail = openclaw_runtime_unavailable_reason(config) assert detail is not None assert "Command not found" in detail def test_runtime_unavailable_reason_detects_node_version_requirement(self) -> None: config = OpenClawConfig(mode="stdio", command="openclaw", args=["mcp", "serve"]) completed = subprocess.CompletedProcess( args=["openclaw", "--help"], returncode=1, stdout="", stderr=( "openclaw: Node.js v22.12+ is required (current: v22.1.0).\n" "If you use nvm, run:\n" " nvm install 22\n" " nvm use 22\n" " nvm alias default 22\n" ), ) with ( patch("integrations.openclaw.shutil.which", return_value="/opt/homebrew/bin/openclaw"), patch("integrations.openclaw.subprocess.run", return_value=completed), ): detail = openclaw_runtime_unavailable_reason(config) assert detail is not None assert "Node.js v22.12+" in detail assert "nvm use 22" in detail assert "current shell" in detail # --------------------------------------------------------------------------- # list_openclaw_tools (integration via mock) # --------------------------------------------------------------------------- class TestListOpenClawTools: def test_returns_tool_list(self) -> None: config = OpenClawConfig(url="https://openclaw.example.com/mcp") mock_tool = MagicMock() mock_tool.name = "conversations_list" mock_tool.description = "List conversations" mock_tool.inputSchema = {"type": "object"} with patch( "integrations.openclaw._list_tools_async", new=AsyncMock(return_value=[mock_tool]), ): tools = list_openclaw_tools(config) assert len(tools) == 1 assert tools[0]["name"] == "conversations_list" assert tools[0]["description"] == "List conversations" # --------------------------------------------------------------------------- # _classify_integrations (resolve_integrations node) # --------------------------------------------------------------------------- class TestClassifyOpenClawIntegration: def _make_record(self, credentials: dict) -> dict: return { "id": "openclaw-1", "service": "openclaw", "status": "active", "credentials": credentials, } def test_streamable_http_classified(self) -> None: record = self._make_record( { "url": "https://openclaw.example.com/mcp", "mode": "streamable-http", "auth_token": "tok", } ) from tools.investigation.stages.gather_evidence.tools import availability_view resolved = _classify_integrations([record]) assert "openclaw" in resolved assert resolved["openclaw"].url == "https://openclaw.example.com/mcp" # connection_verified is set at the tool-availability boundary view = availability_view(resolved) assert view["openclaw"]["connection_verified"] is True def test_stdio_classified(self) -> None: record = self._make_record({"mode": "stdio", "command": "openclaw-mcp"}) resolved = _classify_integrations([record]) assert "openclaw" in resolved assert resolved["openclaw"].command == "openclaw-mcp" def test_invalid_config_skipped(self) -> None: # No url and non-stdio mode → ValidationError inside _classify_integrations → skipped record = self._make_record({"mode": "streamable-http", "url": ""}) resolved = _classify_integrations([record]) assert "openclaw" not in resolved def test_inactive_integration_skipped(self) -> None: record = { "id": "openclaw-2", "service": "openclaw", "status": "inactive", "credentials": {"url": "https://openclaw.example.com/mcp"}, } resolved = _classify_integrations([record]) assert "openclaw" not in resolved def test_integration_id_preserved(self) -> None: record = self._make_record( {"url": "https://openclaw.example.com/mcp", "mode": "streamable-http"} ) resolved = _classify_integrations([record]) assert resolved["openclaw"].integration_id == "openclaw-1" class TestOpenClawExtractParams: def test_extract_params_maps_plain_config_keys(self) -> None: params = call_openclaw_bridge_tool.__opensre_registered_tool__.extract_params( { "openclaw": { "connection_verified": True, "url": "https://openclaw.example.com/mcp", "mode": "streamable-http", "auth_token": "tok", "command": "openclaw", "args": ["mcp", "serve"], } } ) assert params["openclaw_url"] == "https://openclaw.example.com/mcp" assert params["openclaw_mode"] == "streamable-http" assert params["openclaw_token"] == "tok" assert params["openclaw_command"] == "openclaw" assert params["openclaw_args"] == ["mcp", "serve"] def test_extract_params_keeps_prefixed_keys_for_detected_sources(self) -> None: params = call_openclaw_bridge_tool.__opensre_registered_tool__.extract_params( { "openclaw": { "connection_verified": True, "openclaw_mode": "stdio", "openclaw_command": "openclaw", "openclaw_args": ["mcp", "serve"], } } ) assert params["openclaw_mode"] == "stdio" assert params["openclaw_command"] == "openclaw" assert params["openclaw_args"] == ["mcp", "serve"] def test_search_params_maps_search_query_aliases(self) -> None: params = search_openclaw_conversations.__opensre_registered_tool__.extract_params( { "openclaw": { "connection_verified": True, "url": "https://openclaw.example.com/mcp", "mode": "streamable-http", "search_query": "checkout-api", } } ) assert params["search"] == "checkout-api" assert params["limit"] == 10