2026-02-15 15:59:55 +05:30
|
|
|
# ruff: noqa: S101
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
|
|
|
|
import app.services.openclaw.admin_service as admin_service
|
|
|
|
|
import app.services.openclaw.gateway_compat as gateway_compat
|
|
|
|
|
import app.services.openclaw.session_service as session_service
|
|
|
|
|
from app.schemas.gateway_api import GatewayResolveQuery
|
|
|
|
|
from app.services.openclaw.admin_service import GatewayAdminLifecycleService
|
|
|
|
|
from app.services.openclaw.gateway_compat import GatewayVersionCheckResult
|
|
|
|
|
from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError
|
|
|
|
|
from app.services.openclaw.session_service import GatewaySessionService
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
def test_extract_connect_server_version_uses_server_version_as_source_of_truth() -> None:
|
2026-02-15 15:59:55 +05:30
|
|
|
payload = {
|
2026-02-22 22:05:39 +05:30
|
|
|
"version": "dev",
|
|
|
|
|
"runtime": {"version": "2026.1.0"},
|
|
|
|
|
"server": {"version": "2026.2.21-2"},
|
2026-02-15 15:59:55 +05:30
|
|
|
}
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
assert gateway_compat.extract_connect_server_version(payload) == "2026.2.21-2"
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
def test_extract_connect_server_version_returns_none_when_server_version_missing() -> None:
|
|
|
|
|
payload = {
|
|
|
|
|
"version": "2026.2.21-2",
|
|
|
|
|
"runtime": {"version": "2026.2.21-2"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert gateway_compat.extract_connect_server_version(payload) is None
|
|
|
|
|
|
|
|
|
|
|
2026-02-23 01:23:09 +05:30
|
|
|
def test_extract_config_last_touched_version_reads_config_meta_last_touched_version() -> None:
|
|
|
|
|
payload = {
|
|
|
|
|
"config": {
|
|
|
|
|
"meta": {"lastTouchedVersion": "2026.2.9"},
|
|
|
|
|
"wizard": {"lastRunVersion": "2026.2.8"},
|
|
|
|
|
},
|
|
|
|
|
"parsed": {"meta": {"lastTouchedVersion": "2026.2.7"}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert gateway_compat.extract_config_last_touched_version(payload) == "2026.2.9"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_extract_config_last_touched_version_returns_none_without_config_meta_last_touched_version() -> None:
|
|
|
|
|
payload = {
|
|
|
|
|
"config": {"wizard": {"lastRunVersion": "2026.2.9"}},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert gateway_compat.extract_config_last_touched_version(payload) is None
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("current_version", "minimum_version", "expected_compatible"),
|
|
|
|
|
[
|
|
|
|
|
("2026.2.21", "2026.2.21", True),
|
|
|
|
|
("2026.02.20", "2026.2.20", True),
|
|
|
|
|
("2026.2.22", "2026.2.21", True),
|
|
|
|
|
("2026.2.21-2", "2026.2.21-1", True),
|
|
|
|
|
("2026.2.21-1", "2026.2.21-2", False),
|
|
|
|
|
("2026.2.20", "2026.2.21", False),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_evaluate_gateway_version_compares_calver(
|
|
|
|
|
*,
|
|
|
|
|
current_version: str,
|
|
|
|
|
minimum_version: str,
|
|
|
|
|
expected_compatible: bool,
|
|
|
|
|
) -> None:
|
2026-02-15 15:59:55 +05:30
|
|
|
result = gateway_compat.evaluate_gateway_version(
|
2026-02-22 22:05:39 +05:30
|
|
|
current_version=current_version,
|
|
|
|
|
minimum_version=minimum_version,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.compatible is expected_compatible
|
|
|
|
|
assert result.current_version == current_version
|
|
|
|
|
assert result.minimum_version == minimum_version
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("invalid_current", ["dev", "latest", "2026.13.1", "2026.2.0-beta"])
|
|
|
|
|
def test_evaluate_gateway_version_rejects_non_calver_current(invalid_current: str) -> None:
|
|
|
|
|
result = gateway_compat.evaluate_gateway_version(
|
|
|
|
|
current_version=invalid_current,
|
2026-02-15 15:59:55 +05:30
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.compatible is False
|
2026-02-22 22:05:39 +05:30
|
|
|
assert result.current_version == invalid_current
|
|
|
|
|
assert "unsupported version format" in (result.message or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_evaluate_gateway_version_rejects_non_calver_minimum_version() -> None:
|
|
|
|
|
result = gateway_compat.evaluate_gateway_version(
|
|
|
|
|
current_version="2026.2.21",
|
|
|
|
|
minimum_version="1.2.3",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.compatible is False
|
|
|
|
|
assert result.minimum_version == "1.2.3"
|
|
|
|
|
assert "expected calver" in (result.message or "").lower()
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-02-22 22:05:39 +05:30
|
|
|
async def test_check_gateway_version_compatibility_uses_connect_server_version_only(
|
2026-02-15 15:59:55 +05:30
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
2026-02-22 13:37:01 +05:30
|
|
|
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
|
|
|
|
_ = config
|
2026-02-22 22:05:39 +05:30
|
|
|
return {
|
|
|
|
|
"version": "dev",
|
|
|
|
|
"runtime": {"version": "2026.1.0"},
|
|
|
|
|
"server": {"version": "2026.2.13"},
|
|
|
|
|
}
|
2026-02-22 13:37:01 +05:30
|
|
|
|
2026-02-23 01:23:09 +05:30
|
|
|
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
|
|
|
|
_ = (method, params, config)
|
|
|
|
|
raise AssertionError("config.get fallback should not run for valid connect version")
|
|
|
|
|
|
2026-02-22 13:37:01 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
2026-02-23 01:23:09 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
result = await gateway_compat.check_gateway_version_compatibility(
|
2026-02-15 15:59:55 +05:30
|
|
|
GatewayConfig(url="ws://gateway.example/ws"),
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.compatible is True
|
|
|
|
|
assert result.current_version == "2026.2.13"
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 13:37:01 +05:30
|
|
|
@pytest.mark.asyncio
|
2026-02-22 22:05:39 +05:30
|
|
|
async def test_check_gateway_version_compatibility_fails_without_server_version(
|
2026-02-22 13:37:01 +05:30
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
|
|
|
|
_ = config
|
2026-02-22 22:05:39 +05:30
|
|
|
return {"runtime": {"version": "2026.2.13"}}
|
2026-02-22 13:37:01 +05:30
|
|
|
|
2026-02-23 01:23:09 +05:30
|
|
|
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
|
|
|
|
_ = (params, config)
|
|
|
|
|
assert method == "config.get"
|
|
|
|
|
return {"config": {}}
|
|
|
|
|
|
2026-02-22 13:37:01 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
2026-02-23 01:23:09 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
2026-02-22 13:37:01 +05:30
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
result = await gateway_compat.check_gateway_version_compatibility(
|
2026-02-22 13:37:01 +05:30
|
|
|
GatewayConfig(url="ws://gateway.example/ws"),
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
assert result.compatible is False
|
|
|
|
|
assert result.current_version is None
|
|
|
|
|
assert "unable to determine gateway version" in (result.message or "").lower()
|
2026-02-22 13:37:01 +05:30
|
|
|
|
|
|
|
|
|
2026-02-15 15:59:55 +05:30
|
|
|
@pytest.mark.asyncio
|
2026-02-23 01:23:09 +05:30
|
|
|
async def test_check_gateway_version_compatibility_uses_config_get_fallback_when_connect_is_dev(
|
2026-02-15 15:59:55 +05:30
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
2026-02-22 13:37:01 +05:30
|
|
|
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
|
|
|
|
_ = config
|
2026-02-22 22:05:39 +05:30
|
|
|
return {"server": {"version": "dev"}}
|
2026-02-22 13:37:01 +05:30
|
|
|
|
2026-02-23 01:23:09 +05:30
|
|
|
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
|
|
|
|
_ = (params, config)
|
|
|
|
|
assert method == "config.get"
|
|
|
|
|
return {"config": {"meta": {"lastTouchedVersion": "2026.2.9"}}}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
|
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
|
|
|
|
|
|
|
|
|
result = await gateway_compat.check_gateway_version_compatibility(
|
|
|
|
|
GatewayConfig(url="ws://gateway.example/ws"),
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.compatible is True
|
|
|
|
|
assert result.current_version == "2026.2.9"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_check_gateway_version_compatibility_rejects_non_calver_server_version_when_fallback_unavailable(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
|
|
|
|
_ = config
|
|
|
|
|
return {"server": {"version": "dev"}}
|
|
|
|
|
|
|
|
|
|
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
|
|
|
|
_ = (method, params, config)
|
|
|
|
|
raise OpenClawGatewayError("method unavailable")
|
|
|
|
|
|
2026-02-22 13:37:01 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
2026-02-23 01:23:09 +05:30
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
result = await gateway_compat.check_gateway_version_compatibility(
|
2026-02-15 15:59:55 +05:30
|
|
|
GatewayConfig(url="ws://gateway.example/ws"),
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
assert result.compatible is False
|
|
|
|
|
assert result.current_version == "dev"
|
|
|
|
|
assert "unsupported version format" in (result.message or "").lower()
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-02-22 22:05:39 +05:30
|
|
|
async def test_check_gateway_version_compatibility_propagates_connect_errors(
|
2026-02-15 15:59:55 +05:30
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
2026-02-22 13:37:01 +05:30
|
|
|
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
|
|
|
|
_ = config
|
2026-02-22 22:05:39 +05:30
|
|
|
raise OpenClawGatewayError("connection refused")
|
2026-02-22 13:37:01 +05:30
|
|
|
|
|
|
|
|
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
with pytest.raises(OpenClawGatewayError, match="connection refused"):
|
|
|
|
|
await gateway_compat.check_gateway_version_compatibility(
|
|
|
|
|
GatewayConfig(url="ws://gateway.example/ws"),
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_admin_service_rejects_incompatible_gateway(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
return GatewayVersionCheckResult(
|
|
|
|
|
compatible=False,
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
current_version="2026.1.0",
|
|
|
|
|
message="Gateway version 2026.1.0 is not supported.",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 422
|
|
|
|
|
assert "not supported" in str(exc_info.value.detail)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_admin_service_maps_gateway_transport_errors(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
raise OpenClawGatewayError("connection refused")
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 502
|
|
|
|
|
assert "compatibility check failed" in str(exc_info.value.detail).lower()
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 19:19:26 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_admin_service_maps_gateway_scope_errors_with_guidance(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
raise OpenClawGatewayError("missing scope: operator.read")
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-22 19:19:26 +05:30
|
|
|
|
|
|
|
|
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
|
|
|
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
|
|
|
|
|
|
|
|
|
|
assert exc_info.value.status_code == 502
|
|
|
|
|
assert "missing required scope `operator.read`" in str(exc_info.value.detail)
|
|
|
|
|
|
|
|
|
|
|
2026-02-15 15:59:55 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gateway_status_reports_incompatible_version(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
return GatewayVersionCheckResult(
|
|
|
|
|
compatible=False,
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
current_version="2026.1.0",
|
|
|
|
|
message="Gateway version 2026.1.0 is not supported.",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-15 15:59:55 +05:30
|
|
|
|
|
|
|
|
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
response = await service.get_status(
|
|
|
|
|
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
|
|
|
|
|
organization_id=uuid4(),
|
|
|
|
|
user=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.connected is False
|
|
|
|
|
assert response.error == "Gateway version 2026.1.0 is not supported."
|
|
|
|
|
|
|
|
|
|
|
2026-02-22 19:19:26 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gateway_status_surfaces_scope_error_guidance(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
raise OpenClawGatewayError("missing scope: operator.read")
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-22 19:19:26 +05:30
|
|
|
|
|
|
|
|
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
response = await service.get_status(
|
|
|
|
|
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
|
|
|
|
|
organization_id=uuid4(),
|
|
|
|
|
user=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.connected is False
|
|
|
|
|
assert response.error is not None
|
|
|
|
|
assert "missing required scope `operator.read`" in response.error
|
|
|
|
|
|
|
|
|
|
|
2026-02-15 15:59:55 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gateway_status_returns_sessions_when_version_compatible(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
|
|
|
|
|
_ = (config, minimum_version)
|
|
|
|
|
return GatewayVersionCheckResult(
|
|
|
|
|
compatible=True,
|
|
|
|
|
minimum_version="2026.1.30",
|
|
|
|
|
current_version="2026.2.0",
|
|
|
|
|
message=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
|
|
|
|
_ = (params, config)
|
|
|
|
|
assert method == "sessions.list"
|
|
|
|
|
return {"sessions": [{"key": "agent:main"}]}
|
|
|
|
|
|
2026-02-22 22:05:39 +05:30
|
|
|
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
2026-02-15 15:59:55 +05:30
|
|
|
monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call)
|
|
|
|
|
|
|
|
|
|
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
|
|
|
|
response = await service.get_status(
|
|
|
|
|
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
|
|
|
|
|
organization_id=uuid4(),
|
|
|
|
|
user=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.connected is True
|
|
|
|
|
assert response.sessions_count == 1
|