Files
AegisGitea-MCP/tests/test_repository_tools.py
T
Latte e873d0325b
docker / test (push) Successful in 28s
docker / lint (push) Successful in 33s
lint / lint (push) Successful in 35s
test / test (push) Successful in 33s
docker / docker-test (push) Successful in 10s
docker / docker-publish (push) Successful in 6s
feat: scope list_repositories to the authenticated user in service-PAT mode
Previously list_repositories was blocked in service-PAT mode because it has no
repository target for the per-user permission check, so users could not list
their repositories at all (the connector surfaced a generic error).

list_repositories now returns only the repositories the signed-in user owns or
contributes to, instead of everything the bot token can see:
- gitea_client.py: add list_user_repositories(login) — resolves the user id and
  queries /api/v1/repos/search with the uid filter.
- repository.py: list_repositories_tool uses the user-scoped path when a service
  PAT is configured and a user login is present; pure-OAuth mode still uses the
  user's own /user/repos.
- server.py: allow list_repositories through the service-PAT guard (it is scoped
  to the user in the handler); all other tools still require a repository target.
- README.md: document the new user-scoped behavior and its visibility caveat.

Tests: user-scoped client method (uid resolution + unknown user), PAT-mode tool
scoping, and conftest now clears the request context between tests to prevent
contextvar login leakage across files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:07:19 +02:00

173 lines
5.9 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.request_context import clear_gitea_auth_context, set_gitea_user_login
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(), {})
class UserScopedStub(RepoStub):
"""Stub exposing the per-user listing path used in service-PAT mode."""
async def list_user_repositories(self, login):
return [{"name": "mine", "owner": {"login": login}, "full_name": f"{login}/mine"}]
async def list_repositories(self):
raise AssertionError("PAT mode with a known user must use the user-scoped listing")
@pytest.mark.asyncio
async def test_list_repositories_tool_scopes_to_user_in_pat_mode() -> None:
"""With a service PAT (GITEA_TOKEN) and a known user, listing is user-scoped."""
set_gitea_user_login("alice")
try:
result = await list_repositories_tool(UserScopedStub(), {})
finally:
clear_gitea_auth_context()
assert result["count"] == 1
assert result["repositories"][0]["full_name"] == "alice/mine"
@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"},
)