diff --git a/pyproject.toml b/pyproject.toml index 4f7042a1..05f1d1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.43.0" +version = "0.43.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/destination/_models.py b/src/sap_cloud_sdk/destination/_models.py index aa69a980..8667164c 100644 --- a/src/sap_cloud_sdk/destination/_models.py +++ b/src/sap_cloud_sdk/destination/_models.py @@ -413,6 +413,7 @@ class AuthToken: type: Token type (e.g., "Bearer", "Basic") value: Base64 encoded token binary content http_header: Dictionary with 'key' and 'value' for the prepared HTTP header + error: Error message returned by the Destination Service when token retrieval fails refresh_token: Optional base64 encoded refresh token scope: Optional token scopes as space-delimited string """ @@ -420,6 +421,7 @@ class AuthToken: type: str value: str http_header: Dict[str, str] + error: Optional[str] = None refresh_token: Optional[str] = None scope: Optional[str] = None @@ -434,23 +436,28 @@ def from_dict(cls, obj: Dict[str, Any]) -> "AuthToken": AuthToken: Parsed auth token dataclass. Raises: - DestinationOperationError: If required fields are missing. + DestinationOperationError: If required fields are missing, or if the token + carries an error from the Destination Service. """ token_type = obj.get("type") or "" value = obj.get("value") or "" http_header = obj.get("http_header") or {} + error = obj.get("error") or None refresh_token = obj.get("refresh_token") scope = obj.get("scope") - if not token_type or not value or not http_header: + if not error and (not token_type or not value or not http_header): raise DestinationOperationError( "auth token is missing required fields (type/value/http_header)" ) + if error and (not token_type or not value or not http_header): + raise DestinationOperationError(f"auth token retrieval failed: {error}") return cls( type=token_type, value=value, http_header=http_header, + error=error, refresh_token=refresh_token, scope=scope, ) diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index 0f1b15e3..6d972b8d 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -89,6 +89,7 @@ def test_fetches_and_decodes_token_and_url(self): header_value = "Bearer my-raw-jwt-token-123" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/" @@ -112,6 +113,7 @@ def test_strips_trailing_slashes_from_url(self): header_value = "Bearer token" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/v1/mcp///" @@ -149,6 +151,7 @@ def test_raises_when_empty_token_value(self): """Raise MCPServerNotFoundError when http_header value is empty.""" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": ""} with patch( @@ -163,6 +166,7 @@ def test_passes_options_to_destination(self): """Pass consumption options to get_destination.""" mock_dest = MagicMock() mock_dest.auth_tokens = [MagicMock()] + mock_dest.auth_tokens[0].error = None mock_dest.auth_tokens[0].http_header = {"value": "Bearer token"} mock_dest.url = "https://agw.example.com" mock_options = MagicMock() diff --git a/tests/destination/unit/test_models.py b/tests/destination/unit/test_models.py index c72df1c6..3bb6a385 100644 --- a/tests/destination/unit/test_models.py +++ b/tests/destination/unit/test_models.py @@ -293,6 +293,39 @@ def test_get_headers_includes_auth_tokens(self): assert dest.get_headers()["Authorization"] == "Bearer eyJ123" +class TestAuthTokenModel: + """Tests for AuthToken dataclass.""" + + def test_from_dict_valid(self): + """Parse a valid auth token dict.""" + token = AuthToken.from_dict({ + "type": "Bearer", + "value": "eyJ123", + "http_header": {"key": "Authorization", "value": "Bearer eyJ123"}, + }) + assert token.type == "Bearer" + assert token.value == "eyJ123" + assert token.error is None + + def test_from_dict_with_error_field(self): + """Raise DestinationOperationError with the Destination Service error message.""" + with pytest.raises( + DestinationOperationError, + match="No consumed apis matching provided resource parameter found.", + ): + AuthToken.from_dict({ + "type": "", + "value": "", + "error": "No consumed apis matching provided resource parameter found.", + "expires_in": "0", + }) + + def test_from_dict_missing_fields_without_error_raises(self): + """Raise DestinationOperationError when required fields are missing and no error is set.""" + with pytest.raises(DestinationOperationError, match="missing required fields"): + AuthToken.from_dict({"type": "", "value": "", "http_header": {}}) + + class TestFragmentModel: """Tests for Fragment dataclass."""