From 37a3d5e0e5479322fae86ad4b1b62c6a9f97bfc9 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 11:39:22 +0000 Subject: [PATCH 01/31] Add Squelch to cdp_client Signed-off-by: rsuplina --- plugins/module_utils/cdp_client.py | 40 +++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index 54f138d3..eb90ac88 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -258,6 +258,7 @@ def _post( path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP POST request.""" pass @@ -268,12 +269,17 @@ def _put( path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Optional[Dict[int, Any]] = {}, ) -> Dict[str, Any]: """Execute HTTP PUT request.""" pass @abc.abstractmethod - def _delete(self, path: str) -> Dict[str, Any]: + def _delete( + self, + path: str, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: """Execute HTTP DELETE request.""" pass @@ -408,19 +414,21 @@ def post( path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: - return self.api_client._post(path, data, json_data) + return self.api_client._post(path, data, json_data, squelch=squelch) def put( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: - return self.api_client._put(path, data, json_data) + return self.api_client._put(path, data, json_data, squelch=squelch) - def delete(self, path: str) -> Dict[str, Any]: - return self.api_client._delete(path) + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + return self.api_client._delete(path, squelch=squelch) class AnsibleCdpClient(RestClient): @@ -477,6 +485,7 @@ def _make_request( data: Optional[Union[Dict[str, Any], List[Any]]] = None, json_data: Optional[Union[Dict[str, Any], List[Any]]] = None, max_retries: int = 3, + squelch: Optional[Dict[int, Any]] = None, ) -> Any: """ Make HTTP request with retry logic using Ansible's fetch_url. @@ -488,6 +497,7 @@ def _make_request( data: Form data json_data: JSON data max_retries: Maximum number of retry attempts + squelch: Dictionary of HTTP status codes to squelch with default return values Returns: Response data as dictionary or None for 204 responses @@ -556,6 +566,12 @@ def _make_request( if status_code == 403: raise CdpError(f"Forbidden access to {path}", status=403) + if status_code in squelch: + self.module.warn( + f"Squelched error {status_code} for {url}", + ) + return squelch[status_code] + # Handle success responses if 200 <= status_code < 300: # 204 No Content - return None @@ -640,19 +656,25 @@ def _post( path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP POST request.""" - return self._make_request("POST", path, data=data, json_data=json_data) + return self._make_request( + "POST", path, data=data, json_data=json_data, squelch=squelch + ) def _put( self, path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP PUT request.""" - return self._make_request("PUT", path, data=data, json_data=json_data) + return self._make_request( + "PUT", path, data=data, json_data=json_data, squelch=squelch + ) - def _delete(self, path: str) -> Dict[str, Any]: + def _delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: """Execute HTTP DELETE request.""" - return self._make_request("DELETE", path) + return self._make_request("DELETE", path, squelch=squelch) From 40dc28751a9963bca9d86e5c302ea9d6ceafea03 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 11:39:42 +0000 Subject: [PATCH 02/31] Add CDP IAM API client implementation Signed-off-by: rsuplina --- plugins/module_utils/cdp_iam.py | 924 ++++++++++++++++++++++++++++++++ 1 file changed, 924 insertions(+) diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index 37280400..a74e96c9 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -38,6 +38,210 @@ def __init__(self, api_client: RestClient): """ super().__init__(api_client=api_client) + def _is_machine_user(self, user_crn: str) -> bool: + """Check if a user CRN represents a machine user.""" + return ":machineUser:" in user_crn + + def get_group_details(self, group_name: str) -> Optional[Dict[str, Any]]: + """ + Get complete group information including members, roles, and resource assignments. + + This method makes multiple API calls to assemble a comprehensive group profile: + - Basic group information (from list_groups) + - Group members (users and machine users) + - Assigned roles + - Assigned resource roles + + Returns: + Complete group information dict, or None if group doesn't exist + """ + groups = self.list_groups(group_names=[group_name]).get("groups", []) + # all_groups_response = self.list_groups() + # all_groups = all_groups_response.get("groups", []) + # groups = [g for g in all_groups if g.get("groupName") == group_name] + + if not groups: + return None + + group_info = groups[0] + + # Get group members (users and machine users) + members_response = self.list_group_members(group_name=group_name) + members = members_response.get("memberCrns", []) + + # Get assigned roles + roles_response = self.list_group_assigned_roles(group_name=group_name) + roles = roles_response.get("roleCrns", []) + + # Get assigned resource roles + resource_roles_response = self.list_group_assigned_resource_roles( + group_name=group_name + ) + resource_assignments = resource_roles_response.get("resourceAssignments", []) + + # Build complete group object + return { + "groupName": group_info.get("groupName"), + "crn": group_info.get("crn"), + "creationDate": group_info.get("creationDate"), + "syncMembershipOnUserLogin": group_info.get("syncMembershipOnUserLogin"), + "members": members, + "roles": roles, + "resourceAssignments": resource_assignments, + } + + def manage_group_users( + self, + group_name: str, + current_members: List[str], + desired_users: List[str], + purge: bool = False, + ) -> bool: + """ + Manage group membership (add/remove users and machine users). + + Args: + group_name: The name of the group + current_members: List of current member CRNs + desired_users: List of desired user CRNs + purge: If True, remove users not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + if purge: + # Remove all users not in desired list + users_to_remove = [ + user for user in current_members if user not in desired_users + ] + for user_crn in users_to_remove: + if self._is_machine_user(user_crn): + self.remove_machine_user_from_group( + machine_user_name=user_crn, group_name=group_name + ) + else: + self.remove_user_from_group(user_id=user_crn, group_name=group_name) + changed = True + + # Add missing users + users_to_add = [user for user in desired_users if user not in current_members] + for user_crn in users_to_add: + if self._is_machine_user(user_crn): + self.add_machine_user_to_group( + machine_user_name=user_crn, group_name=group_name + ) + else: + self.add_user_to_group(user_id=user_crn, group_name=group_name) + changed = True + + return changed + + def manage_group_roles( + self, + group_name: str, + current_roles: List[str], + desired_roles: List[str], + purge: bool = False, + ) -> bool: + """ + Manage group role assignments. + + Args: + group_name: The name of the group + current_roles: List of current role CRNs + desired_roles: List of desired role CRNs + purge: If True, remove roles not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + if purge: + # Remove all roles not in desired list + roles_to_remove = [ + role for role in current_roles if role not in desired_roles + ] + for role_crn in roles_to_remove: + self.unassign_group_role(group_name=group_name, role=role_crn) + changed = True + + # Add missing roles + roles_to_add = [role for role in desired_roles if role not in current_roles] + for role_crn in roles_to_add: + self.assign_group_role(group_name=group_name, role=role_crn) + changed = True + + return changed + + def manage_group_resource_roles( + self, + group_name: str, + current_assignments: List[Dict[str, str]], + desired_assignments: List[Dict[str, str]], + purge: bool = False, + ) -> bool: + """ + Manage group resource role assignments. + + Args: + group_name: The name of the group + current_assignments: List of current resource role assignments + desired_assignments: List of desired resource role assignments + purge: If True, remove assignments not in desired list + + Returns: + True if changes were made, False otherwise + """ + changed = False + + # Normalize current assignments for comparison + current_normalized = [ + { + "resource": a.get("resourceCrn"), + "role": a.get("resourceRoleCrn"), + } + for a in current_assignments + ] + + # Normalize desired assignments + desired_normalized = [ + { + "resource": a.get("resource") or a.get("resourceCrn"), + "role": a.get("role") or a.get("resourceRoleCrn"), + } + for a in desired_assignments + ] + + if purge: + # Remove all assignments not in desired list + assignments_to_remove = [ + a for a in current_normalized if a not in desired_normalized + ] + for assignment in assignments_to_remove: + self.unassign_group_resource_role( + group_name=group_name, + resource_crn=assignment["resource"], + resource_role_crn=assignment["role"], + ) + changed = True + + # Add missing assignments + assignments_to_add = [ + a for a in desired_normalized if a not in current_normalized + ] + for assignment in assignments_to_add: + self.assign_group_resource_role( + group_name=group_name, + resource_crn=assignment["resource"], + resource_role_crn=assignment["role"], + ) + changed = True + + return changed + @RestClient.paginated() def list_groups( self, @@ -71,4 +275,724 @@ def list_groups( return self.post( "/api/v1/iam/listGroups", json_data=json_data, + squelch={404: {}}, + ) + + @RestClient.paginated() + def list_users( + self, + user_ids: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List IAM users with automatic pagination. + + Args: + user_ids: Optional list of user IDs or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing users list + """ + json_data: Dict[str, Any] = {} + + # Add user IDs filter if provided + if user_ids is not None: + json_data["userIds"] = user_ids + + # Add pagination parameters if provided + # Note: IAM API uses "startingToken" for requests, but decorator uses "pageToken" + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listUsers", + json_data=json_data, + ) + + def get_user(self, user_id: Optional[str] = None) -> Dict[str, Any]: + """ + Get information about a user. + + Args: + user_id: Optional user ID or CRN. If not provided, gets the current user. + + Returns: + Response containing user information + """ + json_data: Dict[str, Any] = {} + + # Add user ID if provided + if user_id is not None: + json_data["userId"] = user_id + + response = self.post( + "/api/v1/iam/getUser", + json_data=json_data, + ) + + return response.get("user", {}) + + @RestClient.paginated() + def list_group_assigned_resource_roles( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listGroupAssignedResourceRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_group_assigned_roles( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listGroupAssignedRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_group_members( + self, + group_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List members of a group with automatic pagination. + + Args: + group_name: Group name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing member CRNs + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listGroupMembers", + json_data=json_data, + ) + + @RestClient.paginated() + def list_groups_for_machine_user( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List groups for a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing group CRNs + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listGroupsForMachineUser", + json_data=json_data, + ) + + @RestClient.paginated() + def list_groups_for_user( + self, + user_id: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List groups for a user with automatic pagination. + + Args: + user_id: User ID or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing group CRNs + """ + json_data: Dict[str, Any] = {"userId": user_id} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listGroupsForUser", + json_data=json_data, + ) + + @RestClient.paginated() + def list_machine_user_assigned_resource_roles( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listMachineUserAssignedResourceRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_machine_user_assigned_roles( + self, + machine_user_name: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a machine user with automatic pagination. + + Args: + machine_user_name: Machine user name or CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {"machineUserName": machine_user_name} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listMachineUserAssignedRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_machine_users( + self, + machine_user_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List machine users with automatic pagination. + + Args: + machine_user_names: Optional list of machine user names or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing machine users list + """ + json_data: Dict[str, Any] = {} + + if machine_user_names is not None: + json_data["machineUserNames"] = machine_user_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listMachineUsers", + json_data=json_data, + ) + + @RestClient.paginated() + def list_resource_assignees( + self, + resource_crn: str, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource assignees and their resource roles with automatic pagination. + + Args: + resource_crn: Resource CRN + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignees + """ + json_data: Dict[str, Any] = {"resourceCrn": resource_crn} + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listResourceAssignees", + json_data=json_data, + ) + + @RestClient.paginated() + def list_resource_roles( + self, + resource_role_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles with automatic pagination. + + Args: + resource_role_names: Optional list of resource role CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource roles list + """ + json_data: Dict[str, Any] = {} + + if resource_role_names is not None: + json_data["resourceRoleNames"] = resource_role_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listResourceRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_roles( + self, + role_names: Optional[List[str]] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles with automatic pagination. + + Args: + role_names: Optional list of role names or CRNs to filter by + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing roles list + """ + json_data: Dict[str, Any] = {} + + if role_names is not None: + json_data["roleNames"] = role_names + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_user_assigned_resource_roles( + self, + user: Optional[str] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List resource roles assigned to a user with automatic pagination. + + Args: + user: Optional user CRN or ID. If not provided, defaults to the user making the request. + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing resource assignments + """ + json_data: Dict[str, Any] = {} + + if user is not None: + json_data["user"] = user + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listUserAssignedResourceRoles", + json_data=json_data, + ) + + @RestClient.paginated() + def list_user_assigned_roles( + self, + user: Optional[str] = None, + pageToken: Optional[str] = None, + pageSize: Optional[int] = None, + ) -> Dict[str, Any]: + """ + List roles assigned to a user with automatic pagination. + + Args: + user: Optional user CRN or ID. If not provided, defaults to the user making the request. + pageToken: Token for pagination (automatically handled by decorator) + pageSize: Page size for pagination (automatically handled by decorator) + + Returns: + Response with automatic pagination handling containing role CRNs + """ + json_data: Dict[str, Any] = {} + + if user is not None: + json_data["user"] = user + + if pageToken is not None: + json_data["startingToken"] = pageToken + if pageSize is not None: + json_data["pageSize"] = pageSize + + return self.post( + "/api/v1/iam/listUserAssignedRoles", + json_data=json_data, + ) + + # Group Lifecycle Management Methods + + def create_group( + self, + group_name: str, + sync_membership_on_user_login: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Create a new IAM group. + + Args: + group_name: The name of the group. Must be unique. + sync_membership_on_user_login: Whether group membership is synced when a user logs in. + Defaults to True if not specified. + + Returns: + Response containing the created group information + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if sync_membership_on_user_login is not None: + json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login + + return self.post( + "/api/v1/iam/createGroup", + json_data=json_data, + ) + + def delete_group(self, group_name: str) -> Dict[str, Any]: + """ + Delete an IAM group. + + Args: + group_name: The name or CRN of the group to delete + + Returns: + Response confirming deletion + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + return self.post( + "/api/v1/iam/deleteGroup", + json_data=json_data, + ) + + def update_group( + self, + group_name: str, + sync_membership_on_user_login: Optional[bool] = None, + ) -> Dict[str, Any]: + """ + Update an IAM group. + + Args: + group_name: The name or CRN of the group to update + sync_membership_on_user_login: Whether group membership is synced when a user logs in. + Can be omitted if no update is required. + + Returns: + Response containing the updated group information + """ + json_data: Dict[str, Any] = {"groupName": group_name} + + if sync_membership_on_user_login is not None: + json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login + + return self.post( + "/api/v1/iam/updateGroup", + json_data=json_data, + ) + + # Group Membership Management Methods + + def add_user_to_group(self, group_name: str, user_id: str) -> Dict[str, Any]: + """ + Add a user to a group. + + Args: + group_name: The name or CRN of the group + user_id: The ID or CRN of the user to add to the group + + Returns: + Response confirming the user was added to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "userId": user_id, + } + + return self.post( + "/api/v1/iam/addUserToGroup", + json_data=json_data, + ) + + def add_machine_user_to_group( + self, group_name: str, machine_user_name: str + ) -> Dict[str, Any]: + """ + Add a machine user to a group. + + Args: + group_name: The name or CRN of the group + machine_user_name: The name or CRN of the machine user to add to the group + + Returns: + Response confirming the machine user was added to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "machineUserName": machine_user_name, + } + + return self.post( + "/api/v1/iam/addMachineUserToGroup", + json_data=json_data, + ) + + def remove_user_from_group(self, group_name: str, user_id: str) -> Dict[str, Any]: + """ + Remove a user from a group. + + Args: + group_name: The name or CRN of the group + user_id: The ID or CRN of the user to remove from the group + + Returns: + Response confirming the user was removed from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "userId": user_id, + } + + return self.post( + "/api/v1/iam/removeUserFromGroup", + json_data=json_data, + ) + + def remove_machine_user_from_group( + self, group_name: str, machine_user_name: str + ) -> Dict[str, Any]: + """ + Remove a machine user from a group. + + Args: + group_name: The name or CRN of the group + machine_user_name: The name or CRN of the machine user to remove from the group + + Returns: + Response confirming the machine user was removed from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "machineUserName": machine_user_name, + } + + return self.post( + "/api/v1/iam/removeMachineUserFromGroup", + json_data=json_data, + ) + + # Group Role Assignment Methods + + def assign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: + """ + Assign a role to a group. + + Args: + group_name: The group name or CRN + role: The role name or CRN to assign + + Returns: + Response confirming the role was assigned to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "role": role, + } + + return self.post( + "/api/v1/iam/assignGroupRole", + json_data=json_data, + ) + + def assign_group_resource_role( + self, group_name: str, resource_crn: str, resource_role_crn: str + ) -> Dict[str, Any]: + """ + Assign a resource role to a group. + + Args: + group_name: The group name or CRN + resource_crn: The resource CRN for which the resource role rights are granted + resource_role_crn: The CRN of the resource role being assigned + + Returns: + Response confirming the resource role was assigned to the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "resourceCrn": resource_crn, + "resourceRoleCrn": resource_role_crn, + } + + return self.post( + "/api/v1/iam/assignGroupResourceRole", + json_data=json_data, + ) + + def unassign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: + """ + Unassign a role from a group. + + Args: + group_name: The group name or CRN + role: The role name or CRN to unassign + + Returns: + Response confirming the role was unassigned from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "role": role, + } + + return self.post( + "/api/v1/iam/unassignGroupRole", + json_data=json_data, + ) + + def unassign_group_resource_role( + self, group_name: str, resource_crn: str, resource_role_crn: str + ) -> Dict[str, Any]: + """ + Unassign a resource role from a group. + + Args: + group_name: The group name or CRN + resource_crn: The CRN of the resource for which the resource role rights will be unassigned + resource_role_crn: The CRN of the resource role to unassign + + Returns: + Response confirming the resource role was unassigned from the group + """ + json_data: Dict[str, Any] = { + "groupName": group_name, + "resourceCrn": resource_crn, + "resourceRoleCrn": resource_role_crn, + } + + return self.post( + "/api/v1/iam/unassignGroupResourceRole", + json_data=json_data, ) From 00bd18c14b7699889703904e78b93be5e5ad1cf3 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 11:39:54 +0000 Subject: [PATCH 03/31] Update iam_group module Signed-off-by: rsuplina --- plugins/modules/iam_group.py | 330 ++++++++++++++--------------------- 1 file changed, 134 insertions(+), 196 deletions(-) diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index ebd2917f..8014e725 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -25,9 +25,8 @@ author: - "Webster Mudge (@wmudge)" - "Dan Chaffelson (@chaffelson)" + - "Ronald Suplina (@rsuplina)" version_added: "1.0.0" -requirements: - - cdpy options: name: description: @@ -63,6 +62,7 @@ required: True aliases: - resourceCrn + - resource_crn role: description: - The resource role CRN to be assigned. @@ -70,6 +70,7 @@ required: True aliases: - resourceRoleCrn + - resource_role_crn roles: description: - A single role or list of roles assigned to the group. @@ -96,16 +97,17 @@ aliases: - sync_membership - sync_on_login + - sync_membership_on_user_login users: description: - A single user or list of users assigned to the group. + - Users can be regular users or machine users. - The user can be either the name or CRN. type: list elements: str required: False extends_documentation_fragment: - - cloudera.cloud.cdp_sdk_options - - cloudera.cloud.cdp_auth_options + - cloudera.cloud.cdp_client """ EXAMPLES = r""" @@ -169,8 +171,8 @@ returned: on success type: str sample: example-01 - users: - description: List of User CRNs which are members of the group. + members: + description: List of member CRNs (users and machine users) which are members of the group. returned: on success type: list elements: str @@ -179,8 +181,8 @@ returned: on success type: list elements: str - resource_roles: - description: List of Resource-to-Role assignments, by CRN, that are associated with the group. + resourceAssignments: + description: List of Resource-to-Role assignments that are associated with the group. returned: on success type: list elements: dict @@ -190,7 +192,7 @@ returned: on success type: str resourceRoleCrn: - description: The CRN of the CDP Role. + description: The CRN of the resource role. returned: on success type: str syncMembershipOnUserLogin: @@ -209,212 +211,148 @@ elements: str """ -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_common import CdpModule +from typing import Any + +from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( + ServicesModule, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +class IAMGroup(ServicesModule): + def __init__(self): + super().__init__( + argument_spec=dict( + state=dict( + required=False, + type="str", + choices=["present", "absent"], + default="present", + ), + name=dict(required=True, type="str", aliases=["group_name"]), + sync=dict( + required=False, + type="bool", + default=True, + aliases=["sync_membership", "sync_membership_on_user_login"], + ), + users=dict(required=False, type="list", elements="str"), + roles=dict(required=False, type="list", elements="str"), + resource_roles=dict( + required=False, + type="list", + elements="dict", + options=dict( + resource=dict( + required=True, type="str", aliases=["resource_crn"] + ), + role=dict( + required=True, type="str", aliases=["resource_role_crn"] + ), + ), + ), + purge=dict(required=False, type="bool", default=False), + ), + supports_check_mode=True, + ) + # Set parameters + self.state = self.get_param("state") + self.name = self.get_param("name") + self.sync = self.get_param("sync") + self.users = self.get_param("users") + self.roles = self.get_param("roles") + self.resource_roles = self.get_param("resource_roles") + self.purge = self.get_param("purge") -class IAMGroup(CdpModule): - def __init__(self, module): - super(IAMGroup, self).__init__(module) + # Initialize return values + self.group = {} + self.changed = False - # Set Variables - self.state = self._get_param("state") - self.name = self._get_param("name") - self.sync = self._get_param("sync") - self.users = self._get_param("users") - self.roles = self._get_param("roles") - self.resource_roles = self._get_param("resource_roles") - self.purge = self._get_param("purge") + # Initialize client + self.client = CdpIamClient(api_client=self.api_client) - # Initialize the return values - self.info = dict() + def process(self): + current_group = self.client.get_group_details(group_name=self.name) - # Execute logic process - self.process() + if self.state == "absent": + if current_group: + if not self.module.check_mode: + self.client.delete_group(group_name=self.name) + self.changed = True + return self.group - @CdpModule._Decorators.process_debug - def process(self): - existing = self._retrieve_group() - if existing is None: - if self.state == "present": + if self.state == "present" and not current_group: + if self.module.check_mode: + self.group = {"groupName": self.name} + else: + response = self.client.create_group( + group_name=self.name, sync_membership_on_user_login=self.sync + ) + self.group = response.get("group", {}) + self.changed = True + current_group = self.client.get_group_details(group_name=self.name) + + if not self.module.check_mode and current_group: + + if self.sync != current_group.get("syncMembershipOnUserLogin"): + self.client.update_group( + group_name=self.name, sync_membership_on_user_login=self.sync + ) self.changed = True - self.cdpy.iam.create_group(self.name, self.sync) - if self.users: - for user in self.users: - self.cdpy.iam.add_group_user(self.name, user) - if self.roles: - for role in self.roles: - self.cdpy.iam.assign_group_role(self.name, role) - if self.resource_roles: - for assignment in self.resource_roles: - self.cdpy.iam.assign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - self.info = self._retrieve_group() - else: - if self.state == "present": - if ( - self.sync is not None - and existing["syncMembershipOnUserLogin"] != self.sync + + if self.users is not None or self.purge: + if self.client.manage_group_users( + group_name=self.name, + current_members=current_group.get("members", []), + desired_users=self.users if self.users is not None else [], + purge=self.purge, ): self.changed = True - self.cdpy.iam.update_group(self.name, self.sync) - - if self.users is not None: - # If an empty user list, don't normalize - normalized_users = ( - self.cdpy.iam.gather_users(self.users) if self.users else list() - ) - new_users = [ - user - for user in normalized_users - if user not in existing["users"] - ] - for user in new_users: - self.changed = True - self.cdpy.iam.add_group_user(self.name, user) - if self.purge: - stale_users = [ - user - for user in existing["users"] - if user not in normalized_users - ] - for user in stale_users: - self.changed = True - self.cdpy.iam.remove_group_user(self.name, user) - - if self.roles is not None: - new_roles = [ - role for role in self.roles if role not in existing["roles"] - ] - for role in new_roles: - self.changed = True - self.cdpy.iam.assign_group_role(self.name, role) - if self.purge: - stale_roles = [ - role for role in existing["roles"] if role not in self.roles - ] - for role in stale_roles: - self.changed = True - self.cdpy.iam.unassign_group_role(self.name, role) - - if self.resource_roles is not None: - new_assignments = self._new_assignments(existing["resource_roles"]) - for assignment in new_assignments: - self.changed = True - self.cdpy.iam.assign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - if self.purge: - stale_assignments = self._stale_assignments( - existing["resource_roles"], - ) - for assignment in stale_assignments: - self.changed = True - self.cdpy.iam.unassign_group_resource_role( - self.name, - assignment["resource"], - assignment["role"], - ) - - if self.changed: - self.info = self._retrieve_group() - else: - self.info = existing - - elif self.state == "absent": - self.changed = True - self.cdpy.iam.delete_group(self.name) - def _retrieve_group(self): - # TODO: What does gather_groups need? - group_list = self.cdpy.iam.gather_groups(self.name) - if group_list: - return group_list[0] + if self.roles is not None or self.purge: + if self.client.manage_group_roles( + group_name=self.name, + current_roles=current_group.get("roles", []), + desired_roles=self.roles if self.roles is not None else [], + purge=self.purge, + ): + self.changed = True + + if self.resource_roles is not None or self.purge: + if self.client.manage_group_resource_roles( + group_name=self.name, + current_assignments=current_group.get("resourceAssignments", []), + desired_assignments=( + self.resource_roles if self.resource_roles is not None else [] + ), + purge=self.purge, + ): + self.changed = True + + if self.changed and not self.module.check_mode: + self.group = self.client.get_group_details(group_name=self.name) else: - return None - - def _new_assignments(self, existing_assignments): - new_assignments = [] - resource_dict = dict() - for existing in existing_assignments: - if existing["resourceCrn"] in resource_dict: - resource_dict[existing["resourceCrn"]].add(existing["resourceRoleCrn"]) - else: - resource_dict[existing["resourceCrn"]] = {existing["resourceRoleCrn"]} - for assignment in self.resource_roles: - if ( - assignment["resource"] not in resource_dict - or assignment["role"] not in resource_dict[assignment["resource"]] - ): - new_assignments.append(assignment) - return new_assignments - - def _stale_assignments(self, existing_assignments): - stale_assignments = [] - resource_dict = dict() - for assignment in self.resource_roles: - if assignment["resource"] in resource_dict: - resource_dict[assignment["resource"]].add(assignment["role"]) - else: - resource_dict[assignment["resource"]] = {assignment["role"]} - for existing in existing_assignments: - if ( - existing["resourceCrn"] not in resource_dict - or existing["resourceRoleCrn"] - not in resource_dict[existing["resourceCrn"]] - ): - stale_assignments.append(existing) - return stale_assignments + self.group = current_group def main(): - module = AnsibleModule( - argument_spec=CdpModule.argument_spec( - state=dict( - required=False, - type="str", - choices=["present", "absent"], - default="present", - ), - name=dict(required=True, type="str", aliases=["group_name"]), - sync=dict( - required=False, - type="bool", - aliases=["sync_membership", "sync_on_login"], - ), - users=dict(required=False, type="list", elements="str"), - roles=dict(required=False, type="list", elements="str"), - resource_roles=dict( - required=False, - type="list", - elements="dict", - options=dict( - resource=dict(required=True, type="str", aliases=["resourceCrn"]), - role=dict(required=True, type="str", aliases=["resourceRoleCrn"]), - ), - aliases=["assignments"], - ), - purge=dict(required=False, type="bool", default=False, aliases=["replace"]), - ), - supports_check_mode=True, - ) - - result = IAMGroup(module) + result = IAMGroup() - output = dict( + output: dict[str, Any] = dict( changed=result.changed, - group=result.info, + group=result.group, ) - if result.debug: - output.update(sdk_out=result.log_out, sdk_out_lines=result.log_lines) + if result.debug_log: + output.update( + sdk_out=result.log_out, + sdk_out_lines=result.log_lines, + ) - module.exit_json(**output) + result.module.exit_json(**output) if __name__ == "__main__": From 8188a178c3ed81f34b41883659803fb5aae6ba56 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 11:40:17 +0000 Subject: [PATCH 04/31] Add tests for group module Signed-off-by: rsuplina --- .../modules/iam_group/test_iam_group.py | 871 ++++++++++++++++++ 1 file changed, 871 insertions(+) create mode 100644 tests/unit/plugins/modules/iam_group/test_iam_group.py diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py new file mode 100644 index 00000000..db01599c --- /dev/null +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -0,0 +1,871 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest +from unittest.mock import MagicMock + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + RestClient, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) +from ansible_collections.cloudera.cloud.plugins.modules.iam_group import ( + IAMGroup, +) + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" + + +# ============================================================================ +# Pytest Fixtures +# ============================================================================ + + +@pytest.fixture +def mock_api_client(mocker): + """Fixture to provide a mocked RestClient instance.""" + api_client = mocker.create_autospec(RestClient, instance=True) + return api_client + + +@pytest.fixture +def iam_client(mock_api_client): + """Fixture to provide a CdpIamClient instance with mocked RestClient.""" + return CdpIamClient(api_client=mock_api_client) + + +@pytest.fixture +def sample_group_data(): + """Fixture providing sample group data for testing.""" + return { + "groupName": "test-group", + "crn": "crn:cdp:iam:us-west-1:altus:group:test-group", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + } + + +@pytest.fixture +def sample_group_details(): + """Fixture providing complete group details including members and roles.""" + return { + "groupName": "test-group", + "crn": "crn:cdp:iam:us-west-1:altus:group:test-group", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + "members": [ + "crn:cdp:iam:us-west-1:altus:user:alice@example.com", + "crn:cdp:iam:us-west-1:altus:user:bob@example.com", + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", + ], + "roles": [ + "crn:cdp:iam:us-west-1:altus:role:PowerUser", + "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", + ], + "resourceAssignments": [ + { + "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + ], + } + + +@pytest.fixture +def sample_users(): + """Fixture providing sample user CRNs for testing.""" + return [ + "crn:cdp:iam:us-west-1:altus:user:alice@example.com", + "crn:cdp:iam:us-west-1:altus:user:bob@example.com", + ] + + +@pytest.fixture +def sample_machine_users(): + """Fixture providing sample machine user CRNs for testing.""" + return [ + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", + ] + + +@pytest.fixture +def sample_roles(): + """Fixture providing sample role CRNs for testing.""" + return [ + "crn:cdp:iam:us-west-1:altus:role:PowerUser", + "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", + "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", + ] + + +@pytest.fixture +def sample_resource_roles(): + """Fixture providing sample resource role assignments for testing.""" + return [ + { + "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, + ] + + +@pytest.fixture +def mock_ansible_module(mocker): + """Fixture to provide a mocked Ansible module.""" + module = MagicMock() + module.check_mode = False + module.params = { + "state": "present", + "name": "test-group", + "sync": True, + "users": None, + "roles": None, + "resource_roles": None, + "purge": False, + } + return module + + +class TestCdpIamClient: + """Unit tests for CdpIamClient group management methods.""" + + def test_list_groups_no_filter(self, mock_api_client, iam_client): + """Test listing all IAM groups without filtering.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + { + "groupName": "data-scientists", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-scientists", + "creationDate": "2024-01-20T14:45:00.000Z", + "syncMembershipOnUserLogin": False, + }, + ], + } + + # Mock the RestClient instance + mock_api_client._post.return_value = mock_response + + response = iam_client.list_groups() + + assert "groups" in response + assert len(response["groups"]) == 2 + assert response["groups"][0]["groupName"] == "data-engineers" + assert response["groups"][1]["syncMembershipOnUserLogin"] == False + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/listGroups", + None, + {"pageSize": 100}, + squelch={404: {}}, + ) + + def test_list_groups_with_filter(self, mock_api_client, iam_client): + """Test listing IAM groups filtered by name.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + ], + } + + # Mock the RestClient instance + mock_api_client._post.return_value = mock_response + + response = iam_client.list_groups(group_names=["data-engineers"]) + + assert "groups" in response + assert len(response["groups"]) == 1 + assert response["groups"][0]["groupName"] == "data-engineers" + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/listGroups", + None, + { + "groupNames": ["data-engineers"], + "pageSize": 100, + }, + squelch={404: {}}, + ) + + def test_create_group(self, mock_api_client, iam_client): + """Test creating a new IAM group.""" + + # Mock response data + mock_response = { + "group": { + "groupName": "new-team", + "crn": "crn:cdp:iam:us-west-1:altus:group:new-team", + "creationDate": "2024-02-01T09:00:00.000Z", + "syncMembershipOnUserLogin": True, + } + } + + mock_api_client._post.return_value = mock_response + + response = iam_client.create_group( + group_name="new-team", sync_membership_on_user_login=True + ) + + assert "group" in response + assert response["group"]["groupName"] == "new-team" + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/createGroup", + None, + { + "groupName": "new-team", + "syncMembershipOnUserLogin": True, + }, + squelch={}, + ) + + def test_delete_group(self, mock_api_client, iam_client): + """Test deleting an IAM group.""" + + # Mock response data (delete operations typically return empty) + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.delete_group(group_name="old-team") + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/deleteGroup", + None, + {"groupName": "old-team"}, + squelch={}, + ) + + def test_update_group(self, mock_api_client, iam_client): + """Test updating an IAM group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.update_group( + group_name="existing-team", sync_membership_on_user_login=False + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/updateGroup", + None, + { + "groupName": "existing-team", + "syncMembershipOnUserLogin": False, + }, + squelch={}, + ) + + def test_list_group_members( + self, mock_api_client, iam_client, sample_users, sample_machine_users + ): + """Test listing group members.""" + + # Mock response data + mock_response = { + "memberCrns": sample_users + [sample_machine_users[0]], + } + + mock_api_client._post.return_value = mock_response + + response = iam_client.list_group_members(group_name="data-engineers") + + assert "memberCrns" in response + assert len(response["memberCrns"]) == 3 + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/listGroupMembers", + None, + { + "groupName": "data-engineers", + "pageSize": 100, + }, + squelch={}, + ) + + def test_add_user_to_group(self, mock_api_client, iam_client, sample_users): + """Test adding a user to a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.add_user_to_group( + user_id=sample_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/addUserToGroup", + None, + { + "userId": sample_users[0], + "groupName": "data-engineers", + }, + squelch={}, + ) + + def test_remove_user_from_group(self, mock_api_client, iam_client, sample_users): + """Test removing a user from a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.remove_user_from_group( + user_id=sample_users[1], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/removeUserFromGroup", + None, + { + "userId": sample_users[1], + "groupName": "data-engineers", + }, + squelch={}, + ) + + def test_add_machine_user_to_group( + self, mock_api_client, iam_client, sample_machine_users + ): + """Test adding a machine user to a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.add_machine_user_to_group( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/addMachineUserToGroup", + None, + { + "machineUserName": sample_machine_users[0], + "groupName": "data-engineers", + }, + squelch={}, + ) + + def test_remove_machine_user_from_group( + self, mock_api_client, iam_client, sample_machine_users + ): + """Test removing a machine user from a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.remove_machine_user_from_group( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/removeMachineUserFromGroup", + None, + { + "machineUserName": sample_machine_users[0], + "groupName": "data-engineers", + }, + squelch={}, + ) + + def test_assign_group_role(self, mock_api_client, iam_client, sample_roles): + """Test assigning a role to a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.assign_group_role( + group_name="data-engineers", + role=sample_roles[0], + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/assignGroupRole", + None, + { + "groupName": "data-engineers", + "role": sample_roles[0], + }, + squelch={}, + ) + + def test_unassign_group_role(self, mock_api_client, iam_client, sample_roles): + """Test unassigning a role from a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.unassign_group_role( + group_name="data-engineers", + role=sample_roles[0], + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/unassignGroupRole", + None, + { + "groupName": "data-engineers", + "role": sample_roles[0], + }, + squelch={}, + ) + + def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): + """Test listing resource roles assigned to a group.""" + + # Mock response data + mock_response = { + "resourceAssignments": [ + { + "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resourceCrn": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, + ], + } + + mock_api_client._post.return_value = mock_response + + response = iam_client.list_group_assigned_resource_roles( + group_name="data-engineers" + ) + + assert "resourceAssignments" in response + assert len(response["resourceAssignments"]) == 2 + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/listGroupAssignedResourceRoles", + None, + { + "groupName": "data-engineers", + "pageSize": 100, + }, + squelch={}, + ) + + def test_assign_group_resource_role( + self, mock_api_client, iam_client, sample_resource_roles + ): + """Test assigning a resource role to a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.assign_group_resource_role( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/assignGroupResourceRole", + None, + { + "groupName": "data-engineers", + "resourceCrn": sample_resource_roles[0]["resource"], + "resourceRoleCrn": sample_resource_roles[0]["role"], + }, + squelch={}, + ) + + def test_unassign_group_resource_role( + self, mock_api_client, iam_client, sample_resource_roles + ): + """Test unassigning a resource role from a group.""" + + # Mock response data + mock_response = {} + + mock_api_client._post.return_value = mock_response + + response = iam_client.unassign_group_resource_role( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) + + assert isinstance(response, dict) + + # Verify that the post method was called with correct parameters + mock_api_client._post.assert_called_once_with( + "/api/v1/iam/unassignGroupResourceRole", + None, + { + "groupName": "data-engineers", + "resourceCrn": sample_resource_roles[0]["resource"], + "resourceRoleCrn": sample_resource_roles[0]["role"], + }, + squelch={}, + ) + + # ============================================================================ + # Tests for manage_group_users method + # ============================================================================ + + def test_manage_group_users_add_only( + self, mock_api_client, iam_client, sample_users + ): + """Test adding users to a group without removing any.""" + mock_api_client._post.return_value = {} + + current_members = [sample_users[0]] + desired_users = sample_users # Add second user + + changed = iam_client.manage_group_users( + group_name="test-group", + current_members=current_members, + desired_users=desired_users, + purge=False, + ) + + assert changed is True + assert mock_api_client._post.call_count == 1 + + def test_manage_group_users_purge(self, mock_api_client, iam_client, sample_users): + """Test purging users not in desired list.""" + mock_api_client._post.return_value = {} + + current_members = sample_users + desired_users = [sample_users[0]] # Keep only first user + + changed = iam_client.manage_group_users( + group_name="test-group", + current_members=current_members, + desired_users=desired_users, + purge=True, + ) + + assert changed is True + + def test_manage_group_users_no_changes( + self, mock_api_client, iam_client, sample_users + ): + """Test when no user changes are needed.""" + current_members = sample_users + desired_users = sample_users + + changed = iam_client.manage_group_users( + group_name="test-group", + current_members=current_members, + desired_users=desired_users, + purge=False, + ) + + assert changed is False + mock_api_client._post.assert_not_called() + + def test_manage_group_users_machine_user( + self, mock_api_client, iam_client, sample_machine_users + ): + """Test adding machine users to a group.""" + mock_api_client._post.return_value = {} + + current_members = [] + desired_users = [sample_machine_users[0]] + + changed = iam_client.manage_group_users( + group_name="test-group", + current_members=current_members, + desired_users=desired_users, + purge=False, + ) + + assert changed is True + + +@pytest.mark.integration_api +class TestCdpIamClientIntegration: + """Integration tests for CdpIamClient group management.""" + + def test_create_update_delete_group_lifecycle(self, api_client): + """Integration test for complete group lifecycle: create, update, delete.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-integration-group" + + try: + # 1. Create a new group + create_response = client.create_group( + group_name=test_group_name, sync_membership_on_user_login=True + ) + + assert "group" in create_response + assert create_response["group"]["groupName"] == test_group_name + assert create_response["group"]["syncMembershipOnUserLogin"] == True + + # 2. Verify group was created by listing it + list_response = client.list_groups(group_names=[test_group_name]) + assert "groups" in list_response + assert len(list_response["groups"]) == 1 + assert list_response["groups"][0]["groupName"] == test_group_name + + # 3. Update the group (change sync setting) + update_response = client.update_group( + group_name=test_group_name, sync_membership_on_user_login=False + ) + assert isinstance(update_response, dict) + + # 4. Verify the update + updated_list = client.list_groups(group_names=[test_group_name]) + assert updated_list["groups"][0]["syncMembershipOnUserLogin"] == False + + # 5. Delete the group + delete_response = client.delete_group(group_name=test_group_name) + assert isinstance(delete_response, dict) + + except Exception as e: + # Cleanup: try to delete the group if test fails + try: + client.delete_group(group_name=test_group_name) + except: + pass + raise e + + def test_group_membership_management(self, api_client): + """Integration test for adding and removing users from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-membership-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, sync_membership_on_user_login=True + ) + + # 2. Get current user to add to group + current_user = client.get_user() + user_crn = current_user.get("crn") + + if user_crn: + # 3. Add user to group + add_response = client.add_user_to_group( + group_name=test_group_name, user_id=user_crn + ) + assert isinstance(add_response, dict) + + # 4. Verify user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + assert user_crn in members_response["memberCrns"] + + # 5. Remove user from group + remove_response = client.remove_user_from_group( + group_name=test_group_name, user_id=user_crn + ) + assert isinstance(remove_response, dict) + + # 6. Verify user was removed + members_after = client.list_group_members(group_name=test_group_name) + assert user_crn not in members_after.get("memberCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_group_role_assignment(self, api_client): + """Integration test for assigning and unassigning roles to/from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-role-assignment-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, sync_membership_on_user_login=True + ) + + # 2. Get available roles + roles_response = client.list_roles() + if roles_response.get("roles") and len(roles_response["roles"]) > 0: + # Use the first available role for testing + test_role_crn = roles_response["roles"][0]["crn"] + + # 3. Assign role to group + assign_response = client.assign_group_role( + group_name=test_group_name, role=test_role_crn + ) + assert isinstance(assign_response, dict) + + # 4. Verify role was assigned + assigned_roles = client.list_group_assigned_roles( + group_name=test_group_name + ) + assert "roleCrns" in assigned_roles + assert test_role_crn in assigned_roles["roleCrns"] + + # 5. Unassign role from group + unassign_response = client.unassign_group_role( + group_name=test_group_name, role=test_role_crn + ) + assert isinstance(unassign_response, dict) + + # 6. Verify role was unassigned + roles_after = client.list_group_assigned_roles( + group_name=test_group_name + ) + assert test_role_crn not in roles_after.get("roleCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_machine_user_group_membership(self, api_client): + """Integration test for adding and removing machine users from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-machine-user-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, sync_membership_on_user_login=True + ) + + # 2. Get list of machine users + machine_users_response = client.list_machine_users() + + if ( + machine_users_response.get("machineUsers") + and len(machine_users_response["machineUsers"]) > 0 + ): + # Use first available machine user for testing + machine_user_name = machine_users_response["machineUsers"][0][ + "machineUserName" + ] + + # 3. Add machine user to group + add_response = client.add_machine_user_to_group( + group_name=test_group_name, machine_user_name=machine_user_name + ) + assert isinstance(add_response, dict) + + # 4. Verify machine user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + + # 5. Remove machine user from group + remove_response = client.remove_machine_user_from_group( + group_name=test_group_name, machine_user_name=machine_user_name + ) + assert isinstance(remove_response, dict) + + # 6. Verify machine user was removed + members_after = client.list_group_members(group_name=test_group_name) + # Machine user should no longer be in the group + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass From a3c81e746c53fca3134cbffb3a71e327c8e594aa Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 13:45:28 +0000 Subject: [PATCH 05/31] Linting Signed-off-by: rsuplina --- plugins/module_utils/cdp_client.py | 12 ++- plugins/module_utils/cdp_iam.py | 26 ++++-- plugins/modules/iam_group.py | 14 ++- .../modules/iam_group/test_iam_group.py | 88 +++++++++++++------ 4 files changed, 102 insertions(+), 38 deletions(-) diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index eb90ac88..51b741e9 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -660,7 +660,11 @@ def _post( ) -> Dict[str, Any]: """Execute HTTP POST request.""" return self._make_request( - "POST", path, data=data, json_data=json_data, squelch=squelch + "POST", + path, + data=data, + json_data=json_data, + squelch=squelch, ) def _put( @@ -672,7 +676,11 @@ def _put( ) -> Dict[str, Any]: """Execute HTTP PUT request.""" return self._make_request( - "PUT", path, data=data, json_data=json_data, squelch=squelch + "PUT", + path, + data=data, + json_data=json_data, + squelch=squelch, ) def _delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index a74e96c9..37503baa 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -75,7 +75,7 @@ def get_group_details(self, group_name: str) -> Optional[Dict[str, Any]]: # Get assigned resource roles resource_roles_response = self.list_group_assigned_resource_roles( - group_name=group_name + group_name=group_name, ) resource_assignments = resource_roles_response.get("resourceAssignments", []) @@ -119,7 +119,8 @@ def manage_group_users( for user_crn in users_to_remove: if self._is_machine_user(user_crn): self.remove_machine_user_from_group( - machine_user_name=user_crn, group_name=group_name + machine_user_name=user_crn, + group_name=group_name, ) else: self.remove_user_from_group(user_id=user_crn, group_name=group_name) @@ -130,7 +131,8 @@ def manage_group_users( for user_crn in users_to_add: if self._is_machine_user(user_crn): self.add_machine_user_to_group( - machine_user_name=user_crn, group_name=group_name + machine_user_name=user_crn, + group_name=group_name, ) else: self.add_user_to_group(user_id=user_crn, group_name=group_name) @@ -837,7 +839,9 @@ def add_user_to_group(self, group_name: str, user_id: str) -> Dict[str, Any]: ) def add_machine_user_to_group( - self, group_name: str, machine_user_name: str + self, + group_name: str, + machine_user_name: str, ) -> Dict[str, Any]: """ Add a machine user to a group. @@ -881,7 +885,9 @@ def remove_user_from_group(self, group_name: str, user_id: str) -> Dict[str, Any ) def remove_machine_user_from_group( - self, group_name: str, machine_user_name: str + self, + group_name: str, + machine_user_name: str, ) -> Dict[str, Any]: """ Remove a machine user from a group. @@ -927,7 +933,10 @@ def assign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: ) def assign_group_resource_role( - self, group_name: str, resource_crn: str, resource_role_crn: str + self, + group_name: str, + resource_crn: str, + resource_role_crn: str, ) -> Dict[str, Any]: """ Assign a resource role to a group. @@ -973,7 +982,10 @@ def unassign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: ) def unassign_group_resource_role( - self, group_name: str, resource_crn: str, resource_role_crn: str + self, + group_name: str, + resource_crn: str, + resource_role_crn: str, ) -> Dict[str, Any]: """ Unassign a resource role from a group. diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index 8014e725..2a4dc2d8 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -246,10 +246,14 @@ def __init__(self): elements="dict", options=dict( resource=dict( - required=True, type="str", aliases=["resource_crn"] + required=True, + type="str", + aliases=["resource_crn"], ), role=dict( - required=True, type="str", aliases=["resource_role_crn"] + required=True, + type="str", + aliases=["resource_role_crn"], ), ), ), @@ -289,7 +293,8 @@ def process(self): self.group = {"groupName": self.name} else: response = self.client.create_group( - group_name=self.name, sync_membership_on_user_login=self.sync + group_name=self.name, + sync_membership_on_user_login=self.sync, ) self.group = response.get("group", {}) self.changed = True @@ -299,7 +304,8 @@ def process(self): if self.sync != current_group.get("syncMembershipOnUserLogin"): self.client.update_group( - group_name=self.name, sync_membership_on_user_login=self.sync + group_name=self.name, + sync_membership_on_user_login=self.sync, ) self.changed = True diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index db01599c..5aa33023 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -244,13 +244,14 @@ def test_create_group(self, mock_api_client, iam_client): "crn": "crn:cdp:iam:us-west-1:altus:group:new-team", "creationDate": "2024-02-01T09:00:00.000Z", "syncMembershipOnUserLogin": True, - } + }, } mock_api_client._post.return_value = mock_response response = iam_client.create_group( - group_name="new-team", sync_membership_on_user_login=True + group_name="new-team", + sync_membership_on_user_login=True, ) assert "group" in response @@ -296,7 +297,8 @@ def test_update_group(self, mock_api_client, iam_client): mock_api_client._post.return_value = mock_response response = iam_client.update_group( - group_name="existing-team", sync_membership_on_user_login=False + group_name="existing-team", + sync_membership_on_user_login=False, ) assert isinstance(response, dict) @@ -313,7 +315,11 @@ def test_update_group(self, mock_api_client, iam_client): ) def test_list_group_members( - self, mock_api_client, iam_client, sample_users, sample_machine_users + self, + mock_api_client, + iam_client, + sample_users, + sample_machine_users, ): """Test listing group members.""" @@ -393,7 +399,10 @@ def test_remove_user_from_group(self, mock_api_client, iam_client, sample_users) ) def test_add_machine_user_to_group( - self, mock_api_client, iam_client, sample_machine_users + self, + mock_api_client, + iam_client, + sample_machine_users, ): """Test adding a machine user to a group.""" @@ -421,7 +430,10 @@ def test_add_machine_user_to_group( ) def test_remove_machine_user_from_group( - self, mock_api_client, iam_client, sample_machine_users + self, + mock_api_client, + iam_client, + sample_machine_users, ): """Test removing a machine user from a group.""" @@ -520,7 +532,7 @@ def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): mock_api_client._post.return_value = mock_response response = iam_client.list_group_assigned_resource_roles( - group_name="data-engineers" + group_name="data-engineers", ) assert "resourceAssignments" in response @@ -538,7 +550,10 @@ def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): ) def test_assign_group_resource_role( - self, mock_api_client, iam_client, sample_resource_roles + self, + mock_api_client, + iam_client, + sample_resource_roles, ): """Test assigning a resource role to a group.""" @@ -568,7 +583,10 @@ def test_assign_group_resource_role( ) def test_unassign_group_resource_role( - self, mock_api_client, iam_client, sample_resource_roles + self, + mock_api_client, + iam_client, + sample_resource_roles, ): """Test unassigning a resource role from a group.""" @@ -602,7 +620,10 @@ def test_unassign_group_resource_role( # ============================================================================ def test_manage_group_users_add_only( - self, mock_api_client, iam_client, sample_users + self, + mock_api_client, + iam_client, + sample_users, ): """Test adding users to a group without removing any.""" mock_api_client._post.return_value = {} @@ -637,7 +658,10 @@ def test_manage_group_users_purge(self, mock_api_client, iam_client, sample_user assert changed is True def test_manage_group_users_no_changes( - self, mock_api_client, iam_client, sample_users + self, + mock_api_client, + iam_client, + sample_users, ): """Test when no user changes are needed.""" current_members = sample_users @@ -654,7 +678,10 @@ def test_manage_group_users_no_changes( mock_api_client._post.assert_not_called() def test_manage_group_users_machine_user( - self, mock_api_client, iam_client, sample_machine_users + self, + mock_api_client, + iam_client, + sample_machine_users, ): """Test adding machine users to a group.""" mock_api_client._post.return_value = {} @@ -686,7 +713,8 @@ def test_create_update_delete_group_lifecycle(self, api_client): try: # 1. Create a new group create_response = client.create_group( - group_name=test_group_name, sync_membership_on_user_login=True + group_name=test_group_name, + sync_membership_on_user_login=True, ) assert "group" in create_response @@ -701,7 +729,8 @@ def test_create_update_delete_group_lifecycle(self, api_client): # 3. Update the group (change sync setting) update_response = client.update_group( - group_name=test_group_name, sync_membership_on_user_login=False + group_name=test_group_name, + sync_membership_on_user_login=False, ) assert isinstance(update_response, dict) @@ -731,7 +760,8 @@ def test_group_membership_management(self, api_client): try: # 1. Create a test group client.create_group( - group_name=test_group_name, sync_membership_on_user_login=True + group_name=test_group_name, + sync_membership_on_user_login=True, ) # 2. Get current user to add to group @@ -741,7 +771,8 @@ def test_group_membership_management(self, api_client): if user_crn: # 3. Add user to group add_response = client.add_user_to_group( - group_name=test_group_name, user_id=user_crn + group_name=test_group_name, + user_id=user_crn, ) assert isinstance(add_response, dict) @@ -752,7 +783,8 @@ def test_group_membership_management(self, api_client): # 5. Remove user from group remove_response = client.remove_user_from_group( - group_name=test_group_name, user_id=user_crn + group_name=test_group_name, + user_id=user_crn, ) assert isinstance(remove_response, dict) @@ -777,7 +809,8 @@ def test_group_role_assignment(self, api_client): try: # 1. Create a test group client.create_group( - group_name=test_group_name, sync_membership_on_user_login=True + group_name=test_group_name, + sync_membership_on_user_login=True, ) # 2. Get available roles @@ -788,26 +821,28 @@ def test_group_role_assignment(self, api_client): # 3. Assign role to group assign_response = client.assign_group_role( - group_name=test_group_name, role=test_role_crn + group_name=test_group_name, + role=test_role_crn, ) assert isinstance(assign_response, dict) # 4. Verify role was assigned assigned_roles = client.list_group_assigned_roles( - group_name=test_group_name + group_name=test_group_name, ) assert "roleCrns" in assigned_roles assert test_role_crn in assigned_roles["roleCrns"] # 5. Unassign role from group unassign_response = client.unassign_group_role( - group_name=test_group_name, role=test_role_crn + group_name=test_group_name, + role=test_role_crn, ) assert isinstance(unassign_response, dict) # 6. Verify role was unassigned roles_after = client.list_group_assigned_roles( - group_name=test_group_name + group_name=test_group_name, ) assert test_role_crn not in roles_after.get("roleCrns", []) @@ -828,7 +863,8 @@ def test_machine_user_group_membership(self, api_client): try: # 1. Create a test group client.create_group( - group_name=test_group_name, sync_membership_on_user_login=True + group_name=test_group_name, + sync_membership_on_user_login=True, ) # 2. Get list of machine users @@ -845,7 +881,8 @@ def test_machine_user_group_membership(self, api_client): # 3. Add machine user to group add_response = client.add_machine_user_to_group( - group_name=test_group_name, machine_user_name=machine_user_name + group_name=test_group_name, + machine_user_name=machine_user_name, ) assert isinstance(add_response, dict) @@ -855,7 +892,8 @@ def test_machine_user_group_membership(self, api_client): # 5. Remove machine user from group remove_response = client.remove_machine_user_from_group( - group_name=test_group_name, machine_user_name=machine_user_name + group_name=test_group_name, + machine_user_name=machine_user_name, ) assert isinstance(remove_response, dict) From 81c3fc3372580e15c243b3ba7a538012a7690f36 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 26 Nov 2025 14:08:59 +0000 Subject: [PATCH 06/31] Update squelch Signed-off-by: rsuplina --- plugins/module_utils/cdp_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index 51b741e9..593315e3 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -269,7 +269,7 @@ def _put( path: str, data: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, - squelch: Optional[Dict[int, Any]] = {}, + squelch: Dict[int, Any] = {}, ) -> Dict[str, Any]: """Execute HTTP PUT request.""" pass @@ -485,7 +485,7 @@ def _make_request( data: Optional[Union[Dict[str, Any], List[Any]]] = None, json_data: Optional[Union[Dict[str, Any], List[Any]]] = None, max_retries: int = 3, - squelch: Optional[Dict[int, Any]] = None, + squelch: Dict[int, Any] = None, ) -> Any: """ Make HTTP request with retry logic using Ansible's fetch_url. From c9c7b803067d85f8dcc282927f118b478f378972 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Mon, 8 Dec 2025 16:30:08 +0000 Subject: [PATCH 07/31] Update IAM Group tests Signed-off-by: rsuplina --- .../modules/iam_group/test_iam_group.py | 613 +++--------------- .../iam_group/test_iam_group_integration.py | 240 +++++++ 2 files changed, 324 insertions(+), 529 deletions(-) create mode 100644 tests/unit/plugins/modules/iam_group/test_iam_group_integration.py diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index 5aa33023..5914209f 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -26,15 +26,9 @@ AnsibleExitJson, ) -from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, -) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( CdpIamClient, ) -from ansible_collections.cloudera.cloud.plugins.modules.iam_group import ( - IAMGroup, -) BASE_URL = "https://cloudera.internal/api" @@ -48,53 +42,9 @@ @pytest.fixture -def mock_api_client(mocker): - """Fixture to provide a mocked RestClient instance.""" - api_client = mocker.create_autospec(RestClient, instance=True) - return api_client - - -@pytest.fixture -def iam_client(mock_api_client): - """Fixture to provide a CdpIamClient instance with mocked RestClient.""" - return CdpIamClient(api_client=mock_api_client) - - -@pytest.fixture -def sample_group_data(): - """Fixture providing sample group data for testing.""" - return { - "groupName": "test-group", - "crn": "crn:cdp:iam:us-west-1:altus:group:test-group", - "creationDate": "2024-01-15T10:30:00.000Z", - "syncMembershipOnUserLogin": True, - } - - -@pytest.fixture -def sample_group_details(): - """Fixture providing complete group details including members and roles.""" - return { - "groupName": "test-group", - "crn": "crn:cdp:iam:us-west-1:altus:group:test-group", - "creationDate": "2024-01-15T10:30:00.000Z", - "syncMembershipOnUserLogin": True, - "members": [ - "crn:cdp:iam:us-west-1:altus:user:alice@example.com", - "crn:cdp:iam:us-west-1:altus:user:bob@example.com", - "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", - ], - "roles": [ - "crn:cdp:iam:us-west-1:altus:role:PowerUser", - "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", - ], - "resourceAssignments": [ - { - "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", - "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", - }, - ], - } +def iam_client(mocker): + """Fixture to provide a mocked CdpIamClient instance.""" + return mocker.create_autospec(CdpIamClient, instance=True) @pytest.fixture @@ -140,27 +90,10 @@ def sample_resource_roles(): ] -@pytest.fixture -def mock_ansible_module(mocker): - """Fixture to provide a mocked Ansible module.""" - module = MagicMock() - module.check_mode = False - module.params = { - "state": "present", - "name": "test-group", - "sync": True, - "users": None, - "roles": None, - "resource_roles": None, - "purge": False, - } - return module - - class TestCdpIamClient: """Unit tests for CdpIamClient group management methods.""" - def test_list_groups_no_filter(self, mock_api_client, iam_client): + def test_list_groups_no_filter(self, iam_client): """Test listing all IAM groups without filtering.""" # Mock response data @@ -181,8 +114,8 @@ def test_list_groups_no_filter(self, mock_api_client, iam_client): ], } - # Mock the RestClient instance - mock_api_client._post.return_value = mock_response + # Mock the CdpIamClient method + iam_client.list_groups.return_value = mock_response response = iam_client.list_groups() @@ -191,15 +124,10 @@ def test_list_groups_no_filter(self, mock_api_client, iam_client): assert response["groups"][0]["groupName"] == "data-engineers" assert response["groups"][1]["syncMembershipOnUserLogin"] == False - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/listGroups", - None, - {"pageSize": 100}, - squelch={404: {}}, - ) + # Verify that the method was called + iam_client.list_groups.assert_called_once() - def test_list_groups_with_filter(self, mock_api_client, iam_client): + def test_list_groups_with_filter(self, iam_client): """Test listing IAM groups filtered by name.""" # Mock response data @@ -214,8 +142,8 @@ def test_list_groups_with_filter(self, mock_api_client, iam_client): ], } - # Mock the RestClient instance - mock_api_client._post.return_value = mock_response + # Mock the CdpIamClient method + iam_client.list_groups.return_value = mock_response response = iam_client.list_groups(group_names=["data-engineers"]) @@ -223,18 +151,10 @@ def test_list_groups_with_filter(self, mock_api_client, iam_client): assert len(response["groups"]) == 1 assert response["groups"][0]["groupName"] == "data-engineers" - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/listGroups", - None, - { - "groupNames": ["data-engineers"], - "pageSize": 100, - }, - squelch={404: {}}, - ) + # Verify that the method was called with correct parameters + iam_client.list_groups.assert_called_once_with(group_names=["data-engineers"]) - def test_create_group(self, mock_api_client, iam_client): + def test_create_group(self, iam_client): """Test creating a new IAM group.""" # Mock response data @@ -247,7 +167,7 @@ def test_create_group(self, mock_api_client, iam_client): }, } - mock_api_client._post.return_value = mock_response + iam_client.create_group.return_value = mock_response response = iam_client.create_group( group_name="new-team", @@ -257,44 +177,34 @@ def test_create_group(self, mock_api_client, iam_client): assert "group" in response assert response["group"]["groupName"] == "new-team" - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/createGroup", - None, - { - "groupName": "new-team", - "syncMembershipOnUserLogin": True, - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.create_group.assert_called_once_with( + group_name="new-team", + sync_membership_on_user_login=True, ) - def test_delete_group(self, mock_api_client, iam_client): + def test_delete_group(self, iam_client): """Test deleting an IAM group.""" # Mock response data (delete operations typically return empty) mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.delete_group.return_value = mock_response response = iam_client.delete_group(group_name="old-team") assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/deleteGroup", - None, - {"groupName": "old-team"}, - squelch={}, - ) + # Verify that the method was called with correct parameters + iam_client.delete_group.assert_called_once_with(group_name="old-team") - def test_update_group(self, mock_api_client, iam_client): + def test_update_group(self, iam_client): """Test updating an IAM group.""" # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.update_group.return_value = mock_response response = iam_client.update_group( group_name="existing-team", @@ -303,20 +213,14 @@ def test_update_group(self, mock_api_client, iam_client): assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/updateGroup", - None, - { - "groupName": "existing-team", - "syncMembershipOnUserLogin": False, - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.update_group.assert_called_once_with( + group_name="existing-team", + sync_membership_on_user_login=False, ) def test_list_group_members( self, - mock_api_client, iam_client, sample_users, sample_machine_users, @@ -328,31 +232,25 @@ def test_list_group_members( "memberCrns": sample_users + [sample_machine_users[0]], } - mock_api_client._post.return_value = mock_response + iam_client.list_group_members.return_value = mock_response response = iam_client.list_group_members(group_name="data-engineers") assert "memberCrns" in response assert len(response["memberCrns"]) == 3 - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/listGroupMembers", - None, - { - "groupName": "data-engineers", - "pageSize": 100, - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.list_group_members.assert_called_once_with( + group_name="data-engineers", ) - def test_add_user_to_group(self, mock_api_client, iam_client, sample_users): + def test_add_user_to_group(self, iam_client, sample_users): """Test adding a user to a group.""" # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.add_user_to_group.return_value = mock_response response = iam_client.add_user_to_group( user_id=sample_users[0], @@ -361,24 +259,19 @@ def test_add_user_to_group(self, mock_api_client, iam_client, sample_users): assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/addUserToGroup", - None, - { - "userId": sample_users[0], - "groupName": "data-engineers", - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.add_user_to_group.assert_called_once_with( + user_id=sample_users[0], + group_name="data-engineers", ) - def test_remove_user_from_group(self, mock_api_client, iam_client, sample_users): + def test_remove_user_from_group(self, iam_client, sample_users): """Test removing a user from a group.""" # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.remove_user_from_group.return_value = mock_response response = iam_client.remove_user_from_group( user_id=sample_users[1], @@ -387,20 +280,14 @@ def test_remove_user_from_group(self, mock_api_client, iam_client, sample_users) assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/removeUserFromGroup", - None, - { - "userId": sample_users[1], - "groupName": "data-engineers", - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.remove_user_from_group.assert_called_once_with( + user_id=sample_users[1], + group_name="data-engineers", ) def test_add_machine_user_to_group( self, - mock_api_client, iam_client, sample_machine_users, ): @@ -409,7 +296,7 @@ def test_add_machine_user_to_group( # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.add_machine_user_to_group.return_value = mock_response response = iam_client.add_machine_user_to_group( machine_user_name=sample_machine_users[0], @@ -418,20 +305,14 @@ def test_add_machine_user_to_group( assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/addMachineUserToGroup", - None, - { - "machineUserName": sample_machine_users[0], - "groupName": "data-engineers", - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.add_machine_user_to_group.assert_called_once_with( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", ) def test_remove_machine_user_from_group( self, - mock_api_client, iam_client, sample_machine_users, ): @@ -440,7 +321,7 @@ def test_remove_machine_user_from_group( # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.remove_machine_user_from_group.return_value = mock_response response = iam_client.remove_machine_user_from_group( machine_user_name=sample_machine_users[0], @@ -449,24 +330,19 @@ def test_remove_machine_user_from_group( assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/removeMachineUserFromGroup", - None, - { - "machineUserName": sample_machine_users[0], - "groupName": "data-engineers", - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.remove_machine_user_from_group.assert_called_once_with( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", ) - def test_assign_group_role(self, mock_api_client, iam_client, sample_roles): + def test_assign_group_role(self, iam_client, sample_roles): """Test assigning a role to a group.""" # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.assign_group_role.return_value = mock_response response = iam_client.assign_group_role( group_name="data-engineers", @@ -475,24 +351,19 @@ def test_assign_group_role(self, mock_api_client, iam_client, sample_roles): assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/assignGroupRole", - None, - { - "groupName": "data-engineers", - "role": sample_roles[0], - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.assign_group_role.assert_called_once_with( + group_name="data-engineers", + role=sample_roles[0], ) - def test_unassign_group_role(self, mock_api_client, iam_client, sample_roles): + def test_unassign_group_role(self, iam_client, sample_roles): """Test unassigning a role from a group.""" # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.unassign_group_role.return_value = mock_response response = iam_client.unassign_group_role( group_name="data-engineers", @@ -501,18 +372,13 @@ def test_unassign_group_role(self, mock_api_client, iam_client, sample_roles): assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/unassignGroupRole", - None, - { - "groupName": "data-engineers", - "role": sample_roles[0], - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.unassign_group_role.assert_called_once_with( + group_name="data-engineers", + role=sample_roles[0], ) - def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): + def test_list_group_assigned_resource_roles(self, iam_client): """Test listing resource roles assigned to a group.""" # Mock response data @@ -529,7 +395,7 @@ def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): ], } - mock_api_client._post.return_value = mock_response + iam_client.list_group_assigned_resource_roles.return_value = mock_response response = iam_client.list_group_assigned_resource_roles( group_name="data-engineers", @@ -538,20 +404,13 @@ def test_list_group_assigned_resource_roles(self, mock_api_client, iam_client): assert "resourceAssignments" in response assert len(response["resourceAssignments"]) == 2 - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/listGroupAssignedResourceRoles", - None, - { - "groupName": "data-engineers", - "pageSize": 100, - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.list_group_assigned_resource_roles.assert_called_once_with( + group_name="data-engineers", ) def test_assign_group_resource_role( self, - mock_api_client, iam_client, sample_resource_roles, ): @@ -560,7 +419,7 @@ def test_assign_group_resource_role( # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.assign_group_resource_role.return_value = mock_response response = iam_client.assign_group_resource_role( group_name="data-engineers", @@ -570,21 +429,15 @@ def test_assign_group_resource_role( assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/assignGroupResourceRole", - None, - { - "groupName": "data-engineers", - "resourceCrn": sample_resource_roles[0]["resource"], - "resourceRoleCrn": sample_resource_roles[0]["role"], - }, - squelch={}, + # Verify that the method was called with correct parameters + iam_client.assign_group_resource_role.assert_called_once_with( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], ) def test_unassign_group_resource_role( self, - mock_api_client, iam_client, sample_resource_roles, ): @@ -593,7 +446,7 @@ def test_unassign_group_resource_role( # Mock response data mock_response = {} - mock_api_client._post.return_value = mock_response + iam_client.unassign_group_resource_role.return_value = mock_response response = iam_client.unassign_group_resource_role( group_name="data-engineers", @@ -603,307 +456,9 @@ def test_unassign_group_resource_role( assert isinstance(response, dict) - # Verify that the post method was called with correct parameters - mock_api_client._post.assert_called_once_with( - "/api/v1/iam/unassignGroupResourceRole", - None, - { - "groupName": "data-engineers", - "resourceCrn": sample_resource_roles[0]["resource"], - "resourceRoleCrn": sample_resource_roles[0]["role"], - }, - squelch={}, - ) - - # ============================================================================ - # Tests for manage_group_users method - # ============================================================================ - - def test_manage_group_users_add_only( - self, - mock_api_client, - iam_client, - sample_users, - ): - """Test adding users to a group without removing any.""" - mock_api_client._post.return_value = {} - - current_members = [sample_users[0]] - desired_users = sample_users # Add second user - - changed = iam_client.manage_group_users( - group_name="test-group", - current_members=current_members, - desired_users=desired_users, - purge=False, - ) - - assert changed is True - assert mock_api_client._post.call_count == 1 - - def test_manage_group_users_purge(self, mock_api_client, iam_client, sample_users): - """Test purging users not in desired list.""" - mock_api_client._post.return_value = {} - - current_members = sample_users - desired_users = [sample_users[0]] # Keep only first user - - changed = iam_client.manage_group_users( - group_name="test-group", - current_members=current_members, - desired_users=desired_users, - purge=True, - ) - - assert changed is True - - def test_manage_group_users_no_changes( - self, - mock_api_client, - iam_client, - sample_users, - ): - """Test when no user changes are needed.""" - current_members = sample_users - desired_users = sample_users - - changed = iam_client.manage_group_users( - group_name="test-group", - current_members=current_members, - desired_users=desired_users, - purge=False, - ) - - assert changed is False - mock_api_client._post.assert_not_called() - - def test_manage_group_users_machine_user( - self, - mock_api_client, - iam_client, - sample_machine_users, - ): - """Test adding machine users to a group.""" - mock_api_client._post.return_value = {} - - current_members = [] - desired_users = [sample_machine_users[0]] - - changed = iam_client.manage_group_users( - group_name="test-group", - current_members=current_members, - desired_users=desired_users, - purge=False, + # Verify that the method was called with correct parameters + iam_client.unassign_group_resource_role.assert_called_once_with( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], ) - - assert changed is True - - -@pytest.mark.integration_api -class TestCdpIamClientIntegration: - """Integration tests for CdpIamClient group management.""" - - def test_create_update_delete_group_lifecycle(self, api_client): - """Integration test for complete group lifecycle: create, update, delete.""" - - client = CdpIamClient(api_client=api_client) - - test_group_name = "test-integration-group" - - try: - # 1. Create a new group - create_response = client.create_group( - group_name=test_group_name, - sync_membership_on_user_login=True, - ) - - assert "group" in create_response - assert create_response["group"]["groupName"] == test_group_name - assert create_response["group"]["syncMembershipOnUserLogin"] == True - - # 2. Verify group was created by listing it - list_response = client.list_groups(group_names=[test_group_name]) - assert "groups" in list_response - assert len(list_response["groups"]) == 1 - assert list_response["groups"][0]["groupName"] == test_group_name - - # 3. Update the group (change sync setting) - update_response = client.update_group( - group_name=test_group_name, - sync_membership_on_user_login=False, - ) - assert isinstance(update_response, dict) - - # 4. Verify the update - updated_list = client.list_groups(group_names=[test_group_name]) - assert updated_list["groups"][0]["syncMembershipOnUserLogin"] == False - - # 5. Delete the group - delete_response = client.delete_group(group_name=test_group_name) - assert isinstance(delete_response, dict) - - except Exception as e: - # Cleanup: try to delete the group if test fails - try: - client.delete_group(group_name=test_group_name) - except: - pass - raise e - - def test_group_membership_management(self, api_client): - """Integration test for adding and removing users from a group.""" - - client = CdpIamClient(api_client=api_client) - - test_group_name = "test-membership-group" - - try: - # 1. Create a test group - client.create_group( - group_name=test_group_name, - sync_membership_on_user_login=True, - ) - - # 2. Get current user to add to group - current_user = client.get_user() - user_crn = current_user.get("crn") - - if user_crn: - # 3. Add user to group - add_response = client.add_user_to_group( - group_name=test_group_name, - user_id=user_crn, - ) - assert isinstance(add_response, dict) - - # 4. Verify user was added - members_response = client.list_group_members(group_name=test_group_name) - assert "memberCrns" in members_response - assert user_crn in members_response["memberCrns"] - - # 5. Remove user from group - remove_response = client.remove_user_from_group( - group_name=test_group_name, - user_id=user_crn, - ) - assert isinstance(remove_response, dict) - - # 6. Verify user was removed - members_after = client.list_group_members(group_name=test_group_name) - assert user_crn not in members_after.get("memberCrns", []) - - finally: - # Cleanup: delete the test group - try: - client.delete_group(group_name=test_group_name) - except: - pass - - def test_group_role_assignment(self, api_client): - """Integration test for assigning and unassigning roles to/from a group.""" - - client = CdpIamClient(api_client=api_client) - - test_group_name = "test-role-assignment-group" - - try: - # 1. Create a test group - client.create_group( - group_name=test_group_name, - sync_membership_on_user_login=True, - ) - - # 2. Get available roles - roles_response = client.list_roles() - if roles_response.get("roles") and len(roles_response["roles"]) > 0: - # Use the first available role for testing - test_role_crn = roles_response["roles"][0]["crn"] - - # 3. Assign role to group - assign_response = client.assign_group_role( - group_name=test_group_name, - role=test_role_crn, - ) - assert isinstance(assign_response, dict) - - # 4. Verify role was assigned - assigned_roles = client.list_group_assigned_roles( - group_name=test_group_name, - ) - assert "roleCrns" in assigned_roles - assert test_role_crn in assigned_roles["roleCrns"] - - # 5. Unassign role from group - unassign_response = client.unassign_group_role( - group_name=test_group_name, - role=test_role_crn, - ) - assert isinstance(unassign_response, dict) - - # 6. Verify role was unassigned - roles_after = client.list_group_assigned_roles( - group_name=test_group_name, - ) - assert test_role_crn not in roles_after.get("roleCrns", []) - - finally: - # Cleanup: delete the test group - try: - client.delete_group(group_name=test_group_name) - except: - pass - - def test_machine_user_group_membership(self, api_client): - """Integration test for adding and removing machine users from a group.""" - - client = CdpIamClient(api_client=api_client) - - test_group_name = "test-machine-user-group" - - try: - # 1. Create a test group - client.create_group( - group_name=test_group_name, - sync_membership_on_user_login=True, - ) - - # 2. Get list of machine users - machine_users_response = client.list_machine_users() - - if ( - machine_users_response.get("machineUsers") - and len(machine_users_response["machineUsers"]) > 0 - ): - # Use first available machine user for testing - machine_user_name = machine_users_response["machineUsers"][0][ - "machineUserName" - ] - - # 3. Add machine user to group - add_response = client.add_machine_user_to_group( - group_name=test_group_name, - machine_user_name=machine_user_name, - ) - assert isinstance(add_response, dict) - - # 4. Verify machine user was added - members_response = client.list_group_members(group_name=test_group_name) - assert "memberCrns" in members_response - - # 5. Remove machine user from group - remove_response = client.remove_machine_user_from_group( - group_name=test_group_name, - machine_user_name=machine_user_name, - ) - assert isinstance(remove_response, dict) - - # 6. Verify machine user was removed - members_after = client.list_group_members(group_name=test_group_name) - # Machine user should no longer be in the group - - finally: - # Cleanup: delete the test group - try: - client.delete_group(group_name=test_group_name) - except: - pass diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_integration.py b/tests/unit/plugins/modules/iam_group/test_iam_group_integration.py new file mode 100644 index 00000000..c9cea906 --- /dev/null +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_integration.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +@pytest.mark.integration_api +class TestIamGroupIntegration: + """Integration tests for CdpIamClient group management.""" + + def test_create_update_delete_group_lifecycle(self, api_client): + """Integration test for complete group lifecycle: create, update, delete.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-integration-group" + + try: + # 1. Create a new group + create_response = client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + assert "group" in create_response + assert create_response["group"]["groupName"] == test_group_name + assert create_response["group"]["syncMembershipOnUserLogin"] == True + + # 2. Verify group was created by listing it + list_response = client.list_groups(group_names=[test_group_name]) + assert "groups" in list_response + assert len(list_response["groups"]) == 1 + assert list_response["groups"][0]["groupName"] == test_group_name + + # 3. Update the group (change sync setting) + update_response = client.update_group( + group_name=test_group_name, + sync_membership_on_user_login=False, + ) + assert isinstance(update_response, dict) + + # 4. Verify the update + updated_list = client.list_groups(group_names=[test_group_name]) + assert updated_list["groups"][0]["syncMembershipOnUserLogin"] == False + + # 5. Delete the group + delete_response = client.delete_group(group_name=test_group_name) + assert isinstance(delete_response, dict) + + except Exception as e: + # Cleanup: try to delete the group if test fails + try: + client.delete_group(group_name=test_group_name) + except: + pass + raise e + + def test_group_membership_management(self, api_client): + """Integration test for adding and removing users from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-membership-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get current user to add to group + current_user = client.get_user() + user_crn = current_user.get("crn") + + if user_crn: + # 3. Add user to group + add_response = client.add_user_to_group( + group_name=test_group_name, + user_id=user_crn, + ) + assert isinstance(add_response, dict) + + # 4. Verify user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + assert user_crn in members_response["memberCrns"] + + # 5. Remove user from group + remove_response = client.remove_user_from_group( + group_name=test_group_name, + user_id=user_crn, + ) + assert isinstance(remove_response, dict) + + # 6. Verify user was removed + members_after = client.list_group_members(group_name=test_group_name) + assert user_crn not in members_after.get("memberCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_group_role_assignment(self, api_client): + """Integration test for assigning and unassigning roles to/from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-role-assignment-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get available roles + roles_response = client.list_roles() + if roles_response.get("roles") and len(roles_response["roles"]) > 0: + # Use the first available role for testing + test_role_crn = roles_response["roles"][0]["crn"] + + # 3. Assign role to group + assign_response = client.assign_group_role( + group_name=test_group_name, + role=test_role_crn, + ) + assert isinstance(assign_response, dict) + + # 4. Verify role was assigned + assigned_roles = client.list_group_assigned_roles( + group_name=test_group_name, + ) + assert "roleCrns" in assigned_roles + assert test_role_crn in assigned_roles["roleCrns"] + + # 5. Unassign role from group + unassign_response = client.unassign_group_role( + group_name=test_group_name, + role=test_role_crn, + ) + assert isinstance(unassign_response, dict) + + # 6. Verify role was unassigned + roles_after = client.list_group_assigned_roles( + group_name=test_group_name, + ) + assert test_role_crn not in roles_after.get("roleCrns", []) + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass + + def test_machine_user_group_membership(self, api_client): + """Integration test for adding and removing machine users from a group.""" + + client = CdpIamClient(api_client=api_client) + + test_group_name = "test-machine-user-group" + + try: + # 1. Create a test group + client.create_group( + group_name=test_group_name, + sync_membership_on_user_login=True, + ) + + # 2. Get list of machine users + machine_users_response = client.list_machine_users() + + if ( + machine_users_response.get("machineUsers") + and len(machine_users_response["machineUsers"]) > 0 + ): + # Use first available machine user for testing + machine_user_name = machine_users_response["machineUsers"][0][ + "machineUserName" + ] + + # 3. Add machine user to group + add_response = client.add_machine_user_to_group( + group_name=test_group_name, + machine_user_name=machine_user_name, + ) + assert isinstance(add_response, dict) + + # 4. Verify machine user was added + members_response = client.list_group_members(group_name=test_group_name) + assert "memberCrns" in members_response + + # 5. Remove machine user from group + remove_response = client.remove_machine_user_from_group( + group_name=test_group_name, + machine_user_name=machine_user_name, + ) + assert isinstance(remove_response, dict) + + # 6. Verify machine user was removed + members_after = client.list_group_members(group_name=test_group_name) + # Machine user should no longer be in the group + + finally: + # Cleanup: delete the test group + try: + client.delete_group(group_name=test_group_name) + except: + pass From d568af66a6896cfff03e3c70dad10d39f0f9313a Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:53:43 -0500 Subject: [PATCH 08/31] Refactor CdpIamClient unit tests (and prep the integration tests) Signed-off-by: Webster Mudge --- .../module_utils/cdp_iam/test_iam_api.py | 455 +++++++++++++++++ .../cdp_iam}/test_iam_group_integration.py | 1 + .../modules/iam_group/test_iam_group.py | 468 ++---------------- 3 files changed, 510 insertions(+), 414 deletions(-) create mode 100644 tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py rename tests/unit/plugins/{modules/iam_group => module_utils/cdp_iam}/test_iam_group_integration.py (99%) diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py new file mode 100644 index 00000000..8c866525 --- /dev/null +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import pytest + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + RestClient, +) +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( + CdpIamClient, +) + + +BASE_URL = "https://cloudera.internal/api" +ACCESS_KEY = "test-access-key" +PRIVATE_KEY = "test-private-key" + +SAMPLE_USERS = [ + "crn:cdp:iam:us-west-1:altus:user:alice@example.com", + "crn:cdp:iam:us-west-1:altus:user:bob@example.com", + ] +SAMPLE_MACHINE_USERS = [ + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", + ] +SAMPLE_ROLES = [ + "crn:cdp:iam:us-west-1:altus:role:PowerUser", + "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", + "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", + ] +SAMPLE_RESOURCE_ROLES = [ + { + "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, + ] + + +class TestCdpIamClient: + """Unit tests for CdpIamClient group management methods.""" + + def test_list_groups_no_filter(self, mocker): + """Test listing all IAM groups without filtering.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + { + "groupName": "data-scientists", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-scientists", + "creationDate": "2024-01-20T14:45:00.000Z", + "syncMembershipOnUserLogin": False, + }, + ], + } + + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + response = client.list_groups() + + # Validate the response + assert "groups" in response + assert len(response["groups"]) == 2 + assert response["groups"][0]["groupName"] == "data-engineers" + assert response["groups"][1]["syncMembershipOnUserLogin"] == False + + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/listGroups", + None, + { + "pageSize": 100, + }, + squelch={404: {}}, + ) + + def test_list_groups_with_filter(self, mocker): + """Test listing IAM groups filtered by name.""" + + # Mock response data + mock_response = { + "groups": [ + { + "groupName": "data-engineers", + "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", + "creationDate": "2024-01-15T10:30:00.000Z", + "syncMembershipOnUserLogin": True, + }, + ], + } + + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.list_groups(group_names=["data-engineers"]) + + assert "groups" in response + assert len(response["groups"]) == 1 + assert response["groups"][0]["groupName"] == "data-engineers" + + # Verify that the method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/listGroups", + None, + { + "pageSize": 100, + "groupNames": ["data-engineers"], + }, + squelch={404: {}}, + ) + + # TODO Update remaining tests to use mocker fixture style + def test_create_group(self, iam_client): + """Test creating a new IAM group.""" + + # Mock response data + mock_response = { + "group": { + "groupName": "new-team", + "crn": "crn:cdp:iam:us-west-1:altus:group:new-team", + "creationDate": "2024-02-01T09:00:00.000Z", + "syncMembershipOnUserLogin": True, + }, + } + + iam_client.create_group.return_value = mock_response + + response = iam_client.create_group( + group_name="new-team", + sync_membership_on_user_login=True, + ) + + assert "group" in response + assert response["group"]["groupName"] == "new-team" + + # Verify that the method was called with correct parameters + iam_client.create_group.assert_called_once_with( + group_name="new-team", + sync_membership_on_user_login=True, + ) + + def test_delete_group(self, iam_client): + """Test deleting an IAM group.""" + + # Mock response data (delete operations typically return empty) + mock_response = {} + + iam_client.delete_group.return_value = mock_response + + response = iam_client.delete_group(group_name="old-team") + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.delete_group.assert_called_once_with(group_name="old-team") + + def test_update_group(self, iam_client): + """Test updating an IAM group.""" + + # Mock response data + mock_response = {} + + iam_client.update_group.return_value = mock_response + + response = iam_client.update_group( + group_name="existing-team", + sync_membership_on_user_login=False, + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.update_group.assert_called_once_with( + group_name="existing-team", + sync_membership_on_user_login=False, + ) + + def test_list_group_members( + self, + iam_client, + sample_users, + sample_machine_users, + ): + """Test listing group members.""" + + # Mock response data + mock_response = { + "memberCrns": sample_users + [sample_machine_users[0]], + } + + iam_client.list_group_members.return_value = mock_response + + response = iam_client.list_group_members(group_name="data-engineers") + + assert "memberCrns" in response + assert len(response["memberCrns"]) == 3 + + # Verify that the method was called with correct parameters + iam_client.list_group_members.assert_called_once_with( + group_name="data-engineers", + ) + + def test_add_user_to_group(self, iam_client, sample_users): + """Test adding a user to a group.""" + + # Mock response data + mock_response = {} + + iam_client.add_user_to_group.return_value = mock_response + + response = iam_client.add_user_to_group( + user_id=sample_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.add_user_to_group.assert_called_once_with( + user_id=sample_users[0], + group_name="data-engineers", + ) + + def test_remove_user_from_group(self, iam_client, sample_users): + """Test removing a user from a group.""" + + # Mock response data + mock_response = {} + + iam_client.remove_user_from_group.return_value = mock_response + + response = iam_client.remove_user_from_group( + user_id=sample_users[1], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.remove_user_from_group.assert_called_once_with( + user_id=sample_users[1], + group_name="data-engineers", + ) + + def test_add_machine_user_to_group( + self, + iam_client, + sample_machine_users, + ): + """Test adding a machine user to a group.""" + + # Mock response data + mock_response = {} + + iam_client.add_machine_user_to_group.return_value = mock_response + + response = iam_client.add_machine_user_to_group( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.add_machine_user_to_group.assert_called_once_with( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + def test_remove_machine_user_from_group( + self, + iam_client, + sample_machine_users, + ): + """Test removing a machine user from a group.""" + + # Mock response data + mock_response = {} + + iam_client.remove_machine_user_from_group.return_value = mock_response + + response = iam_client.remove_machine_user_from_group( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.remove_machine_user_from_group.assert_called_once_with( + machine_user_name=sample_machine_users[0], + group_name="data-engineers", + ) + + def test_assign_group_role(self, iam_client, sample_roles): + """Test assigning a role to a group.""" + + # Mock response data + mock_response = {} + + iam_client.assign_group_role.return_value = mock_response + + response = iam_client.assign_group_role( + group_name="data-engineers", + role=sample_roles[0], + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.assign_group_role.assert_called_once_with( + group_name="data-engineers", + role=sample_roles[0], + ) + + def test_unassign_group_role(self, iam_client, sample_roles): + """Test unassigning a role from a group.""" + + # Mock response data + mock_response = {} + + iam_client.unassign_group_role.return_value = mock_response + + response = iam_client.unassign_group_role( + group_name="data-engineers", + role=sample_roles[0], + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.unassign_group_role.assert_called_once_with( + group_name="data-engineers", + role=sample_roles[0], + ) + + def test_list_group_assigned_resource_roles(self, iam_client): + """Test listing resource roles assigned to a group.""" + + # Mock response data + mock_response = { + "resourceAssignments": [ + { + "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resourceCrn": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, + ], + } + + iam_client.list_group_assigned_resource_roles.return_value = mock_response + + response = iam_client.list_group_assigned_resource_roles( + group_name="data-engineers", + ) + + assert "resourceAssignments" in response + assert len(response["resourceAssignments"]) == 2 + + # Verify that the method was called with correct parameters + iam_client.list_group_assigned_resource_roles.assert_called_once_with( + group_name="data-engineers", + ) + + def test_assign_group_resource_role( + self, + iam_client, + sample_resource_roles, + ): + """Test assigning a resource role to a group.""" + + # Mock response data + mock_response = {} + + iam_client.assign_group_resource_role.return_value = mock_response + + response = iam_client.assign_group_resource_role( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.assign_group_resource_role.assert_called_once_with( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) + + def test_unassign_group_resource_role( + self, + iam_client, + sample_resource_roles, + ): + """Test unassigning a resource role from a group.""" + + # Mock response data + mock_response = {} + + iam_client.unassign_group_resource_role.return_value = mock_response + + response = iam_client.unassign_group_resource_role( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) + + assert isinstance(response, dict) + + # Verify that the method was called with correct parameters + iam_client.unassign_group_resource_role.assert_called_once_with( + group_name="data-engineers", + resource_crn=sample_resource_roles[0]["resource"], + resource_role_crn=sample_resource_roles[0]["role"], + ) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_integration.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py similarity index 99% rename from tests/unit/plugins/modules/iam_group/test_iam_group_integration.py rename to tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py index c9cea906..95d360d6 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_integration.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py @@ -30,6 +30,7 @@ ) +@pytest.mark.skip(reason="Need to update to use factory fixtures") @pytest.mark.integration_api class TestIamGroupIntegration: """Integration tests for CdpIamClient group management.""" diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index 5914209f..c637f15a 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -18,447 +18,87 @@ __metaclass__ = type +import os import pytest -from unittest.mock import MagicMock from ansible_collections.cloudera.cloud.tests.unit import ( AnsibleFailJson, AnsibleExitJson, ) -from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( - CdpIamClient, -) +from ansible_collections.cloudera.cloud.plugins.modules import iam_group BASE_URL = "https://cloudera.internal/api" ACCESS_KEY = "test-access-key" PRIVATE_KEY = "test-private-key" +GROUP_NAME = "test-group" -# ============================================================================ -# Pytest Fixtures -# ============================================================================ - - -@pytest.fixture -def iam_client(mocker): - """Fixture to provide a mocked CdpIamClient instance.""" - return mocker.create_autospec(CdpIamClient, instance=True) - - -@pytest.fixture -def sample_users(): - """Fixture providing sample user CRNs for testing.""" - return [ - "crn:cdp:iam:us-west-1:altus:user:alice@example.com", - "crn:cdp:iam:us-west-1:altus:user:bob@example.com", - ] - +def test_iam_group_default(module_args): + """Test iam_group module with missing parameters.""" -@pytest.fixture -def sample_machine_users(): - """Fixture providing sample machine user CRNs for testing.""" - return [ - "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", - "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", - ] - - -@pytest.fixture -def sample_roles(): - """Fixture providing sample role CRNs for testing.""" - return [ - "crn:cdp:iam:us-west-1:altus:role:PowerUser", - "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", - "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", - ] - - -@pytest.fixture -def sample_resource_roles(): - """Fixture providing sample resource role assignments for testing.""" - return [ - { - "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", - "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", - }, + module_args( { - "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", - "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, }, - ] - - -class TestCdpIamClient: - """Unit tests for CdpIamClient group management methods.""" - - def test_list_groups_no_filter(self, iam_client): - """Test listing all IAM groups without filtering.""" - - # Mock response data - mock_response = { - "groups": [ - { - "groupName": "data-engineers", - "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", - "creationDate": "2024-01-15T10:30:00.000Z", - "syncMembershipOnUserLogin": True, - }, - { - "groupName": "data-scientists", - "crn": "crn:cdp:iam:us-west-1:altus:group:data-scientists", - "creationDate": "2024-01-20T14:45:00.000Z", - "syncMembershipOnUserLogin": False, - }, - ], - } - - # Mock the CdpIamClient method - iam_client.list_groups.return_value = mock_response - - response = iam_client.list_groups() - - assert "groups" in response - assert len(response["groups"]) == 2 - assert response["groups"][0]["groupName"] == "data-engineers" - assert response["groups"][1]["syncMembershipOnUserLogin"] == False - - # Verify that the method was called - iam_client.list_groups.assert_called_once() - - def test_list_groups_with_filter(self, iam_client): - """Test listing IAM groups filtered by name.""" - - # Mock response data - mock_response = { - "groups": [ - { - "groupName": "data-engineers", - "crn": "crn:cdp:iam:us-west-1:altus:group:data-engineers", - "creationDate": "2024-01-15T10:30:00.000Z", - "syncMembershipOnUserLogin": True, - }, - ], - } - - # Mock the CdpIamClient method - iam_client.list_groups.return_value = mock_response - - response = iam_client.list_groups(group_names=["data-engineers"]) - - assert "groups" in response - assert len(response["groups"]) == 1 - assert response["groups"][0]["groupName"] == "data-engineers" - - # Verify that the method was called with correct parameters - iam_client.list_groups.assert_called_once_with(group_names=["data-engineers"]) - - def test_create_group(self, iam_client): - """Test creating a new IAM group.""" - - # Mock response data - mock_response = { - "group": { - "groupName": "new-team", - "crn": "crn:cdp:iam:us-west-1:altus:group:new-team", - "creationDate": "2024-02-01T09:00:00.000Z", - "syncMembershipOnUserLogin": True, - }, - } - - iam_client.create_group.return_value = mock_response - - response = iam_client.create_group( - group_name="new-team", - sync_membership_on_user_login=True, - ) - - assert "group" in response - assert response["group"]["groupName"] == "new-team" - - # Verify that the method was called with correct parameters - iam_client.create_group.assert_called_once_with( - group_name="new-team", - sync_membership_on_user_login=True, - ) - - def test_delete_group(self, iam_client): - """Test deleting an IAM group.""" - - # Mock response data (delete operations typically return empty) - mock_response = {} - - iam_client.delete_group.return_value = mock_response - - response = iam_client.delete_group(group_name="old-team") - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.delete_group.assert_called_once_with(group_name="old-team") - - def test_update_group(self, iam_client): - """Test updating an IAM group.""" - - # Mock response data - mock_response = {} - - iam_client.update_group.return_value = mock_response - - response = iam_client.update_group( - group_name="existing-team", - sync_membership_on_user_login=False, - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.update_group.assert_called_once_with( - group_name="existing-team", - sync_membership_on_user_login=False, - ) - - def test_list_group_members( - self, - iam_client, - sample_users, - sample_machine_users, - ): - """Test listing group members.""" - - # Mock response data - mock_response = { - "memberCrns": sample_users + [sample_machine_users[0]], - } - - iam_client.list_group_members.return_value = mock_response - - response = iam_client.list_group_members(group_name="data-engineers") + ) - assert "memberCrns" in response - assert len(response["memberCrns"]) == 3 + # Expect the module to fail due to missing required parameter + with pytest.raises(AnsibleFailJson, match="name"): + iam_group.main() - # Verify that the method was called with correct parameters - iam_client.list_group_members.assert_called_once_with( - group_name="data-engineers", - ) - def test_add_user_to_group(self, iam_client, sample_users): - """Test adding a user to a group.""" +def test_iam_group_name(module_args, mocker): + """Test iam_group module with missing parameters.""" - # Mock response data - mock_response = {} - - iam_client.add_user_to_group.return_value = mock_response - - response = iam_client.add_user_to_group( - user_id=sample_users[0], - group_name="data-engineers", - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.add_user_to_group.assert_called_once_with( - user_id=sample_users[0], - group_name="data-engineers", - ) - - def test_remove_user_from_group(self, iam_client, sample_users): - """Test removing a user from a group.""" - - # Mock response data - mock_response = {} - - iam_client.remove_user_from_group.return_value = mock_response - - response = iam_client.remove_user_from_group( - user_id=sample_users[1], - group_name="data-engineers", - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.remove_user_from_group.assert_called_once_with( - user_id=sample_users[1], - group_name="data-engineers", - ) - - def test_add_machine_user_to_group( - self, - iam_client, - sample_machine_users, - ): - """Test adding a machine user to a group.""" - - # Mock response data - mock_response = {} - - iam_client.add_machine_user_to_group.return_value = mock_response - - response = iam_client.add_machine_user_to_group( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.add_machine_user_to_group.assert_called_once_with( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - def test_remove_machine_user_from_group( - self, - iam_client, - sample_machine_users, - ): - """Test removing a machine user from a group.""" - - # Mock response data - mock_response = {} - - iam_client.remove_machine_user_from_group.return_value = mock_response - - response = iam_client.remove_machine_user_from_group( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.remove_machine_user_from_group.assert_called_once_with( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - def test_assign_group_role(self, iam_client, sample_roles): - """Test assigning a role to a group.""" - - # Mock response data - mock_response = {} - - iam_client.assign_group_role.return_value = mock_response - - response = iam_client.assign_group_role( - group_name="data-engineers", - role=sample_roles[0], - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.assign_group_role.assert_called_once_with( - group_name="data-engineers", - role=sample_roles[0], - ) - - def test_unassign_group_role(self, iam_client, sample_roles): - """Test unassigning a role from a group.""" - - # Mock response data - mock_response = {} - - iam_client.unassign_group_role.return_value = mock_response - - response = iam_client.unassign_group_role( - group_name="data-engineers", - role=sample_roles[0], - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.unassign_group_role.assert_called_once_with( - group_name="data-engineers", - role=sample_roles[0], - ) - - def test_list_group_assigned_resource_roles(self, iam_client): - """Test listing resource roles assigned to a group.""" - - # Mock response data - mock_response = { - "resourceAssignments": [ - { - "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", - "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", - }, - { - "resourceCrn": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", - "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", - }, - ], - } - - iam_client.list_group_assigned_resource_roles.return_value = mock_response - - response = iam_client.list_group_assigned_resource_roles( - group_name="data-engineers", - ) - - assert "resourceAssignments" in response - assert len(response["resourceAssignments"]) == 2 - - # Verify that the method was called with correct parameters - iam_client.list_group_assigned_resource_roles.assert_called_once_with( - group_name="data-engineers", - ) - - def test_assign_group_resource_role( - self, - iam_client, - sample_resource_roles, - ): - """Test assigning a resource role to a group.""" - - # Mock response data - mock_response = {} - - iam_client.assign_group_resource_role.return_value = mock_response + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "absent", + }, + ) - response = iam_client.assign_group_resource_role( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) + # Patch AnsibleCdpClient to avoid real API calls + client = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.modules.iam_group.CdpIamClient", + autospec=True, + ).return_value + client.get_group_details.return_value = GROUP_NAME - assert isinstance(response, dict) + # Expect the module to fail due to missing required parameter + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() - # Verify that the method was called with correct parameters - iam_client.assign_group_resource_role.assert_called_once_with( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) + assert result.value.changed is True + assert result.value.group == {} - def test_unassign_group_resource_role( - self, - iam_client, - sample_resource_roles, - ): - """Test unassigning a resource role from a group.""" + # Verify CdpIamClient was called correctly + client.get_group_details.assert_called_once_with(group_name=GROUP_NAME) + client.delete_group.assert_called_once_with(group_name=GROUP_NAME) - # Mock response data - mock_response = {} - iam_client.unassign_group_resource_role.return_value = mock_response +@pytest.mark.integration_api +def test_iam_group_integration(module_args): + """Integration test for compute usage info module.""" - response = iam_client.unassign_group_resource_role( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) + module_args( + { + "endpoint": os.getenv("CDP_API_ENDPOINT", BASE_URL), + "access_key": os.getenv("CDP_ACCESS_KEY", ACCESS_KEY), + "private_key": os.getenv("CDP_PRIVATE_KEY", PRIVATE_KEY), + "from_timestamp": "2024-01-31T00:00:00Z", + "to_timestamp": "2024-01-31T23:59:59Z", + }, + ) - assert isinstance(response, dict) + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() - # Verify that the method was called with correct parameters - iam_client.unassign_group_resource_role.assert_called_once_with( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) + assert hasattr(result.value, "records"), "'records' key not found in result" + assert isinstance(result.value.records, list), "'records' is not a list" From cd87f43e9fc25720a2621538c4689646848895e7 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:56:03 -0500 Subject: [PATCH 09/31] Update squelch to be an empty dict Signed-off-by: Webster Mudge --- plugins/module_utils/cdp_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index 593315e3..9ae2e6e9 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -485,7 +485,7 @@ def _make_request( data: Optional[Union[Dict[str, Any], List[Any]]] = None, json_data: Optional[Union[Dict[str, Any], List[Any]]] = None, max_retries: int = 3, - squelch: Dict[int, Any] = None, + squelch: Dict[int, Any] = {}, ) -> Any: """ Make HTTP request with retry logic using Ansible's fetch_url. From 919e9b840083675c55054451343559b961406b38 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:56:20 -0500 Subject: [PATCH 10/31] Update for Python linting Signed-off-by: Webster Mudge --- plugins/module_utils/cdp_iam.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index 37503baa..82bce94d 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -225,8 +225,12 @@ def manage_group_resource_roles( for assignment in assignments_to_remove: self.unassign_group_resource_role( group_name=group_name, - resource_crn=assignment["resource"], - resource_role_crn=assignment["role"], + resource_crn=assignment[ + "resource" + ], # pyright: ignore[reportArgumentType] + resource_role_crn=assignment[ + "role" + ], # pyright: ignore[reportArgumentType] ) changed = True @@ -237,8 +241,12 @@ def manage_group_resource_roles( for assignment in assignments_to_add: self.assign_group_resource_role( group_name=group_name, - resource_crn=assignment["resource"], - resource_role_crn=assignment["role"], + resource_crn=assignment[ + "resource" + ], # pyright: ignore[reportArgumentType] + resource_role_crn=assignment[ + "role" + ], # pyright: ignore[reportArgumentType] ) changed = True From bcdb4f2913fdd8727efb2e4191c3ea01bb83ebba Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:56:53 -0500 Subject: [PATCH 11/31] Update return types of process() and execute() methods Signed-off-by: Webster Mudge --- plugins/module_utils/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index eb497c05..b1848498 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -266,11 +266,11 @@ def get_param(self, param, default=None) -> Any: return default @abc.abstractmethod - def process(self): + def process(self) -> None: """Abstract method that Service modules must implement to perform their logic.""" pass - def execute(self): + def execute(self) -> None: """Execute the process method and capture logging output.""" try: # Call the abstract process method From b88255b5a7d1c6b97f5a604e5af8106acf1c6f83 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:57:08 -0500 Subject: [PATCH 12/31] Remove unused import Signed-off-by: Webster Mudge --- .../module_utils/cdp_client_consumption/test_consumption_api.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py index 3dbe8a3b..fbf2af5b 100644 --- a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py +++ b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py @@ -20,8 +20,6 @@ import pytest -from ansible_collections.cloudera.cloud.tests.unit import AnsibleExitJson - from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( RestClient, ) From 69cb0327cf33e8260c35d132c2ebbe85cf793385 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 11:57:44 -0500 Subject: [PATCH 13/31] Refactor present/absent logic Signed-off-by: Webster Mudge --- plugins/modules/iam_group.py | 109 ++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 53 deletions(-) diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index 2a4dc2d8..b6713e20 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -281,67 +281,70 @@ def __init__(self): def process(self): current_group = self.client.get_group_details(group_name=self.name) + # Delete if self.state == "absent": if current_group: if not self.module.check_mode: self.client.delete_group(group_name=self.name) self.changed = True - return self.group - - if self.state == "present" and not current_group: - if self.module.check_mode: - self.group = {"groupName": self.name} - else: - response = self.client.create_group( - group_name=self.name, - sync_membership_on_user_login=self.sync, - ) - self.group = response.get("group", {}) - self.changed = True - current_group = self.client.get_group_details(group_name=self.name) - - if not self.module.check_mode and current_group: - - if self.sync != current_group.get("syncMembershipOnUserLogin"): - self.client.update_group( - group_name=self.name, - sync_membership_on_user_login=self.sync, - ) - self.changed = True - - if self.users is not None or self.purge: - if self.client.manage_group_users( - group_name=self.name, - current_members=current_group.get("members", []), - desired_users=self.users if self.users is not None else [], - purge=self.purge, - ): - self.changed = True - - if self.roles is not None or self.purge: - if self.client.manage_group_roles( - group_name=self.name, - current_roles=current_group.get("roles", []), - desired_roles=self.roles if self.roles is not None else [], - purge=self.purge, - ): + + if self.state == "present": + # Create + if not current_group: + if self.module.check_mode: + self.group = {"groupName": self.name} + else: + response = self.client.create_group( + group_name=self.name, + sync_membership_on_user_login=self.sync, + ) + self.group = response.get("group", {}) + self.changed = True + current_group = self.client.get_group_details(group_name=self.name) + + # Reconcile + if not self.module.check_mode and current_group: + + if self.sync != current_group.get("syncMembershipOnUserLogin"): + self.client.update_group( + group_name=self.name, + sync_membership_on_user_login=self.sync, + ) self.changed = True - if self.resource_roles is not None or self.purge: - if self.client.manage_group_resource_roles( - group_name=self.name, - current_assignments=current_group.get("resourceAssignments", []), - desired_assignments=( - self.resource_roles if self.resource_roles is not None else [] - ), - purge=self.purge, - ): - self.changed = True + if self.users is not None or self.purge: + if self.client.manage_group_users( + group_name=self.name, + current_members=current_group.get("members", []), + desired_users=self.users if self.users is not None else [], + purge=self.purge, + ): + self.changed = True + + if self.roles is not None or self.purge: + if self.client.manage_group_roles( + group_name=self.name, + current_roles=current_group.get("roles", []), + desired_roles=self.roles if self.roles is not None else [], + purge=self.purge, + ): + self.changed = True + + if self.resource_roles is not None or self.purge: + if self.client.manage_group_resource_roles( + group_name=self.name, + current_assignments=current_group.get("resourceAssignments", []), + desired_assignments=( + self.resource_roles if self.resource_roles is not None else [] + ), + purge=self.purge, + ): + self.changed = True - if self.changed and not self.module.check_mode: - self.group = self.client.get_group_details(group_name=self.name) - else: - self.group = current_group + if self.changed and not self.module.check_mode: + self.group = self.client.get_group_details(group_name=self.name) + else: + self.group = current_group def main(): From 443cf38e2f63493fb760c707f8e4d65e5722ecff Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 12:17:31 -0500 Subject: [PATCH 14/31] Add mock for load_cdp_config() Signed-off-by: Webster Mudge --- .../unit/plugins/modules/iam_group/test_iam_group.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index c637f15a..aa5646ef 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -32,6 +32,10 @@ BASE_URL = "https://cloudera.internal/api" ACCESS_KEY = "test-access-key" PRIVATE_KEY = "test-private-key" +FILE_ACCESS_KEY = "file-access-key" +FILE_PRIVATE_KEY = "file-private-key" +FILE_REGION = "default" + GROUP_NAME = "test-group" @@ -64,7 +68,13 @@ def test_iam_group_name(module_args, mocker): }, ) - # Patch AnsibleCdpClient to avoid real API calls + # Patch load_cdp_config to avoid reading real config files + config = mocker.patch( + "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", + ) + config.return_value = (FILE_ACCESS_KEY, FILE_PRIVATE_KEY, FILE_REGION) + + # Patch CdpIamClient to avoid real API calls client = mocker.patch( "ansible_collections.cloudera.cloud.plugins.modules.iam_group.CdpIamClient", autospec=True, From 0329e372f8ad65e3cc42dcee0cad0e7398b48602 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 12:19:58 -0500 Subject: [PATCH 15/31] Rename test method to reflect function under test Signed-off-by: Webster Mudge --- tests/unit/plugins/modules/iam_group/test_iam_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index aa5646ef..52ad80b1 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -55,7 +55,7 @@ def test_iam_group_default(module_args): iam_group.main() -def test_iam_group_name(module_args, mocker): +def test_iam_group_absent(module_args, mocker): """Test iam_group module with missing parameters.""" module_args( From 60df71e615448c41565ac954abf0703849ce3ed8 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Tue, 9 Dec 2025 12:35:47 -0500 Subject: [PATCH 16/31] Update for linting Signed-off-by: Webster Mudge --- plugins/modules/iam_group.py | 31 ++++++++------- .../module_utils/cdp_iam/test_iam_api.py | 38 +++++++++---------- .../modules/iam_group/test_iam_group.py | 2 +- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index b6713e20..e255b775 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -287,20 +287,20 @@ def process(self): if not self.module.check_mode: self.client.delete_group(group_name=self.name) self.changed = True - + if self.state == "present": # Create if not current_group: - if self.module.check_mode: - self.group = {"groupName": self.name} - else: - response = self.client.create_group( - group_name=self.name, - sync_membership_on_user_login=self.sync, - ) - self.group = response.get("group", {}) - self.changed = True - current_group = self.client.get_group_details(group_name=self.name) + if self.module.check_mode: + self.group = {"groupName": self.name} + else: + response = self.client.create_group( + group_name=self.name, + sync_membership_on_user_login=self.sync, + ) + self.group = response.get("group", {}) + self.changed = True + current_group = self.client.get_group_details(group_name=self.name) # Reconcile if not self.module.check_mode and current_group: @@ -333,9 +333,14 @@ def process(self): if self.resource_roles is not None or self.purge: if self.client.manage_group_resource_roles( group_name=self.name, - current_assignments=current_group.get("resourceAssignments", []), + current_assignments=current_group.get( + "resourceAssignments", + [], + ), desired_assignments=( - self.resource_roles if self.resource_roles is not None else [] + self.resource_roles + if self.resource_roles is not None + else [] ), purge=self.purge, ): diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py index 8c866525..51be0b6a 100644 --- a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -33,28 +33,28 @@ PRIVATE_KEY = "test-private-key" SAMPLE_USERS = [ - "crn:cdp:iam:us-west-1:altus:user:alice@example.com", - "crn:cdp:iam:us-west-1:altus:user:bob@example.com", - ] + "crn:cdp:iam:us-west-1:altus:user:alice@example.com", + "crn:cdp:iam:us-west-1:altus:user:bob@example.com", +] SAMPLE_MACHINE_USERS = [ - "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", - "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", - ] + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-1", + "crn:cdp:iam:us-west-1:altus:machineUser:service-account-2", +] SAMPLE_ROLES = [ - "crn:cdp:iam:us-west-1:altus:role:PowerUser", - "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", - "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", - ] + "crn:cdp:iam:us-west-1:altus:role:PowerUser", + "crn:cdp:iam:us-west-1:altus:role:EnvironmentCreator", + "crn:cdp:iam:us-west-1:altus:role:DFCatalogAdmin", +] SAMPLE_RESOURCE_ROLES = [ - { - "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", - "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", - }, - { - "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", - "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", - }, - ] + { + "resource": "crn:cdp:environments:us-west-1:altus:environment:dev-env", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", + }, + { + "resource": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", + "role": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", + }, +] class TestCdpIamClient: diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index 52ad80b1..1812e670 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -81,7 +81,7 @@ def test_iam_group_absent(module_args, mocker): ).return_value client.get_group_details.return_value = GROUP_NAME - # Expect the module to fail due to missing required parameter + # Test module execution with pytest.raises(AnsibleExitJson) as result: iam_group.main() From c1e067751358f24157e83476e599175ef6ac0099 Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 10 Dec 2025 09:00:57 +0000 Subject: [PATCH 17/31] Refactor CDP IAM API tests to use correct mocker fixture Signed-off-by: rsuplina --- .../module_utils/cdp_iam/test_iam_api.py | 345 ++++++++---------- 1 file changed, 158 insertions(+), 187 deletions(-) diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py index 51be0b6a..45fb17ea 100644 --- a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -18,8 +18,6 @@ __metaclass__ = type -import pytest - from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( RestClient, ) @@ -144,8 +142,7 @@ def test_list_groups_with_filter(self, mocker): squelch={404: {}}, ) - # TODO Update remaining tests to use mocker fixture style - def test_create_group(self, iam_client): + def test_create_group(self, mocker): """Test creating a new IAM group.""" # Mock response data @@ -158,9 +155,14 @@ def test_create_group(self, iam_client): }, } - iam_client.create_group.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.create_group( + response = client.create_group( group_name="new-team", sync_membership_on_user_login=True, ) @@ -168,288 +170,257 @@ def test_create_group(self, iam_client): assert "group" in response assert response["group"]["groupName"] == "new-team" - # Verify that the method was called with correct parameters - iam_client.create_group.assert_called_once_with( - group_name="new-team", - sync_membership_on_user_login=True, + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/createGroup", + None, + { + "groupName": "new-team", + "syncMembershipOnUserLogin": True, + }, + squelch={}, ) - def test_delete_group(self, iam_client): + def test_delete_group(self, mocker): """Test deleting an IAM group.""" # Mock response data (delete operations typically return empty) mock_response = {} - iam_client.delete_group.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.delete_group(group_name="old-team") + response = client.delete_group(group_name="old-team") assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.delete_group.assert_called_once_with(group_name="old-team") + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/deleteGroup", + None, + { + "groupName": "old-team", + }, + squelch={}, + ) - def test_update_group(self, iam_client): + def test_update_group(self, mocker): """Test updating an IAM group.""" # Mock response data mock_response = {} - iam_client.update_group.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response - response = iam_client.update_group( + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.update_group( group_name="existing-team", sync_membership_on_user_login=False, ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.update_group.assert_called_once_with( - group_name="existing-team", - sync_membership_on_user_login=False, + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/updateGroup", + None, + { + "groupName": "existing-team", + "syncMembershipOnUserLogin": False, + }, + squelch={}, ) - def test_list_group_members( - self, - iam_client, - sample_users, - sample_machine_users, - ): + def test_list_group_members(self, mocker): """Test listing group members.""" # Mock response data mock_response = { - "memberCrns": sample_users + [sample_machine_users[0]], + "memberCrns": SAMPLE_USERS + [SAMPLE_MACHINE_USERS[0]], } - iam_client.list_group_members.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.list_group_members(group_name="data-engineers") + response = client.list_group_members(group_name="data-engineers") assert "memberCrns" in response assert len(response["memberCrns"]) == 3 - # Verify that the method was called with correct parameters - iam_client.list_group_members.assert_called_once_with( - group_name="data-engineers", + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/listGroupMembers", + None, + { + "pageSize": 100, + "groupName": "data-engineers", + }, + squelch={}, ) - def test_add_user_to_group(self, iam_client, sample_users): + def test_add_user_to_group(self, mocker): """Test adding a user to a group.""" # Mock response data mock_response = {} - iam_client.add_user_to_group.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response + + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.add_user_to_group( - user_id=sample_users[0], + response = client.add_user_to_group( + user_id=SAMPLE_USERS[0], group_name="data-engineers", ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.add_user_to_group.assert_called_once_with( - user_id=sample_users[0], - group_name="data-engineers", + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/addUserToGroup", + None, + { + "userId": SAMPLE_USERS[0], + "groupName": "data-engineers", + }, + squelch={}, ) - def test_remove_user_from_group(self, iam_client, sample_users): + def test_remove_user_from_group(self, mocker): """Test removing a user from a group.""" # Mock response data mock_response = {} - iam_client.remove_user_from_group.return_value = mock_response + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response - response = iam_client.remove_user_from_group( - user_id=sample_users[1], + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) + + response = client.remove_user_from_group( + user_id=SAMPLE_USERS[1], group_name="data-engineers", ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.remove_user_from_group.assert_called_once_with( - user_id=sample_users[1], - group_name="data-engineers", + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/removeUserFromGroup", + None, + { + "userId": SAMPLE_USERS[1], + "groupName": "data-engineers", + }, + squelch={}, ) - def test_add_machine_user_to_group( - self, - iam_client, - sample_machine_users, - ): + def test_add_machine_user_to_group(self, mocker): """Test adding a machine user to a group.""" # Mock response data mock_response = {} - iam_client.add_machine_user_to_group.return_value = mock_response - - response = iam_client.add_machine_user_to_group( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.add_machine_user_to_group.assert_called_once_with( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", - ) - - def test_remove_machine_user_from_group( - self, - iam_client, - sample_machine_users, - ): - """Test removing a machine user from a group.""" - - # Mock response data - mock_response = {} + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response - iam_client.remove_machine_user_from_group.return_value = mock_response + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.remove_machine_user_from_group( - machine_user_name=sample_machine_users[0], + response = client.add_machine_user_to_group( + machine_user_name=SAMPLE_MACHINE_USERS[0], group_name="data-engineers", ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.remove_machine_user_from_group.assert_called_once_with( - machine_user_name=sample_machine_users[0], - group_name="data-engineers", + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/addMachineUserToGroup", + None, + { + "machineUserName": SAMPLE_MACHINE_USERS[0], + "groupName": "data-engineers", + }, + squelch={}, ) - def test_assign_group_role(self, iam_client, sample_roles): - """Test assigning a role to a group.""" + def test_remove_machine_user_from_group(self, mocker): + """Test removing a machine user from a group.""" # Mock response data mock_response = {} - iam_client.assign_group_role.return_value = mock_response - - response = iam_client.assign_group_role( - group_name="data-engineers", - role=sample_roles[0], - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.assign_group_role.assert_called_once_with( - group_name="data-engineers", - role=sample_roles[0], - ) - - def test_unassign_group_role(self, iam_client, sample_roles): - """Test unassigning a role from a group.""" - - # Mock response data - mock_response = {} + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response - iam_client.unassign_group_role.return_value = mock_response + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.unassign_group_role( + response = client.remove_machine_user_from_group( + machine_user_name=SAMPLE_MACHINE_USERS[0], group_name="data-engineers", - role=sample_roles[0], ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.unassign_group_role.assert_called_once_with( - group_name="data-engineers", - role=sample_roles[0], - ) - - def test_list_group_assigned_resource_roles(self, iam_client): - """Test listing resource roles assigned to a group.""" - - # Mock response data - mock_response = { - "resourceAssignments": [ - { - "resourceCrn": "crn:cdp:environments:us-west-1:altus:environment:dev-env", - "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:EnvironmentUser", - }, - { - "resourceCrn": "crn:cdp:datalake:us-west-1:altus:datalake:prod-dl", - "resourceRoleCrn": "crn:cdp:iam:us-west-1:altus:resourceRole:DataLakeAdmin", - }, - ], - } - - iam_client.list_group_assigned_resource_roles.return_value = mock_response - - response = iam_client.list_group_assigned_resource_roles( - group_name="data-engineers", - ) - - assert "resourceAssignments" in response - assert len(response["resourceAssignments"]) == 2 - - # Verify that the method was called with correct parameters - iam_client.list_group_assigned_resource_roles.assert_called_once_with( - group_name="data-engineers", + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/removeMachineUserFromGroup", + None, + { + "machineUserName": SAMPLE_MACHINE_USERS[0], + "groupName": "data-engineers", + }, + squelch={}, ) - def test_assign_group_resource_role( - self, - iam_client, - sample_resource_roles, - ): - """Test assigning a resource role to a group.""" + def test_assign_group_role(self, mocker): + """Test assigning a role to a group.""" # Mock response data mock_response = {} - iam_client.assign_group_resource_role.return_value = mock_response - - response = iam_client.assign_group_resource_role( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) - - assert isinstance(response, dict) - - # Verify that the method was called with correct parameters - iam_client.assign_group_resource_role.assert_called_once_with( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], - ) - - def test_unassign_group_resource_role( - self, - iam_client, - sample_resource_roles, - ): - """Test unassigning a resource role from a group.""" - - # Mock response data - mock_response = {} + # Mock the RestClient instance + api_client = mocker.create_autospec(RestClient, instance=True) + api_client._post.return_value = mock_response - iam_client.unassign_group_resource_role.return_value = mock_response + # Create the CdpIamClient instance + client = CdpIamClient(api_client=api_client) - response = iam_client.unassign_group_resource_role( + response = client.assign_group_role( group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], + role=SAMPLE_ROLES[0], ) assert isinstance(response, dict) - # Verify that the method was called with correct parameters - iam_client.unassign_group_resource_role.assert_called_once_with( - group_name="data-engineers", - resource_crn=sample_resource_roles[0]["resource"], - resource_role_crn=sample_resource_roles[0]["role"], + # Verify that the post method was called with correct parameters + api_client._post.assert_called_once_with( + "/api/v1/iam/assignGroupRole", + None, + { + "groupName": "data-engineers", + "role": SAMPLE_ROLES[0], + }, + squelch={}, ) From b9e65d64fd7ee5432a3bd9d80a96594cbf1c5bcf Mon Sep 17 00:00:00 2001 From: rsuplina Date: Wed, 10 Dec 2025 10:47:12 +0000 Subject: [PATCH 18/31] Add iam group module tests Signed-off-by: rsuplina --- .../modules/iam_group/test_iam_group.py | 21 --- .../modules/iam_group/test_iam_group_int.py | 144 ++++++++++++++++++ 2 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 tests/unit/plugins/modules/iam_group/test_iam_group_int.py diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group.py b/tests/unit/plugins/modules/iam_group/test_iam_group.py index 1812e670..dfb6f7b4 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group.py @@ -91,24 +91,3 @@ def test_iam_group_absent(module_args, mocker): # Verify CdpIamClient was called correctly client.get_group_details.assert_called_once_with(group_name=GROUP_NAME) client.delete_group.assert_called_once_with(group_name=GROUP_NAME) - - -@pytest.mark.integration_api -def test_iam_group_integration(module_args): - """Integration test for compute usage info module.""" - - module_args( - { - "endpoint": os.getenv("CDP_API_ENDPOINT", BASE_URL), - "access_key": os.getenv("CDP_ACCESS_KEY", ACCESS_KEY), - "private_key": os.getenv("CDP_PRIVATE_KEY", PRIVATE_KEY), - "from_timestamp": "2024-01-31T00:00:00Z", - "to_timestamp": "2024-01-31T23:59:59Z", - }, - ) - - with pytest.raises(AnsibleExitJson) as result: - iam_group.main() - - assert hasattr(result.value, "records"), "'records' key not found in result" - assert isinstance(result.value.records, list), "'records' is not a list" diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py new file mode 100644 index 00000000..9fa36dcb --- /dev/null +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + +# Copyright 2025 Cloudera, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import os +import pytest +import uuid + +from ansible_collections.cloudera.cloud.tests.unit import ( + AnsibleFailJson, + AnsibleExitJson, +) + +from ansible_collections.cloudera.cloud.plugins.modules import iam_group + + +BASE_URL = os.getenv("CDP_API_ENDPOINT") +ACCESS_KEY = os.getenv("CDP_ACCESS_KEY_ID") +PRIVATE_KEY = os.getenv("CDP_PRIVATE_KEY") + +# Generate unique group name for each test run to avoid conflicts +GROUP_NAME = f"test-group-int-{uuid.uuid4().hex[:8]}" + + +# Mark all tests in this module as integration tests requiring API credentials +pytestmark = pytest.mark.integration_api + + +def test_iam_group_create(module_args): + """Test creating a new IAM group with real API calls.""" + # Step 1: Create the group + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "sync": True, + }, + ) + + try: + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + assert "crn" in result.value.group + + finally: + # Cleanup: Delete the test group + cleanup_module_args = { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "absent", + } + try: + module_args(cleanup_module_args) + with pytest.raises(AnsibleExitJson): + iam_group.main() + except Exception: + pass + + +def test_iam_group_update_and_delete(module_args): + """Test creating, updating, and deleting an IAM group with real API calls.""" + + unique_group = f"test-group-update-{uuid.uuid4().hex[:8]}" + # Step 1: Create the group + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": unique_group, + "state": "present", + "sync": True, + }, + ) + + try: + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == unique_group + assert result.value.group["syncMembershipOnUserLogin"] is True + + # Step 2: Update the sync setting + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": unique_group, + "state": "present", + "sync": False, + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["syncMembershipOnUserLogin"] is False + + finally: + # Step 3: Delete the group + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": unique_group, + "state": "absent", + }, + ) + + try: + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + assert result.value.changed is True + except Exception: + pass From f60e87253870d9a9284a53ada6cefc13f27cb6ac Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Wed, 10 Dec 2025 19:15:46 -0500 Subject: [PATCH 19/31] Collapse RestClient into CdpClient Signed-off-by: Webster Mudge --- plugins/module_utils/cdp_client.py | 71 ++-------- plugins/module_utils/cdp_consumption.py | 13 +- plugins/module_utils/cdp_iam.py | 93 ++++++------ tests/unit/conftest.py | 33 +++-- .../cdp_client/test_ansible_cdp_client.py | 1 - .../test_create_canonical_request_string.py | 2 - .../cdp_client/test_pagination.py | 34 ++--- .../cdp_client/test_service_module.py | 24 ++-- .../test_consumption_api.py | 27 ++-- .../module_utils/cdp_iam/test_iam_api.py | 132 ++++++++---------- .../iam_group_info/test_iam_group_info.py | 26 ++-- 11 files changed, 199 insertions(+), 257 deletions(-) diff --git a/plugins/module_utils/cdp_client.py b/plugins/module_utils/cdp_client.py index 9ae2e6e9..a47c32a3 100644 --- a/plugins/module_utils/cdp_client.py +++ b/plugins/module_utils/cdp_client.py @@ -30,7 +30,7 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from email.utils import formatdate from typing import Any, Dict, Optional, List, Tuple, Union -from urllib.parse import urlencode, urlparse +from urllib.parse import urlparse from ansible.module_utils.urls import fetch_url @@ -230,7 +230,7 @@ def __init__(self, msg: str, status: Optional[int] = None): self.status = status -class RestClient: +class CdpClient: """Abstract base class for CDP REST API clients.""" def __init__(self, default_page_size: int = 100): @@ -244,7 +244,7 @@ def __init__(self, default_page_size: int = 100): # Abstract HTTP methods that must be implemented by subclasses @abc.abstractmethod - def _get( + def get( self, path: str, params: Optional[Dict[str, Any]] = None, @@ -253,7 +253,7 @@ def _get( pass @abc.abstractmethod - def _post( + def post( self, path: str, data: Optional[Dict[str, Any]] = None, @@ -264,7 +264,7 @@ def _post( pass @abc.abstractmethod - def _put( + def put( self, path: str, data: Optional[Dict[str, Any]] = None, @@ -275,7 +275,7 @@ def _put( pass @abc.abstractmethod - def _delete( + def delete( self, path: str, squelch: Dict[int, Any] = {}, @@ -289,7 +289,7 @@ def paginated(default_page_size=100): Decorator to handle automatic pagination for CDP API methods. Usage: - @RestClient.paginated() + @CdpClient.paginated() def some_api_method(self, param1, param2, startingToken=None, pageSize=None): # Method implementation pass @@ -384,54 +384,7 @@ def wrapper(self, *args, **kwargs): return decorator -class CdpClient: - """CDP client that uses a RestClient instance to delegate HTTP methods.""" - - def __init__( - self, - api_client: RestClient, - default_page_size: int = 100, - ): - """ - Initialize Delegated CDP client. - - Args: - api_client: CdpClient instance to delegate HTTP methods to - default_page_size: Default page size for paginated requests - """ - self.default_page_size = default_page_size - self.api_client: RestClient = api_client - - def get( - self, - path: str, - params: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - return self.api_client._get(path, params) - - def post( - self, - path: str, - data: Optional[Dict[str, Any]] = None, - json_data: Optional[Dict[str, Any]] = None, - squelch: Dict[int, Any] = {}, - ) -> Dict[str, Any]: - return self.api_client._post(path, data, json_data, squelch=squelch) - - def put( - self, - path: str, - data: Optional[Dict[str, Any]] = None, - json_data: Optional[Dict[str, Any]] = None, - squelch: Dict[int, Any] = {}, - ) -> Dict[str, Any]: - return self.api_client._put(path, data, json_data, squelch=squelch) - - def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: - return self.api_client._delete(path, squelch=squelch) - - -class AnsibleCdpClient(RestClient): +class AnsibleCdpClient(CdpClient): """Ansible-based CDP client using native Ansible HTTP methods.""" def __init__( @@ -643,7 +596,7 @@ def _make_request( except Exception as e: self.module.fail_json(msg=str(e)) - def _get( + def get( self, path: str, params: Optional[Dict[str, Any]] = None, @@ -651,7 +604,7 @@ def _get( """Execute HTTP GET request.""" return self._make_request("GET", path, params=params) - def _post( + def post( self, path: str, data: Optional[Dict[str, Any]] = None, @@ -667,7 +620,7 @@ def _post( squelch=squelch, ) - def _put( + def put( self, path: str, data: Optional[Dict[str, Any]] = None, @@ -683,6 +636,6 @@ def _put( squelch=squelch, ) - def _delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: """Execute HTTP DELETE request.""" return self._make_request("DELETE", path, squelch=squelch) diff --git a/plugins/module_utils/cdp_consumption.py b/plugins/module_utils/cdp_consumption.py index 867fae1c..b7ccecdc 100644 --- a/plugins/module_utils/cdp_consumption.py +++ b/plugins/module_utils/cdp_consumption.py @@ -21,24 +21,23 @@ from typing import Any, Dict, Optional from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, CdpClient, ) -class CdpConsumptionClient(CdpClient): +class CdpConsumptionClient(): """CDP Consumption API client.""" - def __init__(self, api_client: RestClient): + def __init__(self, api_client: CdpClient): """ Initialize CDP Consumption client. Args: - api_client: RestClient instance for managing HTTP method calls + api_client: CdpClient instance for managing HTTP method calls """ - super().__init__(api_client=api_client) + self.api_client = api_client - @RestClient.paginated() + @CdpClient.paginated() def list_compute_usage_records( self, from_timestamp: str, @@ -69,7 +68,7 @@ def list_compute_usage_records( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/consumption/listComputeUsageRecords", json_data=json_data, ) diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index 82bce94d..bf9277fb 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -21,22 +21,21 @@ from typing import Any, Dict, List, Optional from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, CdpClient, ) -class CdpIamClient(CdpClient): +class CdpIamClient(): """CDP IAM API client.""" - def __init__(self, api_client: RestClient): + def __init__(self, api_client: CdpClient): """ Initialize CDP IAM client. Args: - api_client: RestClient instance for managing HTTP method calls + api_client: CdpClient instance for managing HTTP method calls """ - super().__init__(api_client=api_client) + self.api_client = api_client def _is_machine_user(self, user_crn: str) -> bool: """Check if a user CRN represents a machine user.""" @@ -252,7 +251,7 @@ def manage_group_resource_roles( return changed - @RestClient.paginated() + @CdpClient.paginated() def list_groups( self, group_names: Optional[List[str]] = None, @@ -282,13 +281,13 @@ def list_groups( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroups", json_data=json_data, squelch={404: {}}, ) - @RestClient.paginated() + @CdpClient.paginated() def list_users( self, user_ids: Optional[List[str]] = None, @@ -319,7 +318,7 @@ def list_users( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listUsers", json_data=json_data, ) @@ -340,14 +339,14 @@ def get_user(self, user_id: Optional[str] = None) -> Dict[str, Any]: if user_id is not None: json_data["userId"] = user_id - response = self.post( + response = self.api_client.post( "/api/v1/iam/getUser", json_data=json_data, ) return response.get("user", {}) - @RestClient.paginated() + @CdpClient.paginated() def list_group_assigned_resource_roles( self, group_name: str, @@ -372,12 +371,12 @@ def list_group_assigned_resource_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroupAssignedResourceRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_group_assigned_roles( self, group_name: str, @@ -402,12 +401,12 @@ def list_group_assigned_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroupAssignedRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_group_members( self, group_name: str, @@ -432,12 +431,12 @@ def list_group_members( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroupMembers", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_groups_for_machine_user( self, machine_user_name: str, @@ -462,12 +461,12 @@ def list_groups_for_machine_user( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroupsForMachineUser", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_groups_for_user( self, user_id: str, @@ -492,12 +491,12 @@ def list_groups_for_user( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listGroupsForUser", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_machine_user_assigned_resource_roles( self, machine_user_name: str, @@ -522,12 +521,12 @@ def list_machine_user_assigned_resource_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listMachineUserAssignedResourceRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_machine_user_assigned_roles( self, machine_user_name: str, @@ -552,12 +551,12 @@ def list_machine_user_assigned_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listMachineUserAssignedRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_machine_users( self, machine_user_names: Optional[List[str]] = None, @@ -585,12 +584,12 @@ def list_machine_users( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listMachineUsers", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_resource_assignees( self, resource_crn: str, @@ -615,12 +614,12 @@ def list_resource_assignees( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listResourceAssignees", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_resource_roles( self, resource_role_names: Optional[List[str]] = None, @@ -648,12 +647,12 @@ def list_resource_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listResourceRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_roles( self, role_names: Optional[List[str]] = None, @@ -681,12 +680,12 @@ def list_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_user_assigned_resource_roles( self, user: Optional[str] = None, @@ -714,12 +713,12 @@ def list_user_assigned_resource_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listUserAssignedResourceRoles", json_data=json_data, ) - @RestClient.paginated() + @CdpClient.paginated() def list_user_assigned_roles( self, user: Optional[str] = None, @@ -747,7 +746,7 @@ def list_user_assigned_roles( if pageSize is not None: json_data["pageSize"] = pageSize - return self.post( + return self.api_client.post( "/api/v1/iam/listUserAssignedRoles", json_data=json_data, ) @@ -775,7 +774,7 @@ def create_group( if sync_membership_on_user_login is not None: json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login - return self.post( + return self.api_client.post( "/api/v1/iam/createGroup", json_data=json_data, ) @@ -792,7 +791,7 @@ def delete_group(self, group_name: str) -> Dict[str, Any]: """ json_data: Dict[str, Any] = {"groupName": group_name} - return self.post( + return self.api_client.post( "/api/v1/iam/deleteGroup", json_data=json_data, ) @@ -818,7 +817,7 @@ def update_group( if sync_membership_on_user_login is not None: json_data["syncMembershipOnUserLogin"] = sync_membership_on_user_login - return self.post( + return self.api_client.post( "/api/v1/iam/updateGroup", json_data=json_data, ) @@ -841,7 +840,7 @@ def add_user_to_group(self, group_name: str, user_id: str) -> Dict[str, Any]: "userId": user_id, } - return self.post( + return self.api_client.post( "/api/v1/iam/addUserToGroup", json_data=json_data, ) @@ -866,7 +865,7 @@ def add_machine_user_to_group( "machineUserName": machine_user_name, } - return self.post( + return self.api_client.post( "/api/v1/iam/addMachineUserToGroup", json_data=json_data, ) @@ -887,7 +886,7 @@ def remove_user_from_group(self, group_name: str, user_id: str) -> Dict[str, Any "userId": user_id, } - return self.post( + return self.api_client.post( "/api/v1/iam/removeUserFromGroup", json_data=json_data, ) @@ -912,7 +911,7 @@ def remove_machine_user_from_group( "machineUserName": machine_user_name, } - return self.post( + return self.api_client.post( "/api/v1/iam/removeMachineUserFromGroup", json_data=json_data, ) @@ -935,7 +934,7 @@ def assign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: "role": role, } - return self.post( + return self.api_client.post( "/api/v1/iam/assignGroupRole", json_data=json_data, ) @@ -963,7 +962,7 @@ def assign_group_resource_role( "resourceRoleCrn": resource_role_crn, } - return self.post( + return self.api_client.post( "/api/v1/iam/assignGroupResourceRole", json_data=json_data, ) @@ -984,7 +983,7 @@ def unassign_group_role(self, group_name: str, role: str) -> Dict[str, Any]: "role": role, } - return self.post( + return self.api_client.post( "/api/v1/iam/unassignGroupRole", json_data=json_data, ) @@ -1012,7 +1011,7 @@ def unassign_group_resource_role( "resourceRoleCrn": resource_role_crn, } - return self.post( + return self.api_client.post( "/api/v1/iam/unassignGroupResourceRole", json_data=json_data, ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 059f8b1f..744c9f55 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -23,12 +23,18 @@ import sys import pytest +from pytest import MonkeyPatch +from pytest_mock import MockerFixture +from typing import Callable +from unittest.mock import MagicMock, Mock + from ansible.module_utils import basic from ansible.module_utils.common.text.converters import to_bytes from ansible_collections.cloudera.cloud.tests.unit import ( AnsibleFailJson, AnsibleExitJson, + TestRestClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( @@ -77,7 +83,7 @@ def pytest_collection_modifyitems(items): @pytest.fixture -def module_args(): +def module_args() -> Callable[[dict], None]: """Prepare module arguments""" def prep_args(args=dict()): @@ -88,7 +94,7 @@ def prep_args(args=dict()): @pytest.fixture -def module_creds(): +def module_creds() -> dict[str, str]: """Prepare module credentials""" return { @@ -100,7 +106,7 @@ def module_creds(): @pytest.fixture(autouse=True) -def patch_module(monkeypatch): +def patch_module(monkeypatch: MonkeyPatch): """Patch AnsibleModule to raise exceptions on success and failure""" def exit_json(*args, **kwargs): @@ -117,7 +123,7 @@ def fail_json(*args, **kwargs): @pytest.fixture -def mock_ansible_module(mocker): +def mock_ansible_module(mocker: MockerFixture) -> Mock: """Fixture for mock AnsibleModule.""" module = mocker.Mock() module.params = {} @@ -131,7 +137,7 @@ def mock_ansible_module(mocker): @pytest.fixture() -def mock_load_cdp_config(mocker): +def mock_load_cdp_config(mocker: MockerFixture) -> MagicMock: """Mock the load_cdp_config function.""" return mocker.patch( "ansible_collections.cloudera.cloud.plugins.module_utils.common.load_cdp_config", @@ -140,9 +146,9 @@ def mock_load_cdp_config(mocker): @pytest.fixture() -def unset_cdp_env_vars(monkeypatch): +def unset_cdp_env_vars(monkeypatch: MonkeyPatch): """Fixture to unset any prior CDP-related environment variables.""" - monkeypatch.delenv("CDP_ACCESS_KEY", raising=False) + monkeypatch.delenv("CDP_ACCESS_KEY_ID", raising=False) monkeypatch.delenv("CDP_PRIVATE_KEY", raising=False) monkeypatch.delenv("CDP_CREDENTIALS_PATH", raising=False) monkeypatch.delenv("CDP_PROFILE", raising=False) @@ -150,7 +156,7 @@ def unset_cdp_env_vars(monkeypatch): @pytest.fixture() -def api_client(module_creds, mock_ansible_module): +def api_client(module_creds: dict[str, str], mock_ansible_module: Mock) -> AnsibleCdpClient: """Fixture for creating an Ansible API client instance.""" return AnsibleCdpClient( @@ -159,3 +165,14 @@ def api_client(module_creds, mock_ansible_module): access_key=module_creds["access_key"], private_key=module_creds["private_key"], ) + +@pytest.fixture(scope="session") +def cdp_rest_client() -> TestRestClient: + if "CDP_ACCESS_KEY_ID" not in os.environ or "CDP_PRIVATE_KEY" not in os.environ: + pytest.skip("CDP API credentials not set in env vars. Skipping integration tests.") + + return TestRestClient( + endpoint=os.getenv("CDP_API_ENDPOINT"), # pyright: ignore[reportArgumentType] + access_key=os.getenv("CDP_ACCESS_KEY_ID"), # pyright: ignore[reportArgumentType] + private_key=os.getenv("CDP_PRIVATE_KEY"), # pyright: ignore[reportArgumentType] + ) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py b/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py index a15c83f6..8e0dfdcb 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_ansible_cdp_client.py @@ -25,7 +25,6 @@ from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( AnsibleCdpClient, - CdpError, ) BASE_URL = "https://cloudera.internal/api" diff --git a/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py b/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py index 85615e05..c65be821 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_create_canonical_request_string.py @@ -18,8 +18,6 @@ __metaclass__ = type -import pytest - from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( create_canonical_request_string, ) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_pagination.py b/tests/unit/plugins/module_utils/cdp_client/test_pagination.py index 050fbd69..d5115d0c 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_pagination.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_pagination.py @@ -19,7 +19,7 @@ __metaclass__ = type from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) @@ -37,8 +37,8 @@ def test_paginated_decorator_single_page(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -75,8 +75,8 @@ def test_paginated_decorator_multiple_pages(mocker): mock_func.side_effect = [page1_data, page2_data] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -105,8 +105,8 @@ def test_paginated_decorator_with_custom_page_size_single(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated(default_page_size=10) + class TestClient(CdpClient): + @CdpClient.paginated(default_page_size=10) def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -145,8 +145,8 @@ def test_paginated_decorator_with_custom_page_size_multiple(mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated(default_page_size=2) + class TestClient(CdpClient): + @CdpClient.paginated(default_page_size=2) def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -177,8 +177,8 @@ def test_paginated_decorator_non_dict_response(mocker): mock_func.return_value = "Not a dict response" # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -208,8 +208,8 @@ def test_paginated_decorator_empty_list_keys(mock_ansible_module, mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -247,8 +247,8 @@ def test_paginated_decorator_with_explicit_page_size_single(mocker): mock_func.return_value = test_data # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) @@ -287,8 +287,8 @@ def test_paginated_decorator_with_explicit_page_size_multiple(mocker): mock_func.side_effect = [test_data1, test_data2] # Create a function to be decorated - class TestClient(RestClient): - @RestClient.paginated() + class TestClient(CdpClient): + @CdpClient.paginated() def decorated_func(self, *args, **kwargs): return mock_func(*args, **kwargs) diff --git a/tests/unit/plugins/module_utils/cdp_client/test_service_module.py b/tests/unit/plugins/module_utils/cdp_client/test_service_module.py index c54b5452..943820bd 100644 --- a/tests/unit/plugins/module_utils/cdp_client/test_service_module.py +++ b/tests/unit/plugins/module_utils/cdp_client/test_service_module.py @@ -25,7 +25,7 @@ from ansible_collections.cloudera.cloud.tests.unit import AnsibleFailJson from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.common import ( ParametersMixin, @@ -239,7 +239,7 @@ def test_services_module_initialization_basic( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_explicit( self, @@ -261,7 +261,7 @@ def test_services_module_initialization_endpoint_explicit( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_default( self, @@ -283,7 +283,7 @@ def test_services_module_initialization_endpoint_region_default( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_us_west_1( self, @@ -305,7 +305,7 @@ def test_services_module_initialization_endpoint_region_us_west_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_eu_1( self, @@ -327,7 +327,7 @@ def test_services_module_initialization_endpoint_region_eu_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_ap_1( self, @@ -349,7 +349,7 @@ def test_services_module_initialization_endpoint_region_ap_1( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_endpoint_region_env( self, @@ -372,7 +372,7 @@ def test_services_module_initialization_endpoint_region_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_credentials( self, @@ -395,7 +395,7 @@ def test_services_module_initialization_credentials( assert module.access_key == "explicit-access-key" assert module.private_key == "explicit-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_credentials_env( self, @@ -419,7 +419,7 @@ def test_services_module_initialization_credentials_env( assert module.access_key == "env-access-key" assert module.private_key == "env-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_profile_env( self, @@ -448,7 +448,7 @@ def test_services_module_initialization_profile_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_cred_path_env( self, @@ -477,7 +477,7 @@ def test_services_module_initialization_cred_path_env( assert module.access_key == "test-access-key" assert module.private_key == "test-private-key" assert module.api_client is not None - assert isinstance(module.api_client, RestClient) + assert isinstance(module.api_client, CdpClient) def test_services_module_initialization_invalid_endpoint_region( self, diff --git a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py index fbf2af5b..4c5c8b56 100644 --- a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py +++ b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py @@ -21,7 +21,7 @@ import pytest from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_consumption import ( CdpConsumptionClient, @@ -50,9 +50,9 @@ def test_list_compute_usage_records(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpConsumptionClient instance client = CdpConsumptionClient(api_client=api_client) @@ -67,10 +67,9 @@ def test_list_compute_usage_records(self, mocker): assert response["records"][0]["id"] == "record1" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, @@ -96,9 +95,9 @@ def test_list_compute_usage_records_pagination(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.side_effect = [mock_response1, mock_response2] + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.side_effect = [mock_response1, mock_response2] # Create the CdpConsumptionClient instance client = CdpConsumptionClient(api_client=api_client) @@ -114,12 +113,11 @@ def test_list_compute_usage_records_pagination(self, mocker): assert response["records"][2]["id"] == "record3" # Verify that the post method was called with correct parameters - api_client._post.assert_has_calls( + api_client.post.assert_has_calls( [ mocker.call( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, @@ -127,8 +125,7 @@ def test_list_compute_usage_records_pagination(self, mocker): ), mocker.call( "/api/v1/consumption/listComputeUsageRecords", - None, - { + json_data={ "fromTimestamp": FROM_TIMESTAMP, "toTimestamp": TO_TIMESTAMP, "pageSize": 100, diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py index 45fb17ea..85a120b1 100644 --- a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -19,7 +19,7 @@ __metaclass__ = type from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( CdpIamClient, @@ -79,9 +79,9 @@ def test_list_groups_no_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -94,10 +94,9 @@ def test_list_groups_no_filter(self, mocker): assert response["groups"][1]["syncMembershipOnUserLogin"] == False # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - { + json_data={ "pageSize": 100, }, squelch={404: {}}, @@ -118,9 +117,9 @@ def test_list_groups_with_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -132,10 +131,9 @@ def test_list_groups_with_filter(self, mocker): assert response["groups"][0]["groupName"] == "data-engineers" # Verify that the method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - { + json_data={ "pageSize": 100, "groupNames": ["data-engineers"], }, @@ -155,9 +153,9 @@ def test_create_group(self, mocker): }, } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -171,14 +169,12 @@ def test_create_group(self, mocker): assert response["group"]["groupName"] == "new-team" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/createGroup", - None, - { + json_data={ "groupName": "new-team", "syncMembershipOnUserLogin": True, }, - squelch={}, ) def test_delete_group(self, mocker): @@ -187,9 +183,9 @@ def test_delete_group(self, mocker): # Mock response data (delete operations typically return empty) mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -199,13 +195,11 @@ def test_delete_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/deleteGroup", - None, - { + json_data={ "groupName": "old-team", }, - squelch={}, ) def test_update_group(self, mocker): @@ -214,9 +208,9 @@ def test_update_group(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -229,14 +223,12 @@ def test_update_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/updateGroup", - None, - { + json_data={ "groupName": "existing-team", "syncMembershipOnUserLogin": False, }, - squelch={}, ) def test_list_group_members(self, mocker): @@ -247,9 +239,9 @@ def test_list_group_members(self, mocker): "memberCrns": SAMPLE_USERS + [SAMPLE_MACHINE_USERS[0]], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -260,14 +252,12 @@ def test_list_group_members(self, mocker): assert len(response["memberCrns"]) == 3 # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroupMembers", - None, - { + json_data={ "pageSize": 100, "groupName": "data-engineers", }, - squelch={}, ) def test_add_user_to_group(self, mocker): @@ -276,9 +266,9 @@ def test_add_user_to_group(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -291,14 +281,12 @@ def test_add_user_to_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/addUserToGroup", - None, - { + json_data={ "userId": SAMPLE_USERS[0], "groupName": "data-engineers", }, - squelch={}, ) def test_remove_user_from_group(self, mocker): @@ -307,9 +295,9 @@ def test_remove_user_from_group(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -322,14 +310,12 @@ def test_remove_user_from_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/removeUserFromGroup", - None, - { + json_data={ "userId": SAMPLE_USERS[1], "groupName": "data-engineers", }, - squelch={}, ) def test_add_machine_user_to_group(self, mocker): @@ -338,9 +324,9 @@ def test_add_machine_user_to_group(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -353,14 +339,12 @@ def test_add_machine_user_to_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/addMachineUserToGroup", - None, - { + json_data={ "machineUserName": SAMPLE_MACHINE_USERS[0], "groupName": "data-engineers", }, - squelch={}, ) def test_remove_machine_user_from_group(self, mocker): @@ -369,9 +353,9 @@ def test_remove_machine_user_from_group(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -384,14 +368,12 @@ def test_remove_machine_user_from_group(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/removeMachineUserFromGroup", - None, - { + json_data={ "machineUserName": SAMPLE_MACHINE_USERS[0], "groupName": "data-engineers", }, - squelch={}, ) def test_assign_group_role(self, mocker): @@ -400,9 +382,9 @@ def test_assign_group_role(self, mocker): # Mock response data mock_response = {} - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -415,12 +397,10 @@ def test_assign_group_role(self, mocker): assert isinstance(response, dict) # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/assignGroupRole", - None, - { + json_data={ "groupName": "data-engineers", "role": SAMPLE_ROLES[0], }, - squelch={}, ) diff --git a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py index 5baec40f..1137265a 100644 --- a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py +++ b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py @@ -26,7 +26,7 @@ ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( - RestClient, + CdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import ( CdpIamClient, @@ -60,9 +60,9 @@ def test_list_groups_no_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -74,10 +74,10 @@ def test_list_groups_no_filter(self, mocker): assert response["groups"][0]["groupName"] == "group1" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - {"pageSize": 100}, + json_data={"pageSize": 100}, + squelch={404: {}}, ) def test_list_groups_with_filter(self, mocker): @@ -94,9 +94,9 @@ def test_list_groups_with_filter(self, mocker): ], } - # Mock the RestClient instance - api_client = mocker.create_autospec(RestClient, instance=True) - api_client._post.return_value = mock_response + # Mock the CdpClient instance + api_client = mocker.create_autospec(CdpClient, instance=True) + api_client.post.return_value = mock_response # Create the CdpIamClient instance client = CdpIamClient(api_client=api_client) @@ -108,10 +108,10 @@ def test_list_groups_with_filter(self, mocker): assert response["groups"][0]["groupName"] == "specific-group" # Verify that the post method was called with correct parameters - api_client._post.assert_called_once_with( + api_client.post.assert_called_once_with( "/api/v1/iam/listGroups", - None, - {"groupNames": ["specific-group"], "pageSize": 100}, + json_data={"groupNames": ["specific-group"], "pageSize": 100}, + squelch={404: {}}, ) From 57d68f40550f63b578aa69452cea366df99a8c3f Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Wed, 10 Dec 2025 19:16:53 -0500 Subject: [PATCH 20/31] Fix invalid CDP environment variable Signed-off-by: Webster Mudge --- .../modules/compute_usage_info/test_compute_usage_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py b/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py index 3a599699..483289af 100644 --- a/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py +++ b/tests/unit/plugins/modules/compute_usage_info/test_compute_usage_info.py @@ -65,7 +65,7 @@ def test_compute_usage_info_integration(module_args): module_args( { "endpoint": os.getenv("CDP_API_ENDPOINT", BASE_URL), - "access_key": os.getenv("CDP_ACCESS_KEY", ACCESS_KEY), + "access_key": os.getenv("CDP_ACCESS_KEY_ID", ACCESS_KEY), "private_key": os.getenv("CDP_PRIVATE_KEY", PRIVATE_KEY), "from_timestamp": "2024-01-31T00:00:00Z", "to_timestamp": "2024-01-31T23:59:59Z", From 301313a337267d2ed4dd4c3a1a3efa14120fa51d Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Wed, 10 Dec 2025 19:17:26 -0500 Subject: [PATCH 21/31] Create TestRestClient for integration test fixtures Signed-off-by: Webster Mudge --- tests/unit/__init__.py | 103 ++++++++++++++++++ .../modules/iam_group/test_iam_group_int.py | 34 +++++- 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 809c8905..803974f1 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -14,6 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + +from email.utils import formatdate +from typing import Any, Dict +from urllib.parse import urlencode +from urllib.error import HTTPError +from http.client import HTTPResponse + +from ansible.module_utils.urls import Request + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + make_signature_header, +) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( + CdpClient, +) + class AnsibleFailJson(Exception): """Exception class to be raised by module.fail_json and caught by the test case""" @@ -36,3 +54,88 @@ def __init__(self, kwargs): def __getattr__(self, attr): return self.__dict__[attr] + + +class TestRestClient(CdpClient): + def __init__(self, endpoint: str, access_key:str, private_key:str, default_page_size: int = 100): + super().__init__(default_page_size) + self.request = Request(http_agent="PytestRestClient/1.0") + self.endpoint = endpoint.rstrip("/") + self.access_key = access_key + self.private_key = private_key + self.headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + def set_credential_headers(self, method:str, url:str): + self.headers["x-altus-date"] = formatdate(usegmt=True) + self.headers["x-altus-auth"] = make_signature_header( + method, + url, + self.headers, + self.access_key, + self.private_key, + ) + + def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: + if params: + path += "?" + urlencode(params) + + url = f"{self.endpoint}/{path.strip('/')}" + + response:HTTPResponse = Request().get( + url=url, + headers=self.set_credential_headers(method="GET", url=url), + ) + + if response: + response_text = response.read().decode("utf-8") + if response_text: + try: + return json.loads(response_text) + except json.JSONDecodeError: + return {"response": response_text} + else: + return {} + else: + return {} + + def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + url = f"{self.endpoint}/{path.strip('/')}" + + # Prepare request body + body = None + if json_data is not None: + body = json.dumps(json_data) + elif data is not None: + body = json.dumps(data) + + self.set_credential_headers(method="POST", url=url) + + try: + response:HTTPResponse = self.request.post( + url=url, + headers=self.headers, + data=body, + ) + except HTTPError as e: + raise e + + if response: + response_text = response.read().decode("utf-8") + if response_text: + try: + return json.loads(response_text) + except json.JSONDecodeError: + return {"response": response_text} + else: + return {} + else: + return {} + + def put(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + return {} + + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + return {} diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py index 9fa36dcb..4a7ac098 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -27,21 +27,47 @@ AnsibleExitJson, ) + +from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_iam import CdpIamClient from ansible_collections.cloudera.cloud.plugins.modules import iam_group -BASE_URL = os.getenv("CDP_API_ENDPOINT") -ACCESS_KEY = os.getenv("CDP_ACCESS_KEY_ID") -PRIVATE_KEY = os.getenv("CDP_PRIVATE_KEY") +BASE_URL = os.getenv("CDP_API_ENDPOINT", "not set") +ACCESS_KEY = os.getenv("CDP_ACCESS_KEY_ID", "not set") +PRIVATE_KEY = os.getenv("CDP_PRIVATE_KEY", "not set") # Generate unique group name for each test run to avoid conflicts GROUP_NAME = f"test-group-int-{uuid.uuid4().hex[:8]}" - # Mark all tests in this module as integration tests requiring API credentials pytestmark = pytest.mark.integration_api + + + +# @pytest.fixture +# def iam_group_cleanup(): +# """Fixture to clean up IAM groups created during tests.""" + +# group_names = [] + +# def _iam_group_module(name:str): +# group_names.append(name) +# return + +# yield _iam_group_module + +# for name in group_names: +# try: + + +def test_iam_user(iam_client): + """Test that the IAM client can successfully make an API call.""" + result = iam_client.post("/iam/getUser", data={}) + assert "user" in result + + def test_iam_group_create(module_args): """Test creating a new IAM group with real API calls.""" # Step 1: Create the group From 7300d8f8b568a2c93bdc52297285af5fc41e7c10 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:22:15 -0500 Subject: [PATCH 22/31] Add 404 squelch to deleteGroup Signed-off-by: Webster Mudge --- plugins/module_utils/cdp_iam.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index bf9277fb..53947ed4 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -794,6 +794,7 @@ def delete_group(self, group_name: str) -> Dict[str, Any]: return self.api_client.post( "/api/v1/iam/deleteGroup", json_data=json_data, + squelch={404: {}}, ) def update_group( From b9ef3e28a3f7f7a8363272240f52b3f90840a901 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:22:44 -0500 Subject: [PATCH 23/31] Remove comment for IAM endpoint URL Signed-off-by: Webster Mudge --- plugins/module_utils/common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index b1848498..aff9cf48 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -215,7 +215,6 @@ def __init__( self.endpoint_region: str = region # NOTE: If endpoint is not provided, construct the endpoint parameter from the region - # NOTE: IAM endpoints for us-west-1 need to be explicitly set to iamapi.us-west-1.altus.cloudera.com if self.endpoint is None: self.endpoint = f"https://api.{self.endpoint_region}.cdp.cloudera.com" From cac492b4b3ebe8c0347eac3e56dc507e16cd0898 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:23:17 -0500 Subject: [PATCH 24/31] Update TestRestClient HTTP methods to handle credential headers Signed-off-by: Webster Mudge --- tests/unit/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 803974f1..409ffbb4 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -79,14 +79,17 @@ def set_credential_headers(self, method:str, url:str): ) def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: + # Prepare query parameters if params: path += "?" + urlencode(params) url = f"{self.endpoint}/{path.strip('/')}" + self.set_credential_headers(method="GET", url=url) + response:HTTPResponse = Request().get( url=url, - headers=self.set_credential_headers(method="GET", url=url), + headers=self.headers ) if response: @@ -102,8 +105,6 @@ def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any] return {} def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: - url = f"{self.endpoint}/{path.strip('/')}" - # Prepare request body body = None if json_data is not None: @@ -111,16 +112,21 @@ def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[st elif data is not None: body = json.dumps(data) + url = f"{self.endpoint}/{path.strip('/')}" + self.set_credential_headers(method="POST", url=url) try: - response:HTTPResponse = self.request.post( + response:HTTPResponse = Request().post( url=url, headers=self.headers, data=body, ) except HTTPError as e: - raise e + if e.code in squelch: + return squelch[e.code] + else: + raise if response: response_text = response.read().decode("utf-8") From 80c4357800552cdee9f19700ae848673b0b80424 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:24:05 -0500 Subject: [PATCH 25/31] Update iam_group integration tests to use fixture closures for asset lifecycle management Signed-off-by: Webster Mudge --- .../modules/iam_group/test_iam_group_int.py | 184 ++++++++++-------- 1 file changed, 102 insertions(+), 82 deletions(-) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py index 4a7ac098..bd0127c8 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -22,6 +22,8 @@ import pytest import uuid +from typing import Callable, Generator + from ansible_collections.cloudera.cloud.tests.unit import ( AnsibleFailJson, AnsibleExitJson, @@ -43,34 +45,60 @@ pytestmark = pytest.mark.integration_api +@pytest.fixture +def iam_client(cdp_rest_client) -> CdpIamClient: + """Fixture to provide an IAM client for tests.""" + return CdpIamClient(api_client=cdp_rest_client) +@pytest.fixture +def iam_group_delete(iam_client) -> Generator[Callable[[str], None], None, None]: + """Fixture to clean up IAM groups created during tests.""" -# @pytest.fixture -# def iam_group_cleanup(): -# """Fixture to clean up IAM groups created during tests.""" - -# group_names = [] + group_names = [] -# def _iam_group_module(name:str): -# group_names.append(name) -# return + def _iam_group_module(name: str): + group_names.append(name) + return -# yield _iam_group_module + yield _iam_group_module + + for name in group_names: + try: + iam_client.delete_group(group_name=name) + except Exception as e: + pytest.fail(f"Failed to clean up IAM group: {name}. {e}") -# for name in group_names: -# try: +@pytest.fixture +def iam_group_create(iam_client, iam_group_delete) -> Callable[[str], None]: + """Fixture to clean up IAM groups created during tests.""" -def test_iam_user(iam_client): + def _iam_group_module(name: str, sync: bool = False): + iam_group_delete(name) + iam_client.create_group(group_name=name, sync_membership_on_user_login=sync) + return + + return _iam_group_module + +@pytest.mark.skip("Utility test, not part of main suite") +def test_iam_user(cdp_rest_client, iam_client): """Test that the IAM client can successfully make an API call.""" - result = iam_client.post("/iam/getUser", data={}) - assert "user" in result + + rest_result = cdp_rest_client.post("/iam/getUser", data={}) + assert "user" in rest_result + + iam_result = iam_client.get_user() + assert iam_result -def test_iam_group_create(module_args): +def test_iam_group_create(module_args, iam_group_delete): """Test creating a new IAM group with real API calls.""" - # Step 1: Create the group + + # Ensure cleanup after the test + iam_group_delete(GROUP_NAME) + + # Execute function module_args( { "endpoint": BASE_URL, @@ -82,89 +110,81 @@ def test_iam_group_create(module_args): }, ) - try: - with pytest.raises(AnsibleExitJson) as result: - iam_group.main() + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + assert "crn" in result.value.group + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + assert "crn" in result.value.group + + +def test_iam_group_delete(module_args, iam_group_create): + """Test deleting an IAM group with real API calls.""" - assert result.value.changed is True - assert result.value.group["groupName"] == GROUP_NAME - assert result.value.group["syncMembershipOnUserLogin"] is True - assert "crn" in result.value.group + # Create the group to be deleted + iam_group_create(name=GROUP_NAME, sync=True) - finally: - # Cleanup: Delete the test group - cleanup_module_args = { + # Execute function + module_args( + { "endpoint": BASE_URL, "access_key": ACCESS_KEY, "private_key": PRIVATE_KEY, "name": GROUP_NAME, "state": "absent", - } - try: - module_args(cleanup_module_args) - with pytest.raises(AnsibleExitJson): - iam_group.main() - except Exception: - pass + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + assert result.value.changed is False -def test_iam_group_update_and_delete(module_args): - """Test creating, updating, and deleting an IAM group with real API calls.""" - unique_group = f"test-group-update-{uuid.uuid4().hex[:8]}" - # Step 1: Create the group +def test_iam_group_update(module_args, iam_group_create): + """Test updating an IAM group with real API calls.""" + + # Create the group to be updated + iam_group_create(name=GROUP_NAME, sync=False) + + # Execute function module_args( { "endpoint": BASE_URL, "access_key": ACCESS_KEY, "private_key": PRIVATE_KEY, - "name": unique_group, + "name": GROUP_NAME, "state": "present", - "sync": True, + "sync": True, # Update sync setting to True }, ) - try: - with pytest.raises(AnsibleExitJson) as result: - iam_group.main() - - assert result.value.changed is True - assert result.value.group["groupName"] == unique_group - assert result.value.group["syncMembershipOnUserLogin"] is True - - # Step 2: Update the sync setting - module_args( - { - "endpoint": BASE_URL, - "access_key": ACCESS_KEY, - "private_key": PRIVATE_KEY, - "name": unique_group, - "state": "present", - "sync": False, - }, - ) - - with pytest.raises(AnsibleExitJson) as result: - iam_group.main() - - assert result.value.changed is True - assert result.value.group["syncMembershipOnUserLogin"] is False - - finally: - # Step 3: Delete the group - module_args( - { - "endpoint": BASE_URL, - "access_key": ACCESS_KEY, - "private_key": PRIVATE_KEY, - "name": unique_group, - "state": "absent", - }, - ) + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() - try: - with pytest.raises(AnsibleExitJson) as result: - iam_group.main() - assert result.value.changed is True - except Exception: - pass + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert result.value.group["syncMembershipOnUserLogin"] is True + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert result.value.group["syncMembershipOnUserLogin"] is True From 29bec711135937afa9962a25a5461221c111c638 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:57:15 -0500 Subject: [PATCH 26/31] Rename TestRestClient and API client fixtures Signed-off-by: Webster Mudge --- tests/unit/conftest.py | 8 ++++---- .../test_consumption_api.py | 4 ++-- .../plugins/module_utils/cdp_iam/test_iam_api.py | 1 + .../cdp_iam/test_iam_group_integration.py | 16 ++++++++-------- .../modules/iam_group/test_iam_group_int.py | 8 ++++---- .../iam_group_info/test_iam_group_info.py | 4 ++-- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 744c9f55..c29a05f2 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -34,7 +34,7 @@ from ansible_collections.cloudera.cloud.tests.unit import ( AnsibleFailJson, AnsibleExitJson, - TestRestClient, + TestCdpClient, ) from ansible_collections.cloudera.cloud.plugins.module_utils.cdp_client import ( @@ -156,7 +156,7 @@ def unset_cdp_env_vars(monkeypatch: MonkeyPatch): @pytest.fixture() -def api_client(module_creds: dict[str, str], mock_ansible_module: Mock) -> AnsibleCdpClient: +def ansible_cdp_client(module_creds: dict[str, str], mock_ansible_module: Mock) -> AnsibleCdpClient: """Fixture for creating an Ansible API client instance.""" return AnsibleCdpClient( @@ -167,11 +167,11 @@ def api_client(module_creds: dict[str, str], mock_ansible_module: Mock) -> Ansib ) @pytest.fixture(scope="session") -def cdp_rest_client() -> TestRestClient: +def test_cdp_client() -> TestCdpClient: if "CDP_ACCESS_KEY_ID" not in os.environ or "CDP_PRIVATE_KEY" not in os.environ: pytest.skip("CDP API credentials not set in env vars. Skipping integration tests.") - return TestRestClient( + return TestCdpClient( endpoint=os.getenv("CDP_API_ENDPOINT"), # pyright: ignore[reportArgumentType] access_key=os.getenv("CDP_ACCESS_KEY_ID"), # pyright: ignore[reportArgumentType] private_key=os.getenv("CDP_PRIVATE_KEY"), # pyright: ignore[reportArgumentType] diff --git a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py index 4c5c8b56..61408d3a 100644 --- a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py +++ b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py @@ -140,11 +140,11 @@ def test_list_compute_usage_records_pagination(self, mocker): class TestCdpConsumptionClientIntegration: """Integration tests for CdpConsumptionClient.""" - def test_list_compute_usage_records(self, api_client): + def test_list_compute_usage_records(self, ansible_cdp_client): """Test listing compute usage records.""" # Create the CdpConsumptionClient instance - client = CdpConsumptionClient(api_client=api_client) + client = CdpConsumptionClient(api_client=ansible_cdp_client) response = client.list_compute_usage_records( from_timestamp=FROM_TIMESTAMP, diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py index 85a120b1..2a504a80 100644 --- a/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_api.py @@ -200,6 +200,7 @@ def test_delete_group(self, mocker): json_data={ "groupName": "old-team", }, + squelch={404: {}}, ) def test_update_group(self, mocker): diff --git a/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py index 95d360d6..2f1a1ad2 100644 --- a/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py +++ b/tests/unit/plugins/module_utils/cdp_iam/test_iam_group_integration.py @@ -35,10 +35,10 @@ class TestIamGroupIntegration: """Integration tests for CdpIamClient group management.""" - def test_create_update_delete_group_lifecycle(self, api_client): + def test_create_update_delete_group_lifecycle(self, ansible_cdp_client): """Integration test for complete group lifecycle: create, update, delete.""" - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) test_group_name = "test-integration-group" @@ -82,10 +82,10 @@ def test_create_update_delete_group_lifecycle(self, api_client): pass raise e - def test_group_membership_management(self, api_client): + def test_group_membership_management(self, ansible_cdp_client): """Integration test for adding and removing users from a group.""" - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) test_group_name = "test-membership-group" @@ -131,10 +131,10 @@ def test_group_membership_management(self, api_client): except: pass - def test_group_role_assignment(self, api_client): + def test_group_role_assignment(self, ansible_cdp_client): """Integration test for assigning and unassigning roles to/from a group.""" - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) test_group_name = "test-role-assignment-group" @@ -185,10 +185,10 @@ def test_group_role_assignment(self, api_client): except: pass - def test_machine_user_group_membership(self, api_client): + def test_machine_user_group_membership(self, ansible_cdp_client): """Integration test for adding and removing machine users from a group.""" - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) test_group_name = "test-machine-user-group" diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py index bd0127c8..c4eaaeb0 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -46,9 +46,9 @@ @pytest.fixture -def iam_client(cdp_rest_client) -> CdpIamClient: +def iam_client(test_cdp_client) -> CdpIamClient: """Fixture to provide an IAM client for tests.""" - return CdpIamClient(api_client=cdp_rest_client) + return CdpIamClient(api_client=test_cdp_client) @pytest.fixture @@ -82,10 +82,10 @@ def _iam_group_module(name: str, sync: bool = False): return _iam_group_module @pytest.mark.skip("Utility test, not part of main suite") -def test_iam_user(cdp_rest_client, iam_client): +def test_iam_user(test_cdp_client, iam_client): """Test that the IAM client can successfully make an API call.""" - rest_result = cdp_rest_client.post("/iam/getUser", data={}) + rest_result = test_cdp_client.post("/iam/getUser", data={}) assert "user" in rest_result iam_result = iam_client.get_user() diff --git a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py index 1137265a..6f84cdcc 100644 --- a/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py +++ b/tests/unit/plugins/modules/iam_group_info/test_iam_group_info.py @@ -119,11 +119,11 @@ def test_list_groups_with_filter(self, mocker): class TestCdpIamClientIntegration: """Integration tests for CdpIamClient.""" - def test_list_groups(self, api_client): + def test_list_groups(self, ansible_cdp_client): """Integration test for listing IAM groups.""" # Create the CdpIamClient instance - client = CdpIamClient(api_client=api_client) + client = CdpIamClient(api_client=ansible_cdp_client) response = client.list_groups() From d991fe0116f666d25c67d71ecb23faae81faf4ad Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 11:57:45 -0500 Subject: [PATCH 27/31] Convert HTTP response parsing into decorator Signed-off-by: Webster Mudge --- tests/unit/__init__.py | 75 +++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 409ffbb4..b9ac3aa5 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -17,6 +17,7 @@ import json from email.utils import formatdate +from functools import wraps from typing import Any, Dict from urllib.parse import urlencode from urllib.error import HTTPError @@ -56,10 +57,36 @@ def __getattr__(self, attr): return self.__dict__[attr] -class TestRestClient(CdpClient): +def handle_response(func): + """Decorator to handle HTTP response parsing and error squelching.""" + @wraps(func) + def wrapper(*args, **kwargs): + squelch = kwargs.get('squelch', {}) + try: + response: HTTPResponse = func(*args, **kwargs) + if response: + response_text = response.read().decode("utf-8") + if response_text: + try: + return json.loads(response_text) + except json.JSONDecodeError: + return {"response": response_text} + else: + return {} + else: + return {} + except HTTPError as e: + if e.code in squelch: + return squelch[e.code] + else: + raise + return wrapper + + +class TestCdpClient(CdpClient): def __init__(self, endpoint: str, access_key:str, private_key:str, default_page_size: int = 100): super().__init__(default_page_size) - self.request = Request(http_agent="PytestRestClient/1.0") + self.request = Request(http_agent="TestCdpClient/1.0") self.endpoint = endpoint.rstrip("/") self.access_key = access_key self.private_key = private_key @@ -78,6 +105,7 @@ def set_credential_headers(self, method:str, url:str): self.private_key, ) + @handle_response def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: # Prepare query parameters if params: @@ -87,23 +115,12 @@ def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any] self.set_credential_headers(method="GET", url=url) - response:HTTPResponse = Request().get( + return Request().get( url=url, headers=self.headers ) - - if response: - response_text = response.read().decode("utf-8") - if response_text: - try: - return json.loads(response_text) - except json.JSONDecodeError: - return {"response": response_text} - else: - return {} - else: - return {} + @handle_response def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: # Prepare request body body = None @@ -116,29 +133,11 @@ def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[st self.set_credential_headers(method="POST", url=url) - try: - response:HTTPResponse = Request().post( - url=url, - headers=self.headers, - data=body, - ) - except HTTPError as e: - if e.code in squelch: - return squelch[e.code] - else: - raise - - if response: - response_text = response.read().decode("utf-8") - if response_text: - try: - return json.loads(response_text) - except json.JSONDecodeError: - return {"response": response_text} - else: - return {} - else: - return {} + return Request().post( + url=url, + headers=self.headers, + data=body, + ) def put(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: return {} From 61bbcbb7a66e0a61529ac9637e44ada8fdc38ec2 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 12:22:00 -0500 Subject: [PATCH 28/31] Consolidate header and body code into functions Signed-off-by: Webster Mudge --- tests/unit/__init__.py | 93 ++++++++++++++++++++++++++++-------------- 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index b9ac3aa5..13b02a09 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -83,6 +83,32 @@ def wrapper(*args, **kwargs): return wrapper +def set_credential_headers(method:str, url:str, access_key:str, private_key:str) -> Dict: + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + headers["x-altus-date"] = formatdate(usegmt=True) + headers["x-altus-auth"] = make_signature_header( + method, + url, + headers, + access_key, + private_key, + ) + + return headers + + +def prepare_body(data:Dict[str, Any]|None = None, json_data:Dict[str, Any]|None = None) -> str|None: + if json_data is not None: + return json.dumps(json_data) + elif data is not None: + return urlencode(data) + else: + return None + + class TestCdpClient(CdpClient): def __init__(self, endpoint: str, access_key:str, private_key:str, default_page_size: int = 100): super().__init__(default_page_size) @@ -90,20 +116,7 @@ def __init__(self, endpoint: str, access_key:str, private_key:str, default_page_ self.endpoint = endpoint.rstrip("/") self.access_key = access_key self.private_key = private_key - self.headers = { - "Content-Type": "application/json", - "Accept": "application/json", - } - - def set_credential_headers(self, method:str, url:str): - self.headers["x-altus-date"] = formatdate(usegmt=True) - self.headers["x-altus-auth"] = make_signature_header( - method, - url, - self.headers, - self.access_key, - self.private_key, - ) + @handle_response def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: @@ -113,34 +126,54 @@ def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any] url = f"{self.endpoint}/{path.strip('/')}" - self.set_credential_headers(method="GET", url=url) - return Request().get( url=url, - headers=self.headers + headers=set_credential_headers( + method="GET", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), ) @handle_response def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: - # Prepare request body - body = None - if json_data is not None: - body = json.dumps(json_data) - elif data is not None: - body = json.dumps(data) - url = f"{self.endpoint}/{path.strip('/')}" - self.set_credential_headers(method="POST", url=url) - return Request().post( url=url, - headers=self.headers, - data=body, + headers=set_credential_headers( + method="POST", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + data=prepare_body(data, json_data), ) def put(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: - return {} + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().put( + url=url, + headers=set_credential_headers( + method="PUT", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + data=prepare_body(data, json_data), + ) def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: - return {} + url = f"{self.endpoint}/{path.strip('/')}" + + return Request().delete( + url=url, + headers=set_credential_headers( + method="DELETE", + url=url, + access_key=self.access_key, + private_key=self.private_key, + ), + ) From cd49314af4595d666e8a5c1ebc72e9aa332cc418 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 12:29:40 -0500 Subject: [PATCH 29/31] Update linting Signed-off-by: Webster Mudge --- plugins/module_utils/cdp_consumption.py | 2 +- plugins/module_utils/cdp_iam.py | 2 +- tests/unit/__init__.py | 47 +++++++++++++++---- tests/unit/conftest.py | 18 +++++-- .../modules/iam_group/test_iam_group_int.py | 5 +- 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/plugins/module_utils/cdp_consumption.py b/plugins/module_utils/cdp_consumption.py index b7ccecdc..610cc35b 100644 --- a/plugins/module_utils/cdp_consumption.py +++ b/plugins/module_utils/cdp_consumption.py @@ -25,7 +25,7 @@ ) -class CdpConsumptionClient(): +class CdpConsumptionClient: """CDP Consumption API client.""" def __init__(self, api_client: CdpClient): diff --git a/plugins/module_utils/cdp_iam.py b/plugins/module_utils/cdp_iam.py index 53947ed4..d069071b 100644 --- a/plugins/module_utils/cdp_iam.py +++ b/plugins/module_utils/cdp_iam.py @@ -25,7 +25,7 @@ ) -class CdpIamClient(): +class CdpIamClient: """CDP IAM API client.""" def __init__(self, api_client: CdpClient): diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 13b02a09..748ff420 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -59,9 +59,10 @@ def __getattr__(self, attr): def handle_response(func): """Decorator to handle HTTP response parsing and error squelching.""" + @wraps(func) def wrapper(*args, **kwargs): - squelch = kwargs.get('squelch', {}) + squelch = kwargs.get("squelch", {}) try: response: HTTPResponse = func(*args, **kwargs) if response: @@ -80,10 +81,16 @@ def wrapper(*args, **kwargs): return squelch[e.code] else: raise + return wrapper -def set_credential_headers(method:str, url:str, access_key:str, private_key:str) -> Dict: +def set_credential_headers( + method: str, + url: str, + access_key: str, + private_key: str, +) -> Dict: headers = { "Content-Type": "application/json", "Accept": "application/json", @@ -100,7 +107,10 @@ def set_credential_headers(method:str, url:str, access_key:str, private_key:str) return headers -def prepare_body(data:Dict[str, Any]|None = None, json_data:Dict[str, Any]|None = None) -> str|None: +def prepare_body( + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, +) -> str | None: if json_data is not None: return json.dumps(json_data) elif data is not None: @@ -110,13 +120,18 @@ def prepare_body(data:Dict[str, Any]|None = None, json_data:Dict[str, Any]|None class TestCdpClient(CdpClient): - def __init__(self, endpoint: str, access_key:str, private_key:str, default_page_size: int = 100): + def __init__( + self, + endpoint: str, + access_key: str, + private_key: str, + default_page_size: int = 100, + ): super().__init__(default_page_size) self.request = Request(http_agent="TestCdpClient/1.0") self.endpoint = endpoint.rstrip("/") self.access_key = access_key self.private_key = private_key - @handle_response def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: @@ -135,9 +150,15 @@ def get(self, path: str, params: Dict[str, Any] | None = None) -> Dict[str, Any] private_key=self.private_key, ), ) - + @handle_response - def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + def post( + self, + path: str, + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: url = f"{self.endpoint}/{path.strip('/')}" return Request().post( @@ -150,8 +171,14 @@ def post(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[st ), data=prepare_body(data, json_data), ) - - def put(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str, Any] | None = None, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: + + def put( + self, + path: str, + data: Dict[str, Any] | None = None, + json_data: Dict[str, Any] | None = None, + squelch: Dict[int, Any] = {}, + ) -> Dict[str, Any]: url = f"{self.endpoint}/{path.strip('/')}" return Request().put( @@ -164,7 +191,7 @@ def put(self, path: str, data: Dict[str, Any] | None = None, json_data: Dict[str ), data=prepare_body(data, json_data), ) - + def delete(self, path: str, squelch: Dict[int, Any] = {}) -> Dict[str, Any]: url = f"{self.endpoint}/{path.strip('/')}" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c29a05f2..624ed810 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -156,7 +156,10 @@ def unset_cdp_env_vars(monkeypatch: MonkeyPatch): @pytest.fixture() -def ansible_cdp_client(module_creds: dict[str, str], mock_ansible_module: Mock) -> AnsibleCdpClient: +def ansible_cdp_client( + module_creds: dict[str, str], + mock_ansible_module: Mock, +) -> AnsibleCdpClient: """Fixture for creating an Ansible API client instance.""" return AnsibleCdpClient( @@ -166,13 +169,18 @@ def ansible_cdp_client(module_creds: dict[str, str], mock_ansible_module: Mock) private_key=module_creds["private_key"], ) + @pytest.fixture(scope="session") def test_cdp_client() -> TestCdpClient: if "CDP_ACCESS_KEY_ID" not in os.environ or "CDP_PRIVATE_KEY" not in os.environ: - pytest.skip("CDP API credentials not set in env vars. Skipping integration tests.") + pytest.skip( + "CDP API credentials not set in env vars. Skipping integration tests.", + ) return TestCdpClient( - endpoint=os.getenv("CDP_API_ENDPOINT"), # pyright: ignore[reportArgumentType] - access_key=os.getenv("CDP_ACCESS_KEY_ID"), # pyright: ignore[reportArgumentType] - private_key=os.getenv("CDP_PRIVATE_KEY"), # pyright: ignore[reportArgumentType] + endpoint=os.getenv("CDP_API_ENDPOINT"), # pyright: ignore[reportArgumentType] + access_key=os.getenv( + "CDP_ACCESS_KEY_ID", + ), # pyright: ignore[reportArgumentType] + private_key=os.getenv("CDP_PRIVATE_KEY"), # pyright: ignore[reportArgumentType] ) diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py index c4eaaeb0..7f7bf2e2 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -60,7 +60,7 @@ def iam_group_delete(iam_client) -> Generator[Callable[[str], None], None, None] def _iam_group_module(name: str): group_names.append(name) return - + yield _iam_group_module for name in group_names: @@ -81,6 +81,7 @@ def _iam_group_module(name: str, sync: bool = False): return _iam_group_module + @pytest.mark.skip("Utility test, not part of main suite") def test_iam_user(test_cdp_client, iam_client): """Test that the IAM client can successfully make an API call.""" @@ -171,7 +172,7 @@ def test_iam_group_update(module_args, iam_group_create): "private_key": PRIVATE_KEY, "name": GROUP_NAME, "state": "present", - "sync": True, # Update sync setting to True + "sync": True, # Update sync setting to True }, ) From 4cb999a19a9a2e3502114732e301817e960780a0 Mon Sep 17 00:00:00 2001 From: Webster Mudge Date: Thu, 11 Dec 2025 12:39:42 -0500 Subject: [PATCH 30/31] Add pytest mark for slow tests Signed-off-by: Webster Mudge --- pyproject.toml | 1 + .../module_utils/cdp_client_consumption/test_consumption_api.py | 1 + 2 files changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 426a8b37..d0c5dbdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ filterwarnings = [ markers = [ "integration_api: marks tests as integration tests using CDP API credentials", "integration_token: marks tests as integration tests using CDP token credentials", + "slow: marks tests as slow tests", ] [build-system] diff --git a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py index 61408d3a..22f5f0d4 100644 --- a/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py +++ b/tests/unit/plugins/module_utils/cdp_client_consumption/test_consumption_api.py @@ -140,6 +140,7 @@ def test_list_compute_usage_records_pagination(self, mocker): class TestCdpConsumptionClientIntegration: """Integration tests for CdpConsumptionClient.""" + @pytest.mark.slow def test_list_compute_usage_records(self, ansible_cdp_client): """Test listing compute usage records.""" From eac93ab99e698e663dbf05b1a783ccecfe03988b Mon Sep 17 00:00:00 2001 From: rsuplina Date: Mon, 15 Dec 2025 09:45:26 +0000 Subject: [PATCH 31/31] Update IAM Group logic, add integration test Signed-off-by: rsuplina --- plugins/modules/iam_group.py | 16 ++-- .../modules/iam_group/test_iam_group_int.py | 86 +++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/plugins/modules/iam_group.py b/plugins/modules/iam_group.py index e255b775..3a57b9b3 100644 --- a/plugins/modules/iam_group.py +++ b/plugins/modules/iam_group.py @@ -291,16 +291,14 @@ def process(self): if self.state == "present": # Create if not current_group: - if self.module.check_mode: - self.group = {"groupName": self.name} - else: + if not self.module.check_mode: response = self.client.create_group( group_name=self.name, sync_membership_on_user_login=self.sync, ) self.group = response.get("group", {}) + current_group = self.client.get_group_details(group_name=self.name) self.changed = True - current_group = self.client.get_group_details(group_name=self.name) # Reconcile if not self.module.check_mode and current_group: @@ -316,7 +314,7 @@ def process(self): if self.client.manage_group_users( group_name=self.name, current_members=current_group.get("members", []), - desired_users=self.users if self.users is not None else [], + desired_users=self.users or [], purge=self.purge, ): self.changed = True @@ -325,7 +323,7 @@ def process(self): if self.client.manage_group_roles( group_name=self.name, current_roles=current_group.get("roles", []), - desired_roles=self.roles if self.roles is not None else [], + desired_roles=self.roles or [], purge=self.purge, ): self.changed = True @@ -337,11 +335,7 @@ def process(self): "resourceAssignments", [], ), - desired_assignments=( - self.resource_roles - if self.resource_roles is not None - else [] - ), + desired_assignments=(self.resource_roles or []), purge=self.purge, ): self.changed = True diff --git a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py index 7f7bf2e2..9948a52d 100644 --- a/tests/unit/plugins/modules/iam_group/test_iam_group_int.py +++ b/tests/unit/plugins/modules/iam_group/test_iam_group_int.py @@ -189,3 +189,89 @@ def test_iam_group_update(module_args, iam_group_create): assert result.value.changed is False assert result.value.group["syncMembershipOnUserLogin"] is True + + +def test_iam_group_roles_update(module_args, iam_group_delete): + """Test updating IAM group roles with real API calls.""" + + # Ensure cleanup after the test + iam_group_delete(GROUP_NAME) + + # Create group with initial roles + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "roles": [ + "crn:altus:iam:us-west-1:altus:role:BillingAdmin", + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator", + ], + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert len(result.value.group["roles"]) == 2 + assert ( + "crn:altus:iam:us-west-1:altus:role:BillingAdmin" in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator" + in result.value.group["roles"] + ) + + # Update roles - add three new roles + module_args( + { + "endpoint": BASE_URL, + "access_key": ACCESS_KEY, + "private_key": PRIVATE_KEY, + "name": GROUP_NAME, + "state": "present", + "roles": [ + "crn:altus:iam:us-west-1:altus:role:DFCatalogAdmin", + "crn:altus:iam:us-west-1:altus:role:DFCatalogPublisher", + "crn:altus:iam:us-west-1:altus:role:DFCatalogViewer", + ], + }, + ) + + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is True + assert result.value.group["groupName"] == GROUP_NAME + assert len(result.value.group["roles"]) == 5 + # Verify all roles are present + assert ( + "crn:altus:iam:us-west-1:altus:role:BillingAdmin" in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:ClassicClustersCreator" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogAdmin" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogPublisher" + in result.value.group["roles"] + ) + assert ( + "crn:altus:iam:us-west-1:altus:role:DFCatalogViewer" + in result.value.group["roles"] + ) + + # Idempotency check + with pytest.raises(AnsibleExitJson) as result: + iam_group.main() + + assert result.value.changed is False + assert len(result.value.group["roles"]) == 5