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