diff --git a/.env_integration_tests.example b/.env_integration_tests.example index d0a1678..b73fe86 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -13,3 +13,6 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET=your-destination-client-secret-he CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL=https://your-destination-auth-url-here CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=https://your-destination-configuration-uri-here CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here + +CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here +CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}' diff --git a/README.md b/README.md index 60f083e..f416656 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **AI Core Integration** - **Audit Log Service** - **Destination Service** +- **Document Management Service** - **ObjectStore Service** - **Secret Resolver** - **Telemetry & Observability** @@ -49,6 +50,7 @@ Each module has comprehensive usage guides: - [AuditLog](src/sap_cloud_sdk/core/auditlog/user-guide.md) - [Destination](src/sap_cloud_sdk/destination/user-guide.md) +- [DMS](src/sap_cloud_sdk/dms/user-guide.md) - [ObjectStore](src/sap_cloud_sdk/objectstore/user-guide.md) - [Secret Resolver](src/sap_cloud_sdk/core/secret_resolver/user-guide.md) - [Telemetry](src/sap_cloud_sdk/core/telemetry/user-guide.md) diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index b7d592b..6abb913 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -10,6 +10,7 @@ class Module(str, Enum): AUDITLOG = "auditlog" DESTINATION = "destination" OBJECTSTORE = "objectstore" + DMS = "dms" def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index f640953..09c64da 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -52,5 +52,34 @@ class Operation(str, Enum): AICORE_SET_CONFIG = "set_aicore_config" AICORE_AUTO_INSTRUMENT = "auto_instrument" + # DMS Operations + DMS_ONBOARD_REPOSITORY = "onboard_repository" + DMS_GET_REPOSITORY = "get_repository" + DMS_GET_ALL_REPOSITORIES = "get_all_repositories" + DMS_UPDATE_REPOSITORY = "update_repository" + DMS_DELETE_REPOSITORY = "delete_repository" + DMS_CREATE_CONFIG = "create_config" + DMS_GET_CONFIGS = "get_configs" + DMS_UPDATE_CONFIG = "update_config" + DMS_DELETE_CONFIG = "delete_config" + + # DMS CMIS Operations + DMS_CREATE_FOLDER = "create_folder" + DMS_CREATE_DOCUMENT = "create_document" + DMS_CHECK_OUT = "check_out" + DMS_CHECK_IN = "check_in" + DMS_CANCEL_CHECK_OUT = "cancel_check_out" + DMS_APPLY_ACL = "apply_acl" + DMS_GET_OBJECT = "get_object" + DMS_GET_CONTENT = "get_content" + DMS_UPDATE_PROPERTIES = "update_properties" + DMS_GET_CHILDREN = "get_children" + # Value is "delete_cmis_object" (not "delete_object") to avoid collision + # with OBJECTSTORE_DELETE_OBJECT which already uses "delete_object". + DMS_DELETE_OBJECT = "delete_cmis_object" + DMS_RESTORE_OBJECT = "restore_object" + DMS_APPEND_CONTENT_STREAM = "append_content_stream" + DMS_CMIS_QUERY = "cmis_query" + def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py new file mode 100644 index 0000000..33fdf2f --- /dev/null +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -0,0 +1,100 @@ +"""SAP Cloud SDK for Python - Document Management Service (DMS) module + +The create_client() function loads credentials from mounts/env vars and points +to an instance in the cloud. + +Usage: + from sap_cloud_sdk.dms import create_client + + # Recommended: use the factory which configures OAuth/HTTP from environment + client = create_client() + + # Or specify a named instance + client = create_client(instance="my-sdm-instance") + + # List all onboarded repositories + repos = client.get_all_repositories() + + # Create a folder + folder = client.create_folder(repo.id, root_folder_id, "MyFolder") + + # Upload a document + with open("file.pdf", "rb") as f: + doc = client.create_document( + repo.id, folder.object_id, "file.pdf", f, mime_type="application/pdf" + ) +""" + +from typing import Optional + +from sap_cloud_sdk.core.telemetry import Module +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenOptions, + ChildrenPage, + CmisObject, + DMSCredentials, + Document, + Folder, + QueryOptions, + QueryResultPage, + UserClaim, +) +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount +from sap_cloud_sdk.dms.exceptions import DMSError + + +def create_client( + *, + instance: Optional[str] = None, + dms_cred: Optional[DMSCredentials] = None, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + _telemetry_source: Optional[Module] = None, +): + """Create a DMS client with automatic credential resolution. + + Args: + instance: Logical instance name for secret resolution. Defaults to ``"default"``. + dms_cred: Explicit credentials. If provided, skips secret resolution. + connect_timeout: TCP connection timeout in seconds. Defaults to 10. + read_timeout: Response read timeout in seconds. Defaults to 30. + _telemetry_source: Internal telemetry source identifier. Not intended for external use. + + Returns: + DMSClient: Configured client ready to use. + + Raises: + DMSError: If client creation fails due to configuration or initialization issues. + """ + try: + credentials = dms_cred or load_sdm_config_from_env_or_mount(instance) + client = DMSClient( + credentials, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + ) + client._telemetry_source = _telemetry_source + return client + except Exception as e: + raise DMSError(f"Failed to create DMS client: {e}") from e + + +__all__ = [ + "create_client", + "Ace", + "Acl", + "ChildrenOptions", + "ChildrenPage", + "CmisObject", + "DMSClient", + "DMSCredentials", + "DMSError", + "Document", + "Folder", + "QueryOptions", + "QueryResultPage", + "UserClaim", +] diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py new file mode 100644 index 0000000..767f775 --- /dev/null +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -0,0 +1,109 @@ +import logging +import time +import requests +from collections import OrderedDict +from requests.exceptions import RequestException +from typing import Optional, TypedDict +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSConnectionError, + DMSPermissionDeniedException, +) +from sap_cloud_sdk.dms.model import DMSCredentials + +logger = logging.getLogger(__name__) + + +class _TokenResponse(TypedDict): + access_token: str + expires_in: int + + +class _CachedToken: + def __init__(self, token: str, expires_at: float) -> None: + self.token = token + self.expires_at = expires_at + + def is_valid(self) -> bool: + return time.monotonic() < self.expires_at - 30 + + +_MAX_CACHE_SIZE = 10 + + +class Auth: + """Fetches and caches OAuth2 access tokens for DMS service requests.""" + + def __init__(self, credentials: DMSCredentials) -> None: + self._credentials = credentials + self._cache: OrderedDict[str, _CachedToken] = OrderedDict() + + def get_token(self, tenant_subdomain: Optional[str] = None) -> str: + cache_key = tenant_subdomain or "technical" + + cached = self._cache.get(cache_key) + if cached and cached.is_valid(): + self._cache.move_to_end(cache_key) # Mark as recently used by moving to end + logger.debug("Using cached token for key '%s'", cache_key) + return cached.token + + logger.debug("Fetching new token for key '%s'", cache_key) + token_url = self._resolve_token_url(tenant_subdomain) + token = self._fetch_token(token_url) + + if len(self._cache) >= _MAX_CACHE_SIZE: + evicted, _ = self._cache.popitem(last=False) + logger.debug("Cache full — evicted token for key '%s'", evicted) + + self._cache[cache_key] = _CachedToken( + token=token["access_token"], + expires_at=time.monotonic() + token.get("expires_in", 3600), + ) + logger.debug("Token cached for key '%s'", cache_key) + return self._cache[cache_key].token + + def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str: + if not tenant_subdomain: + return self._credentials.token_url + logger.debug("Resolving token URL for tenant '%s'", tenant_subdomain) + return self._credentials.token_url.replace( + self._credentials.identityzone, + tenant_subdomain, + ) + + def _fetch_token(self, token_url: str) -> _TokenResponse: + try: + response = requests.post( + f"{token_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self._credentials.client_id, + "client_secret": self._credentials.client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + response.raise_for_status() + except requests.exceptions.ConnectionError as e: + logger.error("Failed to connect to token endpoint") + raise DMSConnectionError( + "Failed to connect to the authentication server" + ) from e + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else None + logger.error("Token request failed with status %s", status) + if status in (401, 403): + raise DMSPermissionDeniedException( + "Authentication failed — invalid client credentials", status + ) from e + raise DMSError("Failed to obtain access token", status) from e + except RequestException as e: + logger.error("Unexpected error during token fetch") + raise DMSConnectionError("Unexpected error during authentication") from e + + payload: _TokenResponse = response.json() + if not payload.get("access_token"): + raise DMSError("Token response missing access_token") + + logger.debug("Token fetched successfully") + return payload diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py new file mode 100644 index 0000000..9b4f877 --- /dev/null +++ b/src/sap_cloud_sdk/dms/_http.py @@ -0,0 +1,277 @@ +import logging +from typing import Any, Optional +from requests import Response +import requests +from requests.exceptions import RequestException +from sap_cloud_sdk.dms._auth import Auth +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSConflictException, + DMSConnectionError, + DMSInvalidArgumentException, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSRuntimeException, +) +from sap_cloud_sdk.dms.model import UserClaim + +logger = logging.getLogger(__name__) + + +class HttpInvoker: + """Low-level HTTP layer. Injects auth headers and enforces timeouts.""" + + def __init__( + self, + auth: Auth, + base_url: str, + connect_timeout: int | None = None, + read_timeout: int | None = None, + ) -> None: + self._auth = auth + self._base_url = base_url.rstrip("/") + self._connect_timeout = connect_timeout or 10 + self._read_timeout = read_timeout or 30 + + def get( + self, + path: str, + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, + params: Optional[dict[str, str]] = None, + ) -> Response: + logger.debug("GET %s", path) + return self._handle( + self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + params=params, + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def post( + self, + path: str, + payload: dict[str, Any], + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + logger.debug("POST %s", path) + return self._handle( + self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def put( + self, + path: str, + payload: dict[str, Any], + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + logger.debug("PUT %s", path) + return self._handle( + self._execute( + lambda: requests.put( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def delete( + self, + path: str, + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + logger.debug("DELETE %s", path) + return self._handle( + self._execute( + lambda: requests.delete( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def post_form( + self, + path: str, + *, + data: dict[str, str], + files: Optional[dict[str, Any]] = None, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """POST with form-encoded data and optional multipart file uploads. + + Does not set Content-Type — ``requests`` sets it automatically + to ``application/x-www-form-urlencoded`` or ``multipart/form-data``. + """ + logger.debug("POST_FORM %s", path) + return self._handle( + self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._auth_header(tenant_subdomain, user_claim), + data=data, + files=files, + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def get_stream( + self, + path: str, + *, + params: Optional[dict[str, str]] = None, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """GET that returns a raw streaming Response for binary content. + + The caller is responsible for closing the response. + On non-2xx status the usual typed exception is raised. + """ + logger.debug("GET_STREAM %s", path) + return self._handle( + self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, None, user_claim), + params=params, + stream=True, + timeout=(self._connect_timeout, self._read_timeout), + ) + ) + ) + + def _execute(self, fn: Any) -> Response: + """Execute an HTTP call, wrapping network errors into DMSConnectionError.""" + try: + return fn() + except requests.exceptions.ConnectionError as e: + logger.error("Connection error during HTTP request") + raise DMSConnectionError("Failed to connect to the DMS service") from e + except requests.exceptions.Timeout as e: + logger.error("Request timed out") + raise DMSConnectionError("Request to DMS service timed out") from e + except RequestException as e: + logger.error("Unexpected network error") + raise DMSConnectionError("Unexpected network error") from e + + def _auth_header( + self, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> dict[str, str]: + """Auth-only headers (no Content-Type). Used by post_form.""" + return { + "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", + **self._user_claim_headers(user_claim), + } + + def _default_headers( + self, tenant_subdomain: Optional[str] = None + ) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _user_claim_headers(self, user_claim: Optional[UserClaim]) -> dict[str, str]: + if not user_claim: + return {} + headers: dict[str, str] = {} + if user_claim.x_ecm_user_enc: + headers["X-EcmUserEnc"] = user_claim.x_ecm_user_enc + if user_claim.x_ecm_add_principals: + headers["X-EcmAddPrincipals"] = ";".join(user_claim.x_ecm_add_principals) + return headers + + def _merged_headers( + self, + tenant_subdomain: Optional[str], + overrides: Optional[dict[str, str]], + user_claim: Optional[UserClaim] = None, + ) -> dict[str, str]: + return { + **self._default_headers(tenant_subdomain), + **self._user_claim_headers(user_claim), + **(overrides or {}), + } + + def _handle(self, response: Response) -> Response: + logger.debug("Response status: %s", response.status_code) + if response.status_code in (200, 201, 204): + return response + + # error_content kept for debugging but not surfaced in the exception message + error_content = response.text + logger.warning("Request failed with status %s", response.status_code) + + # Try to extract the server's error message from the JSON body + try: + body = response.json() + server_message = body.get("message", "") if isinstance(body, dict) else "" + except Exception: + server_message = "" + + match response.status_code: + case 400: + raise DMSInvalidArgumentException( + server_message + or "Request contains invalid or disallowed parameters", + 400, + error_content, + ) + case 401 | 403: + raise DMSPermissionDeniedException( + server_message or "Access denied — invalid or expired token", + response.status_code, + error_content, + ) + case 404: + raise DMSObjectNotFoundException( + server_message or "The requested resource was not found", + 404, + error_content, + ) + case 409: + raise DMSConflictException( + server_message + or "The request conflicts with the current state of the resource", + 409, + error_content, + ) + case 500: + raise DMSRuntimeException( + server_message or "The DMS service encountered an internal error", + 500, + error_content, + ) + case _: + raise DMSError( + f"Unexpected response from DMS service: {error_content}", + response.status_code, + error_content, + ) diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py new file mode 100644 index 0000000..0f1c4ab --- /dev/null +++ b/src/sap_cloud_sdk/dms/client.py @@ -0,0 +1,1212 @@ +import logging +from typing import BinaryIO, Dict, List, Optional, Union +from requests import Response +from sap_cloud_sdk.dms.model import ( + DMSCredentials, + InternalRepoRequest, + Repository, + UserClaim, + UpdateRepoRequest, + CreateConfigRequest, + RepositoryConfig, + UpdateConfigRequest, + Ace, + Acl, + ChildrenOptions, + ChildrenPage, + CmisObject, + Document, + Folder, + QueryOptions, + QueryResultPage, + _prop_val, +) +from sap_cloud_sdk.dms._auth import Auth +from sap_cloud_sdk.dms._http import HttpInvoker +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Admin API endpoint paths +# --------------------------------------------------------------------------- +_REPOSITORIES = "/rest/v2/repositories" +_CONFIGS = "/rest/v2/configs" +# --------------------------------------------------------------------------- + + +def _build_properties(props: Dict[str, str]) -> Dict[str, str]: + """Encode CMIS properties into indexed form fields. + + ``{"cmis:name": "Doc"} → {"propertyId[0]": "cmis:name", "propertyValue[0]": "Doc"}`` + """ + form: Dict[str, str] = {} + for idx, (key, val) in enumerate(props.items()): + form[f"propertyId[{idx}]"] = key + form[f"propertyValue[{idx}]"] = str(val) + return form + + +def _build_aces(aces: List[Ace], prefix: str) -> Dict[str, str]: + """Encode ACE entries into indexed CMIS form fields. + + *prefix* is ``addACEPrincipal`` or ``removeACEPrincipal``. + """ + perm_prefix = prefix.replace("Principal", "Permission") + form: Dict[str, str] = {} + for i, ace in enumerate(aces): + form[f"{prefix}[{i}]"] = ace.principal_id + for j, perm in enumerate(ace.permissions): + form[f"{perm_prefix}[{i}][{j}]"] = perm + return form + + +class DMSClient: + """Client for the SAP Document Management Service (DMS). + + Provides methods for: + - **Admin API**: Onboard, list, update, and delete repositories; + manage repository configurations. + - **CMIS Browser Binding**: Create folders and documents, manage + versions (check-out / check-in), apply ACLs, browse folder + contents, download document content, delete and restore objects, + append content streams, and execute CMIS queries. + + Use :func:`sap_cloud_sdk.dms.create_client` to obtain an instance + with automatic credential resolution, or construct directly with + explicit :class:`DMSCredentials`. + + Example:: + + from sap_cloud_sdk.dms import create_client + + client = create_client() # resolves from env / mounted secrets + repos = client.get_all_repositories() + """ + + def __init__( + self, + credentials: DMSCredentials, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + ) -> None: + """Initialise a DMSClient. + + Note: + Do not call this constructor directly. Use create_client() from + sap_cloud_sdk.dms instead, which properly configures + authentication and handles environment detection. + + Args: + credentials: OAuth2 credentials and service URI for the DMS instance. + connect_timeout: TCP connection timeout in seconds. Defaults to 10. + read_timeout: Response read timeout in seconds. Defaults to 30. + """ + auth = Auth(credentials) + self._http: HttpInvoker = HttpInvoker( + auth=auth, + base_url=credentials.uri, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + ) + self._telemetry_source: Optional[Module] = None + logger.debug( + "DMSClient initialized for instance '%s'", credentials.instance_name + ) + + @record_metrics(Module.DMS, Operation.DMS_ONBOARD_REPOSITORY) + def onboard_repository( + self, + request: InternalRepoRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Onboard a new internal repository. + + Args: + request: The repository creation request payload. + tenant: Optional tenant subdomain to scope the request. + user_claim: Optional user identity claims. + + Returns: + Repository: The created repository instance. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Onboarding repository '%s'", request.to_dict()) + response = self._http.post( + path=_REPOSITORIES, + payload={"repository": request.to_dict()}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + repo = Repository.from_dict(response.json()) + logger.info("Repository onboarded successfully with id '%s'", repo.id) + return repo + + @record_metrics(Module.DMS, Operation.DMS_GET_ALL_REPOSITORIES) + def get_all_repositories( + self, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> list[Repository]: + """Retrieve all onboarded repositories. + + Args: + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + list[Repository]: List of all repositories. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching all repositories") + response = self._http.get( + path=_REPOSITORIES, + tenant_subdomain=tenant, + user_claim=user_claim, + headers={"Accept": "application/vnd.sap.sdm.repositories+json;version=3"}, + ) + data = response.json() + infos = data.get("repoAndConnectionInfos", []) + repos = [Repository.from_dict(item["repository"]) for item in infos] + logger.info("Fetched %d repositories", len(repos)) + return repos + + @record_metrics(Module.DMS, Operation.DMS_GET_REPOSITORY) + def get_repository( + self, + repo_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Retrieve details of a specific repository. + + Args: + repo_id: The repository UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + Repository: The repository details. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching repository '%s'", repo_id) + response = self._http.get( + path=f"{_REPOSITORIES}/{repo_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Repository.from_dict(response.json()["repository"]) + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_REPOSITORY) + def update_repository( + self, + repo_id: str, + request: UpdateRepoRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Update metadata parameters of a repository. + + Args: + repo_id: The repository UUID. + request: The update request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + Repository: The updated repository. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not repo_id or not repo_id.strip(): + raise ValueError("repo_id must not be empty") + logger.info("Updating repository '%s'", repo_id) + response = self._http.put( + path=f"{_REPOSITORIES}/{repo_id}", + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + repo = Repository.from_dict(response.json()) + logger.info("Repository '%s' updated successfully", repo_id) + return repo + + @record_metrics(Module.DMS, Operation.DMS_DELETE_REPOSITORY) + def delete_repository( + self, + repo_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a specific repository. + + Args: + repo_id: The repository UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + self._http.delete( + path=f"{_REPOSITORIES}/{repo_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + @record_metrics(Module.DMS, Operation.DMS_CREATE_CONFIG) + def create_config( + self, + request: CreateConfigRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> RepositoryConfig: + """Create a new repository configuration. + + Args: + request: The config creation request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + RepositoryConfig: The created configuration. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Creating config '%s'", request.config_name) + response = self._http.post( + path=_CONFIGS, + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + config = RepositoryConfig.from_dict(response.json()) + logger.info("Config created successfully with id '%s'", config.id) + return config + + @record_metrics(Module.DMS, Operation.DMS_GET_CONFIGS) + def get_configs( + self, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> list[RepositoryConfig]: + """Retrieve all repository configurations. + + Args: + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + list[RepositoryConfig]: List of all configurations. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching all configs") + response = self._http.get( + path=_CONFIGS, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + configs = [RepositoryConfig.from_dict(c) for c in response.json()] + logger.info("Fetched %d configs", len(configs)) + return configs + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_CONFIG) + def update_config( + self, + config_id: str, + request: UpdateConfigRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> RepositoryConfig: + """Update a repository configuration. + + Args: + config_id: The configuration UUID. + request: The update request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + RepositoryConfig: The updated configuration. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not config_id or not config_id.strip(): + raise ValueError("config_id must not be empty") + logger.info("Updating config '%s'", config_id) + response = self._http.put( + path=f"{_CONFIGS}/{config_id}", + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + config = RepositoryConfig.from_dict(response.json()) + logger.info("Config '%s' updated successfully", config_id) + return config + + @record_metrics(Module.DMS, Operation.DMS_DELETE_CONFIG) + def delete_config( + self, + config_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a repository configuration. + + Args: + config_id: The configuration UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Raises: + ValueError: If config_id is empty. + DMSObjectNotFoundException: If the config does not exist. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not config_id or not config_id.strip(): + raise ValueError("config_id must not be empty") + logger.info("Deleting config '%s'", config_id) + self._http.delete( + path=f"{_CONFIGS}/{config_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + # ================================================================== + # CMIS — helpers + # ================================================================== + + @staticmethod + def _browser_url(repository_id: str, path: Optional[str] = None) -> str: + base = f"/browser/{repository_id}/root" + if path: + return f"{base}/{path.lstrip('/')}" + return base + + # ================================================================== + # CMIS — folder operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CREATE_FOLDER) + def create_folder( + self, + repository_id: str, + parent_folder_id: str, + folder_name: str, + *, + description: Optional[str] = None, + path: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Folder: + """Create a new folder. + + Args: + repository_id: Target repository ID. + parent_folder_id: CMIS objectId of the parent folder. + folder_name: Name for the new folder. + description: Optional folder description. + path: Optional directory path (appended to /browser/{repo_id}/root/). + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Folder: The created folder. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSObjectNotFoundException: If the parent folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + cmis_props: Dict[str, str] = { + "cmis:name": folder_name, + "cmis:objectTypeId": "cmis:folder", + } + if description is not None: + cmis_props["cmis:description"] = description + + form_data: Dict[str, str] = { + "cmisaction": "createFolder", + "objectId": parent_folder_id, + "_charset_": "UTF-8", + } + form_data.update(_build_properties(cmis_props)) + + logger.info("Creating folder '%s' in repo '%s'", folder_name, repository_id) + response = self._http.post_form( + self._browser_url(repository_id, path), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Folder.from_dict(response.json()) + + # ================================================================== + # CMIS — document operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CREATE_DOCUMENT) + def create_document( + self, + repository_id: str, + parent_folder_id: str, + document_name: str, + file: BinaryIO, + *, + mime_type: Optional[str] = None, + description: Optional[str] = None, + path: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Create a new document with content. + + Args: + repository_id: Target repository ID. + parent_folder_id: Parent folder CMIS objectId. + document_name: File name for the document. + file: Readable binary stream with the content. + mime_type: MIME type (e.g. ``application/pdf``). Defaults to + ``application/octet-stream`` when not provided. + description: Optional document description. + path: Optional directory path. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Document: The created document. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSObjectNotFoundException: If the parent folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + cmis_props: Dict[str, str] = { + "cmis:name": document_name, + "cmis:objectTypeId": "cmis:document", + } + if description is not None: + cmis_props["cmis:description"] = description + + form_data: Dict[str, str] = { + "cmisaction": "createDocument", + "objectId": parent_folder_id, + "_charset_": "UTF-8", + } + form_data.update(_build_properties(cmis_props)) + + logger.info("Creating document '%s' in repo '%s'", document_name, repository_id) + response = self._http.post_form( + self._browser_url(repository_id, path), + data=form_data, + files={ + "media": (document_name, file, mime_type or "application/octet-stream") + }, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + # ================================================================== + # CMIS — versioning + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CHECK_OUT) + def check_out( + self, + repository_id: str, + document_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Check out a document, creating a Private Working Copy (PWC). + + Args: + repository_id: Target repository ID. + document_id: Document to check out. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Document: The Private Working Copy. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "checkOut", + "objectId": document_id, + "_charset_": "UTF-8", + } + logger.info( + "Checking out document '%s' in repo '%s'", document_id, repository_id + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + @record_metrics(Module.DMS, Operation.DMS_CHECK_IN) + def check_in( + self, + repository_id: str, + document_id: str, + *, + major: bool = True, + file: Optional[BinaryIO] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None, + checkin_comment: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Check in a Private Working Copy, creating a new version. + + Args: + repository_id: Target repository ID. + document_id: PWC object ID. + major: True for a major version, False for minor. + file: Optional updated content stream. + file_name: File name when providing new content. + mime_type: MIME type when providing new content. + checkin_comment: Optional version comment. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Document: The new document version. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "checkIn", + "objectId": document_id, + "major": str(major).lower(), + "_charset_": "UTF-8", + } + if checkin_comment is not None: + form_data["checkinComment"] = checkin_comment + + files = None + if file is not None: + files = { + "content": ( + file_name or "content", + file, + mime_type or "application/octet-stream", + ) + } + + logger.info( + "Checking in document '%s' in repo '%s'", document_id, repository_id + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + files=files, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + @record_metrics(Module.DMS, Operation.DMS_CANCEL_CHECK_OUT) + def cancel_check_out( + self, + repository_id: str, + document_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Cancel a check-out and discard the Private Working Copy. + + Args: + repository_id: Target repository ID. + document_id: PWC object ID. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "cancelCheckOut", + "objectId": document_id, + "_charset_": "UTF-8", + } + logger.info( + "Cancelling check-out for '%s' in repo '%s'", document_id, repository_id + ) + self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + # ================================================================== + # CMIS — ACL operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_APPLY_ACL) + def apply_acl( + self, + repository_id: str, + object_id: str, + *, + add_aces: Optional[List[Ace]] = None, + remove_aces: Optional[List[Ace]] = None, + acl_propagation: str = "propagate", + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Acl: + """Get, add, or remove access control entries on an object. + + When neither *add_aces* nor *remove_aces* is provided the current + ACL is fetched (HTTP GET). Otherwise the CMIS ``applyACL`` action + is executed (HTTP POST) with the supplied entries. + + Args: + repository_id: Target repository ID. + object_id: Document or folder CMIS objectId. + add_aces: Optional ACE entries to grant. + remove_aces: Optional ACE entries to revoke. + acl_propagation: ACL propagation mode — ``"propagate"``, + ``"objectonly"``, or ``"repositorydetermined"``. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Acl: The current or updated ACL. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + if not add_aces and not remove_aces: + # Read-only: fetch current ACL + logger.info( + "Fetching ACL for object '%s' in repo '%s'", object_id, repository_id + ) + response = self._http.get( + self._browser_url(repository_id), + params={"objectId": object_id, "cmisselector": "acl"}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Acl.from_dict(response.json()) + + form_data: Dict[str, str] = { + "cmisaction": "applyACL", + "objectId": object_id, + "ACLPropagation": acl_propagation, + "_charset_": "UTF-8", + } + if add_aces: + form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) + if remove_aces: + form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) + + logger.info( + "Applying ACL to object '%s' in repo '%s'", object_id, repository_id + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Acl.from_dict(response.json()) + + # ================================================================== + # CMIS — object read operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_GET_OBJECT) + def get_object( + self, + repository_id: str, + object_id: str, + *, + filter: Optional[str] = None, + include_acl: bool = False, + include_allowable_actions: bool = False, + succinct: bool = True, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Union[Folder, Document, CmisObject]: + """Retrieve a CMIS object by its ID. + + Automatically returns a :class:`Folder` or :class:`Document` + based on the ``cmis:baseTypeId`` value; falls back to + :class:`CmisObject` for unknown types. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId to retrieve. + filter: Comma-separated property list (e.g. ``"*"`` for all). + include_acl: Include ACL data in the response. + include_allowable_actions: Include allowable actions. + succinct: Use succinct property format. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Folder, Document, or CmisObject depending on the base type. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + params: Dict[str, str] = { + "objectId": object_id, + "cmisselector": "object", + } + if filter: + params["filter"] = filter + if include_acl: + params["includeACL"] = "true" + if include_allowable_actions: + params["includeAllowableActions"] = "true" + if succinct: + params["succinct"] = "true" + + logger.info("Getting object '%s' from repo '%s'", object_id, repository_id) + response = self._http.get( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + data = response.json() + props = data.get("succinctProperties") or data.get("properties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + return Document.from_dict(data) + if base_type == "cmis:folder": + return Folder.from_dict(data) + return CmisObject.from_dict(data) + + @record_metrics(Module.DMS, Operation.DMS_GET_CONTENT) + def get_content( + self, + repository_id: str, + document_id: str, + *, + download: Optional[str] = None, + stream_id: Optional[str] = None, + filename: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """Download the content stream of a document. + + Returns the raw :class:`requests.Response` with ``stream=True`` + so the caller can iterate over chunks or read all bytes:: + + resp = client.get_content(repo_id, doc_id, download="attachment") + with open("file.pdf", "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + resp.close() + + Args: + repository_id: Target repository ID. + document_id: Document CMIS objectId. + download: Download disposition — ``"attachment"`` (save-as) or + ``"inline"`` (display in browser). Omit to let the server + decide. + stream_id: Rendition stream identifier (e.g. + ``"sap:zipRendition"``). When omitted the primary content + stream is returned. + filename: Override the file name in the ``Content-Disposition`` + response header. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Response: Raw streaming response. Caller must close it. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + params: Dict[str, str] = { + "objectId": document_id, + "cmisselector": "content", + } + if download: + params["download"] = download + if stream_id: + params["streamId"] = stream_id + if filename: + params["filename"] = filename + + logger.info( + "Getting content for document '%s' from repo '%s'", + document_id, + repository_id, + ) + return self._http.get_stream( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_PROPERTIES) + def update_properties( + self, + repository_id: str, + object_id: str, + properties: Dict[str, str], + *, + change_token: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Union[Folder, Document, CmisObject]: + """Update properties of an existing CMIS object. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId to update. + properties: Map of property IDs to new values + (e.g. ``{"cmis:name": "Renamed", "cmis:description": "New desc"}``) + change_token: Optional concurrency token for optimistic locking. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Folder, Document, or CmisObject depending on the base type. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "update", + "objectId": object_id, + "_charset_": "UTF-8", + } + if change_token is not None: + form_data["changeToken"] = change_token + form_data.update(_build_properties(properties)) + + logger.info( + "Updating properties for object '%s' in repo '%s'", object_id, repository_id + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + data = response.json() + props = data.get("succinctProperties") or data.get("properties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + return Document.from_dict(data) + if base_type == "cmis:folder": + return Folder.from_dict(data) + return CmisObject.from_dict(data) + + @record_metrics(Module.DMS, Operation.DMS_GET_CHILDREN) + def get_children( + self, + repository_id: str, + folder_id: str, + *, + options: Optional[ChildrenOptions] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> ChildrenPage: + """List children of a folder (one page). + + Use :class:`ChildrenOptions` to control pagination, sorting, and + filtering. The returned :class:`ChildrenPage` has a + ``has_more_items`` flag and ``num_items`` count. + + Args: + repository_id: Target repository ID. + folder_id: Parent folder CMIS objectId. + options: Pagination and query options. Defaults to + ``ChildrenOptions()`` (max 100 items, no skip). + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + ChildrenPage: A page of child objects. + + Raises: + DMSObjectNotFoundException: If the folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + + Example:: + + from sap_cloud_sdk.dms import create_client, ChildrenOptions + + client = create_client() + opts = ChildrenOptions(max_items=50, order_by="cmis:creationDate ASC") + page = client.get_children(repo_id, folder_id, options=opts) + """ + opts = options or ChildrenOptions() + params: Dict[str, str] = {"objectId": folder_id, "cmisselector": "children"} + params.update(opts.to_query_params()) + + logger.info( + "Getting children of folder '%s' in repo '%s'", folder_id, repository_id + ) + response = self._http.get( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return ChildrenPage.from_dict(response.json()) + + # ================================================================== + # CMIS — delete / restore operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_DELETE_OBJECT) + def delete_object( + self, + repository_id: str, + object_id: str, + *, + all_versions: bool = True, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a CMIS object (document or folder). + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId of the object to delete. + all_versions: If True, delete all versions of the document. + Defaults to True. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "delete", + "objectId": object_id, + "allVersions": str(all_versions).lower(), + "_charset_": "UTF-8", + } + logger.info("Deleting object '%s' from repo '%s'", object_id, repository_id) + self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + @record_metrics(Module.DMS, Operation.DMS_RESTORE_OBJECT) + def restore_object( + self, + repository_id: str, + object_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> str: + """Restore a previously deleted object. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId of the deleted object. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + str: The server message confirming the restore. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + path = f"{_REPOSITORIES}/{repository_id}/deleted/objects/{object_id}/restore" + logger.info("Restoring object '%s' in repo '%s'", object_id, repository_id) + response = self._http.post( + path=path, + payload={}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return response.json().get("message", "") + + # ================================================================== + # CMIS — content stream operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_APPEND_CONTENT_STREAM) + def append_content_stream( + self, + repository_id: str, + document_id: str, + file: BinaryIO, + *, + is_last_chunk: bool = False, + filename: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Append content to an existing document's content stream. + + Args: + repository_id: Target repository ID. + document_id: Document CMIS objectId. + file: Readable binary stream with the content to append. + is_last_chunk: True if this is the final chunk of a + multi-part upload. Defaults to False. + filename: Optional file name for the content part. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Document: The updated document. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "appendContent", + "objectId": document_id, + "succinct": "true", + "isLastChunk": str(is_last_chunk).lower(), + "_charset_": "UTF-8", + } + logger.info( + "Appending content to document '%s' in repo '%s'", + document_id, + repository_id, + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + files={ + "media": ( + filename or "content", + file, + "application/octet-stream", + ) + }, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + # ================================================================== + # CMIS — query operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CMIS_QUERY) + def cmis_query( + self, + repository_id: str, + statement: str, + *, + options: Optional[QueryOptions] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> QueryResultPage: + """Execute a CMIS SQL query against a repository. + + Args: + repository_id: Target repository ID. + statement: CMIS-QL query string (e.g. + ``"SELECT * FROM cmis:document WHERE cmis:name = 'test.pdf'"``). + options: Pagination options. Defaults to ``QueryOptions()`` + (max 100 items, no skip). + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + QueryResultPage: Paginated query results. + + Raises: + DMSInvalidArgumentException: If the query is malformed. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + + Example:: + + from sap_cloud_sdk.dms import create_client, QueryOptions + + client = create_client() + page = client.cmis_query( + repo_id, + "SELECT * FROM cmis:document WHERE cmis:name LIKE 'report%'", + options=QueryOptions(max_items=50), + ) + """ + opts = options or QueryOptions() + params: Dict[str, str] = { + "cmisselector": "query", + "q": statement, + } + params.update(opts.to_query_params()) + + logger.info("Executing CMIS query in repo '%s'", repository_id) + response = self._http.get( + f"/browser/{repository_id}", + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return QueryResultPage.from_dict(response.json()) diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py new file mode 100644 index 0000000..f7ad14c --- /dev/null +++ b/src/sap_cloud_sdk/dms/config.py @@ -0,0 +1,138 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from sap_cloud_sdk.core.secret_resolver.resolver import ( + read_from_mount_and_fallback_to_env_var, +) +from sap_cloud_sdk.destination.exceptions import ConfigError +from sap_cloud_sdk.dms.model import DMSCredentials + + +@dataclass +class BindingData: + """Dataclass for DMS binding data with URI and UAA credentials. + + Attributes: + uri: The URI endpoint for the DMS service + uaa: JSON string containing XSUAA authentication credentials + """ + + instance_name: str + uri: str + uaa: str + + def validate(self) -> None: + """Validate the binding data. + + Validates that: + - uri is a valid URI + - uaa is valid JSON and contains required credential fields + + Raises: + ValueError: If uri is not a valid URI + json.JSONDecodeError: If uaa is not valid JSON + ValueError: If uaa JSON is missing required fields + """ + self._validate_uri() + self._validate_uaa() + + def _validate_uri(self) -> None: + """Validate that uri is a valid URI. + + Raises: + ValueError: If uri is not a valid URI + """ + try: + result = urlparse(self.uri) + if not result.scheme or not result.netloc: + raise ValueError( + f"Invalid URI format: '{self.uri}'. " + "URI must have a scheme (e.g., https://) and network location." + ) + except Exception as e: + raise ValueError(f"Failed to parse URI: {self.uri}") from e + + def _validate_uaa(self) -> None: + """Validate that uaa is valid JSON with required credential fields. + + Raises: + json.JSONDecodeError: If uaa is not valid JSON + ValueError: If required fields are missing from UAA credentials + """ + required_fields = {"clientid", "clientsecret", "url", "identityzone"} + + try: + uaa_data: Dict[str, Any] = json.loads(self.uaa) + except json.JSONDecodeError as e: + raise json.JSONDecodeError( + f"UAA credentials must be valid JSON. Error: {e.msg}", + e.doc, + e.pos, + ) from e + + missing_fields = required_fields - set(uaa_data.keys()) + if missing_fields: + raise ValueError( + f"UAA credentials missing required fields: {', '.join(sorted(missing_fields))}" + ) + + def to_credentials(self) -> DMSCredentials: + """Convert the binding data to DMSCredentials. + + Parses the UAA JSON and constructs a DMSCredentials object with the necessary information + for authenticating and connecting to the DMS service. + + Returns: + DMSCredentials: The credentials extracted from the binding data + """ + uaa_data: Dict[str, Any] = json.loads(self.uaa) + token_url = uaa_data["url"].rstrip("/") + "/oauth/token" + + return DMSCredentials( + instance_name=self.instance_name, + uri=self.uri, + client_id=uaa_data["clientid"], + client_secret=uaa_data["clientsecret"], + token_url=token_url, + identityzone=uaa_data["identityzone"], + ) + + +def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCredentials: + """Load DMS configuration from mount with env fallback and normalize. + + Args: + instance: Logical instance name; defaults to "default" if not provided. + + Returns: + DMSCredentials + + Raises: + ConfigError: If loading or validation fails. + """ + inst = instance or "default" + binding = BindingData( + uri="", uaa="", instance_name="" + ) # Initialize with empty values; will be populated by resolver + + try: + # 1) Try mount at /etc/secrets/appfnd/destination/{instance}/... + # 2) Fallback to env: CLOUD_SDK_CFG_SDM_{INSTANCE}_{FIELD_KEY} + read_from_mount_and_fallback_to_env_var( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="sdm", + instance=inst, + target=binding, + ) + + binding.validate() + return binding.to_credentials() + + except Exception as e: + # Rely on the central secret resolver to provide aggregated, generic guidance + raise ConfigError( + f"failed to load sdm configuration for instance='{inst}': {e}" + ) diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py new file mode 100644 index 0000000..e524bdd --- /dev/null +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -0,0 +1,42 @@ +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +class DMSError(Exception): + """Base exception for all DMS errors.""" + + def __init__( + self, + message: Optional[str] = None, + status_code: Optional[int] = None, + error_content: str = "", + ) -> None: + self.status_code = status_code + self.error_content = error_content + super().__init__(message) + + +class DMSObjectNotFoundException(DMSError): + """The specified repository or resource does not exist.""" + + +class DMSPermissionDeniedException(DMSError): + """Access token is invalid, expired, or lacks required permissions.""" + + +class DMSInvalidArgumentException(DMSError): + """The request payload contains invalid or disallowed parameters.""" + + +class DMSConnectionError(DMSError): + """A network or connection failure occurred.""" + + +class DMSRuntimeException(DMSError): + """Unexpected server-side error.""" + + +class DMSConflictException(DMSError): + """The request conflicts with the current state of the resource.""" diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py new file mode 100644 index 0000000..f84a7a5 --- /dev/null +++ b/src/sap_cloud_sdk/dms/model.py @@ -0,0 +1,676 @@ +"""Data models for DMS service.""" + +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Dict, List, Optional, TypedDict, cast +from urllib.parse import urlparse + + +def _serialize(v: Any) -> Any: + """Recursively serialize values — converts Enums to their values, handles nested dicts/lists.""" + if isinstance(v, Enum): + return v.value + if isinstance(v, dict): + d: dict[str, Any] = cast(dict[str, Any], v) + return {str(k): _serialize(val) for k, val in d.items()} + if isinstance(v, list): + lst: list[Any] = cast(list[Any], v) + return [_serialize(i) for i in lst] + return v + + +def _to_dict_drop_none(obj: Any) -> dict[str, Any]: + """Convert a dataclass to a dict, dropping None values and serializing enums.""" + raw: dict[str, Any] = asdict(obj) + return {k: _serialize(v) for k, v in raw.items() if v is not None} + + +@dataclass +class DMSCredentials: + """Credentials for authenticating with the DMS service.""" + + instance_name: str + uri: str + client_id: str + client_secret: str + token_url: str + identityzone: str + + def __post_init__(self) -> None: + self._validate() + + def _validate(self) -> None: + placeholders = { + k: v + for k, v in { + "uri": self.uri, + "token_url": self.token_url, + "client_id": self.client_id, + "client_secret": self.client_secret, + "identityzone": self.identityzone, + }.items() + if not v or v.startswith("<") or v.endswith(">") + } + if placeholders: + raise ValueError( + f"DMSCredentials contains unfilled placeholder values: {list(placeholders.keys())}. " + "Replace all <...> values with real credentials before creating a client." + ) + for fname, value in {"uri": self.uri, "token_url": self.token_url}.items(): + parsed = urlparse(value) + if not parsed.scheme or not parsed.netloc: + raise ValueError( + f"DMSCredentials.{fname} is not a valid URL: '{value}'" + ) + + +class RepositoryType(str, Enum): + INTERNAL = "internal" + EXTERNAL = "external" + + +class RepositoryCategory(str, Enum): + COLLABORATION = "Collaboration" + INSTANT = "Instant" + FAVORITES = "Favorites" + + +class ConfigName(str, Enum): + BLOCKED_FILE_EXTENSIONS = "blockedFileExtensions" + TEMPSPACE_MAX_CONTENT_SIZE = "tempspaceMaxContentSize" + IS_CROSS_DOMAIN_MAPPING_ALLOWED = "isCrossDomainMappingAllowed" + + +class HashAlgorithm(str, Enum): + MD5 = "MD5" + SHA1 = "SHA-1" + SHA256 = "SHA-256" + + +@dataclass +class UserClaim: + """User identity claims forwarded to the DMS service. + + Attributes: + x_ecm_user_enc: User identifier (e.g. username or email). + x_ecm_add_principals: Additional principals. + - Groups: prefix with ``~`` (e.g. ``~group1``) + - Extra users: plain username or email + """ + + x_ecm_user_enc: Optional[str] = None + x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda: []) + + +@dataclass +class RepositoryParam: + paramName: str + paramValue: str + + +@dataclass +class InternalRepoRequest: + """Request payload for onboarding a new internal repository.""" + + # Required + displayName: str + repositoryType: RepositoryType = RepositoryType.INTERNAL + + # Optional + description: Optional[str] = None + repositoryCategory: Optional[RepositoryCategory] = None + isVersionEnabled: Optional[bool] = None + isVirusScanEnabled: Optional[bool] = None + skipVirusScanForLargeFile: Optional[bool] = None + hashAlgorithms: Optional[HashAlgorithm] = None + isThumbnailEnabled: Optional[bool] = None + isEncryptionEnabled: Optional[bool] = None + externalId: Optional[str] = None + isContentBridgeEnabled: Optional[bool] = None + isAIEnabled: Optional[bool] = None + repositoryParams: List[RepositoryParam] = field(default_factory=lambda: []) + + def to_dict(self) -> dict[str, Any]: + return _to_dict_drop_none(self) + + +@dataclass +class UpdateRepoRequest: + """Request payload for updating an internal repository.""" + + description: Optional[str] = None + isVirusScanEnabled: Optional[bool] = None + skipVirusScanForLargeFile: Optional[bool] = None + isThumbnailEnabled: Optional[bool] = None + isClientCacheEnabled: Optional[bool] = None + isAIEnabled: Optional[bool] = None + repositoryParams: List[RepositoryParam] = field(default_factory=lambda: []) + + def to_dict(self) -> dict[str, Any]: + return {"repository": _to_dict_drop_none(self)} + + +class RepositoryParams(TypedDict, total=False): + """Typed schema for known repository parameters returned by the API. + + All keys are optional since the API may not always return every param. + Unknown params can still be accessed via get_param() on the Repository object. + """ + + changeLogDuration: int + isVersionEnabled: bool + isThumbnailEnabled: bool + isVirusScanEnabled: bool + hashAlgorithms: str + skipVirusScanForLargeFile: bool + isEncryptionEnabled: bool + isClientCacheEnabled: bool + isAIEnabled: bool + + +@dataclass +class Repository: + """Represents a repository entity returned by the Document Management API. + + Attributes: + cmis_repository_id: Internal CMIS repository identifier. + created_time: Timestamp when the repository was created (UTC). + id: Unique repository UUID. + last_updated_time: Timestamp of the last update (UTC). + name: Human-readable repository name. + repository_category: Category of the repository (e.g. "Instant"). + repository_params: Flat dict of repository parameters. Known keys are + typed via RepositoryParams. Unknown keys can be accessed via get_param(). + repository_sub_type: Repository sub-type (e.g. "SAP Document Management Service"). + repository_type: Repository type (e.g. "internal"). + """ + + cmis_repository_id: str + created_time: datetime + id: str + last_updated_time: datetime + name: str + repository_category: str + repository_params: RepositoryParams + repository_sub_type: str + repository_type: str + + @staticmethod + def _parse_repo_params(raw: Any) -> "RepositoryParams": + """Normalise repositoryParams from the API response. + + The API may return: + - A list of ``{paramName, paramValue}`` dicts (common case) + - A single ``{paramName, paramValue}`` dict (when only one param) + - An empty list or ``None`` + """ + if not raw: + return cast(RepositoryParams, {}) + if isinstance(raw, dict): + raw = [raw] + return cast(RepositoryParams, {p["paramName"]: p["paramValue"] for p in raw}) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Repository": + """Parse a raw API response dict into a Repository instance. + + Converts the repositoryParams list of {paramName, paramValue} objects + into a flat dict for easier access. + + Args: + data: Raw dict returned by the repository API. + + Returns: + Repository: Parsed repository instance. + """ + return cls( + cmis_repository_id=data["cmisRepositoryId"], + created_time=datetime.fromisoformat( + data["createdTime"].replace("Z", "+00:00") + ), + id=data["id"], + last_updated_time=datetime.fromisoformat( + data["lastUpdatedTime"].replace("Z", "+00:00") + ), + name=data["name"], + repository_category=data["repositoryCategory"], + repository_params=cls._parse_repo_params(data.get("repositoryParams")), + repository_sub_type=data["repositorySubType"], + repository_type=data["repositoryType"], + ) + + def to_dict(self) -> dict[str, Any]: + """Serialize back into an API-compatible payload. + + Converts the flat repository_params dict back into the + [{paramName, paramValue}] list format expected by the API. + """ + return { + "cmisRepositoryId": self.cmis_repository_id, + "createdTime": self.created_time.isoformat().replace("+00:00", "Z"), + "id": self.id, + "lastUpdatedTime": self.last_updated_time.isoformat().replace( + "+00:00", "Z" + ), + "name": self.name, + "repositoryCategory": self.repository_category, + "repositoryParams": [ + {"paramName": k, "paramValue": v} + for k, v in self.repository_params.items() + ], + "repositorySubType": self.repository_sub_type, + "repositoryType": self.repository_type, + } + + def get_param(self, name: str, default: Any = None) -> Any: + """Get a repository parameter value by name. + + Use for unknown or dynamic param keys not defined in RepositoryParams. + For known keys, prefer direct access via repository_params for type safety. + + Args: + name: The paramName to look up (e.g. "isEncryptionEnabled"). + default: Fallback value if the param is not found. + + Example: + repo.get_param("isEncryptionEnabled") # True + repo.get_param("unknownParam", "N/A") # "N/A" + """ + return self.repository_params.get(name, default) + + +@dataclass +class CreateConfigRequest: + """Request payload for creating a repository configuration. + + Use ConfigName enum for known config keys. Unknown keys can be passed as raw strings. + + Example: + CreateConfigRequest(ConfigName.BLOCKED_FILE_EXTENSIONS, "bat,dmg,txt") + CreateConfigRequest("someCustomConfig", "value") + """ + + config_name: ConfigName | str + config_value: str + + def to_dict(self) -> dict[str, Any]: + return { + "configName": _serialize(self.config_name), + "configValue": self.config_value, + } + + +@dataclass +class UpdateConfigRequest: + """Request payload for updating a repository configuration. + + Args: + id: Config Id. + config_name: ConfigName enum or raw string. + config_value: Value for the given config name. + service_instance_id: Optional service instance id. + """ + + id: str + config_name: ConfigName | str + config_value: str + service_instance_id: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": self.id, + "configName": _serialize(self.config_name), + "configValue": self.config_value, + } + if self.service_instance_id: + payload["serviceInstanceId"] = self.service_instance_id + return payload + + +@dataclass +class RepositoryConfig: + """Represents a repository configuration entry.""" + + id: str + config_name: str + config_value: str + created_time: datetime + last_updated_time: datetime + service_instance_id: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RepositoryConfig": + return cls( + id=data["id"], + config_name=data["configName"], + config_value=data["configValue"], + created_time=datetime.fromisoformat( + data["createdTime"].replace("Z", "+00:00") + ), + last_updated_time=datetime.fromisoformat( + data["lastUpdatedTime"].replace("Z", "+00:00") + ), + service_instance_id=data["serviceInstanceId"], + ) + + +# --------------------------------------------------------------------------- +# CMIS browser-binding response models +# --------------------------------------------------------------------------- + + +def _parse_datetime(val: Any) -> Optional[datetime]: + """Parse a CMIS timestamp (epoch millis or ISO string) into a datetime.""" + if val is None: + return None + if isinstance(val, (int, float)): + return datetime.fromtimestamp(val / 1000, tz=timezone.utc) + return datetime.fromisoformat(str(val).replace("Z", "+00:00")) + + +def _prop_val(props: Dict[str, Any], key: str) -> Any: + """Extract a property value from verbose or succinct CMIS properties. + + Verbose format: ``{"cmis:name": {"value": "MyDoc"}}`` + Succinct format: ``{"cmis:name": "MyDoc"}`` + """ + raw = props.get(key) + if isinstance(raw, dict) and "value" in raw: + return raw["value"] + return raw + + +@dataclass +class CmisObject: + """Base CMIS object with shared properties.""" + + object_id: str = "" + name: str = "" + base_type_id: str = "" + object_type_id: str = "" + created_by: Optional[str] = None + creation_date: Optional[datetime] = None + last_modified_by: Optional[str] = None + last_modification_date: Optional[datetime] = None + change_token: Optional[str] = None + parent_ids: Optional[List[str]] = None + description: Optional[str] = None + properties: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CmisObject": + props = data.get("succinctProperties") or data.get("properties") or {} + return cls( + object_id=_prop_val(props, "cmis:objectId") or "", + name=_prop_val(props, "cmis:name") or "", + base_type_id=_prop_val(props, "cmis:baseTypeId") or "", + object_type_id=_prop_val(props, "cmis:objectTypeId") or "", + created_by=_prop_val(props, "cmis:createdBy"), + creation_date=_parse_datetime(_prop_val(props, "cmis:creationDate")), + last_modified_by=_prop_val(props, "cmis:lastModifiedBy"), + last_modification_date=_parse_datetime( + _prop_val(props, "cmis:lastModificationDate") + ), + change_token=_prop_val(props, "cmis:changeToken"), + parent_ids=_prop_val(props, "sap:parentIds"), + description=_prop_val(props, "cmis:description"), + properties=props, + ) + + +@dataclass +class Folder(CmisObject): + """CMIS folder object.""" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Folder": + base = CmisObject.from_dict(data) + return cls( + **{k: v for k, v in base.__dict__.items() if k in cls.__dataclass_fields__} + ) + + +@dataclass +class Document(CmisObject): + """CMIS document with content stream and versioning metadata.""" + + content_stream_length: Optional[int] = None + content_stream_mime_type: Optional[str] = None + content_stream_file_name: Optional[str] = None + version_series_id: Optional[str] = None + version_label: Optional[str] = None + is_latest_version: Optional[bool] = None + is_major_version: Optional[bool] = None + is_latest_major_version: Optional[bool] = None + is_private_working_copy: Optional[bool] = None + checkin_comment: Optional[str] = None + is_version_series_checked_out: Optional[bool] = None + version_series_checked_out_id: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Document": + base = CmisObject.from_dict(data) + props = base.properties + return cls( + **{ + k: v + for k, v in base.__dict__.items() + if k in CmisObject.__dataclass_fields__ + }, + content_stream_length=_prop_val(props, "cmis:contentStreamLength"), + content_stream_mime_type=_prop_val(props, "cmis:contentStreamMimeType"), + content_stream_file_name=_prop_val(props, "cmis:contentStreamFileName"), + version_series_id=_prop_val(props, "cmis:versionSeriesId"), + version_label=_prop_val(props, "cmis:versionLabel"), + is_latest_version=_prop_val(props, "cmis:isLatestVersion"), + is_major_version=_prop_val(props, "cmis:isMajorVersion"), + is_latest_major_version=_prop_val(props, "cmis:isLatestMajorVersion"), + is_private_working_copy=_prop_val(props, "cmis:isPrivateWorkingCopy"), + checkin_comment=_prop_val(props, "cmis:checkinComment"), + is_version_series_checked_out=_prop_val( + props, "cmis:isVersionSeriesCheckedOut" + ), + version_series_checked_out_id=_prop_val( + props, "cmis:versionSeriesCheckedOutId" + ), + ) + + +@dataclass +class Ace: + """Single access control entry.""" + + principal_id: str + permissions: List[str] = field(default_factory=list) + is_direct: bool = True + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Ace": + principal = data.get("principal", {}) + return cls( + principal_id=principal.get("principalId", ""), + permissions=data.get("permissions", []), + is_direct=data.get("isDirect", True), + ) + + +@dataclass +class Acl: + """Access control list for a CMIS object.""" + + aces: List[Ace] = field(default_factory=list) + is_exact: bool = True + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Acl": + raw_aces = data.get("aces", []) + return cls( + aces=[Ace.from_dict(a) for a in raw_aces], + is_exact=data.get("isExact", True), + ) + + +@dataclass +class ChildrenOptions: + """Pagination and query options for :meth:`DMSClient.get_children`. + + Encapsulates all pagination and filter parameters for listing folder + children, following the same pattern as the Destination module's + ``ListOptions``. + + Example: + ```python + from sap_cloud_sdk.dms import create_client, ChildrenOptions + + client = create_client() + options = ChildrenOptions(max_items=50, skip_count=100, order_by="cmis:creationDate ASC") + page = client.get_children(repo_id, folder_id, options=options) + while page.has_more_items: + options.skip_count += options.max_items + page = client.get_children(repo_id, folder_id, options=options) + ``` + + Attributes: + max_items: Maximum number of items to return per page (default 100). + skip_count: Number of items to skip (pagination offset, default 0). + order_by: Sort order (e.g. ``"cmis:creationDate ASC"``). + filter: Comma-separated CMIS property filter list. + include_allowable_actions: Include allowable actions per child. + include_path_segment: Include the path segment per child. + succinct: Use succinct property format (default True). + """ + + max_items: int = 100 + skip_count: int = 0 + order_by: Optional[str] = None + filter: Optional[str] = None + include_allowable_actions: bool = False + include_path_segment: bool = False + succinct: bool = True + + def to_query_params(self) -> Dict[str, str]: + """Convert options to CMIS query parameters. + + Returns: + Dict[str, str]: Query parameters for the HTTP request. + """ + params: Dict[str, str] = { + "maxItems": str(self.max_items), + "skipCount": str(self.skip_count), + } + if self.order_by: + params["orderBy"] = self.order_by + if self.filter: + params["filter"] = self.filter + if self.include_allowable_actions: + params["includeAllowableActions"] = "true" + if self.include_path_segment: + params["includePathSegment"] = "true" + if self.succinct: + params["succinct"] = "true" + return params + + +@dataclass +class ChildrenPage: + """Paginated result from a CMIS ``getChildren`` request.""" + + objects: List[CmisObject] = field(default_factory=list) + has_more_items: bool = False + num_items: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": + raw_objects = data.get("objects") or [] + parsed: List[CmisObject] = [] + for entry in raw_objects: + obj_data = entry.get("object") or entry + props = ( + obj_data.get("succinctProperties") or obj_data.get("properties") or {} + ) + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + parsed.append(Document.from_dict(obj_data)) + elif base_type == "cmis:folder": + parsed.append(Folder.from_dict(obj_data)) + else: + parsed.append(CmisObject.from_dict(obj_data)) + return cls( + objects=parsed, + has_more_items=data.get("hasMoreItems", False), + num_items=data.get("numItems"), + ) + + +@dataclass +class QueryOptions: + """Pagination and search options for :meth:`DMSClient.cmis_query`. + + Example: + ```python + from sap_cloud_sdk.dms import create_client, QueryOptions + + client = create_client() + opts = QueryOptions(max_items=50, search_all_versions=True) + page = client.cmis_query(repo_id, "SELECT * FROM cmis:document", options=opts) + while page.has_more_items: + opts.skip_count += opts.max_items + page = client.cmis_query(repo_id, "SELECT * FROM cmis:document", options=opts) + ``` + + Attributes: + max_items: Maximum number of results to return per page (default 100). + skip_count: Number of results to skip (pagination offset, default 0). + search_all_versions: Search all versions, not just latest (default False). + """ + + max_items: int = 100 + skip_count: int = 0 + search_all_versions: bool = False + + def to_query_params(self) -> Dict[str, str]: + """Convert options to CMIS query parameters. + + Returns: + Dict[str, str]: Query parameters for the HTTP request. + """ + params: Dict[str, str] = { + "maxItems": str(self.max_items), + "skipCount": str(self.skip_count), + } + if self.search_all_versions: + params["searchAllVersions"] = "true" + return params + + +@dataclass +class QueryResultPage: + """Paginated result from a CMIS query request. + + Query results use verbose property format where each property is + ``{"cmis:name": {"value": "MyDoc"}}`` rather than succinct format. + """ + + results: List[CmisObject] = field(default_factory=list) + has_more_items: bool = False + num_items: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QueryResultPage": + raw_results = data.get("results") or [] + parsed: List[CmisObject] = [] + for entry in raw_results: + props = entry.get("properties") or entry.get("succinctProperties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + parsed.append(Document.from_dict(entry)) + elif base_type == "cmis:folder": + parsed.append(Folder.from_dict(entry)) + else: + parsed.append(CmisObject.from_dict(entry)) + return cls( + results=parsed, + has_more_items=data.get("hasMoreItems", False), + num_items=data.get("numItems"), + ) diff --git a/src/sap_cloud_sdk/dms/py.typed b/src/sap_cloud_sdk/dms/py.typed new file mode 100644 index 0000000..86bc7df --- /dev/null +++ b/src/sap_cloud_sdk/dms/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 to indicate the 'dms' package is typed. diff --git a/src/sap_cloud_sdk/dms/user-guide.md b/src/sap_cloud_sdk/dms/user-guide.md new file mode 100644 index 0000000..81910de --- /dev/null +++ b/src/sap_cloud_sdk/dms/user-guide.md @@ -0,0 +1,592 @@ +# DMS (Document Management Service) User Guide + +This module provides a Python SDK for interacting with the SAP Document Management Service (DMS). It supports repository management, document and folder operations via the CMIS Browser Binding protocol, versioning, and access control management. + +## Installation + +```bash + +# Using pip +pip install sap-cloud-sdk +``` + +See further information about installation in the [main documentation](/README.md#installation). + +## Import + +```python +from sap_cloud_sdk.dms import create_client +from sap_cloud_sdk.dms.model import ( + InternalRepoRequest, UpdateRepoRequest, Repository, + CreateConfigRequest, UpdateConfigRequest, RepositoryConfig, ConfigName, + UserClaim, Ace, Acl, Folder, Document, CmisObject, ChildrenPage, +) +from sap_cloud_sdk.dms.exceptions import ( + DMSError, DMSObjectNotFoundException, DMSPermissionDeniedException, + DMSInvalidArgumentException, DMSConflictException, + DMSConnectionError, DMSRuntimeException, +) +``` + +--- + +## Getting Started + +Use `create_client()` to get a client with automatic configuration detection: + +```python +from sap_cloud_sdk.dms import create_client + +# Load credentials from mounted secrets or environment variables +client = create_client(instance="my-instance") +``` + +You can also provide credentials directly: + +```python +from sap_cloud_sdk.dms import create_client +from sap_cloud_sdk.dms.model import DMSCredentials + +creds = DMSCredentials( + instance_name="my-instance", + uri="https://api-sdm-di.cfapps.eu10.hana.ondemand.com", + client_id="your-client-id", + client_secret="your-client-secret", + token_url="https://your-subdomain.authentication.eu10.hana.ondemand.com/oauth/token", + identityzone="your-subdomain", +) + +client = create_client(dms_cred=creds) +``` + +> **`instance` refers to the instance name defined in your Cloud descriptor.** +> +> This name determines which set of credentials or mounted secrets to resolve from the environment. + +--- + +## User Identity (UserClaim) + +Many DMS operations accept an optional `user_claim` parameter to impersonate a user when the SDK authenticates via `client_credentials` grant. This forwards `X-EcmUserEnc` and `X-EcmAddPrincipals` headers to the service. + +```python +from sap_cloud_sdk.dms.model import UserClaim + +claim = UserClaim( + x_ecm_user_enc="john.doe@example.com", + x_ecm_add_principals=["~GroupA", "~GroupB", "another.user@example.com"], +) + +# Pass to any API call +repos = client.get_all_repositories(user_claim=claim) +``` + +Groups are prefixed with `~`. Omit the `user_claim` parameter to use the service identity. + +--- + +## Admin API — Repository Management + +### Onboard a Repository + +```python +from sap_cloud_sdk.dms.model import InternalRepoRequest + +request = InternalRepoRequest( + displayName="My Repository", + description="Main document store", + isVersionEnabled=True, + isVirusScanEnabled=True, +) + +repo = client.onboard_repository(request) +print(f"Created: {repo.name} (id={repo.id})") +``` + +### List All Repositories + +```python +repos = client.get_all_repositories() + +for repo in repos: + print(f"{repo.name} — {repo.repository_type} — id={repo.id}") + print(f" CMIS repo ID: {repo.cmis_repository_id}") + print(f" Versioning: {repo.get_param('isVersionEnabled')}") +``` + +### Get a Single Repository + +```python +repo = client.get_repository("repository-uuid") +print(f"{repo.name}: {repo.repository_category}") +``` + +### Update a Repository + +```python +from sap_cloud_sdk.dms.model import UpdateRepoRequest + +request = UpdateRepoRequest( + description="Updated description", + isVirusScanEnabled=False, +) + +repo = client.update_repository("repository-uuid", request) +``` + +### Delete a Repository + +```python +client.delete_repository("repository-uuid") +``` + +--- + +## Admin API — Configuration Management + +### Create a Configuration + +```python +from sap_cloud_sdk.dms.model import CreateConfigRequest, ConfigName + +request = CreateConfigRequest( + config_name=ConfigName.BLOCKED_FILE_EXTENSIONS, + config_value="bat,dmg,exe", +) + +config = client.create_config(request) +print(f"Config: {config.config_name} = {config.config_value}") +``` + +### Get All Configurations + +```python +configs = client.get_configs() + +for cfg in configs: + print(f"{cfg.config_name} = {cfg.config_value} (id={cfg.id})") +``` + +### Update a Configuration + +```python +from sap_cloud_sdk.dms.model import UpdateConfigRequest + +request = UpdateConfigRequest( + id="config-uuid", + config_name=ConfigName.BLOCKED_FILE_EXTENSIONS, + config_value="bat,dmg,exe,msi", +) + +config = client.update_config("config-uuid", request) +``` + +### Delete a Configuration + +```python +client.delete_config("config-uuid") +``` + +--- + +## CMIS API — Folder Operations + +All CMIS operations require a `repository_id` (the CMIS repository ID from `repo.cmis_repository_id`) and use the CMIS Browser Binding protocol under the hood. + +### Create a Folder + +```python +folder = client.create_folder( + repository_id="cmis-repo-id", + parent_folder_id="parent-folder-object-id", + folder_name="My Folder", + description="Optional description", +) + +print(f"Created folder: {folder.name} (objectId={folder.object_id})") +``` + +--- + +## CMIS API — Document Operations + +### Create a Document + +```python +import io + +# From in-memory bytes +content = io.BytesIO(b"Hello, World!") +doc = client.create_document( + repository_id="cmis-repo-id", + parent_folder_id="parent-folder-id", + document_name="hello.txt", + file=content, + mime_type="text/plain", +) + +print(f"Created: {doc.name} ({doc.content_stream_length} bytes)") +print(f"Version: {doc.version_label}") +``` + +### Upload a File from Disk + +```python +with open("/path/to/report.pdf", "rb") as f: + doc = client.create_document( + repository_id="cmis-repo-id", + parent_folder_id="parent-folder-id", + document_name="report.pdf", + file=f, + mime_type="application/pdf", + description="Q4 financial report", + ) +``` + +### Download Document Content + +```python +response = client.get_content( + repository_id="cmis-repo-id", + document_id="document-object-id", + download="attachment", +) + +try: + with open("downloaded.pdf", "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) +finally: + response.close() +``` + +Optional parameters: +- `stream_id` — download a specific rendition (e.g. `"sap:zipRendition"`) +- `filename` — override the filename in the response header + +--- + +## CMIS API — Versioning + +Versioning requires a version-enabled repository. + +### Check Out a Document + +Creates a Private Working Copy (PWC): + +```python +pwc = client.check_out( + repository_id="cmis-repo-id", + document_id="document-object-id", +) + +print(f"PWC objectId: {pwc.object_id}") +print(f"Is PWC: {pwc.is_private_working_copy}") +``` + +### Check In a Document + +Creates a new version from the PWC: + +```python +import io + +new_content = io.BytesIO(b"Updated content for v2") + +doc = client.check_in( + repository_id="cmis-repo-id", + document_id=pwc.object_id, # the PWC objectId + major=True, + file=new_content, + file_name="hello.txt", + mime_type="text/plain", + checkin_comment="Version 2.0 — updated content", +) + +print(f"New version: {doc.version_label}") +print(f"Is latest: {doc.is_latest_version}") +``` + +### Cancel Check Out + +Discards the PWC without creating a new version: + +```python +client.cancel_check_out( + repository_id="cmis-repo-id", + document_id=pwc.object_id, +) +``` + +--- + +## CMIS API — Access Control Lists (ACL) + +### Get the Current ACL + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", +) + +for ace in acl.aces: + print(f"{ace.principal_id}: {ace.permissions} (direct={ace.is_direct})") +``` + +### Add ACE Entries + +```python +from sap_cloud_sdk.dms.model import Ace + +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", + add_aces=[ + Ace(principal_id="user@example.com", permissions=["cmis:read"]), + Ace(principal_id="admin@example.com", permissions=["cmis:all"]), + ], +) +``` + +### Remove ACE Entries + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", + remove_aces=[ + Ace(principal_id="user@example.com", permissions=["cmis:read"]), + ], +) +``` + +### ACL Propagation + +Control whether ACL changes propagate to children: + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="folder-id", + add_aces=[Ace(principal_id="team@example.com", permissions=["cmis:read"])], + acl_propagation="propagate", # "propagate" | "objectonly" | "repositorydetermined" +) +``` + +--- + +## CMIS API — Reading Objects + +### Get Object by ID + +Returns `Folder`, `Document`, or `CmisObject` depending on the base type: + +```python +obj = client.get_object( + repository_id="cmis-repo-id", + object_id="some-object-id", + include_acl=True, +) + +print(f"Type: {type(obj).__name__}") +print(f"Name: {obj.name}") +print(f"Modified: {obj.last_modification_date}") + +if isinstance(obj, Document): + print(f"MIME: {obj.content_stream_mime_type}") + print(f"Size: {obj.content_stream_length}") + print(f"Version: {obj.version_label}") +``` + +### Update Properties + +```python +updated = client.update_properties( + repository_id="cmis-repo-id", + object_id="some-object-id", + properties={ + "cmis:name": "Renamed Document.pdf", + "cmis:description": "Updated description", + }, +) + +print(f"New name: {updated.name}") +``` + +Use `change_token` for optimistic concurrency control: + +```python +updated = client.update_properties( + repository_id="cmis-repo-id", + object_id="some-object-id", + properties={"cmis:name": "New Name"}, + change_token="current-change-token-value", +) +``` + +### List Children (Pagination) + +```python +page = client.get_children( + repository_id="cmis-repo-id", + folder_id="parent-folder-id", + max_items=50, + skip_count=0, + order_by="cmis:creationDate ASC", +) + +print(f"Items: {len(page.objects)}, hasMore: {page.has_more_items}, total: {page.num_items}") + +for obj in page.objects: + kind = "Folder" if isinstance(obj, Folder) else "Document" + print(f" [{kind}] {obj.name} — {obj.object_id}") +``` + +Paginate through all children: + +```python +skip = 0 +page_size = 100 + +while True: + page = client.get_children( + repository_id="cmis-repo-id", + folder_id="parent-folder-id", + max_items=page_size, + skip_count=skip, + ) + + for obj in page.objects: + print(obj.name) + + if not page.has_more_items: + break + skip += page_size +``` + +--- + +## Multi-Tenancy + +All operations support an optional `tenant` parameter for subscriber-scoped requests. The SDK resolves the token URL by replacing the provider's identity zone with the tenant subdomain: + +```python +# Provider context (default) +repos = client.get_all_repositories() + +# Subscriber context +repos = client.get_all_repositories(tenant="subscriber-subdomain") +``` + +--- + +## Error Handling + +The DMS module provides specific exceptions for different error scenarios: + +```python +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSInvalidArgumentException, + DMSConflictException, + DMSConnectionError, + DMSRuntimeException, +) + +try: + obj = client.get_object("repo-uuid", "missing-id") +except DMSObjectNotFoundException as e: + print(f"Not found ({e.status_code}): {e}") +except DMSConflictException as e: + print(f"Conflict ({e.status_code}): {e}") +except DMSPermissionDeniedException as e: + print(f"Access denied ({e.status_code}): {e}") +except DMSInvalidArgumentException as e: + print(f"Bad request ({e.status_code}): {e}") +except DMSConnectionError as e: + print(f"Network error: {e}") +except DMSRuntimeException as e: + print(f"Server error ({e.status_code}): {e}") +``` + +### Exception Hierarchy + +| Exception | HTTP Status | Description | +|---|---|---| +| `DMSError` | any | Base exception for all DMS errors | +| `DMSObjectNotFoundException` | 404 | Repository, document, or folder not found | +| `DMSPermissionDeniedException` | 401, 403 | Invalid or expired access token | +| `DMSInvalidArgumentException` | 400 | Invalid request payload or parameters | +| `DMSConflictException` | 409 | Resource state conflict (e.g. duplicate name in versioned repo) | +| `DMSConnectionError` | — | Network failure, timeout, or connection refused | +| `DMSRuntimeException` | 500 | Server-side internal error | + +All exceptions carry: +- `status_code`: HTTP status code (when applicable) +- `error_content`: Raw response body for debugging + +The SDK extracts the server's error message from JSON responses (the `"message"` field) when available, providing specific error descriptions rather than generic messages. + +--- + +## Models + +### Repository Models + +- **`InternalRepoRequest`**: Create a new repository — `displayName` (required), `description`, `isVersionEnabled`, `isVirusScanEnabled`, `repositoryCategory`, etc. +- **`UpdateRepoRequest`**: Update repository metadata — `description`, `isVirusScanEnabled`, `isThumbnailEnabled`, `isAIEnabled`, etc. +- **`Repository`**: Repository entity — `id`, `name`, `cmis_repository_id`, `repository_type`, `repository_category`, `repository_params`. Use `repo.get_param("paramName")` for dynamic parameter access. + +### Configuration Models + +- **`CreateConfigRequest`**: `config_name` (use `ConfigName` enum), `config_value` +- **`UpdateConfigRequest`**: `id`, `config_name`, `config_value` +- **`RepositoryConfig`**: `id`, `config_name`, `config_value`, timestamps +- **`ConfigName`** enum: `BLOCKED_FILE_EXTENSIONS`, `TEMPSPACE_MAX_CONTENT_SIZE`, `IS_CROSS_DOMAIN_MAPPING_ALLOWED` + +### CMIS Object Models + +- **`CmisObject`**: Base model — `object_id`, `name`, `base_type_id`, `object_type_id`, `created_by`, `creation_date`, `last_modified_by`, `last_modification_date`, `change_token`, `parent_ids`, `description`, `properties` (full raw dict) +- **`Folder`**: Extends `CmisObject` (no additional fields) +- **`Document`**: Extends `CmisObject` — content fields (`content_stream_length`, `content_stream_mime_type`, `content_stream_file_name`) and versioning fields (`version_label`, `version_series_id`, `is_latest_version`, `is_major_version`, `is_private_working_copy`, `checkin_comment`, `is_version_series_checked_out`, etc.) + +### ACL Models + +- **`Ace`**: Access control entry — `principal_id`, `permissions` (list of strings), `is_direct` +- **`Acl`**: Access control list — `aces` (list of `Ace`), `is_exact` + +### Other Models + +- **`ChildrenPage`**: Paginated result — `objects` (list), `has_more_items`, `num_items` +- **`UserClaim`**: User identity — `x_ecm_user_enc`, `x_ecm_add_principals` +- **`DMSCredentials`**: Service credentials — `instance_name`, `uri`, `client_id`, `client_secret`, `token_url`, `identityzone` + +--- + +## Configuration + +The DMS module automatically resolves credentials from the environment. + +### Cloud Mode + +Reads secrets from mounted files or environment variables: +- **Kubernetes-mounted secret** at `/etc/secrets/appfnd/sdm//` +- **Fallback** to environment variables with pattern `CLOUD_SDK_CFG_SDM__` + +The binding provides: +- `uri`: DMS API base URL +- `uaa`: JSON string with XSUAA credentials (`clientid`, `clientsecret`, `url`, `identityzone`) + +### Service Binding (UAA JSON) + +```json +{ + "clientid": "sb-xxx!bxxx|sdm-di-xxx!bxxx", + "clientsecret": "xxx", + "url": "https://subdomain.authentication.region.hana.ondemand.com", + "identityzone": "subdomain" +} +``` diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 5a8077a..62bf50c 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -44,11 +44,12 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 4 + assert len(all_modules) == 5 assert Module.AICORE in all_modules assert Module.AUDITLOG in all_modules assert Module.DESTINATION in all_modules assert Module.OBJECTSTORE in all_modules + assert Module.DMS in all_modules def test_module_iteration(self): """Test iterating over Module enum.""" diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 9bf9801..4ba3323 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -59,6 +59,32 @@ def test_aicore_operations(self): assert Operation.AICORE_SET_CONFIG.value == "set_aicore_config" assert Operation.AICORE_AUTO_INSTRUMENT.value == "auto_instrument" + def test_dms_operations(self): + """Test DMS operation values.""" + assert Operation.DMS_ONBOARD_REPOSITORY.value == "onboard_repository" + assert Operation.DMS_GET_REPOSITORY.value == "get_repository" + assert Operation.DMS_GET_ALL_REPOSITORIES.value == "get_all_repositories" + assert Operation.DMS_UPDATE_REPOSITORY.value == "update_repository" + assert Operation.DMS_DELETE_REPOSITORY.value == "delete_repository" + assert Operation.DMS_CREATE_CONFIG.value == "create_config" + assert Operation.DMS_GET_CONFIGS.value == "get_configs" + assert Operation.DMS_UPDATE_CONFIG.value == "update_config" + assert Operation.DMS_DELETE_CONFIG.value == "delete_config" + assert Operation.DMS_CREATE_FOLDER.value == "create_folder" + assert Operation.DMS_CREATE_DOCUMENT.value == "create_document" + assert Operation.DMS_CHECK_OUT.value == "check_out" + assert Operation.DMS_CHECK_IN.value == "check_in" + assert Operation.DMS_CANCEL_CHECK_OUT.value == "cancel_check_out" + assert Operation.DMS_APPLY_ACL.value == "apply_acl" + assert Operation.DMS_GET_OBJECT.value == "get_object" + assert Operation.DMS_GET_CONTENT.value == "get_content" + assert Operation.DMS_UPDATE_PROPERTIES.value == "update_properties" + assert Operation.DMS_GET_CHILDREN.value == "get_children" + assert Operation.DMS_DELETE_OBJECT.value == "delete_cmis_object" + assert Operation.DMS_RESTORE_OBJECT.value == "restore_object" + assert Operation.DMS_APPEND_CONTENT_STREAM.value == "append_content_stream" + assert Operation.DMS_CMIS_QUERY.value == "cmis_query" + def test_operation_str_representation(self): """Test that Operation enum converts to string correctly.""" assert str(Operation.AUDITLOG_LOG) == "log" @@ -105,5 +131,5 @@ def test_operation_iteration(self): def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) - # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore = 34 - assert len(all_operations) == 34 + # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore + 22 dms = 56 + assert len(all_operations) == 56 diff --git a/tests/dms/__init__.py b/tests/dms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dms/integration/__init__.py b/tests/dms/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py new file mode 100644 index 0000000..254a83b --- /dev/null +++ b/tests/dms/integration/conftest.py @@ -0,0 +1,24 @@ +from sap_cloud_sdk.dms import create_client +import pytest +from pathlib import Path +from dotenv import load_dotenv + + +@pytest.fixture(scope="session") +def dms_client(): + """Create a DMS client for cloud testing using secret resolver.""" + _setup_cloud_mode() + + try: + # Secret resolver handles configuration automatically from /etc/secrets/appfnd or CLOUD_SDK_CFG + client = create_client(instance="default") + return client + except Exception as e: + pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type, too-many-positional-arguments] + + +def _setup_cloud_mode(): + """Common setup for cloud mode integration tests.""" + env_file = Path(__file__).parents[3] / ".env_integration_tests" + if env_file.exists(): + load_dotenv(env_file) diff --git a/tests/dms/integration/dms.feature b/tests/dms/integration/dms.feature new file mode 100644 index 0000000..2568b3f --- /dev/null +++ b/tests/dms/integration/dms.feature @@ -0,0 +1,254 @@ +Feature: Document Management Service Integration + As a developer using the SDK + I want to manage repositories, folders, documents, and ACLs + So that I can store and organize documents in the DMS service + + Background: + Given the DMS service is available + And I have a valid DMS client + + # ==================== Repository Management ==================== + + Scenario: List all repositories + When I list all repositories + Then the repository list should be retrieved successfully + And the list should contain at least 1 repository + + Scenario: Get repository details + Given I select the first available repository + When I get repository details + Then the repository details should be retrieved successfully + And the repository should have a CMIS repository ID + And the repository should have a name + + # ==================== Configuration Management ==================== + + Scenario: Create and delete a configuration + Given I have a config named "tempspaceMaxContentSize" with value "1073741824" + When I create the configuration + Then the configuration creation should be successful + And the configuration should have the expected name and value + When I delete the created configuration + Then the configuration deletion should be successful + + Scenario: List all configurations + When I list all configurations + Then the configuration list should be retrieved successfully + + # ==================== Folder Operations ==================== + + Scenario: Create a folder + Given I select the first available repository + And I use the root folder as parent + When I create a folder named "sdk-integration-test-folder" + Then the folder creation should be successful + And the created folder should have the correct name + And I clean up the created folder + + Scenario: Create a folder with description + Given I select the first available repository + And I use the root folder as parent + When I create a folder named "sdk-test-described-folder" with description "Integration test folder" + Then the folder creation should be successful + And the created folder should have the correct name + And I clean up the created folder + + # ==================== Document Operations ==================== + + Scenario: Upload a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Hello from SAP Cloud SDK Python integration tests!" + When I upload a document named "sdk-integration-test.txt" with mime type "text/plain" + Then the document upload should be successful + And the uploaded document should have the correct name + And the document should have mime type "text/plain" + And I clean up the created document + + Scenario: Upload a document without explicit mime type + Given I select the first available repository + And I use the root folder as parent + And I have document content "Binary content simulation" + When I upload a document named "sdk-test-no-mime.bin" without specifying mime type + Then the document upload should be successful + And the document should have a mime type assigned by the server + And I clean up the created document + + # ==================== Read Operations ==================== + + Scenario: Get object details for a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Content for get-object test" + And I upload a document named "sdk-get-object-test.txt" with mime type "text/plain" + When I get the object by its ID + Then the object should be retrieved successfully + And the object should be a Document + And the object name should be "sdk-get-object-test.txt" + And I clean up the created document + + Scenario: Get object details for a folder + Given I select the first available repository + And I use the root folder as parent + And I create a folder named "sdk-get-folder-test" + When I get the folder object by its ID + Then the object should be retrieved successfully + And the object should be a Folder + And I clean up the created folder + + Scenario: Get object with ACL included + Given I select the first available repository + And I use the root folder as parent + And I have document content "ACL test content" + And I upload a document named "sdk-acl-object-test.txt" with mime type "text/plain" + When I get the object by its ID with ACL included + Then the object should be retrieved successfully + And I clean up the created document + + Scenario: Download document content + Given I select the first available repository + And I use the root folder as parent + And I have document content "Download me!" + And I upload a document named "sdk-download-test.txt" with mime type "text/plain" + When I download the document content + Then the download should be successful + And the downloaded content should match "Download me!" + And I clean up the created document + + Scenario: List children of a folder + Given I select the first available repository + And I use the root folder as parent + And I create a folder named "sdk-children-parent" + And I create a child document "sdk-child-doc.txt" in the folder + When I list children of the folder + Then the children list should be retrieved successfully + And the children should contain at least 1 item + And I clean up the children folder + + Scenario: List children with pagination + Given I select the first available repository + And I use the root folder as parent + When I list children of the root folder with max items 5 + Then the children list should be retrieved successfully + + # ==================== Update Operations ==================== + + Scenario: Update document properties + Given I select the first available repository + And I use the root folder as parent + And I have document content "Update properties test" + And I upload a document named "sdk-update-props-test.txt" with mime type "text/plain" + When I update the object name to "sdk-updated-name.txt" + Then the update should be successful + And the updated object name should be "sdk-updated-name.txt" + And I clean up the updated document + + # ==================== Versioning ==================== + + Scenario: Check out and cancel check out + Given I select a version-enabled repository + And I use the root folder as parent + And I have document content "Versioning test content" + And I upload a document named "sdk-versioning-test.txt" with mime type "text/plain" + When I check out the document + Then the check out should be successful + And the PWC should be a private working copy + When I cancel the check out + Then the cancel check out should be successful + And I clean up the created document + + Scenario: Check out and check in a new version + Given I select a version-enabled repository + And I use the root folder as parent + And I have document content "Version 1 content" + And I upload a document named "sdk-checkin-test.txt" with mime type "text/plain" + When I check out the document + Then the check out should be successful + When I check in with content "Version 2 content" and comment "Updated via integration test" + Then the check in should be successful + And the new version label should not be empty + And I clean up the created document + + # ==================== ACL Operations ==================== + + Scenario: Get ACL for an object + Given I select the first available repository + And I use the root folder as parent + And I have document content "ACL read test" + And I upload a document named "sdk-acl-read-test.txt" with mime type "text/plain" + When I get the ACL for the document + Then the ACL should be retrieved successfully + And I clean up the created document + + # ==================== Error Handling ==================== + + Scenario: Get non-existent object + Given I select the first available repository + When I attempt to get a non-existent object + Then the operation should fail with a not found error + + Scenario: Download non-existent document + Given I select the first available repository + When I attempt to download a non-existent document + Then the operation should fail with a not found error + + # ==================== Delete & Restore Operations ==================== + + Scenario: Delete a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Delete me!" + And I upload a document named "sdk-delete-test.txt" with mime type "text/plain" + When I delete the document + Then the delete should be successful + When I attempt to get the deleted document + Then the operation should fail with a not found error + + Scenario: Delete and restore a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Delete and restore me!" + And I upload a document named "sdk-restore-test.txt" with mime type "text/plain" + When I delete the document + Then the delete should be successful + When I restore the deleted document + Then the restore should be successful + And I clean up the created document + + # ==================== Append Content Stream ==================== + + Scenario: Append content to a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Initial content" + And I upload a document named "sdk-append-test.txt" with mime type "text/plain" + When I append content "Additional content" to the document + Then the append should be successful + And the appended document should be a Document + And I clean up the created document + + Scenario: Append content as last chunk + Given I select the first available repository + And I use the root folder as parent + And I have document content "Base content" + And I upload a document named "sdk-append-last-test.txt" with mime type "text/plain" + When I append content "Final chunk" as the last chunk + Then the append should be successful + And I clean up the created document + + # ==================== CMIS Query ==================== + + Scenario: Execute a simple CMIS query + Given I select the first available repository + And I use the root folder as parent + And I have document content "Query test content" + And I upload a document named "sdk-query-test.txt" with mime type "text/plain" + When I execute a CMIS query for documents named "sdk-query-test" + Then the query should be successful + And the query results should contain at least 1 item + And I clean up the created document + + Scenario: Execute a CMIS query with pagination + Given I select the first available repository + When I execute a CMIS query for all documents with max items 5 + Then the query should be successful diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py new file mode 100644 index 0000000..482b173 --- /dev/null +++ b/tests/dms/integration/test_dms_bdd.py @@ -0,0 +1,999 @@ +"""BDD step definitions for DMS integration tests.""" + +import io +import logging +import uuid +from typing import List, Optional, Union + +import pytest +from pytest_bdd import scenarios, given, when, then, parsers +from requests import Response + +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.model import ( + Acl, + ChildrenOptions, + ChildrenPage, + CmisObject, + CreateConfigRequest, + Document, + Folder, + QueryOptions, + QueryResultPage, + Repository, + RepositoryConfig, +) +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSObjectNotFoundException, +) + +logger = logging.getLogger(__name__) + +# Load scenarios from feature file +scenarios("dms.feature") + + +# ==================== CONTEXT CLASS ==================== + + +class DMSTestContext: + """Context to store test state between BDD steps.""" + + def __init__(self): + self.repo: Optional[Repository] = None + self.repos: List[Repository] = [] + self.repo_id: str = "" + self.root_folder_id: str = "" + self.folder: Optional[Folder] = None + self.document: Optional[Document] = None + self.pwc: Optional[Document] = None + self.checked_in_doc: Optional[Document] = None + self.config: Optional[RepositoryConfig] = None + self.configs: List[RepositoryConfig] = [] + self.acl: Optional[Acl] = None + self.children_page: Optional[ChildrenPage] = None + self.content_response: Optional[Response] = None + self.retrieved_object: Optional[Union[Folder, Document, CmisObject]] = None + self.updated_object: Optional[Union[Folder, Document, CmisObject]] = None + self.content_bytes: Optional[bytes] = None + self.operation_success: bool = False + self.operation_error: Optional[Exception] = None + self.cleanup_configs: List[str] = [] # config IDs + self.cleanup_objects: List[tuple] = [] # (repo_id, object_id) + self.child_doc_id: Optional[str] = None + self._config_request: Optional[CreateConfigRequest] = None + self._expected_updated_name: str = "" + self.restore_message: Optional[str] = None + self.appended_document: Optional[Document] = None + self.query_result: Optional[QueryResultPage] = None + self._deleted_doc_id: Optional[str] = None + + +@pytest.fixture +def context(dms_client: DMSClient): + """Provide a fresh test context for each scenario.""" + ctx = DMSTestContext() + yield ctx + # Always clean up resources, even if the test fails + if ctx.content_response is not None: + ctx.content_response.close() + for config_id in ctx.cleanup_configs: + try: + dms_client.delete_config(config_id) + except Exception as e: + logger.warning("Cleanup failed for config %s: %s", config_id, e) + for repo_id, object_id in ctx.cleanup_objects: + try: + _delete_cmis_object(dms_client, repo_id, object_id) + except Exception as e: + logger.warning("Cleanup failed for object %s: %s", object_id, e) + + +# ==================== BACKGROUND STEPS ==================== + + +@given("the DMS service is available") +def dms_service_available(dms_client: DMSClient): + """Verify that the DMS client is available.""" + assert dms_client is not None + + +@given("I have a valid DMS client") +def have_valid_client(dms_client: DMSClient): + """Verify the DMS client is properly configured.""" + assert dms_client is not None + + +# ==================== REPOSITORY: GIVEN ==================== + + +@given("I select the first available repository") +def select_first_repo(context: DMSTestContext, dms_client: DMSClient): + """Select the first repository from the list.""" + repos = dms_client.get_all_repositories() + assert len(repos) > 0, "No repositories available for testing" + context.repo = repos[0] + # repo.id (UUID) is used in the CMIS browser URL; cmis_repository_id is the root folder objectId + context.repo_id = repos[0].id + + +@given("I select a version-enabled repository") +def select_version_repo(context: DMSTestContext, dms_client: DMSClient): + """Select a repository that has versioning enabled.""" + repos = dms_client.get_all_repositories() + version_repo = None + for r in repos: + if r.get_param("isVersionEnabled") in (True, "true", "True"): + version_repo = r + break + if version_repo is None: + pytest.skip("No version-enabled repository available") # ty: ignore[invalid-argument-type, too-many-positional-arguments] + context.repo = version_repo + context.repo_id = version_repo.id + + +@given("I use the root folder as parent") +def use_root_folder(context: DMSTestContext, dms_client: DMSClient): + """Get the root folder ID for the selected repository.""" + # The CMIS root folder objectId is the cmis_repository_id; repo.id (UUID) is used for the URL + context.root_folder_id = context.repo.cmis_repository_id # ty: ignore[unresolved-attribute] + + +# ==================== CONFIG: GIVEN ==================== + + +@given(parsers.parse('I have a config named "{name}" with value "{value}"')) +def have_config(context: DMSTestContext, name: str, value: str): + """Prepare a configuration request.""" + context.config = None # Will be set after creation + context._config_request = CreateConfigRequest(config_name=name, config_value=value) + + +# ==================== DOCUMENT CONTENT: GIVEN ==================== + + +@given(parsers.parse('I have document content "{content}"')) +def have_document_content(context: DMSTestContext, content: str): + """Set up content bytes for document upload.""" + context.content_bytes = content.encode("utf-8") + + +# ==================== SETUP STEPS (Given with side effects) ==================== + + +@given(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) +def given_upload_document( + context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str +): + """Upload a document as a prerequisite step.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] + mime_type=mime_type, + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + + +@given(parsers.parse('I create a folder named "{name}"')) +def given_create_folder(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a folder as a prerequisite step.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + + +@given(parsers.parse('I create a child document "{name}" in the folder')) +def create_child_document(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a child document inside the previously created folder.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] + unique_name, + io.BytesIO(b"child document content"), + mime_type="text/plain", + ) + context.child_doc_id = doc.object_id + # Child will be cleaned up when its parent folder is deleted + + +# ==================== REPOSITORY: WHEN ==================== + + +@when("I list all repositories") +def list_repos(context: DMSTestContext, dms_client: DMSClient): + """List all repositories.""" + try: + context.repos = dms_client.get_all_repositories() + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get repository details") +def get_repo_details(context: DMSTestContext, dms_client: DMSClient): + """Get details of the selected repository.""" + try: + context.repo = dms_client.get_repository(context.repo.id) # ty: ignore[unresolved-attribute] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== CONFIG: WHEN ==================== + + +@when("I create the configuration") +def create_config(context: DMSTestContext, dms_client: DMSClient): + """Create a repository configuration.""" + try: + context.config = dms_client.create_config(context._config_request) # ty: ignore[invalid-argument-type] + context.cleanup_configs.append(context.config.id) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I delete the created configuration") +def delete_config(context: DMSTestContext, dms_client: DMSClient): + """Delete the previously created configuration.""" + context.operation_error = None + try: + dms_client.delete_config(context.config.id) # ty: ignore[unresolved-attribute] + if context.config.id in context.cleanup_configs: # ty: ignore[unresolved-attribute] + context.cleanup_configs.remove(context.config.id) # ty: ignore[unresolved-attribute] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I list all configurations") +def list_configs(context: DMSTestContext, dms_client: DMSClient): + """List all configurations.""" + try: + context.configs = dms_client.get_configs() + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== FOLDER: WHEN ==================== + + +@when(parsers.parse('I create a folder named "{name}"')) +def when_create_folder(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a folder.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I create a folder named "{name}" with description "{desc}"')) +def create_folder_with_desc( + context: DMSTestContext, dms_client: DMSClient, name: str, desc: str +): + """Create a folder with a description.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + description=desc, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== DOCUMENT: WHEN ==================== + + +@when(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) +def upload_document( + context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str +): + """Upload a document with specified mime type.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] + mime_type=mime_type, + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I upload a document named "{name}" without specifying mime type')) +def upload_document_no_mime(context: DMSTestContext, dms_client: DMSClient, name: str): + """Upload a document without explicit mime type.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== READ: WHEN ==================== + + +@when("I get the object by its ID") +def get_object_by_id(context: DMSTestContext, dms_client: DMSClient): + """Get an object by its ID (document context).""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get the folder object by its ID") +def get_folder_by_id(context: DMSTestContext, dms_client: DMSClient): + """Get a folder by its ID.""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get the object by its ID with ACL included") +def get_object_with_acl(context: DMSTestContext, dms_client: DMSClient): + """Get an object with ACL data included.""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + include_acl=True, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I download the document content") +def download_content(context: DMSTestContext, dms_client: DMSClient): + """Download the content of a document.""" + try: + context.content_response = dms_client.get_content( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + download="attachment", + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I list children of the folder") +def list_children(context: DMSTestContext, dms_client: DMSClient): + """List children of the created folder.""" + try: + context.children_page = dms_client.get_children( + context.repo_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse("I list children of the root folder with max items {max_items:d}")) +def list_children_paginated( + context: DMSTestContext, dms_client: DMSClient, max_items: int +): + """List children with pagination.""" + try: + opts = ChildrenOptions(max_items=max_items) + context.children_page = dms_client.get_children( + context.repo_id, context.root_folder_id, options=opts + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== UPDATE: WHEN ==================== + + +@when(parsers.parse('I update the object name to "{new_name}"')) +def update_object_name(context: DMSTestContext, dms_client: DMSClient, new_name: str): + """Update the name of a document.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{new_name}" + context.updated_object = dms_client.update_properties( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + {"cmis:name": unique_name}, + ) + context._expected_updated_name = unique_name + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== VERSIONING: WHEN ==================== + + +@when("I check out the document") +def check_out_document(context: DMSTestContext, dms_client: DMSClient): + """Check out a document.""" + try: + context.pwc = dms_client.check_out( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I cancel the check out") +def cancel_check_out(context: DMSTestContext, dms_client: DMSClient): + """Cancel a check out.""" + try: + dms_client.cancel_check_out( + context.repo_id, + context.pwc.object_id, # ty: ignore[unresolved-attribute] + ) + context.pwc = None + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I check in with content "{content}" and comment "{comment}"')) +def check_in_document( + context: DMSTestContext, dms_client: DMSClient, content: str, comment: str +): + """Check in the PWC with new content.""" + try: + context.checked_in_doc = dms_client.check_in( + context.repo_id, + context.pwc.object_id, # ty: ignore[unresolved-attribute] + major=True, + file=io.BytesIO(content.encode("utf-8")), + file_name=context.document.name, # ty: ignore[unresolved-attribute] + mime_type="text/plain", + checkin_comment=comment, + ) + context.pwc = None + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== ACL: WHEN ==================== + + +@when("I get the ACL for the document") +def get_acl(context: DMSTestContext, dms_client: DMSClient): + """Get ACL for a document.""" + try: + context.acl = dms_client.apply_acl( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== ERROR: WHEN ==================== + + +@when("I attempt to get a non-existent object") +def get_nonexistent_object(context: DMSTestContext, dms_client: DMSClient): + """Try to get an object that does not exist.""" + try: + dms_client.get_object(context.repo_id, "nonexistent-object-id-12345") + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +@when("I attempt to download a non-existent document") +def download_nonexistent(context: DMSTestContext, dms_client: DMSClient): + """Try to download content of a non-existent document.""" + try: + resp = dms_client.get_content(context.repo_id, "nonexistent-doc-id-12345") + resp.close() + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +# ==================== REPOSITORY: THEN ==================== + + +@then("the repository list should be retrieved successfully") +def repo_list_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then(parsers.parse("the list should contain at least {count:d} repository")) +def repo_list_count(context: DMSTestContext, count: int): + assert len(context.repos) >= count + + +@then("the repository details should be retrieved successfully") +def repo_details_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.repo is not None + + +@then("the repository should have a CMIS repository ID") +def repo_has_cmis_id(context: DMSTestContext): + assert context.repo.cmis_repository_id # ty: ignore[unresolved-attribute] + + +@then("the repository should have a name") +def repo_has_name(context: DMSTestContext): + assert context.repo.name # ty: ignore[unresolved-attribute] + + +# ==================== CONFIG: THEN ==================== + + +@then("the configuration creation should be successful") +def config_created(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.config is not None + + +@then("the configuration should have the expected name and value") +def config_values_match(context: DMSTestContext): + assert context.config.config_name == context._config_request.config_name # ty: ignore[unresolved-attribute] + assert str(context.config.config_value) == str(context._config_request.config_value) # ty: ignore[unresolved-attribute] + + +@then("the configuration deletion should be successful") +def config_deleted(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then("the configuration list should be retrieved successfully") +def config_list_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert isinstance(context.configs, list) + + +# ==================== FOLDER: THEN ==================== + + +@then("the folder creation should be successful") +def folder_created(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.folder is not None + assert isinstance(context.folder, Folder) + + +@then("the created folder should have the correct name") +def folder_name_correct(context: DMSTestContext): + assert context.folder.name # ty: ignore[unresolved-attribute] + # Name starts with UUID prefix, just verify it's set + assert len(context.folder.name) > 0 # ty: ignore[unresolved-attribute] + + +# ==================== DOCUMENT: THEN ==================== + + +@then("the document upload should be successful") +def doc_uploaded(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.document is not None + assert isinstance(context.document, Document) + + +@then("the uploaded document should have the correct name") +def doc_name_correct(context: DMSTestContext): + assert context.document.name # ty: ignore[unresolved-attribute] + assert len(context.document.name) > 0 # ty: ignore[unresolved-attribute] + + +@then(parsers.parse('the document should have mime type "{expected_mime}"')) +def doc_mime_type(context: DMSTestContext, expected_mime: str): + assert context.document.content_stream_mime_type == expected_mime # ty: ignore[unresolved-attribute] + + +@then("the document should have a mime type assigned by the server") +def doc_has_any_mime_type(context: DMSTestContext): + assert context.document.content_stream_mime_type is not None # ty: ignore[unresolved-attribute] + + +# ==================== READ: THEN ==================== + + +@then("the object should be retrieved successfully") +def object_retrieved(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.retrieved_object is not None + + +@then("the object should be a Document") +def object_is_document(context: DMSTestContext): + assert isinstance(context.retrieved_object, Document) + + +@then("the object should be a Folder") +def object_is_folder(context: DMSTestContext): + assert isinstance(context.retrieved_object, Folder) + + +@then(parsers.parse('the object name should be "{expected_name}"')) +def object_name_matches(context: DMSTestContext, expected_name: str): + # Name has UUID prefix, so check suffix + assert context.retrieved_object.name.endswith(expected_name) # ty: ignore[unresolved-attribute] + + +@then("the download should be successful") +def download_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.content_response is not None + + +@then(parsers.parse('the downloaded content should match "{expected}"')) +def download_content_match(context: DMSTestContext, expected: str): + actual = context.content_response.content.decode("utf-8") # ty: ignore[unresolved-attribute] + assert actual == expected + + +@then("the children list should be retrieved successfully") +def children_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.children_page is not None + assert isinstance(context.children_page, ChildrenPage) + + +@then(parsers.parse("the children should contain at least {count:d} item")) +def children_count(context: DMSTestContext, count: int): + assert len(context.children_page.objects) >= count # ty: ignore[unresolved-attribute] + + +# ==================== UPDATE: THEN ==================== + + +@then("the update should be successful") +def update_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.updated_object is not None + + +@then(parsers.parse('the updated object name should be "{expected_name}"')) +def updated_name_matches(context: DMSTestContext, expected_name: str): + # Actual name has UUID prefix + assert context.updated_object.name == context._expected_updated_name # ty: ignore[unresolved-attribute] + + +# ==================== VERSIONING: THEN ==================== + + +@then("the check out should be successful") +def checkout_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.pwc is not None + + +@then("the PWC should be a private working copy") +def pwc_is_private(context: DMSTestContext): + assert context.pwc.is_private_working_copy is True # ty: ignore[unresolved-attribute] + + +@then("the cancel check out should be successful") +def cancel_checkout_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.pwc is None + + +@then("the check in should be successful") +def checkin_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.checked_in_doc is not None + + +@then("the new version label should not be empty") +def version_label_set(context: DMSTestContext): + assert context.checked_in_doc.version_label # ty: ignore[unresolved-attribute] + + +# ==================== ACL: THEN ==================== + + +@then("the ACL should be retrieved successfully") +def acl_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.acl is not None + assert isinstance(context.acl, Acl) + + +# ==================== ERROR: THEN ==================== + + +@then("the operation should fail with a not found error") +def not_found_error(context: DMSTestContext): + assert context.operation_error is not None, "Expected an error but none occurred" + assert isinstance(context.operation_error, (DMSObjectNotFoundException, DMSError)) + + +# ==================== CLEANUP STEPS ==================== + + +@then("I clean up the created folder") +def cleanup_folder(context: DMSTestContext, dms_client: DMSClient): + """Delete the folder created during the test.""" + if context.folder: + try: + _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) + _remove_from_cleanup(context, context.folder.object_id) + except Exception as e: + logger.warning( + "Cleanup failed for folder %s: %s", context.folder.object_id, e + ) + + +@then("I clean up the created document") +def cleanup_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the document created during the test.""" + if context.document: + try: + _delete_cmis_object(dms_client, context.repo_id, context.document.object_id) + _remove_from_cleanup(context, context.document.object_id) + except Exception as e: + logger.warning( + "Cleanup failed for document %s: %s", context.document.object_id, e + ) + + +@then("I clean up the updated document") +def cleanup_updated_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the updated document.""" + obj_id = ( + context.updated_object.object_id + if context.updated_object + else (context.document.object_id if context.document else None) + ) + if obj_id: + try: + _delete_cmis_object(dms_client, context.repo_id, obj_id) + _remove_from_cleanup(context, obj_id) + except Exception as e: + logger.warning("Cleanup failed for document %s: %s", obj_id, e) + + +@then("I clean up the children folder") +def cleanup_children_folder(context: DMSTestContext, dms_client: DMSClient): + """Delete the folder and its children.""" + # Delete child document first + if context.child_doc_id: + try: + _delete_cmis_object(dms_client, context.repo_id, context.child_doc_id) + except Exception as e: + logger.warning( + "Cleanup failed for child doc %s: %s", context.child_doc_id, e + ) + # Then delete the parent folder + if context.folder: + try: + _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) + _remove_from_cleanup(context, context.folder.object_id) + except Exception as e: + logger.warning( + "Cleanup failed for folder %s: %s", context.folder.object_id, e + ) + + +# ==================== HELPERS ==================== + + +def _delete_cmis_object(client: DMSClient, repo_id: str, object_id: str): + """Delete a CMIS object via the SDK's delete_object method.""" + client.delete_object(repo_id, object_id) + + +def _remove_from_cleanup(context: DMSTestContext, object_id: str): + """Remove an object from the cleanup list after successful deletion.""" + context.cleanup_objects = [ + (r, o) for r, o in context.cleanup_objects if o != object_id + ] + + +# ==================== DELETE / RESTORE: WHEN ==================== + + +@when("I delete the document") +def delete_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the uploaded document.""" + try: + dms_client.delete_object( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + ) + context._deleted_doc_id = context.document.object_id # ty: ignore[unresolved-attribute] + _remove_from_cleanup(context, context.document.object_id) # ty: ignore[unresolved-attribute] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I attempt to get the deleted document") +def get_deleted_document(context: DMSTestContext, dms_client: DMSClient): + """Try to get the previously deleted document.""" + context.operation_error = None + try: + dms_client.get_object(context.repo_id, context._deleted_doc_id) # ty: ignore[invalid-argument-type] + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +@when("I restore the deleted document") +def restore_document(context: DMSTestContext, dms_client: DMSClient): + """Restore the previously deleted document.""" + context.operation_error = None + try: + context.restore_message = dms_client.restore_object( + context.repo_id, + context._deleted_doc_id, # ty: ignore[invalid-argument-type] + ) + # Re-add to cleanup since it's restored + context.cleanup_objects.append((context.repo_id, context._deleted_doc_id)) # ty: ignore[invalid-argument-type] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== APPEND CONTENT: WHEN ==================== + + +@when(parsers.parse('I append content "{content}" to the document')) +def append_content(context: DMSTestContext, dms_client: DMSClient, content: str): + """Append content to the uploaded document.""" + try: + context.appended_document = dms_client.append_content_stream( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + io.BytesIO(content.encode("utf-8")), + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I append content "{content}" as the last chunk')) +def append_last_chunk(context: DMSTestContext, dms_client: DMSClient, content: str): + """Append content as the last chunk.""" + try: + context.appended_document = dms_client.append_content_stream( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + io.BytesIO(content.encode("utf-8")), + is_last_chunk=True, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== CMIS QUERY: WHEN ==================== + + +@when(parsers.parse('I execute a CMIS query for documents named "{name_prefix}"')) +def cmis_query_by_name(context: DMSTestContext, dms_client: DMSClient, name_prefix: str): + """Execute a CMIS query filtering by document name.""" + try: + statement = f"SELECT * FROM cmis:document WHERE cmis:name LIKE '{name_prefix}%'" + context.query_result = dms_client.cmis_query( + context.repo_id, + statement, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse("I execute a CMIS query for all documents with max items {max_items:d}")) +def cmis_query_paginated(context: DMSTestContext, dms_client: DMSClient, max_items: int): + """Execute a CMIS query with pagination.""" + try: + opts = QueryOptions(max_items=max_items) + context.query_result = dms_client.cmis_query( + context.repo_id, + "SELECT * FROM cmis:document", + options=opts, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== DELETE / RESTORE: THEN ==================== + + +@then("the delete should be successful") +def delete_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then("the restore should be successful") +def restore_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.restore_message is not None + + +# ==================== APPEND: THEN ==================== + + +@then("the append should be successful") +def append_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.appended_document is not None + + +@then("the appended document should be a Document") +def appended_is_document(context: DMSTestContext): + assert isinstance(context.appended_document, Document) + + +# ==================== QUERY: THEN ==================== + + +@then("the query should be successful") +def query_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.query_result is not None + assert isinstance(context.query_result, QueryResultPage) + + +@then(parsers.parse("the query results should contain at least {count:d} item")) +def query_results_count(context: DMSTestContext, count: int): + assert len(context.query_result.results) >= count # ty: ignore[unresolved-attribute] diff --git a/tests/dms/unit/__init__.py b/tests/dms/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dms/unit/test_client_admin.py b/tests/dms/unit/test_client_admin.py new file mode 100644 index 0000000..5b783c2 --- /dev/null +++ b/tests/dms/unit/test_client_admin.py @@ -0,0 +1,446 @@ +"""Unit tests for DMSClient admin operations (repositories & configs). + +Tests mock HttpInvoker to verify: +- Correct endpoint paths +- Correct payload construction +- Correct response parsing into typed models +- Tenant and user_claim forwarding +- Input validation +""" + +from unittest.mock import Mock, patch + +import pytest + +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.model import ( + CreateConfigRequest, + DMSCredentials, + InternalRepoRequest, + Repository, + RepositoryConfig, + UpdateConfigRequest, + UpdateRepoRequest, + UserClaim, +) + + +# --------------------------------------------------------------- +# Helper +# --------------------------------------------------------------- + + +def _mock_response(data, status_code=200): + resp = Mock() + resp.json.return_value = data + resp.status_code = status_code + return resp + + +# --------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------- + +_REPO_RESPONSE = { + "cmisRepositoryId": "cmis-repo-1", + "createdTime": "2025-06-01T10:00:00Z", + "id": "repo-uuid-1", + "lastUpdatedTime": "2025-06-01T12:00:00Z", + "name": "TestRepo", + "repositoryCategory": "Collaboration", + "repositoryParams": [ + {"paramName": "isVersionEnabled", "paramValue": "true"}, + ], + "repositorySubType": "internal", + "repositoryType": "internal", +} + +_CONFIG_RESPONSE = { + "id": "cfg-uuid-1", + "configName": "blockedFileExtensions", + "configValue": "exe,bat", + "createdTime": "2025-06-01T10:00:00Z", + "lastUpdatedTime": "2025-06-01T12:00:00Z", + "serviceInstanceId": "svc-inst-1", +} + + +@pytest.fixture +def client(): + with patch("sap_cloud_sdk.dms.client.Auth"): + with patch("sap_cloud_sdk.dms.client.HttpInvoker") as MockHttp: + mock_http = Mock() + MockHttp.return_value = mock_http + creds = DMSCredentials( + instance_name="test-instance", + uri="https://api.example.com", + client_id="test-client", + client_secret="test-secret", + token_url="https://auth.example.com/oauth/token", + identityzone="test-zone", + ) + c = DMSClient(creds) + c._mock_http = mock_http # ty: ignore[unresolved-attribute] + yield c + + +# --------------------------------------------------------------- +# onboard_repository +# --------------------------------------------------------------- + + +class TestOnboardRepository: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="TestRepo") + + repo = client.onboard_repository(request) + + assert isinstance(repo, Repository) + assert repo.id == "repo-uuid-1" + assert repo.name == "TestRepo" + + call_args = client._mock_http.post.call_args + assert call_args[1]["path"] == "/rest/v2/repositories" + payload = call_args[1]["payload"] + assert "repository" in payload + assert payload["repository"]["displayName"] == "TestRepo" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="TestRepo") + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.onboard_repository(request, tenant="t1", user_claim=claim) + + call_args = client._mock_http.post.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + def test_with_versioning_enabled(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="VersRepo", isVersionEnabled=True) + + client.onboard_repository(request) + + payload = client._mock_http.post.call_args[1]["payload"] + assert payload["repository"]["isVersionEnabled"] is True + + +# --------------------------------------------------------------- +# get_all_repositories +# --------------------------------------------------------------- + + +class TestGetAllRepositories: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response( + { + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + ] + } + ) + + repos = client.get_all_repositories() + + assert len(repos) == 1 + assert isinstance(repos[0], Repository) + assert repos[0].id == "repo-uuid-1" + + call_args = client._mock_http.get.call_args + assert call_args[1]["path"] == "/rest/v2/repositories" + assert ( + call_args[1]["headers"]["Accept"] + == "application/vnd.sap.sdm.repositories+json;version=3" + ) + + def test_empty_list(self, client): + client._mock_http.get.return_value = _mock_response( + {"repoAndConnectionInfos": []} + ) + + repos = client.get_all_repositories() + + assert repos == [] + + def test_multiple_repos(self, client): + repo2 = {**_REPO_RESPONSE, "id": "repo-uuid-2", "name": "Repo2"} + client._mock_http.get.return_value = _mock_response( + { + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + {"repository": repo2}, + ] + } + ) + + repos = client.get_all_repositories() + + assert len(repos) == 2 + assert repos[1].id == "repo-uuid-2" + + def test_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response( + {"repoAndConnectionInfos": []} + ) + + client.get_all_repositories(tenant="sub1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "sub1" + + +# --------------------------------------------------------------- +# get_repository +# --------------------------------------------------------------- + + +class TestGetRepository: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response( + {"repository": _REPO_RESPONSE} + ) + + repo = client.get_repository("repo-uuid-1") + + assert isinstance(repo, Repository) + assert repo.name == "TestRepo" + assert ( + client._mock_http.get.call_args[1]["path"] + == "/rest/v2/repositories/repo-uuid-1" + ) + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response( + {"repository": _REPO_RESPONSE} + ) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.get_repository("repo-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# update_repository +# --------------------------------------------------------------- + + +class TestUpdateRepository: + def test_basic(self, client): + client._mock_http.put.return_value = _mock_response(_REPO_RESPONSE) + request = UpdateRepoRequest(description="Updated desc") + + repo = client.update_repository("repo-uuid-1", request) + + assert isinstance(repo, Repository) + call_args = client._mock_http.put.call_args + assert call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + payload = call_args[1]["payload"] + assert "repository" in payload + assert payload["repository"]["description"] == "Updated desc" + + def test_empty_repo_id_raises_value_error(self, client): + request = UpdateRepoRequest(description="x") + + with pytest.raises(ValueError, match="repo_id must not be empty"): + client.update_repository("", request) + + def test_whitespace_repo_id_raises_value_error(self, client): + request = UpdateRepoRequest(description="x") + + with pytest.raises(ValueError, match="repo_id must not be empty"): + client.update_repository(" ", request) + + def test_with_tenant(self, client): + client._mock_http.put.return_value = _mock_response(_REPO_RESPONSE) + request = UpdateRepoRequest(description="d") + + client.update_repository("repo-uuid-1", request, tenant="t1") + + assert client._mock_http.put.call_args[1]["tenant_subdomain"] == "t1" + + +# --------------------------------------------------------------- +# delete_repository +# --------------------------------------------------------------- + + +class TestDeleteRepository: + def test_basic(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + + client.delete_repository("repo-uuid-1") + + call_args = client._mock_http.delete.call_args + assert call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.delete_repository("repo-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.delete.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# create_config +# --------------------------------------------------------------- + + +class TestCreateConfig: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) + request = CreateConfigRequest( + config_name="blockedFileExtensions", + config_value="exe,bat", + ) + + config = client.create_config(request) + + assert isinstance(config, RepositoryConfig) + assert config.id == "cfg-uuid-1" + assert config.config_name == "blockedFileExtensions" + assert config.config_value == "exe,bat" + + call_args = client._mock_http.post.call_args + assert call_args[1]["path"] == "/rest/v2/configs" + payload = call_args[1]["payload"] + assert payload["configName"] == "blockedFileExtensions" + assert payload["configValue"] == "exe,bat" + + def test_with_tenant(self, client): + client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) + request = CreateConfigRequest( + config_name="blockedFileExtensions", + config_value="exe", + ) + + client.create_config(request, tenant="sub1") + + assert client._mock_http.post.call_args[1]["tenant_subdomain"] == "sub1" + + +# --------------------------------------------------------------- +# get_configs +# --------------------------------------------------------------- + + +class TestGetConfigs: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE]) + + configs = client.get_configs() + + assert len(configs) == 1 + assert isinstance(configs[0], RepositoryConfig) + assert configs[0].config_name == "blockedFileExtensions" + assert client._mock_http.get.call_args[1]["path"] == "/rest/v2/configs" + + def test_empty_list(self, client): + client._mock_http.get.return_value = _mock_response([]) + + configs = client.get_configs() + + assert configs == [] + + def test_multiple_configs(self, client): + cfg2 = { + **_CONFIG_RESPONSE, + "id": "cfg-uuid-2", + "configName": "tempspaceMaxContentSize", + } + client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE, cfg2]) + + configs = client.get_configs() + + assert len(configs) == 2 + assert configs[1].config_name == "tempspaceMaxContentSize" + + def test_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response([]) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.get_configs(user_claim=claim) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# update_config +# --------------------------------------------------------------- + + +class TestUpdateConfig: + def test_basic(self, client): + updated = {**_CONFIG_RESPONSE, "configValue": "exe,bat,sh"} + client._mock_http.put.return_value = _mock_response(updated) + request = UpdateConfigRequest( + id="cfg-uuid-1", + config_name="blockedFileExtensions", + config_value="exe,bat,sh", + ) + + config = client.update_config("cfg-uuid-1", request) + + assert isinstance(config, RepositoryConfig) + assert config.config_value == "exe,bat,sh" + + call_args = client._mock_http.put.call_args + assert call_args[1]["path"] == "/rest/v2/configs/cfg-uuid-1" + + def test_empty_config_id_raises_value_error(self, client): + request = UpdateConfigRequest( + id="x", + config_name="n", + config_value="v", + ) + + with pytest.raises(ValueError, match="config_id must not be empty"): + client.update_config("", request) + + def test_with_tenant(self, client): + client._mock_http.put.return_value = _mock_response(_CONFIG_RESPONSE) + request = UpdateConfigRequest( + id="cfg-uuid-1", + config_name="n", + config_value="v", + ) + + client.update_config("cfg-uuid-1", request, tenant="t1") + + assert client._mock_http.put.call_args[1]["tenant_subdomain"] == "t1" + + +# --------------------------------------------------------------- +# delete_config +# --------------------------------------------------------------- + + +class TestDeleteConfig: + def test_basic(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + + client.delete_config("cfg-uuid-1") + + call_args = client._mock_http.delete.call_args + assert call_args[1]["path"] == "/rest/v2/configs/cfg-uuid-1" + + def test_empty_config_id_raises_value_error(self, client): + with pytest.raises(ValueError, match="config_id must not be empty"): + client.delete_config("") + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.delete_config("cfg-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.delete.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py new file mode 100644 index 0000000..1d9c59c --- /dev/null +++ b/tests/dms/unit/test_client_cmis.py @@ -0,0 +1,1280 @@ +"""Unit tests for DMSClient CMIS operations. + +Tests mock HttpInvoker to verify: +- Correct URL construction +- Correct form-data encoding (cmisaction, objectId, indexed properties) +- Correct file tuple for upload methods +- Response parsing into typed models +""" + +from io import BytesIO +from unittest.mock import Mock, patch + +import pytest + +from sap_cloud_sdk.dms.client import DMSClient, _build_properties, _build_aces +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenPage, + ChildrenOptions, + CmisObject, + DMSCredentials, + Document, + Folder, + QueryOptions, + QueryResultPage, + UserClaim, +) + + +# --------------------------------------------------------------- +# Helper to wrap a dict in a mock Response with .json() +# --------------------------------------------------------------- + + +def _mock_response(data): + """Create a Mock that behaves like requests.Response with .json() returning *data*.""" + resp = Mock() + resp.json.return_value = data + resp.status_code = 200 + return resp + + +# --------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------- + +_FOLDER_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "folder-abc", + "cmis:name": "NewFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + "cmis:createdBy": "admin", + "cmis:creationDate": 1705320000000, + } +} + +_DOCUMENT_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "doc-xyz", + "cmis:name": "report.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 2048, + "cmis:contentStreamMimeType": "application/pdf", + "cmis:contentStreamFileName": "report.pdf", + "cmis:versionSeriesId": "vs-1", + "cmis:versionLabel": "1.0", + "cmis:isLatestVersion": True, + "cmis:isMajorVersion": True, + "cmis:isPrivateWorkingCopy": False, + } +} + +_PWC_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "pwc-001", + "cmis:name": "report.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:isPrivateWorkingCopy": True, + "cmis:isVersionSeriesCheckedOut": True, + } +} + +_ACL_RESPONSE = { + "aces": [ + { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read"], + "isDirect": True, + } + ], + "isExact": True, +} + + +@pytest.fixture +def client(): + """Create a DMSClient with a mocked HttpInvoker.""" + creds = DMSCredentials( + instance_name="test-instance", + uri="https://api.example.com", + client_id="test-client", + client_secret="test-secret", + token_url="https://auth.example.com/oauth/token", + identityzone="test-zone", + ) + with ( + patch("sap_cloud_sdk.dms.client.Auth"), + patch("sap_cloud_sdk.dms.client.HttpInvoker") as mock_http_cls, + ): + mock_http = Mock() + mock_http_cls.return_value = mock_http + c = DMSClient(creds) + # Expose the mock for assertions + c._mock_http = mock_http # ty: ignore[unresolved-attribute] + yield c + + +# --------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------- + + +class TestBuildProperties: + def test_single_property(self): + result = _build_properties({"cmis:name": "Doc"}) + assert result == { + "propertyId[0]": "cmis:name", + "propertyValue[0]": "Doc", + } + + def test_multiple_properties(self): + result = _build_properties( + { + "cmis:name": "Doc", + "cmis:objectTypeId": "cmis:document", + "cmis:description": "A doc", + } + ) + assert result["propertyId[0]"] == "cmis:name" + assert result["propertyValue[0]"] == "Doc" + assert result["propertyId[1]"] == "cmis:objectTypeId" + assert result["propertyValue[1]"] == "cmis:document" + assert result["propertyId[2]"] == "cmis:description" + assert result["propertyValue[2]"] == "A doc" + + def test_empty_properties(self): + assert _build_properties({}) == {} + + def test_integer_value_coerced_to_string(self): + result = _build_properties({"cmis:contentStreamLength": "1024"}) + assert result["propertyValue[0]"] == "1024" + + +class TestBuildAces: + def test_single_ace_single_permission(self): + ace = Ace(principal_id="user1", permissions=["cmis:read"]) + result = _build_aces([ace], prefix="addACEPrincipal") + assert result == { + "addACEPrincipal[0]": "user1", + "addACEPermission[0][0]": "cmis:read", + } + + def test_single_ace_multiple_permissions(self): + ace = Ace(principal_id="user1", permissions=["cmis:read", "cmis:write"]) + result = _build_aces([ace], prefix="addACEPrincipal") + assert result == { + "addACEPrincipal[0]": "user1", + "addACEPermission[0][0]": "cmis:read", + "addACEPermission[0][1]": "cmis:write", + } + + def test_multiple_aces(self): + aces = [ + Ace(principal_id="user1", permissions=["cmis:read"]), + Ace(principal_id="user2", permissions=["cmis:all"]), + ] + result = _build_aces(aces, prefix="removeACEPrincipal") + assert result["removeACEPrincipal[0]"] == "user1" + assert result["removeACEPermission[0][0]"] == "cmis:read" + assert result["removeACEPrincipal[1]"] == "user2" + assert result["removeACEPermission[1][0]"] == "cmis:all" + + def test_empty_aces(self): + assert _build_aces([], prefix="addACEPrincipal") == {} + + +class TestBrowserUrl: + def test_no_path(self): + assert DMSClient._browser_url("repo1") == "/browser/repo1/root" + + def test_with_path(self): + assert ( + DMSClient._browser_url("repo1", "sub/folder") + == "/browser/repo1/root/sub/folder" + ) + + def test_strips_leading_slash(self): + assert DMSClient._browser_url("repo1", "/sub") == "/browser/repo1/root/sub" + + def test_none_path_same_as_no_path(self): + assert DMSClient._browser_url("repo1", None) == "/browser/repo1/root" + + +# --------------------------------------------------------------- +# create_folder +# --------------------------------------------------------------- + + +class TestCreateFolder: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + folder = client.create_folder("repo1", "parent-id", "NewFolder") + + assert isinstance(folder, Folder) + assert folder.object_id == "folder-abc" + assert folder.name == "NewFolder" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "createFolder" + assert data["objectId"] == "parent-id" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "NewFolder" + assert data["propertyId[1]"] == "cmis:objectTypeId" + assert data["propertyValue[1]"] == "cmis:folder" + assert data["_charset_"] == "UTF-8" + assert call_args[1]["tenant_subdomain"] is None + + def test_with_description(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + client.create_folder("repo1", "parent-id", "NewFolder", description="Desc") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[2]"] == "cmis:description" + assert data["propertyValue[2]"] == "Desc" + + def test_with_path_and_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + client.create_folder("repo1", "parent-id", "F", path="sub/dir", tenant="t1") + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root/sub/dir" + assert call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + claim = UserClaim( + x_ecm_user_enc="alice@sap.com", x_ecm_add_principals=["~editors"] + ) + + client.create_folder("repo1", "parent-id", "F", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# create_document +# --------------------------------------------------------------- + + +class TestCreateDocument: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"hello world") + + doc = client.create_document( + "repo1", "folder-id", "report.pdf", stream, mime_type="application/pdf" + ) + + assert isinstance(doc, Document) + assert doc.object_id == "doc-xyz" + assert doc.content_stream_mime_type == "application/pdf" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "createDocument" + assert data["objectId"] == "folder-id" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "report.pdf" + assert data["propertyId[1]"] == "cmis:objectTypeId" + assert data["propertyValue[1]"] == "cmis:document" + + files_arg = call_args[1]["files"] + assert "media" in files_arg + assert files_arg["media"][0] == "report.pdf" + assert files_arg["media"][2] == "application/pdf" + + def test_with_description(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.create_document( + "repo1", + "folder-id", + "f.txt", + BytesIO(b""), + mime_type="text/plain", + description="D", + ) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[2]"] == "cmis:description" + assert data["propertyValue[2]"] == "D" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.create_document( + "repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", tenant="sub1" + ) + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.create_document( + "repo1", + "fid", + "f.txt", + BytesIO(b""), + mime_type="text/plain", + user_claim=claim, + ) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + def test_without_mime_type_uses_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"binary data") + + client.create_document("repo1", "fid", "data.bin", stream) + + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["media"][2] == "application/octet-stream" + + +# --------------------------------------------------------------- +# check_out +# --------------------------------------------------------------- + + +class TestCheckOut: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + + doc = client.check_out("repo1", "doc-xyz") + + assert isinstance(doc, Document) + assert doc.object_id == "pwc-001" + assert doc.is_private_working_copy is True + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "checkOut" + assert data["objectId"] == "doc-xyz" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + + client.check_out("repo1", "doc-xyz", tenant="t1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + claim = UserClaim(x_ecm_user_enc="dave@sap.com") + + client.check_out("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# check_in +# --------------------------------------------------------------- + + +class TestCheckIn: + def test_major_version_no_file(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + doc = client.check_in("repo1", "pwc-001") + + assert isinstance(doc, Document) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "checkIn" + assert data["objectId"] == "pwc-001" + assert data["major"] == "true" + assert client._mock_http.post_form.call_args[1]["files"] is None + + def test_minor_version(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.check_in("repo1", "pwc-001", major=False) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["major"] == "false" + + def test_with_file_and_comment(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"updated content") + + client.check_in( + "repo1", + "pwc-001", + file=stream, + file_name="report_v2.pdf", + mime_type="application/pdf", + checkin_comment="Version 2", + ) + + call_args = client._mock_http.post_form.call_args + data = call_args[1]["data"] + assert data["checkinComment"] == "Version 2" + + files_arg = call_args[1]["files"] + assert "content" in files_arg + assert files_arg["content"][0] == "report_v2.pdf" + assert files_arg["content"][2] == "application/pdf" + + def test_file_without_name_uses_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.check_in("repo1", "pwc-001", file=BytesIO(b"data")) + + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["content"][0] == "content" + assert files_arg["content"][2] == "application/octet-stream" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim( + x_ecm_user_enc="eve@sap.com", x_ecm_add_principals=["~reviewers"] + ) + + client.check_in("repo1", "pwc-001", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# cancel_check_out +# --------------------------------------------------------------- + + +class TestCancelCheckOut: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + result = client.cancel_check_out("repo1", "pwc-001") + + assert result is None + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "cancelCheckOut" + assert data["objectId"] == "pwc-001" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.cancel_check_out("repo1", "pwc-001", tenant="sub2") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub2" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + claim = UserClaim(x_ecm_user_enc="frank@sap.com") + + client.cancel_check_out("repo1", "pwc-001", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# apply_acl +# --------------------------------------------------------------- + + +class TestApplyAcl: + def test_get_acl_when_no_aces(self, client): + """No add/remove => GET current ACL.""" + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + + acl = client.apply_acl("repo1", "obj-99") + + assert isinstance(acl, Acl) + assert len(acl.aces) == 1 + assert acl.aces[0].principal_id == "user1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + assert call_args[1]["params"] == {"objectId": "obj-99", "cmisselector": "acl"} + + def test_get_acl_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + + client.apply_acl("repo1", "obj-99", tenant="t1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "t1" + + def test_get_acl_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + claim = UserClaim(x_ecm_user_enc="grace@sap.com") + + client.apply_acl("repo1", "obj-99", user_claim=claim) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim + + def test_add_aces_only(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="user1", permissions=["cmis:read", "cmis:write"])] + + acl = client.apply_acl("repo1", "obj-99", add_aces=aces) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "applyACL" + assert data["objectId"] == "obj-99" + assert data["ACLPropagation"] == "propagate" + assert data["addACEPrincipal[0]"] == "user1" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert data["addACEPermission[0][1]"] == "cmis:write" + assert not any(k.startswith("removeACE") for k in data) + + def test_remove_aces_only(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="user1", permissions=["cmis:write"])] + + acl = client.apply_acl("repo1", "obj-99", remove_aces=aces) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "applyACL" + assert data["removeACEPrincipal[0]"] == "user1" + assert data["removeACEPermission[0][0]"] == "cmis:write" + assert not any(k.startswith("addACE") for k in data) + + def test_add_and_remove_together(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + add = [Ace(principal_id="u1", permissions=["cmis:read"])] + remove = [Ace(principal_id="u2", permissions=["cmis:all"])] + + acl = client.apply_acl("repo1", "obj-99", add_aces=add, remove_aces=remove) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "u1" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert data["removeACEPrincipal[0]"] == "u2" + assert data["removeACEPermission[0][0]"] == "cmis:all" + + def test_multiple_add_aces(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [ + Ace(principal_id="u1", permissions=["cmis:read"]), + Ace(principal_id="u2", permissions=["cmis:all"]), + ] + + client.apply_acl("repo1", "obj-99", add_aces=aces) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "u1" + assert data["addACEPrincipal[1]"] == "u2" + + def test_with_tenant_on_modify(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + + client.apply_acl("repo1", "obj-99", remove_aces=aces, tenant="t1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim_on_modify(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + claim = UserClaim(x_ecm_user_enc="heidi@sap.com") + + client.apply_acl("repo1", "obj-99", add_aces=aces, user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + def test_custom_acl_propagation(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + + client.apply_acl("repo1", "obj-99", add_aces=aces, acl_propagation="objectonly") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["ACLPropagation"] == "objectonly" + + +# --------------------------------------------------------------- +# get_object +# --------------------------------------------------------------- + + +class TestGetObject: + def test_returns_folder_for_folder_type(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + obj = client.get_object("repo1", "folder-abc") + + assert isinstance(obj, Folder) + assert obj.object_id == "folder-abc" + assert obj.name == "NewFolder" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "folder-abc" + assert params["cmisselector"] == "object" + assert params["succinct"] == "true" + + def test_returns_document_for_document_type(self, client): + client._mock_http.get.return_value = _mock_response(_DOCUMENT_RESPONSE) + + obj = client.get_object("repo1", "doc-xyz") + + assert isinstance(obj, Document) + assert obj.object_id == "doc-xyz" + assert obj.content_stream_mime_type == "application/pdf" + + def test_returns_cmis_object_for_unknown_type(self, client): + client._mock_http.get.return_value = _mock_response( + { + "succinctProperties": { + "cmis:objectId": "item-1", + "cmis:name": "Item", + "cmis:baseTypeId": "cmis:item", + "cmis:objectTypeId": "cmis:item", + } + } + ) + + obj = client.get_object("repo1", "item-1") + + assert isinstance(obj, CmisObject) + assert not isinstance(obj, (Folder, Document)) + assert obj.object_id == "item-1" + + def test_with_filter_and_include_acl(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + client.get_object("repo1", "folder-abc", filter="*", include_acl=True) + + params = client._mock_http.get.call_args[1]["params"] + assert params["filter"] == "*" + assert params["includeACL"] == "true" + + def test_with_include_allowable_actions(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + client.get_object("repo1", "folder-abc", include_allowable_actions=True) + + params = client._mock_http.get.call_args[1]["params"] + assert params["includeAllowableActions"] == "true" + + def test_without_succinct(self, client): + resp = { + "properties": { + "cmis:objectId": {"value": "f1"}, + "cmis:name": {"value": "F"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + } + client._mock_http.get.return_value = _mock_response(resp) + + obj = client.get_object("repo1", "f1", succinct=False) + + params = client._mock_http.get.call_args[1]["params"] + assert "succinct" not in params + assert isinstance(obj, Folder) + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.get_object("repo1", "folder-abc", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# get_content +# --------------------------------------------------------------- + + +class TestGetContent: + def test_basic(self, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"PDF binary data" + client._mock_http.get_stream.return_value = mock_resp + + resp = client.get_content("repo1", "doc-xyz") + + assert resp is mock_resp + call_args = client._mock_http.get_stream.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "doc-xyz" + assert params["cmisselector"] == "content" + + def test_with_tenant(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.get_stream.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.get_content("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.get_stream.call_args[1]["user_claim"] is claim + + def test_with_download_attachment(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", download="attachment") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["download"] == "attachment" + + def test_no_download_by_default(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert "download" not in params + + def test_with_stream_id(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", stream_id="sap:zipRendition") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["streamId"] == "sap:zipRendition" + + def test_with_filename(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", filename="report.pdf") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["filename"] == "report.pdf" + + def test_no_stream_id_or_filename_by_default(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert "streamId" not in params + assert "filename" not in params + + +# --------------------------------------------------------------- +# update_properties +# --------------------------------------------------------------- + + +class TestUpdateProperties: + def test_basic_rename(self, client): + resp = { + "succinctProperties": { + "cmis:objectId": "doc-xyz", + "cmis:name": "Renamed.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 2048, + "cmis:contentStreamMimeType": "application/pdf", + } + } + client._mock_http.post_form.return_value = _mock_response(resp) + + obj = client.update_properties("repo1", "doc-xyz", {"cmis:name": "Renamed.pdf"}) + + assert isinstance(obj, Document) + assert obj.name == "Renamed.pdf" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "update" + assert data["objectId"] == "doc-xyz" + assert data["_charset_"] == "UTF-8" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "Renamed.pdf" + + def test_returns_folder_for_folder_type(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + obj = client.update_properties( + "repo1", "folder-abc", {"cmis:description": "Updated"} + ) + + assert isinstance(obj, Folder) + + def test_with_change_token(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties( + "repo1", "doc-xyz", {"cmis:name": "X"}, change_token="tok-123" + ) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["changeToken"] == "tok-123" + + def test_no_change_token_by_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}) + + data = client._mock_http.post_form.call_args[1]["data"] + assert "changeToken" not in data + + def test_multiple_properties(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties( + "repo1", + "doc-xyz", + { + "cmis:name": "New", + "cmis:description": "Desc", + }, + ) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "New" + assert data["propertyId[1]"] == "cmis:description" + assert data["propertyValue[1]"] == "Desc" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="charlie@sap.com") + + client.update_properties( + "repo1", "doc-xyz", {"cmis:name": "X"}, tenant="t1", user_claim=claim + ) + + call_args = client._mock_http.post_form.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# get_children +# --------------------------------------------------------------- + +_CHILDREN_RESPONSE = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "child-folder-1", + "cmis:name": "SubFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + } + } + }, + { + "object": { + "succinctProperties": { + "cmis:objectId": "child-doc-1", + "cmis:name": "readme.txt", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 512, + "cmis:contentStreamMimeType": "text/plain", + } + } + }, + ], + "hasMoreItems": True, + "numItems": 42, +} + + +class TestGetChildren: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response(_CHILDREN_RESPONSE) + + page = client.get_children("repo1", "root-folder-id") + + assert isinstance(page, ChildrenPage) + assert len(page.objects) == 2 + assert page.has_more_items is True + assert page.num_items == 42 + + # First child is Folder + assert isinstance(page.objects[0], Folder) + assert page.objects[0].object_id == "child-folder-1" + + # Second child is Document + assert isinstance(page.objects[1], Document) + assert page.objects[1].object_id == "child-doc-1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "root-folder-id" + assert params["cmisselector"] == "children" + assert params["maxItems"] == "100" + assert params["skipCount"] == "0" + assert params["succinct"] == "true" + + def test_pagination_params_via_options(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + + opts = ChildrenOptions(max_items=50, skip_count=200) + client.get_children("repo1", "fid", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["maxItems"] == "50" + assert params["skipCount"] == "200" + + def test_with_order_by(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + + opts = ChildrenOptions(order_by="cmis:creationDate ASC") + client.get_children("repo1", "fid", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["orderBy"] == "cmis:creationDate ASC" + + def test_with_filter(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + + opts = ChildrenOptions(filter="cmis:name,cmis:objectId") + client.get_children("repo1", "fid", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["filter"] == "cmis:name,cmis:objectId" + + def test_with_include_allowable_and_path_segment(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + + opts = ChildrenOptions( + include_allowable_actions=True, include_path_segment=True + ) + client.get_children("repo1", "fid", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["includeAllowableActions"] == "true" + assert params["includePathSegment"] == "true" + + def test_no_optional_params_omitted(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + + client.get_children("repo1", "fid") + + params = client._mock_http.get.call_args[1]["params"] + assert "orderBy" not in params + assert "filter" not in params + assert "includeAllowableActions" not in params + assert "includePathSegment" not in params + + def test_empty_children(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False, "numItems": 0} + ) + + page = client.get_children("repo1", "fid") + + assert page.objects == [] + assert page.has_more_items is False + assert page.num_items == 0 + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) + claim = UserClaim(x_ecm_user_enc="dana@sap.com") + + client.get_children("repo1", "fid", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# delete_object +# --------------------------------------------------------------- + + +class TestDeleteObject: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + result = client.delete_object("repo1", "doc-xyz") + + assert result is None + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "delete" + assert data["objectId"] == "doc-xyz" + assert data["allVersions"] == "true" + assert data["_charset_"] == "UTF-8" + + def test_all_versions_false(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.delete_object("repo1", "doc-xyz", all_versions=False) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["allVersions"] == "false" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.delete_object("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.delete_object("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# restore_object +# --------------------------------------------------------------- + +_RESTORE_RESPONSE = {"message": "Object restored successfully"} + + +class TestRestoreObject: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + + msg = client.restore_object("repo1", "doc-xyz") + + assert msg == "Object restored successfully" + call_args = client._mock_http.post.call_args + assert ( + call_args[1]["path"] + == "/rest/v2/repositories/repo1/deleted/objects/doc-xyz/restore" + ) + assert call_args[1]["payload"] == {} + + def test_with_tenant(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + + client.restore_object("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.post.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.restore_object("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post.call_args[1]["user_claim"] is claim + + def test_empty_message(self, client): + client._mock_http.post.return_value = _mock_response({}) + + msg = client.restore_object("repo1", "doc-xyz") + + assert msg == "" + + +# --------------------------------------------------------------- +# append_content_stream +# --------------------------------------------------------------- + + +class TestAppendContentStream: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"additional content") + + doc = client.append_content_stream("repo1", "doc-xyz", stream) + + assert isinstance(doc, Document) + assert doc.object_id == "doc-xyz" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "appendContent" + assert data["objectId"] == "doc-xyz" + assert data["succinct"] == "true" + assert data["isLastChunk"] == "false" + assert data["_charset_"] == "UTF-8" + + files_arg = call_args[1]["files"] + assert "media" in files_arg + assert files_arg["media"][0] == "content" + assert files_arg["media"][2] == "application/octet-stream" + + def test_is_last_chunk_true(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"final"), is_last_chunk=True + ) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["isLastChunk"] == "true" + + def test_with_filename(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), filename="chunk.bin" + ) + + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["media"][0] == "chunk.bin" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), tenant="sub1" + ) + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="carol@sap.com") + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), user_claim=claim + ) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# cmis_query +# --------------------------------------------------------------- + +_QUERY_RESPONSE = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Report.pdf"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + "cmis:contentStreamLength": {"value": 4096}, + } + }, + { + "properties": { + "cmis:objectId": {"value": "folder-1"}, + "cmis:name": {"value": "Archive"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + }, + ], + "hasMoreItems": True, + "numItems": 200, +} + + +class TestCmisQuery: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response(_QUERY_RESPONSE) + + page = client.cmis_query("repo1", "SELECT * FROM cmis:document") + + assert isinstance(page, QueryResultPage) + assert len(page.results) == 2 + assert page.has_more_items is True + assert page.num_items == 200 + + assert isinstance(page.results[0], Document) + assert page.results[0].object_id == "doc-1" + assert isinstance(page.results[1], Folder) + assert page.results[1].object_id == "folder-1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1" + params = call_args[1]["params"] + assert params["cmisselector"] == "query" + assert params["q"] == "SELECT * FROM cmis:document" + assert params["maxItems"] == "100" + assert params["skipCount"] == "0" + + def test_with_options(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + opts = QueryOptions(max_items=25, skip_count=50, search_all_versions=True) + client.cmis_query("repo1", "SELECT * FROM cmis:document", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["maxItems"] == "25" + assert params["skipCount"] == "50" + assert params["searchAllVersions"] == "true" + + def test_search_all_versions_default_not_in_params(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + client.cmis_query("repo1", "SELECT * FROM cmis:document") + + params = client._mock_http.get.call_args[1]["params"] + assert "searchAllVersions" not in params + + def test_empty_results(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False, "numItems": 0} + ) + + page = client.cmis_query("repo1", "SELECT * FROM cmis:document") + + assert page.results == [] + assert page.has_more_items is False + assert page.num_items == 0 + + def test_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + client.cmis_query("repo1", "SELECT * FROM cmis:document", tenant="sub1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + claim = UserClaim(x_ecm_user_enc="eve@sap.com") + + client.cmis_query( + "repo1", "SELECT * FROM cmis:document", user_claim=claim + ) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim diff --git a/tests/dms/unit/test_cmis_models.py b/tests/dms/unit/test_cmis_models.py new file mode 100644 index 0000000..f814dba --- /dev/null +++ b/tests/dms/unit/test_cmis_models.py @@ -0,0 +1,486 @@ +"""Unit tests for CMIS object models.""" + +from datetime import datetime, timezone + + +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenPage, + CmisObject, + Document, + Folder, + QueryOptions, + QueryResultPage, + _parse_datetime as _parse_cmis_datetime, + _prop_val, +) + + +# --------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------- + + +class TestParseCmisDatetime: + def test_none_returns_none(self): + assert _parse_cmis_datetime(None) is None + + def test_epoch_millis(self): + # 2024-01-15T12:00:00Z + result = _parse_cmis_datetime(1705320000000) + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + assert result.year == 2024 + + def test_iso_string_with_z(self): + result = _parse_cmis_datetime("2024-01-15T12:00:00Z") + assert isinstance(result, datetime) + assert result.tzinfo is not None + + def test_iso_string_with_offset(self): + result = _parse_cmis_datetime("2024-01-15T12:00:00+00:00") + assert isinstance(result, datetime) + + +class TestPropVal: + def test_succinct_format(self): + props = {"cmis:name": "MyDoc"} + assert _prop_val(props, "cmis:name") == "MyDoc" + + def test_verbose_format(self): + props = {"cmis:name": {"value": "MyDoc"}} + assert _prop_val(props, "cmis:name") == "MyDoc" + + def test_missing_key_returns_none(self): + assert _prop_val({}, "cmis:name") is None + + def test_dict_without_value_key(self): + props = {"cmis:name": {"id": "cmis:name", "localName": "name"}} + result = _prop_val(props, "cmis:name") + assert isinstance(result, dict) + + def test_boolean_value(self): + props = {"cmis:isLatestVersion": True} + assert _prop_val(props, "cmis:isLatestVersion") is True + + def test_integer_value(self): + props = {"cmis:contentStreamLength": 42} + assert _prop_val(props, "cmis:contentStreamLength") == 42 + + +# --------------------------------------------------------------- +# CmisObject +# --------------------------------------------------------------- + +_SUCCINCT_FOLDER = { + "succinctProperties": { + "cmis:objectId": "folder-123", + "cmis:name": "TestFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + "cmis:createdBy": "admin", + "cmis:creationDate": 1705320000000, + "cmis:lastModifiedBy": "admin", + "cmis:lastModificationDate": 1705320000000, + "cmis:changeToken": "tok1", + "cmis:description": "A test folder", + "sap:parentIds": ["root-id"], + } +} + +_SUCCINCT_DOCUMENT = { + "succinctProperties": { + "cmis:objectId": "doc-456", + "cmis:name": "readme.txt", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:createdBy": "user1", + "cmis:creationDate": 1705320000000, + "cmis:lastModifiedBy": "user1", + "cmis:lastModificationDate": 1705320000000, + "cmis:contentStreamLength": 1024, + "cmis:contentStreamMimeType": "text/plain", + "cmis:contentStreamFileName": "readme.txt", + "cmis:versionSeriesId": "vs-789", + "cmis:versionLabel": "1.0", + "cmis:isLatestVersion": True, + "cmis:isMajorVersion": True, + "cmis:isLatestMajorVersion": True, + "cmis:isPrivateWorkingCopy": False, + "cmis:checkinComment": "Initial upload", + "cmis:isVersionSeriesCheckedOut": False, + "cmis:versionSeriesCheckedOutId": None, + "sap:parentIds": ["folder-123"], + } +} + + +class TestCmisObject: + def test_from_dict_succinct(self): + obj = CmisObject.from_dict(_SUCCINCT_FOLDER) + assert obj.object_id == "folder-123" + assert obj.name == "TestFolder" + assert obj.base_type_id == "cmis:folder" + assert obj.created_by == "admin" + assert obj.description == "A test folder" + assert obj.parent_ids == ["root-id"] + assert isinstance(obj.creation_date, datetime) + + def test_from_dict_verbose(self): + data = { + "properties": { + "cmis:objectId": {"value": "obj-99"}, + "cmis:name": {"value": "VerboseObj"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + } + obj = CmisObject.from_dict(data) + assert obj.object_id == "obj-99" + assert obj.name == "VerboseObj" + + def test_from_dict_empty(self): + obj = CmisObject.from_dict({}) + assert obj.object_id == "" + assert obj.name == "" + assert obj.properties == {} + + def test_from_dict_prefers_succinct_over_properties(self): + data = { + "succinctProperties": {"cmis:name": "Succinct"}, + "properties": {"cmis:name": {"value": "Verbose"}}, + } + obj = CmisObject.from_dict(data) + assert obj.name == "Succinct" + + +# --------------------------------------------------------------- +# Folder +# --------------------------------------------------------------- + + +class TestFolder: + def test_from_dict(self): + folder = Folder.from_dict(_SUCCINCT_FOLDER) + assert isinstance(folder, Folder) + assert isinstance(folder, CmisObject) + assert folder.object_id == "folder-123" + assert folder.name == "TestFolder" + + def test_from_dict_empty(self): + folder = Folder.from_dict({}) + assert folder.object_id == "" + + +# --------------------------------------------------------------- +# Document +# --------------------------------------------------------------- + + +class TestDocument: + def test_from_dict_full(self): + doc = Document.from_dict(_SUCCINCT_DOCUMENT) + assert isinstance(doc, Document) + assert isinstance(doc, CmisObject) + assert doc.object_id == "doc-456" + assert doc.name == "readme.txt" + assert doc.content_stream_length == 1024 + assert doc.content_stream_mime_type == "text/plain" + assert doc.content_stream_file_name == "readme.txt" + assert doc.version_series_id == "vs-789" + assert doc.version_label == "1.0" + assert doc.is_latest_version is True + assert doc.is_major_version is True + assert doc.is_latest_major_version is True + assert doc.is_private_working_copy is False + assert doc.checkin_comment == "Initial upload" + assert doc.is_version_series_checked_out is False + assert doc.version_series_checked_out_id is None + assert doc.parent_ids == ["folder-123"] + + def test_from_dict_minimal(self): + data = { + "succinctProperties": { + "cmis:objectId": "doc-min", + "cmis:name": "minimal.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + } + } + doc = Document.from_dict(data) + assert doc.object_id == "doc-min" + assert doc.content_stream_length is None + assert doc.version_label is None + + def test_from_dict_empty(self): + doc = Document.from_dict({}) + assert doc.object_id == "" + assert doc.content_stream_length is None + + +# --------------------------------------------------------------- +# Ace +# --------------------------------------------------------------- + + +class TestAce: + def test_from_dict(self): + data = { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read", "cmis:write"], + "isDirect": True, + } + ace = Ace.from_dict(data) + assert ace.principal_id == "user1" + assert ace.permissions == ["cmis:read", "cmis:write"] + assert ace.is_direct is True + + def test_from_dict_missing_principal(self): + ace = Ace.from_dict({}) + assert ace.principal_id == "" + assert ace.permissions == [] + assert ace.is_direct is True + + def test_from_dict_indirect(self): + data = { + "principal": {"principalId": "role:Admin"}, + "permissions": ["cmis:all"], + "isDirect": False, + } + ace = Ace.from_dict(data) + assert ace.is_direct is False + + def test_constructor(self): + ace = Ace(principal_id="p1", permissions=["cmis:read"]) + assert ace.principal_id == "p1" + assert ace.permissions == ["cmis:read"] + + +# --------------------------------------------------------------- +# Acl +# --------------------------------------------------------------- + + +class TestAcl: + def test_from_dict(self): + data = { + "aces": [ + { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read"], + "isDirect": True, + }, + { + "principal": {"principalId": "user2"}, + "permissions": ["cmis:write"], + "isDirect": False, + }, + ], + "isExact": False, + } + acl = Acl.from_dict(data) + assert len(acl.aces) == 2 + assert acl.aces[0].principal_id == "user1" + assert acl.aces[1].principal_id == "user2" + assert acl.is_exact is False + + def test_from_dict_empty(self): + acl = Acl.from_dict({}) + assert acl.aces == [] + assert acl.is_exact is True + + def test_from_dict_no_aces(self): + acl = Acl.from_dict({"aces": [], "isExact": True}) + assert acl.aces == [] + + +class TestChildrenPage: + def test_from_dict_mixed_types(self): + data = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "f1", + "cmis:name": "Folder1", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + } + } + }, + { + "object": { + "succinctProperties": { + "cmis:objectId": "d1", + "cmis:name": "Doc1", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 100, + } + } + }, + ], + "hasMoreItems": True, + "numItems": 50, + } + page = ChildrenPage.from_dict(data) + assert len(page.objects) == 2 + assert isinstance(page.objects[0], Folder) + assert isinstance(page.objects[1], Document) + assert page.objects[0].object_id == "f1" + assert page.objects[1].object_id == "d1" + assert page.objects[1].content_stream_length == 100 + assert page.has_more_items is True + assert page.num_items == 50 + + def test_from_dict_empty(self): + page = ChildrenPage.from_dict({"objects": [], "hasMoreItems": False}) + assert page.objects == [] + assert page.has_more_items is False + assert page.num_items is None + + def test_from_dict_unknown_type(self): + data = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "i1", + "cmis:name": "Item", + "cmis:baseTypeId": "cmis:item", + "cmis:objectTypeId": "cmis:item", + } + } + }, + ], + "hasMoreItems": False, + } + page = ChildrenPage.from_dict(data) + assert len(page.objects) == 1 + assert isinstance(page.objects[0], CmisObject) + assert not isinstance(page.objects[0], (Folder, Document)) + + def test_from_dict_no_num_items(self): + page = ChildrenPage.from_dict({"objects": [], "hasMoreItems": True}) + assert page.has_more_items is True + assert page.num_items is None + + +# --------------------------------------------------------------- +# QueryOptions +# --------------------------------------------------------------- + + +class TestQueryOptions: + def test_defaults(self): + opts = QueryOptions() + assert opts.max_items == 100 + assert opts.skip_count == 0 + assert opts.search_all_versions is False + + def test_to_query_params_defaults(self): + params = QueryOptions().to_query_params() + assert params == {"maxItems": "100", "skipCount": "0"} + assert "searchAllVersions" not in params + + def test_to_query_params_custom(self): + opts = QueryOptions(max_items=25, skip_count=50, search_all_versions=True) + params = opts.to_query_params() + assert params["maxItems"] == "25" + assert params["skipCount"] == "50" + assert params["searchAllVersions"] == "true" + + def test_search_all_versions_false_omitted(self): + opts = QueryOptions(search_all_versions=False) + params = opts.to_query_params() + assert "searchAllVersions" not in params + + +# --------------------------------------------------------------- +# QueryResultPage +# --------------------------------------------------------------- + + +class TestQueryResultPage: + def test_from_dict_verbose_properties(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Report.pdf"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + "cmis:contentStreamLength": {"value": 4096}, + } + }, + ], + "hasMoreItems": True, + "numItems": 100, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 1 + assert isinstance(page.results[0], Document) + assert page.results[0].object_id == "doc-1" + assert page.results[0].name == "Report.pdf" + assert page.has_more_items is True + assert page.num_items == 100 + + def test_from_dict_mixed_types(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Doc"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + } + }, + { + "properties": { + "cmis:objectId": {"value": "folder-1"}, + "cmis:name": {"value": "Folder"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + }, + ], + "hasMoreItems": False, + "numItems": 2, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 2 + assert isinstance(page.results[0], Document) + assert isinstance(page.results[1], Folder) + + def test_from_dict_empty(self): + page = QueryResultPage.from_dict({"results": [], "hasMoreItems": False}) + assert page.results == [] + assert page.has_more_items is False + assert page.num_items is None + + def test_from_dict_unknown_type(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "item-1"}, + "cmis:name": {"value": "Item"}, + "cmis:baseTypeId": {"value": "cmis:item"}, + "cmis:objectTypeId": {"value": "cmis:item"}, + } + }, + ], + "hasMoreItems": False, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 1 + assert isinstance(page.results[0], CmisObject) + assert not isinstance(page.results[0], (Folder, Document)) + + def test_from_dict_no_num_items(self): + page = QueryResultPage.from_dict({"results": [], "hasMoreItems": True}) + assert page.has_more_items is True + assert page.num_items is None diff --git a/tests/dms/unit/test_http_invoker.py b/tests/dms/unit/test_http_invoker.py new file mode 100644 index 0000000..9620300 --- /dev/null +++ b/tests/dms/unit/test_http_invoker.py @@ -0,0 +1,412 @@ +"""Unit tests for HttpInvoker (get, post_form, get_stream, header methods).""" + +from unittest.mock import Mock, patch + +import pytest +import requests + +from sap_cloud_sdk.dms._http import HttpInvoker +from sap_cloud_sdk.dms.exceptions import ( + DMSConflictException, + DMSConnectionError, + DMSInvalidArgumentException, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSRuntimeException, +) + + +@pytest.fixture +def mock_auth(): + auth = Mock() + auth.get_token.return_value = "test-token-123" + return auth + + +@pytest.fixture +def invoker(mock_auth): + return HttpInvoker( + auth=mock_auth, + base_url="https://api.example.com", + connect_timeout=5, + read_timeout=15, + ) + + +# --------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------- + + +class TestHeaders: + def test_auth_header(self, invoker): + headers = invoker._auth_header() + assert headers == {"Authorization": "Bearer test-token-123"} + + def test_auth_header_with_tenant(self, invoker, mock_auth): + invoker._auth_header("tenant-sub") + mock_auth.get_token.assert_called_with("tenant-sub") + + def test_default_headers(self, invoker): + headers = invoker._default_headers() + assert headers["Authorization"] == "Bearer test-token-123" + assert headers["Content-Type"] == "application/json" + assert headers["Accept"] == "application/json" + + def test_merged_headers_applies_overrides(self, invoker): + merged = invoker._merged_headers(None, {"Accept": "text/xml"}) + assert merged["Accept"] == "text/xml" + assert merged["Authorization"] == "Bearer test-token-123" + + +# --------------------------------------------------------------- +# GET +# --------------------------------------------------------------- + + +class TestGet: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_basic(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b'{"key": "val"}' + mock_resp.json.return_value = {"key": "val"} + mock_get.return_value = mock_resp + + result = invoker.get("/rest/v2/repos") + + mock_get.assert_called_once_with( + "https://api.example.com/rest/v2/repos", + headers={ + "Authorization": "Bearer test-token-123", + "Content-Type": "application/json", + "Accept": "application/json", + }, + params=None, + timeout=(5, 15), + ) + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_params(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + result = invoker.get("/path", params={"objectId": "abc", "cmisselector": "acl"}) + + call_kwargs = mock_get.call_args[1] + assert call_kwargs["params"] == {"objectId": "abc", "cmisselector": "acl"} + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_custom_headers(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + invoker.get("/repos", headers={"Accept": "application/vnd.sap.sdm+json"}) + + call_kwargs = mock_get.call_args[1] + # Custom Accept should override default + assert call_kwargs["headers"]["Accept"] == "application/vnd.sap.sdm+json" + # Auth should still be present + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token-123" + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_tenant(self, mock_get, invoker, mock_auth): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + invoker.get("/path", tenant_subdomain="sub1") + + mock_auth.get_token.assert_called_with("sub1") + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_404_raises_not_found(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = "Not Found" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get("/missing") + assert exc_info.value.status_code == 404 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_400_raises_invalid_argument(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = "Bad Request" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert exc_info.value.status_code == 400 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_401_raises_permission_denied(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 401 + mock_resp.text = "Unauthorized" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSPermissionDeniedException) as exc_info: + invoker.get("/unauthorized") + assert exc_info.value.status_code == 401 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_500_raises_runtime(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSRuntimeException) as exc_info: + invoker.get("/error") + assert exc_info.value.status_code == 500 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_connection_error(self, mock_get, invoker): + mock_get.side_effect = requests.exceptions.ConnectionError("refused") + + with pytest.raises(DMSConnectionError): + invoker.get("/unreachable") + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_timeout_error(self, mock_get, invoker): + mock_get.side_effect = requests.exceptions.Timeout("timed out") + + with pytest.raises(DMSConnectionError): + invoker.get("/slow") + + +# --------------------------------------------------------------- +# Error message extraction +# --------------------------------------------------------------- + + +class TestErrorMessageExtraction: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_400_extracts_json_message(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = '{"exception": "versioning", "message": "The object is not the latest version"}' + mock_resp.json.return_value = { + "exception": "versioning", + "message": "The object is not the latest version", + } + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert "The object is not the latest version" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_400_fallback_when_no_json(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = "Bad Request" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert "Request contains invalid or disallowed parameters" in str( + exc_info.value + ) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_404_extracts_json_message(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = '{"message": "Document abc-123 not found"}' + mock_resp.json.return_value = {"message": "Document abc-123 not found"} + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get("/missing") + assert "Document abc-123 not found" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_409_raises_conflict(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 409 + mock_resp.text = '{"exception": "versioning", "message": "Object already exists with name test.txt"}' + mock_resp.json.return_value = { + "exception": "versioning", + "message": "Object already exists with name test.txt", + } + mock_get.return_value = mock_resp + + with pytest.raises(DMSConflictException) as exc_info: + invoker.get("/conflict") + assert exc_info.value.status_code == 409 + assert "Object already exists with name test.txt" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_409_fallback_when_no_json(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 409 + mock_resp.text = "Conflict" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSConflictException) as exc_info: + invoker.get("/conflict") + assert "conflicts with the current state" in str(exc_info.value) + + +# --------------------------------------------------------------- +# POST (form-encoded) +# --------------------------------------------------------------- + + +class TestPostForm: + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_basic(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_resp.content = b'{"succinctProperties": {}}' + mock_resp.json.return_value = {"succinctProperties": {}} + mock_post.return_value = mock_resp + + form = {"cmisaction": "createFolder", "objectId": "root-id"} + result = invoker.post_form("/browser/repo1/root", data=form) + + mock_post.assert_called_once_with( + "https://api.example.com/browser/repo1/root", + headers={"Authorization": "Bearer test-token-123"}, + data=form, + files=None, + timeout=(5, 15), + ) + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_no_content_type_header(self, mock_post, invoker): + """post_form must NOT set Content-Type — let requests handle it.""" + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + invoker.post_form("/path", data={"key": "val"}) + + headers_sent = mock_post.call_args[1]["headers"] + assert "Content-Type" not in headers_sent + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_with_files(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + files = {"media": ("test.pdf", b"content", "application/pdf")} + invoker.post_form("/path", data={"cmisaction": "createDocument"}, files=files) + + call_kwargs = mock_post.call_args[1] + assert call_kwargs["files"] == files + assert call_kwargs["data"] == {"cmisaction": "createDocument"} + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_with_tenant(self, mock_post, invoker, mock_auth): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + invoker.post_form("/path", data={"a": "b"}, tenant_subdomain="tenant-x") + + mock_auth.get_token.assert_called_with("tenant-x") + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_500_raises_runtime(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_resp.json.side_effect = ValueError("No JSON") + mock_post.return_value = mock_resp + + with pytest.raises(DMSRuntimeException) as exc_info: + invoker.post_form("/path", data={}) + assert exc_info.value.status_code == 500 + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_204_returns_response(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 204 + mock_resp.content = b"" + mock_post.return_value = mock_resp + + result = invoker.post_form("/path", data={}) + assert result is mock_resp + + +# --------------------------------------------------------------- +# Base URL stripping +# --------------------------------------------------------------- + + +class TestBaseUrl: + def test_trailing_slash_stripped(self, mock_auth): + inv = HttpInvoker( + auth=mock_auth, + base_url="https://api.example.com/", + ) + assert inv._base_url == "https://api.example.com" + + +# --------------------------------------------------------------- +# get_stream +# --------------------------------------------------------------- + + +class TestGetStream: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_returns_raw_response(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"binary content" + mock_get.return_value = mock_resp + + result = invoker.get_stream( + "/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"} + ) + + assert result is mock_resp + mock_get.assert_called_once() + call_kwargs = mock_get.call_args + assert call_kwargs[1]["stream"] is True + assert call_kwargs[1]["params"] == {"objectId": "d1", "cmisselector": "content"} + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_raises_on_error(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = "Not found" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get_stream( + "/browser/repo1/root", + params={"objectId": "d1", "cmisselector": "content"}, + ) + assert exc_info.value.status_code == 404 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_uses_auth_headers(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"data" + mock_get.return_value = mock_resp + + invoker.get_stream("/path") + + headers = mock_get.call_args[1]["headers"] + assert "Authorization" in headers + assert headers["Authorization"] == "Bearer test-token-123"