# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for toolset authentication functionality.""" from typing import Optional from unittest.mock import AsyncMock from unittest.mock import MagicMock from unittest.mock import Mock from unittest.mock import patch from fastapi.openapi.models import OAuth2 from fastapi.openapi.models import OAuthFlowAuthorizationCode from fastapi.openapi.models import OAuthFlows from google.adk.agents.callback_context import CallbackContext from google.adk.agents.invocation_context import InvocationContext from google.adk.auth.auth_credential import AuthCredential from google.adk.auth.auth_credential import AuthCredentialTypes from google.adk.auth.auth_credential import OAuth2Auth from google.adk.auth.auth_preprocessor import TOOLSET_AUTH_CREDENTIAL_ID_PREFIX from google.adk.auth.auth_tool import AuthConfig from google.adk.auth.auth_tool import AuthToolArguments from google.adk.flows.llm_flows.base_llm_flow import _resolve_toolset_auth from google.adk.flows.llm_flows.base_llm_flow import TOOLSET_AUTH_CREDENTIAL_ID_PREFIX as FLOW_PREFIX from google.adk.flows.llm_flows.functions import build_auth_request_event from google.adk.flows.llm_flows.functions import REQUEST_EUC_FUNCTION_CALL_NAME from google.adk.tools.base_tool import BaseTool from google.adk.tools.base_toolset import BaseToolset import pytest class MockToolset(BaseToolset): """A mock toolset for testing.""" def __init__( self, auth_config: Optional[AuthConfig] = None, tools: Optional[list[BaseTool]] = None, ): super().__init__() self._auth_config = auth_config self._tools = tools or [] def get_auth_config(self) -> Optional[AuthConfig]: return self._auth_config async def get_tools(self, readonly_context=None) -> list[BaseTool]: return self._tools async def close(self): pass def create_oauth2_auth_config() -> AuthConfig: """Create a sample OAuth2 auth config for testing.""" return AuthConfig( auth_scheme=OAuth2( flows=OAuthFlows( authorizationCode=OAuthFlowAuthorizationCode( authorizationUrl="https://example.com/auth", tokenUrl="https://example.com/token", scopes={"read": "Read access"}, ) ) ), raw_auth_credential=AuthCredential( auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth( client_id="test_client_id", client_secret="test_client_secret", ), ), ) class TestToolsetAuthPrefixConstant: """Test that prefix constants are consistent.""" def test_prefix_constants_match(self): """Ensure auth_preprocessor and _reasoning use the same prefix.""" assert TOOLSET_AUTH_CREDENTIAL_ID_PREFIX == FLOW_PREFIX assert TOOLSET_AUTH_CREDENTIAL_ID_PREFIX == "_adk_toolset_auth_" class TestResolveToolsetAuth: """Tests for _resolve_toolset_auth.""" @pytest.fixture def mock_invocation_context(self): """Create a mock invocation context.""" ctx = Mock(spec=InvocationContext) ctx._state_schema = None ctx.invocation_id = "test-invocation-id" ctx.end_invocation = False ctx.branch = None ctx.session = Mock() ctx.session.state = {} ctx.session.id = "test-session-id" ctx.credential_service = None ctx.app_name = "test-app" ctx.user_id = "test-user" ctx.credential_by_key = {} return ctx @pytest.fixture def mock_agent(self): """Create a mock LLM agent.""" agent = Mock() agent.name = "test-agent" agent.tools = [] return agent @pytest.mark.asyncio async def test_no_tools_returns_no_events( self, mock_invocation_context, mock_agent ): """Test that no events are yielded when agent has no tools.""" mock_agent.tools = [] events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) assert len(events) == 0 assert mock_invocation_context.end_invocation is False @pytest.mark.asyncio async def test_toolset_without_auth_config_skipped( self, mock_invocation_context, mock_agent ): """Test that toolsets without auth config are skipped.""" toolset = MockToolset(auth_config=None) mock_agent.tools = [toolset] events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) assert len(events) == 0 assert mock_invocation_context.end_invocation is False @pytest.mark.asyncio async def test_toolset_with_credential_available_populates_context( self, mock_invocation_context, mock_agent ): """Test that credential is stored in invocation context when available.""" auth_config = create_oauth2_auth_config() toolset = MockToolset(auth_config=auth_config) mock_agent.tools = [toolset] # Mock CredentialManager to return a credential mock_credential = AuthCredential( auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth(access_token="test-token"), ) with patch( "google.adk.auth.credential_manager.CredentialManager" ) as MockCredentialManager: mock_manager = AsyncMock() mock_manager.get_auth_credential = AsyncMock(return_value=mock_credential) MockCredentialManager.return_value = mock_manager events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) # No auth request events - credential was available assert len(events) == 0 assert mock_invocation_context.end_invocation is False # Credential should be stored in invocation context, not auth_config assert ( mock_invocation_context.credential_by_key[auth_config.credential_key] == mock_credential ) assert auth_config.exchanged_auth_credential is None @pytest.mark.asyncio async def test_toolset_auth_uses_copy_and_does_not_mutate_shared_config( self, mock_invocation_context, mock_agent ): """Test that _resolve_toolset_auth uses a copy and does not mutate shared config.""" auth_config = create_oauth2_auth_config() toolset = MockToolset(auth_config=auth_config) mock_agent.tools = [toolset] def create_mock_cm(cfg): m = AsyncMock() m._auth_config = cfg async def get_cred(ctx): cfg.exchanged_auth_credential = AuthCredential( auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth(auth_uri="https://example.com/consent"), ) return None m.get_auth_credential = AsyncMock(side_effect=get_cred) return m with patch( "google.adk.auth.credential_manager.CredentialManager", side_effect=create_mock_cm, ): events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) # Should yield one auth request event assert len(events) == 1 assert mock_invocation_context.end_invocation is True # The shared auth_config should NOT be mutated assert auth_config.exchanged_auth_credential is None @pytest.mark.asyncio async def test_toolset_without_credential_yields_auth_event( self, mock_invocation_context, mock_agent ): """Test that auth request event is yielded when credential not available.""" auth_config = create_oauth2_auth_config() toolset = MockToolset(auth_config=auth_config) mock_agent.tools = [toolset] with patch( "google.adk.auth.credential_manager.CredentialManager" ) as MockCredentialManager: mock_manager = AsyncMock() mock_manager.get_auth_credential = AsyncMock(return_value=None) MockCredentialManager.return_value = mock_manager events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) # Should yield one auth request event assert len(events) == 1 assert mock_invocation_context.end_invocation is True # Check event structure event = events[0] assert event.invocation_id == "test-invocation-id" assert event.author == "test-agent" assert event.content is not None assert len(event.content.parts) == 1 # Check function call fc = event.content.parts[0].function_call assert fc.name == REQUEST_EUC_FUNCTION_CALL_NAME # The args use camelCase aliases from the pydantic model assert fc.args["functionCallId"].startswith( TOOLSET_AUTH_CREDENTIAL_ID_PREFIX ) assert "MockToolset" in fc.args["functionCallId"] @pytest.mark.asyncio async def test_multiple_toolsets_needing_auth( self, mock_invocation_context, mock_agent ): """Test that multiple toolsets needing auth yield multiple function calls.""" auth_config1 = create_oauth2_auth_config() auth_config2 = create_oauth2_auth_config() toolset1 = MockToolset(auth_config=auth_config1) toolset2 = MockToolset(auth_config=auth_config2) mock_agent.tools = [toolset1, toolset2] with patch( "google.adk.auth.credential_manager.CredentialManager" ) as MockCredentialManager: mock_manager = AsyncMock() mock_manager.get_auth_credential = AsyncMock(return_value=None) MockCredentialManager.return_value = mock_manager events = [] async for event in _resolve_toolset_auth( mock_invocation_context, mock_agent ): events.append(event) # Should yield one event with multiple function calls # But since both toolsets have same class name, they'll have same ID # and only one will be in pending_auth_requests (dict overwrites) assert len(events) == 1 assert mock_invocation_context.end_invocation is True class TestAuthPreprocessorToolsetAuthSkip: """Tests for auth preprocessor skipping toolset auth.""" def test_toolset_auth_prefix_skipped(self): """Test that function calls with toolset auth prefix are skipped.""" from google.adk.auth.auth_preprocessor import TOOLSET_AUTH_CREDENTIAL_ID_PREFIX # Verify the prefix is correct assert TOOLSET_AUTH_CREDENTIAL_ID_PREFIX == "_adk_toolset_auth_" # Test that a function_call_id starting with this prefix would be skipped toolset_function_call_id = f"{TOOLSET_AUTH_CREDENTIAL_ID_PREFIX}McpToolset" assert toolset_function_call_id.startswith( TOOLSET_AUTH_CREDENTIAL_ID_PREFIX ) # Regular tool auth function_call_id should NOT start with prefix regular_function_call_id = "call_123" assert not regular_function_call_id.startswith( TOOLSET_AUTH_CREDENTIAL_ID_PREFIX ) class TestCallbackContextGetAuthResponse: """Tests for CallbackContext.get_auth_response method.""" @pytest.fixture def mock_invocation_context(self): """Create a mock invocation context.""" ctx = Mock(spec=InvocationContext) ctx._state_schema = None ctx.session = Mock() ctx.session.state = {} return ctx def test_get_auth_response_returns_none_when_no_response( self, mock_invocation_context ): """Test that get_auth_response returns None when no auth response in state.""" callback_context = CallbackContext(mock_invocation_context) auth_config = create_oauth2_auth_config() result = callback_context.get_auth_response(auth_config) # Should return None when no auth response is stored assert result is None def test_get_auth_response_delegates_to_auth_handler( self, mock_invocation_context ): """Test that get_auth_response delegates to AuthHandler.""" callback_context = CallbackContext(mock_invocation_context) auth_config = create_oauth2_auth_config() # AuthHandler is imported inside the method, so we patch the module with patch("google.adk.auth.auth_handler.AuthHandler") as MockAuthHandler: mock_handler = Mock() mock_handler.get_auth_response = Mock(return_value=None) MockAuthHandler.return_value = mock_handler callback_context.get_auth_response(auth_config) MockAuthHandler.assert_called_once_with(auth_config) mock_handler.get_auth_response.assert_called_once() class TestBuildAuthRequestEvent: """Tests for build_auth_request_event helper function.""" @pytest.fixture def mock_invocation_context(self): """Create a mock invocation context.""" ctx = Mock(spec=InvocationContext) ctx._state_schema = None ctx.invocation_id = "test-invocation-id" ctx.branch = None ctx.agent = Mock() ctx.agent.name = "test-agent" return ctx def test_builds_event_with_auth_requests(self, mock_invocation_context): """Test that build_auth_request_event creates correct event.""" auth_requests = { "call_123": create_oauth2_auth_config(), } event = build_auth_request_event(mock_invocation_context, auth_requests) assert event.invocation_id == "test-invocation-id" assert event.author == "test-agent" assert event.content is not None assert len(event.content.parts) == 1 fc = event.content.parts[0].function_call assert fc.name == REQUEST_EUC_FUNCTION_CALL_NAME assert fc.args["functionCallId"] == "call_123" def test_multiple_auth_requests_create_multiple_parts( self, mock_invocation_context ): """Test that multiple auth requests create multiple function call parts.""" auth_requests = { "call_1": create_oauth2_auth_config(), "call_2": create_oauth2_auth_config(), } event = build_auth_request_event(mock_invocation_context, auth_requests) assert len(event.content.parts) == 2 function_call_ids = { p.function_call.args["functionCallId"] for p in event.content.parts } assert function_call_ids == {"call_1", "call_2"} def test_always_adds_long_running_tool_ids(self, mock_invocation_context): """Test that long_running_tool_ids is always set.""" auth_requests = {"call_123": create_oauth2_auth_config()} event = build_auth_request_event(mock_invocation_context, auth_requests) assert event.long_running_tool_ids is not None assert len(event.long_running_tool_ids) == 1 def test_custom_author_overrides_default(self, mock_invocation_context): """Test that custom author overrides default agent name.""" auth_requests = {"call_123": create_oauth2_auth_config()} event = build_auth_request_event( mock_invocation_context, auth_requests, author="custom-author" ) assert event.author == "custom-author" def test_role_is_set_in_content(self, mock_invocation_context): """Test that role is set in content.""" auth_requests = {"call_123": create_oauth2_auth_config()} event = build_auth_request_event( mock_invocation_context, auth_requests, role="model" ) assert event.content.role == "model"