Files
AegisGitea-MCP/tests/test_oauth.py
latte 59e1ea53a8
Some checks failed
docker / lint (push) Has been cancelled
docker / test (push) Has been cancelled
docker / docker-build (push) Has been cancelled
lint / lint (push) Has been cancelled
test / test (push) Has been cancelled
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).
2026-02-25 16:54:01 +01:00

380 lines
14 KiB
Python

"""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