Files
AegisGitea-MCP/tests/test_stdio_app.py
T
Latte 5d4a98d06e feat(stdio): harden local MCP transport and add end-to-end tests
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.
2026-06-27 15:19:42 +02:00

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