173 lines
6.4 KiB
Python
173 lines
6.4 KiB
Python
|
|
"""Tests for UnityInstanceMiddleware auth enforcement in remote-hosted mode."""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import sys
|
||
|
|
from unittest.mock import AsyncMock, Mock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from core.config import config
|
||
|
|
from tests.integration.test_helpers import DummyContext
|
||
|
|
|
||
|
|
|
||
|
|
class TestMiddlewareAuthEnforcement:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remote_hosted_requires_user_id(self, monkeypatch):
|
||
|
|
"""_inject_unity_instance should raise RuntimeError when remote-hosted and no user_id."""
|
||
|
|
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||
|
|
|
||
|
|
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||
|
|
|
||
|
|
middleware = UnityInstanceMiddleware()
|
||
|
|
|
||
|
|
# Mock _resolve_user_id to return None (no API key / failed validation)
|
||
|
|
monkeypatch.setattr(middleware, "_resolve_user_id",
|
||
|
|
AsyncMock(return_value=None))
|
||
|
|
|
||
|
|
ctx = DummyContext()
|
||
|
|
middleware_ctx = Mock()
|
||
|
|
middleware_ctx.fastmcp_context = ctx
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="API key authentication required"):
|
||
|
|
await middleware._inject_unity_instance(middleware_ctx)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_sets_user_id_in_context_state(self, monkeypatch):
|
||
|
|
"""_inject_unity_instance should set user_id in ctx state when resolved."""
|
||
|
|
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||
|
|
|
||
|
|
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||
|
|
|
||
|
|
middleware = UnityInstanceMiddleware()
|
||
|
|
monkeypatch.setattr(middleware, "_resolve_user_id",
|
||
|
|
AsyncMock(return_value="user-55"))
|
||
|
|
|
||
|
|
# We need PluginHub to be configured for the session resolution path
|
||
|
|
# But we don't need it to actually find a session for this test
|
||
|
|
from transport.plugin_hub import PluginHub
|
||
|
|
from transport.plugin_registry import PluginRegistry
|
||
|
|
|
||
|
|
registry = PluginRegistry()
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
PluginHub.configure(registry, loop)
|
||
|
|
|
||
|
|
ctx = DummyContext()
|
||
|
|
ctx.client_id = "client-1"
|
||
|
|
middleware_ctx = Mock()
|
||
|
|
middleware_ctx.fastmcp_context = ctx
|
||
|
|
|
||
|
|
# Set an active instance so the middleware doesn't try to auto-select
|
||
|
|
middleware.set_active_instance(ctx, "Proj@hash1")
|
||
|
|
# Register a matching session so resolution doesn't fail
|
||
|
|
await registry.register("s1", "Proj", "hash1", "2022", user_id="user-55")
|
||
|
|
|
||
|
|
await middleware._inject_unity_instance(middleware_ctx)
|
||
|
|
|
||
|
|
assert ctx.get_state("user_id") == "user-55"
|
||
|
|
|
||
|
|
|
||
|
|
class TestMiddlewareSessionKey:
|
||
|
|
def test_get_session_key_uses_user_id_fallback(self):
|
||
|
|
"""When no client_id, middleware should use user:$user_id as session key."""
|
||
|
|
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||
|
|
|
||
|
|
middleware = UnityInstanceMiddleware()
|
||
|
|
|
||
|
|
ctx = DummyContext()
|
||
|
|
# Simulate no client_id attribute
|
||
|
|
if hasattr(ctx, "client_id"):
|
||
|
|
delattr(ctx, "client_id")
|
||
|
|
ctx.set_state("user_id", "user-77")
|
||
|
|
|
||
|
|
key = middleware.get_session_key(ctx)
|
||
|
|
assert key == "user:user-77"
|
||
|
|
|
||
|
|
def test_get_session_key_prefers_client_id(self):
|
||
|
|
"""client_id should take precedence over user_id."""
|
||
|
|
from transport.unity_instance_middleware import UnityInstanceMiddleware
|
||
|
|
|
||
|
|
middleware = UnityInstanceMiddleware()
|
||
|
|
|
||
|
|
ctx = DummyContext()
|
||
|
|
ctx.client_id = "client-abc"
|
||
|
|
ctx.set_state("user_id", "user-77")
|
||
|
|
|
||
|
|
key = middleware.get_session_key(ctx)
|
||
|
|
assert key == "client-abc"
|
||
|
|
|
||
|
|
|
||
|
|
class TestAutoSelectDisabledRemoteHosted:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_auto_select_returns_none_in_remote_hosted(self, monkeypatch):
|
||
|
|
"""_maybe_autoselect_instance should return None in remote-hosted mode even with one session."""
|
||
|
|
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||
|
|
monkeypatch.setattr(config, "transport_mode", "http")
|
||
|
|
|
||
|
|
# Re-import middleware to pick up the stubbed transport module
|
||
|
|
monkeypatch.delitem(
|
||
|
|
sys.modules, "transport.unity_instance_middleware", raising=False)
|
||
|
|
from transport.unity_instance_middleware import UnityInstanceMiddleware, PluginHub as HubRef
|
||
|
|
|
||
|
|
# Configure PluginHub with one session so auto-select has something to find
|
||
|
|
from transport.plugin_registry import PluginRegistry
|
||
|
|
registry = PluginRegistry()
|
||
|
|
await registry.register("s1", "Proj", "h1", "2022", user_id="userA")
|
||
|
|
|
||
|
|
loop = asyncio.get_running_loop()
|
||
|
|
HubRef.configure(registry, loop)
|
||
|
|
|
||
|
|
middleware = UnityInstanceMiddleware()
|
||
|
|
ctx = DummyContext()
|
||
|
|
ctx.client_id = "client-1"
|
||
|
|
|
||
|
|
result = await middleware._maybe_autoselect_instance(ctx)
|
||
|
|
# Remote-hosted mode should NOT auto-select (early return at the transport check)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
|
||
|
|
class TestHttpAuthBehavior:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_http_local_does_not_require_user_id(self, monkeypatch):
|
||
|
|
"""HTTP local mode should allow requests without user_id."""
|
||
|
|
monkeypatch.setattr(config, "http_remote_hosted", False)
|
||
|
|
monkeypatch.setattr(config, "transport_mode", "http")
|
||
|
|
|
||
|
|
from transport import unity_transport
|
||
|
|
|
||
|
|
async def fake_send_command_for_instance(*_args, **_kwargs):
|
||
|
|
return {"success": True, "data": {"ok": True}}
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
unity_transport.PluginHub,
|
||
|
|
"send_command_for_instance",
|
||
|
|
fake_send_command_for_instance,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _unused_send_fn(*_args, **_kwargs):
|
||
|
|
raise AssertionError("send_fn should not be used in HTTP mode")
|
||
|
|
|
||
|
|
result = await unity_transport.send_with_unity_instance(
|
||
|
|
_unused_send_fn, None, "ping", {}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["success"] is True
|
||
|
|
assert result["data"] == {"ok": True}
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_http_remote_requires_user_id(self, monkeypatch):
|
||
|
|
"""HTTP remote-hosted mode should reject requests without user_id."""
|
||
|
|
monkeypatch.setattr(config, "http_remote_hosted", True)
|
||
|
|
monkeypatch.setattr(config, "transport_mode", "http")
|
||
|
|
|
||
|
|
from transport import unity_transport
|
||
|
|
|
||
|
|
async def _unused_send_fn(*_args, **_kwargs):
|
||
|
|
raise AssertionError("send_fn should not be used in HTTP mode")
|
||
|
|
|
||
|
|
result = await unity_transport.send_with_unity_instance(
|
||
|
|
_unused_send_fn, None, "ping", {}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["success"] is False
|
||
|
|
assert result["error"] == "auth_required"
|