From c7abe2228b6a9f7d0e05b3d408f3ea7b78967d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Engstr=C3=B6m?= Date: Tue, 3 Mar 2026 15:36:35 +0100 Subject: [PATCH 1/2] Replace deprecated Jamf Pro API endpoints with supported alternatives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add v3 computer inventory methods (get_computer_inventory_v3, get_computer_v3, get_computer_inventory_detail_v3, update_computer_v3, delete_computer_v3) to replace deprecated v1 endpoint (2025-06-30) - Add send_mdm_command_v2() with ~25 new command models to replace the preview MDM endpoint removed from the API schema - Add upload_package_v1() for direct multipart file upload, replacing the deprecated JCDS v1 S3-based workflow (2025-08-28) without requiring boto3 - Add upload_package() to JCDS2 client using the new upload endpoint - Add HrefResponse model, v3 api_options (sections/sort/filter fields) - Add cfBundleShortVersionString and cfBundleVersion to ComputerApplication - Mark deprecated methods with DeprecationWarning (v1 inventory, JCDS v1, preview MDM commands) - Fix typo in api_options: "serAndLocation.buildingId" → "userAndLocation.buildingId" Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + src/jamf_pro_sdk/clients/jcds2.py | 55 +++ src/jamf_pro_sdk/clients/pro_api/__init__.py | 353 ++++++++++++++++++- src/jamf_pro_sdk/models/pro/__init__.py | 5 + src/jamf_pro_sdk/models/pro/api_options.py | 121 ++++++- src/jamf_pro_sdk/models/pro/computers.py | 9 +- src/jamf_pro_sdk/models/pro/mdm.py | 224 +++++++++++- 7 files changed, 760 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 6b363b3..2daab27 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ htmlcov/ .coverage .DS_Store .vscode +api_schema diff --git a/src/jamf_pro_sdk/clients/jcds2.py b/src/jamf_pro_sdk/clients/jcds2.py index 9fa9f9c..0479d3a 100644 --- a/src/jamf_pro_sdk/clients/jcds2.py +++ b/src/jamf_pro_sdk/clients/jcds2.py @@ -2,6 +2,7 @@ import logging import math +import warnings from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Callable, Iterator, Union @@ -134,9 +135,57 @@ def _upload_part(s3_client, multipart_upload: dict, part_number: int, file_uploa logger.debug(part_resp) return {"PartNumber": part_number, "ETag": part_resp["ETag"]} + def upload_package(self, file_path: Union[str, Path]) -> None: + """Upload a file and create the package object using the new package upload API. + + This method replaces :meth:`upload_file` and does not require the ``aws`` extra dependency. + It uses the ``POST /v1/packages/{id}/upload`` endpoint instead of the deprecated JCDS v1 + S3-based workflow. + + A ``JCDS2FileExistsError`` is raised if any file of the same name exists and is associated + to a package. + + :param file_path: The path to the file to upload. Will raise ``FileNotFoundError`` if the + path to the file's location does not exist. + :type file_path: Union[str, Path] + """ + if not isinstance(file_path, Path): + file_path = Path(file_path) + + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + packages = [ + self.classic_api_client.get_package_by_id(p) + for p in self.classic_api_client.list_all_packages() + ] + + for p in packages: + if file_path.name == p.filename: + raise JCDS2FileExistsError( + f"The file '{file_path.name}' exists and is associated to package " + f"({p.id}) '{p.name}'" + ) + + new_package = ClassicPackage(name=file_path.name, filename=file_path.name) + new_pkg_id = self.classic_api_client.create_package(data=new_package) + logger.debug("Created package %s", new_pkg_id) + + try: + self.pro_api_client.upload_package_v1( + package_id=new_pkg_id, file_path=file_path + ) + except Exception as err: + logger.exception(err) + raise + def upload_file(self, file_path: Union[str, Path]) -> None: """Upload a file to the JCDS and create the package object. + .. deprecated:: + The JCDS v1 API is deprecated by Jamf (2025-08-28). Use :meth:`upload_package` instead, + which does not require the ``aws`` extra dependency. + If the file is less than 1 GiB in size the upload will be performed in a single request. If the file is greater than 1 GiB in size a multipart upload operation will be performed. @@ -151,6 +200,12 @@ def upload_file(self, file_path: Union[str, Path]) -> None: to the file's location does not exist. :type file_path: Union[str, Path] """ + warnings.warn( + "upload_file() is deprecated. The JCDS v1 API was deprecated by Jamf on 2025-08-28. " + "Use upload_package() instead, which does not require the 'aws' extra dependency.", + DeprecationWarning, + stacklevel=2, + ) if not BOTO3_IS_INSTALLED: raise ImportError("The 'aws' extra dependency is required.") diff --git a/src/jamf_pro_sdk/clients/pro_api/__init__.py b/src/jamf_pro_sdk/clients/pro_api/__init__.py index 9de1da3..09bf725 100644 --- a/src/jamf_pro_sdk/clients/pro_api/__init__.py +++ b/src/jamf_pro_sdk/clients/pro_api/__init__.py @@ -1,12 +1,18 @@ from __future__ import annotations +import warnings +from pathlib import Path from typing import TYPE_CHECKING, Callable, Iterator, List, Literal, Optional, Union, overload from uuid import UUID +from ...models.pro import HrefResponse from ...models.pro.api_options import ( get_computer_inventory_v1_allowed_filter_fields, get_computer_inventory_v1_allowed_sections, get_computer_inventory_v1_allowed_sort_fields, + get_computer_inventory_v3_allowed_filter_fields, + get_computer_inventory_v3_allowed_sections, + get_computer_inventory_v3_allowed_sort_fields, get_mdm_commands_v2_allowed_command_types, get_mdm_commands_v2_allowed_filter_fields, get_mdm_commands_v2_allowed_sort_fields, @@ -23,6 +29,8 @@ EnableLostModeCommand, EraseDeviceCommand, LogOutUserCommand, + MdmCommandClientRequest, + MdmCommandRequest, MdmCommandStatus, RenewMdmProfileResponse, RestartDeviceCommand, @@ -91,6 +99,10 @@ def get_computer_inventory_v1( ) -> Union[List[Computer], Iterator[Page]]: """Returns a list of computer inventory records. + .. deprecated:: + The v1 computer inventory API is deprecated by Jamf (2025-06-30). + Use :meth:`get_computer_inventory_v3` instead. + :param sections: (optional) Select which sections of the computer's details to return. If not specific the request will default to ``GENERAL``. If ``ALL`` is passed then all sections will be returned. @@ -139,6 +151,13 @@ def get_computer_inventory_v1( :rtype: List[~jamf_pro_sdk.models.pro.computer.Computer] | Iterator[Page] """ + warnings.warn( + "get_computer_inventory_v1() is deprecated. The v1 computer inventory API was " + "deprecated by Jamf on 2025-06-30. Use get_computer_inventory_v3() instead.", + DeprecationWarning, + stacklevel=2, + ) + if not sections: sections = ["GENERAL"] elif "ALL" in sections: @@ -169,6 +188,200 @@ def get_computer_inventory_v1( return paginator(return_generator=return_generator) + # Computer Inventory V3 APIs + + @overload + def get_computer_inventory_v3( + self, + sections: Optional[List[str]] = ..., + start_page: int = ..., + end_page: Optional[int] = ..., + page_size: int = ..., + sort_expression: Optional[SortExpression] = ..., + filter_expression: Optional[FilterExpression] = ..., + return_generator: Literal[False] = False, + ) -> List[Computer]: ... + + @overload + def get_computer_inventory_v3( + self, + sections: Optional[List[str]] = ..., + start_page: int = ..., + end_page: Optional[int] = ..., + page_size: int = ..., + sort_expression: Optional[SortExpression] = ..., + filter_expression: Optional[FilterExpression] = ..., + return_generator: Literal[True] = True, + ) -> Iterator[Page]: ... + + def get_computer_inventory_v3( + self, + sections: Optional[List[str]] = None, + start_page: int = 0, + end_page: Optional[int] = None, + page_size: int = 100, + sort_expression: Optional[SortExpression] = None, + filter_expression: Optional[FilterExpression] = None, + return_generator: bool = False, + ) -> Union[List[Computer], Iterator[Page]]: + """Returns a list of computer inventory records using the v3 API. + + This replaces :meth:`get_computer_inventory_v1` which uses an API deprecated by Jamf. + + :param sections: (optional) Select which sections of the computer's details to return. If + not specified the request will default to ``GENERAL``. If ``ALL`` is passed then all + sections will be returned. + + Allowed sections: + + .. autoapioptions:: jamf_pro_sdk.models.pro.api_options.get_computer_inventory_v3_allowed_sections + + :type sections: List[str] + + :param start_page: (optional) The page to begin returning results from. See + :class:`Paginator` for more information. + :type start_page: int + + :param end_page: (optional) The page to end returning results at. See :class:`Paginator` for + more information. + :type end_page: int + + :param page_size: (optional) The number of results to include in each requested page. See + :class:`Paginator` for more information. + :type page_size: int + + :param sort_expression: (optional) The sort fields to apply to the request. See the + documentation for :ref:`Pro API Sorting` for more information. + + Allowed sort fields: + + .. autoapioptions:: jamf_pro_sdk.models.pro.api_options.get_computer_inventory_v3_allowed_sort_fields + + :type sort_expression: SortExpression + + :param filter_expression: (optional) The filter expression to apply to the request. See the + documentation for :ref:`Pro API Filtering` for more information. + + Allowed filter fields: + + .. autoapioptions:: jamf_pro_sdk.models.pro.api_options.get_computer_inventory_v3_allowed_filter_fields + + :type filter_expression: FilterExpression + + :param return_generator: If ``True`` a generator is returned to iterate over pages. By + default, the results for all pages will be returned in a single response. + :type return_generator: bool + + :return: List of computers OR a paginator generator. + :rtype: List[~jamf_pro_sdk.models.pro.computers.Computer] | Iterator[Page] + + """ + if not sections: + sections = ["GENERAL"] + elif "ALL" in sections: + sections = get_computer_inventory_v3_allowed_sections[1:] + + if not all([i in get_computer_inventory_v3_allowed_sections for i in sections]): + raise ValueError( + f"Values for 'sections' must be one of: {', '.join(get_computer_inventory_v3_allowed_sections)}" + ) + + if sort_expression: + sort_expression.validate(get_computer_inventory_v3_allowed_sort_fields) + + if filter_expression: + filter_expression.validate(get_computer_inventory_v3_allowed_filter_fields) + + paginator = Paginator( + api_client=self, + resource_path="v3/computers-inventory", + return_model=Computer, + start_page=start_page, + end_page=end_page, + page_size=page_size, + sort_expression=sort_expression, + filter_expression=filter_expression, + extra_params={"section": ",".join(sections)}, + ) + + return paginator(return_generator=return_generator) + + def get_computer_inventory_detail_v3(self, computer_id: Union[int, str]) -> Computer: + """Return all sections of a computer inventory record. + + :param computer_id: The ID of the computer record. + :type computer_id: Union[int, str] + + :return: A full computer inventory record with all sections. + :rtype: Computer + """ + resp = self.api_request( + method="get", resource_path=f"v3/computers-inventory-detail/{computer_id}" + ) + return Computer(**resp.json()) + + def get_computer_v3( + self, + computer_id: Union[int, str], + sections: Optional[List[str]] = None, + ) -> Computer: + """Return a computer inventory record by ID with optional section selection. + + :param computer_id: The ID of the computer record. + :type computer_id: Union[int, str] + + :param sections: (optional) Select which sections of the computer's details to return. + If not specified, returns the GENERAL section. + :type sections: List[str] + + :return: A computer inventory record. + :rtype: Computer + """ + params = {} + if sections: + if not all([i in get_computer_inventory_v3_allowed_sections for i in sections]): + raise ValueError( + f"Values for 'sections' must be one of: " + f"{', '.join(get_computer_inventory_v3_allowed_sections)}" + ) + params["section"] = ",".join(sections) + + resp = self.api_request( + method="get", + resource_path=f"v3/computers-inventory/{computer_id}", + params=params if params else None, + ) + return Computer(**resp.json()) + + def update_computer_v3( + self, computer_id: Union[int, str], data: Union[dict, Computer] + ) -> Computer: + """Update specific fields on a computer inventory record. + + :param computer_id: The ID of the computer record. + :type computer_id: Union[int, str] + + :param data: The fields to update on the computer record. + :type data: Union[dict, Computer] + + :return: The updated computer inventory record. + :rtype: Computer + """ + resp = self.api_request( + method="patch", + resource_path=f"v3/computers-inventory-detail/{computer_id}", + data=data, + ) + return Computer(**resp.json()) + + def delete_computer_v3(self, computer_id: Union[int, str]) -> None: + """Delete a computer inventory record. + + :param computer_id: The ID of the computer record. + :type computer_id: Union[int, str] + """ + self.api_request(method="delete", resource_path=f"v3/computers-inventory/{computer_id}") + # Package APIs @overload @@ -261,47 +474,119 @@ def get_packages_v1( return paginator(return_generator=return_generator) - # JCDS APIs + def upload_package_v1( + self, package_id: Union[int, str], file_path: Union[str, Path] + ) -> HrefResponse: + """Upload a package file to a package record. + + This is the replacement for the deprecated JCDS v1 file upload workflow. Instead of + uploading to S3 via JCDS credentials, the file is uploaded directly to Jamf Pro + using a multipart form upload. + + :param package_id: The ID of the package to upload the file to. The package must already + exist (create it first with the Classic API or Pro API). + :type package_id: Union[int, str] + + :param file_path: The path to the file to upload. + :type file_path: Union[str, Path] + + :return: An HrefResponse with the package ID and href. + :rtype: HrefResponse + """ + if not isinstance(file_path, Path): + file_path = Path(file_path) + + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + with open(file_path, "rb") as fobj: + resp = self.api_request( + method="post", + resource_path=f"v1/packages/{package_id}/upload", + files={"file": (file_path.name, fobj)}, + ) + + return HrefResponse(**resp.json()) + + # JCDS APIs (Deprecated) def get_jcds_files_v1(self) -> List[File]: """Return a list of files in the JCDS. + .. deprecated:: + The JCDS v1 API is deprecated by Jamf (2025-08-28). + Use :meth:`upload_package_v1` for uploading packages instead. + :return: List JCDS File objects. :rtype: List[File] """ + warnings.warn( + "get_jcds_files_v1() is deprecated. The JCDS v1 API was deprecated by Jamf on " + "2025-08-28. Use the cloud-distribution-point API or upload_package_v1() instead.", + DeprecationWarning, + stacklevel=2, + ) resp = self.api_request(method="get", resource_path="v1/jcds/files") return [File(**i) for i in resp.json()] def create_jcds_file_v1(self) -> NewFile: """Create a new file in the JCDS. + .. deprecated:: + The JCDS v1 API is deprecated by Jamf (2025-08-28). + Use :meth:`upload_package_v1` for uploading packages instead. + :return: A JCDS NewFile object. :rtype: NewFile """ + warnings.warn( + "create_jcds_file_v1() is deprecated. The JCDS v1 API was deprecated by Jamf on " + "2025-08-28. Use upload_package_v1() instead.", + DeprecationWarning, + stacklevel=2, + ) resp = self.api_request(method="post", resource_path="v1/jcds/files") return NewFile(**resp.json()) def get_jcds_file_v1(self, file_name: str) -> DownloadUrl: """Read a JCDS file record by its filename. + .. deprecated:: + The JCDS v1 API is deprecated by Jamf (2025-08-28). + :return: A JCDS DownloadUrl object. :rtype: DownloadUrl """ + warnings.warn( + "get_jcds_file_v1() is deprecated. The JCDS v1 API was deprecated by Jamf on " + "2025-08-28.", + DeprecationWarning, + stacklevel=2, + ) resp = self.api_request(method="get", resource_path=f"v1/jcds/files/{file_name}") return DownloadUrl(**resp.json()) def delete_jcds_file_v1(self, file_name: str) -> None: """Delete a file from the JCDS. + .. deprecated:: + The JCDS v1 API is deprecated by Jamf (2025-08-28). + .. warning:: This operation *WILL NOT* delete an associated package object. It is recommended to use :meth:`~jamf_pro_sdk.clients.classic_api.ClassicApi.delete_package_by_id`. """ + warnings.warn( + "delete_jcds_file_v1() is deprecated. The JCDS v1 API was deprecated by Jamf on " + "2025-08-28.", + DeprecationWarning, + stacklevel=2, + ) self.api_request(method="delete", resource_path=f"v1/jcds/files/{file_name}") # MDM APIs @@ -344,10 +629,9 @@ def send_mdm_command_preview( ) -> List[SendMdmCommandResponse]: """Send an MDM command to one or more devices. - .. caution:: - - This API is labeled as a **Preview** by Jamf. It may change or become deprecated in - favor of another API in a future release. + .. deprecated:: + The preview MDM commands API has been removed from the Jamf Pro API schema. + Use :meth:`send_mdm_command_v2` instead. Example usage: @@ -377,6 +661,13 @@ def send_mdm_command_preview( :return: A list of command responses. :rtype: List[SendMdmCommandResponse] """ + warnings.warn( + "send_mdm_command_preview() is deprecated. The preview MDM commands API has been " + "removed from the Jamf Pro API schema. Use send_mdm_command_v2() instead.", + DeprecationWarning, + stacklevel=2, + ) + data = SendMdmCommand( clientData=[SendMdmCommandClientData(managementId=i) for i in management_ids], commandData=command, @@ -385,6 +676,58 @@ def send_mdm_command_preview( resp = self.api_request(method="post", resource_path="preview/mdm/commands", data=data) return [SendMdmCommandResponse(**i) for i in resp.json()] + def send_mdm_command_v2( + self, + management_ids: List[Union[str, UUID]], + command: Union[ + EnableLostModeCommand, + EraseDeviceCommand, + LogOutUserCommand, + RestartDeviceCommand, + SetRecoveryLockCommand, + ShutDownDeviceCommand, + CustomCommand, + ], + ) -> List[HrefResponse]: + """Send an MDM command to one or more devices using the v2 API. + + This replaces :meth:`send_mdm_command_preview` which used an API that has been removed + from the Jamf Pro API schema. + + Example usage: + + .. code-block:: python + + from jamf_pro_sdk.models.pro.mdm import LogOutUserCommand + + client.pro_api.send_mdm_command_v2( + management_ids=["4eecc1fb-f52d-48c5-9560-c246b23601d3"], + command=LogOutUserCommand() + ) + + The v2 API supports many more command types than the preview API. See the MDM command + models in :mod:`jamf_pro_sdk.models.pro.mdm` for all supported commands. + + The management IDs can be obtained from computer inventory records + at ``computer.general.managementId``. + + :param management_ids: A list of device management IDs to issue the MDM command to. + :type management_ids: List[Union[str, UUID]] + + :param command: The MDM command to send. + :type command: An MDM command model instance (e.g. LogOutUserCommand, EraseDeviceCommand) + + :return: A list of href responses referencing the created commands. + :rtype: List[HrefResponse] + """ + data = MdmCommandRequest( + clientData=[MdmCommandClientRequest(managementId=i) for i in management_ids], + commandData=command, + ) + + resp = self.api_request(method="post", resource_path="v2/mdm/commands", data=data) + return [HrefResponse(**i) for i in resp.json()] + @overload def get_mdm_commands_v2( self, diff --git a/src/jamf_pro_sdk/models/pro/__init__.py b/src/jamf_pro_sdk/models/pro/__init__.py index 12fa9d5..a70ea8f 100644 --- a/src/jamf_pro_sdk/models/pro/__init__.py +++ b/src/jamf_pro_sdk/models/pro/__init__.py @@ -6,3 +6,8 @@ class V1Site(BaseModel): id: Optional[str] = None name: Optional[str] = None + + +class HrefResponse(BaseModel): + id: Optional[str] = None + href: Optional[str] = None diff --git a/src/jamf_pro_sdk/models/pro/api_options.py b/src/jamf_pro_sdk/models/pro/api_options.py index 4bfcda2..56ed3a7 100644 --- a/src/jamf_pro_sdk/models/pro/api_options.py +++ b/src/jamf_pro_sdk/models/pro/api_options.py @@ -93,7 +93,126 @@ "security.activationLockEnabled", "security.recoveryLockEnabled", "security.firewallEnabled", - "serAndLocation.buildingId", + "userAndLocation.buildingId", + "userAndLocation.departmentId", + "userAndLocation.email", + "userAndLocation.realname", + "userAndLocation.phone", + "userAndLocation.position", + "userAndLocation.room", + "userAndLocation.username", + "diskEncryption.fileVault2Enabled", + "purchasing.appleCareId", + "purchasing.lifeExpectancy", + "purchasing.purchased", + "purchasing.leased", + "purchasing.vendor", + "purchasing.warrantyDate", +] + +get_computer_inventory_v3_allowed_sections = [ + "ALL", + "GENERAL", + "DISK_ENCRYPTION", + "PURCHASING", + "APPLICATIONS", + "STORAGE", + "USER_AND_LOCATION", + "CONFIGURATION_PROFILES", + "PRINTERS", + "SERVICES", + "HARDWARE", + "LOCAL_USER_ACCOUNTS", + "CERTIFICATES", + "ATTACHMENTS", + "PACKAGE_RECEIPTS", + "SECURITY", + "OPERATING_SYSTEM", + "LICENSED_SOFTWARE", + "IBEACONS", + "SOFTWARE_UPDATES", + "EXTENSION_ATTRIBUTES", + "CONTENT_CACHING", + "GROUP_MEMBERSHIPS", +] + +get_computer_inventory_v3_allowed_sort_fields = [ + "general.name", + "udid", + "id", + "general.assetTag", + "general.jamfBinaryVersion", + "general.lastContactTime", + "general.lastEnrolledDate", + "general.lastCloudBackupDate", + "general.reportDate", + "general.mdmCertificateExpiration", + "general.platform", + "general.lastLoggedInUsernameSelfService", + "general.lastLoggedInUsernameSelfServiceTimestamp", + "general.lastLoggedInUsernameBinary", + "general.lastLoggedInUsernameBinaryTimestamp", + "hardware.make", + "hardware.model", + "operatingSystem.build", + "operatingSystem.supplementalBuildVersion", + "operatingSystem.rapidSecurityResponse", + "operatingSystem.name", + "operatingSystem.version", + "userAndLocation.realname", + "purchasing.lifeExpectancy", + "purchasing.warrantyDate", +] + +get_computer_inventory_v3_allowed_filter_fields = [ + "general.name", + "udid", + "id", + "general.assetTag", + "general.barcode1", + "general.barcode2", + "general.enrolledViaAutomatedDeviceEnrollment", + "general.lastIpAddress", + "general.itunesStoreAccountActive", + "general.jamfBinaryVersion", + "general.lastContactTime", + "general.lastEnrolledDate", + "general.lastCloudBackupDate", + "general.reportDate", + "general.lastReportedIp", + "general.lastReportedIpV4", + "general.lastReportedIpV6", + "general.managementId", + "general.remoteManagement.managed", + "general.mdmCapable.capable", + "general.mdmCertificateExpiration", + "general.platform", + "general.supervised", + "general.userApprovedMdm", + "general.declarativeDeviceManagementEnabled", + "general.lastLoggedInUsernameSelfService", + "general.lastLoggedInUsernameSelfServiceTimestamp", + "general.lastLoggedInUsernameBinary", + "general.lastLoggedInUsernameBinaryTimestamp", + "hardware.bleCapable", + "hardware.macAddress", + "hardware.make", + "hardware.model", + "hardware.modelIdentifier", + "hardware.serialNumber", + "hardware.supportsIosAppInstalls", + "hardware.appleSilicon", + "operatingSystem.activeDirectoryStatus", + "operatingSystem.fileVault2Status", + "operatingSystem.build", + "operatingSystem.supplementalBuildVersion", + "operatingSystem.rapidSecurityResponse", + "operatingSystem.name", + "operatingSystem.version", + "security.activationLockEnabled", + "security.recoveryLockEnabled", + "security.firewallEnabled", + "userAndLocation.buildingId", "userAndLocation.departmentId", "userAndLocation.email", "userAndLocation.realname", diff --git a/src/jamf_pro_sdk/models/pro/computers.py b/src/jamf_pro_sdk/models/pro/computers.py index 4a0727b..8c7de48 100644 --- a/src/jamf_pro_sdk/models/pro/computers.py +++ b/src/jamf_pro_sdk/models/pro/computers.py @@ -169,6 +169,8 @@ class ComputerApplication(BaseModel): name: Optional[str] = None path: Optional[str] = None version: Optional[str] = None + cfBundleShortVersionString: Optional[str] = None + cfBundleVersion: Optional[str] = None macAppStore: Optional[bool] = None sizeMegabytes: Optional[int] = None bundleId: Optional[str] = None @@ -678,7 +680,12 @@ class ComputerGroupMembership(BaseModel): class Computer(BaseModel): - """Represents a full computer inventory record.""" + """Represents a full computer inventory record. + + Used for both v1 and v3 endpoints. The v3 API does not return ``plugins`` or ``fonts`` + (they will be ``None``), and adds ``cfBundleShortVersionString``/``cfBundleVersion`` + to application entries. + """ model_config = ConfigDict(extra="allow") diff --git a/src/jamf_pro_sdk/models/pro/mdm.py b/src/jamf_pro_sdk/models/pro/mdm.py index 219bdeb..ea33066 100644 --- a/src/jamf_pro_sdk/models/pro/mdm.py +++ b/src/jamf_pro_sdk/models/pro/mdm.py @@ -203,7 +203,178 @@ class CustomCommand(BaseModel): commandType: str -# MDM Send Command Models +# V2 MDM Command Models + + +class ApplyRedemptionCodeCommand(BaseModel): + """MDM command to apply a redemption code to a device.""" + + commandType: Literal["APPLY_REDEMPTION_CODE"] = "APPLY_REDEMPTION_CODE" + redemptionCode: str + + +class CertificateListCommand(BaseModel): + """MDM command to request a certificate list from a device.""" + + commandType: Literal["CERTIFICATE_LIST"] = "CERTIFICATE_LIST" + + +class ClearPasscodeCommand(BaseModel): + """MDM command to clear the passcode on a device.""" + + commandType: Literal["CLEAR_PASSCODE"] = "CLEAR_PASSCODE" + + +class ClearRestrictionsPasswordCommand(BaseModel): + """MDM command to clear restrictions password on a device.""" + + commandType: Literal["CLEAR_RESTRICTIONS_PASSWORD"] = "CLEAR_RESTRICTIONS_PASSWORD" + + +class DeclarativeManagementCommand(BaseModel): + """MDM command for declarative device management.""" + + commandType: Literal["DECLARATIVE_MANAGEMENT"] = "DECLARATIVE_MANAGEMENT" + + +class DeleteUserCommand(BaseModel): + """MDM command to delete a user from a shared device.""" + + commandType: Literal["DELETE_USER"] = "DELETE_USER" + userName: Optional[str] = None + forceDeletion: Optional[bool] = None + + +class DeviceInformationCommand(BaseModel): + """MDM command to request device information.""" + + commandType: Literal["DEVICE_INFORMATION"] = "DEVICE_INFORMATION" + + +class DeviceLocationCommand(BaseModel): + """MDM command to request device location.""" + + commandType: Literal["DEVICE_LOCATION"] = "DEVICE_LOCATION" + + +class DeviceLockCommand(BaseModel): + """MDM command to lock a device.""" + + commandType: Literal["DEVICE_LOCK"] = "DEVICE_LOCK" + pin: Optional[str] = None + message: Optional[str] = None + phoneNumber: Optional[str] = None + + +class DisableLostModeCommand(BaseModel): + """MDM command to disable Lost Mode on a device.""" + + commandType: Literal["DISABLE_LOST_MODE"] = "DISABLE_LOST_MODE" + + +class DisableRemoteDesktopCommand(BaseModel): + """MDM command to disable Remote Desktop on a Mac.""" + + commandType: Literal["DISABLE_REMOTE_DESKTOP"] = "DISABLE_REMOTE_DESKTOP" + + +class EnableRemoteDesktopCommand(BaseModel): + """MDM command to enable Remote Desktop on a Mac.""" + + commandType: Literal["ENABLE_REMOTE_DESKTOP"] = "ENABLE_REMOTE_DESKTOP" + + +class InstalledApplicationListCommand(BaseModel): + """MDM command to request the list of installed applications.""" + + commandType: Literal["INSTALLED_APPLICATION_LIST"] = "INSTALLED_APPLICATION_LIST" + + +class ManagedApplicationListCommand(BaseModel): + """MDM command to request the list of managed applications.""" + + commandType: Literal["MANAGED_APPLICATION_LIST"] = "MANAGED_APPLICATION_LIST" + + +class ManagedMediaListCommand(BaseModel): + """MDM command to request the list of managed media.""" + + commandType: Literal["MANAGED_MEDIA_LIST"] = "MANAGED_MEDIA_LIST" + + +class PlayLostModeSoundCommand(BaseModel): + """MDM command to play a sound on a device in Lost Mode.""" + + commandType: Literal["PLAY_LOST_MODE_SOUND"] = "PLAY_LOST_MODE_SOUND" + + +class ProvisioningProfileListCommand(BaseModel): + """MDM command to request the list of provisioning profiles.""" + + commandType: Literal["PROVISIONING_PROFILE_LIST"] = "PROVISIONING_PROFILE_LIST" + + +class RefreshCellularPlansCommand(BaseModel): + """MDM command to refresh cellular plans on a device.""" + + commandType: Literal["REFRESH_CELLULAR_PLANS"] = "REFRESH_CELLULAR_PLANS" + + +class RequestMirroringCommand(BaseModel): + """MDM command to request AirPlay mirroring.""" + + commandType: Literal["REQUEST_MIRRORING"] = "REQUEST_MIRRORING" + + +class SecurityInfoCommand(BaseModel): + """MDM command to request security information from a device.""" + + commandType: Literal["SECURITY_INFO"] = "SECURITY_INFO" + + +class SetAutoAdminPasswordCommand(BaseModel): + """MDM command to set the auto admin password.""" + + commandType: Literal["SET_AUTO_ADMIN_PASSWORD"] = "SET_AUTO_ADMIN_PASSWORD" + guid: str + password: str + + +class SettingsCommand(BaseModel): + """MDM command to update device settings.""" + + model_config = ConfigDict(extra="allow") + + commandType: Literal["SETTINGS"] = "SETTINGS" + + +class StopMirroringCommand(BaseModel): + """MDM command to stop AirPlay mirroring.""" + + commandType: Literal["STOP_MIRRORING"] = "STOP_MIRRORING" + + +class UnlockUserAccountCommand(BaseModel): + """MDM command to unlock a user account on a device.""" + + commandType: Literal["UNLOCK_USER_ACCOUNT"] = "UNLOCK_USER_ACCOUNT" + userName: str + + +class ValidateApplicationsCommand(BaseModel): + """MDM command to validate managed applications on a device.""" + + commandType: Literal["VALIDATE_APPLICATIONS"] = "VALIDATE_APPLICATIONS" + + +class VerifyRecoveryLockCommand(BaseModel): + """MDM command to verify the Recovery Lock password on a device.""" + + commandType: Literal["VERIFY_RECOVERY_LOCK"] = "VERIFY_RECOVERY_LOCK" + password: str + + +# MDM Send Command Models (Preview - Deprecated) class SendMdmCommandClientData(BaseModel): @@ -228,6 +399,57 @@ class SendMdmCommand(BaseModel): commandData: Union[BuiltInCommands, CustomCommand] +# MDM Send Command Models (V2) + + +class MdmCommandClientRequest(BaseModel): + managementId: Union[str, UUID] + + +BuiltInCommandsV2 = Annotated[ + Union[ + ApplyRedemptionCodeCommand, + CertificateListCommand, + ClearPasscodeCommand, + ClearRestrictionsPasswordCommand, + DeclarativeManagementCommand, + DeleteUserCommand, + DeviceInformationCommand, + DeviceLocationCommand, + DeviceLockCommand, + DisableLostModeCommand, + DisableRemoteDesktopCommand, + EnableLostModeCommand, + EnableRemoteDesktopCommand, + EraseDeviceCommand, + InstalledApplicationListCommand, + LogOutUserCommand, + ManagedApplicationListCommand, + ManagedMediaListCommand, + PlayLostModeSoundCommand, + ProvisioningProfileListCommand, + RefreshCellularPlansCommand, + RequestMirroringCommand, + RestartDeviceCommand, + SecurityInfoCommand, + SetAutoAdminPasswordCommand, + SetRecoveryLockCommand, + SettingsCommand, + ShutDownDeviceCommand, + StopMirroringCommand, + UnlockUserAccountCommand, + ValidateApplicationsCommand, + VerifyRecoveryLockCommand, + ], + Field(..., discriminator="commandType"), +] + + +class MdmCommandRequest(BaseModel): + clientData: List[MdmCommandClientRequest] + commandData: Union[BuiltInCommandsV2, CustomCommand] + + # MDM Command Responses From 71ce8b6a89685c5f9cb5fee9ef01ba4f23623ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Engstr=C3=B6m?= Date: Tue, 3 Mar 2026 15:42:08 +0100 Subject: [PATCH 2/2] Add unit tests for Pro API computer and MDM command models - test_models_pro_computers.py: ComputerApplication v1/v3 parsing, roundtrip, Computer model with/without plugins/fonts, extra fields - test_models_pro_mdm.py: all v2 MDM command models, MdmCommandRequest construction/serialization/parsing, CustomCommand, SettingsCommand extra fields Co-Authored-By: Claude Opus 4.6 --- .../unit/models/test_models_pro_computers.py | 160 ++++++++++++ tests/unit/models/test_models_pro_mdm.py | 237 ++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 tests/unit/models/test_models_pro_computers.py create mode 100644 tests/unit/models/test_models_pro_mdm.py diff --git a/tests/unit/models/test_models_pro_computers.py b/tests/unit/models/test_models_pro_computers.py new file mode 100644 index 0000000..2c8c8f5 --- /dev/null +++ b/tests/unit/models/test_models_pro_computers.py @@ -0,0 +1,160 @@ +import json + +from deepdiff import DeepDiff + +from src.jamf_pro_sdk.models.pro.computers import Computer, ComputerApplication + +# Simulated v1 API response (no cfBundle fields) +APPLICATION_V1_JSON = { + "name": "Safari", + "path": "/Applications/Safari.app", + "version": "17.0", + "macAppStore": False, + "sizeMegabytes": 50, + "bundleId": "com.apple.Safari", + "updateAvailable": False, + "externalVersionId": "123456", +} + +# Simulated v3 API response (includes cfBundle fields) +APPLICATION_V3_JSON = { + "name": "Safari", + "path": "/Applications/Safari.app", + "version": "17.0", + "cfBundleShortVersionString": "17.0", + "cfBundleVersion": "19617.1.17.11.12", + "macAppStore": False, + "sizeMegabytes": 50, + "bundleId": "com.apple.Safari", + "updateAvailable": False, + "externalVersionId": "123456", +} + +COMPUTER_JSON = { + "id": "42", + "udid": "AAAA-BBBB-CCCC", + "general": { + "name": "Test Mac", + "lastIpAddress": "192.168.1.100", + "platform": "Mac", + "managementId": "mgmt-id-123", + "remoteManagement": {"managed": True, "managementUsername": "admin"}, + "site": {"id": "1", "name": "Main Site"}, + }, + "hardware": { + "make": "Apple", + "model": "MacBook Pro", + "serialNumber": "C02XYZ123456", + "appleSilicon": True, + }, + "applications": [APPLICATION_V3_JSON], +} + +COMPUTER_WITH_PLUGINS_FONTS_JSON = { + "id": "43", + "udid": "DDDD-EEEE-FFFF", + "general": {"name": "Legacy Mac"}, + "plugins": [{"name": "Flash Player", "version": "32.0", "path": "/Library/Plugins/Flash"}], + "fonts": [{"name": "Helvetica", "version": "1.0", "path": "/Library/Fonts/Helvetica.ttf"}], +} + + +# ComputerApplication tests + + +def test_application_model_parsing_v1(): + """v1 response data (no cfBundle fields) should parse with None defaults.""" + app = ComputerApplication.model_validate(APPLICATION_V1_JSON) + assert app.name == "Safari" + assert app.version == "17.0" + assert app.bundleId == "com.apple.Safari" + assert app.cfBundleShortVersionString is None + assert app.cfBundleVersion is None + + +def test_application_model_parsing_v3(): + """v3 response data (with cfBundle fields) should parse all fields.""" + app = ComputerApplication.model_validate(APPLICATION_V3_JSON) + assert app.name == "Safari" + assert app.version == "17.0" + assert app.cfBundleShortVersionString == "17.0" + assert app.cfBundleVersion == "19617.1.17.11.12" + assert app.bundleId == "com.apple.Safari" + + +def test_application_v1_json_roundtrip(): + """v1 data should roundtrip without adding cfBundle fields.""" + app = ComputerApplication.model_validate(APPLICATION_V1_JSON) + serialized = json.loads(app.model_dump_json(exclude_none=True)) + diff = DeepDiff(APPLICATION_V1_JSON, serialized, ignore_order=True) + assert not diff + + +def test_application_v3_json_roundtrip(): + """v3 data should roundtrip with cfBundle fields included.""" + app = ComputerApplication.model_validate(APPLICATION_V3_JSON) + serialized = json.loads(app.model_dump_json(exclude_none=True)) + diff = DeepDiff(APPLICATION_V3_JSON, serialized, ignore_order=True) + assert not diff + + +# Computer model tests + + +def test_computer_model_parsing(): + """Verify select attributes across the Computer model.""" + computer = Computer.model_validate(COMPUTER_JSON) + assert computer.id == "42" + assert computer.udid == "AAAA-BBBB-CCCC" + assert computer.general.name == "Test Mac" + assert computer.general.platform == "Mac" + assert computer.general.managementId == "mgmt-id-123" + assert computer.hardware.serialNumber == "C02XYZ123456" + assert computer.hardware.appleSilicon is True + + +def test_computer_model_applications_v3(): + """Computer model should include cfBundle fields from v3 application data.""" + computer = Computer.model_validate(COMPUTER_JSON) + assert len(computer.applications) == 1 + app = computer.applications[0] + assert app.cfBundleShortVersionString == "17.0" + assert app.cfBundleVersion == "19617.1.17.11.12" + + +def test_computer_model_with_plugins_and_fonts(): + """Computer model should handle plugins and fonts (v1 data).""" + computer = Computer.model_validate(COMPUTER_WITH_PLUGINS_FONTS_JSON) + assert len(computer.plugins) == 1 + assert computer.plugins[0].name == "Flash Player" + assert len(computer.fonts) == 1 + assert computer.fonts[0].name == "Helvetica" + + +def test_computer_model_without_plugins_and_fonts(): + """Computer model should handle missing plugins/fonts (v3 data).""" + computer = Computer.model_validate(COMPUTER_JSON) + assert computer.plugins is None + assert computer.fonts is None + + +def test_computer_model_json_roundtrip(): + """The Computer model has default_factory for general and userAndLocation, + so a roundtrip will include those as empty objects even if not in the input. + We verify input data is preserved and only expected keys are added.""" + computer = Computer.model_validate(COMPUTER_JSON) + serialized = json.loads(computer.model_dump_json(exclude_none=True)) + + # All original fields should be present + for key in COMPUTER_JSON: + assert key in serialized + diff = DeepDiff(COMPUTER_JSON[key], serialized[key], ignore_order=True) + assert not diff, f"Mismatch in '{key}': {diff}" + + +def test_computer_model_extra_fields(): + """Computer model with extra='allow' should accept unknown fields.""" + data = {**COMPUTER_JSON, "unknownNewField": "someValue"} + computer = Computer.model_validate(data) + assert computer.id == "42" + assert computer.unknownNewField == "someValue" diff --git a/tests/unit/models/test_models_pro_mdm.py b/tests/unit/models/test_models_pro_mdm.py new file mode 100644 index 0000000..92ff84e --- /dev/null +++ b/tests/unit/models/test_models_pro_mdm.py @@ -0,0 +1,237 @@ +import json + +from deepdiff import DeepDiff + +from src.jamf_pro_sdk.models.pro.mdm import ( + ApplyRedemptionCodeCommand, + CertificateListCommand, + ClearPasscodeCommand, + ClearRestrictionsPasswordCommand, + CustomCommand, + DeclarativeManagementCommand, + DeleteUserCommand, + DeviceInformationCommand, + DeviceLocationCommand, + DeviceLockCommand, + DisableLostModeCommand, + DisableRemoteDesktopCommand, + EnableLostModeCommand, + EnableRemoteDesktopCommand, + EraseDeviceCommand, + InstalledApplicationListCommand, + LogOutUserCommand, + ManagedApplicationListCommand, + ManagedMediaListCommand, + MdmCommandClientRequest, + MdmCommandRequest, + PlayLostModeSoundCommand, + ProvisioningProfileListCommand, + RefreshCellularPlansCommand, + RequestMirroringCommand, + RestartDeviceCommand, + SecurityInfoCommand, + SetAutoAdminPasswordCommand, + SetRecoveryLockCommand, + SettingsCommand, + ShutDownDeviceCommand, + StopMirroringCommand, + UnlockUserAccountCommand, + ValidateApplicationsCommand, + VerifyRecoveryLockCommand, +) + + +# Simple commands (no required fields beyond commandType) + +SIMPLE_COMMANDS = [ + (CertificateListCommand, "CERTIFICATE_LIST"), + (ClearPasscodeCommand, "CLEAR_PASSCODE"), + (ClearRestrictionsPasswordCommand, "CLEAR_RESTRICTIONS_PASSWORD"), + (DeclarativeManagementCommand, "DECLARATIVE_MANAGEMENT"), + (DeviceInformationCommand, "DEVICE_INFORMATION"), + (DeviceLocationCommand, "DEVICE_LOCATION"), + (DisableLostModeCommand, "DISABLE_LOST_MODE"), + (DisableRemoteDesktopCommand, "DISABLE_REMOTE_DESKTOP"), + (EnableRemoteDesktopCommand, "ENABLE_REMOTE_DESKTOP"), + (InstalledApplicationListCommand, "INSTALLED_APPLICATION_LIST"), + (LogOutUserCommand, "LOG_OUT_USER"), + (ManagedApplicationListCommand, "MANAGED_APPLICATION_LIST"), + (ManagedMediaListCommand, "MANAGED_MEDIA_LIST"), + (PlayLostModeSoundCommand, "PLAY_LOST_MODE_SOUND"), + (ProvisioningProfileListCommand, "PROVISIONING_PROFILE_LIST"), + (RefreshCellularPlansCommand, "REFRESH_CELLULAR_PLANS"), + (RequestMirroringCommand, "REQUEST_MIRRORING"), + (SecurityInfoCommand, "SECURITY_INFO"), + (ShutDownDeviceCommand, "SHUT_DOWN_DEVICE"), + (StopMirroringCommand, "STOP_MIRRORING"), + (ValidateApplicationsCommand, "VALIDATE_APPLICATIONS"), +] + + +def test_simple_command_defaults(): + """All simple commands should have the correct commandType default.""" + for command_cls, expected_type in SIMPLE_COMMANDS: + command = command_cls() + assert command.commandType == expected_type + + +def test_simple_command_serialization(): + """Simple commands should serialize to a dict with only commandType.""" + for command_cls, expected_type in SIMPLE_COMMANDS: + command = command_cls() + data = json.loads(command.model_dump_json(exclude_none=True)) + assert data == {"commandType": expected_type} + + +def test_enable_lost_mode_command(): + command = EnableLostModeCommand( + lostModeMessage="Return me", + lostModePhone="555-1234", + lostModeFootnote="No reward", + ) + assert command.commandType == "ENABLE_LOST_MODE" + assert command.lostModeMessage == "Return me" + assert command.lostModePhone == "555-1234" + assert command.lostModeFootnote == "No reward" + + +def test_erase_device_command(): + command = EraseDeviceCommand(pin="123456", obliterationBehavior="ObliterateWithWarning") + assert command.commandType == "ERASE_DEVICE" + assert command.pin == "123456" + assert command.obliterationBehavior.value == "ObliterateWithWarning" + + +def test_erase_device_command_optional_fields(): + command = EraseDeviceCommand() + assert command.commandType == "ERASE_DEVICE" + assert command.pin is None + assert command.obliterationBehavior is None + assert command.returnToService is None + + +def test_restart_device_command(): + command = RestartDeviceCommand( + notifyUser=True, rebuildKernelCache=False, kextPaths=None + ) + assert command.commandType == "RESTART_DEVICE" + assert command.notifyUser is True + assert command.rebuildKernelCache is False + + +def test_set_recovery_lock_command(): + command = SetRecoveryLockCommand(newPassword="jamf1234") + assert command.commandType == "SET_RECOVERY_LOCK" + assert command.newPassword == "jamf1234" + + +def test_device_lock_command(): + command = DeviceLockCommand(pin="123456", message="Locked", phoneNumber="555-0000") + assert command.commandType == "DEVICE_LOCK" + assert command.pin == "123456" + assert command.message == "Locked" + assert command.phoneNumber == "555-0000" + + +def test_delete_user_command(): + command = DeleteUserCommand(userName="testuser", forceDeletion=True) + assert command.commandType == "DELETE_USER" + assert command.userName == "testuser" + assert command.forceDeletion is True + + +def test_set_auto_admin_password_command(): + command = SetAutoAdminPasswordCommand(guid="abc-123", password="secret") + assert command.commandType == "SET_AUTO_ADMIN_PASSWORD" + assert command.guid == "abc-123" + assert command.password == "secret" + + +def test_apply_redemption_code_command(): + command = ApplyRedemptionCodeCommand(redemptionCode="ABCD-1234") + assert command.commandType == "APPLY_REDEMPTION_CODE" + assert command.redemptionCode == "ABCD-1234" + + +def test_unlock_user_account_command(): + command = UnlockUserAccountCommand(userName="lockeduser") + assert command.commandType == "UNLOCK_USER_ACCOUNT" + assert command.userName == "lockeduser" + + +def test_verify_recovery_lock_command(): + command = VerifyRecoveryLockCommand(password="jamf1234") + assert command.commandType == "VERIFY_RECOVERY_LOCK" + assert command.password == "jamf1234" + + +def test_settings_command_allows_extra_fields(): + command = SettingsCommand(voiceRoaming=True, dataRoaming=False) + assert command.commandType == "SETTINGS" + assert command.voiceRoaming is True + assert command.dataRoaming is False + + +def test_custom_command(): + command = CustomCommand(commandType="MY_CUSTOM_COMMAND", customField="value") + assert command.commandType == "MY_CUSTOM_COMMAND" + assert command.customField == "value" + + +# MdmCommandRequest tests + + +def test_mdm_command_request_with_simple_command(): + request = MdmCommandRequest( + clientData=[ + MdmCommandClientRequest(managementId="4eecc1fb-f52d-48c5-9560-c246b23601d3") + ], + commandData=LogOutUserCommand(), + ) + assert len(request.clientData) == 1 + assert str(request.clientData[0].managementId) == "4eecc1fb-f52d-48c5-9560-c246b23601d3" + assert request.commandData.commandType == "LOG_OUT_USER" + + +def test_mdm_command_request_multiple_devices(): + request = MdmCommandRequest( + clientData=[ + MdmCommandClientRequest(managementId="aaaa-bbbb"), + MdmCommandClientRequest(managementId="cccc-dddd"), + ], + commandData=ShutDownDeviceCommand(), + ) + assert len(request.clientData) == 2 + + +def test_mdm_command_request_serialization(): + request = MdmCommandRequest( + clientData=[ + MdmCommandClientRequest(managementId="4eecc1fb-f52d-48c5-9560-c246b23601d3") + ], + commandData=SetRecoveryLockCommand(newPassword="test123"), + ) + data = json.loads(request.model_dump_json()) + assert data["clientData"][0]["managementId"] == "4eecc1fb-f52d-48c5-9560-c246b23601d3" + assert data["commandData"]["commandType"] == "SET_RECOVERY_LOCK" + assert data["commandData"]["newPassword"] == "test123" + + +def test_mdm_command_request_with_custom_command(): + request = MdmCommandRequest( + clientData=[MdmCommandClientRequest(managementId="test-id")], + commandData=CustomCommand(commandType="FUTURE_COMMAND", someField="value"), + ) + assert request.commandData.commandType == "FUTURE_COMMAND" + + +def test_mdm_command_request_parsing_from_dict(): + """Verify MdmCommandRequest can be constructed from a raw dict.""" + raw = { + "clientData": [{"managementId": "id-1"}, {"managementId": "id-2"}], + "commandData": {"commandType": "ERASE_DEVICE", "pin": "654321"}, + } + request = MdmCommandRequest.model_validate(raw) + assert len(request.clientData) == 2 + assert request.commandData.commandType == "ERASE_DEVICE" + assert request.commandData.pin == "654321"