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:
1 parent
c0b22c531e
commit
4ac9626eb5
41 files changed
-13441
No files matched your search
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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) :]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user