chore: import upstream snapshot with attribution
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
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
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for AutoAuthCredentialExchanger."""
|
||||
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_schemes import AuthScheme
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.auto_auth_credential_exchanger import AutoAuthCredentialExchanger
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import BaseAuthCredentialExchanger
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.oauth2_exchanger import OAuth2CredentialExchanger
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.service_account_exchanger import ServiceAccountCredentialExchanger
|
||||
import pytest
|
||||
|
||||
|
||||
class MockCredentialExchanger(BaseAuthCredentialExchanger):
|
||||
"""Mock credential exchanger for testing."""
|
||||
|
||||
def exchange_credential(
|
||||
self,
|
||||
auth_scheme: AuthScheme,
|
||||
auth_credential: Optional[AuthCredential] = None,
|
||||
) -> AuthCredential:
|
||||
"""Mock exchange credential method."""
|
||||
return auth_credential
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auto_exchanger():
|
||||
"""Fixture for creating an AutoAuthCredentialExchanger instance."""
|
||||
return AutoAuthCredentialExchanger()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme():
|
||||
"""Fixture for creating a mock AuthScheme instance."""
|
||||
scheme = MagicMock(spec=AuthScheme)
|
||||
return scheme
|
||||
|
||||
|
||||
def test_init_with_custom_exchangers():
|
||||
"""Test initialization with custom exchangers."""
|
||||
custom_exchangers: Dict[str, Type[BaseAuthCredentialExchanger]] = {
|
||||
AuthCredentialTypes.API_KEY: MockCredentialExchanger
|
||||
}
|
||||
|
||||
auto_exchanger = AutoAuthCredentialExchanger(
|
||||
custom_exchangers=custom_exchangers
|
||||
)
|
||||
|
||||
assert (
|
||||
auto_exchanger.exchangers[AuthCredentialTypes.API_KEY]
|
||||
== MockCredentialExchanger
|
||||
)
|
||||
assert (
|
||||
auto_exchanger.exchangers[AuthCredentialTypes.OPEN_ID_CONNECT]
|
||||
== OAuth2CredentialExchanger
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_credential_no_auth_credential(auto_exchanger, auth_scheme):
|
||||
"""Test exchange_credential with no auth_credential."""
|
||||
|
||||
assert auto_exchanger.exchange_credential(auth_scheme, None) is None
|
||||
|
||||
|
||||
def test_exchange_credential_no_exchange(auto_exchanger, auth_scheme):
|
||||
"""Test exchange_credential with NoExchangeCredentialExchanger."""
|
||||
auth_credential = AuthCredential(auth_type=AuthCredentialTypes.API_KEY)
|
||||
|
||||
result = auto_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
|
||||
assert result == auth_credential
|
||||
|
||||
|
||||
def test_exchange_credential_open_id_connect(auto_exchanger, auth_scheme):
|
||||
"""Test exchange_credential with OpenID Connect scheme."""
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT
|
||||
)
|
||||
mock_exchanger = MagicMock(spec=OAuth2CredentialExchanger)
|
||||
mock_exchanger.exchange_credential.return_value = "exchanged_credential"
|
||||
auto_exchanger.exchangers[AuthCredentialTypes.OPEN_ID_CONNECT] = (
|
||||
lambda: mock_exchanger
|
||||
)
|
||||
|
||||
result = auto_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
|
||||
assert result == "exchanged_credential"
|
||||
mock_exchanger.exchange_credential.assert_called_once_with(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_credential_service_account(auto_exchanger, auth_scheme):
|
||||
"""Test exchange_credential with Service Account scheme."""
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT
|
||||
)
|
||||
mock_exchanger = MagicMock(spec=ServiceAccountCredentialExchanger)
|
||||
mock_exchanger.exchange_credential.return_value = "exchanged_credential_sa"
|
||||
auto_exchanger.exchangers[AuthCredentialTypes.SERVICE_ACCOUNT] = (
|
||||
lambda: mock_exchanger
|
||||
)
|
||||
|
||||
result = auto_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
|
||||
assert result == "exchanged_credential_sa"
|
||||
mock_exchanger.exchange_credential.assert_called_once_with(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_credential_custom_exchanger(auto_exchanger, auth_scheme):
|
||||
"""Test that exchange_credential calls the correct (custom) exchanger."""
|
||||
# Use a custom exchanger via the initialization
|
||||
mock_exchanger = MagicMock(spec=MockCredentialExchanger)
|
||||
mock_exchanger.exchange_credential.return_value = "custom_credential"
|
||||
auto_exchanger.exchangers[AuthCredentialTypes.API_KEY] = (
|
||||
lambda: mock_exchanger
|
||||
)
|
||||
auth_credential = AuthCredential(auth_type=AuthCredentialTypes.API_KEY)
|
||||
|
||||
result = auto_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
|
||||
assert result == "custom_credential"
|
||||
mock_exchanger.exchange_credential.assert_called_once_with(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# 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 the BaseAuthCredentialExchanger class."""
|
||||
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_schemes import AuthScheme
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import AuthCredentialMissingError
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import BaseAuthCredentialExchanger
|
||||
import pytest
|
||||
|
||||
|
||||
class MockAuthCredentialExchanger(BaseAuthCredentialExchanger):
|
||||
|
||||
def exchange_credential(
|
||||
self,
|
||||
auth_scheme: AuthScheme,
|
||||
auth_credential: Optional[AuthCredential] = None,
|
||||
) -> AuthCredential:
|
||||
return AuthCredential(token="some-token")
|
||||
|
||||
|
||||
class TestBaseAuthCredentialExchanger:
|
||||
"""Tests for the BaseAuthCredentialExchanger class."""
|
||||
|
||||
@pytest.fixture
|
||||
def base_exchanger(self):
|
||||
return BaseAuthCredentialExchanger()
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme(self):
|
||||
scheme = MagicMock(spec=AuthScheme)
|
||||
scheme.type = "apiKey"
|
||||
scheme.name = "x-api-key"
|
||||
return scheme
|
||||
|
||||
def test_exchange_credential_not_implemented(
|
||||
self, base_exchanger, auth_scheme
|
||||
):
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, token="some-token"
|
||||
)
|
||||
with pytest.raises(NotImplementedError) as exc_info:
|
||||
base_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "Subclasses must implement exchange_credential." in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
def test_auth_credential_missing_error(self):
|
||||
error_message = "Test missing credential"
|
||||
error = AuthCredentialMissingError(error_message)
|
||||
# assert error.message == error_message
|
||||
assert str(error) == error_message
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# 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 OAuth2CredentialExchanger."""
|
||||
|
||||
import copy
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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_schemes import AuthSchemeType
|
||||
from google.adk.auth.auth_schemes import OpenIdConnectWithConfig
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers import OAuth2CredentialExchanger
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import AuthCredentialMissingError
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth2_exchanger():
|
||||
return OAuth2CredentialExchanger()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme():
|
||||
openid_config = OpenIdConnectWithConfig(
|
||||
type_=AuthSchemeType.openIdConnect,
|
||||
authorization_endpoint="https://example.com/auth",
|
||||
token_endpoint="https://example.com/token",
|
||||
scopes=["openid", "profile"],
|
||||
)
|
||||
return openid_config
|
||||
|
||||
|
||||
def test_check_scheme_credential_type_success(oauth2_exchanger, auth_scheme):
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id="test_client",
|
||||
client_secret="test_secret",
|
||||
redirect_uri="http://localhost:8080",
|
||||
),
|
||||
)
|
||||
# Check that the method does not raise an exception
|
||||
oauth2_exchanger._check_scheme_credential_type(auth_scheme, auth_credential)
|
||||
|
||||
|
||||
def test_check_scheme_credential_type_missing_credential(
|
||||
oauth2_exchanger, auth_scheme
|
||||
):
|
||||
# Test case: auth_credential is None
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
oauth2_exchanger._check_scheme_credential_type(auth_scheme, None)
|
||||
assert "auth_credential is empty" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_check_scheme_credential_type_invalid_scheme_type(
|
||||
oauth2_exchanger, auth_scheme: OpenIdConnectWithConfig
|
||||
):
|
||||
"""Test case: Invalid AuthSchemeType."""
|
||||
# Test case: Invalid AuthSchemeType
|
||||
invalid_scheme = copy.deepcopy(auth_scheme)
|
||||
invalid_scheme.type_ = AuthSchemeType.apiKey
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id="test_client",
|
||||
client_secret="test_secret",
|
||||
redirect_uri="http://localhost:8080",
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
oauth2_exchanger._check_scheme_credential_type(
|
||||
invalid_scheme, auth_credential
|
||||
)
|
||||
assert "Invalid security scheme" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_check_scheme_credential_type_missing_openid_connect(
|
||||
oauth2_exchanger, auth_scheme
|
||||
):
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
)
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
oauth2_exchanger._check_scheme_credential_type(auth_scheme, auth_credential)
|
||||
assert "auth_credential is not configured with oauth2" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_generate_auth_token_success(
|
||||
oauth2_exchanger, auth_scheme, monkeypatch
|
||||
):
|
||||
"""Test case: Successful generation of access token."""
|
||||
# Test case: Successful generation of access token
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id="test_client",
|
||||
client_secret="test_secret",
|
||||
redirect_uri="http://localhost:8080",
|
||||
auth_response_uri="https://example.com/callback?code=test_code",
|
||||
access_token="test_access_token",
|
||||
),
|
||||
)
|
||||
updated_credential = oauth2_exchanger.generate_auth_token(auth_credential)
|
||||
|
||||
assert updated_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert updated_credential.http.scheme == "bearer"
|
||||
assert updated_credential.http.credentials.token == "test_access_token"
|
||||
|
||||
|
||||
def test_exchange_credential_generate_auth_token(
|
||||
oauth2_exchanger, auth_scheme, monkeypatch
|
||||
):
|
||||
"""Test exchange_credential when auth_response_uri is present."""
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id="test_client",
|
||||
client_secret="test_secret",
|
||||
redirect_uri="http://localhost:8080",
|
||||
auth_response_uri="https://example.com/callback?code=test_code",
|
||||
access_token="test_access_token",
|
||||
),
|
||||
)
|
||||
|
||||
updated_credential = oauth2_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert updated_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert updated_credential.http.scheme == "bearer"
|
||||
assert updated_credential.http.credentials.token == "test_access_token"
|
||||
|
||||
|
||||
def test_exchange_credential_auth_missing(oauth2_exchanger, auth_scheme):
|
||||
"""Test exchange_credential when auth_credential is missing."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
oauth2_exchanger.exchange_credential(auth_scheme, None)
|
||||
assert "auth_credential is empty. Please create AuthCredential using" in str(
|
||||
exc_info.value
|
||||
)
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for the service account credential exchanger."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_credential import ServiceAccount
|
||||
from google.adk.auth.auth_credential import ServiceAccountCredential
|
||||
from google.adk.auth.auth_schemes import AuthScheme
|
||||
from google.adk.auth.auth_schemes import AuthSchemeType
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import AuthCredentialMissingError
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.service_account_exchanger import ServiceAccountCredentialExchanger
|
||||
import google.auth
|
||||
from google.auth import exceptions as google_auth_exceptions
|
||||
import pytest
|
||||
|
||||
_ACCESS_TOKEN_MONKEYPATCH_TARGET = (
|
||||
"google.adk.tools.openapi_tool.auth.credential_exchangers."
|
||||
"service_account_exchanger.service_account.Credentials."
|
||||
"from_service_account_info"
|
||||
)
|
||||
|
||||
_ID_TOKEN_MONKEYPATCH_TARGET = (
|
||||
"google.adk.tools.openapi_tool.auth.credential_exchangers."
|
||||
"service_account_exchanger.service_account.IDTokenCredentials."
|
||||
"from_service_account_info"
|
||||
)
|
||||
|
||||
_FETCH_ID_TOKEN_MONKEYPATCH_TARGET = "google.oauth2.id_token.fetch_id_token"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_account_exchanger():
|
||||
return ServiceAccountCredentialExchanger()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_scheme():
|
||||
scheme = MagicMock(spec=AuthScheme)
|
||||
scheme.type_ = AuthSchemeType.oauth2
|
||||
scheme.description = "Google Service Account"
|
||||
return scheme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sa_credential():
|
||||
"""A minimal valid ServiceAccountCredential for testing."""
|
||||
return ServiceAccountCredential(
|
||||
type_="service_account",
|
||||
project_id="test_project_id",
|
||||
private_key_id="test_private_key_id",
|
||||
private_key="-----BEGIN PRIVATE KEY-----...",
|
||||
client_email="test@test.iam.gserviceaccount.com",
|
||||
client_id="test_client_id",
|
||||
auth_uri="https://accounts.google.com/o/oauth2/auth",
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
auth_provider_x509_cert_url="https://www.googleapis.com/oauth2/v1/certs",
|
||||
client_x509_cert_url=(
|
||||
"https://www.googleapis.com/robot/v1/metadata/x509/test"
|
||||
),
|
||||
universe_domain="googleapis.com",
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
||||
|
||||
|
||||
# --- Access token exchange tests ---
|
||||
|
||||
|
||||
def test_exchange_access_token_with_explicit_credentials(
|
||||
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
|
||||
):
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "mock_access_token"
|
||||
mock_from_sa_info = MagicMock(return_value=mock_credentials)
|
||||
monkeypatch.setattr(_ACCESS_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
service_account_credential=sa_credential,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
),
|
||||
)
|
||||
|
||||
result = service_account_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert result.auth_type == AuthCredentialTypes.HTTP
|
||||
assert result.http.scheme == "bearer"
|
||||
assert result.http.credentials.token == "mock_access_token"
|
||||
mock_from_sa_info.assert_called_once()
|
||||
mock_credentials.refresh.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cred_quota_project_id, adc_project_id, expected_quota_project_id",
|
||||
[
|
||||
("test_project", "another_project", "test_project"),
|
||||
(None, "adc_project", "adc_project"),
|
||||
(None, None, None),
|
||||
],
|
||||
)
|
||||
def test_exchange_access_token_with_adc_sets_quota_project(
|
||||
service_account_exchanger,
|
||||
auth_scheme,
|
||||
monkeypatch,
|
||||
cred_quota_project_id,
|
||||
adc_project_id,
|
||||
expected_quota_project_id,
|
||||
):
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "mock_access_token"
|
||||
mock_credentials.quota_project_id = cred_quota_project_id
|
||||
mock_google_auth_default = MagicMock(
|
||||
return_value=(mock_credentials, adc_project_id)
|
||||
)
|
||||
monkeypatch.setattr(google.auth, "default", mock_google_auth_default)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
use_default_credential=True,
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"],
|
||||
),
|
||||
)
|
||||
|
||||
result = service_account_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert result.auth_type == AuthCredentialTypes.HTTP
|
||||
assert result.http.scheme == "bearer"
|
||||
assert result.http.credentials.token == "mock_access_token"
|
||||
if expected_quota_project_id:
|
||||
assert (
|
||||
result.http.additional_headers["x-goog-user-project"]
|
||||
== expected_quota_project_id
|
||||
)
|
||||
else:
|
||||
assert not result.http.additional_headers
|
||||
mock_google_auth_default.assert_called_once_with(
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"]
|
||||
)
|
||||
mock_credentials.refresh.assert_called_once()
|
||||
|
||||
|
||||
def test_exchange_access_token_with_adc_defaults_to_cloud_platform_scope(
|
||||
service_account_exchanger, auth_scheme, monkeypatch
|
||||
):
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "mock_access_token"
|
||||
mock_credentials.quota_project_id = None
|
||||
mock_google_auth_default = MagicMock(return_value=(mock_credentials, None))
|
||||
monkeypatch.setattr(google.auth, "default", mock_google_auth_default)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
use_default_credential=True,
|
||||
),
|
||||
)
|
||||
|
||||
result = service_account_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert result.auth_type == AuthCredentialTypes.HTTP
|
||||
assert result.http.scheme == "bearer"
|
||||
assert result.http.credentials.token == "mock_access_token"
|
||||
mock_google_auth_default.assert_called_once_with(scopes=_DEFAULT_SCOPES)
|
||||
|
||||
|
||||
def test_exchange_raises_when_auth_credential_is_none(
|
||||
service_account_exchanger, auth_scheme
|
||||
):
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, None)
|
||||
assert "Service account credentials are missing" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_exchange_raises_when_service_account_is_none(
|
||||
service_account_exchanger, auth_scheme
|
||||
):
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
)
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "Service account credentials are missing" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_exchange_wraps_google_auth_error_as_missing_error(
|
||||
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
|
||||
):
|
||||
mock_from_sa_info = MagicMock(
|
||||
side_effect=ValueError("Failed to load credentials")
|
||||
)
|
||||
monkeypatch.setattr(_ACCESS_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
service_account_credential=sa_credential,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "Failed to exchange service account token" in str(exc_info.value)
|
||||
mock_from_sa_info.assert_called_once()
|
||||
|
||||
|
||||
def test_exchange_raises_when_explicit_credentials_have_no_scopes(
|
||||
service_account_exchanger, auth_scheme, sa_credential
|
||||
):
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
service_account_credential=sa_credential,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "scopes are required" in str(exc_info.value)
|
||||
|
||||
|
||||
# --- ID token exchange tests ---
|
||||
|
||||
|
||||
def test_exchange_id_token_with_explicit_credentials(
|
||||
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
|
||||
):
|
||||
mock_id_credentials = MagicMock()
|
||||
mock_id_credentials.token = "mock_id_token"
|
||||
mock_from_sa_info = MagicMock(return_value=mock_id_credentials)
|
||||
monkeypatch.setattr(_ID_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
service_account_credential=sa_credential,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
use_id_token=True,
|
||||
audience="https://my-service.run.app",
|
||||
),
|
||||
)
|
||||
|
||||
result = service_account_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert result.auth_type == AuthCredentialTypes.HTTP
|
||||
assert result.http.scheme == "bearer"
|
||||
assert result.http.credentials.token == "mock_id_token"
|
||||
assert result.http.additional_headers is None
|
||||
mock_from_sa_info.assert_called_once()
|
||||
assert (
|
||||
mock_from_sa_info.call_args[1]["target_audience"]
|
||||
== "https://my-service.run.app"
|
||||
)
|
||||
mock_id_credentials.refresh.assert_called_once()
|
||||
|
||||
|
||||
def test_exchange_id_token_with_adc(
|
||||
service_account_exchanger, auth_scheme, monkeypatch
|
||||
):
|
||||
mock_fetch_id_token = MagicMock(return_value="mock_adc_id_token")
|
||||
monkeypatch.setattr(_FETCH_ID_TOKEN_MONKEYPATCH_TARGET, mock_fetch_id_token)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
use_default_credential=True,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
use_id_token=True,
|
||||
audience="https://my-service.run.app",
|
||||
),
|
||||
)
|
||||
|
||||
result = service_account_exchanger.exchange_credential(
|
||||
auth_scheme, auth_credential
|
||||
)
|
||||
|
||||
assert result.auth_type == AuthCredentialTypes.HTTP
|
||||
assert result.http.scheme == "bearer"
|
||||
assert result.http.credentials.token == "mock_adc_id_token"
|
||||
assert result.http.additional_headers is None
|
||||
mock_fetch_id_token.assert_called_once()
|
||||
assert mock_fetch_id_token.call_args[0][1] == "https://my-service.run.app"
|
||||
|
||||
|
||||
def test_id_token_requires_audience():
|
||||
with pytest.raises(
|
||||
ValueError, match="audience is required when use_id_token is True"
|
||||
):
|
||||
ServiceAccount(
|
||||
use_default_credential=True,
|
||||
use_id_token=True,
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_id_token_wraps_error_with_explicit_credentials(
|
||||
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
|
||||
):
|
||||
mock_from_sa_info = MagicMock(
|
||||
side_effect=ValueError("Failed to create ID token credentials")
|
||||
)
|
||||
monkeypatch.setattr(_ID_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
service_account_credential=sa_credential,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
use_id_token=True,
|
||||
audience="https://my-service.run.app",
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "Failed to exchange service account for ID token" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
def test_exchange_id_token_wraps_error_with_adc(
|
||||
service_account_exchanger, auth_scheme, monkeypatch
|
||||
):
|
||||
mock_fetch_id_token = MagicMock(
|
||||
side_effect=google_auth_exceptions.DefaultCredentialsError(
|
||||
"Metadata service unavailable"
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(_FETCH_ID_TOKEN_MONKEYPATCH_TARGET, mock_fetch_id_token)
|
||||
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
|
||||
service_account=ServiceAccount(
|
||||
use_default_credential=True,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
use_id_token=True,
|
||||
audience="https://my-service.run.app",
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(AuthCredentialMissingError) as exc_info:
|
||||
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
|
||||
assert "Failed to exchange service account for ID token" in str(
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
|
||||
# --- Model validator tests ---
|
||||
|
||||
|
||||
def test_model_validator_rejects_missing_credential_without_adc():
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="service_account_credential is required",
|
||||
):
|
||||
ServiceAccount(
|
||||
use_default_credential=False,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
)
|
||||
|
||||
|
||||
def test_model_validator_allows_adc_without_explicit_credential():
|
||||
sa = ServiceAccount(
|
||||
use_default_credential=True,
|
||||
scopes=_DEFAULT_SCOPES,
|
||||
)
|
||||
assert sa.service_account_credential is None
|
||||
assert sa.use_default_credential is True
|
||||
@@ -0,0 +1,573 @@
|
||||
# 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.
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.openapi.models import APIKey
|
||||
from fastapi.openapi.models import APIKeyIn
|
||||
from fastapi.openapi.models import HTTPBase
|
||||
from fastapi.openapi.models import HTTPBearer
|
||||
from fastapi.openapi.models import OAuth2
|
||||
from fastapi.openapi.models import OpenIdConnect
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_credential import HttpAuth
|
||||
from google.adk.auth.auth_credential import HttpCredentials
|
||||
from google.adk.auth.auth_credential import ServiceAccount
|
||||
from google.adk.auth.auth_credential import ServiceAccountCredential
|
||||
from google.adk.auth.auth_schemes import AuthSchemeType
|
||||
from google.adk.auth.auth_schemes import OpenIdConnectWithConfig
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import credential_to_param
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import dict_to_auth_scheme
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import INTERNAL_AUTH_PREFIX
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import openid_dict_to_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import openid_url_to_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import service_account_dict_to_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import service_account_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_credential
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_api_key_header():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"apikey", "header", "X-API-Key", "test_key"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, APIKey)
|
||||
assert scheme.type_ == AuthSchemeType.apiKey
|
||||
assert scheme.in_ == APIKeyIn.header
|
||||
assert scheme.name == "X-API-Key"
|
||||
assert credential == AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_api_key_query():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"apikey", "query", "api_key", "test_key"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, APIKey)
|
||||
assert scheme.type_ == AuthSchemeType.apiKey
|
||||
assert scheme.in_ == APIKeyIn.query
|
||||
assert scheme.name == "api_key"
|
||||
assert credential == AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_api_key_cookie():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"apikey", "cookie", "session_id", "test_key"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, APIKey)
|
||||
assert scheme.type_ == AuthSchemeType.apiKey
|
||||
assert scheme.in_ == APIKeyIn.cookie
|
||||
assert scheme.name == "session_id"
|
||||
assert credential == AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_api_key_no_credential():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"apikey", "cookie", "session_id"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, APIKey)
|
||||
assert credential is None
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_oauth2_token():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"oauth2Token", "header", "Authorization", "test_token"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, HTTPBearer)
|
||||
assert scheme.bearerFormat == "JWT"
|
||||
assert credential == AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP,
|
||||
http=HttpAuth(
|
||||
scheme="bearer", credentials=HttpCredentials(token="test_token")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_token_to_scheme_credential_oauth2_no_credential():
|
||||
scheme, credential = token_to_scheme_credential(
|
||||
"oauth2Token", "header", "Authorization"
|
||||
)
|
||||
|
||||
assert isinstance(scheme, HTTPBearer)
|
||||
assert credential is None
|
||||
|
||||
|
||||
def test_service_account_dict_to_scheme_credential():
|
||||
config = {
|
||||
"type": "service_account",
|
||||
"project_id": "project_id",
|
||||
"private_key_id": "private_key_id",
|
||||
"private_key": "private_key",
|
||||
"client_email": "client_email",
|
||||
"client_id": "client_id",
|
||||
"auth_uri": "auth_uri",
|
||||
"token_uri": "token_uri",
|
||||
"auth_provider_x509_cert_url": "auth_provider_x509_cert_url",
|
||||
"client_x509_cert_url": "client_x509_cert_url",
|
||||
"universe_domain": "universe_domain",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = service_account_dict_to_scheme_credential(config, scopes)
|
||||
|
||||
assert isinstance(scheme, HTTPBearer)
|
||||
assert scheme.bearerFormat == "JWT"
|
||||
assert credential.auth_type == AuthCredentialTypes.SERVICE_ACCOUNT
|
||||
assert credential.service_account.scopes == scopes
|
||||
assert (
|
||||
credential.service_account.service_account_credential.project_id
|
||||
== "project_id"
|
||||
)
|
||||
|
||||
|
||||
def test_service_account_scheme_credential():
|
||||
config = ServiceAccount(
|
||||
service_account_credential=ServiceAccountCredential(
|
||||
type="service_account",
|
||||
project_id="project_id",
|
||||
private_key_id="private_key_id",
|
||||
private_key="private_key",
|
||||
client_email="client_email",
|
||||
client_id="client_id",
|
||||
auth_uri="auth_uri",
|
||||
token_uri="token_uri",
|
||||
auth_provider_x509_cert_url="auth_provider_x509_cert_url",
|
||||
client_x509_cert_url="client_x509_cert_url",
|
||||
universe_domain="universe_domain",
|
||||
),
|
||||
scopes=["scope1", "scope2"],
|
||||
)
|
||||
|
||||
scheme, credential = service_account_scheme_credential(config)
|
||||
|
||||
assert isinstance(scheme, HTTPBearer)
|
||||
assert scheme.bearerFormat == "JWT"
|
||||
assert credential.auth_type == AuthCredentialTypes.SERVICE_ACCOUNT
|
||||
assert credential.service_account == config
|
||||
|
||||
|
||||
def test_openid_dict_to_scheme_credential():
|
||||
config_dict = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
"openIdConnectUrl": "openid_url",
|
||||
}
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"redirect_uri": "redirect_uri",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = openid_dict_to_scheme_credential(
|
||||
config_dict, scopes, credential_dict
|
||||
)
|
||||
|
||||
assert isinstance(scheme, OpenIdConnectWithConfig)
|
||||
assert scheme.authorization_endpoint == "auth_url"
|
||||
assert scheme.token_endpoint == "token_url"
|
||||
assert scheme.scopes == scopes
|
||||
assert credential.auth_type == AuthCredentialTypes.OPEN_ID_CONNECT
|
||||
assert credential.oauth2.client_id == "client_id"
|
||||
assert credential.oauth2.client_secret == "client_secret"
|
||||
assert credential.oauth2.redirect_uri == "redirect_uri"
|
||||
|
||||
|
||||
def test_openid_dict_to_scheme_credential_no_openid_url():
|
||||
config_dict = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
}
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"redirect_uri": "redirect_uri",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = openid_dict_to_scheme_credential(
|
||||
config_dict, scopes, credential_dict
|
||||
)
|
||||
|
||||
assert scheme.openIdConnectUrl == ""
|
||||
|
||||
|
||||
def test_openid_dict_to_scheme_credential_google_oauth_credential():
|
||||
config_dict = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
"openIdConnectUrl": "openid_url",
|
||||
}
|
||||
credential_dict = {
|
||||
"web": {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"redirect_uri": "redirect_uri",
|
||||
}
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = openid_dict_to_scheme_credential(
|
||||
config_dict, scopes, credential_dict
|
||||
)
|
||||
|
||||
assert isinstance(scheme, OpenIdConnectWithConfig)
|
||||
assert credential.auth_type == AuthCredentialTypes.OPEN_ID_CONNECT
|
||||
assert credential.oauth2.client_id == "client_id"
|
||||
assert credential.oauth2.client_secret == "client_secret"
|
||||
assert credential.oauth2.redirect_uri == "redirect_uri"
|
||||
|
||||
|
||||
def test_openid_dict_to_scheme_credential_invalid_config():
|
||||
config_dict = {
|
||||
"invalid_field": "value",
|
||||
}
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid OpenID Connect configuration"):
|
||||
openid_dict_to_scheme_credential(config_dict, scopes, credential_dict)
|
||||
|
||||
|
||||
def test_openid_dict_to_scheme_credential_missing_credential_fields():
|
||||
config_dict = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
}
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Missing required fields in credential_dict: client_secret",
|
||||
):
|
||||
openid_dict_to_scheme_credential(config_dict, scopes, credential_dict)
|
||||
|
||||
|
||||
@patch("httpx.get")
|
||||
def test_openid_url_to_scheme_credential(mock_get):
|
||||
mock_response = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
"userinfo_endpoint": "userinfo_url",
|
||||
}
|
||||
mock_get.return_value.json.return_value = mock_response
|
||||
mock_get.return_value.raise_for_status.return_value = None
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"redirect_uri": "redirect_uri",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = openid_url_to_scheme_credential(
|
||||
"openid_url", scopes, credential_dict
|
||||
)
|
||||
|
||||
assert isinstance(scheme, OpenIdConnectWithConfig)
|
||||
assert scheme.authorization_endpoint == "auth_url"
|
||||
assert scheme.token_endpoint == "token_url"
|
||||
assert scheme.scopes == scopes
|
||||
assert credential.auth_type == AuthCredentialTypes.OPEN_ID_CONNECT
|
||||
assert credential.oauth2.client_id == "client_id"
|
||||
assert credential.oauth2.client_secret == "client_secret"
|
||||
assert credential.oauth2.redirect_uri == "redirect_uri"
|
||||
mock_get.assert_called_once_with("openid_url", timeout=10)
|
||||
|
||||
|
||||
@patch("httpx.get")
|
||||
def test_openid_url_to_scheme_credential_no_openid_url(mock_get):
|
||||
mock_response = {
|
||||
"authorization_endpoint": "auth_url",
|
||||
"token_endpoint": "token_url",
|
||||
"userinfo_endpoint": "userinfo_url",
|
||||
}
|
||||
mock_get.return_value.json.return_value = mock_response
|
||||
mock_get.return_value.raise_for_status.return_value = None
|
||||
credential_dict = {
|
||||
"client_id": "client_id",
|
||||
"client_secret": "client_secret",
|
||||
"redirect_uri": "redirect_uri",
|
||||
}
|
||||
scopes = ["scope1", "scope2"]
|
||||
|
||||
scheme, credential = openid_url_to_scheme_credential(
|
||||
"openid_url", scopes, credential_dict
|
||||
)
|
||||
|
||||
assert scheme.openIdConnectUrl == "openid_url"
|
||||
|
||||
|
||||
@patch("httpx.get")
|
||||
def test_openid_url_to_scheme_credential_request_exception(mock_get):
|
||||
mock_get.side_effect = httpx.RequestError("Test Error", request=None)
|
||||
credential_dict = {"client_id": "client_id", "client_secret": "client_secret"}
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Failed to fetch OpenID configuration from openid_url"
|
||||
):
|
||||
openid_url_to_scheme_credential("openid_url", [], credential_dict)
|
||||
|
||||
|
||||
@patch("httpx.get")
|
||||
def test_openid_url_to_scheme_credential_invalid_json(mock_get):
|
||||
mock_get.return_value.json.side_effect = ValueError("Invalid JSON")
|
||||
mock_get.return_value.raise_for_status.return_value = None
|
||||
credential_dict = {"client_id": "client_id", "client_secret": "client_secret"}
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=(
|
||||
"Invalid JSON response from OpenID configuration endpoint openid_url"
|
||||
),
|
||||
):
|
||||
openid_url_to_scheme_credential("openid_url", [], credential_dict)
|
||||
|
||||
|
||||
def test_credential_to_param_api_key_header():
|
||||
auth_scheme = APIKey(
|
||||
**{"type": "apiKey", "in": "header", "name": "X-API-Key"}
|
||||
)
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "X-API-Key"
|
||||
assert param.param_location == "header"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "X-API-Key": "test_key"}
|
||||
|
||||
|
||||
def test_credential_to_param_api_key_query():
|
||||
auth_scheme = APIKey(**{"type": "apiKey", "in": "query", "name": "api_key"})
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "api_key"
|
||||
assert param.param_location == "query"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "api_key": "test_key"}
|
||||
|
||||
|
||||
def test_credential_to_param_api_key_cookie():
|
||||
auth_scheme = APIKey(
|
||||
**{"type": "apiKey", "in": "cookie", "name": "session_id"}
|
||||
)
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.API_KEY, api_key="test_key"
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "session_id"
|
||||
assert param.param_location == "cookie"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "session_id": "test_key"}
|
||||
|
||||
|
||||
def test_credential_to_param_http_bearer():
|
||||
auth_scheme = HTTPBearer(bearerFormat="JWT")
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP,
|
||||
http=HttpAuth(
|
||||
scheme="bearer", credentials=HttpCredentials(token="test_token")
|
||||
),
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "Authorization"
|
||||
assert param.param_location == "header"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "Authorization": "Bearer test_token"}
|
||||
|
||||
|
||||
def test_credential_to_param_http_basic_not_supported():
|
||||
auth_scheme = HTTPBase(scheme="basic")
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP,
|
||||
http=HttpAuth(
|
||||
scheme="basic",
|
||||
credentials=HttpCredentials(username="user", password="password"),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
NotImplementedError, match="Basic Authentication is not supported."
|
||||
):
|
||||
credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
|
||||
def test_credential_to_param_http_invalid_credentials_no_http():
|
||||
auth_scheme = HTTPBase(scheme="basic")
|
||||
auth_credential = AuthCredential(auth_type=AuthCredentialTypes.HTTP)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid HTTP auth credentials"):
|
||||
credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
|
||||
def test_credential_to_param_oauth2():
|
||||
auth_scheme = OAuth2(flows={})
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP,
|
||||
http=HttpAuth(
|
||||
scheme="bearer", credentials=HttpCredentials(token="test_token")
|
||||
),
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "Authorization"
|
||||
assert param.param_location == "header"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "Authorization": "Bearer test_token"}
|
||||
|
||||
|
||||
def test_credential_to_param_openid_connect():
|
||||
auth_scheme = OpenIdConnect(openIdConnectUrl="openid_url")
|
||||
auth_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP,
|
||||
http=HttpAuth(
|
||||
scheme="bearer", credentials=HttpCredentials(token="test_token")
|
||||
),
|
||||
)
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, auth_credential)
|
||||
|
||||
assert param.original_name == "Authorization"
|
||||
assert param.param_location == "header"
|
||||
assert kwargs == {INTERNAL_AUTH_PREFIX + "Authorization": "Bearer test_token"}
|
||||
|
||||
|
||||
def test_credential_to_param_openid_no_credential():
|
||||
auth_scheme = OpenIdConnect(openIdConnectUrl="openid_url")
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, None)
|
||||
|
||||
assert param == None
|
||||
assert kwargs == None
|
||||
|
||||
|
||||
def test_credential_to_param_oauth2_no_credential():
|
||||
auth_scheme = OAuth2(flows={})
|
||||
|
||||
param, kwargs = credential_to_param(auth_scheme, None)
|
||||
|
||||
assert param == None
|
||||
assert kwargs == None
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_api_key():
|
||||
data = {"type": "apiKey", "in": "header", "name": "X-API-Key"}
|
||||
|
||||
scheme = dict_to_auth_scheme(data)
|
||||
|
||||
assert isinstance(scheme, APIKey)
|
||||
assert scheme.type_ == AuthSchemeType.apiKey
|
||||
assert scheme.in_ == APIKeyIn.header
|
||||
assert scheme.name == "X-API-Key"
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_http_bearer():
|
||||
data = {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}
|
||||
|
||||
scheme = dict_to_auth_scheme(data)
|
||||
|
||||
assert isinstance(scheme, HTTPBearer)
|
||||
assert scheme.scheme == "bearer"
|
||||
assert scheme.bearerFormat == "JWT"
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_http_base():
|
||||
data = {"type": "http", "scheme": "basic"}
|
||||
|
||||
scheme = dict_to_auth_scheme(data)
|
||||
|
||||
assert isinstance(scheme, HTTPBase)
|
||||
assert scheme.scheme == "basic"
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_oauth2():
|
||||
data = {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"authorizationCode": {
|
||||
"authorizationUrl": "https://example.com/auth",
|
||||
"tokenUrl": "https://example.com/token",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
scheme = dict_to_auth_scheme(data)
|
||||
|
||||
assert isinstance(scheme, OAuth2)
|
||||
assert hasattr(scheme.flows, "authorizationCode")
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_openid_connect():
|
||||
data = {
|
||||
"type": "openIdConnect",
|
||||
"openIdConnectUrl": (
|
||||
"https://example.com/.well-known/openid-configuration"
|
||||
),
|
||||
}
|
||||
|
||||
scheme = dict_to_auth_scheme(data)
|
||||
|
||||
assert isinstance(scheme, OpenIdConnect)
|
||||
assert (
|
||||
scheme.openIdConnectUrl
|
||||
== "https://example.com/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_missing_type():
|
||||
data = {"in": "header", "name": "X-API-Key"}
|
||||
with pytest.raises(
|
||||
ValueError, match="Missing 'type' field in security scheme dictionary."
|
||||
):
|
||||
dict_to_auth_scheme(data)
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_invalid_type():
|
||||
data = {"type": "invalid", "in": "header", "name": "X-API-Key"}
|
||||
with pytest.raises(ValueError, match="Invalid security scheme type: invalid"):
|
||||
dict_to_auth_scheme(data)
|
||||
|
||||
|
||||
def test_dict_to_auth_scheme_invalid_data():
|
||||
data = {"type": "apiKey", "in": "header"} # Missing 'name'
|
||||
with pytest.raises(ValueError, match="Invalid security scheme data"):
|
||||
dict_to_auth_scheme(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__])
|
||||
@@ -0,0 +1,412 @@
|
||||
# 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.
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
from fastapi.openapi.models import Response
|
||||
from fastapi.openapi.models import Schema
|
||||
from google.adk.tools.openapi_tool.common.common import ApiParameter
|
||||
from google.adk.tools.openapi_tool.common.common import PydocHelper
|
||||
from google.adk.tools.openapi_tool.common.common import rename_python_keywords
|
||||
from google.adk.tools.openapi_tool.common.common import TypeHintHelper
|
||||
import pytest
|
||||
|
||||
|
||||
def dict_to_responses(input: Dict[str, Any]) -> Dict[str, Response]:
|
||||
return {k: Response.model_validate(input[k]) for k in input}
|
||||
|
||||
|
||||
class TestRenamePythonKeywords:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'input_str, expected_output',
|
||||
[
|
||||
('in', 'param_in'),
|
||||
('for', 'param_for'),
|
||||
('class', 'param_class'),
|
||||
('normal', 'normal'),
|
||||
('param_if', 'param_if'),
|
||||
('', ''),
|
||||
],
|
||||
)
|
||||
def test_rename_python_keywords(self, input_str, expected_output):
|
||||
assert rename_python_keywords(input_str) == expected_output
|
||||
|
||||
|
||||
class TestApiParameter:
|
||||
|
||||
def test_api_parameter_initialization(self):
|
||||
schema = Schema(type='string', description='A string parameter')
|
||||
param = ApiParameter(
|
||||
original_name='testParam',
|
||||
description='A string description',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.original_name == 'testParam'
|
||||
assert param.param_location == 'query'
|
||||
assert param.param_schema.type == 'string'
|
||||
assert param.param_schema.description == 'A string parameter'
|
||||
assert param.py_name == 'test_param'
|
||||
assert param.type_hint == 'str'
|
||||
assert param.type_value == str
|
||||
assert param.description == 'A string description'
|
||||
|
||||
def test_api_parameter_keyword_rename(self):
|
||||
schema = Schema(type='string')
|
||||
param = ApiParameter(
|
||||
original_name='in',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.py_name == 'param_in'
|
||||
|
||||
def test_api_parameter_uses_location_default_when_name_missing(self):
|
||||
schema = Schema(type='string')
|
||||
param = ApiParameter(
|
||||
original_name='',
|
||||
param_location='body',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.py_name == 'body'
|
||||
|
||||
def test_api_parameter_uses_value_default_when_location_unknown(self):
|
||||
schema = Schema(type='integer')
|
||||
param = ApiParameter(
|
||||
original_name='',
|
||||
param_location='',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.py_name == 'value'
|
||||
|
||||
def test_api_parameter_custom_py_name(self):
|
||||
schema = Schema(type='integer')
|
||||
param = ApiParameter(
|
||||
original_name='testParam',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
py_name='custom_name',
|
||||
)
|
||||
assert param.py_name == 'custom_name'
|
||||
|
||||
def test_api_parameter_str_representation(self):
|
||||
schema = Schema(type='number')
|
||||
param = ApiParameter(
|
||||
original_name='testParam',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert str(param) == 'test_param: float'
|
||||
|
||||
def test_api_parameter_to_arg_string(self):
|
||||
schema = Schema(type='boolean')
|
||||
param = ApiParameter(
|
||||
original_name='testParam',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.to_arg_string() == 'test_param=test_param'
|
||||
|
||||
def test_api_parameter_to_dict_property(self):
|
||||
schema = Schema(type='string')
|
||||
param = ApiParameter(
|
||||
original_name='testParam',
|
||||
param_location='path',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.to_dict_property() == '"test_param": test_param'
|
||||
|
||||
def test_api_parameter_model_serializer(self):
|
||||
schema = Schema(type='string', description='test description')
|
||||
param = ApiParameter(
|
||||
original_name='TestParam',
|
||||
param_location='path',
|
||||
param_schema=schema,
|
||||
py_name='test_param_custom',
|
||||
description='test description',
|
||||
)
|
||||
|
||||
serialized_param = param.model_dump(mode='json', exclude_none=True)
|
||||
|
||||
assert serialized_param == {
|
||||
'original_name': 'TestParam',
|
||||
'param_location': 'path',
|
||||
'param_schema': {'type': 'string', 'description': 'test description'},
|
||||
'description': 'test description',
|
||||
'py_name': 'test_param_custom',
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'schema, expected_type_value, expected_type_hint',
|
||||
[
|
||||
({'type': 'integer'}, int, 'int'),
|
||||
({'type': 'number'}, float, 'float'),
|
||||
({'type': 'boolean'}, bool, 'bool'),
|
||||
({'type': 'string'}, str, 'str'),
|
||||
(
|
||||
{'type': 'string', 'format': 'date'},
|
||||
str,
|
||||
'str',
|
||||
),
|
||||
(
|
||||
{'type': 'string', 'format': 'date-time'},
|
||||
str,
|
||||
'str',
|
||||
),
|
||||
(
|
||||
{'type': 'array', 'items': {'type': 'integer'}},
|
||||
List[int],
|
||||
'List[int]',
|
||||
),
|
||||
(
|
||||
{'type': 'array', 'items': {'type': 'string'}},
|
||||
List[str],
|
||||
'List[str]',
|
||||
),
|
||||
(
|
||||
{
|
||||
'type': 'array',
|
||||
'items': {'type': 'object'},
|
||||
},
|
||||
List[Dict[str, Any]],
|
||||
'List[Dict[str, Any]]',
|
||||
),
|
||||
({'type': 'object'}, Dict[str, Any], 'Dict[str, Any]'),
|
||||
({}, Any, 'Any'),
|
||||
],
|
||||
)
|
||||
def test_api_parameter_type_hint_helper(
|
||||
self, schema, expected_type_value, expected_type_hint
|
||||
):
|
||||
param = ApiParameter(
|
||||
original_name='test', param_location='query', param_schema=schema
|
||||
)
|
||||
assert param.type_value == expected_type_value
|
||||
assert param.type_hint == expected_type_hint
|
||||
assert (
|
||||
TypeHintHelper.get_type_hint(param.param_schema) == expected_type_hint
|
||||
)
|
||||
assert (
|
||||
TypeHintHelper.get_type_value(param.param_schema) == expected_type_value
|
||||
)
|
||||
|
||||
def test_api_parameter_description(self):
|
||||
schema = Schema(type='string')
|
||||
param = ApiParameter(
|
||||
original_name='param1',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
description='The description',
|
||||
)
|
||||
assert param.description == 'The description'
|
||||
|
||||
def test_api_parameter_description_use_schema_fallback(self):
|
||||
schema = Schema(type='string', description='The description')
|
||||
param = ApiParameter(
|
||||
original_name='param1',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
assert param.description == 'The description'
|
||||
|
||||
|
||||
class TestTypeHintHelper:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'schema, expected_type_value, expected_type_hint',
|
||||
[
|
||||
({'type': 'integer'}, int, 'int'),
|
||||
({'type': 'number'}, float, 'float'),
|
||||
({'type': 'string'}, str, 'str'),
|
||||
(
|
||||
{
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
},
|
||||
List[str],
|
||||
'List[str]',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_type_value_and_hint(
|
||||
self, schema, expected_type_value, expected_type_hint
|
||||
):
|
||||
|
||||
param = ApiParameter(
|
||||
original_name='test_param',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
description='Test parameter',
|
||||
)
|
||||
assert (
|
||||
TypeHintHelper.get_type_value(param.param_schema) == expected_type_value
|
||||
)
|
||||
assert (
|
||||
TypeHintHelper.get_type_hint(param.param_schema) == expected_type_hint
|
||||
)
|
||||
|
||||
|
||||
class TestPydocHelper:
|
||||
|
||||
def test_generate_param_doc_simple(self):
|
||||
schema = Schema(type='string')
|
||||
param = ApiParameter(
|
||||
original_name='test_param',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
description='Test description',
|
||||
)
|
||||
|
||||
expected_doc = 'test_param (str): Test description'
|
||||
assert PydocHelper.generate_param_doc(param) == expected_doc
|
||||
|
||||
def test_generate_param_doc_no_description(self):
|
||||
schema = Schema(type='integer')
|
||||
param = ApiParameter(
|
||||
original_name='test_param',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
)
|
||||
expected_doc = 'test_param (int): '
|
||||
assert PydocHelper.generate_param_doc(param) == expected_doc
|
||||
|
||||
def test_generate_param_doc_object(self):
|
||||
schema = Schema(
|
||||
type='object',
|
||||
properties={
|
||||
'prop1': {'type': 'string', 'description': 'Prop1 desc'},
|
||||
'prop2': {'type': 'integer'},
|
||||
},
|
||||
)
|
||||
param = ApiParameter(
|
||||
original_name='test_param',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
description='Test object parameter',
|
||||
)
|
||||
expected_doc = (
|
||||
'test_param (Dict[str, Any]): Test object parameter Object'
|
||||
' properties:\n prop1 (str): Prop1 desc\n prop2'
|
||||
' (int): \n'
|
||||
)
|
||||
assert PydocHelper.generate_param_doc(param) == expected_doc
|
||||
|
||||
def test_generate_param_doc_object_no_properties(self):
|
||||
schema = Schema(type='object', description='A test schema')
|
||||
param = ApiParameter(
|
||||
original_name='test_param',
|
||||
param_location='query',
|
||||
param_schema=schema,
|
||||
description='The description.',
|
||||
)
|
||||
expected_doc = 'test_param (Dict[str, Any]): The description.'
|
||||
assert PydocHelper.generate_param_doc(param) == expected_doc
|
||||
|
||||
def test_generate_return_doc_simple(self):
|
||||
responses = {
|
||||
'200': {
|
||||
'description': 'Successful response',
|
||||
'content': {'application/json': {'schema': {'type': 'string'}}},
|
||||
}
|
||||
}
|
||||
expected_doc = 'Returns (str): Successful response'
|
||||
assert (
|
||||
PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
== expected_doc
|
||||
)
|
||||
|
||||
def test_generate_return_doc_no_content(self):
|
||||
responses = {'204': {'description': 'No content'}}
|
||||
assert not PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
|
||||
def test_generate_return_doc_object(self):
|
||||
responses = {
|
||||
'200': {
|
||||
'description': 'Successful object response',
|
||||
'content': {
|
||||
'application/json': {
|
||||
'schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'prop1': {
|
||||
'type': 'string',
|
||||
'description': 'Prop1 desc',
|
||||
},
|
||||
'prop2': {'type': 'integer'},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return_doc = PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
|
||||
assert 'Returns (Dict[str, Any]): Successful object response' in return_doc
|
||||
assert 'prop1 (str): Prop1 desc' in return_doc
|
||||
assert 'prop2 (int):' in return_doc
|
||||
|
||||
def test_generate_return_doc_multiple_success(self):
|
||||
responses = {
|
||||
'200': {
|
||||
'description': 'Successful response',
|
||||
'content': {'application/json': {'schema': {'type': 'string'}}},
|
||||
},
|
||||
'400': {'description': 'Bad request'},
|
||||
}
|
||||
expected_doc = 'Returns (str): Successful response'
|
||||
assert (
|
||||
PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
== expected_doc
|
||||
)
|
||||
|
||||
def test_generate_return_doc_2xx_smallest_status_code_response(self):
|
||||
responses = {
|
||||
'201': {
|
||||
'description': '201 response',
|
||||
'content': {'application/json': {'schema': {'type': 'integer'}}},
|
||||
},
|
||||
'200': {
|
||||
'description': '200 response',
|
||||
'content': {'application/json': {'schema': {'type': 'string'}}},
|
||||
},
|
||||
'400': {'description': 'Bad request'},
|
||||
}
|
||||
|
||||
expected_doc = 'Returns (str): 200 response'
|
||||
assert (
|
||||
PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
== expected_doc
|
||||
)
|
||||
|
||||
def test_generate_return_doc_contentful_response(self):
|
||||
responses = {
|
||||
'200': {'description': 'No content response'},
|
||||
'201': {
|
||||
'description': '201 response',
|
||||
'content': {'application/json': {'schema': {'type': 'string'}}},
|
||||
},
|
||||
'400': {'description': 'Bad request'},
|
||||
}
|
||||
expected_doc = 'Returns (str): 201 response'
|
||||
assert (
|
||||
PydocHelper.generate_return_doc(dict_to_responses(responses))
|
||||
== expected_doc
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__])
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,868 @@
|
||||
# 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.
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_spec_parser import OpenApiSpecParser
|
||||
import pytest
|
||||
|
||||
|
||||
def create_minimal_openapi_spec() -> Dict[str, Any]:
|
||||
"""Creates a minimal valid OpenAPI spec."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Minimal API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"summary": "Test GET endpoint",
|
||||
"operationId": "testGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openapi_spec_generator():
|
||||
"""Fixture for creating an OperationGenerator instance."""
|
||||
return OpenApiSpecParser()
|
||||
|
||||
|
||||
def test_parse_minimal_spec(openapi_spec_generator):
|
||||
"""Test parsing a minimal OpenAPI specification."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
op = parsed_operations[0]
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert op.name == "test_get"
|
||||
assert op.endpoint.path == "/test"
|
||||
assert op.endpoint.method == "get"
|
||||
assert op.return_value.type_value == str
|
||||
|
||||
|
||||
def test_parse_spec_with_no_operation_id(openapi_spec_generator):
|
||||
"""Test parsing a spec where operationId is missing (auto-generation)."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
del openapi_spec["paths"]["/test"]["get"]["operationId"] # Remove operationId
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
# Check if operationId is auto generated based on path and method.
|
||||
assert parsed_operations[0].name == "test_get"
|
||||
|
||||
|
||||
def test_parse_spec_with_multiple_methods(openapi_spec_generator):
|
||||
"""Test parsing a spec with multiple HTTP methods for the same path."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["paths"]["/test"]["post"] = {
|
||||
"summary": "Test POST endpoint",
|
||||
"operationId": "testPost",
|
||||
"responses": {"200": {"description": "Successful response"}},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
operation_names = {op.name for op in parsed_operations}
|
||||
|
||||
assert len(parsed_operations) == 2
|
||||
assert "test_get" in operation_names
|
||||
assert "test_post" in operation_names
|
||||
|
||||
|
||||
def test_parse_spec_with_parameters(openapi_spec_generator):
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["paths"]["/test"]["get"]["parameters"] = [
|
||||
{"name": "param1", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "param2", "in": "header", "schema": {"type": "integer"}},
|
||||
]
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations[0].parameters) == 2
|
||||
assert parsed_operations[0].parameters[0].original_name == "param1"
|
||||
assert parsed_operations[0].parameters[0].param_location == "query"
|
||||
assert parsed_operations[0].parameters[1].original_name == "param2"
|
||||
assert parsed_operations[0].parameters[1].param_location == "header"
|
||||
|
||||
|
||||
def test_parse_spec_with_request_body(openapi_spec_generator):
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["paths"]["/test"]["post"] = {
|
||||
"summary": "Endpoint with request body",
|
||||
"operationId": "testPostWithBody",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
post_operations = [
|
||||
op for op in parsed_operations if op.endpoint.method == "post"
|
||||
]
|
||||
op = post_operations[0]
|
||||
|
||||
assert len(post_operations) == 1
|
||||
assert op.name == "test_post_with_body"
|
||||
assert len(op.parameters) == 1
|
||||
assert op.parameters[0].original_name == "name"
|
||||
assert op.parameters[0].type_value == str
|
||||
|
||||
|
||||
def test_parse_spec_with_reference(openapi_spec_generator):
|
||||
"""Test parsing a specification with $ref."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "API with Refs", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test_ref": {
|
||||
"get": {
|
||||
"summary": "Endpoint with ref",
|
||||
"operationId": "testGetRef",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MySchema"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"MySchema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
op = parsed_operations[0]
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert op.return_value.type_value.__origin__ is dict
|
||||
|
||||
|
||||
def test_parse_spec_with_circular_reference(openapi_spec_generator):
|
||||
"""Test correct handling of circular $ref (important!)."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Circular Ref API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/circular": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/A"}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"A": {
|
||||
"type": "object",
|
||||
"properties": {"b": {"$ref": "#/components/schemas/B"}},
|
||||
},
|
||||
"B": {
|
||||
"type": "object",
|
||||
"properties": {"a": {"$ref": "#/components/schemas/A"}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
assert len(parsed_operations) == 1
|
||||
|
||||
op = parsed_operations[0]
|
||||
assert op.return_value.type_value.__origin__ is dict
|
||||
assert op.return_value.type_hint == "Dict[str, Any]"
|
||||
|
||||
|
||||
def test_parse_no_paths(openapi_spec_generator):
|
||||
"""Test with a spec that has no paths defined."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "No Paths API", "version": "1.0.0"},
|
||||
}
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
assert len(parsed_operations) == 0 # Should be empty
|
||||
|
||||
|
||||
def test_parse_empty_path_item(openapi_spec_generator):
|
||||
"""Test a path item that is present but empty."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Empty Path Item API", "version": "1.0.0"},
|
||||
"paths": {"/empty": None},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 0
|
||||
|
||||
|
||||
def test_parse_spec_with_global_auth_scheme(openapi_spec_generator):
|
||||
"""Test parsing with a global security scheme."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["security"] = [{"api_key": []}]
|
||||
openapi_spec["components"] = {
|
||||
"securitySchemes": {
|
||||
"api_key": {"type": "apiKey", "in": "header", "name": "X-API-Key"}
|
||||
}
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
op = parsed_operations[0]
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert op.auth_scheme is not None
|
||||
assert op.auth_scheme.type_.value == "apiKey"
|
||||
|
||||
|
||||
def test_parse_spec_with_local_auth_scheme(openapi_spec_generator):
|
||||
"""Test parsing with a local (operation-level) security scheme."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["paths"]["/test"]["get"]["security"] = [{"local_auth": []}]
|
||||
openapi_spec["components"] = {
|
||||
"securitySchemes": {"local_auth": {"type": "http", "scheme": "bearer"}}
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
op = parsed_operations[0]
|
||||
|
||||
assert op.auth_scheme is not None
|
||||
assert op.auth_scheme.type_.value == "http"
|
||||
assert op.auth_scheme.scheme == "bearer"
|
||||
|
||||
|
||||
def test_parse_spec_with_servers(openapi_spec_generator):
|
||||
"""Test parsing with server URLs."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["servers"] = [
|
||||
{"url": "https://api.example.com"},
|
||||
{"url": "http://localhost:8000"},
|
||||
]
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert parsed_operations[0].endpoint.base_url == "https://api.example.com"
|
||||
|
||||
|
||||
def test_parse_spec_with_no_servers(openapi_spec_generator):
|
||||
"""Test with no servers defined (should default to empty string)."""
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
if "servers" in openapi_spec:
|
||||
del openapi_spec["servers"]
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert parsed_operations[0].endpoint.base_url == ""
|
||||
|
||||
|
||||
def test_parse_spec_with_description(openapi_spec_generator):
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
expected_description = "This is a test description."
|
||||
openapi_spec["paths"]["/test"]["get"]["description"] = expected_description
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert parsed_operations[0].description == expected_description
|
||||
|
||||
|
||||
def test_parse_spec_with_empty_description(openapi_spec_generator):
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
openapi_spec["paths"]["/test"]["get"]["description"] = ""
|
||||
openapi_spec["paths"]["/test"]["get"]["summary"] = ""
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert parsed_operations[0].description == ""
|
||||
|
||||
|
||||
def test_parse_spec_with_no_description(openapi_spec_generator):
|
||||
openapi_spec = create_minimal_openapi_spec()
|
||||
|
||||
# delete description
|
||||
if "description" in openapi_spec["paths"]["/test"]["get"]:
|
||||
del openapi_spec["paths"]["/test"]["get"]["description"]
|
||||
if "summary" in openapi_spec["paths"]["/test"]["get"]:
|
||||
del openapi_spec["paths"]["/test"]["get"]["summary"]
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert (
|
||||
parsed_operations[0].description == ""
|
||||
) # it should be initialized with empty string
|
||||
|
||||
|
||||
def test_parse_invalid_openapi_spec_type(openapi_spec_generator):
|
||||
"""Test that passing a non-dict object to parse raises TypeError"""
|
||||
with pytest.raises(AttributeError):
|
||||
openapi_spec_generator.parse(123) # type: ignore
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
openapi_spec_generator.parse("openapi_spec") # type: ignore
|
||||
|
||||
with pytest.raises(AttributeError):
|
||||
openapi_spec_generator.parse([]) # type: ignore
|
||||
|
||||
|
||||
def test_parse_external_ref_raises_error(openapi_spec_generator):
|
||||
"""Check that external references (not starting with #) raise ValueError."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "External Ref API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/external": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": (
|
||||
"external_file.json#/components/schemas/ExternalSchema"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
|
||||
def test_parse_spec_with_multiple_paths_deep_refs(openapi_spec_generator):
|
||||
"""Test specs with multiple paths, request/response bodies using deep refs."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Multiple Paths Deep Refs API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/path1": {
|
||||
"post": {
|
||||
"operationId": "postPath1",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Request1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Response1"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/path2": {
|
||||
"put": {
|
||||
"operationId": "putPath2",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Request2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Response2"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"get": {
|
||||
"operationId": "getPath2",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Response2"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Request1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"req1_prop1": {"$ref": "#/components/schemas/Level1_1"}
|
||||
},
|
||||
},
|
||||
"Response1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res1_prop1": {"$ref": "#/components/schemas/Level1_2"}
|
||||
},
|
||||
},
|
||||
"Request2": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"req2_prop1": {"$ref": "#/components/schemas/Level1_1"}
|
||||
},
|
||||
},
|
||||
"Response2": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"res2_prop1": {"$ref": "#/components/schemas/Level1_2"}
|
||||
},
|
||||
},
|
||||
"Level1_1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level1_1_prop1": {
|
||||
"$ref": "#/components/schemas/Level2_1"
|
||||
}
|
||||
},
|
||||
},
|
||||
"Level1_2": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level1_2_prop1": {
|
||||
"$ref": "#/components/schemas/Level2_2"
|
||||
}
|
||||
},
|
||||
},
|
||||
"Level2_1": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level2_1_prop1": {"$ref": "#/components/schemas/Level3"}
|
||||
},
|
||||
},
|
||||
"Level2_2": {
|
||||
"type": "object",
|
||||
"properties": {"level2_2_prop1": {"type": "string"}},
|
||||
},
|
||||
"Level3": {"type": "integer"},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
assert len(parsed_operations) == 3
|
||||
|
||||
# Verify Path 1
|
||||
path1_ops = [op for op in parsed_operations if op.endpoint.path == "/path1"]
|
||||
assert len(path1_ops) == 1
|
||||
path1_op = path1_ops[0]
|
||||
assert path1_op.name == "post_path1"
|
||||
|
||||
assert len(path1_op.parameters) == 1
|
||||
assert path1_op.parameters[0].original_name == "req1_prop1"
|
||||
assert (
|
||||
path1_op.parameters[0]
|
||||
.param_schema.properties["level1_1_prop1"]
|
||||
.properties["level2_1_prop1"]
|
||||
.type
|
||||
== "integer"
|
||||
)
|
||||
assert (
|
||||
path1_op.return_value.param_schema.properties["res1_prop1"]
|
||||
.properties["level1_2_prop1"]
|
||||
.properties["level2_2_prop1"]
|
||||
.type
|
||||
== "string"
|
||||
)
|
||||
|
||||
# Verify Path 2
|
||||
path2_ops = [
|
||||
op
|
||||
for op in parsed_operations
|
||||
if op.endpoint.path == "/path2" and op.name == "put_path2"
|
||||
]
|
||||
path2_op = path2_ops[0]
|
||||
assert path2_op is not None
|
||||
assert len(path2_op.parameters) == 1
|
||||
assert path2_op.parameters[0].original_name == "req2_prop1"
|
||||
assert (
|
||||
path2_op.parameters[0]
|
||||
.param_schema.properties["level1_1_prop1"]
|
||||
.properties["level2_1_prop1"]
|
||||
.type
|
||||
== "integer"
|
||||
)
|
||||
assert (
|
||||
path2_op.return_value.param_schema.properties["res2_prop1"]
|
||||
.properties["level1_2_prop1"]
|
||||
.properties["level2_2_prop1"]
|
||||
.type
|
||||
== "string"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_spec_with_duplicate_parameter_names(openapi_spec_generator):
|
||||
"""Test handling of duplicate parameter names (one in query, one in body).
|
||||
|
||||
The expected behavior is that both parameters should be captured but with
|
||||
different suffix, and
|
||||
their `original_name` attributes should reflect their origin (query or body).
|
||||
"""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Duplicate Parameter Names API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/duplicate": {
|
||||
"post": {
|
||||
"operationId": "createWithDuplicate",
|
||||
"parameters": [{
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"schema": {"type": "string"},
|
||||
}],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "integer"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
assert len(parsed_operations) == 1
|
||||
op = parsed_operations[0]
|
||||
assert op.name == "create_with_duplicate"
|
||||
assert len(op.parameters) == 2
|
||||
|
||||
query_param = None
|
||||
body_param = None
|
||||
for param in op.parameters:
|
||||
if param.param_location == "query" and param.original_name == "name":
|
||||
query_param = param
|
||||
elif param.param_location == "body" and param.original_name == "name":
|
||||
body_param = param
|
||||
|
||||
assert query_param is not None
|
||||
assert query_param.original_name == "name"
|
||||
assert query_param.py_name == "name"
|
||||
|
||||
assert body_param is not None
|
||||
assert body_param.original_name == "name"
|
||||
assert body_param.py_name == "name_0"
|
||||
|
||||
|
||||
def test_parse_spec_with_path_level_parameters(openapi_spec_generator):
|
||||
"""Test that operation parameters are correctly combined with path-level parameters."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Combine Parameters API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"parameters": [{
|
||||
"name": "global_param",
|
||||
"in": "query",
|
||||
"schema": {"type": "string"},
|
||||
}],
|
||||
"get": {
|
||||
"parameters": [{
|
||||
"name": "local_param",
|
||||
"in": "header",
|
||||
"schema": {"type": "integer"},
|
||||
}],
|
||||
"operationId": "testGet",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
assert len(parsed_operations) == 1
|
||||
|
||||
operation = parsed_operations[0]
|
||||
assert len(operation.parameters) == 2
|
||||
|
||||
# Verify the combined parameters
|
||||
global_param = next(
|
||||
(p for p in operation.parameters if p.original_name == "global_param"),
|
||||
None,
|
||||
)
|
||||
local_param = next(
|
||||
(p for p in operation.parameters if p.original_name == "local_param"),
|
||||
None,
|
||||
)
|
||||
|
||||
assert global_param is not None
|
||||
assert global_param.param_location == "query"
|
||||
assert global_param.type_value is str
|
||||
|
||||
assert local_param is not None
|
||||
assert local_param.param_location == "header"
|
||||
assert local_param.type_value is int
|
||||
|
||||
|
||||
def test_parse_spec_with_invalid_type_any(openapi_spec_generator):
|
||||
"""Test that schemas with type='Any' are sanitized for Pydantic 2.11+.
|
||||
|
||||
External APIs like Google Integration Connectors may return schemas with
|
||||
non-standard types like 'Any'. This test verifies that such types are
|
||||
removed to allow parsing to succeed.
|
||||
"""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "API with Any type", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"operationId": "testAnyType",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "Any"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# This should not raise a ValidationError
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
assert parsed_operations[0].name == "test_any_type"
|
||||
|
||||
|
||||
def test_parse_spec_with_nested_invalid_types(openapi_spec_generator):
|
||||
"""Test that nested schemas with invalid types are sanitized."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Nested Invalid Types API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"post": {
|
||||
"operationId": "testNestedInvalid",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"valid_prop": {"type": "string"},
|
||||
"invalid_prop": {"type": "Unknown"},
|
||||
"nested_obj": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deeply_invalid": {
|
||||
"type": "CustomType"
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# This should not raise a ValidationError
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
op = parsed_operations[0]
|
||||
# The valid properties should still be parsed
|
||||
param_names = [p.original_name for p in op.parameters]
|
||||
assert "valid_prop" in param_names
|
||||
assert "invalid_prop" in param_names
|
||||
assert "nested_obj" in param_names
|
||||
|
||||
|
||||
def test_parse_spec_with_type_list_containing_invalid(openapi_spec_generator):
|
||||
"""Test that type arrays with invalid values are filtered."""
|
||||
openapi_spec = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Type List API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/test": {
|
||||
"get": {
|
||||
"operationId": "testTypeList",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"type": ["string", "Any", "null"]}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# This should not raise a ValidationError
|
||||
parsed_operations = openapi_spec_generator.parse(openapi_spec)
|
||||
|
||||
assert len(parsed_operations) == 1
|
||||
|
||||
|
||||
def test_sanitize_schema_types_removes_invalid_types(openapi_spec_generator):
|
||||
"""Test that _sanitize_schema_types correctly handles invalid types."""
|
||||
spec_with_invalid = {
|
||||
"components": {
|
||||
"schemas": {
|
||||
"InvalidSchema": {"type": "Any", "description": "Invalid type"},
|
||||
"ValidSchema": {"type": "string", "description": "Valid type"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sanitized = openapi_spec_generator._sanitize_schema_types(spec_with_invalid)
|
||||
|
||||
# Invalid type should be removed
|
||||
assert "type" not in sanitized["components"]["schemas"]["InvalidSchema"]
|
||||
assert (
|
||||
sanitized["components"]["schemas"]["InvalidSchema"]["description"]
|
||||
== "Invalid type"
|
||||
)
|
||||
|
||||
# Valid type should be preserved
|
||||
assert sanitized["components"]["schemas"]["ValidSchema"]["type"] == "string"
|
||||
|
||||
|
||||
def test_sanitize_schema_types_does_not_touch_security_schemes(
|
||||
openapi_spec_generator,
|
||||
):
|
||||
"""Test that schema type sanitization does not affect security schemes."""
|
||||
spec = {
|
||||
"components": {
|
||||
"schemas": {"InvalidSchema": {"type": "Any"}},
|
||||
"securitySchemes": {
|
||||
"api_key": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
sanitized = openapi_spec_generator._sanitize_schema_types(spec)
|
||||
|
||||
assert "type" not in sanitized["components"]["schemas"]["InvalidSchema"]
|
||||
assert (
|
||||
sanitized["components"]["securitySchemes"]["api_key"]["type"] == "apiKey"
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_schema_types_filters_type_lists(openapi_spec_generator):
|
||||
"""Test that type lists with invalid values are filtered."""
|
||||
spec_with_list = {"schema": {"type": ["string", "Any", "null", "Unknown"]}}
|
||||
|
||||
sanitized = openapi_spec_generator._sanitize_schema_types(spec_with_list)
|
||||
|
||||
# Only valid types should remain
|
||||
assert sanitized["schema"]["type"] == ["string", "null"]
|
||||
|
||||
|
||||
def test_sanitize_schema_types_removes_all_invalid_list(openapi_spec_generator):
|
||||
"""Test that type field is removed when all list values are invalid."""
|
||||
spec_with_all_invalid = {"schema": {"type": ["Any", "Unknown", "Custom"]}}
|
||||
|
||||
sanitized = openapi_spec_generator._sanitize_schema_types(
|
||||
spec_with_all_invalid
|
||||
)
|
||||
|
||||
# Type field should be removed entirely
|
||||
assert "type" not in sanitized["schema"]
|
||||
@@ -0,0 +1,334 @@
|
||||
# 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.
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
|
||||
from fastapi.openapi.models import APIKey
|
||||
from fastapi.openapi.models import APIKeyIn
|
||||
from fastapi.openapi.models import MediaType
|
||||
from fastapi.openapi.models import OAuth2
|
||||
from fastapi.openapi.models import ParameterInType
|
||||
from fastapi.openapi.models import SecuritySchemeType
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool import RestApiTool
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
def load_spec(file_path: str) -> Dict:
|
||||
"""Loads the OpenAPI specification from a YAML file."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openapi_spec() -> Dict:
|
||||
"""Fixture to load the OpenAPI specification."""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Join the directory path with the filename
|
||||
yaml_path = os.path.join(current_dir, "test.yaml")
|
||||
return load_spec(yaml_path)
|
||||
|
||||
|
||||
def test_openapi_toolset_initialization_from_dict(openapi_spec: Dict):
|
||||
"""Test initialization of OpenAPIToolset with a dictionary."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
assert isinstance(toolset._tools, list)
|
||||
assert len(toolset._tools) == 5
|
||||
assert all(isinstance(tool, RestApiTool) for tool in toolset._tools)
|
||||
|
||||
|
||||
def test_openapi_toolset_initialization_from_yaml_string(openapi_spec: Dict):
|
||||
"""Test initialization of OpenAPIToolset with a YAML string."""
|
||||
spec_str = yaml.dump(openapi_spec)
|
||||
toolset = OpenAPIToolset(spec_str=spec_str, spec_str_type="yaml")
|
||||
assert isinstance(toolset._tools, list)
|
||||
assert len(toolset._tools) == 5
|
||||
assert all(isinstance(tool, RestApiTool) for tool in toolset._tools)
|
||||
|
||||
|
||||
def test_openapi_toolset_tool_existing(openapi_spec: Dict):
|
||||
"""Test the tool() method for an existing tool."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
tool_name = "calendar_calendars_insert" # Example operationId from the spec
|
||||
tool = toolset.get_tool(tool_name)
|
||||
assert isinstance(tool, RestApiTool)
|
||||
assert tool.name == tool_name
|
||||
assert tool.description == "Creates a secondary calendar."
|
||||
assert tool.endpoint.method == "post"
|
||||
assert tool.endpoint.base_url == "https://www.googleapis.com/calendar/v3"
|
||||
assert tool.endpoint.path == "/calendars"
|
||||
assert tool.is_long_running is False
|
||||
assert tool.operation.operationId == "calendar.calendars.insert"
|
||||
assert tool.operation.description == "Creates a secondary calendar."
|
||||
assert isinstance(
|
||||
tool.operation.requestBody.content["application/json"], MediaType
|
||||
)
|
||||
assert len(tool.operation.responses) == 1
|
||||
response = tool.operation.responses["200"]
|
||||
assert response.description == "Successful response"
|
||||
assert isinstance(response.content["application/json"], MediaType)
|
||||
assert isinstance(tool.auth_scheme, OAuth2)
|
||||
|
||||
tool_name = "calendar_calendars_get"
|
||||
tool = toolset.get_tool(tool_name)
|
||||
assert isinstance(tool, RestApiTool)
|
||||
assert tool.name == tool_name
|
||||
assert tool.description == "Returns metadata for a calendar."
|
||||
assert tool.endpoint.method == "get"
|
||||
assert tool.endpoint.base_url == "https://www.googleapis.com/calendar/v3"
|
||||
assert tool.endpoint.path == "/calendars/{calendarId}"
|
||||
assert tool.is_long_running is False
|
||||
assert tool.operation.operationId == "calendar.calendars.get"
|
||||
assert tool.operation.description == "Returns metadata for a calendar."
|
||||
assert len(tool.operation.parameters) == 8
|
||||
assert tool.operation.parameters[0].name == "calendarId"
|
||||
assert tool.operation.parameters[0].in_ == ParameterInType.path
|
||||
assert tool.operation.parameters[0].required is True
|
||||
assert tool.operation.parameters[0].schema_.type == "string"
|
||||
assert (
|
||||
tool.operation.parameters[0].description
|
||||
== "Calendar identifier. To retrieve calendar IDs call the"
|
||||
" calendarList.list method. If you want to access the primary calendar"
|
||||
' of the currently logged in user, use the "primary" keyword.'
|
||||
)
|
||||
assert isinstance(tool.auth_scheme, OAuth2)
|
||||
|
||||
assert isinstance(toolset.get_tool("calendar_calendars_update"), RestApiTool)
|
||||
assert isinstance(toolset.get_tool("calendar_calendars_delete"), RestApiTool)
|
||||
assert isinstance(toolset.get_tool("calendar_calendars_patch"), RestApiTool)
|
||||
|
||||
|
||||
def test_openapi_toolset_tool_non_existing(openapi_spec: Dict):
|
||||
"""Test the tool() method for a non-existing tool."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
tool = toolset.get_tool("non_existent_tool")
|
||||
assert tool is None
|
||||
|
||||
|
||||
def test_openapi_toolset_configure_auth_on_init(openapi_spec: Dict):
|
||||
"""Test configuring auth during initialization."""
|
||||
|
||||
auth_scheme = APIKey(**{
|
||||
"in": APIKeyIn.header, # Use alias name in dict
|
||||
"name": "api_key",
|
||||
"type": SecuritySchemeType.http,
|
||||
})
|
||||
auth_credential = AuthCredential(auth_type=AuthCredentialTypes.API_KEY)
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=openapi_spec,
|
||||
auth_scheme=auth_scheme,
|
||||
auth_credential=auth_credential,
|
||||
)
|
||||
assert all(tool.auth_scheme == auth_scheme for tool in toolset._tools)
|
||||
assert all(tool.auth_credential == auth_credential for tool in toolset._tools)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"verify_value", ["/path/to/enterprise-ca-bundle.crt", False]
|
||||
)
|
||||
def test_openapi_toolset_verify_on_init(
|
||||
openapi_spec: Dict[str, Any], verify_value: str | bool
|
||||
):
|
||||
"""Test configuring verify during initialization."""
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=openapi_spec,
|
||||
ssl_verify=verify_value,
|
||||
)
|
||||
assert all(tool._ssl_verify == verify_value for tool in toolset._tools)
|
||||
|
||||
|
||||
def test_openapi_toolset_httpx_client_factory_on_init(
|
||||
openapi_spec: Dict[str, Any],
|
||||
):
|
||||
"""The httpx_client_factory is forwarded to every generated tool."""
|
||||
custom_factory = lambda: None # noqa: E731 - placeholder, never invoked here
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=openapi_spec, httpx_client_factory=custom_factory
|
||||
)
|
||||
assert toolset._httpx_client_factory is custom_factory
|
||||
assert all(
|
||||
tool._httpx_client_factory is custom_factory for tool in toolset._tools
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_toolset_httpx_client_factory_none_by_default(
|
||||
openapi_spec: Dict[str, Any],
|
||||
):
|
||||
"""httpx_client_factory is None on the toolset and each tool by default."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
assert toolset._httpx_client_factory is None
|
||||
assert all(tool._httpx_client_factory is None for tool in toolset._tools)
|
||||
|
||||
|
||||
def test_openapi_toolset_configure_verify_all(openapi_spec: Dict[str, Any]):
|
||||
"""Test configure_verify_all method."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
|
||||
# Initially verify should be None
|
||||
assert all(tool._ssl_verify is None for tool in toolset._tools)
|
||||
|
||||
# Configure verify for all tools
|
||||
ca_bundle_path = "/path/to/custom-ca.crt"
|
||||
toolset.configure_ssl_verify_all(ca_bundle_path)
|
||||
|
||||
assert all(tool._ssl_verify == ca_bundle_path for tool in toolset._tools)
|
||||
|
||||
|
||||
async def test_openapi_toolset_tool_name_prefix(openapi_spec: Dict[str, Any]):
|
||||
"""Test tool_name_prefix parameter prefixes tool names."""
|
||||
prefix = "my_api"
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec, tool_name_prefix=prefix)
|
||||
|
||||
# Verify the toolset has the prefix set
|
||||
assert toolset.tool_name_prefix == prefix
|
||||
|
||||
prefixed_tools = await toolset.get_tools_with_prefix()
|
||||
assert len(prefixed_tools) == 5
|
||||
|
||||
# Verify all tool names are prefixed
|
||||
assert all(tool.name.startswith(f"{prefix}_") for tool in prefixed_tools)
|
||||
|
||||
# Verify specific tool name is prefixed
|
||||
expected_prefixed_name = "my_api_calendar_calendars_insert"
|
||||
prefixed_tool_names = [t.name for t in prefixed_tools]
|
||||
assert expected_prefixed_name in prefixed_tool_names
|
||||
|
||||
|
||||
def test_openapi_toolset_header_provider(openapi_spec: Dict[str, Any]):
|
||||
"""Test header_provider parameter is passed to tools."""
|
||||
|
||||
def my_header_provider(context):
|
||||
return {"X-Custom-Header": "custom-value", "X-Request-ID": "12345"}
|
||||
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=openapi_spec,
|
||||
header_provider=my_header_provider,
|
||||
)
|
||||
|
||||
# Verify the toolset has the header_provider set
|
||||
assert toolset._header_provider is my_header_provider
|
||||
|
||||
# Verify all tools have the header_provider
|
||||
assert all(
|
||||
tool._header_provider is my_header_provider for tool in toolset._tools
|
||||
)
|
||||
|
||||
|
||||
def test_openapi_toolset_header_provider_none_by_default(
|
||||
openapi_spec: Dict[str, Any],
|
||||
):
|
||||
"""Test that header_provider is None by default."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
|
||||
# Verify the toolset has no header_provider by default
|
||||
assert toolset._header_provider is None
|
||||
|
||||
# Verify all tools have no header_provider
|
||||
assert all(tool._header_provider is None for tool in toolset._tools)
|
||||
|
||||
|
||||
def test_openapi_toolset_preserve_property_names(openapi_spec: Dict[str, Any]):
|
||||
"""Test that preserve_property_names keeps original camelCase names."""
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=openapi_spec,
|
||||
preserve_property_names=True,
|
||||
)
|
||||
tool = toolset.get_tool("calendar_calendars_get")
|
||||
assert tool is not None
|
||||
|
||||
# The calendarId parameter should keep its original camelCase name
|
||||
params = tool._operation_parser.get_parameters()
|
||||
param_names = [p.py_name for p in params]
|
||||
assert "calendarId" in param_names
|
||||
|
||||
# The JSON schema should also use the original name
|
||||
schema = tool._operation_parser.get_json_schema()
|
||||
assert "calendarId" in schema["properties"]
|
||||
|
||||
|
||||
def test_openapi_toolset_default_snake_case_conversion(
|
||||
openapi_spec: Dict[str, Any],
|
||||
):
|
||||
"""Test that default behavior still converts to snake_case."""
|
||||
toolset = OpenAPIToolset(spec_dict=openapi_spec)
|
||||
tool = toolset.get_tool("calendar_calendars_get")
|
||||
assert tool is not None
|
||||
|
||||
# The calendarId parameter should be converted to snake_case by default
|
||||
params = tool._operation_parser.get_parameters()
|
||||
param_names = [p.py_name for p in params]
|
||||
assert "calendar_id" in param_names
|
||||
assert "calendarId" not in param_names
|
||||
|
||||
# The JSON schema should also use snake_case
|
||||
schema = tool._operation_parser.get_json_schema()
|
||||
assert "calendar_id" in schema["properties"]
|
||||
assert "calendarId" not in schema["properties"]
|
||||
|
||||
|
||||
def test_openapi_toolset_preserve_property_names_body_params():
|
||||
"""Test preserve_property_names with request body properties."""
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0"},
|
||||
"servers": [{"url": "https://api.example.com"}],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"operationId": "createUser",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"firstName": {"type": "string"},
|
||||
"lastName": {"type": "string"},
|
||||
"emailAddress": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"200": {"description": "OK"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# With preserve_property_names=True
|
||||
toolset = OpenAPIToolset(
|
||||
spec_dict=spec,
|
||||
preserve_property_names=True,
|
||||
)
|
||||
tool = toolset.get_tool("create_user")
|
||||
params = tool._operation_parser.get_parameters()
|
||||
param_names = [p.py_name for p in params]
|
||||
assert "firstName" in param_names
|
||||
assert "lastName" in param_names
|
||||
assert "emailAddress" in param_names
|
||||
|
||||
# Without preserve_property_names (default)
|
||||
toolset_default = OpenAPIToolset(spec_dict=spec)
|
||||
tool_default = toolset_default.get_tool("create_user")
|
||||
params_default = tool_default._operation_parser.get_parameters()
|
||||
param_names_default = [p.py_name for p in params_default]
|
||||
assert "first_name" in param_names_default
|
||||
assert "last_name" in param_names_default
|
||||
assert "email_address" in param_names_default
|
||||
@@ -0,0 +1,452 @@
|
||||
# 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.
|
||||
|
||||
from fastapi.openapi.models import MediaType
|
||||
from fastapi.openapi.models import Operation
|
||||
from fastapi.openapi.models import Parameter
|
||||
from fastapi.openapi.models import RequestBody
|
||||
from fastapi.openapi.models import Response
|
||||
from fastapi.openapi.models import Schema
|
||||
from google.adk.tools.openapi_tool.common.common import ApiParameter
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.operation_parser import OperationParser
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_operation() -> Operation:
|
||||
"""Fixture to provide a sample OpenAPI Operation object."""
|
||||
return Operation(
|
||||
operationId='test_operation',
|
||||
summary='Test Summary',
|
||||
description='Test Description',
|
||||
parameters=[
|
||||
Parameter(**{
|
||||
'name': 'param1',
|
||||
'in': 'query',
|
||||
'schema': Schema(type='string'),
|
||||
'description': 'Parameter 1',
|
||||
}),
|
||||
Parameter(**{
|
||||
'name': 'param2',
|
||||
'in': 'header',
|
||||
'schema': Schema(type='string'),
|
||||
'description': 'Parameter 2',
|
||||
}),
|
||||
],
|
||||
requestBody=RequestBody(
|
||||
content={
|
||||
'application/json': MediaType(
|
||||
schema=Schema(
|
||||
type='object',
|
||||
properties={
|
||||
'prop1': Schema(
|
||||
type='string', description='Property 1'
|
||||
),
|
||||
'prop2': Schema(
|
||||
type='integer', description='Property 2'
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
},
|
||||
description='Request body description',
|
||||
),
|
||||
responses={
|
||||
'200': Response(
|
||||
description='Success',
|
||||
content={
|
||||
'application/json': MediaType(schema=Schema(type='string'))
|
||||
},
|
||||
),
|
||||
'400': Response(description='Client Error'),
|
||||
},
|
||||
security=[{'oauth2': ['resource: read', 'resource: write']}],
|
||||
)
|
||||
|
||||
|
||||
def test_operation_parser_initialization(sample_operation):
|
||||
"""Test initialization of OperationParser."""
|
||||
parser = OperationParser(sample_operation)
|
||||
assert parser._operation == sample_operation
|
||||
assert len(parser._params) == 4 # 2 params + 2 request body props
|
||||
assert parser._return_value is not None
|
||||
|
||||
|
||||
def test_process_operation_parameters(sample_operation):
|
||||
"""Test _process_operation_parameters method."""
|
||||
parser = OperationParser(sample_operation, should_parse=False)
|
||||
parser._process_operation_parameters()
|
||||
assert len(parser._params) == 2
|
||||
assert parser._params[0].original_name == 'param1'
|
||||
assert parser._params[0].param_location == 'query'
|
||||
assert parser._params[1].original_name == 'param2'
|
||||
assert parser._params[1].param_location == 'header'
|
||||
|
||||
|
||||
def test_process_request_body(sample_operation):
|
||||
"""Test _process_request_body method."""
|
||||
parser = OperationParser(sample_operation, should_parse=False)
|
||||
parser._process_request_body()
|
||||
assert len(parser._params) == 2 # 2 properties in request body
|
||||
assert parser._params[0].original_name == 'prop1'
|
||||
assert parser._params[0].param_location == 'body'
|
||||
assert parser._params[1].original_name == 'prop2'
|
||||
assert parser._params[1].param_location == 'body'
|
||||
|
||||
|
||||
def test_process_request_body_array():
|
||||
"""Test _process_request_body method with array schema."""
|
||||
operation = Operation(
|
||||
requestBody=RequestBody(
|
||||
content={
|
||||
'application/json': MediaType(
|
||||
schema=Schema(
|
||||
type='array',
|
||||
items=Schema(
|
||||
type='object',
|
||||
properties={
|
||||
'item_prop1': Schema(
|
||||
type='string', description='Item Property 1'
|
||||
),
|
||||
'item_prop2': Schema(
|
||||
type='integer', description='Item Property 2'
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
parser = OperationParser(operation, should_parse=False)
|
||||
parser._process_request_body()
|
||||
assert len(parser._params) == 1
|
||||
assert parser._params[0].original_name == 'array'
|
||||
assert parser._params[0].param_location == 'body'
|
||||
# Check that schema is correctly propagated and is a dictionary
|
||||
assert parser._params[0].param_schema.type == 'array'
|
||||
assert parser._params[0].param_schema.items.type == 'object'
|
||||
assert 'item_prop1' in parser._params[0].param_schema.items.properties
|
||||
assert 'item_prop2' in parser._params[0].param_schema.items.properties
|
||||
assert (
|
||||
parser._params[0].param_schema.items.properties['item_prop1'].description
|
||||
== 'Item Property 1'
|
||||
)
|
||||
assert (
|
||||
parser._params[0].param_schema.items.properties['item_prop2'].description
|
||||
== 'Item Property 2'
|
||||
)
|
||||
|
||||
|
||||
def test_process_request_body_no_name():
|
||||
"""Test _process_request_body with a schema that has no properties (unnamed)"""
|
||||
operation = Operation(
|
||||
requestBody=RequestBody(
|
||||
content={'application/json': MediaType(schema=Schema(type='string'))}
|
||||
)
|
||||
)
|
||||
parser = OperationParser(operation, should_parse=False)
|
||||
parser._process_request_body()
|
||||
assert len(parser._params) == 1
|
||||
assert parser._params[0].original_name == '' # No name
|
||||
assert parser._params[0].param_location == 'body'
|
||||
|
||||
|
||||
def test_process_request_body_one_of_schema_assigns_name():
|
||||
"""Ensures oneOf bodies result in a named parameter."""
|
||||
operation = Operation(
|
||||
operationId='one_of_request',
|
||||
requestBody=RequestBody(
|
||||
content={
|
||||
'application/json': MediaType(
|
||||
schema=Schema(
|
||||
oneOf=[
|
||||
Schema(
|
||||
type='object',
|
||||
properties={
|
||||
'type': Schema(type='string'),
|
||||
'stage': Schema(type='string'),
|
||||
},
|
||||
)
|
||||
],
|
||||
discriminator={'propertyName': 'type'},
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
responses={'200': Response(description='ok')},
|
||||
)
|
||||
parser = OperationParser(operation)
|
||||
params = parser.get_parameters()
|
||||
assert len(params) == 1
|
||||
assert params[0].original_name == 'body'
|
||||
assert params[0].py_name == 'body'
|
||||
schema = parser.get_json_schema()
|
||||
assert 'body' in schema['properties']
|
||||
assert '' not in schema['properties']
|
||||
|
||||
|
||||
def test_process_request_body_empty_object():
|
||||
"""Test _process_request_body with a schema that is of type object but with no properties."""
|
||||
operation = Operation(
|
||||
requestBody=RequestBody(
|
||||
content={'application/json': MediaType(schema=Schema(type='object'))}
|
||||
)
|
||||
)
|
||||
parser = OperationParser(operation, should_parse=False)
|
||||
parser._process_request_body()
|
||||
assert len(parser._params) == 0
|
||||
|
||||
|
||||
def test_dedupe_param_names(sample_operation):
|
||||
"""Test _dedupe_param_names method."""
|
||||
parser = OperationParser(sample_operation, should_parse=False)
|
||||
# Add duplicate named parameters.
|
||||
parser._params = [
|
||||
ApiParameter(original_name='test', param_location='', param_schema={}),
|
||||
ApiParameter(original_name='test', param_location='', param_schema={}),
|
||||
ApiParameter(original_name='test', param_location='', param_schema={}),
|
||||
]
|
||||
parser._dedupe_param_names()
|
||||
assert parser._params[0].py_name == 'test'
|
||||
assert parser._params[1].py_name == 'test_0'
|
||||
assert parser._params[2].py_name == 'test_1'
|
||||
|
||||
|
||||
def test_process_return_value(sample_operation):
|
||||
"""Test _process_return_value method."""
|
||||
parser = OperationParser(sample_operation, should_parse=False)
|
||||
parser._process_return_value()
|
||||
assert parser._return_value is not None
|
||||
assert parser._return_value.type_hint == 'str'
|
||||
|
||||
|
||||
def test_process_return_value_no_2xx(sample_operation):
|
||||
"""Tests _process_return_value when no 2xx response exists."""
|
||||
operation_no_2xx = Operation(
|
||||
responses={'400': Response(description='Client Error')}
|
||||
)
|
||||
parser = OperationParser(operation_no_2xx, should_parse=False)
|
||||
parser._process_return_value()
|
||||
assert parser._return_value is not None
|
||||
assert parser._return_value.type_hint == 'Any'
|
||||
|
||||
|
||||
def test_process_return_value_multiple_2xx(sample_operation):
|
||||
"""Tests _process_return_value when multiple 2xx responses exist."""
|
||||
operation_multi_2xx = Operation(
|
||||
responses={
|
||||
'201': Response(
|
||||
description='Success',
|
||||
content={
|
||||
'application/json': MediaType(schema=Schema(type='integer'))
|
||||
},
|
||||
),
|
||||
'202': Response(
|
||||
description='Success',
|
||||
content={'text/plain': MediaType(schema=Schema(type='string'))},
|
||||
),
|
||||
'200': Response(
|
||||
description='Success',
|
||||
content={
|
||||
'application/pdf': MediaType(schema=Schema(type='boolean'))
|
||||
},
|
||||
),
|
||||
'400': Response(
|
||||
description='Failure',
|
||||
content={
|
||||
'application/xml': MediaType(schema=Schema(type='object'))
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
parser = OperationParser(operation_multi_2xx, should_parse=False)
|
||||
parser._process_return_value()
|
||||
|
||||
assert parser._return_value is not None
|
||||
# Take the content type of the 200 response since it's the smallest response
|
||||
# code
|
||||
assert parser._return_value.param_schema.type == 'boolean'
|
||||
|
||||
|
||||
def test_process_return_value_no_content(sample_operation):
|
||||
"""Test when 2xx response has no content"""
|
||||
operation_no_content = Operation(
|
||||
responses={'200': Response(description='Success', content={})}
|
||||
)
|
||||
parser = OperationParser(operation_no_content, should_parse=False)
|
||||
parser._process_return_value()
|
||||
assert parser._return_value.type_hint == 'Any'
|
||||
|
||||
|
||||
def test_process_return_value_no_schema(sample_operation):
|
||||
"""Tests when the 2xx response's content has no schema."""
|
||||
operation_no_schema = Operation(
|
||||
responses={
|
||||
'200': Response(
|
||||
description='Success',
|
||||
content={'application/json': MediaType(schema=None)},
|
||||
)
|
||||
}
|
||||
)
|
||||
parser = OperationParser(operation_no_schema, should_parse=False)
|
||||
parser._process_return_value()
|
||||
assert parser._return_value.type_hint == 'Any'
|
||||
|
||||
|
||||
def test_get_function_name(sample_operation):
|
||||
"""Test get_function_name method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
assert parser.get_function_name() == 'test_operation'
|
||||
|
||||
|
||||
def test_get_function_name_missing_id():
|
||||
"""Tests get_function_name when operationId is missing"""
|
||||
operation = Operation() # No ID
|
||||
parser = OperationParser(operation)
|
||||
with pytest.raises(ValueError, match='Operation ID is missing'):
|
||||
parser.get_function_name()
|
||||
|
||||
|
||||
def test_get_return_type_hint(sample_operation):
|
||||
"""Test get_return_type_hint method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
assert parser.get_return_type_hint() == 'str'
|
||||
|
||||
|
||||
def test_get_return_type_value(sample_operation):
|
||||
"""Test get_return_type_value method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
assert parser.get_return_type_value() == str
|
||||
|
||||
|
||||
def test_get_parameters(sample_operation):
|
||||
"""Test get_parameters method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
params = parser.get_parameters()
|
||||
assert len(params) == 4 # Correct count after processing
|
||||
assert all(isinstance(p, ApiParameter) for p in params)
|
||||
|
||||
|
||||
def test_get_return_value(sample_operation):
|
||||
"""Test get_return_value method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
return_value = parser.get_return_value()
|
||||
assert isinstance(return_value, ApiParameter)
|
||||
|
||||
|
||||
def test_get_auth_scheme_name(sample_operation):
|
||||
"""Test get_auth_scheme_name method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
assert parser.get_auth_scheme_name() == 'oauth2'
|
||||
|
||||
|
||||
def test_get_auth_scheme_name_no_security():
|
||||
"""Test get_auth_scheme_name when no security is present."""
|
||||
operation = Operation(responses={})
|
||||
parser = OperationParser(operation)
|
||||
assert parser.get_auth_scheme_name() == ''
|
||||
|
||||
|
||||
def test_get_pydoc_string(sample_operation):
|
||||
"""Test get_pydoc_string method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
pydoc_string = parser.get_pydoc_string()
|
||||
assert 'Test Summary' in pydoc_string
|
||||
assert 'Args:' in pydoc_string
|
||||
assert 'param1 (str): Parameter 1' in pydoc_string
|
||||
assert 'prop1 (str): Property 1' in pydoc_string
|
||||
assert 'Returns (str):' in pydoc_string
|
||||
assert 'Success' in pydoc_string
|
||||
|
||||
|
||||
def test_get_json_schema(sample_operation):
|
||||
"""Test get_json_schema method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
json_schema = parser.get_json_schema()
|
||||
assert json_schema['title'] == 'test_operation_Arguments'
|
||||
assert json_schema['type'] == 'object'
|
||||
assert 'param1' in json_schema['properties']
|
||||
assert 'prop1' in json_schema['properties']
|
||||
# By default nothing is required unless explicitly stated
|
||||
assert 'required' not in json_schema or json_schema['required'] == []
|
||||
|
||||
|
||||
def test_get_signature_parameters(sample_operation):
|
||||
"""Test get_signature_parameters method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
signature_params = parser.get_signature_parameters()
|
||||
assert len(signature_params) == 4
|
||||
assert signature_params[0].name == 'param1'
|
||||
assert signature_params[0].annotation == str
|
||||
assert signature_params[2].name == 'prop1'
|
||||
assert signature_params[2].annotation == str
|
||||
|
||||
|
||||
def test_get_annotations(sample_operation):
|
||||
"""Test get_annotations method."""
|
||||
parser = OperationParser(sample_operation)
|
||||
annotations = parser.get_annotations()
|
||||
assert len(annotations) == 5 # 4 parameters + return
|
||||
assert annotations['param1'] == str
|
||||
assert annotations['prop1'] == str
|
||||
assert annotations['return'] == str
|
||||
|
||||
|
||||
def test_load():
|
||||
"""Test the load classmethod."""
|
||||
operation = Operation(operationId='my_op') # Minimal operation
|
||||
params = [
|
||||
ApiParameter(
|
||||
original_name='p1',
|
||||
param_location='',
|
||||
param_schema={'type': 'integer'},
|
||||
)
|
||||
]
|
||||
return_value = ApiParameter(
|
||||
original_name='', param_location='', param_schema={'type': 'string'}
|
||||
)
|
||||
|
||||
parser = OperationParser.load(operation, params, return_value)
|
||||
|
||||
assert isinstance(parser, OperationParser)
|
||||
assert parser._operation == operation
|
||||
assert parser._params == params
|
||||
assert parser._return_value == return_value
|
||||
assert (
|
||||
parser.get_function_name() == 'my_op'
|
||||
) # Check that the operation is loaded
|
||||
|
||||
|
||||
def test_operation_parser_with_dict():
|
||||
"""Test initialization of OperationParser with a dictionary."""
|
||||
operation_dict = {
|
||||
'operationId': 'test_dict_operation',
|
||||
'parameters': [
|
||||
{'name': 'dict_param', 'in': 'query', 'schema': {'type': 'string'}}
|
||||
],
|
||||
'responses': {
|
||||
'200': {
|
||||
'description': 'Dict Success',
|
||||
'content': {'application/json': {'schema': {'type': 'string'}}},
|
||||
}
|
||||
},
|
||||
}
|
||||
parser = OperationParser(operation_dict)
|
||||
assert parser._operation.operationId == 'test_dict_operation'
|
||||
assert len(parser._params) == 1
|
||||
assert parser._params[0].original_name == 'dict_param'
|
||||
assert parser._return_value.type_hint == 'str'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
||||
# 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.
|
||||
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.agents.llm_agent import LlmAgent
|
||||
from google.adk.auth.auth_credential import AuthCredential
|
||||
from google.adk.auth.auth_credential import AuthCredentialTypes
|
||||
from google.adk.auth.auth_credential import HttpAuth
|
||||
from google.adk.auth.auth_credential import HttpCredentials
|
||||
from google.adk.auth.auth_credential import OAuth2Auth
|
||||
from google.adk.auth.auth_schemes import AuthScheme
|
||||
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
||||
from google.adk.sessions.session import Session
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import openid_dict_to_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.auth_helpers import token_to_scheme_credential
|
||||
from google.adk.tools.openapi_tool.auth.credential_exchangers.auto_auth_credential_exchanger import OAuth2CredentialExchanger
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser import tool_auth_handler
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler import ToolAuthHandler
|
||||
from google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler import ToolContextCredentialStore
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
import pytest
|
||||
|
||||
|
||||
# Helper function to create a mock ToolContext
|
||||
def create_mock_tool_context():
|
||||
return ToolContext(
|
||||
function_call_id='test-fc-id',
|
||||
invocation_context=InvocationContext(
|
||||
agent=LlmAgent(name='test'),
|
||||
session=Session(app_name='test', user_id='123', id='123'),
|
||||
invocation_id='123',
|
||||
session_service=InMemorySessionService(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Test cases for OpenID Connect
|
||||
class MockOpenIdConnectCredentialExchanger(OAuth2CredentialExchanger):
|
||||
|
||||
def __init__(
|
||||
self, expected_scheme, expected_credential, expected_access_token
|
||||
):
|
||||
self.expected_scheme = expected_scheme
|
||||
self.expected_credential = expected_credential
|
||||
self.expected_access_token = expected_access_token
|
||||
|
||||
def exchange_credential(
|
||||
self,
|
||||
auth_scheme: AuthScheme,
|
||||
auth_credential: Optional[AuthCredential] = None,
|
||||
) -> AuthCredential:
|
||||
if auth_credential.oauth2 and (
|
||||
auth_credential.oauth2.auth_response_uri
|
||||
or auth_credential.oauth2.auth_code
|
||||
):
|
||||
auth_code = (
|
||||
auth_credential.oauth2.auth_response_uri
|
||||
if auth_credential.oauth2.auth_response_uri
|
||||
else auth_credential.oauth2.auth_code
|
||||
)
|
||||
# Simulate the token exchange
|
||||
updated_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.HTTP, # Store as a bearer token
|
||||
http=HttpAuth(
|
||||
scheme='bearer',
|
||||
credentials=HttpCredentials(
|
||||
token=auth_code + self.expected_access_token
|
||||
),
|
||||
),
|
||||
)
|
||||
return updated_credential
|
||||
|
||||
# simulate the case of getting auth_uri
|
||||
return None
|
||||
|
||||
|
||||
def get_mock_openid_scheme_credential():
|
||||
config_dict = {
|
||||
'authorization_endpoint': 'test.com',
|
||||
'token_endpoint': 'test.com',
|
||||
}
|
||||
scopes = ['test_scope']
|
||||
credential_dict = {
|
||||
'client_id': '123',
|
||||
'client_secret': '456',
|
||||
'redirect_uri': 'test.com',
|
||||
}
|
||||
return openid_dict_to_scheme_credential(config_dict, scopes, credential_dict)
|
||||
|
||||
|
||||
# Fixture for the OpenID Connect security scheme
|
||||
@pytest.fixture
|
||||
def openid_connect_scheme():
|
||||
scheme, _ = get_mock_openid_scheme_credential()
|
||||
return scheme
|
||||
|
||||
|
||||
# Fixture for a base OpenID Connect credential
|
||||
@pytest.fixture
|
||||
def openid_connect_credential():
|
||||
_, credential = get_mock_openid_scheme_credential()
|
||||
return credential
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_connect_no_auth_response(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
):
|
||||
# Setup Mock exchanger
|
||||
mock_exchanger = MockOpenIdConnectCredentialExchanger(
|
||||
openid_connect_scheme, openid_connect_credential, None
|
||||
)
|
||||
tool_context = create_mock_tool_context()
|
||||
credential_store = ToolContextCredentialStore(tool_context=tool_context)
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_exchanger=mock_exchanger,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
result = await handler.prepare_auth_credentials()
|
||||
assert result.state == 'pending'
|
||||
assert result.auth_credential == openid_connect_credential
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_connect_uses_explicit_credential_key(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
):
|
||||
tool_context = create_mock_tool_context()
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_key='my_tool_tokens',
|
||||
)
|
||||
result = await handler.prepare_auth_credentials()
|
||||
assert result.state == 'pending'
|
||||
requested = tool_context.actions.requested_auth_configs['test-fc-id']
|
||||
assert requested.credential_key == 'my_tool_tokens'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_connect_with_auth_response(
|
||||
openid_connect_scheme, openid_connect_credential, monkeypatch
|
||||
):
|
||||
mock_exchanger = MockOpenIdConnectCredentialExchanger(
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
'test_access_token',
|
||||
)
|
||||
tool_context = create_mock_tool_context()
|
||||
|
||||
mock_auth_handler = MagicMock()
|
||||
returned_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
|
||||
oauth2=OAuth2Auth(auth_response_uri='test_auth_response_uri'),
|
||||
)
|
||||
mock_auth_handler.get_auth_response.return_value = returned_credential
|
||||
mock_auth_handler_path = 'google.adk.auth.auth_handler.AuthHandler'
|
||||
monkeypatch.setattr(
|
||||
mock_auth_handler_path, lambda *args, **kwargs: mock_auth_handler
|
||||
)
|
||||
|
||||
credential_store = ToolContextCredentialStore(tool_context=tool_context)
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_exchanger=mock_exchanger,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
result = await handler.prepare_auth_credentials()
|
||||
assert result.state == 'done'
|
||||
assert result.auth_credential.auth_type == AuthCredentialTypes.HTTP
|
||||
assert 'test_access_token' in result.auth_credential.http.credentials.token
|
||||
# Verify that the credential was stored:
|
||||
stored_credential = credential_store.get_credential(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
)
|
||||
assert stored_credential == returned_credential
|
||||
mock_auth_handler.get_auth_response.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_connect_existing_token(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
):
|
||||
_, existing_credential = token_to_scheme_credential(
|
||||
'oauth2Token', 'header', 'bearer', '123123123'
|
||||
)
|
||||
tool_context = create_mock_tool_context()
|
||||
# Store the credential to simulate existing credential
|
||||
credential_store = ToolContextCredentialStore(tool_context=tool_context)
|
||||
key = credential_store.get_credential_key(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
)
|
||||
credential_store.store_credential(key, existing_credential)
|
||||
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
result = await handler.prepare_auth_credentials()
|
||||
assert result.state == 'done'
|
||||
assert result.auth_credential == existing_credential
|
||||
|
||||
|
||||
@patch.object(tool_auth_handler, 'OAuth2CredentialRefresher')
|
||||
@pytest.mark.asyncio
|
||||
async def test_openid_connect_existing_oauth2_token_refresh(
|
||||
mock_oauth2_refresher, openid_connect_scheme, openid_connect_credential
|
||||
):
|
||||
"""Test that OAuth2 tokens are refreshed when existing credentials are found."""
|
||||
# Create existing OAuth2 credential
|
||||
existing_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='test_client_id',
|
||||
client_secret='test_client_secret',
|
||||
access_token='existing_token',
|
||||
refresh_token='refresh_token',
|
||||
),
|
||||
)
|
||||
|
||||
# Mock the refreshed credential
|
||||
refreshed_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='test_client_id',
|
||||
client_secret='test_client_secret',
|
||||
access_token='refreshed_token',
|
||||
refresh_token='new_refresh_token',
|
||||
),
|
||||
)
|
||||
|
||||
# Setup mock OAuth2CredentialRefresher
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
mock_refresher_instance = MagicMock()
|
||||
mock_refresher_instance.is_refresh_needed = AsyncMock(return_value=True)
|
||||
mock_refresher_instance.refresh = AsyncMock(return_value=refreshed_credential)
|
||||
mock_oauth2_refresher.return_value = mock_refresher_instance
|
||||
|
||||
tool_context = create_mock_tool_context()
|
||||
credential_store = ToolContextCredentialStore(tool_context=tool_context)
|
||||
|
||||
# Store the existing credential
|
||||
key = credential_store.get_credential_key(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
)
|
||||
credential_store.store_credential(key, existing_credential)
|
||||
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
|
||||
result = await handler.prepare_auth_credentials()
|
||||
|
||||
# Verify OAuth2CredentialRefresher was called for refresh
|
||||
mock_oauth2_refresher.assert_called_once()
|
||||
|
||||
mock_refresher_instance.is_refresh_needed.assert_called_once_with(
|
||||
existing_credential
|
||||
)
|
||||
mock_refresher_instance.refresh.assert_called_once_with(
|
||||
existing_credential, openid_connect_scheme
|
||||
)
|
||||
|
||||
assert result.state == 'done'
|
||||
# The result should contain the refreshed credential after exchange
|
||||
assert result.auth_credential is not None
|
||||
|
||||
|
||||
@patch.object(tool_auth_handler, 'OAuth2CredentialRefresher')
|
||||
@pytest.mark.asyncio
|
||||
async def test_refreshed_credential_is_persisted_to_store(
|
||||
mock_oauth2_refresher, openid_connect_scheme, openid_connect_credential
|
||||
):
|
||||
"""Test that refreshed OAuth2 credentials are persisted back to the store."""
|
||||
# Create existing OAuth2 credential with an "old" refresh token.
|
||||
existing_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='test_client_id',
|
||||
client_secret='test_client_secret',
|
||||
access_token='old_access_token',
|
||||
refresh_token='old_refresh_token',
|
||||
),
|
||||
)
|
||||
|
||||
# The refresher will return a credential with rotated tokens.
|
||||
refreshed_credential = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='test_client_id',
|
||||
client_secret='test_client_secret',
|
||||
access_token='new_access_token',
|
||||
refresh_token='new_refresh_token',
|
||||
),
|
||||
)
|
||||
|
||||
mock_refresher_instance = MagicMock()
|
||||
mock_refresher_instance.is_refresh_needed = AsyncMock(return_value=True)
|
||||
mock_refresher_instance.refresh = AsyncMock(return_value=refreshed_credential)
|
||||
mock_oauth2_refresher.return_value = mock_refresher_instance
|
||||
|
||||
tool_context = create_mock_tool_context()
|
||||
credential_store = ToolContextCredentialStore(tool_context=tool_context)
|
||||
|
||||
# Store the existing (stale) credential.
|
||||
key = credential_store.get_credential_key(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
)
|
||||
credential_store.store_credential(key, existing_credential)
|
||||
|
||||
handler = ToolAuthHandler(
|
||||
tool_context,
|
||||
openid_connect_scheme,
|
||||
openid_connect_credential,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
|
||||
await handler.prepare_auth_credentials()
|
||||
|
||||
# The critical assertion: the *refreshed* credential must now be in the
|
||||
# store so that the next invocation reads the new tokens, not the old ones.
|
||||
persisted = credential_store.get_credential(
|
||||
openid_connect_scheme, openid_connect_credential
|
||||
)
|
||||
assert persisted is not None
|
||||
assert persisted.oauth2.access_token == 'new_access_token'
|
||||
assert persisted.oauth2.refresh_token == 'new_refresh_token'
|
||||
|
||||
|
||||
def test_credential_key_is_stable_across_redirect_uri():
|
||||
"""get_credential_key should be invariant under redirect_uri changes.
|
||||
|
||||
redirect_uri is deployment configuration (which callback URL the auth
|
||||
server should redirect to), not part of the credential identity. Two
|
||||
AuthCredential instances that share the same client_id, client_secret,
|
||||
and scopes but differ only in redirect_uri should produce the same key.
|
||||
"""
|
||||
scheme, _ = get_mock_openid_scheme_credential()
|
||||
credential_local = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='client',
|
||||
client_secret='secret',
|
||||
redirect_uri='http://localhost:8001/oauth2callback',
|
||||
),
|
||||
)
|
||||
credential_deployed = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='client',
|
||||
client_secret='secret',
|
||||
redirect_uri='https://deployed.example.com/oauth2callback',
|
||||
),
|
||||
)
|
||||
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
|
||||
|
||||
assert store.get_credential_key(
|
||||
scheme, credential_local
|
||||
) == store.get_credential_key(scheme, credential_deployed)
|
||||
|
||||
|
||||
def test_legacy_credential_key_is_stable_across_redirect_uri():
|
||||
"""_get_legacy_credential_key should be invariant under redirect_uri changes.
|
||||
|
||||
The same redirect_uri-strip behavior must apply to the legacy key path so
|
||||
that already-stored credentials remain findable after the fix.
|
||||
"""
|
||||
scheme, _ = get_mock_openid_scheme_credential()
|
||||
credential_local = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='client',
|
||||
client_secret='secret',
|
||||
redirect_uri='http://localhost:8001/oauth2callback',
|
||||
),
|
||||
)
|
||||
credential_deployed = AuthCredential(
|
||||
auth_type=AuthCredentialTypes.OAUTH2,
|
||||
oauth2=OAuth2Auth(
|
||||
client_id='client',
|
||||
client_secret='secret',
|
||||
redirect_uri='https://deployed.example.com/oauth2callback',
|
||||
),
|
||||
)
|
||||
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
|
||||
|
||||
assert store._get_legacy_credential_key(
|
||||
scheme, credential_local
|
||||
) == store._get_legacy_credential_key(scheme, credential_deployed)
|
||||
Reference in New Issue
Block a user