refactor!: remove legacy adapters, policy engine and API-key auth

Removes the FastAPI server, hand-rolled MCP protocol, tool registry and
tools, YAML policy, resource-type authz, API-key and OAuth proxy modules,
automation, observability, cache, the old Gitea client and stdio adapter,
the key-management scripts and their tests (ADR 0006).
This commit is contained in:
Latte committed 2026-10-14 15:40:37 +02:00
1 parent c0b22c531e
commit 4ac9626eb5
41 files changed
-13441

No files matched your search

-208
View File
@@ -1,208 +0,0 @@
"""Tests for authentication module."""
import pytest
from aegis_gitea_mcp.auth import (
APIKeyValidator,
generate_api_key,
get_validator,
hash_api_key,
reset_validator,
)
from aegis_gitea_mcp.config import reset_settings
@pytest.fixture(autouse=True)
def reset_auth():
"""Reset authentication state between tests."""
reset_validator()
reset_settings()
yield
reset_validator()
reset_settings()
@pytest.fixture
def mock_env_with_key(monkeypatch):
"""Set up environment with valid API key."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64) # 64-char key
@pytest.fixture
def validator(mock_env_with_key):
"""Create API key validator instance."""
return APIKeyValidator()
def test_generate_api_key():
"""Test API key generation."""
key = generate_api_key(length=64)
assert len(key) == 64
assert all(c in "0123456789abcdef" for c in key)
def test_generate_api_key_custom_length():
"""Test API key generation with custom length."""
key = generate_api_key(length=128)
assert len(key) == 128
def test_hash_api_key():
"""Test API key hashing."""
key = "test-key-12345"
hashed = hash_api_key(key)
assert len(hashed) == 64 # SHA256 produces 64-char hex string
assert hashed == hash_api_key(key) # Deterministic
def test_validator_singleton(mock_env_with_key):
"""Test that get_validator returns same instance."""
validator1 = get_validator()
validator2 = get_validator()
assert validator1 is validator2
def test_constant_time_compare(validator):
"""Test constant-time string comparison."""
# Same strings
assert validator._constant_time_compare("test", "test")
# Different strings
assert not validator._constant_time_compare("test", "fail")
# Different lengths
assert not validator._constant_time_compare("test", "testing")
def test_extract_bearer_token(validator):
"""Test bearer token extraction from Authorization header."""
# Valid bearer token
token = validator.extract_bearer_token("Bearer abc123")
assert token == "abc123"
# No header
token = validator.extract_bearer_token(None)
assert token is None
# Wrong format
token = validator.extract_bearer_token("abc123")
assert token is None
# Wrong scheme
token = validator.extract_bearer_token("Basic abc123")
assert token is None
# Too many parts
token = validator.extract_bearer_token("Bearer abc 123")
assert token is None
def test_validate_api_key_missing(validator):
"""Test validation with missing API key."""
is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent")
assert not is_valid
assert "Authorization header missing" in error
def test_validate_api_key_too_short(validator):
"""Test validation with too-short API key."""
is_valid, error = validator.validate_api_key("short", "127.0.0.1", "test-agent")
assert not is_valid
assert "Invalid API key format" in error
def test_validate_api_key_invalid(validator, mock_env_with_key):
"""Test validation with invalid API key."""
is_valid, error = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent")
assert not is_valid
assert "Invalid API key" in error
def test_validate_api_key_valid(validator, mock_env_with_key):
"""Test validation with valid API key."""
is_valid, error = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent")
assert is_valid
assert error is None
def test_rate_limiting(validator):
"""Test rate limiting after multiple failures."""
client_ip = "192.168.1.1"
# First 5 attempts should be allowed
for _ in range(5):
is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent")
assert not is_valid
# 6th attempt should be rate limited
is_valid, error = validator.validate_api_key("b" * 64, client_ip, "test-agent")
assert not is_valid
assert "Too many failed" in error
def test_rate_limiting_per_ip(validator):
"""Test that rate limiting is per IP address."""
ip1 = "192.168.1.1"
ip2 = "192.168.1.2"
# Fail 5 times from IP1
for _ in range(5):
validator.validate_api_key("b" * 64, ip1, "test-agent")
# IP1 should be rate limited
is_valid, error = validator.validate_api_key("b" * 64, ip1, "test-agent")
assert "Too many failed" in error
# IP2 should still work
is_valid, error = validator.validate_api_key("b" * 64, ip2, "test-agent")
assert "Invalid API key" in error # Wrong key, but not rate limited
def test_auth_disabled(monkeypatch):
"""Test that authentication can be disabled."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("MCP_API_KEYS", "") # No keys needed when disabled
validator = APIKeyValidator()
# Should allow access without key
is_valid, error = validator.validate_api_key(None, "127.0.0.1", "test-agent")
assert is_valid
assert error is None
def test_multiple_keys(monkeypatch):
"""Test validation with multiple API keys."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("MCP_API_KEYS", f"{'a' * 64},{'b' * 64},{'c' * 64}")
validator = APIKeyValidator()
# All three keys should work
is_valid, _ = validator.validate_api_key("a" * 64, "127.0.0.1", "test-agent")
assert is_valid
is_valid, _ = validator.validate_api_key("b" * 64, "127.0.0.1", "test-agent")
assert is_valid
is_valid, _ = validator.validate_api_key("c" * 64, "127.0.0.1", "test-agent")
assert is_valid
# Invalid key should fail
is_valid, _ = validator.validate_api_key("d" * 64, "127.0.0.1", "test-agent")
assert not is_valid
-261
View File
@@ -1,261 +0,0 @@
"""Tests for resource-type-aware authorization (fail-closed)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aegis_gitea_mcp import authz
from aegis_gitea_mcp.authz import (
ResourceClass,
ResourceType,
authorize_non_repository_access,
classify_raw_endpoint,
classify_tool,
verify_org_membership,
verify_site_admin,
)
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import normalize_raw_endpoint
@pytest.fixture
def authz_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Service-PAT-mode settings used by the authorization layer."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
def _endpoint(path: str) -> str:
return normalize_raw_endpoint(path)
# --- Classification ---------------------------------------------------------
@pytest.mark.parametrize(
("method", "path", "rtype", "ident_field", "ident_value"),
[
("GET", "/repos/acme/app/pulls/1", ResourceType.REPOSITORY, "repository", "acme/app"),
("GET", "/repos/issues/search", ResourceType.REPOSITORY, "repository", None),
("GET", "/orgs/acme/repos", ResourceType.ORG, "org", "acme"),
("GET", "/users/bob/repos", ResourceType.USER_OWNED, "owner", "bob"),
("GET", "/packages/bob/pypi", ResourceType.USER_OWNED, "owner", "bob"),
("GET", "/user/repos", ResourceType.USER_SELF, "repository", None),
("GET", "/notifications", ResourceType.USER_SELF, "repository", None),
("GET", "/markdown", ResourceType.MISC_GLOBAL, "repository", None),
("GET", "/version", ResourceType.MISC_GLOBAL, "repository", None),
("DELETE", "/admin/users/bob", ResourceType.ADMIN, "repository", None),
],
)
def test_classify_raw_endpoint(
method: str, path: str, rtype: ResourceType, ident_field: str, ident_value: str | None
) -> None:
result = classify_raw_endpoint(method, _endpoint(path))
assert result.resource_type is rtype
assert getattr(result, ident_field) == ident_value
def test_classify_tool_maps_typed_tools() -> None:
assert classify_tool("list_org_repositories", {"org": "acme"}).resource_type is ResourceType.ORG
assert classify_tool("list_org_repositories", {"org": "acme"}).org == "acme"
assert classify_tool("list_organizations", {}).resource_type is ResourceType.USER_SELF
# An unrecognized non-repo tool is UNKNOWN (deny).
assert classify_tool("something_new", {}).resource_type is ResourceType.UNKNOWN
def test_classify_tool_gitea_request_uses_path() -> None:
cls = classify_tool("gitea_request", {"method": "GET", "path": "/orgs/acme/repos"})
assert cls.resource_type is ResourceType.ORG
assert cls.org == "acme"
def test_classify_tool_gitea_request_traversal_is_unknown() -> None:
cls = classify_tool("gitea_request", {"method": "GET", "path": "/repos/../../admin"})
assert cls.resource_type is ResourceType.UNKNOWN
# --- Decision matrix (verification mocked) ----------------------------------
async def test_org_member_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_org_nonmember_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.ORG, is_write=False, org="acme")
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert exc_info.value.status_code == 403
async def test_user_owned_self_allowed(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="alice")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_owned_member_org_allowed(
authz_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="acme")
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_owned_other_denied(authz_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(authz, "verify_org_membership", AsyncMock(return_value=False))
cls = ResourceClass(ResourceType.USER_OWNED, is_write=False, owner="bob")
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_user_self_denied_in_service_pat_mode(authz_env: None) -> None:
cls = ResourceClass(ResourceType.USER_SELF, is_write=False)
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert "token-owner-scoped" in str(exc_info.value.detail)
async def test_misc_global_read_allowed_write_denied(authz_env: None) -> None:
read_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=False)
await authorize_non_repository_access(
classification=read_cls, user_login="alice", tool_name="gitea_request"
)
write_cls = ResourceClass(ResourceType.MISC_GLOBAL, is_write=True)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=write_cls, user_login="alice", tool_name="gitea_request"
)
async def test_admin_denied_without_opt_in(authz_env: None) -> None:
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
with pytest.raises(ToolError) as exc_info:
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
assert "RAW_API_ALLOW_SENSITIVE" in str(exc_info.value.detail)
async def test_admin_allowed_only_for_site_admin_with_opt_in(
authz_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true")
reset_settings()
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=True))
cls = ResourceClass(ResourceType.ADMIN, is_write=True)
await authorize_non_repository_access(
classification=cls, user_login="root", tool_name="gitea_request"
)
monkeypatch.setattr(authz, "verify_site_admin", AsyncMock(return_value=False))
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_unknown_resource_denied(authz_env: None) -> None:
cls = ResourceClass(ResourceType.UNKNOWN, is_write=False)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
async def test_repository_without_target_denied(authz_env: None) -> None:
"""A repo-typed call that could not be scoped to owner/repo fails closed."""
cls = ResourceClass(ResourceType.REPOSITORY, is_write=False, repository=None)
with pytest.raises(ToolError):
await authorize_non_repository_access(
classification=cls, user_login="alice", tool_name="gitea_request"
)
# --- Gitea verification helpers (fail-closed) -------------------------------
def _patch_service_response(status_code: int, json_value: object = None) -> Any:
response = MagicMock()
response.status_code = status_code
response.json.return_value = json_value
return response
def _patched_client(response: object) -> Any:
patcher = patch("aegis_gitea_mcp.authz.httpx.AsyncClient")
mock_client_cls = patcher.start()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
return patcher
async def test_verify_org_membership_204_true(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(204))
try:
assert await verify_org_membership(org="acme", user_login="alice") is True
finally:
patcher.stop()
async def test_verify_org_membership_404_false(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(404))
try:
assert await verify_org_membership(org="acme", user_login="alice") is False
finally:
patcher.stop()
async def test_verify_org_membership_unknown_user_false(authz_env: None) -> None:
assert await verify_org_membership(org="acme", user_login="unknown") is False
async def test_verify_site_admin_true_only_when_flag_set(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(200, {"is_admin": True}))
try:
assert await verify_site_admin(user_login="root") is True
finally:
patcher.stop()
async def test_verify_site_admin_false_when_flag_absent(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(200, {"is_admin": False}))
try:
assert await verify_site_admin(user_login="alice") is False
finally:
patcher.stop()
async def test_verify_site_admin_non_200_false(authz_env: None) -> None:
patcher = _patched_client(_patch_service_response(403, {}))
try:
assert await verify_site_admin(user_login="alice") is False
finally:
patcher.stop()
-155
View File
@@ -1,155 +0,0 @@
"""Tests for automation endpoints and controls."""
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def allow_oauth(monkeypatch: pytest.MonkeyPatch) -> None:
"""Mock OAuth validation to return a deterministic authenticated principal."""
async def _validate(_self, token, _ip, _ua):
if token == "a" * 64:
return True, None, {"login": "automation-user", "scopes": ["read:repository"]}
return False, "Invalid or expired OAuth token.", None
monkeypatch.setattr(
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
_validate,
)
def _set_base_env(
monkeypatch: pytest.MonkeyPatch, automation_enabled: bool, policy_path: Path
) -> None:
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
monkeypatch.setenv("AUTOMATION_ENABLED", "true" if automation_enabled else "false")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path))
def _yaml_with_trailing_newline(content: str) -> str:
return content.strip() + "\n"
def test_automation_job_denied_when_disabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
) -> None:
"""Automation endpoints should deny requests when automation mode is disabled."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("defaults:\n read: allow\n write: deny\n", encoding="utf-8")
_set_base_env(monkeypatch, automation_enabled=False, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
)
assert response.status_code == 403
assert "disabled" in response.json()["detail"]
def test_automation_job_executes_when_enabled(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
) -> None:
"""Dependency scan job should execute when automation is enabled and policy allows it."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
_yaml_with_trailing_newline("""
defaults:
read: allow
write: deny
tools:
allow:
- automation_dependency_hygiene_scan
- automation_webhook_ingest
"""),
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "dependency_hygiene_scan", "owner": "acme", "repo": "app"},
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert payload["result"]["job"] == "dependency_hygiene_scan"
def test_automation_webhook_policy_denied(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
) -> None:
"""Webhook ingestion must respect policy deny rules."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
_yaml_with_trailing_newline("""
defaults:
read: allow
write: deny
tools:
deny:
- automation_webhook_ingest
"""),
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/webhook",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"event_type": "scan.completed", "payload": {"status": "ok"}},
)
assert response.status_code == 403
assert "policy denied" in response.json()["detail"].lower()
def test_auto_issue_creation_denied_without_write_mode(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, allow_oauth: None
) -> None:
"""Auto issue creation job should be denied unless write mode is enabled."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
_yaml_with_trailing_newline("""
defaults:
read: allow
write: allow
tools:
allow:
- automation_auto_issue_creation
"""),
encoding="utf-8",
)
_set_base_env(monkeypatch, automation_enabled=True, policy_path=policy_path)
from aegis_gitea_mcp.server import app
client = TestClient(app)
response = client.post(
"/automation/jobs/run",
headers={"Authorization": f"Bearer {'a' * 64}"},
json={"job_name": "auto_issue_creation", "owner": "acme", "repo": "app"},
)
assert response.status_code == 403
assert "write mode is disabled" in response.json()["detail"].lower()
-140
View File
@@ -1,140 +0,0 @@
"""Unit tests for automation manager job paths."""
from __future__ import annotations
from pathlib import Path
import pytest
from aegis_gitea_mcp.automation import AutomationError, AutomationManager
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.policy import reset_policy_engine
class StubAutomationGiteaClient:
"""Async context manager stub for automation jobs."""
def __init__(self, token: str, issues: list[dict] | None = None) -> None:
self.token = token
self._issues = issues or []
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def list_issues(self, owner, repo, *, state, page, limit, labels=None):
return self._issues
async def create_issue(self, owner, repo, *, title, body, labels=None, assignees=None):
return {"number": 77, "title": title, "body": body}
@pytest.fixture(autouse=True)
def reset_globals() -> None:
"""Reset singleton state between tests."""
reset_settings()
reset_policy_engine()
yield
reset_settings()
reset_policy_engine()
@pytest.fixture
def automation_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Set environment for automation manager tests."""
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
(
"defaults:\n"
" read: allow\n"
" write: allow\n"
"tools:\n"
" allow:\n"
" - automation_stale_issue_detection\n"
" - automation_auto_issue_creation\n"
),
encoding="utf-8",
)
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")
monkeypatch.setenv("AUTOMATION_ENABLED", "true")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_path))
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/demo")
monkeypatch.setenv("AUTOMATION_STALE_DAYS", "30")
return policy_path
@pytest.mark.asyncio
async def test_stale_issue_detection_job_finds_old_issues(
automation_env: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Stale issue detection returns issue numbers older than cutoff."""
issues = [
{"number": 1, "updated_at": "2020-01-01T00:00:00Z"},
{"number": 2, "updated_at": "2999-01-01T00:00:00Z"},
]
monkeypatch.setattr(
"aegis_gitea_mcp.automation.GiteaClient",
lambda token: StubAutomationGiteaClient(token=token, issues=issues),
)
manager = AutomationManager()
result = await manager.run_job(
job_name="stale_issue_detection",
owner="acme",
repo="demo",
user_token="user-token",
)
assert result["stale_issue_numbers"] == [1]
assert result["stale_count"] == 1
@pytest.mark.asyncio
async def test_auto_issue_creation_requires_token(
automation_env: Path,
) -> None:
"""Auto-issue creation is denied when no user token is provided."""
manager = AutomationManager()
with pytest.raises(AutomationError, match="missing authenticated user token"):
await manager.run_job(
job_name="auto_issue_creation",
owner="acme",
repo="demo",
user_token=None,
)
@pytest.mark.asyncio
async def test_auto_issue_creation_job_success(
automation_env: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Auto-issue creation succeeds with write mode + scope + token."""
monkeypatch.setattr(
"aegis_gitea_mcp.automation.GiteaClient",
lambda token: StubAutomationGiteaClient(token=token),
)
manager = AutomationManager()
result = await manager.run_job(
job_name="auto_issue_creation",
owner="acme",
repo="demo",
user_token="user-token",
finding_title="Security finding",
finding_body="Details",
)
assert result["job"] == "auto_issue_creation"
assert result["issue_number"] == 77
-72
View File
@@ -1,72 +0,0 @@
"""Tests for the bounded TTL cache utility."""
from __future__ import annotations
import time
import pytest
from aegis_gitea_mcp.cache import BoundedTTLCache
def test_set_and_get_returns_value() -> None:
"""A stored value is returned before it expires."""
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=8)
cache.set("a", 1)
assert cache.get("a") == 1
def test_missing_key_returns_none() -> None:
"""An unknown key returns None."""
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60)
assert cache.get("missing") is None
def test_entry_expires_after_ttl() -> None:
"""An entry is evicted once its TTL elapses."""
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=0.05, max_size=8)
cache.set("a", 1)
assert cache.get("a") == 1
time.sleep(0.06)
assert cache.get("a") is None
def test_size_bound_evicts_oldest() -> None:
"""The cache never exceeds max_size; oldest entries are evicted first."""
cache: BoundedTTLCache[int, int] = BoundedTTLCache(ttl_seconds=60, max_size=3)
for i in range(5):
cache.set(i, i)
assert len(cache) == 3
# 0 and 1 were evicted; 2, 3, 4 remain.
assert cache.get(0) is None
assert cache.get(1) is None
assert cache.get(4) == 4
def test_reinsert_refreshes_recency() -> None:
"""Re-setting a key refreshes its position so it is not evicted first."""
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60, max_size=2)
cache.set("a", 1)
cache.set("b", 2)
cache.set("a", 3) # refresh "a"
cache.set("c", 4) # should evict "b", the oldest
assert cache.get("b") is None
assert cache.get("a") == 3
assert cache.get("c") == 4
def test_clear_empties_cache() -> None:
"""clear() removes all entries."""
cache: BoundedTTLCache[str, int] = BoundedTTLCache(ttl_seconds=60)
cache.set("a", 1)
cache.clear()
assert cache.get("a") is None
assert len(cache) == 0
def test_invalid_constructor_args() -> None:
"""Non-positive TTL or size is rejected."""
with pytest.raises(ValueError):
BoundedTTLCache(ttl_seconds=0)
with pytest.raises(ValueError):
BoundedTTLCache(ttl_seconds=60, max_size=0)
-128
View File
@@ -1,128 +0,0 @@
"""Tests for the gitea_request read/write classifier and known-path gate."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import (
normalize_raw_endpoint,
raw_is_known_api_path,
raw_request_is_write,
)
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
@pytest.fixture
def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""API-key-mode settings with default policy (read allow, write deny)."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
class StubRawGitea:
"""Stub Gitea client capturing raw_request calls."""
def __init__(self, response: Any = None) -> None:
self._response: Any = {"ok": True} if response is None else response
self.calls: list[dict[str, Any]] = []
async def raw_request(
self,
method: str,
endpoint: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
self.calls.append({"method": method, "endpoint": endpoint})
return self._response
# --- Pure classifier --------------------------------------------------------
@pytest.mark.parametrize(
("method", "path", "expected_write"),
[
("GET", "/repos/o/r/issues", False),
("HEAD", "/repos/o/r", False),
("POST", "/repos/o/r/issues", True),
("PUT", "/repos/o/r/pulls/1/merge", True),
("PATCH", "/repos/o/r/issues/1", True),
("DELETE", "/repos/o/r/issues/1", True),
# Render-only overrides are reads even though they are POSTs.
("POST", "/markdown", False),
("POST", "/markdown/raw", False),
("POST", "/repos/o/r/markup", False),
],
)
def test_raw_request_is_write(method: str, path: str, expected_write: bool) -> None:
endpoint = normalize_raw_endpoint(path)
assert raw_request_is_write(method, endpoint) is expected_write
def test_override_never_upgrades_a_mutating_post() -> None:
"""A normal mutating POST is never reclassified as a read."""
endpoint = normalize_raw_endpoint("/repos/o/r/issues")
assert raw_request_is_write("POST", endpoint) is True
@pytest.mark.parametrize(
("path", "known"),
[
("/repos/o/r", True),
("/orgs/acme/repos", True),
("/admin/users", True),
("/user/repos", True),
("/markdown", True),
("/version", True),
("/definitely/not/a/real/prefix", False),
("/wibble", False),
],
)
def test_raw_is_known_api_path(path: str, known: bool) -> None:
assert raw_is_known_api_path(normalize_raw_endpoint(path)) is known
# --- Handler: unknown path is denied before any network call ----------------
async def test_unknown_prefix_denied_before_network(raw_env: None) -> None:
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "GET", "path": "/wibble/wobble"})
assert exc_info.value.status_code == 403
assert "known Gitea API route prefix" in str(exc_info.value.detail)
assert stub.calls == []
# --- Write-mode bypass: a write that "looks like a read" is still a write ----
async def test_write_method_denied_with_write_mode_off_even_on_readish_path(
raw_env: None,
) -> None:
"""A POST to a known repo path is a write and is denied while write-mode is off."""
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
assert exc_info.value.status_code == 403
assert "write mode is disabled" in str(exc_info.value.detail)
assert stub.calls == []
async def test_render_only_post_allowed_as_read_without_write_mode(raw_env: None) -> None:
"""A markdown-render POST is classified read and proceeds with write-mode off."""
stub = StubRawGitea({"rendered": "<p>hi</p>"})
result = await raw_api_request_tool(stub, {"method": "POST", "path": "/markdown"})
assert result["write"] is False
assert stub.calls and stub.calls[0]["endpoint"] == "/api/v1/markdown"
-63
View File
@@ -1,63 +0,0 @@
"""Lock the transport-agnostic core boundary.
The core (tool registry, Gitea client, policy, audit, config, request context,
tools) must import cleanly without dragging in the web stack. If a stray
``import fastapi`` creeps back into a core module, the local stdio package would
gain a needless heavy dependency and the ``[server]`` extra split would leak.
The check runs in a subprocess because, within the pytest process, FastAPI is
already imported by the server tests — so ``'fastapi' in sys.modules`` would be
true regardless. A clean interpreter is the only reliable probe.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
_SRC = Path(__file__).resolve().parents[1] / "src"
# Core modules that must stay free of the web stack.
_CORE_MODULES = [
"aegis_gitea_mcp.registry",
"aegis_gitea_mcp.gitea_client",
"aegis_gitea_mcp.policy",
"aegis_gitea_mcp.audit",
"aegis_gitea_mcp.config",
"aegis_gitea_mcp.request_context",
"aegis_gitea_mcp.response_limits",
"aegis_gitea_mcp.security",
"aegis_gitea_mcp.cache",
"aegis_gitea_mcp.logging_utils",
"aegis_gitea_mcp.mcp_protocol",
"aegis_gitea_mcp.errors",
"aegis_gitea_mcp.tools.raw_tools",
"aegis_gitea_mcp.tools.read_tools",
"aegis_gitea_mcp.tools.write_tools",
"aegis_gitea_mcp.tools.repository",
"aegis_gitea_mcp.tools.arguments",
]
def test_core_does_not_import_fastapi() -> None:
"""Importing the core in a clean interpreter must not import FastAPI."""
imports = "\n".join(f"import {module}" for module in _CORE_MODULES)
program = (
f"import sys\n{imports}\n"
"leaked = [m for m in ('fastapi', 'uvicorn', 'starlette') if m in sys.modules]\n"
"assert not leaked, f'core leaked web stack: {leaked}'\n"
"print('ok')\n"
)
env = dict(os.environ)
env["PYTHONPATH"] = str(_SRC)
result = subprocess.run(
[sys.executable, "-c", program],
env=env,
capture_output=True,
text=True,
)
detail = f"core import boundary violated.\nstdout: {result.stdout}\nstderr: {result.stderr}"
assert result.returncode == 0, detail
assert "ok" in result.stdout
-413
View File
@@ -1,413 +0,0 @@
"""Unit tests for Gitea client request behavior."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from httpx import Client, Request, Response
from pydantic import ValidationError
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.gitea_client import (
GiteaAuthenticationError,
GiteaAuthorizationError,
GiteaClient,
GiteaError,
GiteaNotFoundError,
)
from aegis_gitea_mcp.tools.arguments import (
CommitDiffArgs,
CompareRefsArgs,
FileTreeArgs,
)
@pytest.fixture(autouse=True)
def gitea_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide minimal environment for client initialization."""
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()
@pytest.mark.asyncio
async def test_client_context_uses_bearer_header() -> None:
"""HTTP client is created with bearer token and closed on exit."""
with patch("aegis_gitea_mcp.gitea_client.AsyncClient") as mock_async_client:
mock_instance = AsyncMock()
mock_async_client.return_value = mock_instance
async with GiteaClient(token="user-oauth-token"):
pass
_, kwargs = mock_async_client.call_args
assert kwargs["headers"]["Authorization"] == "Bearer user-oauth-token"
mock_instance.aclose.assert_awaited_once()
def test_client_requires_non_empty_token() -> None:
"""Client construction fails when token is missing."""
with pytest.raises(ValueError, match="non-empty"):
GiteaClient(token=" ")
def test_handle_response_maps_error_codes() -> None:
"""HTTP status codes map to explicit domain exceptions."""
client = GiteaClient(token="user-token")
request = Request("GET", "https://gitea.example.com/api/v1/user")
with pytest.raises(GiteaAuthenticationError):
client._handle_response(Response(401, request=request), correlation_id="c1")
with pytest.raises(GiteaAuthorizationError):
client._handle_response(Response(403, request=request), correlation_id="c2")
with pytest.raises(GiteaNotFoundError):
client._handle_response(Response(404, request=request), correlation_id="c3")
with pytest.raises(GiteaError, match="boom"):
client._handle_response(
Response(500, request=request, json={"message": "boom"}),
correlation_id="c4",
)
assert client._handle_response(Response(200, request=request, json={"ok": True}), "c5") == {
"ok": True
}
@pytest.mark.asyncio
async def test_public_methods_delegate_to_request_and_normalize() -> None:
"""Wrapper methods call shared request logic and normalize return types."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint == "/api/v1/user":
return {"login": "alice"}
if endpoint == "/api/v1/user/repos":
return [{"name": "repo"}]
if endpoint == "/api/v1/repos/acme/demo":
return {"name": "demo"}
if endpoint == "/api/v1/repos/acme/demo/contents/README.md":
return {"size": 8, "content": "aGVsbG8=", "encoding": "base64"}
if endpoint == "/api/v1/repos/acme/demo/git/trees/main":
return {"tree": [{"path": "README.md"}]}
if endpoint == "/api/v1/repos/acme/demo/search":
return {"hits": []}
if endpoint == "/api/v1/repos/acme/demo/commits":
return [{"sha": "abc"}]
if endpoint == "/api/v1/repos/acme/demo/git/commits/abc":
return {"sha": "abc"}
if endpoint == "/api/v1/repos/acme/demo/compare/main...feature":
return {"total_commits": 1}
if endpoint == "/api/v1/repos/acme/demo/issues":
if method == "GET":
return [{"number": 1}]
return {"number": 12}
if endpoint == "/api/v1/repos/acme/demo/issues/1":
if method == "GET":
return {"number": 1}
return {"number": 1, "state": "closed"}
if endpoint == "/api/v1/repos/acme/demo/pulls":
return [{"number": 2}]
if endpoint == "/api/v1/repos/acme/demo/pulls/2":
return {"number": 2}
if endpoint == "/api/v1/repos/acme/demo/labels":
return [{"id": 1, "name": "bug"}]
if endpoint == "/api/v1/repos/acme/demo/tags":
return [{"name": "v1"}]
if endpoint == "/api/v1/repos/acme/demo/releases":
return [{"id": 1}]
if endpoint == "/api/v1/repos/acme/demo/issues/1/comments":
return {"id": 9}
if endpoint == "/api/v1/repos/acme/demo/issues/1/labels":
return {"labels": [{"name": "bug"}]}
if endpoint == "/api/v1/repos/acme/demo/issues/1/assignees":
return {"assignees": [{"login": "alice"}]}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
assert (await client.get_current_user())["login"] == "alice"
assert len(await client.list_repositories()) == 1
assert (await client.get_repository("acme", "demo"))["name"] == "demo"
assert (await client.get_file_contents("acme", "demo", "README.md"))["size"] == 8
assert len((await client.get_tree("acme", "demo"))["tree"]) == 1
assert isinstance(
await client.search_code("acme", "demo", "needle", ref="main", page=1, limit=5), dict
)
assert len(await client.list_commits("acme", "demo", ref="main", page=1, limit=5)) == 1
assert (await client.get_commit_diff("acme", "demo", "abc"))["sha"] == "abc"
assert isinstance(await client.compare_refs("acme", "demo", "main", "feature"), dict)
assert len(await client.list_issues("acme", "demo", state="open", page=1, limit=10)) == 1
assert (await client.get_issue("acme", "demo", 1))["number"] == 1
assert len(await client.list_pull_requests("acme", "demo", state="open", page=1, limit=10)) == 1
assert (await client.get_pull_request("acme", "demo", 2))["number"] == 2
assert len(await client.list_labels("acme", "demo", page=1, limit=10)) == 1
assert len(await client.list_tags("acme", "demo", page=1, limit=10)) == 1
assert len(await client.list_releases("acme", "demo", page=1, limit=10)) == 1
assert (await client.create_issue("acme", "demo", title="Hi", body="Body"))["number"] == 12
assert (await client.update_issue("acme", "demo", 1, state="closed"))["state"] == "closed"
assert (await client.create_issue_comment("acme", "demo", 1, "comment"))["id"] == 9
assert (await client.create_pr_comment("acme", "demo", 1, "comment"))["id"] == 9
assert isinstance(await client.add_labels("acme", "demo", 1, ["bug"]), dict)
assert isinstance(await client.assign_issue("acme", "demo", 1, ["alice"]), dict)
@pytest.mark.asyncio
async def test_get_file_contents_blocks_oversized_payload(monkeypatch: pytest.MonkeyPatch) -> None:
"""File size limits are enforced before returning content."""
monkeypatch.setenv("MAX_FILE_SIZE_BYTES", "5")
reset_settings()
client = GiteaClient(token="user-token")
client._request = AsyncMock( # type: ignore[method-assign]
return_value={"size": 50, "content": "x", "encoding": "base64"}
)
with pytest.raises(GiteaError, match="exceeds limit"):
await client.get_file_contents("acme", "demo", "big.bin")
_MALICIOUS_REFS = [
"../../../x/y",
"..",
"/etc/passwd",
"a\x00b",
"a?b",
"a#b",
]
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
def test_file_tree_args_reject_traversal_ref(value: str) -> None:
"""Layer 1: FileTreeArgs.ref rejects traversal/unsafe values."""
with pytest.raises(ValidationError):
FileTreeArgs(owner="o", repo="r", ref=value)
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
def test_commit_diff_args_reject_traversal_sha(value: str) -> None:
"""Layer 1: CommitDiffArgs.sha rejects traversal/unsafe values."""
# ".." is shorter than the 7-char min_length; still rejected (length or ref check).
with pytest.raises(ValidationError):
CommitDiffArgs(owner="o", repo="r", sha=value)
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
def test_compare_refs_args_reject_traversal_base(value: str) -> None:
"""Layer 1: CompareRefsArgs.base rejects traversal/unsafe values."""
with pytest.raises(ValidationError):
CompareRefsArgs(owner="o", repo="r", base=value, head="main")
@pytest.mark.parametrize("value", _MALICIOUS_REFS)
def test_compare_refs_args_reject_traversal_head(value: str) -> None:
"""Layer 1: CompareRefsArgs.head rejects traversal/unsafe values."""
with pytest.raises(ValidationError):
CompareRefsArgs(owner="o", repo="r", base="main", head=value)
@pytest.mark.asyncio
async def test_list_user_repositories_scopes_by_uid() -> None:
"""User-scoped listing resolves the uid and filters repo search by it."""
client = GiteaClient(token="service-pat")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint == "/api/v1/users/alice":
return {"id": 7, "login": "alice"}
if endpoint == "/api/v1/repos/search":
captured["params"] = kwargs.get("params")
return {"ok": True, "data": [{"full_name": "alice/demo"}, "not-a-dict"]}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
repos = await client.list_user_repositories("alice")
assert captured["params"]["uid"] == 7
assert repos == [{"full_name": "alice/demo"}]
@pytest.mark.asyncio
async def test_list_user_repositories_unknown_user_returns_empty() -> None:
"""A user that cannot be resolved yields an empty list, not an error."""
client = GiteaClient(token="service-pat")
async def fake_request(method: str, endpoint: str, **kwargs):
return {} # no id field
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
assert await client.list_user_repositories("ghost") == []
@pytest.mark.asyncio
async def test_resolve_label_ids_maps_names_case_insensitively() -> None:
"""Label names are resolved to ids regardless of case."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 3, "name": "Bug"}, {"id": 9, "name": "wontfix"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
ids = await client._resolve_label_ids("o", "r", ["bug", "WONTFIX"], correlation_id="c")
assert ids == [3, 9]
@pytest.mark.asyncio
async def test_resolve_label_ids_rejects_unknown_label() -> None:
"""An unknown label name raises a clear error instead of a silent failure."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 3, "name": "bug"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
with pytest.raises(GiteaError, match="Unknown label"):
await client._resolve_label_ids("o", "r", ["ghost"], correlation_id="c")
@pytest.mark.asyncio
async def test_resolve_milestone_id_passes_through_integer() -> None:
"""An integer milestone reference is used as a Gitea milestone id as-is."""
client = GiteaClient(token="user-token")
client._request = AsyncMock() # type: ignore[method-assign]
assert await client._resolve_milestone_id("o", "r", 7, correlation_id="c") == 7
# Integer ids must not trigger a milestone lookup.
client._request.assert_not_called()
@pytest.mark.asyncio
async def test_resolve_milestone_id_maps_title_case_insensitively() -> None:
"""A milestone title is resolved to its id regardless of case."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 11, "title": "Sprint 1"}, {"id": 12, "title": "Backlog"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
resolved = await client._resolve_milestone_id("o", "r", "sprint 1", correlation_id="c")
assert resolved == 11
@pytest.mark.asyncio
async def test_resolve_milestone_id_rejects_unknown_title() -> None:
"""An unknown milestone title raises a clear error."""
client = GiteaClient(token="user-token")
async def fake_request(method: str, endpoint: str, **kwargs):
return [{"id": 11, "title": "Sprint 1"}]
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
with pytest.raises(GiteaError, match="Unknown milestone"):
await client._resolve_milestone_id("o", "r", "Sprint 2", correlation_id="c")
@pytest.mark.asyncio
async def test_create_issue_resolves_milestone_title() -> None:
"""create_issue resolves a milestone title to an id in the POST payload."""
client = GiteaClient(token="user-token")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint.endswith("/milestones") and method == "GET":
return [{"id": 11, "title": "Sprint 1"}]
if endpoint.endswith("/issues") and method == "POST":
captured["payload"] = kwargs.get("json_body")
return {"number": 1, "title": "Issue", "state": "open"}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
await client.create_issue("o", "r", title="Issue", body="", milestone="Sprint 1")
assert captured["payload"]["milestone"] == 11
@pytest.mark.asyncio
async def test_update_issue_clears_milestone_with_zero() -> None:
"""update_issue forwards milestone id 0 verbatim to clear the milestone."""
client = GiteaClient(token="user-token")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
captured["payload"] = kwargs.get("json_body")
return {"number": 1, "title": "Issue", "state": "open"}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
await client.update_issue("o", "r", 1, milestone=0)
assert captured["payload"]["milestone"] == 0
@pytest.mark.asyncio
async def test_add_labels_resolves_names_to_ids() -> None:
"""add_labels translates names to ids before POSTing to Gitea."""
client = GiteaClient(token="user-token")
captured: dict = {}
async def fake_request(method: str, endpoint: str, **kwargs):
if endpoint.endswith("/labels") and method == "GET":
return [{"id": 42, "name": "bug"}]
if endpoint.endswith("/issues/1/labels") and method == "POST":
captured["body"] = kwargs.get("json_body")
return {"labels": [{"name": "bug"}]}
return {}
client._request = AsyncMock(side_effect=fake_request) # type: ignore[method-assign]
await client.add_labels("o", "r", 1, ["bug"])
assert captured["body"] == {"labels": [42]}
def test_git_refs_allow_slash_containing_refs() -> None:
"""Legitimate refs that contain '/' validate successfully."""
tree = FileTreeArgs(owner="o", repo="r", ref="feature/foo")
assert tree.ref == "feature/foo"
compare = CompareRefsArgs(owner="o", repo="r", base="release/1.0", head="main")
assert compare.base == "release/1.0"
assert compare.head == "main"
def test_git_ref_length_bounds_still_enforced() -> None:
"""Field min/max length still applies alongside the AfterValidator."""
with pytest.raises(ValidationError):
FileTreeArgs(owner="o", repo="r", ref="")
with pytest.raises(ValidationError):
FileTreeArgs(owner="o", repo="r", ref="a" * 201)
with pytest.raises(ValidationError):
# sha below 7-char minimum
CommitDiffArgs(owner="o", repo="r", sha="abc")
def test_layer2_encoding_confines_unsafe_chars_to_path_segment() -> None:
"""Layer 2 defense-in-depth: quote(..., safe='/') confines unsafe chars.
A sha containing ``?``, ``#``, whitespace, or a backslash that hypothetically
bypassed Layer 1 is percent-encoded so it cannot split off a query string,
fragment, or extra path component -- it stays inside the single ref segment
under the declared ``owner/repo``. (Note: ``..`` collapse is closed by Layer 1
validation, since ``quote`` intentionally leaves ``.`` and ``/`` literal to
preserve refs like ``feature/foo``.)
"""
from urllib.parse import quote
owner, repo = "alice", "repoA"
prefix = f"/api/v1/repos/{owner}/{repo}/git/commits/"
for malicious_sha in ["abc?injected=1", "abc#frag", "abc def", "abc\\..\\evil"]:
endpoint = (
f"/api/v1/repos/{quote(owner, safe='')}/{quote(repo, safe='')}"
f"/git/commits/{quote(malicious_sha, safe='/')}"
)
with Client(base_url="https://gitea.example.com") as http_client:
request = http_client.build_request("GET", endpoint)
# Stays scoped to declared repo, no query/fragment broke out.
assert request.url.path.startswith(prefix)
assert request.url.query == b""
assert request.url.fragment == ""
# Exactly one ref segment after the prefix (no path splitting).
assert "/" not in request.url.path[len(prefix) :]
-121
View File
@@ -1,121 +0,0 @@
"""Integration tests for end-to-end MCP authentication behavior."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.oauth import reset_oauth_validator
@pytest.fixture(autouse=True)
def reset_state() -> None:
"""Reset global state between tests."""
reset_settings()
reset_oauth_validator()
yield
reset_settings()
reset_oauth_validator()
@pytest.fixture
def full_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set OAuth-enabled environment for integration tests."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
monkeypatch.setenv("MCP_PORT", "8080")
monkeypatch.setenv("LOG_LEVEL", "INFO")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
@pytest.fixture
def client(full_env: None, monkeypatch: pytest.MonkeyPatch) -> TestClient:
"""Create test client with deterministic OAuth behavior."""
async def _validate(_self, token: str | None, _ip: str, _ua: str):
if token == "valid-read-token":
return True, None, {"login": "alice", "scopes": ["read:repository"]}
return False, "Invalid or expired OAuth token.", None
monkeypatch.setattr(
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
_validate,
)
from aegis_gitea_mcp.server import app
return TestClient(app)
def test_no_token_returns_401_with_www_authenticate(client: TestClient) -> None:
"""Missing bearer token is rejected with OAuth challenge metadata."""
response = client.post(
"/mcp/tool/call",
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
assert "WWW-Authenticate" in response.headers
assert "resource_metadata=" in response.headers["WWW-Authenticate"]
def test_invalid_token_returns_401(client: TestClient) -> None:
"""Invalid OAuth token is rejected."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer invalid-token"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
def test_valid_token_executes_tool(client: TestClient) -> None:
"""Valid OAuth token allows tool execution."""
with patch("aegis_gitea_mcp.gitea_client.GiteaClient.list_repositories") as mock_list_repos:
mock_list_repos.return_value = [{"id": 1, "name": "repo-one", "owner": {"login": "alice"}}]
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read-token"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
assert "result" in payload
def test_write_scope_enforcement_returns_403(client: TestClient) -> None:
"""Write tool calls are denied when token lacks write scope."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read-token"},
json={
"tool": "create_issue",
"arguments": {"owner": "acme", "repo": "demo", "title": "Needs write scope"},
},
)
assert response.status_code == 403
assert "required scope: write:repository" in response.json()["detail"].lower()
def test_error_responses_include_helpful_messages(client: TestClient) -> None:
"""Auth failures include actionable guidance."""
response = client.post(
"/mcp/tool/call",
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
data = response.json()
assert "Provide Authorization" in data["message"]
-640
View File
@@ -1,640 +0,0 @@
"""Tests for OAuth2 per-user Gitea authentication."""
from __future__ import annotations
import asyncio
import os
import stat
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.oauth import GiteaOAuthValidator, get_oauth_validator, reset_oauth_validator
from aegis_gitea_mcp.oauth_flow import OAuthClientRegistry, OAuthRegistrationRequest
from aegis_gitea_mcp.request_context import (
get_gitea_user_login,
get_gitea_user_token,
set_gitea_user_login,
set_gitea_user_token,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_state():
"""Reset global state between tests."""
reset_settings()
reset_oauth_validator()
yield
reset_settings()
reset_oauth_validator()
@pytest.fixture
def mock_env_oauth(monkeypatch):
"""Environment for OAuth mode tests."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
@pytest.fixture
def oauth_validator(mock_env_oauth):
"""Create GiteaOAuthValidator instance in OAuth mode."""
return GiteaOAuthValidator()
@pytest.fixture
def oauth_client(mock_env_oauth):
"""Create FastAPI test client in OAuth mode."""
from aegis_gitea_mcp.server import app
return TestClient(app, raise_server_exceptions=False)
def _register_public_client(oauth_client: TestClient, redirect_uri: str) -> dict[str, str]:
"""Register a public OAuth client for test flows."""
response = oauth_client.post(
"/register",
json={
"client_name": "pytest-client",
"redirect_uris": [redirect_uri],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
},
)
assert response.status_code == 200
payload = response.json()
assert "client_id" in payload
return payload
# ---------------------------------------------------------------------------
# GiteaOAuthValidator unit tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_validate_oauth_token_success(oauth_validator):
"""Valid Gitea OAuth token returns is_valid=True and user_data."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"login": "testuser", "id": 42}
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
"valid-gitea-token", "127.0.0.1", "TestAgent/1.0"
)
assert is_valid is True
assert error is None
assert user_data is not None
assert user_data["login"] == "testuser"
@pytest.mark.asyncio
async def test_validate_oauth_token_invalid_401(oauth_validator):
"""Gitea returning 401 results in is_valid=False."""
mock_response = MagicMock()
mock_response.status_code = 401
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
"expired-token", "127.0.0.1", "TestAgent/1.0"
)
assert is_valid is False
assert error is not None
assert user_data is None
@pytest.mark.asyncio
async def test_validate_oauth_token_missing_token(oauth_validator):
"""Missing token results in is_valid=False."""
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
None, "127.0.0.1", "TestAgent/1.0"
)
assert is_valid is False
assert error is not None
assert user_data is None
@pytest.mark.asyncio
async def test_validate_oauth_token_network_error(oauth_validator):
"""Network error results in is_valid=False with informative message."""
import httpx
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=httpx.RequestError("Connection refused", request=MagicMock())
)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
"some-token", "127.0.0.1", "TestAgent/1.0"
)
assert is_valid is False
assert error is not None
assert "unable to validate oauth token" in error.lower()
assert user_data is None
@pytest.mark.asyncio
async def test_validate_oauth_token_rate_limit(oauth_validator):
"""Exceeding failure threshold triggers rate limiting."""
mock_response = MagicMock()
mock_response.status_code = 401
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
# Exhaust failures (default MAX_AUTH_FAILURES=5)
for _ in range(5):
await oauth_validator.validate_oauth_token("bad-token", "10.0.0.1", "Agent")
# Next attempt should be rate-limited
is_valid, error, user_data = await oauth_validator.validate_oauth_token(
"bad-token", "10.0.0.1", "Agent"
)
assert is_valid is False
assert error is not None
assert "too many" in error.lower()
# ---------------------------------------------------------------------------
# Singleton tests
# ---------------------------------------------------------------------------
def test_get_oauth_validator_singleton(mock_env_oauth):
"""get_oauth_validator returns the same instance on repeated calls."""
v1 = get_oauth_validator()
v2 = get_oauth_validator()
assert v1 is v2
def test_reset_oauth_validator(mock_env_oauth):
"""reset_oauth_validator creates a fresh instance after reset."""
v1 = get_oauth_validator()
reset_oauth_validator()
v2 = get_oauth_validator()
assert v1 is not v2
# ---------------------------------------------------------------------------
# ContextVar isolation tests
# ---------------------------------------------------------------------------
def test_context_var_token_isolation():
"""ContextVar values do not leak between coroutines."""
results = {}
async def task_a():
set_gitea_user_token("token-for-a")
await asyncio.sleep(0)
results["a"] = get_gitea_user_token()
async def task_b():
# task_b never sets the token; should see None (default)
await asyncio.sleep(0)
results["b"] = get_gitea_user_token()
async def run():
await asyncio.gather(task_a(), task_b())
asyncio.run(run())
assert results["a"] == "token-for-a"
assert results["b"] is None # ContextVar isolation: task_b sees default
def test_context_var_login_set_and_get():
"""set_gitea_user_login / get_gitea_user_login work correctly."""
set_gitea_user_login("alice")
assert get_gitea_user_login() == "alice"
# ---------------------------------------------------------------------------
# /oauth/token proxy endpoint tests
# ---------------------------------------------------------------------------
def test_oauth_token_endpoint_available_when_oauth_mode_false(monkeypatch):
"""POST /oauth/token remains available regardless of OAUTH_MODE."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("OAUTH_MODE", "false")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"access_token": "token"}
from aegis_gitea_mcp.server import app
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
with TestClient(app, raise_server_exceptions=False) as client:
registration = client.post(
"/register",
json={
"client_name": "pytest-client",
"redirect_uris": ["http://127.0.0.1:8080/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
},
)
assert registration.status_code == 200
client_id = registration.json()["client_id"]
response = client.post(
"/oauth/token",
data={"client_id": client_id, "code": "abc123", "code_verifier": "pkce"},
)
assert response.status_code == 200
def test_oauth_token_endpoint_missing_code(oauth_client):
"""POST /oauth/token without a code returns 400."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
response = oauth_client.post(
"/oauth/token",
data={"client_id": client_data["client_id"], "code_verifier": "pkce"},
)
assert response.status_code == 400
def test_oauth_token_endpoint_proxy_success(oauth_client):
"""POST /oauth/token proxies successfully to Gitea and returns access_token."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "gitea-access-token-xyz",
"token_type": "bearer",
}
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
response = oauth_client.post(
"/oauth/token",
data={
"client_id": client_data["client_id"],
"code": "auth-code-123",
"code_verifier": "pkce-verifier",
},
)
assert response.status_code == 200
body = response.json()
assert body["access_token"] == "gitea-access-token-xyz"
def test_oauth_token_endpoint_gitea_error(oauth_client):
"""POST /oauth/token propagates Gitea error status."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.json.return_value = {"error": "invalid_grant"}
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
response = oauth_client.post(
"/oauth/token",
data={
"client_id": client_data["client_id"],
"code": "bad-code",
"code_verifier": "pkce-verifier",
},
)
assert response.status_code == 400
def test_oauth_authorize_and_callback_round_trip(oauth_client):
"""OAuth authorize/callback round-trip preserves the original redirect URI and state."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
authorize_response = oauth_client.get(
"/oauth/authorize",
params={
"client_id": client_data["client_id"],
"redirect_uri": "http://127.0.0.1:8080/callback",
"state": "original-state",
"code_challenge": "pkce-challenge",
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert authorize_response.status_code == 302
location = authorize_response.headers["location"]
assert "state=" in location
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback" not in location
from urllib.parse import parse_qs, urlparse
parsed = urlparse(location)
query = parse_qs(parsed.query)
proxy_state = query["state"][0]
callback_response = oauth_client.get(
"/oauth/callback",
params={"state": proxy_state, "code": "auth-code-123"},
follow_redirects=False,
)
assert callback_response.status_code == 302
callback_location = callback_response.headers["location"]
assert callback_location.startswith("http://127.0.0.1:8080/callback?")
assert "code=auth-code-123" in callback_location
assert "state=original-state" in callback_location
def test_oauth_callback_rejects_tampered_state(oauth_client):
"""OAuth callback rejects modified signed proxy state."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
authorize_response = oauth_client.get(
"/oauth/authorize",
params={
"client_id": client_data["client_id"],
"redirect_uri": "http://127.0.0.1:8080/callback",
"state": "original-state",
"code_challenge": "pkce-challenge",
"code_challenge_method": "S256",
},
follow_redirects=False,
)
from urllib.parse import parse_qs, urlparse
proxy_state = parse_qs(urlparse(authorize_response.headers["location"]).query)["state"][0]
tampered_state = proxy_state[:-1] + ("A" if proxy_state[-1] != "A" else "B")
callback_response = oauth_client.get(
"/oauth/callback",
params={"state": tampered_state, "code": "auth-code-123"},
)
assert callback_response.status_code == 400
@pytest.mark.parametrize(
"redirect_uri",
[
"https://claude.ai/api/mcp/auth_callback",
"https://claude.com/api/mcp/auth_callback",
],
)
def test_dcr_accepts_default_claude_callbacks(oauth_client, redirect_uri):
"""Claude's hosted connector callback URLs are allowed by default."""
response = oauth_client.post(
"/register",
json={
"client_name": "claude-client",
"redirect_uris": [redirect_uri],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
},
)
assert response.status_code == 200
def test_oauth_authorize_rejects_unknown_client(oauth_client):
"""OAuth authorize returns invalid_client for unregistered client IDs."""
response = oauth_client.get(
"/oauth/authorize",
params={
"client_id": "unknown-client",
"redirect_uri": "http://127.0.0.1:8080/callback",
"state": "x",
"code_challenge": "pkce-challenge",
"code_challenge_method": "S256",
},
)
assert response.status_code == 401
assert response.json()["detail"] == "invalid_client"
def test_oauth_token_rejects_unknown_dcr_client(oauth_client):
"""Unknown dynamic clients receive RFC 6749 invalid_client from token endpoint."""
response = oauth_client.post(
"/oauth/token",
data={
"client_id": "deleted-or-unknown-client",
"code": "auth-code-123",
"code_verifier": "pkce-verifier",
},
)
assert response.status_code == 401
assert response.json() == {"error": "invalid_client"}
def test_oauth_authorize_requires_pkce_s256(oauth_client):
"""Authorization endpoint enforces PKCE S256 for public clients."""
client_data = _register_public_client(oauth_client, "http://127.0.0.1:8080/callback")
missing_challenge = oauth_client.get(
"/oauth/authorize",
params={
"client_id": client_data["client_id"],
"redirect_uri": "http://127.0.0.1:8080/callback",
"state": "x",
},
)
plain_method = oauth_client.get(
"/oauth/authorize",
params={
"client_id": client_data["client_id"],
"redirect_uri": "http://127.0.0.1:8080/callback",
"state": "x",
"code_challenge": "pkce-challenge",
"code_challenge_method": "plain",
},
)
assert missing_challenge.status_code == 400
assert plain_method.status_code == 400
def test_register_rejects_foreign_redirect_uri(oauth_client):
"""DCR rejects redirect URIs outside the allowlist and loopback/Claude patterns."""
response = oauth_client.post(
"/register",
json={
"client_name": "pytest-client",
"redirect_uris": ["https://evil.example.com/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
},
)
assert response.status_code == 400
def test_dcr_registry_persists_registered_clients(tmp_path):
"""Registered OAuth clients survive registry reloads."""
storage_path = tmp_path / "dcr_clients.json"
registry = OAuthClientRegistry(storage_path)
request = OAuthRegistrationRequest.model_validate(
{
"client_name": "persisted-client",
"redirect_uris": ["http://127.0.0.1:8080/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
}
)
response = registry.register(request)
reloaded = OAuthClientRegistry(storage_path)
assert reloaded.get(response["client_id"]) is not None
@pytest.mark.skipif(
os.name != "posix", reason="POSIX permission bits are not enforced on this platform"
)
def test_dcr_storage_is_written_owner_only(tmp_path):
"""The persisted DCR store must not be readable beyond its owner (0o600)."""
storage_path = tmp_path / "dcr_clients.json"
registry = OAuthClientRegistry(storage_path)
request = OAuthRegistrationRequest.model_validate(
{
"client_name": "perm-client",
"redirect_uris": ["http://127.0.0.1:8080/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
}
)
registry.register(request)
mode = stat.S_IMODE(os.stat(storage_path).st_mode)
assert mode == 0o600
# ---------------------------------------------------------------------------
# Config validation tests
# ---------------------------------------------------------------------------
def test_config_oauth_mode_requires_client_id(monkeypatch):
"""OAUTH_MODE=true without client_id raises ValueError."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "some-secret")
from aegis_gitea_mcp.config import Settings
with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_ID"):
Settings() # type: ignore[call-arg]
def test_config_oauth_mode_requires_client_secret(monkeypatch):
"""OAUTH_MODE=true without client_secret raises ValueError."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "some-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "")
from aegis_gitea_mcp.config import Settings
with pytest.raises(Exception, match="GITEA_OAUTH_CLIENT_SECRET"):
Settings() # type: ignore[call-arg]
def test_config_standard_mode_requires_gitea_token(monkeypatch):
"""Standard mode without GITEA_TOKEN raises ValueError."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "false")
monkeypatch.setenv("GITEA_TOKEN", "")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
from aegis_gitea_mcp.config import Settings
with pytest.raises(Exception, match="GITEA_TOKEN"):
Settings() # type: ignore[call-arg]
# ---------------------------------------------------------------------------
# Server middleware: OAuth mode authentication
# ---------------------------------------------------------------------------
def test_mcp_tool_call_requires_valid_gitea_token(oauth_client):
"""POST /mcp/tool/call with an invalid Gitea token returns 401."""
mock_response = MagicMock()
mock_response.status_code = 401
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
response = oauth_client.post(
"/mcp/tool/call",
json={"tool": "list_repositories", "arguments": {}},
headers={"Authorization": "Bearer invalid-token"},
)
assert response.status_code == 401
def test_mcp_tool_call_no_token_returns_401(oauth_client):
"""POST /mcp/tool/call without Authorization header returns 401."""
response = oauth_client.post(
"/mcp/tool/call",
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
-215
View File
@@ -1,215 +0,0 @@
"""OIDC/JWKS-focused OAuth validator tests."""
from __future__ import annotations
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.algorithms import RSAAlgorithm
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.oauth import GiteaOAuthValidator, reset_oauth_validator
@pytest.fixture(autouse=True)
def reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset state and configure OAuth validation environment."""
reset_settings()
reset_oauth_validator()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("OAUTH_CACHE_TTL_SECONDS", "600")
yield
reset_settings()
reset_oauth_validator()
def _build_jwt_fixture(aud: str = "test-client-id") -> tuple[str, dict[str, object]]:
"""Generate RS256 access token and matching JWKS payload."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
jwk = json.loads(RSAAlgorithm.to_jwk(public_key))
jwk["kid"] = "kid-123"
now = int(time.time())
token = jwt.encode(
{
"sub": "user-1",
"preferred_username": "alice",
"scope": "read:repository write:repository",
"aud": aud,
"iss": "https://gitea.example.com",
"iat": now,
"exp": now + 3600,
},
private_key,
algorithm="RS256",
headers={"kid": "kid-123"},
)
return token, {"keys": [jwk]}
async def _validate_with_jwks(
validator: GiteaOAuthValidator, token: str, jwks: dict[str, object]
) -> tuple[bool, str | None, dict[str, object] | None]:
"""Drive a JWT validation with mocked discovery + JWKS responses."""
discovery_response = MagicMock()
discovery_response.status_code = 200
discovery_response.json.return_value = {
"issuer": "https://gitea.example.com",
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
}
jwks_response = MagicMock()
jwks_response.status_code = 200
jwks_response.json.return_value = jwks
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
return await validator.validate_oauth_token(token, "127.0.0.1", "TestAgent")
def test_acceptable_audiences_includes_resource_and_client_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The canonical MCP resource and the Gitea client id are accepted audiences."""
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
reset_settings()
reset_oauth_validator()
audiences = GiteaOAuthValidator()._acceptable_audiences()
assert "https://mcp.example.com" in audiences
assert "test-client-id" in audiences
@pytest.mark.asyncio
async def test_jwt_with_canonical_resource_audience_is_accepted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A token whose aud is the canonical MCP resource URL validates (P4)."""
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
reset_settings()
reset_oauth_validator()
token, jwks = _build_jwt_fixture(aud="https://mcp.example.com")
valid, error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks)
assert valid is True
assert error is None
assert principal is not None
@pytest.mark.asyncio
async def test_jwt_with_foreign_audience_is_rejected() -> None:
"""A token minted for a different audience is rejected (audience binding)."""
token, jwks = _build_jwt_fixture(aud="some-other-service")
# Foreign-audience JWT fails JWT validation, then falls back to userinfo, which
# is not mocked here and raises a network error -> overall failure.
with patch("aegis_gitea_mcp.oauth.GiteaOAuthValidator._validate_userinfo") as mock_userinfo:
from aegis_gitea_mcp.oauth import OAuthTokenValidationError
mock_userinfo.side_effect = OAuthTokenValidationError("Invalid", "userinfo_denied")
valid, _error, principal = await _validate_with_jwks(GiteaOAuthValidator(), token, jwks)
assert valid is False
assert principal is None
@pytest.mark.asyncio
async def test_validate_oauth_token_with_oidc_jwt_and_cache() -> None:
"""JWT token validation uses discovery + JWKS and caches both documents."""
token, jwks = _build_jwt_fixture()
validator = GiteaOAuthValidator()
discovery_response = MagicMock()
discovery_response.status_code = 200
discovery_response.json.return_value = {
"issuer": "https://gitea.example.com",
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
}
jwks_response = MagicMock()
jwks_response.status_code = 200
jwks_response.json.return_value = jwks
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[discovery_response, jwks_response])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
valid1, error1, principal1 = await validator.validate_oauth_token(
token, "127.0.0.1", "TestAgent"
)
valid2, error2, principal2 = await validator.validate_oauth_token(
token, "127.0.0.1", "TestAgent"
)
assert valid1 is True
assert error1 is None
assert principal1 is not None
assert principal1["login"] == "alice"
assert "write:repository" in principal1["scopes"]
assert valid2 is True
assert error2 is None
assert principal2 is not None
# Discovery + JWKS fetched once each because of cache.
assert mock_client.get.await_count == 2
@pytest.mark.asyncio
async def test_invalid_jwt_falls_back_and_fails_userinfo() -> None:
"""Invalid JWT returns auth failure when userinfo fallback rejects token."""
validator = GiteaOAuthValidator()
# JWT-shaped token with invalid signature/header.
bad_token = "abc.def.ghi"
discovery_response = MagicMock()
discovery_response.status_code = 200
discovery_response.json.return_value = {
"issuer": "https://gitea.example.com",
"jwks_uri": "https://gitea.example.com/login/oauth/keys",
}
jwks_response = MagicMock()
jwks_response.status_code = 200
jwks_response.json.return_value = {
"keys": [{"kid": "missing", "kty": "RSA", "n": "x", "e": "AQAB"}]
}
userinfo_denied = MagicMock()
userinfo_denied.status_code = 401
with patch("aegis_gitea_mcp.oauth.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=[discovery_response, jwks_response, userinfo_denied]
)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
is_valid, error, principal = await validator.validate_oauth_token(
bad_token,
"127.0.0.1",
"TestAgent",
)
assert is_valid is False
assert principal is None
assert error is not None
def test_extract_bearer_token_strict_parsing() -> None:
"""Bearer extraction accepts only strict `Bearer <token>` format."""
assert GiteaOAuthValidator.extract_bearer_token("Bearer abc123") == "abc123"
assert GiteaOAuthValidator.extract_bearer_token("bearer abc123") is None
assert GiteaOAuthValidator.extract_bearer_token("Bearer ") is None
assert GiteaOAuthValidator.extract_bearer_token("Basic abc") is None
-148
View File
@@ -1,148 +0,0 @@
"""Tests for YAML policy engine."""
from pathlib import Path
import pytest
from aegis_gitea_mcp.config import get_settings, reset_settings
from aegis_gitea_mcp.policy import PolicyEngine, PolicyError
def _set_base_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "token-12345")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
def _yaml_with_trailing_newline(content: str) -> str:
return content.strip() + "\n"
def test_default_policy_allows_read_and_denies_write(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Default policy should allow reads and deny writes when write mode is disabled."""
_set_base_env(monkeypatch)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(tmp_path / "does-not-exist.yaml")
read_decision = engine.authorize("list_repositories", is_write=False)
write_decision = engine.authorize("create_issue", is_write=True, repository="owner/repo")
assert read_decision.allowed
assert not write_decision.allowed
def test_policy_global_deny(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Global deny should reject matching tool names."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
_yaml_with_trailing_newline("""
defaults:
read: allow
write: deny
tools:
deny:
- list_repositories
"""),
encoding="utf-8",
)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
decision = engine.authorize("list_repositories", is_write=False)
assert not decision.allowed
assert "denied" in decision.reason
def test_repository_path_restriction(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Repository path allow-list should block unknown paths."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text(
_yaml_with_trailing_newline("""
repositories:
acme/app:
tools:
allow:
- get_file_contents
paths:
allow:
- src/*
"""),
encoding="utf-8",
)
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
allowed = engine.authorize(
"get_file_contents",
is_write=False,
repository="acme/app",
target_path="src/main.py",
)
denied = engine.authorize(
"get_file_contents",
is_write=False,
repository="acme/app",
target_path="docs/readme.md",
)
assert allowed.allowed
assert not denied.allowed
def test_invalid_policy_structure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Invalid policy YAML should raise PolicyError."""
_set_base_env(monkeypatch)
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("repositories: []\n", encoding="utf-8")
reset_settings()
_ = get_settings()
with pytest.raises(PolicyError):
PolicyEngine.from_yaml_file(policy_path)
def test_write_mode_repository_whitelist(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Write mode should require repo whitelist and honor configured repository entries."""
_set_base_env(monkeypatch)
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8")
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
allowed = engine.authorize("create_issue", is_write=True, repository="acme/app")
denied = engine.authorize("create_issue", is_write=True, repository="acme/other")
assert allowed.allowed
assert denied.allowed is False
def test_write_mode_allow_all_token_repos(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Write mode can be configured to allow all repos accessible to token."""
_set_base_env(monkeypatch)
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_ALLOW_ALL_TOKEN_REPOS", "true")
policy_path = tmp_path / "policy.yaml"
policy_path.write_text("defaults:\n write: allow\n", encoding="utf-8")
reset_settings()
_ = get_settings()
engine = PolicyEngine.from_yaml_file(policy_path)
decision = engine.authorize("create_issue", is_write=True, repository="acme/any-repo")
assert decision.allowed
-321
View File
@@ -1,321 +0,0 @@
"""Tests for the generic gitea_request raw API dispatch tool."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.tools.arguments import (
extract_repository,
extract_target_path,
normalize_raw_endpoint,
parse_raw_repository,
parse_raw_target_path,
raw_is_sensitive,
raw_top_segment,
)
from aegis_gitea_mcp.tools.raw_tools import raw_api_request_tool
@pytest.fixture
def raw_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Minimal API-key-mode settings with policy that allows reads, denies writes."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
# Point at a non-existent policy file so the default config applies
# (read: allow, write: deny) and tests do not depend on the repo policy.yaml.
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing-policy.yaml"))
class StubRawGitea:
"""Stub Gitea client capturing raw_request calls."""
def __init__(self, response: Any = None) -> None:
self._response: Any = {"ok": True} if response is None else response
self.calls: list[dict[str, Any]] = []
async def raw_request(
self,
method: str,
endpoint: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
self.calls.append(
{"method": method, "endpoint": endpoint, "params": params, "json_body": json_body}
)
return self._response
# --- Handler behavior ------------------------------------------------------
async def test_get_repo_endpoint_allowed_and_parses_repository(raw_env: None) -> None:
"""A GET on a repo endpoint is allowed and parses owner/repo from the path."""
stub = StubRawGitea({"number": 1})
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app/pulls/1"})
assert result["method"] == "GET"
assert result["path"] == "/api/v1/repos/acme/app/pulls/1"
assert result["write"] is False
assert result["repository"] == "acme/app"
assert result["data"] == {"number": 1}
assert stub.calls[0]["endpoint"] == "/api/v1/repos/acme/app/pulls/1"
async def test_lowercase_method_is_normalized(raw_env: None) -> None:
"""A lowercase method is uppercased and accepted."""
stub = StubRawGitea([{"id": 1}])
result = await raw_api_request_tool(stub, {"method": "get", "path": "/repos/acme/app/issues"})
assert result["method"] == "GET"
assert result["count"] == 1
async def test_delete_denied_when_write_mode_off(raw_env: None) -> None:
"""A write method is denied (no network call) while write-mode is disabled."""
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "DELETE", "path": "/repos/acme/app/issues/1"})
assert exc_info.value.status_code == 403
assert "write mode is disabled" in str(exc_info.value.detail)
assert stub.calls == []
async def test_write_allowed_with_write_mode_and_whitelist(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A write succeeds only when write-mode is on, the repo is whitelisted, and policy allows."""
policy_file = tmp_path / "policy.yaml"
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
stub = StubRawGitea({"merged": True})
result = await raw_api_request_tool(
stub,
{"method": "PUT", "path": "/repos/acme/app/pulls/1/merge", "body": {"Do": "merge"}},
)
assert result["write"] is True
assert result["repository"] == "acme/app"
assert stub.calls[0]["json_body"] == {"Do": "merge"}
async def test_write_denied_for_repo_outside_whitelist(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A write on a repo not in the whitelist is denied even with write-mode on."""
policy_file = tmp_path / "policy.yaml"
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/other")
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "POST", "path": "/repos/acme/app/issues"})
assert exc_info.value.status_code == 403
assert "whitelist" in str(exc_info.value.detail)
assert stub.calls == []
async def test_non_repository_write_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""A write that targets no repository is denied (secure default)."""
policy_file = tmp_path / "policy.yaml"
policy_file.write_text("defaults:\n read: allow\n write: allow\n", encoding="utf-8")
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(policy_file))
monkeypatch.setenv("WRITE_MODE", "true")
monkeypatch.setenv("WRITE_REPOSITORY_WHITELIST", "acme/app")
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "POST", "path": "/user/repos"})
assert exc_info.value.status_code == 403
assert "repository target" in str(exc_info.value.detail)
assert stub.calls == []
@pytest.mark.parametrize(
"path",
["/admin/users", "/users/bob/tokens", "/repos/acme/app/hooks", "/user/keys"],
)
async def test_sensitive_paths_denied_on_get(raw_env: None, path: str) -> None:
"""Admin/credential surfaces are denied for every method, including GET."""
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "GET", "path": path})
assert exc_info.value.status_code == 403
assert "sensitive-path denylist" in str(exc_info.value.detail)
assert stub.calls == []
async def test_sensitive_path_allowed_with_override(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""RAW_API_ALLOW_SENSITIVE bypasses the admin/credential denylist."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml"))
monkeypatch.setenv("RAW_API_ALLOW_SENSITIVE", "true")
stub = StubRawGitea([{"id": 1}])
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/admin/users"})
assert result["data"] == [{"id": 1}]
assert stub.calls[0]["endpoint"] == "/api/v1/admin/users"
async def test_cross_repo_search_not_treated_as_repository(raw_env: None) -> None:
"""/repos/issues/search is a cross-repo endpoint, so repository is None."""
stub = StubRawGitea([{"id": 1}])
result = await raw_api_request_tool(
stub, {"method": "GET", "path": "/repos/issues/search", "query": {"q": "bug"}}
)
assert result["repository"] is None
assert result["count"] == 1
assert stub.calls[0]["params"] == {"q": "bug"}
async def test_unknown_method_rejected_before_network(raw_env: None) -> None:
"""An unknown HTTP method is rejected during validation before any network call."""
stub = StubRawGitea()
with pytest.raises(ValidationError):
await raw_api_request_tool(stub, {"method": "OPTIONS", "path": "/repos/acme/app"})
assert stub.calls == []
async def test_path_traversal_rejected(raw_env: None) -> None:
"""A path containing '..' is rejected during validation."""
stub = StubRawGitea()
with pytest.raises(ValidationError):
await raw_api_request_tool(
stub, {"method": "GET", "path": "/repos/acme/app/../../admin/users"}
)
assert stub.calls == []
async def test_full_url_is_reduced_to_path(raw_env: None) -> None:
"""A full URL is reduced to just the API path."""
stub = StubRawGitea({"name": "app"})
result = await raw_api_request_tool(
stub,
{
"method": "GET",
"path": "https://gitea.example.com/api/v1/repos/acme/app/contents/src/app.py?ref=main",
},
)
assert result["path"] == "/api/v1/repos/acme/app/contents/src/app.py"
assert result["repository"] == "acme/app"
async def test_raw_api_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""The killswitch disables every dispatch."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("POLICY_FILE_PATH", str(tmp_path / "missing.yaml"))
monkeypatch.setenv("RAW_API_ENABLED", "false")
stub = StubRawGitea()
with pytest.raises(ToolError) as exc_info:
await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
assert exc_info.value.status_code == 403
assert "disabled" in str(exc_info.value.detail)
assert stub.calls == []
async def test_large_dict_response_is_truncated(raw_env: None) -> None:
"""An oversized object response is returned as a truncated JSON string."""
big = {"blob": "x" * 50_000}
stub = StubRawGitea(big)
result = await raw_api_request_tool(stub, {"method": "GET", "path": "/repos/acme/app"})
assert result["truncated"] is True
assert isinstance(result["data"], str)
# --- Path parsing helpers --------------------------------------------------
@pytest.mark.parametrize(
("path", "expected"),
[
("/repos/acme/app", "/api/v1/repos/acme/app"),
("repos/acme/app", "/api/v1/repos/acme/app"),
("/api/v1/repos/acme/app", "/api/v1/repos/acme/app"),
("/", "/api/v1"),
("", "/api/v1"),
],
)
def test_normalize_raw_endpoint(path: str, expected: str) -> None:
assert normalize_raw_endpoint(path) == expected
def test_normalize_raw_endpoint_rejects_traversal() -> None:
with pytest.raises(ValueError):
normalize_raw_endpoint("/repos/acme/../admin")
def test_parse_raw_repository_variants() -> None:
assert parse_raw_repository("/api/v1/repos/acme/app/pulls/1") == "acme/app"
assert parse_raw_repository("/api/v1/repos/search") is None
assert parse_raw_repository("/api/v1/repos/issues/search") is None
assert parse_raw_repository("/api/v1/user/repos") is None
def test_parse_raw_target_path() -> None:
assert parse_raw_target_path("/api/v1/repos/acme/app/contents/src/app.py") == "src/app.py"
assert parse_raw_target_path("/api/v1/repos/acme/app/raw/README.md") == "README.md"
assert parse_raw_target_path("/api/v1/repos/acme/app/pulls/1") is None
def test_raw_top_segment_and_sensitivity() -> None:
assert raw_top_segment("/api/v1/repos/acme/app") == "repos"
assert raw_top_segment("/api/v1") == ""
assert raw_is_sensitive("/api/v1/repos/acme/app/hooks") is True
assert raw_is_sensitive("/api/v1/user/applications/oauth2") is True
assert raw_is_sensitive("/api/v1/repos/acme/app/pulls") is False
def test_extractors_are_raw_aware() -> None:
raw_args = {"method": "GET", "path": "/repos/acme/app/contents/src/app.py"}
assert extract_repository(raw_args) == "acme/app"
assert extract_target_path(raw_args) == "src/app.py"
# Malformed raw path must not raise from the extractors.
assert extract_repository({"method": "GET", "path": "/repos/acme/../x"}) is None
assert extract_target_path({"method": "GET", "path": "/repos/acme/../x"}) is None
-172
View File
@@ -1,172 +0,0 @@
"""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"},
)
-862
View File
@@ -1,862 +0,0 @@
"""Tests for MCP server endpoints."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi.testclient import TestClient
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.oauth import reset_oauth_validator
@pytest.fixture(autouse=True)
def reset_state() -> None:
"""Reset global state between tests."""
reset_settings()
reset_oauth_validator()
yield
reset_settings()
reset_oauth_validator()
@pytest.fixture
def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set OAuth-first environment for server tests."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
monkeypatch.setenv("WRITE_MODE", "false")
monkeypatch.setenv("PUBLIC_BASE_URL", "")
@pytest.fixture
def mock_oauth_validation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Mock OAuth validator outcomes by token value."""
async def _validate(_self, token: str | None, _ip: str, _ua: str):
if token == "valid-read":
return True, None, {"login": "alice", "scopes": ["read:repository"]}
if token == "valid-write":
return (
True,
None,
{
"login": "alice",
"scopes": ["read:repository", "write:repository"],
},
)
return False, "Invalid or expired OAuth token.", None
monkeypatch.setattr(
"aegis_gitea_mcp.oauth.GiteaOAuthValidator.validate_oauth_token",
_validate,
)
@pytest.fixture
def client(oauth_env: None, mock_oauth_validation: None) -> TestClient:
"""Create FastAPI test client."""
from aegis_gitea_mcp.server import app
return TestClient(app)
def test_root_endpoint(client: TestClient) -> None:
"""Root endpoint returns server metadata."""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["name"] == "AegisGitea MCP Server"
assert data["status"] == "running"
def test_health_endpoint(client: TestClient) -> None:
"""Health endpoint does not require auth."""
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
def test_oauth_protected_resource_metadata(client: TestClient) -> None:
"""PRM advertises THIS server's canonical URL as the protected resource."""
response = client.get("/.well-known/oauth-protected-resource")
assert response.status_code == 200
data = response.json()
# RFC 9728/8707: the resource identifier is the MCP server's own URL, not Gitea's.
assert data["resource"] == "http://testserver"
assert data["authorization_servers"] == [
"http://testserver",
"https://gitea.example.com",
]
assert data["bearer_methods_supported"] == ["header"]
assert data["scopes_supported"] == ["read:repository", "write:repository"]
assert "resource_documentation" in data
def test_oauth_authorization_server_metadata(client: TestClient) -> None:
"""Auth server metadata advertises this server's proxy OAuth endpoints."""
response = client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
payload = response.json()
# Claude must be sent to our proxy authorize endpoint (Gitea does not know
# Claude's redirect_uri), so the endpoint lives on this server.
assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize"
assert payload["token_endpoint"] == "http://testserver/oauth/token"
assert payload["registration_endpoint"] == "http://testserver/register"
assert payload["scopes_supported"] == ["read:repository", "write:repository"]
def test_openid_configuration_metadata(client: TestClient) -> None:
"""OpenID metadata is exposed for clients expecting OIDC discovery."""
response = client.get("/.well-known/openid-configuration")
assert response.status_code == 200
payload = response.json()
assert payload["issuer"] == "https://gitea.example.com"
assert payload["authorization_endpoint"] == "http://testserver/oauth/authorize"
assert payload["token_endpoint"] == "http://testserver/oauth/token"
assert payload["userinfo_endpoint"].endswith("/login/oauth/userinfo")
assert payload["jwks_uri"].endswith("/login/oauth/keys")
assert "read:repository" in payload["scopes_supported"]
assert "write:repository" in payload["scopes_supported"]
def test_oauth_metadata_uses_public_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
"""Public base URL is used for externally advertised OAuth metadata links."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("PUBLIC_BASE_URL", "https://mcp.example.com")
monkeypatch.setenv("ENVIRONMENT", "test")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
from aegis_gitea_mcp.server import app
client = TestClient(app)
metadata_response = client.get("/.well-known/oauth-authorization-server")
assert metadata_response.status_code == 200
payload = metadata_response.json()
assert payload["token_endpoint"] == "https://mcp.example.com/oauth/token"
openid_response = client.get("/.well-known/openid-configuration")
assert openid_response.status_code == 200
openid_payload = openid_response.json()
assert openid_payload["token_endpoint"] == "https://mcp.example.com/oauth/token"
protected_response = client.get("/.well-known/oauth-protected-resource")
assert protected_response.status_code == 200
protected_payload = protected_response.json()
# P4: the protected resource identifier must equal this server's public base.
assert protected_payload["resource"] == "https://mcp.example.com"
assert protected_payload["authorization_servers"] == [
"https://mcp.example.com",
"https://gitea.example.com",
]
challenge_response = client.post(
"/mcp/tool/call",
json={"tool": "list_repositories", "arguments": {}},
)
assert challenge_response.status_code == 401
challenge = challenge_response.headers["WWW-Authenticate"]
assert (
'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
in challenge
)
def test_mcp_streamable_http_path_works(client: TestClient) -> None:
"""The spec path /mcp exposes the same transport behavior as the SSE alias."""
response = client.post(
"/mcp",
headers={"Authorization": "Bearer valid-read"},
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["protocolVersion"] == "2024-11-05"
def test_mcp_preflight_allows_same_origin(client: TestClient) -> None:
"""Same-origin preflight requests to /mcp return strict CORS headers."""
response = client.options(
"/mcp",
headers={
"Origin": "http://testserver",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
assert response.status_code == 204
assert response.headers["Access-Control-Allow-Origin"] == "http://testserver"
def test_mcp_preflight_rejects_cross_origin(client: TestClient) -> None:
"""Cross-origin browser requests to /mcp are denied."""
response = client.options(
"/mcp",
headers={
"Origin": "https://evil.example.com",
"Access-Control-Request-Method": "POST",
},
)
assert response.status_code == 403
def test_service_pat_requests_verify_user_repo_access_before_execution(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Service PAT fallback checks the user's repository permission before executing tools."""
from aegis_gitea_mcp import server
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
server._api_scope_cache.clear()
server.reset_repo_authz_cache()
probe_response = MagicMock()
probe_response.status_code = 200
repo_response = MagicMock()
repo_response.status_code = 403
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=[probe_response, repo_response])
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={
"tool": "get_repository_info",
"arguments": {"owner": "acme", "repo": "demo"},
},
)
assert response.status_code == 403
assert "permission" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_service_pat_repo_authz_allows_user_with_read_permission(
oauth_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Read-level collaborator permission allows service PAT execution to proceed."""
from aegis_gitea_mcp import server
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
server.reset_repo_authz_cache()
permission_response = MagicMock()
permission_response.status_code = 200
permission_response.json.return_value = {"permission": "read"}
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=permission_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
await server._verify_user_repository_access(
repository="acme/demo",
required_scope=server.READ_SCOPE,
user_login="alice",
correlation_id="corr-1",
tool_name="get_repository_info",
)
mock_client.get.assert_awaited_once()
requested_url = mock_client.get.await_args.args[0]
requested_headers = mock_client.get.await_args.kwargs["headers"]
assert requested_url.endswith("/api/v1/repos/acme/demo/collaborators/alice/permission")
assert requested_headers["Authorization"] == "token service-pat-token"
@pytest.mark.asyncio
async def test_service_pat_repo_authz_denies_read_user_for_write_tool(
oauth_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Read permission is insufficient for write tools in service PAT mode."""
from fastapi import HTTPException
from aegis_gitea_mcp import server
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
server.reset_repo_authz_cache()
permission_response = MagicMock()
permission_response.status_code = 200
permission_response.json.return_value = {"permission": "read"}
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=permission_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
with pytest.raises(HTTPException) as exc_info:
await server._verify_user_repository_access(
repository="acme/demo",
required_scope=server.WRITE_SCOPE,
user_login="alice",
correlation_id="corr-1",
tool_name="create_issue",
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_service_pat_repo_authz_cache_hit_and_expiry(
oauth_env: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Repository permission decisions are cached briefly and rechecked after expiry."""
from aegis_gitea_mcp import cache as cache_module
from aegis_gitea_mcp import server
monkeypatch.setenv("GITEA_TOKEN", "service-pat-token")
monkeypatch.setenv("REPO_AUTHZ_CACHE_TTL_SECONDS", "1")
server.reset_repo_authz_cache()
now = 1000.0
monkeypatch.setattr(cache_module.time, "monotonic", lambda: now)
permission_response = MagicMock()
permission_response.status_code = 200
permission_response.json.return_value = {"permission": "read"}
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=permission_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
for _ in range(2):
await server._verify_user_repository_access(
repository="acme/demo",
required_scope=server.READ_SCOPE,
user_login="alice",
correlation_id="corr-1",
tool_name="get_repository_info",
)
assert mock_client.get.await_count == 1
now = 1002.0
await server._verify_user_repository_access(
repository="acme/demo",
required_scope=server.READ_SCOPE,
user_login="alice",
correlation_id="corr-1",
tool_name="get_repository_info",
)
assert mock_client.get.await_count == 2
def test_scope_compatibility_write_implies_read() -> None:
"""write:repository grants read-level access for read tools."""
from aegis_gitea_mcp.server import READ_SCOPE, _has_required_scope
assert _has_required_scope(READ_SCOPE, {"write:repository"})
def test_scope_compatibility_repository_aliases() -> None:
"""Legacy/broad repository scopes satisfy MCP read/write requirements."""
from aegis_gitea_mcp.server import READ_SCOPE, WRITE_SCOPE, _has_required_scope
assert _has_required_scope(READ_SCOPE, {"repository"})
assert _has_required_scope(WRITE_SCOPE, {"repository"})
assert _has_required_scope(WRITE_SCOPE, {"repo"})
def test_list_tools_without_auth(client: TestClient) -> None:
"""Tool listing remains discoverable without auth."""
response = client.get("/mcp/tools")
assert response.status_code == 200
assert "tools" in response.json()
def test_call_tool_without_auth_returns_challenge(client: TestClient) -> None:
"""Tool calls without bearer token return 401 + WWW-Authenticate challenge."""
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
assert response.status_code == 401
assert "WWW-Authenticate" in response.headers
challenge = response.headers["WWW-Authenticate"]
assert 'resource_metadata="http://testserver/.well-known/oauth-protected-resource"' in challenge
assert 'scope="read:repository"' in challenge
def test_call_tool_invalid_token_returns_challenge(client: TestClient) -> None:
"""Invalid bearer token returns 401 + WWW-Authenticate challenge."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer invalid-token"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
assert "WWW-Authenticate" in response.headers
def test_sse_tools_list_returns_camel_case_schema(client: TestClient) -> None:
"""SSE tools/list returns MCP-compatible camelCase inputSchema."""
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"},
)
assert response.status_code == 200
data = response.json()
assert "result" in data
assert "tools" in data["result"]
assert "inputSchema" in data["result"]["tools"][0]
def test_sse_initialize_message(client: TestClient) -> None:
"""SSE initialize message returns protocol and server metadata."""
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
)
assert response.status_code == 200
payload = response.json()
assert payload["result"]["protocolVersion"] == "2024-11-05"
assert payload["result"]["serverInfo"]["name"] == "AegisGitea MCP"
def test_sse_tools_call_success_response(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""SSE tools/call wraps successful tool output in text content."""
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
assert tool_name == "list_repositories"
assert isinstance(arguments, dict)
assert correlation_id
return {"ok": True}
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={
"jsonrpc": "2.0",
"id": "call-1",
"method": "tools/call",
"params": {"name": "list_repositories", "arguments": {}},
},
)
assert response.status_code == 200
assert '"ok": true' in response.json()["result"]["content"][0]["text"].lower()
def test_sse_tools_call_http_exception(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""SSE tools/call maps HTTPException to JSON-RPC error envelope."""
async def _fake_execute(_tool_name: str, _arguments: dict, _correlation_id: str) -> dict:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Insufficient scope")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={
"jsonrpc": "2.0",
"id": "call-2",
"method": "tools/call",
"params": {"name": "create_issue", "arguments": {}},
},
)
assert response.status_code == 200
body = response.json()
assert body["error"]["code"] == -32000
assert "insufficient scope" in body["error"]["message"].lower()
def test_tool_call_not_found_maps_to_404(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A GiteaNotFoundError wrapped in RuntimeError surfaces as a clear 404."""
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
try:
raise GiteaNotFoundError("Resource not found")
except GiteaNotFoundError as exc:
raise RuntimeError("Failed to get issue: Resource not found") from exc
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
)
assert response.status_code == 404
assert "not found" in response.json()["error"].lower()
def test_tool_call_internal_error_includes_exception_type(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Masked internal errors name the exception type (safe) but never the message."""
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
raise TypeError("'NoneType' object is not iterable")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "get_issue", "arguments": {"owner": "a", "repo": "b", "issue_number": 1}},
)
assert response.status_code == 500
error = response.json()["error"]
assert "TypeError" in error
assert "NoneType" not in error
def test_sse_tools_call_not_found_returns_jsonrpc_error(
client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A wrapped GiteaNotFoundError in the SSE path returns -32000 with a clear message."""
from aegis_gitea_mcp.gitea_client import GiteaNotFoundError
async def _fake_execute(_tool: str, _args: dict, _cid: str) -> dict:
try:
raise GiteaNotFoundError("Resource not found")
except GiteaNotFoundError as exc:
raise RuntimeError("Failed to get issue: Resource not found") from exc
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={
"jsonrpc": "2.0",
"id": "nf-1",
"method": "tools/call",
"params": {
"name": "get_issue",
"arguments": {"owner": "a", "repo": "b", "issue_number": 1},
},
},
)
assert response.status_code == 200
body = response.json()
assert body["error"]["code"] == -32000
assert "not found" in body["error"]["message"].lower()
def test_call_nonexistent_tool(client: TestClient) -> None:
"""Unknown tools return 404 after successful auth."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "nonexistent_tool", "arguments": {}},
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_write_scope_enforced_before_policy(client: TestClient) -> None:
"""Write tools require write:repository scope."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={
"tool": "create_issue",
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
},
)
assert response.status_code == 403
assert "required scope: write:repository" in response.json()["detail"].lower()
def test_write_tool_denied_by_default_policy(client: TestClient) -> None:
"""Even with write scope, write mode stays denied by default policy."""
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-write"},
json={
"tool": "create_issue",
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
},
)
assert response.status_code == 403
assert "write mode is disabled" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_startup_event_fails_when_discovery_unreachable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Startup validation fails with clear guidance if OIDC discovery is unreachable."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("ENVIRONMENT", "production")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
from aegis_gitea_mcp import server
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(
side_effect=httpx.RequestError("connect failed", request=MagicMock())
)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
with pytest.raises(
RuntimeError,
match="unable to reach Gitea OIDC discovery endpoint",
):
await server.startup_event()
@pytest.mark.asyncio
async def test_startup_event_succeeds_when_discovery_ready(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Startup validation succeeds when OIDC discovery returns HTTP 200."""
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("ENVIRONMENT", "production")
monkeypatch.setenv("OAUTH_MODE", "true")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_ID", "test-client-id")
monkeypatch.setenv("GITEA_OAUTH_CLIENT_SECRET", "test-client-secret")
monkeypatch.setenv("OAUTH_STATE_SECRET", "test-state-secret-0123456789abcdef")
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
from aegis_gitea_mcp import server
mock_response = MagicMock()
mock_response.status_code = 200
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
await server.startup_event()
def test_probe_scopeless_token_returns_401(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Middleware returns 401 with re-auth guidance when Gitea probe returns 403."""
from aegis_gitea_mcp import server
server._api_scope_cache.clear()
mock_probe_response = MagicMock()
mock_probe_response.status_code = 403
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_probe_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
assert "WWW-Authenticate" in response.headers
def test_probe_valid_token_proceeds(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Middleware allows request through when Gitea probe returns 200."""
from aegis_gitea_mcp import server
server._api_scope_cache.clear()
mock_probe_response = MagicMock()
mock_probe_response.status_code = 200
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
return {"ok": True}
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_probe_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 200
assert response.json()["success"] is True
def test_gitea_authorization_error_returns_403(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GiteaAuthorizationError from tool handler returns 403 with re-auth guidance."""
from aegis_gitea_mcp import server
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
server._api_scope_cache.clear()
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
raise GiteaAuthorizationError("403 Forbidden")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
mock_probe_response = MagicMock()
mock_probe_response.status_code = 200
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_probe_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 403
body = response.json()
assert body["success"] is False
assert "re-authorize" in body["error"].lower()
def test_gitea_authentication_error_returns_401(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GiteaAuthenticationError from tool handler returns 401."""
from aegis_gitea_mcp import server
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError
server._api_scope_cache.clear()
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
raise GiteaAuthenticationError("401 Unauthorized")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
mock_probe_response = MagicMock()
mock_probe_response.status_code = 200
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_probe_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/tool/call",
headers={"Authorization": "Bearer valid-read"},
json={"tool": "list_repositories", "arguments": {}},
)
assert response.status_code == 401
body = response.json()
assert body["success"] is False
assert "re-authenticate" in body["error"].lower()
def test_sse_gitea_authorization_error_returns_jsonrpc_error(
oauth_env: None, mock_oauth_validation: None, monkeypatch: pytest.MonkeyPatch
) -> None:
"""GiteaAuthorizationError in SSE handler returns JSON-RPC -32000 with guidance."""
from aegis_gitea_mcp import server
from aegis_gitea_mcp.gitea_client import GiteaAuthorizationError
server._api_scope_cache.clear()
async def _fake_execute(tool_name: str, arguments: dict, correlation_id: str) -> dict:
raise GiteaAuthorizationError("403 Forbidden")
monkeypatch.setattr("aegis_gitea_mcp.server._execute_tool_call", _fake_execute)
mock_probe_response = MagicMock()
mock_probe_response.status_code = 200
with patch("aegis_gitea_mcp.server.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_probe_response)
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
from aegis_gitea_mcp.server import app
client = TestClient(app, raise_server_exceptions=False)
response = client.post(
"/mcp/sse",
headers={"Authorization": "Bearer valid-read"},
json={
"jsonrpc": "2.0",
"id": "err-1",
"method": "tools/call",
"params": {"name": "list_repositories", "arguments": {}},
},
)
assert response.status_code == 200
body = response.json()
assert body["error"]["code"] == -32000
assert "re-authorize" in body["error"]["message"].lower()
-210
View File
@@ -1,210 +0,0 @@
"""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
-542
View File
@@ -1,542 +0,0 @@
"""Tests for expanded read/write MCP tool handlers."""
import pytest
from aegis_gitea_mcp.config import reset_settings
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError, GiteaError
from aegis_gitea_mcp.tools.read_tools import (
compare_refs_tool,
get_branch_tool,
get_commit_diff_tool,
get_commit_status_tool,
get_issue_tool,
get_latest_release_tool,
get_pull_request_tool,
get_release_tool,
get_repo_languages_tool,
list_branches_tool,
list_commits_tool,
list_issue_comments_tool,
list_issues_tool,
list_labels_tool,
list_milestones_tool,
list_org_repositories_tool,
list_organizations_tool,
list_pull_request_commits_tool,
list_pull_request_files_tool,
list_pull_requests_tool,
list_releases_tool,
list_repo_topics_tool,
list_tags_tool,
search_code_tool,
)
from aegis_gitea_mcp.tools.write_tools import (
add_labels_tool,
assign_issue_tool,
create_branch_tool,
create_issue_comment_tool,
create_issue_tool,
create_label_tool,
create_milestone_tool,
create_pr_comment_tool,
create_pull_request_tool,
create_release_tool,
edit_issue_comment_tool,
edit_release_tool,
remove_labels_tool,
update_issue_tool,
update_label_tool,
)
@pytest.fixture(autouse=True)
def tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide minimal settings environment for response limit helpers."""
reset_settings()
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
monkeypatch.setenv("GITEA_TOKEN", "test-token")
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
monkeypatch.setenv("ENVIRONMENT", "test")
class StubGitea:
"""Stubbed Gitea client for tool unit tests."""
async def search_code(self, owner, repo, query, *, ref, page, limit):
return {"hits": [{"path": "src/main.py", "snippet": "match text", "score": 1.0}]}
async def list_commits(self, owner, repo, *, ref, page, limit):
return [{"sha": "abc1234", "commit": {"message": "Fix bug", "author": {"date": "now"}}}]
async def get_commit_diff(self, owner, repo, sha):
return {
"commit": {"message": "Fix bug"},
"files": [{"filename": "a.py", "status": "modified"}],
}
async def compare_refs(self, owner, repo, base, head):
return {
"commits": [{"sha": "abc", "commit": {"message": "Msg"}}],
"files": [{"filename": "a.py", "status": "modified"}],
}
async def list_issues(self, owner, repo, *, state, page, limit, labels=None):
return [{"number": 1, "title": "Issue", "state": "open", "labels": []}]
async def get_issue(self, owner, repo, index):
return {"number": index, "title": "Issue", "body": "Body", "state": "open", "labels": []}
async def list_pull_requests(self, owner, repo, *, state, page, limit):
return [{"number": 1, "title": "PR", "state": "open"}]
async def get_pull_request(self, owner, repo, index):
return {"number": index, "title": "PR", "body": "Body", "state": "open"}
async def list_labels(self, owner, repo, *, page, limit):
return [{"id": 1, "name": "bug", "color": "ff0000", "description": "desc"}]
async def list_tags(self, owner, repo, *, page, limit):
return [{"name": "v1.0.0", "commit": {"sha": "abc"}}]
async def list_releases(self, owner, repo, *, page, limit):
return [{"id": 1, "tag_name": "v1.0.0", "name": "release"}]
async def list_pull_request_files(self, owner, repo, index, *, page, limit):
return [{"filename": "a.py", "status": "modified", "additions": 1, "deletions": 0}]
async def list_pull_request_commits(self, owner, repo, index, *, page, limit):
return [{"sha": "abc", "commit": {"message": "m"}, "author": {"login": "alice"}}]
async def list_issue_comments(self, owner, repo, index, *, page, limit):
return [{"id": 1, "body": "hi", "user": {"login": "alice"}}]
async def list_branches(self, owner, repo, *, page, limit):
return [{"name": "main", "protected": True, "commit": {"id": "abc"}}]
async def get_branch(self, owner, repo, branch):
return {"name": branch, "protected": False, "commit": {"id": "abc"}}
async def get_release(self, owner, repo, release_id):
return {"id": release_id, "tag_name": "v1", "name": "rel"}
async def get_latest_release(self, owner, repo):
return {"id": 1, "tag_name": "v1", "name": "rel"}
async def list_milestones(self, owner, repo, *, state, page, limit):
return [{"id": 1, "title": "M", "state": state}]
async def get_commit_status(self, owner, repo, sha):
return {"state": "success", "statuses": [{"context": "ci", "status": "success"}]}
async def list_org_repositories(self, org, *, page, limit):
return [{"name": "r", "owner": {"login": org}, "full_name": f"{org}/r"}]
async def list_organizations(self, *, page, limit):
return [{"id": 1, "username": "acme", "description": "d"}]
async def get_repo_languages(self, owner, repo):
return {"Python": 100, "HTML": 5}
async def list_repo_topics(self, owner, repo):
return ["python", "mcp"]
async def create_issue(
self, owner, repo, *, title, body, labels=None, assignees=None, milestone=None
):
result = {"number": 1, "title": title, "state": "open"}
if milestone is not None:
result["milestone"] = {"id": 4, "title": str(milestone)}
return result
async def update_issue(
self, owner, repo, index, *, title=None, body=None, state=None, milestone=None
):
result = {"number": index, "title": title or "Issue", "state": state or "open"}
if milestone is not None:
result["milestone"] = {"id": 4, "title": str(milestone)}
return result
async def create_issue_comment(self, owner, repo, index, body):
return {"id": 1, "body": body}
async def create_pr_comment(self, owner, repo, index, body):
return {"id": 2, "body": body}
async def add_labels(self, owner, repo, index, labels):
return {"labels": [{"name": label} for label in labels]}
async def assign_issue(self, owner, repo, index, assignees):
return {"assignees": [{"login": user} for user in assignees]}
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
return {"id": 5, "name": name, "color": color, "description": description, "url": "u"}
async def update_label(self, owner, repo, *, name, new_name=None, color=None, description=None):
return {
"id": 5,
"name": new_name or name,
"color": color or "#ffffff",
"description": description or "",
}
async def remove_labels(self, owner, repo, index, labels):
return []
async def create_pull_request(self, owner, repo, *, title, head, base, body=""):
return {"number": 7, "title": title, "state": "open"}
async def create_release(
self, owner, repo, *, tag_name, name="", body="", draft=False, prerelease=False, target=None
):
return {"id": 3, "tag_name": tag_name, "name": name or tag_name}
async def edit_release(
self, owner, repo, release_id, *, name=None, body=None, draft=None, prerelease=None
):
return {"id": release_id, "tag_name": "v1", "name": name or "rel"}
async def create_branch(self, owner, repo, *, new_branch_name, old_branch_name=None):
return {"name": new_branch_name, "commit": {"id": "abc"}}
async def create_milestone(self, owner, repo, *, title, description="", due_on=None):
return {"id": 4, "title": title, "state": "open"}
async def edit_issue_comment(self, owner, repo, comment_id, body):
return {"id": comment_id, "body": body}
class ErrorGitea(StubGitea):
"""Stub that raises backend errors for failure-mode coverage."""
async def list_commits(self, owner, repo, *, ref, page, limit):
raise GiteaError("backend failure")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool,args,expected_key",
[
(search_code_tool, {"owner": "acme", "repo": "app", "query": "foo"}, "results"),
(list_commits_tool, {"owner": "acme", "repo": "app"}, "commits"),
(get_commit_diff_tool, {"owner": "acme", "repo": "app", "sha": "abc1234"}, "files"),
(
compare_refs_tool,
{"owner": "acme", "repo": "app", "base": "main", "head": "feature"},
"commits",
),
(list_issues_tool, {"owner": "acme", "repo": "app"}, "issues"),
(get_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1}, "title"),
(list_pull_requests_tool, {"owner": "acme", "repo": "app"}, "pull_requests"),
(get_pull_request_tool, {"owner": "acme", "repo": "app", "pull_number": 1}, "title"),
(list_labels_tool, {"owner": "acme", "repo": "app"}, "labels"),
(list_tags_tool, {"owner": "acme", "repo": "app"}, "tags"),
(list_releases_tool, {"owner": "acme", "repo": "app"}, "releases"),
(
list_pull_request_files_tool,
{"owner": "acme", "repo": "app", "pull_number": 1},
"files",
),
(
list_pull_request_commits_tool,
{"owner": "acme", "repo": "app", "pull_number": 1},
"commits",
),
(
list_issue_comments_tool,
{"owner": "acme", "repo": "app", "issue_number": 1},
"comments",
),
(list_branches_tool, {"owner": "acme", "repo": "app"}, "branches"),
(get_branch_tool, {"owner": "acme", "repo": "app", "branch": "main"}, "name"),
(get_release_tool, {"owner": "acme", "repo": "app", "release_id": 1}, "tag_name"),
(get_latest_release_tool, {"owner": "acme", "repo": "app"}, "tag_name"),
(list_milestones_tool, {"owner": "acme", "repo": "app"}, "milestones"),
(
get_commit_status_tool,
{"owner": "acme", "repo": "app", "sha": "abc1234"},
"state",
),
(list_org_repositories_tool, {"org": "acme"}, "repositories"),
(list_organizations_tool, {}, "organizations"),
(get_repo_languages_tool, {"owner": "acme", "repo": "app"}, "languages"),
(list_repo_topics_tool, {"owner": "acme", "repo": "app"}, "topics"),
],
)
async def test_extended_read_tools_success(tool, args, expected_key):
"""Each expanded read tool should return expected top-level keys."""
result = await tool(StubGitea(), args)
assert expected_key in result
@pytest.mark.asyncio
async def test_extended_read_tools_failure_mode() -> None:
"""Expanded read tools should wrap backend failures."""
with pytest.raises(RuntimeError):
await list_commits_tool(ErrorGitea(), {"owner": "acme", "repo": "app"})
@pytest.mark.asyncio
async def test_get_issue_tolerates_null_collections() -> None:
"""Regression for #13: Gitea may return null for labels/assignees/user.
`.get(key, [])` returns None when the key is present with a null value, so
iterating the result raised `'NoneType' object is not iterable`.
"""
class NullFieldsGitea(StubGitea):
async def get_issue(self, owner, repo, index):
return {
"number": index,
"title": "Issue",
"body": "Body",
"state": "open",
"user": None,
"labels": None,
"assignees": None,
}
result = await get_issue_tool(
NullFieldsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1}
)
assert result["author"] == ""
assert result["labels"] == []
assert result["assignees"] == []
@pytest.mark.asyncio
async def test_get_issue_skips_non_dict_collection_elements() -> None:
"""Defense-in-depth for #27: tolerate non-dict entries inside labels/assignees.
A stray null/non-object element would otherwise raise AttributeError when
`.get()` is called on it, surfacing as an opaque internal error.
"""
class MalformedElementsGitea(StubGitea):
async def get_issue(self, owner, repo, index):
return {
"number": index,
"title": "Issue",
"body": "Body",
"state": "open",
"user": {"login": "alice"},
"labels": [{"name": "bug"}, None, "weird"],
"assignees": [{"login": "bob"}, None, 42],
}
result = await get_issue_tool(
MalformedElementsGitea(), {"owner": "acme", "repo": "app", "issue_number": 1}
)
assert result["labels"] == ["bug"]
assert result["assignees"] == ["bob"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"tool,args,expected_key",
[
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}, "number"),
(
update_issue_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "title": "Updated"},
"number",
),
(
create_issue_comment_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "body": "comment"},
"id",
),
(
create_pr_comment_tool,
{"owner": "acme", "repo": "app", "pull_number": 1, "body": "comment"},
"id",
),
(
add_labels_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
"labels",
),
(
assign_issue_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["alice"]},
"assignees",
),
(
create_label_tool,
{"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"},
"id",
),
(
update_label_tool,
{"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"},
"id",
),
(
remove_labels_tool,
{"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]},
"removed",
),
(
create_pull_request_tool,
{"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"},
"number",
),
(
create_release_tool,
{"owner": "acme", "repo": "app", "tag_name": "v1.0.0"},
"id",
),
(
edit_release_tool,
{"owner": "acme", "repo": "app", "release_id": 3, "name": "x"},
"id",
),
(
create_branch_tool,
{"owner": "acme", "repo": "app", "new_branch_name": "feature/x"},
"name",
),
(
create_milestone_tool,
{"owner": "acme", "repo": "app", "title": "M1"},
"id",
),
(
edit_issue_comment_tool,
{"owner": "acme", "repo": "app", "comment_id": 5, "body": "edited"},
"id",
),
],
)
async def test_write_tools_success(tool, args, expected_key):
"""Write tools should normalize successful backend responses."""
result = await tool(StubGitea(), args)
assert expected_key in result
@pytest.mark.asyncio
async def test_create_label_normalizes_color_without_hash() -> None:
"""A hex color without a leading '#' is normalized before hitting Gitea."""
captured: dict = {}
class CaptureStub(StubGitea):
async def create_label(self, owner, repo, *, name, color, description="", exclusive=False):
captured["color"] = color
return {"id": 7, "name": name, "color": color}
result = await create_label_tool(
CaptureStub(),
{"owner": "acme", "repo": "app", "name": "bug", "color": "ff0000"},
)
assert captured["color"] == "#ff0000"
assert result["id"] == 7
def test_create_label_args_reject_invalid_color() -> None:
"""Non-hex color values are rejected at the argument layer."""
import pydantic
from aegis_gitea_mcp.tools.arguments import CreateLabelArgs
with pytest.raises(pydantic.ValidationError):
CreateLabelArgs(owner="o", repo="r", name="bug", color="red")
@pytest.mark.asyncio
async def test_create_issue_returns_assigned_milestone_title() -> None:
"""create_issue surfaces the assigned milestone title in its response."""
result = await create_issue_tool(
StubGitea(),
{"owner": "acme", "repo": "app", "title": "Issue", "milestone": "Sprint 1"},
)
assert result["milestone"] == "Sprint 1"
@pytest.mark.asyncio
async def test_update_issue_accepts_milestone_only() -> None:
"""update_issue may change only the milestone (no title/body/state needed)."""
result = await update_issue_tool(
StubGitea(),
{"owner": "acme", "repo": "app", "issue_number": 1, "milestone": 4},
)
assert result["milestone"] == "4"
def test_update_issue_args_require_a_changed_field() -> None:
"""An update with no mutable field (incl. milestone) is rejected."""
import pydantic
from aegis_gitea_mcp.tools.arguments import UpdateIssueArgs
with pytest.raises(pydantic.ValidationError):
UpdateIssueArgs(owner="o", repo="r", issue_number=1)
# Supplying only a milestone satisfies the change requirement.
assert UpdateIssueArgs(owner="o", repo="r", issue_number=1, milestone=0).milestone == 0
def test_issue_args_reject_boolean_milestone() -> None:
"""A boolean is rejected as a milestone reference (it subclasses int)."""
import pydantic
from aegis_gitea_mcp.tools.arguments import CreateIssueArgs
with pytest.raises(pydantic.ValidationError):
CreateIssueArgs(owner="o", repo="r", title="x", milestone=True)
# (tool, valid_args) for every write tool, used to exercise error branches.
WRITE_TOOL_ERROR_CASES = [
(create_issue_tool, {"owner": "acme", "repo": "app", "title": "Issue"}),
(update_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "title": "x"}),
(create_issue_comment_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "body": "c"}),
(create_pr_comment_tool, {"owner": "acme", "repo": "app", "pull_number": 1, "body": "c"}),
(add_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}),
(assign_issue_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "assignees": ["al"]}),
(create_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "color": "#ff0000"}),
(update_label_tool, {"owner": "acme", "repo": "app", "name": "bug", "new_name": "defect"}),
(remove_labels_tool, {"owner": "acme", "repo": "app", "issue_number": 1, "labels": ["bug"]}),
(
create_pull_request_tool,
{"owner": "acme", "repo": "app", "title": "PR", "head": "feature", "base": "main"},
),
(create_release_tool, {"owner": "acme", "repo": "app", "tag_name": "v1.0.0"}),
(edit_release_tool, {"owner": "acme", "repo": "app", "release_id": 3, "name": "x"}),
(create_branch_tool, {"owner": "acme", "repo": "app", "new_branch_name": "feature/x"}),
(create_milestone_tool, {"owner": "acme", "repo": "app", "title": "M1"}),
(edit_issue_comment_tool, {"owner": "acme", "repo": "app", "comment_id": 5, "body": "e"}),
]
class _WriteBackendErrorGitea:
"""Stub whose every method raises a generic Gitea backend error."""
def __getattr__(self, name: str):
async def _raise(*args: object, **kwargs: object) -> object:
raise GiteaError("backend failure")
return _raise
class _WriteAuthErrorGitea:
"""Stub whose every method raises an authentication error."""
def __getattr__(self, name: str):
async def _raise(*args: object, **kwargs: object) -> object:
raise GiteaAuthenticationError("token expired")
return _raise
@pytest.mark.asyncio
@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES)
async def test_write_tools_wrap_backend_errors(tool, args) -> None:
"""Every write tool wraps a backend GiteaError as a RuntimeError."""
with pytest.raises(RuntimeError):
await tool(_WriteBackendErrorGitea(), args)
@pytest.mark.asyncio
@pytest.mark.parametrize("tool,args", WRITE_TOOL_ERROR_CASES)
async def test_write_tools_propagate_auth_errors(tool, args) -> None:
"""Every write tool lets auth failures surface for re-authorization."""
with pytest.raises(GiteaAuthenticationError):
await tool(_WriteAuthErrorGitea(), args)