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).
121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""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("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"]
|