Repository: getmoto/moto (python)

Issue:
Cognito-Idp: I see 'code_delivery_details' is nil when signingUp.
### Discussed in https://github.com/getmoto/moto/discussions/7701

<div type='discussions-op-text'>

<sup>Originally posted by **atharnoor2** May 16, 2024</sup>
I'm using moto via Docker and have setup Lambda, UserPool  and client. When I signup a new user in the pool, I see the `code_delivery_details` is always return as nil. Incase of ForgotPassword, it does turn `code_delivery_details` in response. Is this something not supported for signUp at the moment ?</div>

Pull request description:
CognitoIDP: sign_up() now returns CodeDeliveryDetails
Fixes #7708 

Patch:
diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py
index 27adb10ee..71facff68 100644
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -1674,13 +1674,14 @@ class CognitoIdpBackend(BaseBackend):
             )
             user.confirmation_code = confirmation_code
 
-        code_delivery_details = {
-            "Destination": username + "@h***.com"
-            if not user
-            else user.attribute_lookup.get("email", username + "@h***.com"),
-            "DeliveryMedium": "EMAIL",
-            "AttributeName": "email",
-        }
+        code_delivery_details = self._get_code_delivery_details(
+            recovery_settings, user, username
+        )
+        return confirmation_code, {"CodeDeliveryDetails": code_delivery_details}
+
+    def _get_code_delivery_details(
+        self, recovery_settings: Any, user: Optional[CognitoIdpUser], username: str
+    ) -> Dict[str, str]:
         selected_recovery = min(
             recovery_settings["RecoveryMechanisms"],
             key=lambda recovery_mechanism: recovery_mechanism["Priority"],
@@ -1688,14 +1689,24 @@ class CognitoIdpBackend(BaseBackend):
         if selected_recovery["Name"] == "admin_only":
             raise NotAuthorizedError("Contact administrator to reset password.")
         if selected_recovery["Name"] == "verified_phone_number":
-            code_delivery_details = {
-                "Destination": "+*******9934"
-                if not user
-                else user.attribute_lookup.get("phone_number", "+*******9934"),
+            number = "+*******9934"
+            if user and "phone_number" in user.attribute_lookup:
+                number = user.attribute_lookup["phone_number"]
+            return {
+                "Destination": number,
                 "DeliveryMedium": "SMS",
                 "AttributeName": "phone_number",
             }
-        return confirmation_code, {"CodeDeliveryDetails": code_delivery_details}
+        else:
+            email = username + "@h***.com"
+            if user and "email" in user.attribute_lookup:
+                first, second = user.attribute_lookup["email"].split("@")
+                email = f"{first[0]}***@{second[0]}***"
+            return {
+                "Destination": email,
+                "DeliveryMedium": "EMAIL",
+                "AttributeName": "email",
+            }
 
     def change_password(
         self, access_token: str, previous_password: str, proposed_password: str
@@ -1796,7 +1807,7 @@ class CognitoIdpBackend(BaseBackend):
         username: str,
         password: str,
         attributes: List[Dict[str, str]],
-    ) -> CognitoIdpUser:
+    ) -> Tuple[CognitoIdpUser, Any]:
         user_pool = None
         for p in self.user_pools.values():
             if client_id in p.clients:
@@ -1858,7 +1869,27 @@ class CognitoIdpBackend(BaseBackend):
             status=UserStatus.UNCONFIRMED,
         )
         user_pool.users[user.username] = user
-        return user
+
+        has_email = "email" in user.attribute_lookup
+        has_phone = "phone_number" in user.attribute_lookup
+        verified_attributes = user_pool.extended_config.get(
+            "AutoVerifiedAttributes", []
+        )
+        email_verified = (
+            "email_verified" in user.attribute_lookup or "email" in verified_attributes
+        )
+        phone_verified = (
+            "phone_number_verified" in user.attribute_lookup
+            or "phone_number" in verified_attributes
+        )
+        if (has_email and email_verified) or (has_phone and phone_verified):
+            recovery_settings = user_pool.extended_config["AccountRecoverySetting"]
+            details = self._get_code_delivery_details(
+                recovery_settings=recovery_settings, user=user, username=user.username
+            )
+            return user, details
+        else:
+            return user, None
 
     def confirm_sign_up(self, client_id: str, username: str) -> str:
         user_pool = None
