-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathtest_toolset_auth.py
More file actions
473 lines (388 loc) · 15.9 KB
/
Copy pathtest_toolset_auth.py
File metadata and controls
473 lines (388 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
# 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."""
config1 = create_oauth2_auth_config()
config2 = create_oauth2_auth_config()
config2.credential_key = "different_key"
auth_requests = {
"call_1": config1,
"call_2": config2,
}
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_duplicate_auth_requests_are_deduplicated(
self, mock_invocation_context
):
"""Test that auth requests with the same credential key are deduplicated."""
config1 = create_oauth2_auth_config()
config2 = create_oauth2_auth_config()
# Ensure they have the same credential key
assert config1.credential_key == config2.credential_key
auth_requests = {
"call_1": config1,
"call_2": config2,
}
event = build_auth_request_event(mock_invocation_context, auth_requests)
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_1"
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"