From 5de95f4d0267545e2eb95486b1585d79d25afc2c Mon Sep 17 00:00:00 2001 From: jeremyrobertson Date: Wed, 21 Jun 2023 03:49:49 -0600 Subject: [PATCH] Identitystore - Partial implementation (#6411) --- moto/identitystore/exceptions.py | 36 ++ moto/identitystore/models.py | 259 +++++++- moto/identitystore/responses.py | 143 +++++ tests/__init__.py | 1 - .../test_cloudformation_stack_crud_boto3.py | 1 + .../test_cloudformation_stack_integration.py | 3 + .../test_identitystore/test_identitystore.py | 594 +++++++++++++++++- tests/test_redshiftdata/test_server.py | 1 + 8 files changed, 1029 insertions(+), 9 deletions(-) diff --git a/moto/identitystore/exceptions.py b/moto/identitystore/exceptions.py index 65060b557..87d46ff0a 100644 --- a/moto/identitystore/exceptions.py +++ b/moto/identitystore/exceptions.py @@ -1 +1,37 @@ """Exceptions raised by the identitystore service.""" +import json + +from moto.core.exceptions import AWSError +from typing import Any + + +request_id = "178936da-50ad-4d58-8871-22d9979e8658example" + + +class IdentityStoreError(AWSError): + def __init__(self, **kwargs: Any): + super(AWSError, self).__init__(error_type=self.TYPE, message=kwargs["message"]) # type: ignore + self.description: str = json.dumps( + { + "__type": self.error_type, + "RequestId": request_id, + "Message": self.message, + "ResourceType": kwargs.get("resource_type"), + "Reason": kwargs.get("reason"), + } + ) + + +class ResourceNotFoundException(IdentityStoreError): + TYPE = "ResourceNotFoundException" + code = 400 + + +class ValidationException(IdentityStoreError): + TYPE = "ValidationException" + code = 400 + + +class ConflictException(IdentityStoreError): + TYPE = "ConflictException" + code = 400 diff --git a/moto/identitystore/models.py b/moto/identitystore/models.py index 397150095..f20314631 100644 --- a/moto/identitystore/models.py +++ b/moto/identitystore/models.py @@ -1,19 +1,98 @@ -from typing import Dict, Tuple +from typing import Dict, Tuple, List, Any, NamedTuple, Optional +from typing_extensions import Self +from moto.utilities.paginator import paginate + +from botocore.exceptions import ParamValidationError from moto.moto_api._internal import mock_random from moto.core import BaseBackend, BackendDict +from .exceptions import ( + ResourceNotFoundException, + ValidationException, + ConflictException, +) +import warnings + + +class Name(NamedTuple): + Formatted: Optional[str] + FamilyName: Optional[str] + GivenName: Optional[str] + MiddleName: Optional[str] + HonorificPrefix: Optional[str] + HonorificSuffix: Optional[str] + + @classmethod + def from_dict(cls, name_dict: Dict[str, str]) -> Optional[Self]: + if not name_dict: + return None + return cls( + name_dict.get("Formatted"), + name_dict.get("FamilyName"), + name_dict.get("GivenName"), + name_dict.get("MiddleName"), + name_dict.get("HonorificPrefix"), + name_dict.get("HonorificSuffix"), + ) + + +class User(NamedTuple): + UserId: str + IdentityStoreId: str + UserName: str + Name: Optional[Name] + DisplayName: str + NickName: str + ProfileUrl: str + Emails: List[Dict[str, str]] + Addresses: List[Dict[str, str]] + PhoneNumbers: List[Dict[str, str]] + UserType: str + Title: str + PreferredLanguage: str + Locale: str + Timezone: str + + +class IdentityStoreData: + def __init__(self) -> None: + self.groups: Dict[str, Dict[str, str]] = {} + self.users: Dict[str, User] = {} + self.group_memberships: Dict[str, Any] = {} class IdentityStoreBackend(BaseBackend): """Implementation of IdentityStore APIs.""" - def __init__(self, region_name: str, account_id: str): + PAGINATION_MODEL = { + "list_group_memberships": { + "input_token": "next_token", + "limit_key": "max_results", + "limit_default": 100, + "unique_attribute": "MembershipId", + }, + } + + def __init__(self, region_name: str, account_id: str) -> None: super().__init__(region_name, account_id) - self.groups: Dict[str, Dict[str, str]] = {} + self.identity_stores: Dict[str, IdentityStoreData] = {} def create_group( self, identity_store_id: str, display_name: str, description: str ) -> Tuple[str, str]: + identity_store = self.__get_identity_store(identity_store_id) + + matching = [ + g + for g in identity_store.groups.values() + if g["DisplayName"] == display_name + ] + if len(matching) > 0: + raise ConflictException( + message="Duplicate GroupDisplayName", + reason="UNIQUENESS_CONSTRAINT_VIOLATION", + ) + group_id = str(mock_random.uuid4()) group_dict = { "GroupId": group_id, @@ -21,8 +100,180 @@ class IdentityStoreBackend(BaseBackend): "DisplayName": display_name, "Description": description, } - self.groups[group_id] = group_dict + identity_store.groups[group_id] = group_dict return group_id, identity_store_id + def get_group_id( + self, identity_store_id: str, alternate_identifier: Dict[str, Any] + ) -> Tuple[str, str]: + identity_store = self.__get_identity_store(identity_store_id) + if "UniqueAttribute" in alternate_identifier: + if ( + "AttributeValue" in alternate_identifier["UniqueAttribute"] + and alternate_identifier["UniqueAttribute"]["AttributePath"].lower() + == "displayname" + ): + for g in identity_store.groups.values(): + if ( + g["DisplayName"] + == alternate_identifier["UniqueAttribute"]["AttributeValue"] + ): + return g["GroupId"], identity_store_id + elif "ExternalId" in alternate_identifier: + warnings.warn("ExternalId has not been implemented.") + + raise ResourceNotFoundException( + message="GROUP not found.", resource_type="GROUP" + ) + + def delete_group(self, identity_store_id: str, group_id: str) -> None: + identity_store = self.__get_identity_store(identity_store_id) + if group_id in identity_store.groups: + del identity_store.groups[group_id] + + def create_user( + self, + identity_store_id: str, + user_name: str, + name: Dict[str, str], + display_name: str, + nick_name: str, + profile_url: str, + emails: List[Dict[str, Any]], + addresses: List[Dict[str, Any]], + phone_numbers: List[Dict[str, Any]], + user_type: str, + title: str, + preferred_language: str, + locale: str, + timezone: str, + ) -> Tuple[str, str]: + identity_store = self.__get_identity_store(identity_store_id) + user_id = str(mock_random.uuid4()) + + new_user = User( + user_id, + identity_store_id, + user_name, + Name.from_dict(name), + display_name, + nick_name, + profile_url, + emails, + addresses, + phone_numbers, + user_type, + title, + preferred_language, + locale, + timezone, + ) + self.__validate_create_user(new_user, identity_store) + + identity_store.users[user_id] = new_user + + return user_id, identity_store_id + + def describe_user(self, identity_store_id: str, user_id: str) -> User: + identity_store = self.__get_identity_store(identity_store_id) + + if user_id in identity_store.users: + return identity_store.users[user_id] + + raise ResourceNotFoundException(message="USER not found.", resource_type="USER") + + def delete_user(self, identity_store_id: str, user_id: str) -> None: + identity_store = self.__get_identity_store(identity_store_id) + + if user_id in identity_store.users: + del identity_store.users[user_id] + + def create_group_membership( + self, identity_store_id: str, group_id: str, member_id: Dict[str, str] + ) -> Tuple[str, str]: + identity_store = self.__get_identity_store(identity_store_id) + user_id = member_id["UserId"] + if user_id not in identity_store.users: + raise ResourceNotFoundException( + message="Member does not exist", resource_type="USER" + ) + + if group_id not in identity_store.groups: + raise ResourceNotFoundException( + message="Group does not exist", resource_type="GROUP" + ) + + membership_id = str(mock_random.uuid4()) + identity_store.group_memberships[membership_id] = { + "IdentityStoreId": identity_store_id, + "MembershipId": membership_id, + "GroupId": group_id, + "MemberId": {"UserId": user_id}, + } + + return membership_id, identity_store_id + + @paginate(pagination_model=PAGINATION_MODEL) # type: ignore + def list_group_memberships( + self, + identity_store_id: str, + group_id: str, + ) -> List[Any]: # type: ignore + identity_store = self.__get_identity_store(identity_store_id) + + return [ + m + for m in identity_store.group_memberships.values() + if m["GroupId"] == group_id + ] + + def delete_group_membership( + self, identity_store_id: str, membership_id: str + ) -> None: + identity_store = self.__get_identity_store(identity_store_id) + if membership_id in identity_store.group_memberships: + del identity_store.group_memberships[membership_id] + + def __get_identity_store(self, store_id: str) -> IdentityStoreData: + if len(store_id) < 1: + raise ParamValidationError( + msg="Invalid length for parameter IdentityStoreId, value: 0, valid min length: 1" + ) + if store_id not in self.identity_stores: + self.identity_stores[store_id] = IdentityStoreData() + return self.identity_stores[store_id] + + def __validate_create_user( + self, new_user: User, identity_store: IdentityStoreData + ) -> None: + if not new_user.UserName: + raise ValidationException(message="userName is a required attribute") + + missing = [] + + if not new_user.DisplayName: + missing.append("displayname") + if not new_user.Name: + missing.append("name") + else: + if not new_user.Name.GivenName: + missing.append("givenname") + if not new_user.Name.FamilyName: + missing.append("familyname") + + if len(missing) > 0: + message = ", ".join( + [f"{att}: The attribute {att} is required" for att in missing] + ) + raise ValidationException(message=message) + + matching = [ + u for u in identity_store.users.values() if u.UserName == new_user.UserName + ] + if len(matching) > 0: + raise ConflictException( + message="Duplicate UserName", reason="UNIQUENESS_CONSTRAINT_VIOLATION" + ) + identitystore_backends = BackendDict(IdentityStoreBackend, "identitystore") diff --git a/moto/identitystore/responses.py b/moto/identitystore/responses.py index 40d764559..4467709ad 100644 --- a/moto/identitystore/responses.py +++ b/moto/identitystore/responses.py @@ -1,5 +1,6 @@ """Handles incoming identitystore requests, invokes methods, returns responses.""" import json +from typing import NamedTuple, Any, Dict, Optional from moto.core.responses import BaseResponse from .models import identitystore_backends, IdentityStoreBackend @@ -26,3 +27,145 @@ class IdentityStoreResponse(BaseResponse): description=description, ) return json.dumps(dict(GroupId=group_id, IdentityStoreId=identity_store_id)) + + def create_group_membership(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + group_id = self._get_param("GroupId") + member_id = self._get_param("MemberId") + ( + membership_id, + identity_store_id, + ) = self.identitystore_backend.create_group_membership( + identity_store_id=identity_store_id, + group_id=group_id, + member_id=member_id, + ) + + return json.dumps( + dict(MembershipId=membership_id, IdentityStoreId=identity_store_id) + ) + + def create_user(self) -> str: + user_id, identity_store_id = self.identitystore_backend.create_user( + self._get_param("IdentityStoreId"), + self._get_param("UserName"), + self._get_param("Name"), + self._get_param("DisplayName"), + self._get_param("NickName"), + self._get_param("ProfileUrl"), + self._get_param("Emails"), + self._get_param("Addresses"), + self._get_param("PhoneNumbers"), + self._get_param("UserType"), + self._get_param("Title"), + self._get_param("PreferredLanguage"), + self._get_param("Locale"), + self._get_param("Timezone"), + ) + return json.dumps(dict(UserId=user_id, IdentityStoreId=identity_store_id)) + + def get_group_id(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + alternate_identifier = self._get_param("AlternateIdentifier") + group_id, identity_store_id = self.identitystore_backend.get_group_id( + identity_store_id=identity_store_id, + alternate_identifier=alternate_identifier, + ) + return json.dumps(dict(GroupId=group_id, IdentityStoreId=identity_store_id)) + + def describe_user(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + user_id = self._get_param("UserId") + ( + user_id, + identity_store_id, + user_name, + name, + display_name, + nick_name, + profile_url, + emails, + addresses, + phone_numbers, + user_type, + title, + preferred_language, + locale, + timezone, + ) = self.identitystore_backend.describe_user( + identity_store_id=identity_store_id, + user_id=user_id, + ) + return json.dumps( + dict( + UserName=user_name, + UserId=user_id, + ExternalIds=None, + Name=self.named_tuple_to_dict(name), + DisplayName=display_name, + NickName=nick_name, + ProfileUrl=profile_url, + Emails=emails, + Addresses=addresses, + PhoneNumbers=phone_numbers, + UserType=user_type, + Title=title, + PreferredLanguage=preferred_language, + Locale=locale, + Timezone=timezone, + IdentityStoreId=identity_store_id, + ) + ) + + def list_group_memberships(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + group_id = self._get_param("GroupId") + max_results = self._get_param("MaxResults") + next_token = self._get_param("NextToken") + ( + group_memberships, + next_token, + ) = self.identitystore_backend.list_group_memberships( + identity_store_id=identity_store_id, + group_id=group_id, + max_results=max_results, + next_token=next_token, + ) + + return json.dumps( + dict(GroupMemberships=group_memberships, NextToken=next_token) + ) + + def delete_group(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + group_id = self._get_param("GroupId") + self.identitystore_backend.delete_group( + identity_store_id=identity_store_id, + group_id=group_id, + ) + return json.dumps(dict()) + + def delete_group_membership(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + membership_id = self._get_param("MembershipId") + self.identitystore_backend.delete_group_membership( + identity_store_id=identity_store_id, + membership_id=membership_id, + ) + return json.dumps(dict()) + + def delete_user(self) -> str: + identity_store_id = self._get_param("IdentityStoreId") + user_id = self._get_param("UserId") + self.identitystore_backend.delete_user( + identity_store_id=identity_store_id, + user_id=user_id, + ) + return json.dumps(dict()) + + def named_tuple_to_dict( + self, value: Optional[NamedTuple] + ) -> Optional[Dict[str, Any]]: + if value: + return value._asdict() + return None diff --git a/tests/__init__.py b/tests/__init__.py index e938a957f..82e3d494d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,6 +1,5 @@ import logging -from . import helpers # noqa # Disable extra logging for tests logging.getLogger("boto").setLevel(logging.CRITICAL) diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index dcf59aa0c..9fa6bef0a 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -22,6 +22,7 @@ from moto.cloudformation import cloudformation_backends from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID from tests import EXAMPLE_AMI_ID +from tests.helpers import match_dict # noqa # pylint: disable=unused-import dummy_template = { "AWSTemplateFormatVersion": "2010-09-09", diff --git a/tests/test_cloudformation/test_cloudformation_stack_integration.py b/tests/test_cloudformation/test_cloudformation_stack_integration.py index 35f023da7..26326b3fd 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_integration.py +++ b/tests/test_cloudformation/test_cloudformation_stack_integration.py @@ -29,6 +29,9 @@ from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID from tests import EXAMPLE_AMI_ID, EXAMPLE_AMI_ID2 from tests.markers import requires_docker from tests.test_cloudformation.fixtures import fn_join, single_instance_with_ebs_volume +from tests.helpers import ( # noqa # pylint: disable=unused-import + containing_item_with_attributes, +) @mock_cloudformation diff --git a/tests/test_identitystore/test_identitystore.py b/tests/test_identitystore/test_identitystore.py index 440889e06..ad7520d37 100644 --- a/tests/test_identitystore/test_identitystore.py +++ b/tests/test_identitystore/test_identitystore.py @@ -1,20 +1,28 @@ """Unit tests for identitystore-supported APIs.""" -from uuid import UUID +import random +import string +from uuid import UUID, uuid4 import boto3 -import sure # noqa # pylint: disable=unused-import +import pytest +from botocore.exceptions import ClientError from moto import mock_identitystore +from moto.moto_api._internal import mock_random # See our Development Tips on writing tests for hints on how to write good tests: # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html +def get_identity_store_id() -> str: + return f"d-{random.choices(string.ascii_lowercase, k=10)}" + + @mock_identitystore def test_create_group(): - client = boto3.client("identitystore", region_name="ap-southeast-1") - identity_store_id = "d-9067028cf5" + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() create_resp = client.create_group( IdentityStoreId=identity_store_id, DisplayName="test_group", @@ -22,3 +30,581 @@ def test_create_group(): ) assert create_resp["IdentityStoreId"] == identity_store_id assert UUID(create_resp["GroupId"]) + + +@mock_identitystore +def test_create_group_duplicate_name(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + create_resp = client.create_group( + IdentityStoreId=identity_store_id, + DisplayName="test_group", + Description="description", + ) + assert create_resp["IdentityStoreId"] == identity_store_id + assert UUID(create_resp["GroupId"]) + + with pytest.raises(ClientError) as exc: + client.create_group( + IdentityStoreId=identity_store_id, + DisplayName="test_group", + Description="description", + ) + err = exc.value + assert "ConflictException" in str(type(err)) + assert ( + str(err) + == "An error occurred (ConflictException) when calling the CreateGroup operation: Duplicate GroupDisplayName" + ) + assert err.operation_name == "CreateGroup" + assert err.response["Error"]["Code"] == "ConflictException" + assert err.response["Error"]["Message"] == "Duplicate GroupDisplayName" + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["Message"] == "Duplicate GroupDisplayName" + assert err.response["Reason"] == "UNIQUENESS_CONSTRAINT_VIOLATION" + + +@mock_identitystore +def test_group_multiple_identity_stores(): + identity_store_id = get_identity_store_id() + identity_store_id2 = get_identity_store_id() + client = boto3.client("identitystore", region_name="us-east-2") + group1 = __create_test_group(client, store_id=identity_store_id) + group2 = __create_test_group(client, store_id=identity_store_id2) + + assert __group_exists(client, group1[0], store_id=identity_store_id) + assert not __group_exists(client, group1[0], store_id=identity_store_id2) + + assert __group_exists(client, group2[0], store_id=identity_store_id2) + assert not __group_exists(client, group2[0], store_id=identity_store_id) + + +@mock_identitystore +def test_create_group_membership(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + group_id = client.create_group( + IdentityStoreId=identity_store_id, + DisplayName="test_group", + Description="description", + )["GroupId"] + + user_id = __create_and_verify_sparse_user(client, identity_store_id) + + create_response = client.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_id, + MemberId={"UserId": user_id}, + ) + assert UUID(create_response["MembershipId"]) + assert create_response["IdentityStoreId"] == identity_store_id + + list_response = client.list_group_memberships( + IdentityStoreId=identity_store_id, GroupId=group_id + ) + assert len(list_response["GroupMemberships"]) == 1 + assert ( + list_response["GroupMemberships"][0]["MembershipId"] + == create_response["MembershipId"] + ) + assert list_response["GroupMemberships"][0]["MemberId"]["UserId"] == user_id + + +@mock_identitystore +def test_create_duplicate_username(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + # This should succeed + client.create_user( + IdentityStoreId=identity_store_id, + UserName="deleteme_username", + DisplayName="deleteme_displayname", + Name={"GivenName": "Givenname", "FamilyName": "Familyname"}, + ) + + with pytest.raises(ClientError) as exc: + # This should fail + client.create_user( + IdentityStoreId=identity_store_id, + UserName="deleteme_username", + DisplayName="deleteme_displayname", + Name={"GivenName": "Givenname", "FamilyName": "Familyname"}, + ) + err = exc.value + assert "ConflictException" in str(type(err)) + assert ( + str(err) + == "An error occurred (ConflictException) when calling the CreateUser operation: Duplicate UserName" + ) + assert err.operation_name == "CreateUser" + assert err.response["Error"]["Code"] == "ConflictException" + assert err.response["Error"]["Message"] == "Duplicate UserName" + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["Message"] == "Duplicate UserName" + assert err.response["Reason"] == "UNIQUENESS_CONSTRAINT_VIOLATION" + + +@mock_identitystore +def test_create_username_no_username(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + with pytest.raises(ClientError) as exc: + client.create_user(IdentityStoreId=identity_store_id) + err = exc.value + assert "ValidationException" in str(type(err)) + assert ( + str(err) + == "An error occurred (ValidationException) when calling the CreateUser operation: userName is a required attribute" + ) + assert err.operation_name == "CreateUser" + assert err.response["Error"]["Code"] == "ValidationException" + assert err.response["Error"]["Message"] == "userName is a required attribute" + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["Message"] == "userName is a required attribute" + + +@mock_identitystore +def test_create_username_missing_required_attributes(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + with pytest.raises(ClientError) as exc: + client.create_user( + IdentityStoreId=identity_store_id, UserName="username", Name={} + ) + err = exc.value + assert "ValidationException" in str(type(err)) + assert ( + str(err) + == "An error occurred (ValidationException) when calling the CreateUser operation: displayname: The attribute displayname is required, name: The attribute name is required" + ) + assert err.operation_name == "CreateUser" + assert err.response["Error"]["Code"] == "ValidationException" + assert ( + err.response["Error"]["Message"] + == "displayname: The attribute displayname is required, name: The attribute name is required" + ) + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert ( + err.response["Message"] + == "displayname: The attribute displayname is required, name: The attribute name is required" + ) + + +@mock_identitystore +@pytest.mark.parametrize( + "field, missing", [("GivenName", "familyname"), ("FamilyName", "givenname")] +) +def test_create_username_missing_required_name_field(field, missing): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + with pytest.raises(ClientError) as exc: + client.create_user( + IdentityStoreId=identity_store_id, + UserName="username", + DisplayName="displayName", + Name={field: field}, + ) + err = exc.value + assert "ValidationException" in str(type(err)) + assert ( + str(err) + == f"An error occurred (ValidationException) when calling the CreateUser operation: {missing}: The attribute {missing} is required" + ) + assert err.operation_name == "CreateUser" + assert err.response["Error"]["Code"] == "ValidationException" + assert ( + err.response["Error"]["Message"] + == f"{missing}: The attribute {missing} is required" + ) + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["Message"] == f"{missing}: The attribute {missing} is required" + + +@mock_identitystore +def test_create_describe_sparse_user(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + response = client.create_user( + IdentityStoreId=identity_store_id, + UserName="the_username", + DisplayName="display_name", + Name={"GivenName": "given_name", "FamilyName": "family_name"}, + ) + assert UUID(response["UserId"]) + + user_resp = client.describe_user( + IdentityStoreId=identity_store_id, UserId=response["UserId"] + ) + + assert user_resp["UserName"] == "the_username" + assert user_resp["DisplayName"] == "display_name" + assert "Name" in user_resp + assert user_resp["Name"]["GivenName"] == "given_name" + assert user_resp["Name"]["FamilyName"] == "family_name" + + +@mock_identitystore +def test_create_describe_full_user(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + response = client.create_user( + IdentityStoreId=identity_store_id, + UserName="the_username", + DisplayName="display_name", + Name={ + "Formatted": "formatted_name", + "GivenName": "given_name", + "FamilyName": "family_name", + "MiddleName": "middle_name", + "HonorificPrefix": "The Honorable", + "HonorificSuffix": "Wisest of us all", + }, + NickName="nick_name", + ProfileUrl="https://example.com", + Emails=[ + {"Value": "email1@example.com", "Type": "Personal", "Primary": True}, + {"Value": "email2@example.com", "Type": "Work", "Primary": False}, + ], + Addresses=[ + { + "StreetAddress": "123 Address St.", + "Locality": "locality", + "Region": "region", + "PostalCode": "123456", + "Country": "USA", + "Formatted": "123 Address St.\nlocality, region, 123456", + "Type": "Home", + "Primary": True, + }, + ], + PhoneNumbers=[ + {"Value": "555-456-7890", "Type": "Home", "Primary": True}, + ], + UserType="user_type", + Title="title", + PreferredLanguage="preferred_language", + Locale="locale", + Timezone="timezone", + ) + assert UUID(response["UserId"]) + + user_resp = client.describe_user( + IdentityStoreId=identity_store_id, UserId=response["UserId"] + ) + + assert user_resp["UserName"] == "the_username" + assert user_resp["DisplayName"] == "display_name" + assert "Name" in user_resp + assert user_resp["Name"]["Formatted"] == "formatted_name" + assert user_resp["Name"]["GivenName"] == "given_name" + assert user_resp["Name"]["FamilyName"] == "family_name" + assert user_resp["Name"]["MiddleName"] == "middle_name" + assert user_resp["Name"]["HonorificPrefix"] == "The Honorable" + assert user_resp["Name"]["HonorificSuffix"] == "Wisest of us all" + assert user_resp["NickName"] == "nick_name" + assert user_resp["ProfileUrl"] == "https://example.com" + assert "Emails" in user_resp + assert len(user_resp["Emails"]) == 2 + email1 = user_resp["Emails"][0] + assert email1["Value"] == "email1@example.com" + assert email1["Type"] == "Personal" + assert email1["Primary"] is True + email2 = user_resp["Emails"][1] + assert email2["Value"] == "email2@example.com" + assert email2["Type"] == "Work" + assert email2["Primary"] is False + assert "Addresses" in user_resp + assert len(user_resp["Addresses"]) == 1 + assert user_resp["Addresses"][0]["StreetAddress"] == "123 Address St." + assert user_resp["Addresses"][0]["Locality"] == "locality" + assert user_resp["Addresses"][0]["Region"] == "region" + assert user_resp["Addresses"][0]["PostalCode"] == "123456" + assert user_resp["Addresses"][0]["Country"] == "USA" + assert ( + user_resp["Addresses"][0]["Formatted"] + == "123 Address St.\nlocality, region, 123456" + ) + assert user_resp["Addresses"][0]["Type"] == "Home" + assert user_resp["Addresses"][0]["Primary"] is True + assert "PhoneNumbers" in user_resp + assert len(user_resp["PhoneNumbers"]) == 1 + assert user_resp["PhoneNumbers"][0]["Value"] == "555-456-7890" + assert user_resp["PhoneNumbers"][0]["Type"] == "Home" + assert user_resp["PhoneNumbers"][0]["Primary"] is True + assert user_resp["UserType"] == "user_type" + assert user_resp["Title"] == "title" + assert user_resp["PreferredLanguage"] == "preferred_language" + assert user_resp["Locale"] == "locale" + assert user_resp["Timezone"] == "timezone" + + +@mock_identitystore +def test_describe_user_doesnt_exist(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + with pytest.raises(ClientError) as exc: + client.describe_user( + IdentityStoreId=identity_store_id, UserId=str(mock_random.uuid4()) + ) + + err = exc.value + assert err.response["Error"]["Code"] == "ResourceNotFoundException" + assert err.response["Error"]["Message"] == "USER not found." + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["ResourceType"] == "USER" + assert err.response["Message"] == "USER not found." + assert "RequestId" in err.response + + +@mock_identitystore +def test_get_group_id(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + groups = {} + + # Create a bunch of groups + for _ in range(1, 10): + group = __create_test_group(client, identity_store_id) + groups[group[0]] = group[1] + + # Make sure we can get their ID + for name, group_id in groups.items(): + + response = client.get_group_id( + IdentityStoreId=identity_store_id, + AlternateIdentifier={ + "UniqueAttribute": { + "AttributePath": "displayName", + "AttributeValue": name, + } + }, + ) + + assert response["IdentityStoreId"] == identity_store_id + assert response["GroupId"] == group_id + + +@mock_identitystore +def test_get_group_id_does_not_exist(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + with pytest.raises(ClientError) as exc: + client.get_group_id( + IdentityStoreId=identity_store_id, + AlternateIdentifier={ + "UniqueAttribute": { + "AttributePath": "displayName", + "AttributeValue": "does-not-exist", + } + }, + ) + err = exc.value + assert err.response["Error"]["Code"] == "ResourceNotFoundException" + assert err.response["Error"]["Message"] == "GROUP not found." + assert err.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert err.response["ResourceType"] == "GROUP" + assert err.response["Message"] == "GROUP not found." + assert "RequestId" in err.response + + +@mock_identitystore +def test_list_group_memberships(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + start = 0 + end = 5000 + batch_size = 321 + next_token = None + membership_ids = [] + + group_id = client.create_group( + IdentityStoreId=identity_store_id, + DisplayName="test_group", + Description="description", + )["GroupId"] + + for _ in range(end): + user_id = __create_and_verify_sparse_user(client, identity_store_id) + create_response = client.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_id, + MemberId={"UserId": user_id}, + ) + membership_ids.append((create_response["MembershipId"], user_id)) + + for iteration in range(start, end, batch_size): + last_iteration = end - iteration <= batch_size + expected_size = batch_size if not last_iteration else end - iteration + end_index = iteration + expected_size + + if next_token is not None: + list_response = client.list_group_memberships( + IdentityStoreId=identity_store_id, + GroupId=group_id, + MaxResults=batch_size, + NextToken=next_token, + ) + else: + list_response = client.list_group_memberships( + IdentityStoreId=identity_store_id, + GroupId=group_id, + MaxResults=batch_size, + ) + + assert len(list_response["GroupMemberships"]) == expected_size + __check_membership_list_values( + list_response["GroupMemberships"], membership_ids[iteration:end_index] + ) + if last_iteration: + assert "NextToken" not in list_response + else: + assert "NextToken" in list_response + next_token = list_response["NextToken"] + + +def __check_membership_list_values(members, expected): + assert len(members) == len(expected) + for i in range(len(expected)): + assert members[i]["MembershipId"] == expected[i][0] + assert members[i]["MemberId"]["UserId"] == expected[i][1] + + +@mock_identitystore +def test_delete_group(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + test_group = __create_test_group(client, identity_store_id) + assert __group_exists(client, test_group[0], identity_store_id) + + resp = client.delete_group(IdentityStoreId=identity_store_id, GroupId=test_group[1]) + assert "ResponseMetadata" in resp + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + + assert not __group_exists(client, test_group[0], identity_store_id) + + +@mock_identitystore +def test_delete_group_doesnt_exist(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + bogus_id = str(uuid4()) + + resp = client.delete_group(IdentityStoreId=identity_store_id, GroupId=bogus_id) + assert "ResponseMetadata" in resp + assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200 + + assert not __group_exists(client, bogus_id, identity_store_id) + + +@mock_identitystore +def test_delete_group_membership(): + client = boto3.client("identitystore", region_name="eu-west-1") + identity_store_id = get_identity_store_id() + user_id = __create_and_verify_sparse_user(client, identity_store_id) + _, group_id = __create_test_group(client, identity_store_id) + + membership = client.create_group_membership( + IdentityStoreId=identity_store_id, + GroupId=group_id, + MemberId={"UserId": user_id}, + ) + + # Verify the group membership + response = client.list_group_memberships( + IdentityStoreId=identity_store_id, GroupId=group_id + ) + assert response["GroupMemberships"][0]["MemberId"]["UserId"] == user_id + + client.delete_group_membership( + IdentityStoreId=identity_store_id, MembershipId=membership["MembershipId"] + ) + + # Verify the group membership has been removed + response = client.list_group_memberships( + IdentityStoreId=identity_store_id, GroupId=group_id + ) + assert len(response["GroupMemberships"]) == 0 + + +@mock_identitystore +def test_delete_user(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + user_id = __create_and_verify_sparse_user(client, identity_store_id) + + client.delete_user(IdentityStoreId=identity_store_id, UserId=user_id) + + with pytest.raises(ClientError) as exc: + client.describe_user(IdentityStoreId=identity_store_id, UserId=user_id) + err = exc.value + assert err.response["Error"]["Code"] == "ResourceNotFoundException" + + +@mock_identitystore +def test_delete_user_doesnt_exist(): + client = boto3.client("identitystore", region_name="us-east-2") + identity_store_id = get_identity_store_id() + + # This test ensures that the delete_user call does not raise an error if the user ID does not exist + client.delete_user( + IdentityStoreId=identity_store_id, UserId=str(mock_random.uuid4()) + ) + + +def __create_test_group(client, store_id: str): + rand = "".join(random.choices(string.ascii_lowercase, k=8)) + group_name = f"test_group_{rand}" + + create_resp = client.create_group( + IdentityStoreId=store_id, + DisplayName=group_name, + Description="description", + ) + + return group_name, create_resp["GroupId"] + + +def __group_exists(client, group_name: str, store_id: str) -> bool: + try: + client.get_group_id( + IdentityStoreId=store_id, + AlternateIdentifier={ + "UniqueAttribute": { + "AttributePath": "displayName", + "AttributeValue": group_name, + } + }, + ) + return True + except ClientError as e: + if "ResourceNotFoundException" in str(type(e)): + return False + raise e + + +def __create_and_verify_sparse_user(client, store_id: str): + rand = random.choices(string.ascii_lowercase, k=8) + username = f"the_username_{rand}" + response = client.create_user( + IdentityStoreId=store_id, + UserName=username, + DisplayName=f"display_name_{rand}", + Name={"GivenName": f"given_name_{rand}", "FamilyName": f"family_name_{rand}"}, + ) + assert UUID(response["UserId"]) + + user_resp = client.describe_user( + IdentityStoreId=store_id, UserId=response["UserId"] + ) + + assert user_resp["UserName"] == username + return user_resp["UserId"] diff --git a/tests/test_redshiftdata/test_server.py b/tests/test_redshiftdata/test_server.py index 1ccf9bba9..5c01694d6 100644 --- a/tests/test_redshiftdata/test_server.py +++ b/tests/test_redshiftdata/test_server.py @@ -7,6 +7,7 @@ from tests.test_redshiftdata.test_redshiftdata_constants import ( DEFAULT_ENCODING, HttpHeaders, ) +from tests.helpers import match_uuid4 # noqa # pylint: disable=unused-import CLIENT_ENDPOINT = "/"