5d4a98d06e
Reserve stdout for the JSON-RPC stream: _configure_stderr_logging() pins all logging to stderr (and rewrites any stray stdout handler) so a log line can never corrupt the stdio protocol. Extract a pure, testable build_server() from _serve(). Add end-to-end tests over the mcp in-memory transport (initialize + tools/list + tools/call), covering a successful round trip and a policy denial surfaced as an MCP error.
211 lines
7.8 KiB
Python
211 lines
7.8 KiB
Python
"""Tests for the local stdio transport adapter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from aegis_gitea_mcp import stdio_app
|
|
from aegis_gitea_mcp.config import reset_settings
|
|
from aegis_gitea_mcp.errors import ToolError
|
|
from aegis_gitea_mcp.request_context import get_gitea_user_login
|
|
|
|
|
|
@pytest.fixture
|
|
def stdio_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""Local-mode settings: PAT auth, no OAuth, no API-key requirement."""
|
|
reset_settings()
|
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
|
monkeypatch.setenv("GITEA_TOKEN", "local-pat-token")
|
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
|
monkeypatch.setenv("ENVIRONMENT", "test")
|
|
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
|
|
monkeypatch.setattr(stdio_app, "_owner_login", None)
|
|
|
|
|
|
def _patch_gitea_client(**methods: object) -> object:
|
|
"""Patch GiteaClient with an async-context-manager mock exposing methods."""
|
|
patcher = patch("aegis_gitea_mcp.gitea_client.GiteaClient")
|
|
cls = patcher.start()
|
|
instance = AsyncMock()
|
|
for name, value in methods.items():
|
|
setattr(instance, name, AsyncMock(return_value=value))
|
|
cls.return_value.__aenter__ = AsyncMock(return_value=instance)
|
|
cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
return patcher
|
|
|
|
|
|
# --- Environment bootstrap --------------------------------------------------
|
|
|
|
|
|
def test_bootstrap_forces_local_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OAUTH_MODE", raising=False)
|
|
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
|
monkeypatch.delenv("AUDIT_LOG_PATH", raising=False)
|
|
stdio_app._bootstrap_env()
|
|
import os
|
|
|
|
assert os.environ["OAUTH_MODE"] == "false"
|
|
assert os.environ["AUTH_ENABLED"] == "false"
|
|
assert os.environ["AUDIT_LOG_PATH"].endswith("audit.log")
|
|
|
|
|
|
def test_check_required_env_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GITEA_URL", raising=False)
|
|
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
|
with pytest.raises(stdio_app.StdioConfigError) as exc_info:
|
|
stdio_app._check_required_env()
|
|
assert "GITEA_URL" in str(exc_info.value)
|
|
assert "GITEA_TOKEN" in str(exc_info.value)
|
|
|
|
|
|
def test_check_required_env_passes_when_present(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
|
monkeypatch.setenv("GITEA_TOKEN", "tok")
|
|
stdio_app._check_required_env() # no raise
|
|
|
|
|
|
def test_default_audit_log_path_is_user_scoped() -> None:
|
|
path = stdio_app._default_audit_log_path()
|
|
assert path.name == "audit.log"
|
|
assert "aegis-gitea-mcp" in str(path)
|
|
|
|
|
|
def test_configure_stderr_logging_keeps_stdout_clean(stdio_env: None) -> None:
|
|
"""No log handler may target stdout (it is the JSON-RPC channel)."""
|
|
import logging
|
|
import sys
|
|
|
|
# Simulate a library that attached a stdout handler.
|
|
root = logging.getLogger()
|
|
root.addHandler(logging.StreamHandler(sys.stdout))
|
|
stdio_app._configure_stderr_logging()
|
|
for handler in logging.getLogger().handlers:
|
|
assert getattr(handler, "stream", sys.stderr) is not sys.stdout
|
|
|
|
|
|
def test_main_exits_when_env_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("GITEA_URL", raising=False)
|
|
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
stdio_app.main()
|
|
assert exc_info.value.code == 2
|
|
|
|
|
|
# --- Owner resolution -------------------------------------------------------
|
|
|
|
|
|
async def test_resolve_owner_login(stdio_env: None) -> None:
|
|
patcher = _patch_gitea_client(get_current_user={"login": "alice"})
|
|
try:
|
|
login = await stdio_app._resolve_owner_login()
|
|
finally:
|
|
patcher.stop()
|
|
assert login == "alice"
|
|
|
|
|
|
async def test_resolve_owner_login_empty_raises(stdio_env: None) -> None:
|
|
patcher = _patch_gitea_client(get_current_user={"login": ""})
|
|
try:
|
|
with pytest.raises(stdio_app.StdioConfigError):
|
|
await stdio_app._resolve_owner_login()
|
|
finally:
|
|
patcher.stop()
|
|
|
|
|
|
# --- Dispatch (shared registry + policy + audit) ----------------------------
|
|
|
|
|
|
async def test_dispatch_unknown_tool(stdio_env: None) -> None:
|
|
with pytest.raises(ToolError) as exc_info:
|
|
await stdio_app._dispatch("nope_not_a_tool", {})
|
|
assert exc_info.value.status_code == 404
|
|
|
|
|
|
async def test_dispatch_policy_denies_write_without_write_mode(stdio_env: None) -> None:
|
|
"""A write tool is denied by policy/WRITE_MODE before any network call."""
|
|
with pytest.raises(ToolError) as exc_info:
|
|
await stdio_app._dispatch("create_issue", {"owner": "acme", "repo": "app", "title": "x"})
|
|
assert exc_info.value.status_code == 403
|
|
assert "write mode is disabled" in str(exc_info.value.detail)
|
|
|
|
|
|
async def test_dispatch_pins_owner_login_and_returns(
|
|
stdio_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Dispatch pins request context to the PAT owner and runs the shared handler."""
|
|
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
|
patcher = _patch_gitea_client(
|
|
get_repository={
|
|
"owner": {"login": "acme"},
|
|
"name": "app",
|
|
"full_name": "acme/app",
|
|
}
|
|
)
|
|
try:
|
|
result = await stdio_app._dispatch("get_repository_info", {"owner": "acme", "repo": "app"})
|
|
finally:
|
|
patcher.stop()
|
|
assert result["name"] == "app"
|
|
# The dispatch pinned the trusted PAT owner onto the request context.
|
|
assert get_gitea_user_login() == "alice"
|
|
|
|
|
|
# --- End-to-end over the MCP protocol (in-memory transport) -----------------
|
|
|
|
|
|
async def test_build_server_exposes_registry_tools(stdio_env: None) -> None:
|
|
"""build_server() wires every registry tool, including gitea_request."""
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
|
server = stdio_app.build_server()
|
|
async with create_connected_server_and_client_session(server) as client:
|
|
await client.initialize()
|
|
listed = await client.list_tools()
|
|
names = {t.name for t in listed.tools}
|
|
assert "get_repository_info" in names
|
|
assert "gitea_request" in names
|
|
assert len(names) >= 40
|
|
|
|
|
|
async def test_stdio_tool_call_round_trip(stdio_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A tools/call over the MCP protocol dispatches through the shared core."""
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
|
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
|
patcher = _patch_gitea_client(
|
|
get_repository={"owner": {"login": "acme"}, "name": "app", "full_name": "acme/app"}
|
|
)
|
|
try:
|
|
server = stdio_app.build_server()
|
|
async with create_connected_server_and_client_session(server) as client:
|
|
await client.initialize()
|
|
result = await client.call_tool("get_repository_info", {"owner": "acme", "repo": "app"})
|
|
finally:
|
|
patcher.stop()
|
|
|
|
assert result.isError is False
|
|
text = "".join(getattr(block, "text", "") for block in result.content)
|
|
assert "app" in text
|
|
|
|
|
|
async def test_stdio_tool_call_policy_denial_is_reported(
|
|
stdio_env: None, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A write tool denied by WRITE_MODE surfaces as an MCP error, not a crash."""
|
|
from mcp.shared.memory import create_connected_server_and_client_session
|
|
|
|
monkeypatch.setattr(stdio_app, "_owner_login", "alice")
|
|
server = stdio_app.build_server()
|
|
async with create_connected_server_and_client_session(server) as client:
|
|
await client.initialize()
|
|
result = await client.call_tool(
|
|
"create_issue", {"owner": "acme", "repo": "app", "title": "x"}
|
|
)
|
|
|
|
assert result.isError is True
|
|
text = "".join(getattr(block, "text", "") for block in result.content)
|
|
assert "write mode is disabled" in text
|