Files
AegisGitea-MCP/tests/test_repository_tools.py
T
Latte 624a3c79ee
docker / test (push) Successful in 25s
test / test (push) Successful in 32s
lint / lint (push) Successful in 33s
docker / docker-publish (push) Successful in 6s
docker / lint (push) Successful in 30s
docker / docker-test (push) Successful in 10s
fix: surface Gitea auth errors and document the service PAT
Two related issues made the connected MCP server return a bare "Internal
server error" for tools that need real Gitea API access (e.g.
list_repositories), while public-repo-by-path reads worked:

1. Gitea OIDC access tokens only carry openid/profile/email and cannot call
   the repository REST API, so pure-OAuth mode fails for most tools. A service
   PAT (GITEA_TOKEN) is required in practice; per-user permission is still
   enforced before each call, so this does not weaken authorization.
2. The tool handlers caught GiteaError broadly and re-raised it as RuntimeError.
   Because GiteaAuthenticationError/GiteaAuthorizationError subclass GiteaError,
   a clean 401/403 was masked as a generic internal error and the server's
   re-authorization guidance never fired.

Changes:
- read_tools.py / repository.py / write_tools.py: re-raise the auth/authz
  subclasses before the broad GiteaError catch so server.py returns actionable
  guidance instead of a generic 500.
- .env.example + README.md: document GITEA_TOKEN as a least-privilege bot PAT,
  explain why it's needed and that OAuth remains authoritative, and note that
  list_repositories is intentionally unavailable in service-PAT mode.
- tests: assert tool handlers propagate auth errors unwrapped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:47:10 +02:00

150 lines
5.0 KiB
Python

"""Tests for repository-focused tool handlers."""
from __future__ import annotations
import pytest
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaError,
)
from aegis_gitea_mcp.tools.repository import (
get_file_contents_tool,
get_file_tree_tool,
get_repository_info_tool,
list_repositories_tool,
)
@pytest.fixture(autouse=True)
def repository_tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide minimal settings needed by response limit helpers."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "legacy-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
yield
reset_settings()
class RepoStub:
"""Stub Gitea client for repository tools."""
async def list_repositories(self):
return [{"name": "demo", "owner": {"login": "acme"}, "full_name": "acme/demo"}]
async def get_repository(self, owner, repo):
return {"name": repo, "owner": {"login": owner}, "full_name": f"{owner}/{repo}"}
async def get_tree(self, owner, repo, ref, recursive):
return {"tree": [{"path": "README.md", "type": "blob", "size": 11, "sha": "abc"}]}
async def get_file_contents(self, owner, repo, filepath, ref):
return {
"content": "SGVsbG8gV29ybGQ=",
"encoding": "base64",
"size": 11,
"sha": "abc",
"html_url": f"https://example/{owner}/{repo}/{filepath}",
}
class RepoErrorStub(RepoStub):
"""Stub that raises backend errors."""
async def list_repositories(self):
raise GiteaError("backend down")
@pytest.mark.asyncio
async def test_list_repositories_tool_success() -> None:
"""Repository listing tool normalizes output shape."""
result = await list_repositories_tool(RepoStub(), {})
assert result["count"] == 1
assert result["repositories"][0]["owner"] == "acme"
@pytest.mark.asyncio
async def test_list_repositories_tool_failure_mode() -> None:
"""Repository listing tool wraps backend errors."""
with pytest.raises(RuntimeError, match="Failed to list repositories"):
await list_repositories_tool(RepoErrorStub(), {})
@pytest.mark.parametrize("auth_error", [GiteaAuthenticationError, GiteaAuthorizationError])
@pytest.mark.asyncio
async def test_tool_propagates_auth_errors_unwrapped(auth_error: type[GiteaError]) -> None:
"""Auth/authz failures must surface as-is, not masked behind RuntimeError.
The server maps these to actionable re-authorization guidance; wrapping them
in RuntimeError would hide that and return a generic internal error instead.
"""
class AuthErrorStub(RepoStub):
async def list_repositories(self):
raise auth_error("token rejected")
with pytest.raises(auth_error):
await list_repositories_tool(AuthErrorStub(), {})
@pytest.mark.asyncio
async def test_get_repository_info_tool_success() -> None:
"""Repository info tool returns normalized metadata."""
result = await get_repository_info_tool(RepoStub(), {"owner": "acme", "repo": "demo"})
assert result["full_name"] == "acme/demo"
@pytest.mark.asyncio
async def test_get_file_tree_tool_success() -> None:
"""File tree tool returns bounded tree entries."""
result = await get_file_tree_tool(
RepoStub(),
{"owner": "acme", "repo": "demo", "ref": "main", "recursive": False},
)
assert result["count"] == 1
assert result["tree"][0]["path"] == "README.md"
@pytest.mark.asyncio
async def test_get_file_contents_tool_decodes_base64() -> None:
"""File contents tool decodes UTF-8 base64 payloads."""
result = await get_file_contents_tool(
RepoStub(),
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
)
assert result["content"] == "Hello World"
@pytest.mark.asyncio
async def test_get_file_contents_tool_handles_invalid_base64() -> None:
"""Invalid base64 payloads are returned safely without crashing."""
class InvalidBase64Stub(RepoStub):
async def get_file_contents(self, owner, repo, filepath, ref):
return {"content": "%%%not-base64%%%", "encoding": "base64", "size": 4, "sha": "abc"}
result = await get_file_contents_tool(
InvalidBase64Stub(),
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
)
assert result["content"] == "%%%not-base64%%%"
@pytest.mark.asyncio
async def test_get_file_contents_tool_failure_mode() -> None:
"""File contents tool wraps backend failures."""
class ErrorFileStub(RepoStub):
async def get_file_contents(self, owner, repo, filepath, ref):
raise GiteaError("boom")
with pytest.raises(RuntimeError, match="Failed to get file contents"):
await get_file_contents_tool(
ErrorFileStub(),
{"owner": "acme", "repo": "demo", "filepath": "README.md", "ref": "main"},
)