@@ -2229,7 +2260,7 @@ class RegionAgnosticBackend:
         username: str,
         password: str,
         attributes: List[Dict[str, str]],
-    ) -> CognitoIdpUser:
+    ) -> Tuple[CognitoIdpUser, Any]:
         backend = self._find_backend_for_clientid(client_id)
         return backend.sign_up(client_id, username, password, attributes)
 
diff --git a/moto/cognitoidp/responses.py b/moto/cognitoidp/responses.py
index 19b8ecb71..6c7b3c15f 100644
--- a/moto/cognitoidp/responses.py
+++ b/moto/cognitoidp/responses.py
@@ -588,18 +588,19 @@ class CognitoIdpResponse(BaseResponse):
         client_id = self._get_param("ClientId")
         username = self._get_param("Username")
         password = self._get_param("Password")
-        user = self._get_region_agnostic_backend().sign_up(
+        user, code_delivery_details = self._get_region_agnostic_backend().sign_up(
             client_id=client_id,
             username=username,
             password=password,
             attributes=self._get_param("UserAttributes", []),
         )
-        return json.dumps(
-            {
-                "UserConfirmed": user.status == UserStatus["CONFIRMED"],
-                "UserSub": user.id,
-            }
-        )
+        response = {
+            "UserConfirmed": user.status == UserStatus["CONFIRMED"],
+            "UserSub": user.id,
+        }
+        if code_delivery_details:
+            response["CodeDeliveryDetails"] = code_delivery_details
+        return json.dumps(response)
 
     def confirm_sign_up(self) -> str:
         client_id = self._get_param("ClientId")


Test patch:
diff --git a/.github/workflows/tests_real_aws.yml b/.github/workflows/tests_real_aws.yml
index 71c401ad4..736b47a55 100644
--- a/.github/workflows/tests_real_aws.yml
+++ b/.github/workflows/tests_real_aws.yml
@@ -7,6 +7,8 @@ on:
 jobs:
   test:
     runs-on: ubuntu-latest
+    name: "Test Against AWS"
+    if: ${{ github.repository == 'getmoto/moto' }}
     permissions:
       id-token: write
 
@@ -42,4 +44,4 @@ jobs:
       env:
         MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
       run: |
-        pytest -sv tests/test_cloudformation/ tests/test_dynamodb/ tests/test_ec2/ tests/test_iam/ tests/test_lakeformation/ tests/test_logs/ tests/test_sqs/ tests/test_ses/ tests/test_s3* tests/test_sns/ -m aws_verified
+        pytest -sv -n auto tests/test_applicationautoscaling/ tests/test_athena/ tests/test_cloudformation/ tests/test_dynamodb/ tests/test_ec2/ tests/test_iam/ tests/test_iot/ tests/test_lakeformation/ tests/test_logs/ tests/test_sqs/ tests/test_ses/ tests/test_s3* tests/test_stepfunctions/ tests/test_sns/ tests/test_timestreamwrite/ -m aws_verified
diff --git a/tests/test_cognitoidp/__init__.py b/tests/test_cognitoidp/__init__.py
index 1a06f6b82..738449e87 100644
--- a/tests/test_cognitoidp/__init__.py
+++ b/tests/test_cognitoidp/__init__.py
@@ -12,6 +12,8 @@ def cognitoidp_aws_verified(
     read_attributes=None,
     explicit_auth_flows=None,
     generate_secret=False,
+    recovery=None,
+    verified_attributes=None,
 ):
     """
     Function that is verified to work against AWS.
@@ -35,6 +37,12 @@ def cognitoidp_aws_verified(
                 userpool_args = {}
                 if username_attributes:
                     userpool_args["UsernameAttributes"] = username_attributes
+                if recovery:
+                    userpool_args["AccountRecoverySetting"] = {
+                        "RecoveryMechanisms": recovery
+                    }
+                if verified_attributes:
+                    userpool_args["AutoVerifiedAttributes"] = verified_attributes
                 user_pool = conn.create_user_pool(PoolName=pool_name, **userpool_args)
                 pool_id = user_pool["UserPool"]["Id"]
                 pool_client_kwargs = {}
diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py
index 52b0a3c48..96583501a 100644
--- a/tests/test_cognitoidp/test_cognitoidp.py
+++ b/tests/test_cognitoidp/test_cognitoidp.py
@@ -3224,14 +3224,14 @@ def test_forgot_password_user_with_all_recovery_attributes():
         UserPoolId=user_pool_id,
         Username=username,
         UserAttributes=[
-            {"Name": "email", "Value": "test@moto.com"},
+            {"Name": "email", "Value": "test@m***"},
             {"Name": "phone_number", "Value": "555555555"},
         ],
     )
 
     result = conn.forgot_password(ClientId=client_id, Username=username)
 
-    assert result["CodeDeliveryDetails"]["Destination"] == "test@moto.com"
+    assert result["CodeDeliveryDetails"]["Destination"] == "t***@m***"
     assert result["CodeDeliveryDetails"]["DeliveryMedium"] == "EMAIL"
     assert result["CodeDeliveryDetails"]["AttributeName"] == "email"
 
@@ -3251,6 +3251,8 @@ def test_forgot_password_user_with_all_recovery_attributes():
 
 @mock_aws
 def test_forgot_password_nonexistent_user_or_user_without_attributes():
+    # Whether this fails against AWS or not depends on a setting
+    # https://github.com/aws/aws-aspnet-cognito-identity-provider/issues/179#issuecomment-871051383
     conn = boto3.client("cognito-idp", "us-west-2")
     user_pool_id = conn.create_user_pool(
         PoolName=str(uuid.uuid4()),
@@ -3263,6 +3265,7 @@ def test_forgot_password_nonexistent_user_or_user_without_attributes():
     )["UserPoolClient"]["ClientId"]
     user_without_attributes = str(uuid.uuid4())
     nonexistent_user = str(uuid.uuid4())
+
     conn.admin_create_user(UserPoolId=user_pool_id, Username=user_without_attributes)
     for user in user_without_attributes, nonexistent_user:
         result = conn.forgot_password(ClientId=client_id, Username=user)
@@ -3286,6 +3289,37 @@ def test_forgot_password_nonexistent_user_or_user_without_attributes():
         assert result["CodeDeliveryDetails"]["AttributeName"] == "phone_number"
 
 
+@cognitoidp_aws_verified(recovery=[{"Name": "verified_email", "Priority": 1}])
+@pytest.mark.aws_verified
+def test_forgot_password_with_email_delivery_details(
+    user_pool=None, user_pool_client=None
+):
+    conn = boto3.client("cognito-idp", "us-west-2")
+    user_pool_id = user_pool["UserPool"]["Id"]
+    client_id = user_pool_client["UserPoolClient"]["ClientId"]
+    user_without_attributes = str(uuid.uuid4())
+
+    conn.admin_create_user(
+        UserPoolId=user_pool_id,
+        Username=user_without_attributes,
+        UserAttributes=[
+            {"Name": "email", "Value": "test@test.com"},
+            {"Name": "email_verified", "Value": "True"},
+        ],
+    )
+    conn.admin_set_user_password(
+        UserPoolId=user_pool_id,
+        Username=user_without_attributes,
+        Password="secretP2ss!",
+        Permanent=True,
+    )
+
+    result = conn.forgot_password(ClientId=client_id, Username=user_without_attributes)
+    assert result["CodeDeliveryDetails"]["Destination"] == "t***@t***"
+    assert result["CodeDeliveryDetails"]["DeliveryMedium"] == "EMAIL"
+    assert result["CodeDeliveryDetails"]["AttributeName"] == "email"
+
+
 @mock_aws
 def test_confirm_forgot_password_legacy():
     conn = boto3.client("cognito-idp", "us-west-2")
@@ -3938,18 +3972,76 @@ def test_list_resource_servers_multi_page():
             )
 
 
-@mock_aws
-def test_sign_up():
+@cognitoidp_aws_verified()
+@pytest.mark.aws_verified
+def test_sign_up(user_pool=None, user_pool_client=None):
     conn = boto3.client("cognito-idp", "us-west-2")
-    user_pool_id = conn.create_user_pool(PoolName=str(uuid.uuid4()))["UserPool"]["Id"]
-    client_id = conn.create_user_pool_client(
-        UserPoolId=user_pool_id, ClientName=str(uuid.uuid4())
-    )["UserPoolClient"]["ClientId"]
+    client_id = user_pool_client["UserPoolClient"]["ClientId"]
+    username = str(uuid.uuid4())
+    password = "P2$Sword"
+    result = conn.sign_up(ClientId=client_id, Username=username, Password=password)
+    assert result["UserConfirmed"] is False
+    assert result["UserSub"] is not None
+    assert "CodeDeliveryDetails" not in result
+
+
+@cognitoidp_aws_verified(recovery=[{"Name": "verified_email", "Priority": 1}])
+@pytest.mark.aws_verified
+def test_sign_up_with_unverified_email(user_pool=None, user_pool_client=None):
+    conn = boto3.client("cognito-idp", "us-west-2")
+    client_id = user_pool_client["UserPoolClient"]["ClientId"]
+    username = str(uuid.uuid4())
+    password = "P2$Sword"
+    result = conn.sign_up(
+        ClientId=client_id,
+        Username=username,
+        Password=password,
+        UserAttributes=[{"Name": "email", "Value": "test@test.com"}],
+    )
+    assert result["UserConfirmed"] is False
+    assert result["UserSub"] is not None
+    assert "CodeDeliveryDetails" not in result
+
+
+@cognitoidp_aws_verified(
+    recovery=[{"Name": "verified_email", "Priority": 1}], verified_attributes=["email"]
+)
+@pytest.mark.aws_verified
+def test_sign_up_with_verified_email(user_pool=None, user_pool_client=None):
+    conn = boto3.client("cognito-idp", "us-west-2")
+    client_id = user_pool_client["UserPoolClient"]["ClientId"]
+    username = str(uuid.uuid4())
+    password = "P2$Sword"
+    result = conn.sign_up(
+        ClientId=client_id,
+        Username=username,
+        Password=password,
+        UserAttributes=[{"Name": "email", "Value": "test@test.com"}],
+    )
+    assert result["UserConfirmed"] is False
+    assert result["UserSub"] is not None
+    assert result["CodeDeliveryDetails"] == {
+        "Destination": "t***@t***",
+        "DeliveryMedium": "EMAIL",
+        "AttributeName": "email",
+    }
+
+
+@cognitoidp_aws_verified(
+    recovery=[{"Name": "verified_email", "Priority": 1}], verified_attributes=["email"]
+)
+@pytest.mark.aws_verified
+def test_sign_up_with_verified_but_not_supplied_email(
+    user_pool=None, user_pool_client=None
+):
+    conn = boto3.client("cognito-idp", "us-west-2")
+    client_id = user_pool_client["UserPoolClient"]["ClientId"]
     username = str(uuid.uuid4())
     password = "P2$Sword"
     result = conn.sign_up(ClientId=client_id, Username=username, Password=password)
     assert result["UserConfirmed"] is False
     assert result["UserSub"] is not None
+    assert "CodeDeliveryDetails" not in result
 
 
 @mock_aws