e873d0325b
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>
173 lines
5.9 KiB
Python
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"},
|
|
)
|