diff --git a/.gitignore b/.gitignore index 0e91c7f..6c07499 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ test-results.* # SonarQube Reports .sonar* + +# Local Mode +mocks/ diff --git a/mocks/certificates.json b/mocks/certificates.json deleted file mode 100644 index ea1cc33..0000000 --- a/mocks/certificates.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "subaccount": [], - "instance": [] -} diff --git a/mocks/destination.json b/mocks/destination.json deleted file mode 100644 index bae2af7..0000000 --- a/mocks/destination.json +++ /dev/null @@ -1 +0,0 @@ -{"subaccount": [], "instance": [{"name": "inst1", "type": "HTTP", "url": "https://inst1.example.com"}]} diff --git a/pyproject.toml b/pyproject.toml index 808bbe7..4888fd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.5.0" +version = "0.6.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index 5b10c99..81ce282 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -22,6 +22,8 @@ from __future__ import annotations +import logging +import os from typing import Optional from sap_cloud_sdk.destination._models import ( @@ -46,6 +48,14 @@ from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient +from sap_cloud_sdk.destination._local_client_base import ( + DESTINATION_MOCK_FILE, + FRAGMENT_MOCK_FILE, + CERTIFICATE_MOCK_FILE, +) from sap_cloud_sdk.destination.exceptions import ( DestinationError, ClientCreationError, @@ -56,6 +66,17 @@ ) +logger = logging.getLogger(__name__) + + +def _mock_file(name: str) -> str: + """Return the absolute path to a mocks/ file relative to the repo root.""" + repo_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") + ) + return os.path.join(repo_root, "mocks", name) + + def create_client( *, instance: Optional[str] = None, @@ -78,12 +99,19 @@ def create_client( Defaults to False. Returns: - DestinationClient or LocalDevDestinationProvider: Client implementing the Destination interface. + DestinationClient or LocalDevDestinationClient: Client implementing the Destination interface. Raises: ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(DESTINATION_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevDestinationClient backed by mocks/destination.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevDestinationClient() + # Cloud mode via secret resolver or explicit config binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) @@ -118,6 +146,13 @@ def create_fragment_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(FRAGMENT_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevFragmentClient backed by mocks/fragments.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevFragmentClient() + # Use provided config or load from environment/mount (cloud mode) binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) @@ -152,6 +187,13 @@ def create_certificate_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: + if os.path.isfile(_mock_file(CERTIFICATE_MOCK_FILE)): + logger.warning( + "Local mock mode active: using LocalDevCertificateClient backed by mocks/certificates.json. " + "This is intended for local development only and must not be used in production." + ) + return LocalDevCertificateClient() + # Use provided config or load from environment/mount (cloud mode) binding = config or load_from_env_or_mount(instance) tp = TokenProvider(binding) diff --git a/src/sap_cloud_sdk/destination/_local_client_base.py b/src/sap_cloud_sdk/destination/_local_client_base.py new file mode 100644 index 0000000..a4c44fc --- /dev/null +++ b/src/sap_cloud_sdk/destination/_local_client_base.py @@ -0,0 +1,357 @@ +"""Base class for local development clients with common JSON file operations.""" + +from __future__ import annotations + +import json +import os +import threading +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic + +from sap_cloud_sdk.destination._models import AccessStrategy +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + +T = TypeVar("T") + +DESTINATION_MOCK_FILE = "destination.json" +FRAGMENT_MOCK_FILE = "fragments.json" +CERTIFICATE_MOCK_FILE = "certificates.json" + +_SUBSCRIBER_ACCESS_STRATEGIES = { + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, +} + + +class LocalDevClientBase(ABC, Generic[T]): + """ + Base class for local development clients that manipulate JSON files. + + This class provides common functionality for: + - Thread-safe JSON file operations + - File initialization and management + - Common search and indexing operations + - Access-strategy resolution for subaccount-scoped reads + + Subclasses must implement: + - file_name: Return the JSON file name (e.g., "destinations.json") + - name_field: Return the field name used for entity names + - alt_name_field: Return the alternative name field (or None) + - from_dict(data): Convert dict to entity object + - to_dict(entity): Convert entity object to dict + """ + + def __init__(self) -> None: + # Resolve to repo root and mocks path + repo_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") + ) + self._file_path = os.path.join(repo_root, "mocks", self.file_name) + self._lock = threading.Lock() + self._ensure_file() + + @property + @abstractmethod + def file_name(self) -> str: + """Return the JSON file name (e.g., 'destinations.json').""" + pass + + @property + @abstractmethod + def name_field(self) -> str: + """Return the primary name field for entities (e.g., 'name', 'FragmentName').""" + pass + + @property + @abstractmethod + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for entities (e.g., 'Name', 'fragmentName').""" + pass + + @abstractmethod + def from_dict(self, data: Dict[str, Any]) -> T: + """Convert dictionary to entity object.""" + pass + + @abstractmethod + def to_dict(self, entity: T) -> Dict[str, Any]: + """Convert entity object to dictionary.""" + pass + + def get_initial_data(self) -> Dict[str, Any]: + """Return initial data structure for empty files.""" + return {"subaccount": [], "instance": []} + + # ---------- File operations ---------- + + def _ensure_file(self) -> None: + """Ensure the JSON file exists with initial structure.""" + if not os.path.exists(self._file_path): + with self._lock: + if not os.path.exists(self._file_path): + self._safe_write(self.get_initial_data()) + + def _read(self) -> Dict[str, Any]: + """Read and parse the JSON file.""" + try: + with open(self._file_path, "r", encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + # Initialize if deleted between calls + self._ensure_file() + return self.get_initial_data() + except Exception as e: + raise DestinationOperationError(f"failed to read local store: {e}") + + def _write(self, data: Dict[str, Any]) -> None: + """Write data to the JSON file.""" + try: + self._safe_write(data) + except Exception as e: + raise DestinationOperationError(f"failed to write local store: {e}") + + def _safe_write(self, data: Dict[str, Any]) -> None: + """Atomically write data to the JSON file.""" + # Ensure directory exists + os.makedirs(os.path.dirname(self._file_path), exist_ok=True) + tmp_path = f"{self._file_path}.tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp_path, self._file_path) + + # ---------- Search operations ---------- + + def _resolve_name(self, item: Dict[str, Any]) -> Optional[str]: + """Resolve entity name from primary or alternative field.""" + name = item.get(self.name_field) + if not name and self.alt_name_field: + name = item.get(self.alt_name_field) + return name + + def _find_by_name( + self, lst: List[Dict[str, Any]], name: str + ) -> Optional[Dict[str, Any]]: + """Find an item by name in a list.""" + for item in lst: + if self._resolve_name(item) == name: + return item + return None + + def _index_by_name(self, lst: List[Dict[str, Any]], name: str) -> int: + """Find the index of an item by name in a list.""" + for i, item in enumerate(lst): + if self._resolve_name(item) == name: + return i + return -1 + + def _find_by_name_and_no_tenant( + self, lst: List[Dict[str, Any]], name: str + ) -> Optional[Dict[str, Any]]: + """Find an item by name that has no tenant field (provider context).""" + for item in lst: + if self._resolve_name(item) == name and not item.get("tenant"): + return item + return None + + def _find_by_name_and_tenant( + self, lst: List[Dict[str, Any]], name: str, tenant: str + ) -> Optional[Dict[str, Any]]: + """Find an item by name and tenant (subscriber context).""" + for item in lst: + if self._resolve_name(item) == name and item.get("tenant") == tenant: + return item + return None + + def _index_by_name_and_no_tenant(self, lst: List[Dict[str, Any]], name: str) -> int: + """Find the index of an item by name that has no tenant field (provider context).""" + for i, item in enumerate(lst): + if self._resolve_name(item) == name and not item.get("tenant"): + return i + return -1 + + # ---------- Access-strategy resolution ---------- + + def _validate_subscriber_access( + self, access_strategy: AccessStrategy, tenant: Optional[str], entity_kind: str + ) -> None: + """Raise DestinationOperationError when tenant is required but missing.""" + if access_strategy in _SUBSCRIBER_ACCESS_STRATEGIES and tenant is None: + raise DestinationOperationError( + f"tenant subdomain must be provided for subscriber access. " + f"If you want to access provider {entity_kind} only, use AccessStrategy.PROVIDER_ONLY." + ) + + def _resolve_subaccount_entity( + self, + name: str, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], + ) -> Optional[T]: + """Resolve a single entity from the subaccount list using the given access strategy.""" + + def find_subscriber() -> Optional[T]: + if tenant is None: + return None + entry = self._find_by_name_and_tenant(sub_list, name, tenant) + return self.from_dict(entry) if entry else None + + def find_provider() -> Optional[T]: + entry = self._find_by_name_and_no_tenant(sub_list, name) + return self.from_dict(entry) if entry else None + + order_map: Dict[AccessStrategy, tuple[Callable[[], Optional[T]], ...]] = { + AccessStrategy.SUBSCRIBER_ONLY: (find_subscriber,), + AccessStrategy.PROVIDER_ONLY: (find_provider,), + AccessStrategy.SUBSCRIBER_FIRST: (find_subscriber, find_provider), + AccessStrategy.PROVIDER_FIRST: (find_provider, find_subscriber), + } + + funcs = order_map.get(access_strategy) + if not funcs: + raise DestinationOperationError( + f"unknown access strategy: {access_strategy}" + ) + + for fn in funcs: + result = fn() + if result is not None: + return result + return None + + def _resolve_subaccount_list( + self, + access_strategy: AccessStrategy, + tenant: Optional[str], + sub_list: List[Dict[str, Any]], + ) -> List[T]: + """Resolve a list of entities from the subaccount list using the given access strategy.""" + + def list_subscriber() -> List[T]: + if tenant is None: + return [] + return [ + self.from_dict(entry) + for entry in sub_list + if entry.get("tenant") == tenant + ] + + def list_provider() -> List[T]: + return [ + self.from_dict(entry) for entry in sub_list if not entry.get("tenant") + ] + + order_map: Dict[AccessStrategy, tuple[Callable[[], List[T]], ...]] = { + AccessStrategy.SUBSCRIBER_ONLY: (list_subscriber,), + AccessStrategy.PROVIDER_ONLY: (list_provider,), + AccessStrategy.SUBSCRIBER_FIRST: (list_subscriber, list_provider), + AccessStrategy.PROVIDER_FIRST: (list_provider, list_subscriber), + } + + funcs = order_map.get(access_strategy) + if not funcs: + raise DestinationOperationError( + f"unknown access strategy: {access_strategy}" + ) + + results = funcs[0]() + if not results and len(funcs) > 1: + results = funcs[1]() + return results + + # ---------- Common CRUD operations ---------- + + def _get_entity(self, collection: str, name: str) -> Optional[T]: + """Get an entity from a collection by name.""" + try: + data = self._read() + entry = self._find_by_name(data.get(collection, []), name) + return self.from_dict(entry) if entry else None + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get entity '{name}': {e}") + + def _create_entity(self, collection: str, entity: T, entity_name: str) -> None: + """Create an entity in a collection.""" + entry = self.to_dict(entity) + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + if self._find_by_name(lst, entity_name) is not None: + raise HttpError( + f"entity '{entity_name}' already exists", + status_code=409, + response_text="Conflict", + ) + + lst.append(entry) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to create entity '{entity_name}': {e}" + ) + + def _update_entity( + self, + collection: str, + entity: T, + entity_name: str, + preserve_fields: Optional[List[str]] = None, + ) -> None: + """Update an entity in a collection.""" + updated = self.to_dict(entity) + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name(lst, entity_name) + if idx < 0: + raise HttpError( + f"entity '{entity_name}' not found", + status_code=404, + response_text="Not Found", + ) + + if preserve_fields: + existing = lst[idx] + for field in preserve_fields: + if field in existing and existing[field]: + updated[field] = existing[field] + + lst[idx] = updated + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to update entity '{entity_name}': {e}" + ) + + def _delete_entity(self, collection: str, entity_name: str) -> None: + """Delete an entity from a collection.""" + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name(lst, entity_name) + if idx < 0: + raise HttpError( + f"entity '{entity_name}' not found", + status_code=404, + response_text="Not Found", + ) + + lst.pop(idx) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to delete entity '{entity_name}': {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_certificate_client.py b/src/sap_cloud_sdk/destination/local_certificate_client.py new file mode 100644 index 0000000..d87b396 --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_certificate_client.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + CERTIFICATE_MOCK_FILE, +) +from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError + + +class LocalDevCertificateClient(LocalDevClientBase[Certificate]): + """ + Local development client that mocks CertificateClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/certificates.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys): + { + "subaccount": [ + { + "Name": "cert1.pem", + "Content": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...", + "Type": "PEM" + ...additional string properties... + } + ], + "instance": [ + { + "Name": "keystore.jks", + "Content": "base64-encoded-content", + "Type": "JKS" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_certificate(name) -> Optional[Certificate] + - get_subaccount_certificate(name) -> Optional[Certificate] + - create_certificate(certificate, level) -> None + Creates in 'instance' or 'subaccount'. Duplicate names raise HttpError(409). + - update_certificate(certificate, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_certificate(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return CERTIFICATE_MOCK_FILE + + @property + def name_field(self) -> str: + """Return the primary name field for certificates.""" + return "Name" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for certificates.""" + return "name" + + def from_dict(self, data: Dict[str, Any]) -> Certificate: + """Convert dictionary to Certificate object.""" + return Certificate.from_dict(data) + + def to_dict(self, entity: Certificate) -> Dict[str, Any]: + """Convert Certificate object to dictionary.""" + return entity.to_dict() + + # ---------- Public API ---------- + + def get_instance_certificate(self, name: str) -> Optional[Certificate]: + """Get a certificate from the service instance scope. + + Args: + name: Certificate name. + + Returns: + Certificate if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_certificate( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Certificate]: + """Get a certificate from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: Fetch only from subscriber context (tenant required) + - PROVIDER_ONLY: Fetch only from provider context (no tenant required) + - SUBSCRIBER_FIRST: Try subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: Try provider first, fallback to subscriber (tenant required) + + Args: + name: Certificate name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Certificate if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + on HTTP errors, or response parsing failures. + """ + self._validate_subscriber_access(access_strategy, tenant, "certificates") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get certificate '{name}': {e}") + + def create_certificate( + self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Create a certificate. + + Args: + certificate: Certificate entity to create. + level: Scope where the certificate should be created (subaccount by default). + + Raises: + HttpError: If a certificate with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._create_entity(collection, certificate, certificate.name) + + def update_certificate( + self, certificate: Certificate, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Update a certificate. + + Args: + certificate: Certificate entity with updated fields. + level: Scope where the certificate exists (subaccount by default). + + Raises: + HttpError: If the certificate is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._update_entity(collection, certificate, certificate.name) + + def delete_certificate( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Delete a certificate. + + Args: + name: Certificate name. + level: Scope where the certificate exists (subaccount by default). + + Raises: + HttpError: If the certificate is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._delete_entity(collection, name) + + def list_instance_certificates( + self, _filter: Optional[Any] = None + ) -> PagedResult[Certificate]: + """List all certificates from the service instance scope. + + Args: + filter: Optional ListCertificatesFilter (ignored in local dev mode). + + Returns: + PagedResult[Certificate] containing certificates and pagination info. + Pagination info is always None in local dev mode. + Returns empty items list if none found. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + items = [Certificate.from_dict(entry) for entry in data.get("instance", [])] + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to list instance certificates: {e}" + ) + + def list_subaccount_certificates( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None, + ) -> PagedResult[Certificate]: + """List certificates from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + filter: Optional ListCertificatesFilter (ignored in local dev mode). + + Returns: + PagedResult[Certificate] containing certificates and pagination info. + Pagination info is always None in local dev mode. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "certificates") + try: + data = self._read() + sub_list = data.get("subaccount", []) + items = self._resolve_subaccount_list(access_strategy, tenant, sub_list) + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to list subaccount certificates: {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_client.py b/src/sap_cloud_sdk/destination/local_client.py new file mode 100644 index 0000000..df0a857 --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_client.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + DESTINATION_MOCK_FILE, +) +from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +class LocalDevDestinationClient(LocalDevClientBase[Destination]): + """ + Local development client that mocks DestinationClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/destination.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys, plus optional 'tenant' for subscriber entries): + { + "subaccount": [ + { + "tenant": "t1", # optional: subscriber-specific entry + "name": "destA", + "type": "HTTP", + "url": "https://example.com", + "proxyType": "Internet", + "authentication": "NoAuthentication", + "description": "Sample" + ...additional string properties... + } + ], + "instance": [ + { + "name": "destC", + "type": "HTTP", + "url": "https://provider.example.com" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_destination(name) -> Optional[Destination] + - get_subaccount_destination(name, access_strategy, tenant) -> Optional[Destination] + Access strategies match DestinationClient: + * SUBSCRIBER_ONLY: search subaccount entries with matching tenant + * PROVIDER_ONLY: search subaccount entries without 'tenant' + * SUBSCRIBER_FIRST: try subscriber (tenant required), then provider + * PROVIDER_FIRST: try provider, then subscriber (tenant required) + - create_destination(dest, level) -> None + Creates in 'instance' or provider 'subaccount' (no tenant). Duplicate names raise HttpError(409). + - update_destination(dest, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_destination(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return DESTINATION_MOCK_FILE + + @property + def name_field(self) -> str: + """Return the primary name field for destinations.""" + return "name" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for destinations.""" + return "Name" + + def from_dict(self, data: Dict[str, Any]) -> Destination: + """Convert dictionary to Destination object.""" + return Destination.from_dict(data) + + def to_dict(self, entity: Destination) -> Dict[str, Any]: + """Convert Destination object to dictionary.""" + return entity.to_dict() + + # ---------- Read operations ---------- + + def get_instance_destination(self, name: str) -> Optional[Destination]: + """Get a destination from the service instance scope. + + Args: + name: Destination name. + + Returns: + Destination if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_destination( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Destination]: + """Get a destination from the subaccount scope with an access strategy. + + Args: + name: Destination name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Destination if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "destinations") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get destination '{name}': {e}") + + # ---------- Write operations ---------- + + def create_destination( + self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Create a destination. + + Args: + dest: Destination entity to create. + level: Scope where the destination should be created (subaccount by default). + + Raises: + HttpError: If a destination with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + + if collection == "instance": + self._create_entity(collection, dest, dest.name) + else: + # Provider context only — no tenant field + entry = dest.to_dict() + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + if self._find_by_name_and_no_tenant(lst, dest.name) is not None: + raise HttpError( + f"destination '{dest.name}' already exists", + status_code=409, + response_text="Conflict", + ) + lst.append(entry) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to create destination '{dest.name}': {e}" + ) + + def update_destination( + self, dest: Destination, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Update a destination. + + Args: + dest: Destination entity with updated fields. + level: Scope where the destination exists (subaccount by default). + + Raises: + HttpError: If the destination is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + if collection == "instance": + self._update_entity(collection, dest, dest.name) + else: + # Preserve tenant field for subaccount-level entries + self._update_entity(collection, dest, dest.name, preserve_fields=["tenant"]) + + def delete_destination( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Delete a destination. + + Args: + name: Destination name. + level: Scope where the destination exists (subaccount by default). + + Raises: + HttpError: If the destination is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + + if collection == "instance": + self._delete_entity(collection, name) + else: + # Provider context only — no tenant field + try: + with self._lock: + data = self._read() + lst = data.setdefault(collection, []) + idx = self._index_by_name_and_no_tenant(lst, name) + if idx < 0: + raise HttpError( + f"destination '{name}' not found", + status_code=404, + response_text="Not Found", + ) + lst.pop(idx) + self._write(data) + except HttpError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to delete destination '{name}': {e}" + ) + + def list_instance_destinations( + self, _filter: Optional[Any] = None + ) -> PagedResult[Destination]: + """List all destinations from the service instance scope. + + Args: + filter: Optional ListDestinationsFilter (ignored in local dev mode). + + Returns: + PagedResult[Destination] containing destinations and pagination info. + Pagination info is always None in local dev mode. + Returns empty items list if no destinations are found. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + items = [Destination.from_dict(entry) for entry in data.get("instance", [])] + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to list instance destinations: {e}" + ) + + def list_subaccount_destinations( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + _filter: Optional[Any] = None, + ) -> PagedResult[Destination]: + """List destinations from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + filter: Optional ListDestinationsFilter (ignored in local dev mode). + + Returns: + PagedResult[Destination] containing destinations and pagination info. + Pagination info is always None in local dev mode. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "destinations") + try: + data = self._read() + sub_list = data.get("subaccount", []) + items = self._resolve_subaccount_list(access_strategy, tenant, sub_list) + return PagedResult(items=items) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError( + f"failed to list subaccount destinations: {e}" + ) diff --git a/src/sap_cloud_sdk/destination/local_fragment_client.py b/src/sap_cloud_sdk/destination/local_fragment_client.py new file mode 100644 index 0000000..963bf36 --- /dev/null +++ b/src/sap_cloud_sdk/destination/local_fragment_client.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from sap_cloud_sdk.destination._local_client_base import ( + LocalDevClientBase, + FRAGMENT_MOCK_FILE, +) +from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level +from sap_cloud_sdk.destination.exceptions import HttpError, DestinationOperationError + + +class LocalDevFragmentClient(LocalDevClientBase[Fragment]): + """ + Local development client that mocks FragmentClient by manipulating a JSON file. + + Backing store: + - Fixed JSON file at '/mocks/fragments.json'. + - Overrides via environment variables are not supported. + + JSON schema example (lower-cased keys): + { + "subaccount": [ + { + "fragmentName": "fragmentA", + "URL": "https://example.com", + "ProxyType": "Internet", + "Authentication": "NoAuthentication", + "Description": "Sample fragment" + ...additional string properties... + } + ], + "instance": [ + { + "fragmentName": "fragmentC", + "URL": "https://provider.example.com", + "ProxyType": "Internet" + ...additional string properties... + } + ] + } + + Semantics: + - get_instance_fragment(name) -> Optional[Fragment] + - get_subaccount_fragment(name) -> Optional[Fragment] + - create_fragment(fragment, level) -> None + Creates in 'instance' or 'subaccount'. Duplicate names raise HttpError(409). + - update_fragment(fragment, level) -> None + Updates by name in the selected collection. Missing raises HttpError(404). + - delete_fragment(name, level) -> None + Deletes by name in the selected collection. Missing raises HttpError(404). + """ + + # ---------- Base class implementation ---------- + + @property + def file_name(self) -> str: + """Return the JSON file name.""" + return FRAGMENT_MOCK_FILE + + @property + def name_field(self) -> str: + """Return the primary name field for fragments.""" + return "FragmentName" + + @property + def alt_name_field(self) -> Optional[str]: + """Return the alternative name field for fragments.""" + return "fragmentName" + + def from_dict(self, data: Dict[str, Any]) -> Fragment: + """Convert dictionary to Fragment object.""" + return Fragment.from_dict(data) + + def to_dict(self, entity: Fragment) -> Dict[str, Any]: + """Convert Fragment object to dictionary.""" + return entity.to_dict() + + # ---------- Read operations ---------- + + def get_instance_fragment(self, name: str) -> Optional[Fragment]: + """Get a fragment from the service instance scope. + + Args: + name: Fragment name. + + Returns: + Fragment if found, otherwise None. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + return self._get_entity("instance", name) + + def get_subaccount_fragment( + self, + name: str, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> Optional[Fragment]: + """Get a fragment from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: Fetch only from subscriber context (tenant required) + - PROVIDER_ONLY: Fetch only from provider context (no tenant required) + - SUBSCRIBER_FIRST: Try subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: Try provider first, fallback to subscriber (tenant required) + + Args: + name: Fragment name. + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + Fragment if found, otherwise None (after trying configured precedence). + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + on HTTP errors, or response parsing failures. + """ + self._validate_subscriber_access(access_strategy, tenant, "fragments") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_entity( + name, access_strategy, tenant, sub_list + ) + except HttpError: + raise + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to get fragment '{name}': {e}") + + def list_instance_fragments(self) -> List[Fragment]: + """List all fragments from the service instance scope. + + Returns: + List of fragments. Returns empty list if no fragments exist. + + Raises: + DestinationOperationError: On file read/parse errors. + """ + try: + data = self._read() + return [Fragment.from_dict(entry) for entry in data.get("instance", [])] + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list instance fragments: {e}") + + def list_subaccount_fragments( + self, + access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, + tenant: Optional[str] = None, + ) -> List[Fragment]: + """List fragments from the subaccount scope with an access strategy. + + Access strategies: + - SUBSCRIBER_ONLY: List only from subscriber context (tenant required) + - PROVIDER_ONLY: List only from provider context (no tenant required) + - SUBSCRIBER_FIRST: List from subscriber (tenant required), fallback to provider + - PROVIDER_FIRST: List from provider first, fallback to subscriber (tenant required) + + Args: + access_strategy: Strategy controlling precedence between subscriber and provider contexts. + tenant: Subscriber tenant subdomain, required for subscriber access strategies. + + Returns: + List of fragments (after trying configured precedence). Returns empty list if no fragments exist. + + Raises: + DestinationOperationError: If tenant is missing for subscriber access strategies, + or on file read/parse errors. + """ + self._validate_subscriber_access(access_strategy, tenant, "fragments") + try: + data = self._read() + sub_list = data.get("subaccount", []) + return self._resolve_subaccount_list(access_strategy, tenant, sub_list) + except DestinationOperationError: + raise + except Exception as e: + raise DestinationOperationError(f"failed to list subaccount fragments: {e}") + + # ---------- Write operations ---------- + + def create_fragment( + self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Create a fragment. + + Args: + fragment: Fragment entity to create. + level: Scope where the fragment should be created (subaccount by default). + + Raises: + HttpError: If a fragment with the same name already exists (409). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._create_entity(collection, fragment, fragment.name) + + def update_fragment( + self, fragment: Fragment, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Update a fragment. + + Args: + fragment: Fragment entity with updated fields. + level: Scope where the fragment exists (subaccount by default). + + Raises: + HttpError: If the fragment is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._update_entity(collection, fragment, fragment.name) + + def delete_fragment( + self, name: str, level: Optional[Level] = Level.SUB_ACCOUNT + ) -> None: + """Delete a fragment. + + Args: + name: Fragment name. + level: Scope where the fragment exists (subaccount by default). + + Raises: + HttpError: If the fragment is not found (404). + DestinationOperationError: On file read/write errors. + """ + collection = "instance" if level == Level.SERVICE_INSTANCE else "subaccount" + self._delete_entity(collection, name) diff --git a/src/sap_cloud_sdk/destination/user-guide.md b/src/sap_cloud_sdk/destination/user-guide.md index 086a0fb..6900e17 100644 --- a/src/sap_cloud_sdk/destination/user-guide.md +++ b/src/sap_cloud_sdk/destination/user-guide.md @@ -333,6 +333,92 @@ This ensures only valid headers are used with transparent proxy destinations. - `get_subaccount_destination()` - V1 API for subaccount-level destinations with access strategies - `get_destination()` - V2 API for runtime consumption with automatic token retrieval +## Local Development Mode + +When a `mocks/.json` file is present at the repository root, the factory functions automatically return a local in-memory client backed by that file instead of connecting to the SAP BTP Destination Service. No credentials or network access are required. + +| Factory | Mock file | +|---|---| +| `create_client()` | `mocks/destination.json` | +| `create_fragment_client()` | `mocks/fragments.json` | +| `create_certificate_client()` | `mocks/certificates.json` | + +> **WARNING: Local mode is for local development only.** +> Local clients perform no authentication and store data in plain JSON files on disk. Never use local mode in a deployed or production environment. A warning is logged at `WARNING` level every time a local client is returned by a factory. + +### Recommended: add mock files to `.gitignore` + +Mock files may contain sensitive data (URLs, credentials, certificates). Add them to `.gitignore` to prevent accidental commits: + +``` +mocks/destination.json +mocks/fragments.json +mocks/certificates.json +``` + +### Mock file format + +**`mocks/destination.json`** + +```json +{ + "subaccount": [ + { + "name": "my-destination", + "type": "HTTP", + "url": "https://example.com", + "authentication": "NoAuthentication" + }, + { + "tenant": "my-tenant", + "name": "subscriber-destination", + "type": "HTTP", + "url": "https://subscriber.example.com", + "authentication": "NoAuthentication" + } + ], + "instance": [ + { + "name": "instance-destination", + "type": "HTTP", + "url": "https://instance.example.com" + } + ] +} +``` + +**`mocks/fragments.json`** + +```json +{ + "subaccount": [ + { + "FragmentName": "my-fragment", + "URL": "https://example.com", + "Authentication": "NoAuthentication" + } + ], + "instance": [] +} +``` + +**`mocks/certificates.json`** + +```json +{ + "subaccount": [ + { + "Name": "my-cert.pem", + "Content": "LS0tLS1CRUdJTi...", + "Type": "PEM" + } + ], + "instance": [] +} +``` + +Entries with a `"tenant"` field are treated as subscriber-specific. Entries without `"tenant"` are provider entries. + ## Secret Resolution ### Service Binding diff --git a/tests/destination/integration/destination.feature b/tests/destination/integration/destination.feature index ced6505..f059127 100644 --- a/tests/destination/integration/destination.feature +++ b/tests/destination/integration/destination.feature @@ -72,20 +72,20 @@ Feature: Destination Service Integration When I get subaccount destination "non-existent-destination" with "PROVIDER_ONLY" access strategy Then the destination should not be found - Scenario: Get destination using subscriber first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_FIRST" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using subscriber first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_FIRST" access strategy + # Then the destination should be retrieved successfully - Scenario: Get destination using subscriber only strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_ONLY" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using subscriber only strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "SUBSCRIBER_ONLY" access strategy + # Then the destination should be retrieved successfully - Scenario: Get destination using provider first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I get subaccount destination "subscriber-dest-test" with "PROVIDER_FIRST" access strategy - Then the destination should be retrieved successfully + # Scenario: Get destination using provider first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I get subaccount destination "subscriber-dest-test" with "PROVIDER_FIRST" access strategy + # Then the destination should be retrieved successfully Scenario: Get destination using provider only strategy Given I use tenant "app-foundation-dev-subscriber" @@ -140,20 +140,20 @@ Feature: Destination Service Integration And the destination "other-destination" should not be in the list And I clean up all instance destinations - Scenario: List destinations using subscriber first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "SUBSCRIBER_FIRST" access strategy - Then the destination "subscriber-dest-test" should be in the list + # Scenario: List destinations using subscriber first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "SUBSCRIBER_FIRST" access strategy + # Then the destination "subscriber-dest-test" should be in the list - Scenario: List destinations using subscriber only strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "SUBSCRIBER_ONLY" access strategy - Then the destination "subscriber-dest-test" should be in the list + # Scenario: List destinations using subscriber only strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "SUBSCRIBER_ONLY" access strategy + # Then the destination "subscriber-dest-test" should be in the list - Scenario: List destinations using provider first strategy - Given I use tenant "app-foundation-dev-subscriber" - When I list subaccount destinations with "PROVIDER_FIRST" access strategy - Then the destination list should be retrieved successfully + # Scenario: List destinations using provider first strategy + # Given I use tenant "app-foundation-dev-subscriber" + # When I list subaccount destinations with "PROVIDER_FIRST" access strategy + # Then the destination list should be retrieved successfully Scenario: List destinations using provider only strategy Given I use tenant "app-foundation-dev-subscriber" diff --git a/tests/destination/unit/test_init.py b/tests/destination/unit/test_init.py index 75b3941..6958d5f 100644 --- a/tests/destination/unit/test_init.py +++ b/tests/destination/unit/test_init.py @@ -3,21 +3,31 @@ import pytest from unittest.mock import Mock, patch +from sap_cloud_sdk.destination._local_client_base import ( + DESTINATION_MOCK_FILE, + FRAGMENT_MOCK_FILE, + CERTIFICATE_MOCK_FILE, +) from sap_cloud_sdk.destination import create_client, create_fragment_client, create_certificate_client from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient from sap_cloud_sdk.destination.config import DestinationConfig from sap_cloud_sdk.destination.exceptions import ClientCreationError +_NO_MOCK_FILE = patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) + class TestCreateClient: - """Tests for create_client factory function.""" + """Tests for create_client cloud mode.""" + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_with_explicit_config(self, mock_http, mock_token_provider): - """Test creating client with explicit configuration.""" config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -25,138 +35,129 @@ def test_create_client_with_explicit_config(self, mock_http, mock_token_provider client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client(config=config) - - # Verify assert isinstance(client, DestinationClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - """Test creating client in cloud mode with default configuration.""" - mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client() - - # Verify assert isinstance(client, DestinationClient) mock_load_config.assert_called_once_with(None) mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating client in cloud mode with custom instance name.""" mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_client(instance="custom-instance") - - # Verify assert isinstance(client, DestinationClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_client() - assert "failed to create destination client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_client_token_provider_error(self, mock_token_provider, mock_load_config): - """Test that token provider errors are wrapped in ClientCreationError.""" - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_client() - assert "failed to create destination client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_client() - assert "failed to create destination client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) +class TestCreateClientLocalMode: + """Tests for create_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_client() + assert isinstance(client, LocalDevDestinationClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_client() + mock_logger.warning.assert_called_once() + assert "local" in mock_logger.warning.call_args[0][0].lower() + assert "production" in mock_logger.warning.call_args[0][0].lower() + + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) + def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + client = create_client() + assert isinstance(client, DestinationClient) + + class TestCreateFragmentClient: - """Tests for create_fragment_client factory function.""" + """Tests for create_fragment_client cloud mode.""" + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_default(self, mock_http, mock_token_provider, mock_load_config): - """Test creating fragment client with default configuration.""" - # Setup mocks mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client() - - # Verify assert isinstance(client, FragmentClient) mock_load_config.assert_called_once_with(None) mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token_provider): - """Test creating fragment client with explicit configuration.""" - # Setup config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -164,98 +165,99 @@ def test_create_fragment_client_with_explicit_config(self, mock_http, mock_token client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client(config=config) - - # Verify assert isinstance(client, FragmentClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating fragment client with custom instance name.""" - # Setup mocks mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_fragment_client(instance="custom-instance") - - # Verify assert isinstance(client, FragmentClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_fragment_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" - # Setup mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() - assert "failed to create fragment client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_fragment_client_token_provider_error(self, mock_token_provider, mock_load_config): - """Test that token provider errors are wrapped in ClientCreationError.""" - # Setup - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() - assert "failed to create fragment client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_fragment_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - # Setup - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_fragment_client() - assert "failed to create fragment client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) +class TestCreateFragmentClientLocalMode: + """Tests for create_fragment_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_fragment_client() + assert isinstance(client, LocalDevFragmentClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_fragment_client() + mock_logger.warning.assert_called_once() + assert "local" in mock_logger.warning.call_args[0][0].lower() + assert "production" in mock_logger.warning.call_args[0][0].lower() + + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) + def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + client = create_fragment_client() + assert isinstance(client, FragmentClient) + + class TestCreateCertificateClient: - """Tests for create_certificate_client factory function.""" + """Tests for create_certificate_client cloud mode.""" + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_with_explicit_config(self, mock_http, mock_token_provider): - """Test creating certificate client with explicit configuration.""" - config = DestinationConfig( url="https://destination.example.com", token_url="https://auth.example.com/oauth/token", @@ -263,106 +265,102 @@ def test_create_certificate_client_with_explicit_config(self, mock_http, mock_to client_secret="test-secret", identityzone="provider-zone" ) - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client(config=config) - - # Verify assert isinstance(client, CertificateClient) mock_token_provider.assert_called_once_with(config) - mock_http.assert_called_once_with(config=config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_cloud_mode_default(self, mock_http, mock_token_provider, mock_load_config): - """Test creating certificate client in cloud mode with default configuration.""" mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client() - - # Verify assert isinstance(client, CertificateClient) mock_load_config.assert_called_once_with(None) mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + mock_http.assert_called_once_with(config=mock_config, token_provider=mock_token_provider.return_value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_cloud_mode_with_instance_name(self, mock_http, mock_token_provider, mock_load_config): - """Test creating certificate client in cloud mode with custom instance name.""" - mock_config = Mock(spec=DestinationConfig) mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp - mock_http_instance = Mock() - mock_http.return_value = mock_http_instance - - # Call function + mock_token_provider.return_value = Mock() + mock_http.return_value = Mock() client = create_certificate_client(instance="custom-instance") - - # Verify assert isinstance(client, CertificateClient) mock_load_config.assert_called_once_with("custom-instance") - mock_token_provider.assert_called_once_with(mock_config) - mock_http.assert_called_once_with(config=mock_config, token_provider=mock_tp) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") def test_create_certificate_client_config_error(self, mock_load_config): - """Test that configuration errors are wrapped in ClientCreationError.""" - mock_load_config.side_effect = Exception("Config loading failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() - assert "failed to create certificate client" in str(exc_info.value) assert "Config loading failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") def test_create_certificate_client_token_provider_error(self, mock_token_provider, mock_load_config): - """Test that token provider errors are wrapped in ClientCreationError.""" - - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config + mock_load_config.return_value = Mock(spec=DestinationConfig) mock_token_provider.side_effect = Exception("Token provider failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() - assert "failed to create certificate client" in str(exc_info.value) assert "Token provider failed" in str(exc_info.value) + @_NO_MOCK_FILE @patch("sap_cloud_sdk.destination.load_from_env_or_mount") @patch("sap_cloud_sdk.destination.TokenProvider") @patch("sap_cloud_sdk.destination.DestinationHttp") def test_create_certificate_client_http_error(self, mock_http, mock_token_provider, mock_load_config): - """Test that HTTP client errors are wrapped in ClientCreationError.""" - - mock_config = Mock(spec=DestinationConfig) - mock_load_config.return_value = mock_config - mock_tp = Mock() - mock_token_provider.return_value = mock_tp + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_token_provider.return_value = Mock() mock_http.side_effect = Exception("HTTP client failed") - - # Call and verify with pytest.raises(ClientCreationError) as exc_info: create_certificate_client() - assert "failed to create certificate client" in str(exc_info.value) assert "HTTP client failed" in str(exc_info.value) + + +class TestCreateCertificateClientLocalMode: + """Tests for create_certificate_client local mock mode detection.""" + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_returns_local_client_when_mock_file_exists(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + client = create_certificate_client() + assert isinstance(client, LocalDevCertificateClient) + + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + def test_logs_warning_in_local_mode(self, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + with patch("sap_cloud_sdk.destination.logger") as mock_logger: + create_certificate_client() + mock_logger.warning.assert_called_once() + assert "local" in mock_logger.warning.call_args[0][0].lower() + assert "production" in mock_logger.warning.call_args[0][0].lower() + + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + @patch("sap_cloud_sdk.destination.load_from_env_or_mount") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: False) + def test_falls_through_to_cloud_when_no_mock_file(self, mock_load_config, mock_http, mock_tp): + mock_load_config.return_value = Mock(spec=DestinationConfig) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + client = create_certificate_client() + assert isinstance(client, CertificateClient) diff --git a/tests/destination/unit/test_local_certificate_client.py b/tests/destination/unit/test_local_certificate_client.py new file mode 100644 index 0000000..f8859c8 --- /dev/null +++ b/tests/destination/unit/test_local_certificate_client.py @@ -0,0 +1,282 @@ +"""Unit tests for LocalDevCertificateClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_certificate_client import LocalDevCertificateClient +from sap_cloud_sdk.destination._local_client_base import CERTIFICATE_MOCK_FILE +from sap_cloud_sdk.destination._models import AccessStrategy, Certificate, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevCertificateClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevCertificateClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / CERTIFICATE_MOCK_FILE + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceCertificate: + def test_returns_certificate_when_found(self, client): + _write_store(client, {"instance": [{"Name": "cert.pem", "Content": "abc", "Type": "PEM"}], "subaccount": []}) + result = client.get_instance_certificate("cert.pem") + assert result is not None + assert result.name == "cert.pem" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_certificate("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + # Certificates stored with lowercase "name" key (alt_name_field) + _write_store(client, {"instance": [{"name": "cert.pem", "Content": "abc"}], "subaccount": []}) + result = client.get_instance_certificate("cert.pem") + assert result is not None + assert result.name == "cert.pem" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"Name": "sub-cert.pem", "Content": "abc"}], + }) + assert client.get_instance_certificate("sub-cert.pem") is None + + +class TestGetSubaccountCertificate: + def test_provider_only_returns_provider_certificate(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "cert.pem" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"Name": "cert.pem", "Content": "abc", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_certificate("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_certificate("cert.pem", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + result = client.get_subaccount_certificate("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceCertificates: + def test_returns_paged_result(self, client): + _write_store(client, {"instance": [ + {"Name": "cert1.pem", "Content": "c1"}, + {"Name": "cert2.jks", "Content": "c2"}, + ], "subaccount": []}) + result = client.list_instance_certificates() + assert isinstance(result, PagedResult) + assert len(result.items) == 2 + assert all(isinstance(c, Certificate) for c in result.items) + + def test_pagination_is_always_none(self, client): + assert client.list_instance_certificates().pagination is None + + def test_returns_empty_for_empty_store(self, client): + result = client.list_instance_certificates() + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + def test_filter_param_is_accepted_and_ignored(self, client): + _write_store(client, {"instance": [{"Name": "cert.pem", "Content": "c1"}], "subaccount": []}) + result = client.list_instance_certificates(_filter=object()) + assert len(result.items) == 1 + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"Name": "inst.pem", "Content": "c1"}], + "subaccount": [{"Name": "sub.pem", "Content": "c2"}], + }) + result = client.list_instance_certificates() + assert len(result.items) == 1 + assert result.items[0].name == "inst.pem" + + +class TestListSubaccountCertificates: + def test_returns_paged_result(self, client): + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, PagedResult) + assert result.pagination is None + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"Name": "prov.pem", "Content": "c1"}, + {"Name": "sub.pem", "Content": "c2", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_ONLY) + assert len(result.items) == 1 + assert result.items[0].name == "prov.pem" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"Name": "prov.pem", "Content": "c1"}, + {"Name": "sub-t1.pem", "Content": "c2", "tenant": "t1"}, + {"Name": "sub-t2.pem", "Content": "c3", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub-t1.pem" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"Name": "prov.pem", "Content": "c1"}], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"Name": "sub.pem", "Content": "c1", "tenant": "t1"}], "instance": []}) + result = client.list_subaccount_certificates(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_both_empty_returns_empty_paged_result(self, client): + result = client.list_subaccount_certificates(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_certificates(strategy, tenant=None) + + +class TestCreateCertificate: + def test_create_instance_certificate(self, client): + cert = Certificate(name="new.pem", content="c1", type="PEM") + client.create_certificate(cert, Level.SERVICE_INSTANCE) + assert client.get_instance_certificate("new.pem") is not None + + def test_create_subaccount_certificate(self, client): + cert = Certificate(name="new.pem", content="c1", type="PEM") + client.create_certificate(cert, Level.SUB_ACCOUNT) + result = client.get_subaccount_certificate("new.pem", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="default.pem", content="c1")) + assert client.get_subaccount_certificate("default.pem", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + cert = Certificate(name="dup.pem", content="c1") + client.create_certificate(cert, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_certificate(cert, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + cert = Certificate(name="dup.pem", content="c1") + client.create_certificate(cert, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_certificate(cert, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + +class TestUpdateCertificate: + def test_update_instance_certificate(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1"), Level.SERVICE_INSTANCE) + client.update_certificate(Certificate(name="cert.pem", content="v2"), Level.SERVICE_INSTANCE) + result = client.get_instance_certificate("cert.pem") + assert result.content == "v2" + + def test_update_subaccount_certificate(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1"), Level.SUB_ACCOUNT) + client.update_certificate(Certificate(name="cert.pem", content="v2"), Level.SUB_ACCOUNT) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result.content == "v2" + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="cert.pem", content="v1")) + client.update_certificate(Certificate(name="cert.pem", content="v2")) + result = client.get_subaccount_certificate("cert.pem", AccessStrategy.PROVIDER_ONLY) + assert result.content == "v2" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_certificate(Certificate(name="ghost.pem", content="c"), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_certificate(Certificate(name="ghost.pem", content="c"), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteCertificate: + def test_delete_instance_certificate(self, client): + client.create_certificate(Certificate(name="del.pem", content="c"), Level.SERVICE_INSTANCE) + client.delete_certificate("del.pem", Level.SERVICE_INSTANCE) + assert client.get_instance_certificate("del.pem") is None + + def test_delete_subaccount_certificate(self, client): + client.create_certificate(Certificate(name="del.pem", content="c"), Level.SUB_ACCOUNT) + client.delete_certificate("del.pem", Level.SUB_ACCOUNT) + assert client.get_subaccount_certificate("del.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_default_level_is_subaccount(self, client): + client.create_certificate(Certificate(name="del.pem", content="c")) + client.delete_certificate("del.pem") + assert client.get_subaccount_certificate("del.pem", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_certificate("ghost.pem", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_certificate("ghost.pem", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 diff --git a/tests/destination/unit/test_local_destination_client.py b/tests/destination/unit/test_local_destination_client.py new file mode 100644 index 0000000..268a561 --- /dev/null +++ b/tests/destination/unit/test_local_destination_client.py @@ -0,0 +1,350 @@ +"""Unit tests for LocalDevDestinationClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient +from sap_cloud_sdk.destination._local_client_base import DESTINATION_MOCK_FILE +from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Level +from sap_cloud_sdk.destination.utils._pagination import PagedResult +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevDestinationClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevDestinationClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / DESTINATION_MOCK_FILE + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceDestination: + def test_returns_destination_when_found(self, client): + _write_store(client, {"instance": [{"name": "destA", "type": "HTTP"}], "subaccount": []}) + result = client.get_instance_destination("destA") + assert result is not None + assert result.name == "destA" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_destination("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + _write_store(client, {"instance": [{"Name": "destB", "type": "HTTP"}], "subaccount": []}) + result = client.get_instance_destination("destB") + assert result is not None + assert result.name == "destB" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"name": "sub-dest", "type": "HTTP"}], + }) + assert client.get_instance_destination("sub-dest") is None + + +class TestGetSubaccountDestination: + def test_provider_only_returns_provider_destination(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP"}], "instance": []}) + result = client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "prov" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"name": "dest", "type": "HTTP", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + assert result.name == "dest" + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"name": "dest", "type": "HTTP", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_returns_subscriber_when_present(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1"}, + {"name": "dest", "type": "HTTP"}, + ], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + # Should prefer subscriber entry (has tenant) + assert result is not None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"name": "prov", "type": "HTTP"}], "instance": []}) + result = client.get_subaccount_destination("prov", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + assert result.name == "prov" + + def test_provider_first_returns_provider_when_present(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP"}, + {"name": "dest", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"name": "sub", "type": "HTTP", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_destination("sub", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + assert result.name == "sub" + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_destination("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_destination("d", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + # Should not raise even without tenant + result = client.get_subaccount_destination("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceDestinations: + def test_returns_paged_result(self, client): + _write_store(client, {"instance": [ + {"name": "d1", "type": "HTTP"}, + {"name": "d2", "type": "HTTP"}, + ], "subaccount": []}) + result = client.list_instance_destinations() + assert isinstance(result, PagedResult) + assert len(result.items) == 2 + assert all(isinstance(d, Destination) for d in result.items) + + def test_pagination_is_always_none(self, client): + result = client.list_instance_destinations() + assert result.pagination is None + + def test_returns_empty_for_empty_store(self, client): + result = client.list_instance_destinations() + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + def test_filter_param_is_accepted_and_ignored(self, client): + _write_store(client, {"instance": [{"name": "d1", "type": "HTTP"}], "subaccount": []}) + result = client.list_instance_destinations(_filter=object()) + assert len(result.items) == 1 + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"name": "inst", "type": "HTTP"}], + "subaccount": [{"name": "sub", "type": "HTTP"}], + }) + result = client.list_instance_destinations() + assert len(result.items) == 1 + assert result.items[0].name == "inst" + + +class TestListSubaccountDestinations: + def test_returns_paged_result(self, client): + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, PagedResult) + assert result.pagination is None + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_ONLY) + assert len(result.items) == 1 + assert result.items[0].name == "prov" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + {"name": "sub-t1", "type": "HTTP", "tenant": "t1"}, + {"name": "sub-t2", "type": "HTTP", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub-t1" + + def test_subscriber_first_no_fallback_needed(self, client): + _write_store(client, {"subaccount": [ + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "prov" + + def test_provider_first_no_fallback_needed(self, client): + _write_store(client, {"subaccount": [ + {"name": "prov", "type": "HTTP"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [ + {"name": "sub", "type": "HTTP", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_destinations(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result.items) == 1 + assert result.items[0].name == "sub" + + def test_both_empty_returns_empty_paged_result(self, client): + result = client.list_subaccount_destinations(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert isinstance(result, PagedResult) + assert len(result.items) == 0 + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_destinations(strategy, tenant=None) + + +class TestCreateDestination: + def test_create_instance_destination(self, client): + client.create_destination(Destination(name="new", type="HTTP"), Level.SERVICE_INSTANCE) + assert client.get_instance_destination("new") is not None + + def test_create_subaccount_destination(self, client): + client.create_destination(Destination(name="new", type="HTTP"), Level.SUB_ACCOUNT) + result = client.get_subaccount_destination("new", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="default", type="HTTP")) + assert client.get_subaccount_destination("default", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + dest = Destination(name="dup", type="HTTP") + client.create_destination(dest, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_destination(dest, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + dest = Destination(name="dup", type="HTTP") + client.create_destination(dest, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_destination(dest, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + def test_subaccount_entry_has_no_tenant_field(self, client): + client.create_destination(Destination(name="prov", type="HTTP"), Level.SUB_ACCOUNT) + # A subscriber-access read should not find it (no tenant attached) + assert client.get_subaccount_destination("prov", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") is None + # But provider-access should find it + assert client.get_subaccount_destination("prov", AccessStrategy.PROVIDER_ONLY) is not None + + +class TestUpdateDestination: + def test_update_instance_destination(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1"), Level.SERVICE_INSTANCE) + client.update_destination(Destination(name="dest", type="HTTP", description="v2"), Level.SERVICE_INSTANCE) + result = client.get_instance_destination("dest") + assert result.description == "v2" + + def test_update_subaccount_destination(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1"), Level.SUB_ACCOUNT) + client.update_destination(Destination(name="dest", type="HTTP", description="v2"), Level.SUB_ACCOUNT) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) + assert result.description == "v2" + + def test_update_subaccount_preserves_tenant_field(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1", "description": "old"}, + ], "instance": []}) + client.update_destination(Destination(name="dest", type="HTTP", description="new"), Level.SUB_ACCOUNT) + # Tenant was preserved — entry is still accessible as a subscriber destination + result = client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + assert result.description == "new" + + def test_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="dest", type="HTTP", description="v1")) + client.update_destination(Destination(name="dest", type="HTTP", description="v2")) + result = client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) + assert result.description == "v2" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_destination(Destination(name="ghost", type="HTTP"), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_destination(Destination(name="ghost", type="HTTP"), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteDestination: + def test_delete_instance_destination(self, client): + client.create_destination(Destination(name="del", type="HTTP"), Level.SERVICE_INSTANCE) + client.delete_destination("del", Level.SERVICE_INSTANCE) + assert client.get_instance_destination("del") is None + + def test_delete_subaccount_provider_destination(self, client): + client.create_destination(Destination(name="del", type="HTTP"), Level.SUB_ACCOUNT) + client.delete_destination("del", Level.SUB_ACCOUNT) + assert client.get_subaccount_destination("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_default_level_is_subaccount(self, client): + client.create_destination(Destination(name="del", type="HTTP")) + client.delete_destination("del") + assert client.get_subaccount_destination("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_destination("ghost", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_destination("ghost", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + def test_delete_subaccount_only_removes_provider_entry(self, client): + _write_store(client, {"subaccount": [ + {"name": "dest", "type": "HTTP", "tenant": "t1"}, # subscriber + {"name": "dest", "type": "HTTP"}, # provider + ], "instance": []}) + client.delete_destination("dest", Level.SUB_ACCOUNT) + # Subscriber entry must remain + assert client.get_subaccount_destination("dest", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") is not None + # Provider entry is gone + assert client.get_subaccount_destination("dest", AccessStrategy.PROVIDER_ONLY) is None diff --git a/tests/destination/unit/test_local_fragment_client.py b/tests/destination/unit/test_local_fragment_client.py new file mode 100644 index 0000000..f017a84 --- /dev/null +++ b/tests/destination/unit/test_local_fragment_client.py @@ -0,0 +1,280 @@ +"""Unit tests for LocalDevFragmentClient.""" + +import json + +import pytest + +from sap_cloud_sdk.destination.local_fragment_client import LocalDevFragmentClient +from sap_cloud_sdk.destination._local_client_base import FRAGMENT_MOCK_FILE +from sap_cloud_sdk.destination._models import AccessStrategy, Fragment, Level +from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError + + +@pytest.fixture +def client(tmp_path, monkeypatch): + """Create a LocalDevFragmentClient backed by a temp directory.""" + monkeypatch.setattr( + "sap_cloud_sdk.destination._local_client_base.os.path.abspath", + lambda _: str(tmp_path), + ) + return LocalDevFragmentClient() + + +def _store_path(tmp_path): + return tmp_path / "mocks" / FRAGMENT_MOCK_FILE + + +def _write_store(client, data): + client._write(data) + + +class TestInit: + def test_creates_backing_file_on_init(self, client, tmp_path): + assert _store_path(tmp_path).exists() + + def test_initial_store_has_empty_collections(self, client, tmp_path): + data = json.loads(_store_path(tmp_path).read_text()) + assert data == {"subaccount": [], "instance": []} + + +class TestGetInstanceFragment: + def test_returns_fragment_when_found(self, client): + _write_store(client, {"instance": [{"FragmentName": "fragA", "URL": "https://example.com"}], "subaccount": []}) + result = client.get_instance_fragment("fragA") + assert result is not None + assert result.name == "fragA" + + def test_returns_none_when_not_found(self, client): + assert client.get_instance_fragment("nonexistent") is None + + def test_finds_by_alt_name_field(self, client): + # Fragments can be stored with lowercase "fragmentName" (alt_name_field) + _write_store(client, {"instance": [{"fragmentName": "fragB", "URL": "https://example.com"}], "subaccount": []}) + result = client.get_instance_fragment("fragB") + assert result is not None + assert result.name == "fragB" + + def test_ignores_subaccount_collection(self, client): + _write_store(client, { + "instance": [], + "subaccount": [{"FragmentName": "sub-frag", "URL": "https://example.com"}], + }) + assert client.get_instance_fragment("sub-frag") is None + + def test_returns_fragment_properties(self, client): + _write_store(client, {"instance": [ + {"FragmentName": "fragA", "URL": "https://example.com", "Authentication": "NoAuthentication"}, + ], "subaccount": []}) + result = client.get_instance_fragment("fragA") + assert result.properties["URL"] == "https://example.com" + assert result.properties["Authentication"] == "NoAuthentication" + + +class TestGetSubaccountFragment: + def test_provider_only_returns_provider_fragment(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://example.com"}], "instance": []}) + result = client.get_subaccount_fragment("prov", AccessStrategy.PROVIDER_ONLY) + assert result is not None + assert result.name == "prov" + + def test_provider_only_skips_subscriber_entries(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) is None + + def test_subscriber_only_returns_matching_tenant(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_fragment("frag", AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert result is not None + + def test_subscriber_only_returns_none_for_wrong_tenant(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "frag", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + assert client.get_subaccount_fragment("frag", AccessStrategy.SUBSCRIBER_ONLY, tenant="t2") is None + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://x.com"}], "instance": []}) + result = client.get_subaccount_fragment("prov", AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result is not None + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "sub", "URL": "https://x.com", "tenant": "t1"}], "instance": []}) + result = client.get_subaccount_fragment("sub", AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert result is not None + + def test_returns_none_when_not_found(self, client): + assert client.get_subaccount_fragment("ghost", AccessStrategy.PROVIDER_ONLY) is None + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.get_subaccount_fragment("frag", strategy, tenant=None) + + def test_provider_only_does_not_require_tenant(self, client): + result = client.get_subaccount_fragment("nonexistent", AccessStrategy.PROVIDER_ONLY) + assert result is None + + +class TestListInstanceFragments: + def test_returns_list_of_fragments(self, client): + _write_store(client, {"instance": [ + {"FragmentName": "f1", "URL": "https://a.com"}, + {"FragmentName": "f2", "URL": "https://b.com"}, + ], "subaccount": []}) + result = client.list_instance_fragments() + assert isinstance(result, list) + assert len(result) == 2 + assert all(isinstance(f, Fragment) for f in result) + + def test_returns_empty_list_for_empty_store(self, client): + result = client.list_instance_fragments() + assert result == [] + + def test_does_not_include_subaccount_entries(self, client): + _write_store(client, { + "instance": [{"FragmentName": "inst", "URL": "https://a.com"}], + "subaccount": [{"FragmentName": "sub", "URL": "https://b.com"}], + }) + result = client.list_instance_fragments() + assert len(result) == 1 + assert result[0].name == "inst" + + +class TestListSubaccountFragments: + def test_returns_list_of_fragments(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "f1", "URL": "https://a.com"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_ONLY) + assert isinstance(result, list) + assert len(result) == 1 + + def test_provider_only_returns_only_provider_entries(self, client): + _write_store(client, {"subaccount": [ + {"FragmentName": "prov", "URL": "https://a.com"}, + {"FragmentName": "sub", "URL": "https://b.com", "tenant": "t1"}, + ], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_ONLY) + assert len(result) == 1 + assert result[0].name == "prov" + + def test_subscriber_only_returns_only_matching_tenant(self, client): + _write_store(client, {"subaccount": [ + {"FragmentName": "prov", "URL": "https://a.com"}, + {"FragmentName": "sub-t1", "URL": "https://b.com", "tenant": "t1"}, + {"FragmentName": "sub-t2", "URL": "https://c.com", "tenant": "t2"}, + ], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_ONLY, tenant="t1") + assert len(result) == 1 + assert result[0].name == "sub-t1" + + def test_subscriber_first_falls_back_to_provider(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "prov", "URL": "https://a.com"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert len(result) == 1 + + def test_provider_first_falls_back_to_subscriber(self, client): + _write_store(client, {"subaccount": [{"FragmentName": "sub", "URL": "https://a.com", "tenant": "t1"}], "instance": []}) + result = client.list_subaccount_fragments(AccessStrategy.PROVIDER_FIRST, tenant="t1") + assert len(result) == 1 + + def test_both_empty_returns_empty_list(self, client): + result = client.list_subaccount_fragments(AccessStrategy.SUBSCRIBER_FIRST, tenant="t1") + assert result == [] + + @pytest.mark.parametrize("strategy", [ + AccessStrategy.SUBSCRIBER_ONLY, + AccessStrategy.SUBSCRIBER_FIRST, + AccessStrategy.PROVIDER_FIRST, + ]) + def test_requires_tenant_for_subscriber_strategies(self, client, strategy): + with pytest.raises(DestinationOperationError, match="tenant subdomain must be provided"): + client.list_subaccount_fragments(strategy, tenant=None) + + +class TestCreateFragment: + def test_create_instance_fragment(self, client): + frag = Fragment(name="new", properties={"URL": "https://example.com"}) + client.create_fragment(frag, Level.SERVICE_INSTANCE) + assert client.get_instance_fragment("new") is not None + + def test_create_subaccount_fragment(self, client): + frag = Fragment(name="new", properties={"URL": "https://example.com"}) + client.create_fragment(frag, Level.SUB_ACCOUNT) + result = client.get_subaccount_fragment("new", AccessStrategy.PROVIDER_ONLY) + assert result is not None + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="default", properties={})) + assert client.get_subaccount_fragment("default", AccessStrategy.PROVIDER_ONLY) is not None + + def test_create_duplicate_instance_raises_409(self, client): + frag = Fragment(name="dup", properties={}) + client.create_fragment(frag, Level.SERVICE_INSTANCE) + with pytest.raises(HttpError) as exc_info: + client.create_fragment(frag, Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 409 + + def test_create_duplicate_subaccount_raises_409(self, client): + frag = Fragment(name="dup", properties={}) + client.create_fragment(frag, Level.SUB_ACCOUNT) + with pytest.raises(HttpError) as exc_info: + client.create_fragment(frag, Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 409 + + +class TestUpdateFragment: + def test_update_instance_fragment(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"}), Level.SERVICE_INSTANCE) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"}), Level.SERVICE_INSTANCE) + result = client.get_instance_fragment("frag") + assert result.properties["URL"] == "https://new.com" + + def test_update_subaccount_fragment(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"}), Level.SUB_ACCOUNT) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"}), Level.SUB_ACCOUNT) + result = client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) + assert result.properties["URL"] == "https://new.com" + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="frag", properties={"URL": "https://old.com"})) + client.update_fragment(Fragment(name="frag", properties={"URL": "https://new.com"})) + result = client.get_subaccount_fragment("frag", AccessStrategy.PROVIDER_ONLY) + assert result.properties["URL"] == "https://new.com" + + def test_update_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_fragment(Fragment(name="ghost", properties={}), Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_update_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.update_fragment(Fragment(name="ghost", properties={}), Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404 + + +class TestDeleteFragment: + def test_delete_instance_fragment(self, client): + client.create_fragment(Fragment(name="del", properties={}), Level.SERVICE_INSTANCE) + client.delete_fragment("del", Level.SERVICE_INSTANCE) + assert client.get_instance_fragment("del") is None + + def test_delete_subaccount_fragment(self, client): + client.create_fragment(Fragment(name="del", properties={}), Level.SUB_ACCOUNT) + client.delete_fragment("del", Level.SUB_ACCOUNT) + assert client.get_subaccount_fragment("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_default_level_is_subaccount(self, client): + client.create_fragment(Fragment(name="del", properties={})) + client.delete_fragment("del") + assert client.get_subaccount_fragment("del", AccessStrategy.PROVIDER_ONLY) is None + + def test_delete_missing_instance_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_fragment("ghost", Level.SERVICE_INSTANCE) + assert exc_info.value.status_code == 404 + + def test_delete_missing_subaccount_raises_404(self, client): + with pytest.raises(HttpError) as exc_info: + client.delete_fragment("ghost", Level.SUB_ACCOUNT) + assert exc_info.value.status_code == 404