diff --git a/pyproject.toml b/pyproject.toml index c6b0bcde..28174475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.45.1" +version = "0.45.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index fb1d63f8..6ee67458 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -37,6 +37,7 @@ MCPTool, MCPToolFilter, ) +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @@ -155,10 +156,12 @@ def _resolve_value( def _resolve_tenant_subdomain(self) -> str: """Resolve tenant subdomain from string or callable.""" - return self._resolve_value( + resolved_tenant_subdomain = self._resolve_value( self._tenant_subdomain, "tenant_subdomain is required for LoB agent flow.", ) + _validate_tenant_subdomain(resolved_tenant_subdomain) + return resolved_tenant_subdomain @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self) -> AuthResult: diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index ab3cd3f5..c578bff3 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -271,9 +271,9 @@ Both fields default to empty lists. `agent_names` is applied after fetching; `or from sap_cloud_sdk.agentgateway import MCPToolFilter MCPToolFilter( - names=[], # tool names to include (matched against MCPTool.name); empty = no filter + names=[], # tool names to include (matched against MCPTool.name); empty = no filter ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched - # against IntegrationDependency.ord_id for customer agents); empty = no filter + # against IntegrationDependency.ord_id for customer agents); empty = no filter ) ``` diff --git a/src/sap_cloud_sdk/core/_tenant.py b/src/sap_cloud_sdk/core/_tenant.py new file mode 100644 index 00000000..1bb4b1ec --- /dev/null +++ b/src/sap_cloud_sdk/core/_tenant.py @@ -0,0 +1,20 @@ +import re + + +_SUBDOMAIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$") + + +def _validate_tenant_subdomain(tenant_subdomain: str | None) -> None: + """Validate that *tenant_subdomain* is a single RFC 1123 DNS label. + + A valid label contains only ASCII letters, digits, and hyphens, must not + start or end with a hyphen, and is at most 63 characters long. + If *tenant_subdomain* is ``None``, the call is a no-op. + + Raises: + ValueError: If *tenant_subdomain* does not match the expected format. + """ + if tenant_subdomain is None: + return + if not _SUBDOMAIN_RE.fullmatch(tenant_subdomain): + raise ValueError(f"Invalid tenant_subdomain: {tenant_subdomain!r}") diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 93400e41..ae6ac4c6 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -295,6 +295,7 @@ from litellm import completion logger = logging.getLogger(__name__) + async def handle_request(query: str, user_id: str): set_tenant_id("bh7sjh...") @@ -435,10 +436,10 @@ The `record_metrics` decorator records request and error counters for any SDK mo ```python from sap_cloud_sdk.core.telemetry import record_metrics + class MyClient: @record_metrics("my_module", "my_operation") - def my_method(self): - ... + def my_method(self): ... ``` Each call to the decorated method increments `sap.cloud_sdk.capability.requests`. On exception it increments `sap.cloud_sdk.capability.errors` and re-raises. Metrics are emitted only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set — no-op otherwise. @@ -450,10 +451,10 @@ For modules that live inside this package, use the `Module` and `Operation` enum ```python from sap_cloud_sdk.core.telemetry import record_metrics, Module, Operation + class DestinationClient: @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_DESTINATION) - def get_destination(self, name: str): - ... + def get_destination(self, name: str): ... ``` ### Using plain strings (external packages) @@ -463,10 +464,10 @@ External packages that depend on `sap-cloud-sdk` can pass plain strings directly ```python from sap_cloud_sdk.core.telemetry import record_metrics + class MyExternalClient: @record_metrics("my_module", "my_operation") - def my_method(self): - ... + def my_method(self): ... ``` The `Module` enum values are still the canonical form for OSS modules. Plain strings are the extension point for packages that have their own release lifecycle. diff --git a/src/sap_cloud_sdk/destination/_http.py b/src/sap_cloud_sdk/destination/_http.py index b61f461e..72a451f7 100644 --- a/src/sap_cloud_sdk/destination/_http.py +++ b/src/sap_cloud_sdk/destination/_http.py @@ -18,6 +18,7 @@ from sap_cloud_sdk.destination.config import DestinationConfig from sap_cloud_sdk.destination.exceptions import HttpError +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain # API version constants API_V1 = "v1" @@ -64,12 +65,10 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: token_url = self._config.token_url identityzone = self._config.identityzone + _validate_tenant_subdomain(tenant_subdomain) + if tenant_subdomain: - try: - token_url = token_url.replace(str(identityzone), tenant_subdomain) - except Exception: - # Fallback to base token_url if replacement fails - token_url = self._config.token_url + token_url = token_url.replace(str(identityzone), tenant_subdomain) token: Dict[str, Any] = self._session.fetch_token( token_url=token_url, diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 767f775c..cedb032d 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -10,6 +10,7 @@ DMSPermissionDeniedException, ) from sap_cloud_sdk.dms.model import DMSCredentials +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain logger = logging.getLogger(__name__) @@ -65,6 +66,8 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str: if not tenant_subdomain: return self._credentials.token_url + _validate_tenant_subdomain(tenant_subdomain) + logger.debug("Resolving token URL for tenant '%s'", tenant_subdomain) return self._credentials.token_url.replace( self._credentials.identityzone, diff --git a/src/sap_cloud_sdk/outputmanagement/user-guide.md b/src/sap_cloud_sdk/outputmanagement/user-guide.md index 99c3435c..a13b0e6f 100644 --- a/src/sap_cloud_sdk/outputmanagement/user-guide.md +++ b/src/sap_cloud_sdk/outputmanagement/user-guide.md @@ -68,9 +68,9 @@ response = client.send_email( "PurchaseOrder": { "orderId": "PO-12345", "vendor": "ACME Corp", - "total": 1500.00 + "total": 1500.00, } - } + }, ) # Check the result @@ -94,7 +94,7 @@ response = client.send_email( to=["user@example.com"], business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], # Optional - template_language="en" # Optional, default: "en" + template_language="en", # Optional, default: "en" ) ``` @@ -103,18 +103,13 @@ response = client.send_email( response = client.send_email( notification_template_key="INVOICE_NOTIFICATION", to=["customer@example.com"], - business_document={ - "Invoice": { - "invoiceNumber": "INV-2024-001", - "amount": 5000.00 - } - }, + business_document={"Invoice": {"invoiceNumber": "INV-2024-001", "amount": 5000.00}}, cc=["accounting@company.com"], template_language="en", attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", - "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" - ] + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", + ], ) ``` @@ -128,7 +123,7 @@ output_request = client.create_output_request( business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], # Optional template_language="en", # Optional - attachment_urls=["https://dms.example.com/..."] # Optional + attachment_urls=["https://dms.example.com/..."], # Optional ) # Inspect or modify the request @@ -156,7 +151,7 @@ response = await client.send_email_with_mcp( notification_template_key="TEMPLATE_KEY", to_emails=["user@example.com"], business_document={"Document": {"id": "123"}}, - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -167,17 +162,14 @@ response = await client.send_email_with_mcp( notification_template_key="CONTRACT_NOTIFICATION", to_emails=["legal@company.com"], business_document={ - "Contract": { - "contractId": "CNT-2024-100", - "partyName": "Partner Corp" - } + "Contract": {"contractId": "CNT-2024-100", "partyName": "Partner Corp"} }, cc_email="manager@company.com", attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", - "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", ], - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -202,9 +194,9 @@ response = client.send_email( "orderId": "ORD-789", "customerName": "John Doe", "orderDate": "2024-01-15", - "totalAmount": 2500.00 + "totalAmount": 2500.00, } - } + }, ) if response.error: @@ -230,9 +222,9 @@ response = client.send_email( "Invoice": { "invoiceNumber": "INV-2024-001", "amount": 5000.00, - "dueDate": "2024-02-15" + "dueDate": "2024-02-15", } - } + }, ) ``` @@ -248,13 +240,8 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="WELCOME_EMAIL", to=["user@example.com"], - business_document={ - "User": { - "userId": "U12345", - "name": "Jane Smith" - } - }, - template_language="de" # German template + business_document={"User": {"userId": "U12345", "name": "Jane Smith"}}, + template_language="de", # German template ) ``` @@ -273,14 +260,11 @@ response = client.send_email( notification_template_key="CONTRACT_NOTIFICATION", to=["legal@company.com"], business_document={ - "Contract": { - "contractId": "CNT-2024-100", - "partyName": "Partner Corp" - } + "Contract": {"contractId": "CNT-2024-100", "partyName": "Partner Corp"} }, attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content" - ] + ], ) ``` @@ -297,17 +281,13 @@ response = client.send_email( notification_template_key="REPORT_PACKAGE", to=["management@company.com"], business_document={ - "Report": { - "reportId": "RPT-Q1-2024", - "quarter": "Q1", - "year": 2024 - } + "Report": {"reportId": "RPT-Q1-2024", "quarter": "Q1", "year": 2024} }, attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", - "https://dms.example.com/browser/root?objectId=11111&cmisselector=content" - ] + "https://dms.example.com/browser/root?objectId=11111&cmisselector=content", + ], ) ``` @@ -328,7 +308,7 @@ client = create_client() client = create_client( destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY", - instance="default" + instance="default", ) ``` @@ -341,14 +321,12 @@ from sap_cloud_sdk.outputmanagement import create_client # Provider-only access (default) client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - access_strategy="PROVIDER_ONLY" + destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY" ) # Subscriber-only access client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - access_strategy="SUBSCRIBER_ONLY" + destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="SUBSCRIBER_ONLY" ) ``` @@ -360,8 +338,7 @@ Specify a custom destination service instance: from sap_cloud_sdk.outputmanagement import create_client client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - instance="my-custom-instance" + destination_name="ARIBA_OUTPUT_SERVICE", instance="my-custom-instance" ) ``` @@ -378,15 +355,12 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") output_request = client.create_output_request( notification_template_key="CUSTOM_NOTIFICATION", to=["recipient@example.com"], - business_document={ - "CustomDocument": { - "id": "DOC-456", - "type": "Important" - } - }, + business_document={"CustomDocument": {"id": "DOC-456", "type": "Important"}}, cc=["supervisor@example.com"], template_language="en", - attachment_urls=["https://dms.example.com/browser/root?objectId=999&cmisselector=content"] + attachment_urls=[ + "https://dms.example.com/browser/root?objectId=999&cmisselector=content" + ], ) # Step 2: Inspect or modify the request if needed @@ -411,7 +385,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -433,7 +407,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="", # Invalid: empty template key to=[], # Invalid: no recipients - business_document={} # Invalid: empty document + business_document={}, # Invalid: empty document ) if response.error: @@ -456,7 +430,7 @@ try: response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -491,13 +465,15 @@ for order in orders: response = client.send_email( notification_template_key="ORDER_CONFIRMATION", to=[order.customer_email], - business_document={"Order": order.to_dict()} + business_document={"Order": order.to_dict()}, ) if response.error: print(f"Failed to send email for order {order.id}: {response.error.message}") else: - print(f"Email sent for order {order.id}, Request ID: {response.outputRequestId}") + print( + f"Email sent for order {order.id}, Request ID: {response.outputRequestId}" + ) ``` ### 2. Validate Input Before Sending @@ -507,6 +483,7 @@ Validate your data before calling the API: ```python from sap_cloud_sdk.outputmanagement import create_client + def send_order_confirmation(order): # Validate input if not order.customer_email: @@ -521,12 +498,7 @@ def send_order_confirmation(order): response = client.send_email( notification_template_key="ORDER_CONFIRMATION", to=[order.customer_email], - business_document={ - "Order": { - "orderId": order.order_id, - "total": order.total - } - } + business_document={"Order": {"orderId": order.order_id, "total": order.total}}, ) return response @@ -542,7 +514,7 @@ business_document = { "Invoice": { "invoiceNumber": "INV-2024-001", # Clear identifier "customerId": "CUST-12345", - "amount": 1000.00 + "amount": 1000.00, } } @@ -550,7 +522,7 @@ business_document = { business_document = { "Invoice": { "id": "123", # Too generic - "amount": 1000.00 + "amount": 1000.00, } } ``` @@ -563,6 +535,7 @@ Always handle errors and provide meaningful feedback: from sap_cloud_sdk.outputmanagement import create_client import time + def send_notification_with_retry(template_key, recipients, document, max_retries=3): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -571,14 +544,14 @@ def send_notification_with_retry(template_key, recipients, document, max_retries response = client.send_email( notification_template_key=template_key, to=recipients, - business_document=document + business_document=document, ) if response.error: if response.error.code in ["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]: if attempt < max_retries - 1: print(f"Retrying... (attempt {attempt + 1}/{max_retries})") - time.sleep(2 ** attempt) # Exponential backoff + time.sleep(2**attempt) # Exponential backoff continue print(f"Failed to send email: {response.error.message}") @@ -588,8 +561,10 @@ def send_notification_with_retry(template_key, recipients, document, max_retries except Exception as e: if attempt < max_retries - 1: - print(f"Error occurred, retrying... (attempt {attempt + 1}/{max_retries})") - time.sleep(2 ** attempt) + print( + f"Error occurred, retrying... (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(2**attempt) continue raise @@ -611,7 +586,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -647,7 +622,7 @@ from sap_cloud_sdk.outputmanagement import create_client client = create_client( destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY", - instance="default" + instance="default", ) # Using environment variables @@ -682,7 +657,7 @@ response = client.send_email( business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], template_language="en", - attachment_urls=["https://dms.example.com/..."] + attachment_urls=["https://dms.example.com/..."], ) ``` @@ -710,7 +685,7 @@ output_request = client.create_output_request( notification_template_key="NOTIFICATION", to=["user@example.com"], business_document={"Document": {"id": "123"}}, - cc=["manager@example.com"] + cc=["manager@example.com"], ) ``` @@ -760,7 +735,7 @@ response = await client.send_email_with_mcp( notification_template_key="NOTIFICATION", to_emails=["user@example.com"], business_document={"Document": {"id": "123"}}, - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -771,6 +746,7 @@ response = await client.send_email_with_mcp( ```python from sap_cloud_sdk.outputmanagement import create_client + def send_order_confirmation(order): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -788,12 +764,12 @@ def send_order_confirmation(order): { "productName": item.product_name, "quantity": item.quantity, - "price": float(item.price) + "price": float(item.price), } for item in order.items - ] + ], } - } + }, ) return response @@ -804,6 +780,7 @@ def send_order_confirmation(order): ```python from sap_cloud_sdk.outputmanagement import create_client + def send_invoice_with_pdf(invoice, pdf_dms_url): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -817,10 +794,10 @@ def send_invoice_with_pdf(invoice, pdf_dms_url): "invoiceDate": invoice.date.isoformat(), "dueDate": invoice.due_date.isoformat(), "amount": float(invoice.amount), - "currency": invoice.currency + "currency": invoice.currency, } }, - attachment_urls=[pdf_dms_url] + attachment_urls=[pdf_dms_url], ) return response @@ -831,6 +808,7 @@ def send_invoice_with_pdf(invoice, pdf_dms_url): ```python from sap_cloud_sdk.outputmanagement import create_client + def send_bulk_notification(recipients, notification_data): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -842,9 +820,9 @@ def send_bulk_notification(recipients, notification_data): "notificationId": notification_data["id"], "title": notification_data["title"], "message": notification_data["message"], - "timestamp": notification_data["timestamp"] + "timestamp": notification_data["timestamp"], } - } + }, ) return response @@ -901,7 +879,7 @@ from sap_cloud_sdk.outputmanagement import ( ValidationException, NetworkException, DestinationNotFoundException, - DestinationAccessException + DestinationAccessException, ) try: @@ -910,7 +888,7 @@ try: response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index f950946a..714c3ec9 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -122,6 +122,50 @@ def test_raises_on_callable_returning_empty(self): AgentGatewayClient._resolve_value(get_empty, "test error") +# ============================================================ +# Test: AgentGatewayClient._resolve_tenant_subdomain +# ============================================================ + + +class TestResolveTenantSubdomain: + """Tests for AgentGatewayClient._resolve_tenant_subdomain.""" + + def test_valid_string_subdomain_returns_value(self): + client = AgentGatewayClient(tenant_subdomain="tenant-123") + assert client._resolve_tenant_subdomain() == "tenant-123" + + def test_callable_subdomain_is_resolved_then_validated(self): + client = AgentGatewayClient(tenant_subdomain=lambda: "dynamic-tenant") + assert client._resolve_tenant_subdomain() == "dynamic-tenant" + + def test_invalid_subdomain_raises_value_error(self): + client = AgentGatewayClient(tenant_subdomain="-bad-subdomain") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + client._resolve_tenant_subdomain() + + def test_invalid_subdomain_from_callable_raises_value_error(self): + client = AgentGatewayClient(tenant_subdomain=lambda: "has.dot") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + client._resolve_tenant_subdomain() + + def test_none_subdomain_raises_sdk_error(self): + client = AgentGatewayClient(tenant_subdomain=None) + with pytest.raises(AgentGatewaySDKError, match="tenant_subdomain is required"): + client._resolve_tenant_subdomain() + + @patch("sap_cloud_sdk.agentgateway.agw_client._validate_tenant_subdomain") + def test_validator_is_called_with_resolved_value(self, mock_validate): + client = AgentGatewayClient(tenant_subdomain="tenant-abc") + client._resolve_tenant_subdomain() + mock_validate.assert_called_once_with("tenant-abc") + + @patch("sap_cloud_sdk.agentgateway.agw_client._validate_tenant_subdomain") + def test_validator_called_with_result_of_callable(self, mock_validate): + client = AgentGatewayClient(tenant_subdomain=lambda: "from-callable") + client._resolve_tenant_subdomain() + mock_validate.assert_called_once_with("from-callable") + + # ============================================================ # Test: get_system_auth # ============================================================ diff --git a/tests/core/unit/test_tenant.py b/tests/core/unit/test_tenant.py new file mode 100644 index 00000000..b1d259d4 --- /dev/null +++ b/tests/core/unit/test_tenant.py @@ -0,0 +1,38 @@ +"""Unit tests for sap_cloud_sdk.core.url_utils.""" + +import pytest + +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain + + +class TestValidateTenantSubdomain: + + @pytest.mark.parametrize("valid", [ + "tenant", + "tenant-123", + "my-company-subdomain", + "a", + "a" * 63, + "A1b2C3", + "xn--nxasmq6b", # punycoded label — valid RFC 1123 label chars + ]) + def test_valid_subdomains_do_not_raise(self, valid): + _validate_tenant_subdomain(valid) # must not raise + + @pytest.mark.parametrize("invalid, description", [ + ("", "empty string"), + ("-leading-hyphen", "starts with hyphen"), + ("trailing-hyphen-", "ends with hyphen"), + ("-both-", "starts and ends with hyphen"), + ("a" * 64, "64 chars — one over the 63-char limit"), + ("has.dot", "dot is not a valid label char"), + ("has space", "space is not allowed"), + ("under_score", "underscore is not allowed"), + ("has/slash", "slash is not allowed"), + ]) + def test_invalid_subdomains_raise_value_error(self, invalid, description): + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + _validate_tenant_subdomain(invalid) + + def test_none_is_a_no_op(self): + _validate_tenant_subdomain(None) # must not raise diff --git a/tests/destination/unit/test_http.py b/tests/destination/unit/test_http.py index 50680311..d78a92b9 100644 --- a/tests/destination/unit/test_http.py +++ b/tests/destination/unit/test_http.py @@ -72,6 +72,31 @@ def test_missing_access_token_raises(self, mock_oauth): with pytest.raises(HttpError, match="missing access_token"): provider.get_token() + @patch("sap_cloud_sdk.destination._http.OAuth2Session") + def test_invalid_tenant_subdomain_raises_value_error(self, mock_oauth): + mock_session = MagicMock() + mock_oauth.return_value = mock_session + + binding = DestinationConfig( + url="https://destination.example.com", + token_url="https://provider-zone.authentication.region/oauth/token", + client_id="cid", + client_secret="csecret", + identityzone="provider-zone", + ) + provider = TokenProvider(binding) + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="-invalid") + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="has.dot") + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="trailing-hyphen-") + + mock_session.fetch_token.assert_not_called() + @patch("sap_cloud_sdk.destination._http.OAuth2Session") def test_tenant_subdomain_replaces_identityzone(self, mock_oauth): mock_session = MagicMock() diff --git a/tests/dms/unit/test_auth.py b/tests/dms/unit/test_auth.py new file mode 100644 index 00000000..49b6becb --- /dev/null +++ b/tests/dms/unit/test_auth.py @@ -0,0 +1,129 @@ +"""Unit tests for sap_cloud_sdk.dms._auth.Auth.""" + +import pytest +from unittest.mock import patch + +from sap_cloud_sdk.dms._auth import Auth, _MAX_CACHE_SIZE +from sap_cloud_sdk.dms.model import DMSCredentials + + +def _make_credentials(identityzone: str = "provider-zone") -> DMSCredentials: + return DMSCredentials( + uri="https://dms.example.com", + token_url=f"https://{identityzone}.authentication.region", + client_id="cid", + client_secret="csecret", + identityzone=identityzone, + ) + + +class TestResolveTokenUrl: + def test_no_subdomain_returns_provider_url(self): + creds = _make_credentials() + auth = Auth(creds) + assert auth._resolve_token_url(None) == creds.token_url + assert auth._resolve_token_url("") == creds.token_url + + def test_valid_subdomain_replaces_identityzone(self): + creds = _make_credentials() + auth = Auth(creds) + result = auth._resolve_token_url("tenant-123") + assert result == "https://tenant-123.authentication.region" + + def test_invalid_subdomain_raises_value_error(self): + creds = _make_credentials() + auth = Auth(creds) + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth._resolve_token_url("-bad") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth._resolve_token_url("has.dot") + + @patch("sap_cloud_sdk.dms._auth._validate_tenant_subdomain") + def test_resolve_token_url_calls_validator(self, mock_validate): + creds = _make_credentials() + auth = Auth(creds) + + auth._resolve_token_url("tenant-abc") + mock_validate.assert_called_once_with("tenant-abc") + + @patch("sap_cloud_sdk.dms._auth._validate_tenant_subdomain") + def test_validator_not_called_when_no_subdomain(self, mock_validate): + creds = _make_credentials() + auth = Auth(creds) + + auth._resolve_token_url(None) + auth._resolve_token_url("") + mock_validate.assert_not_called() + + +class TestGetToken: + def test_returns_token(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok-1", "expires_in": 3600}, + ): + assert auth.get_token() == "tok-1" + + def test_caches_token_on_second_call(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok-1", "expires_in": 3600}, + ) as mock_fetch: + auth.get_token() + auth.get_token() + mock_fetch.assert_called_once() + + def test_subscriber_and_provider_cached_separately(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + side_effect=[ + {"access_token": "prov-tok", "expires_in": 3600}, + {"access_token": "sub-tok", "expires_in": 3600}, + ], + ) as mock_fetch: + prov = auth.get_token() + sub = auth.get_token(tenant_subdomain="tenant-x") + assert prov == "prov-tok" + assert sub == "sub-tok" + assert mock_fetch.call_count == 2 + + def test_invalid_subdomain_raises_before_fetch(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok", "expires_in": 3600}, + ) as mock_fetch: + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth.get_token(tenant_subdomain="-invalid") + mock_fetch.assert_not_called() + + def test_cache_evicts_oldest_when_full(self): + creds = _make_credentials() + auth = Auth(creds) + side_effects = [ + {"access_token": f"tok-{i}", "expires_in": 3600} + for i in range(_MAX_CACHE_SIZE + 1) + ] + with patch.object(auth, "_fetch_token", side_effect=side_effects): + for i in range(_MAX_CACHE_SIZE): + auth.get_token(tenant_subdomain=f"tenant-{i:02d}") + + assert len(auth._cache) == _MAX_CACHE_SIZE + assert "tenant-00" in auth._cache + + # One more entry should evict the oldest (tenant-00) + auth.get_token(tenant_subdomain="tenant-99") + assert len(auth._cache) == _MAX_CACHE_SIZE + assert "tenant-00" not in auth._cache + assert "tenant-99" in auth._cache