Add OAuth2/OIDC per-user Gitea authentication
Introduce a GiteaOAuthValidator for JWT and userinfo validation and fallbacks, add /oauth/token proxy, and thread per-user tokens through the request context and automation paths. Update config and .env.example for OAuth-first mode, add OpenAPI, extensive unit/integration tests, GitHub/Gitea CI workflows, docs, and lint/test enforcement (>=80% cov).
This commit is contained in:
@@ -8,6 +8,7 @@ import pytest
|
||||
from aegis_gitea_mcp.audit import reset_audit_logger
|
||||
from aegis_gitea_mcp.auth import reset_validator
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
from aegis_gitea_mcp.observability import reset_metrics_registry
|
||||
from aegis_gitea_mcp.policy import reset_policy_engine
|
||||
from aegis_gitea_mcp.rate_limit import reset_rate_limiter
|
||||
@@ -20,6 +21,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
||||
reset_settings()
|
||||
reset_audit_logger()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
reset_policy_engine()
|
||||
reset_rate_limiter()
|
||||
reset_metrics_registry()
|
||||
@@ -34,6 +36,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
||||
reset_settings()
|
||||
reset_audit_logger()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
reset_policy_engine()
|
||||
reset_rate_limiter()
|
||||
reset_metrics_registry()
|
||||
@@ -41,7 +44,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set up mock environment variables for testing."""
|
||||
"""Set up mock environment variables for testing (standard API key mode)."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
@@ -50,3 +53,17 @@ def mock_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_oauth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set up mock environment variables for OAuth mode testing."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("MCP_PORT", "8080")
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
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("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
@@ -6,6 +6,21 @@ 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:
|
||||
@@ -20,7 +35,7 @@ def _set_base_env(
|
||||
|
||||
|
||||
def test_automation_job_denied_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
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"
|
||||
@@ -41,7 +56,7 @@ def test_automation_job_denied_when_disabled(
|
||||
|
||||
|
||||
def test_automation_job_executes_when_enabled(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
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"
|
||||
@@ -74,7 +89,9 @@ tools:
|
||||
assert payload["result"]["job"] == "dependency_hygiene_scan"
|
||||
|
||||
|
||||
def test_automation_webhook_policy_denied(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
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(
|
||||
@@ -104,7 +121,7 @@ tools:
|
||||
|
||||
|
||||
def test_auto_issue_creation_denied_without_write_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
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"
|
||||
|
||||
140
tests/test_automation_manager.py
Normal file
140
tests/test_automation_manager.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
168
tests/test_gitea_client.py
Normal file
168
tests/test_gitea_client.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Unit tests for Gitea client request behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import Request, Response
|
||||
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import (
|
||||
GiteaAuthenticationError,
|
||||
GiteaAuthorizationError,
|
||||
GiteaClient,
|
||||
GiteaError,
|
||||
GiteaNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@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 [{"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")
|
||||
@@ -1,225 +1,120 @@
|
||||
"""Integration tests for the complete system."""
|
||||
"""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.auth import reset_validator
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
def reset_state() -> None:
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_env(monkeypatch):
|
||||
"""Set up complete test environment."""
|
||||
def full_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set OAuth-enabled environment for integration tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
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("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", f"{'a' * 64},{'b' * 64}")
|
||||
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("MCP_PORT", "8080")
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
monkeypatch.setenv("MAX_AUTH_FAILURES", "5")
|
||||
monkeypatch.setenv("AUTH_FAILURE_WINDOW", "300")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(full_env):
|
||||
"""Create test client with full environment."""
|
||||
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_complete_authentication_flow(client):
|
||||
"""Test complete authentication flow from start to finish."""
|
||||
# 1. Health check should work without auth
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
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": {}},
|
||||
)
|
||||
|
||||
# 2. Tool listing should work without auth (Mixed mode for ChatGPT)
|
||||
response = client.get("/mcp/tools")
|
||||
assert response.status_code == 200
|
||||
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": {}},
|
||||
)
|
||||
|
||||
# 3. Protected endpoint (tool execution) should reject without auth
|
||||
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
|
||||
assert response.status_code == 401
|
||||
|
||||
# 4. Protected endpoint should reject with invalid key
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer " + "c" * 64},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# 5. Protected endpoint should pass auth with valid key (first key)
|
||||
# Note: May fail with 500 due to missing Gitea connection, but auth passes
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer " + "a" * 64},
|
||||
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"}}]
|
||||
|
||||
# 6. Protected endpoint should pass auth with valid key (second key)
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer " + "b" * 64},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
assert response.status_code != 401
|
||||
|
||||
|
||||
def test_key_rotation_simulation(client, monkeypatch):
|
||||
"""Simulate key rotation with grace period."""
|
||||
# Start with key A
|
||||
response = client.get("/mcp/tools", headers={"Authorization": "Bearer " + "a" * 64})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Both keys A and B work (grace period)
|
||||
response = client.get("/mcp/tools", headers={"Authorization": "Bearer " + "a" * 64})
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/mcp/tools", headers={"Authorization": "Bearer " + "b" * 64})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_multiple_tool_calls_with_auth(client):
|
||||
"""Test multiple tool calls with authentication."""
|
||||
headers = {"Authorization": "Bearer " + "a" * 64}
|
||||
|
||||
# List tools
|
||||
response = client.get("/mcp/tools", headers=headers)
|
||||
assert response.status_code == 200
|
||||
tools = response.json()["tools"]
|
||||
|
||||
# Try to call each tool (will fail without proper Gitea connection, but auth should work)
|
||||
for tool in tools:
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers=headers, json={"tool": tool["name"], "arguments": {}}
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-read-token"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
)
|
||||
# Should pass auth but may fail on actual execution (Gitea not available in tests)
|
||||
assert response.status_code != 401 # Not auth error
|
||||
|
||||
|
||||
def test_concurrent_requests_different_ips(client):
|
||||
"""Test that different IPs are tracked separately for rate limiting."""
|
||||
# This is a simplified test since we can't easily simulate different IPs in TestClient
|
||||
# But we can verify rate limiting works for single IP
|
||||
|
||||
headers_invalid = {"Authorization": "Bearer " + "x" * 64}
|
||||
tool_call_data = {"tool": "list_repositories", "arguments": {}}
|
||||
|
||||
# Make 5 failed attempts on protected endpoint
|
||||
for _ in range(5):
|
||||
response = client.post("/mcp/tool/call", headers=headers_invalid, json=tool_call_data)
|
||||
assert response.status_code == 401
|
||||
|
||||
# 6th attempt should be rate limited
|
||||
response = client.post("/mcp/tool/call", headers=headers_invalid, json=tool_call_data)
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Too many failed" in data["message"]
|
||||
|
||||
# Note: Rate limiting is IP-based, so even valid keys from the same IP are blocked
|
||||
# This is a security feature to prevent brute force attacks
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": "Bearer " + "a" * 64}, json=tool_call_data
|
||||
)
|
||||
# After rate limit is triggered, all requests from that IP are blocked
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_all_mcp_tools_discoverable(client):
|
||||
"""Test that all MCP tools are properly registered and discoverable."""
|
||||
response = client.get("/mcp/tools", headers={"Authorization": "Bearer " + "a" * 64})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
tools = data["tools"]
|
||||
|
||||
# Expected tools
|
||||
expected_tools = [
|
||||
"list_repositories",
|
||||
"get_repository_info",
|
||||
"get_file_tree",
|
||||
"get_file_contents",
|
||||
"search_code",
|
||||
"list_commits",
|
||||
"get_commit_diff",
|
||||
"compare_refs",
|
||||
"list_issues",
|
||||
"get_issue",
|
||||
"list_pull_requests",
|
||||
"get_pull_request",
|
||||
"list_labels",
|
||||
"list_tags",
|
||||
"list_releases",
|
||||
"create_issue",
|
||||
"update_issue",
|
||||
"create_issue_comment",
|
||||
"create_pr_comment",
|
||||
"add_labels",
|
||||
"assign_issue",
|
||||
]
|
||||
|
||||
tool_names = [tool["name"] for tool in tools]
|
||||
|
||||
for expected in expected_tools:
|
||||
assert expected in tool_names, f"Tool {expected} not found in registered tools"
|
||||
|
||||
# Verify each tool has required fields
|
||||
for tool in tools:
|
||||
assert "name" in tool
|
||||
assert "description" in tool
|
||||
assert "inputSchema" in tool
|
||||
assert tool["description"] # Not empty
|
||||
assert "type" in tool["inputSchema"]
|
||||
payload = response.json()
|
||||
assert payload["success"] is True
|
||||
assert "result" in payload
|
||||
|
||||
|
||||
def test_error_responses_include_helpful_messages(client):
|
||||
"""Test that error responses include helpful messages for users."""
|
||||
tool_data = {"tool": "list_repositories", "arguments": {}}
|
||||
|
||||
# Missing auth on protected endpoint
|
||||
response = client.post("/mcp/tool/call", json=tool_data)
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Authorization" in data["detail"] or "Authentication" in data["error"]
|
||||
|
||||
# Invalid key format
|
||||
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 short"}, json=tool_data
|
||||
"/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 (
|
||||
"Invalid" in data.get("message", "")
|
||||
or "format" in data.get("message", "").lower()
|
||||
or "Authentication" in data.get("error", "")
|
||||
)
|
||||
|
||||
|
||||
def test_audit_logging_integration(client, tmp_path, monkeypatch):
|
||||
"""Test that audit logging works with authentication."""
|
||||
# Set audit log to temp file
|
||||
audit_log = tmp_path / "audit.log"
|
||||
monkeypatch.setenv("AUDIT_LOG_PATH", str(audit_log))
|
||||
|
||||
# Make authenticated request
|
||||
response = client.get("/mcp/tools", headers={"Authorization": "Bearer " + "a" * 64})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Note: In real system, audit logs would be written
|
||||
# This test verifies the system doesn't crash with audit logging enabled
|
||||
assert "Provide Authorization" in data["message"]
|
||||
|
||||
379
tests/test_oauth.py
Normal file
379
tests/test_oauth.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Tests for OAuth2 per-user Gitea authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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.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("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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
response = client.post("/oauth/token", data={"code": "abc123"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_oauth_token_endpoint_missing_code(oauth_client):
|
||||
"""POST /oauth/token without a code returns 400."""
|
||||
response = oauth_client.post("/oauth/token", data={})
|
||||
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."""
|
||||
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={"code": "auth-code-123", "redirect_uri": "https://chat.openai.com/callback"},
|
||||
)
|
||||
|
||||
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."""
|
||||
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={"code": "bad-code"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
150
tests/test_oauth_oidc.py
Normal file
150
tests/test_oauth_oidc.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""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_CACHE_TTL_SECONDS", "600")
|
||||
yield
|
||||
reset_settings()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
def _build_jwt_fixture() -> 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": "test-client-id",
|
||||
"iss": "https://gitea.example.com",
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
headers={"kid": "kid-123"},
|
||||
)
|
||||
return token, {"keys": [jwk]}
|
||||
|
||||
|
||||
@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
|
||||
128
tests/test_repository_tools.py
Normal file
128
tests/test_repository_tools.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""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 GiteaError
|
||||
from aegis_gitea_mcp.tools.repository import (
|
||||
get_file_contents_tool,
|
||||
get_file_tree_tool,
|
||||
get_repository_info_tool,
|
||||
list_repositories_tool,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def repository_tool_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Provide minimal settings needed by response limit helpers."""
|
||||
reset_settings()
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "legacy-token")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
yield
|
||||
reset_settings()
|
||||
|
||||
|
||||
class RepoStub:
|
||||
"""Stub Gitea client for repository tools."""
|
||||
|
||||
async def list_repositories(self):
|
||||
return [{"name": "demo", "owner": {"login": "acme"}, "full_name": "acme/demo"}]
|
||||
|
||||
async def get_repository(self, owner, repo):
|
||||
return {"name": repo, "owner": {"login": owner}, "full_name": f"{owner}/{repo}"}
|
||||
|
||||
async def get_tree(self, owner, repo, ref, recursive):
|
||||
return {"tree": [{"path": "README.md", "type": "blob", "size": 11, "sha": "abc"}]}
|
||||
|
||||
async def get_file_contents(self, owner, repo, filepath, ref):
|
||||
return {
|
||||
"content": "SGVsbG8gV29ybGQ=",
|
||||
"encoding": "base64",
|
||||
"size": 11,
|
||||
"sha": "abc",
|
||||
"html_url": f"https://example/{owner}/{repo}/{filepath}",
|
||||
}
|
||||
|
||||
|
||||
class RepoErrorStub(RepoStub):
|
||||
"""Stub that raises backend errors."""
|
||||
|
||||
async def list_repositories(self):
|
||||
raise GiteaError("backend down")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repositories_tool_success() -> None:
|
||||
"""Repository listing tool normalizes output shape."""
|
||||
result = await list_repositories_tool(RepoStub(), {})
|
||||
assert result["count"] == 1
|
||||
assert result["repositories"][0]["owner"] == "acme"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_repositories_tool_failure_mode() -> None:
|
||||
"""Repository listing tool wraps backend errors."""
|
||||
with pytest.raises(RuntimeError, match="Failed to list repositories"):
|
||||
await list_repositories_tool(RepoErrorStub(), {})
|
||||
|
||||
|
||||
@pytest.mark.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,158 +1,144 @@
|
||||
"""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.auth import reset_validator
|
||||
from aegis_gitea_mcp.config import reset_settings
|
||||
from aegis_gitea_mcp.gitea_client import GiteaAuthenticationError, GiteaAuthorizationError
|
||||
from aegis_gitea_mcp.oauth import reset_oauth_validator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_state():
|
||||
def reset_state() -> None:
|
||||
"""Reset global state between tests."""
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
yield
|
||||
reset_settings()
|
||||
reset_validator()
|
||||
reset_oauth_validator()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env(monkeypatch):
|
||||
"""Set up test environment."""
|
||||
def oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set OAuth-first environment for server tests."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
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("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_auth_disabled(monkeypatch):
|
||||
"""Set up test environment with auth disabled."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "test")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "")
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "false")
|
||||
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(mock_env):
|
||||
"""Create test client."""
|
||||
# Import after setting env vars
|
||||
def client(oauth_env: None, mock_oauth_validation: None) -> TestClient:
|
||||
"""Create FastAPI test client."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_auth(mock_env_auth_disabled):
|
||||
"""Create test client with auth disabled."""
|
||||
from aegis_gitea_mcp.server import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_root_endpoint(client):
|
||||
"""Test root endpoint returns server info."""
|
||||
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 "version" in data
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
def test_health_endpoint(client):
|
||||
"""Test health check endpoint."""
|
||||
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:
|
||||
"""OAuth protected-resource metadata contains required OpenAI-compatible fields."""
|
||||
response = client.get("/.well-known/oauth-protected-resource")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "healthy"
|
||||
assert data["resource"] == "https://gitea.example.com"
|
||||
assert data["authorization_servers"] == ["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_metrics_endpoint(client):
|
||||
"""Metrics endpoint should be available for observability."""
|
||||
response = client.get("/metrics")
|
||||
def test_oauth_authorization_server_metadata(client: TestClient) -> None:
|
||||
"""Auth server metadata includes expected OAuth endpoints and scopes."""
|
||||
response = client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
assert "aegis_http_requests_total" in response.text
|
||||
payload = response.json()
|
||||
assert payload["authorization_endpoint"].endswith("/login/oauth/authorize")
|
||||
assert payload["token_endpoint"].endswith("/oauth/token")
|
||||
assert payload["scopes_supported"] == ["read:repository", "write:repository"]
|
||||
|
||||
|
||||
def test_health_endpoint_no_auth_required(client):
|
||||
"""Test that health check doesn't require authentication."""
|
||||
response = client.get("/health")
|
||||
|
||||
# Should work without Authorization header
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_tools_without_auth(client):
|
||||
"""Test that /mcp/tools is public (Mixed mode for ChatGPT)."""
|
||||
def test_list_tools_without_auth(client: TestClient) -> None:
|
||||
"""Tool listing remains discoverable without auth."""
|
||||
response = client.get("/mcp/tools")
|
||||
|
||||
# Tool listing is public to support ChatGPT discovery
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert "tools" in response.json()
|
||||
|
||||
|
||||
def test_list_tools_with_invalid_key(client):
|
||||
"""Test /mcp/tools works even with invalid key (public endpoint)."""
|
||||
response = client.get(
|
||||
"/mcp/tools",
|
||||
headers={"Authorization": "Bearer invalid-key-12345678901234567890123456789012"},
|
||||
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": {}},
|
||||
)
|
||||
|
||||
# Tool listing is public, so even invalid keys can list tools
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 401
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
|
||||
def test_list_tools_with_valid_key(client, mock_env):
|
||||
"""Test /mcp/tools with valid API key."""
|
||||
response = client.get("/mcp/tools", headers={"Authorization": f"Bearer {'a' * 64}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert len(data["tools"]) > 0
|
||||
|
||||
# Check tool structure
|
||||
tool = data["tools"][0]
|
||||
assert "name" in tool
|
||||
assert "description" in tool
|
||||
assert "inputSchema" in tool
|
||||
|
||||
|
||||
def test_list_tools_with_query_param(client):
|
||||
"""Test /mcp/tools with API key in query parameter."""
|
||||
response = client.get(f"/mcp/tools?api_key={'a' * 64}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
assert len(data["tools"]) > 0
|
||||
|
||||
|
||||
def test_list_tools_no_auth_when_disabled(client_no_auth):
|
||||
"""Test that /mcp/tools works without auth when disabled."""
|
||||
response = client_no_auth.get("/mcp/tools")
|
||||
|
||||
# Should work without Authorization header when auth is disabled
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "tools" in data
|
||||
|
||||
|
||||
def test_sse_tools_list_returns_camel_case_schema(client):
|
||||
def test_sse_tools_list_returns_camel_case_schema(client: TestClient) -> None:
|
||||
"""SSE tools/list returns MCP-compatible camelCase inputSchema."""
|
||||
response = client.post(
|
||||
f"/mcp/sse?api_key={'a' * 64}",
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"},
|
||||
)
|
||||
|
||||
@@ -160,48 +146,95 @@ def test_sse_tools_list_returns_camel_case_schema(client):
|
||||
data = response.json()
|
||||
assert "result" in data
|
||||
assert "tools" in data["result"]
|
||||
tool = data["result"]["tools"][0]
|
||||
assert "inputSchema" in tool
|
||||
assert "type" in tool["inputSchema"]
|
||||
assert "inputSchema" in data["result"]["tools"][0]
|
||||
|
||||
|
||||
def test_call_tool_without_auth(client):
|
||||
"""Test that /mcp/tool/call requires authentication."""
|
||||
response = client.post("/mcp/tool/call", json={"tool": "list_repositories", "arguments": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_call_tool_with_invalid_key(client):
|
||||
"""Test /mcp/tool/call with invalid API key."""
|
||||
def test_sse_initialize_message(client: TestClient) -> None:
|
||||
"""SSE initialize message returns protocol and server metadata."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer invalid-key-12345678901234567890123456789012"},
|
||||
json={"tool": "list_repositories", "arguments": {}},
|
||||
"/mcp/sse",
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"jsonrpc": "2.0", "id": "init-1", "method": "initialize"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["result"]["protocolVersion"] == "2024-11-05"
|
||||
assert payload["result"]["serverInfo"]["name"] == "AegisGitea MCP"
|
||||
|
||||
|
||||
def test_call_nonexistent_tool(client):
|
||||
"""Test calling a tool that doesn't exist."""
|
||||
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_call_nonexistent_tool(client: TestClient) -> None:
|
||||
"""Unknown tools return 404 after successful auth."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={"tool": "nonexistent_tool", "arguments": {}},
|
||||
)
|
||||
|
||||
# Tool not found returns 404 (auth passes but tool missing)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "not found" in data["detail"].lower()
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_write_tool_denied_by_default_policy(client):
|
||||
"""Write tools must be denied when write mode is disabled."""
|
||||
def test_write_scope_enforced_before_policy(client: TestClient) -> None:
|
||||
"""Write tools require write:repository scope."""
|
||||
response = client.post(
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": f"Bearer {'a' * 64}"},
|
||||
headers={"Authorization": "Bearer valid-read"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
@@ -209,96 +242,74 @@ def test_write_tool_denied_by_default_policy(client):
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
data = response.json()
|
||||
assert "policy denied" in data["detail"].lower()
|
||||
assert "required scope: write:repository" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_sse_endpoint_without_auth(client):
|
||||
"""Test that SSE endpoint requires authentication."""
|
||||
response = client.get("/mcp/sse")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_auth_header_formats(client):
|
||||
"""Test various Authorization header formats on protected endpoint."""
|
||||
# Test with /mcp/tool/call since /mcp/tools is now public
|
||||
tool_data = {"tool": "list_repositories", "arguments": {}}
|
||||
|
||||
# Missing "Bearer" prefix
|
||||
response = client.post("/mcp/tool/call", headers={"Authorization": "a" * 64}, json=tool_data)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Wrong case
|
||||
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 " + "a" * 64}, json=tool_data
|
||||
"/mcp/tool/call",
|
||||
headers={"Authorization": "Bearer valid-write"},
|
||||
json={
|
||||
"tool": "create_issue",
|
||||
"arguments": {"owner": "acme", "repo": "demo", "title": "test"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Extra spaces
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": f"Bearer {'a' * 64}"}, json=tool_data
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 403
|
||||
assert "write mode is disabled" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_rate_limiting(client):
|
||||
"""Test rate limiting after multiple failed auth attempts."""
|
||||
tool_data = {"tool": "list_repositories", "arguments": {}}
|
||||
@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("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
# Make 6 failed attempts on protected endpoint
|
||||
for _ in range(6):
|
||||
response = client.post(
|
||||
"/mcp/tool/call", headers={"Authorization": "Bearer " + "x" * 64}, json=tool_data
|
||||
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)
|
||||
|
||||
# Last response should mention rate limiting
|
||||
data = response.json()
|
||||
assert "Too many failed" in data["message"]
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="unable to reach Gitea OIDC discovery endpoint",
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_with_authentication_guidance(monkeypatch):
|
||||
"""Startup validation should fail with explicit auth guidance on 401."""
|
||||
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("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
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("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
async def raise_auth_error(*_args, **_kwargs):
|
||||
raise GiteaAuthenticationError("Authentication failed - check bot token")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
monkeypatch.setattr(server.GiteaClient, "get_current_user", raise_auth_error)
|
||||
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)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError, match=r"Startup validation failed: Gitea authentication was rejected"
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_event_fails_with_authorization_guidance(monkeypatch):
|
||||
"""Startup validation should fail with explicit permission guidance on 403."""
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
monkeypatch.setenv("GITEA_TOKEN", "test-gitea-token-12345")
|
||||
monkeypatch.setenv("ENVIRONMENT", "production")
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("MCP_API_KEYS", "a" * 64)
|
||||
monkeypatch.setenv("STARTUP_VALIDATE_GITEA", "true")
|
||||
|
||||
from aegis_gitea_mcp import server
|
||||
|
||||
async def raise_authorization_error(*_args, **_kwargs):
|
||||
raise GiteaAuthorizationError("Bot user lacks permission for this operation")
|
||||
|
||||
monkeypatch.setattr(server.GiteaClient, "get_current_user", raise_authorization_error)
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match=r"Startup validation failed: Gitea token lacks permission for /api/v1/user",
|
||||
):
|
||||
await server.startup_event()
|
||||
|
||||
Reference in New Issue
Block a user