ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
450 lines
15 KiB
Python
450 lines
15 KiB
Python
# 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"
|