""" Unit tests for hybrid authentication mode OAuth setup. Tests the setup_oauth_config_for_multi_user_basic() function that enables hybrid authentication where MCP operations use BasicAuth and management APIs use OAuth. """ from unittest.mock import AsyncMock, MagicMock import httpx import pytest from nextcloud_mcp_server.app import setup_oauth_config_for_multi_user_basic from nextcloud_mcp_server.config import Settings pytestmark = pytest.mark.unit @pytest.fixture def hybrid_auth_settings(): """Create settings for hybrid auth mode testing.""" return Settings( nextcloud_host="https://nextcloud.example.com", enable_offline_access=False, # Start with offline access disabled ) @pytest.fixture def oidc_discovery_response(): """Mock OIDC discovery endpoint response.""" return { "issuer": "https://nextcloud.example.com", "authorization_endpoint": "https://nextcloud.example.com/apps/oidc/authorize", "token_endpoint": "https://nextcloud.example.com/apps/oidc/token", "userinfo_endpoint": "https://nextcloud.example.com/apps/oidc/userinfo", "jwks_uri": "https://nextcloud.example.com/apps/oidc/jwks", "introspection_endpoint": "https://nextcloud.example.com/apps/oidc/introspect", "registration_endpoint": "https://nextcloud.example.com/apps/oidc/register", "scopes_supported": ["openid", "profile", "email", "offline_access"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], } class TestSetupOAuthConfigForMultiUserBasic: """Test setup_oauth_config_for_multi_user_basic() function.""" async def test_successful_setup_without_offline_access( self, hybrid_auth_settings, oidc_discovery_response, mocker ): """Test successful OAuth setup without offline access.""" # Mock httpx.AsyncClient mock_response = MagicMock() mock_response.json = MagicMock(return_value=oidc_discovery_response) mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock() mocker.patch("httpx.AsyncClient", return_value=mock_client) # Call function ( verifier, storage, client_id, client_secret, ) = await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) # Verify OIDC discovery was called mock_client.get.assert_called_once_with( "https://nextcloud.example.com/.well-known/openid-configuration" ) # Verify settings were updated assert hybrid_auth_settings.oidc_client_id == "test-client-id" assert hybrid_auth_settings.oidc_client_secret == "test-client-secret" assert hybrid_auth_settings.oidc_issuer == "https://nextcloud.example.com" assert ( hybrid_auth_settings.jwks_uri == "https://nextcloud.example.com/apps/oidc/jwks" ) assert ( hybrid_auth_settings.introspection_uri == "https://nextcloud.example.com/apps/oidc/introspect" ) assert ( hybrid_auth_settings.userinfo_uri == "https://nextcloud.example.com/apps/oidc/userinfo" ) # Verify token verifier was created assert verifier is not None from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier assert isinstance(verifier, UnifiedTokenVerifier) # Verify storage is None (offline access disabled) assert storage is None # Verify credentials returned assert client_id == "test-client-id" assert client_secret == "test-client-secret" async def test_successful_setup_with_offline_access( self, hybrid_auth_settings, oidc_discovery_response, mocker ): """Test successful OAuth setup with offline access enabled.""" # Enable offline access hybrid_auth_settings.enable_offline_access = True # Generate a valid Fernet key for testing from cryptography.fernet import Fernet valid_fernet_key = Fernet.generate_key().decode() # Mock TOKEN_ENCRYPTION_KEY environment variable mocker.patch( "os.getenv", side_effect=lambda k, default=None: { "TOKEN_ENCRYPTION_KEY": valid_fernet_key, "NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000", }.get(k, default), ) # Mock httpx.AsyncClient mock_response = MagicMock() mock_response.json = MagicMock(return_value=oidc_discovery_response) mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock() mocker.patch("httpx.AsyncClient", return_value=mock_client) # Call function ( verifier, storage, client_id, client_secret, ) = await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) # Verify storage was created assert storage is not None from nextcloud_mcp_server.auth.storage import RefreshTokenStorage assert isinstance(storage, RefreshTokenStorage) async def test_discovered_urls_used_directly( self, hybrid_auth_settings, oidc_discovery_response, mocker ): """Test that discovered URLs are used directly without rewriting.""" # Mock httpx.AsyncClient mock_response = MagicMock() mock_response.json = MagicMock(return_value=oidc_discovery_response) mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock() mocker.patch("httpx.AsyncClient", return_value=mock_client) # Call function ( verifier, storage, client_id, client_secret, ) = await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) # Verify discovered URLs are used directly (not rewritten) assert hybrid_auth_settings.jwks_uri == oidc_discovery_response["jwks_uri"] assert ( hybrid_auth_settings.introspection_uri == oidc_discovery_response["introspection_endpoint"] ) assert ( hybrid_auth_settings.userinfo_uri == oidc_discovery_response["userinfo_endpoint"] ) # Verify issuer is used directly for JWT validation assert hybrid_auth_settings.oidc_issuer == oidc_discovery_response["issuer"] async def test_oidc_discovery_failure_http_error( self, hybrid_auth_settings, mocker ): """Test handling of OIDC discovery HTTP errors.""" # Create a mock response with a status error mock_response = MagicMock() mock_response.status_code = 404 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=MagicMock(), response=MagicMock(status_code=404), ) mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__.return_value = mock_client # Return None to propagate exceptions (not suppress them) mock_client.__aexit__ = AsyncMock(return_value=None) mocker.patch("httpx.AsyncClient", return_value=mock_client) # Should raise ValueError with helpful message (not UnboundLocalError) with pytest.raises(ValueError, match="OIDC discovery failed"): await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) async def test_oidc_discovery_failure_connection_error( self, hybrid_auth_settings, mocker ): """Test handling of OIDC discovery connection errors.""" import httpx mock_client = AsyncMock() mock_client.get = AsyncMock( side_effect=httpx.ConnectError("Connection refused") ) mock_client.__aenter__.return_value = mock_client # Return None to propagate exceptions (not suppress them) mock_client.__aexit__ = AsyncMock(return_value=None) mocker.patch("httpx.AsyncClient", return_value=mock_client) # Should raise ValueError with helpful message with pytest.raises(ValueError, match="Cannot connect to"): await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) async def test_missing_nextcloud_host(self): """Test that missing NEXTCLOUD_HOST raises ValueError.""" settings = Settings() # No nextcloud_host set with pytest.raises(ValueError, match="NEXTCLOUD_HOST is required"): await setup_oauth_config_for_multi_user_basic( settings=settings, client_id="test-client-id", client_secret="test-client-secret", ) async def test_custom_discovery_url( self, hybrid_auth_settings, oidc_discovery_response, mocker ): """Test using custom OIDC discovery URL.""" # Mock OIDC_DISCOVERY_URL environment variable custom_discovery_url = ( "https://custom.idp.example.com/.well-known/openid-configuration" ) mocker.patch( "os.getenv", side_effect=lambda k, default=None: { "OIDC_DISCOVERY_URL": custom_discovery_url, "NEXTCLOUD_MCP_SERVER_URL": "http://localhost:8000", }.get(k, default), ) # Mock httpx.AsyncClient mock_response = MagicMock() mock_response.json = MagicMock(return_value=oidc_discovery_response) mock_response.raise_for_status = MagicMock() mock_client = AsyncMock() mock_client.get = AsyncMock(return_value=mock_response) mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = AsyncMock() mocker.patch("httpx.AsyncClient", return_value=mock_client) # Call function await setup_oauth_config_for_multi_user_basic( settings=hybrid_auth_settings, client_id="test-client-id", client_secret="test-client-secret", ) # Verify custom discovery URL was used mock_client.get.assert_called_once_with(custom_discovery_url)