Repository: getmoto/moto
Pull request #7470 (2024-03-14 23:01:50)

CloudFormation: Allow Conditions in Outputs
See #7381 


Patch:
diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py
index 5a1216be7..0a6b913b6 100644
--- a/moto/cloudformation/parsing.py
+++ b/moto/cloudformation/parsing.py
@@ -475,6 +475,11 @@ def parse_condition(condition: Union[Dict[str, Any], bool], resources_map: "Reso
 def parse_output(
     output_logical_id: str, output_json: Any, resources_map: "ResourceMap"
 ) -> Optional[Output]:
+    if "Condition" in output_json and not resources_map.lazy_condition_map.get(
+        output_json["Condition"]
+    ):
+        # This Resource is not initialized - impossible to show Output
+        return None
     output_json = clean_json(output_json, resources_map)
     if "Value" not in output_json:
         return None
@@ -684,11 +689,22 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
     def validate_outputs(self) -> None:
         outputs = self._template.get("Outputs") or {}
         for value in outputs.values():
+            if "Condition" in value:
+                if not self.lazy_condition_map[value["Condition"]]:
+                    # This Output is not shown - no point in validating it
+                    continue
             value = value.get("Value", {})
             if "Fn::GetAtt" in value:
-                resource_type = self._resource_json_map.get(value["Fn::GetAtt"][0])[  # type: ignore[index]
-                    "Type"
-                ]
+                resource_name = value["Fn::GetAtt"][0]
+                resource = self._resource_json_map.get(resource_name)
+                # validate resource will be created
+                if "Condition" in resource:  # type: ignore
+                    if not self.lazy_condition_map[resource["Condition"]]:  # type: ignore[index]
+                        raise ValidationError(
+                            message=f"Unresolved resource dependencies [{resource_name}] in the Outputs block of the template"
+                        )
+                # Validate attribute exists on this Type
+                resource_type = resource["Type"]  # type: ignore[index]
                 attr = value["Fn::GetAtt"][1]
                 resource_class = resource_class_from_type(resource_type)
                 if not resource_class.has_cfn_attr(attr):
diff --git a/moto/cloudformation/responses.py b/moto/cloudformation/responses.py
index 07a1fb363..b848f5e14 100644
--- a/moto/cloudformation/responses.py
+++ b/moto/cloudformation/responses.py
@@ -812,6 +812,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
         <NotificationARNs/>
         {% endif %}
         <DisableRollback>false</DisableRollback>
+        {%if stack.stack_outputs %}
         <Outputs>
         {% for output in stack.stack_outputs %}
           <member>
@@ -820,6 +821,7 @@ DESCRIBE_STACKS_TEMPLATE = """<DescribeStacksResponse>
           </member>
         {% endfor %}
         </Outputs>
+        {% endif %}
         <Parameters>
         {% for param_name, param_value in stack.stack_parameters.items() %}
           <member>
diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py
index 0e960764e..051eb7ee4 100644
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -869,6 +869,7 @@ class CognitoIdpUser(BaseModel):
         flat_attributes.update(flatten_attrs(new_attributes))
         self.attribute_lookup = flat_attributes
         self.attributes = expand_attrs(flat_attributes)
+        self.last_modified_date = utcnow()
 
     def delete_attributes(self, attrs_to_delete: List[str]) -> None:
         flat_attributes = flatten_attrs(self.attributes)
@@ -891,6 +892,7 @@ class CognitoIdpUser(BaseModel):
             )
         self.attribute_lookup = flat_attributes
         self.attributes = expand_attrs(flat_attributes)
+        self.last_modified_date = utcnow()
 
 
 class CognitoResourceServer(BaseModel):


Test patch:
diff --git a/.github/workflows/tests_real_aws.yml b/.github/workflows/tests_real_aws.yml
index 350464194..71c401ad4 100644
--- a/.github/workflows/tests_real_aws.yml
+++ b/.github/workflows/tests_real_aws.yml
@@ -42,4 +42,4 @@ jobs:
       env:
         MOTO_TEST_ALLOW_AWS_REQUEST: ${{ true }}
       run: |
-        pytest -sv 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 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
diff --git a/tests/test_cloudformation/__init__.py b/tests/test_cloudformation/__init__.py
index e69de29bb..2dc371035 100644
--- a/tests/test_cloudformation/__init__.py
+++ b/tests/test_cloudformation/__init__.py
@@ -0,0 +1,31 @@
+import os
+from functools import wraps
+
+from moto import mock_aws
+
+
+def cloudformation_aws_verified():
+    """
+    Function that is verified to work against AWS.
+    Can be run against AWS at any time by setting:
+      MOTO_TEST_ALLOW_AWS_REQUEST=true
+
+    If this environment variable is not set, the function runs in a `mock_aws` context.
+    """
+
+    def inner(func):
+        @wraps(func)
+        def pagination_wrapper():
+            allow_aws_request = (
+                os.environ.get("MOTO_TEST_ALLOW_AWS_REQUEST", "false").lower() == "true"
+            )
+
+            if allow_aws_request:
+                return func()
+            else:
+                with mock_aws():
+                    return func()
+
+        return pagination_wrapper
+
+    return inner
diff --git a/tests/test_cloudformation/test_cloudformation_custom_resources.py b/tests/test_cloudformation/test_cloudformation_custom_resources.py
index 97e1d8e9b..b5da00d38 100644
--- a/tests/test_cloudformation/test_cloudformation_custom_resources.py
+++ b/tests/test_cloudformation/test_cloudformation_custom_resources.py
@@ -150,7 +150,7 @@ def test_create_custom_lambda_resource__verify_manual_request():
     )
     stack_id = stack["StackId"]
     stack = cf.describe_stacks(StackName=stack_id)["Stacks"][0]
-    assert stack["Outputs"] == []
+    assert "Outputs" not in stack
     assert stack["StackStatus"] == "CREATE_IN_PROGRESS"
 
     callback_url = f"http://cloudformation.{region_name}.amazonaws.com/cloudformation_{region_name}/cfnresponse?stack={stack_id}"
diff --git a/tests/test_cloudformation/test_conditions.py b/tests/test_cloudformation/test_conditions.py
new file mode 100644
index 000000000..343d3403c
--- /dev/null
+++ b/tests/test_cloudformation/test_conditions.py
@@ -0,0 +1,212 @@
+from uuid import uuid4
+
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from . import cloudformation_aws_verified
+
+conditional_output_conditional_value = """
+Outputs:
+  roleArn:
+    Condition: RoleCondition
+    Value: !GetAtt role.Arn
+
+Parameters:
+  roleName:
+    Type: String
+    Description: Predefined role name
+
+Conditions:
+  RoleCondition: !Equals [ !Ref roleName, "myrole" ]
+
+Resources:
+  role:
+    Condition: RoleCondition
+    Type: 'AWS::IAM::Role'
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service:
+                - ec2.amazonaws.com
+            Action:
+              - 'sts:AssumeRole'
+"""
+
+
+@pytest.mark.aws_verified
+@cloudformation_aws_verified()
+def test_conditional_output_conditional_value():
+    cf = boto3.client("cloudformation", region_name="us-east-1")
+    param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}]
+    name = f"stack{str(uuid4())[0:6]}"
+    cf.create_stack(
+        StackName=name,
+        TemplateBody=conditional_output_conditional_value,
+        Parameters=param,
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    waiter = cf.get_waiter("stack_create_complete")
+    waiter.wait(StackName=name)
+
+    stack = cf.describe_stacks(StackName=name)["Stacks"][0]
+    assert stack["Outputs"][0]["OutputKey"] == "roleArn"
+
+    cf.delete_stack(StackName=name)
+
+    # verify role does not exist with different name
+    param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}]
+    cf.create_stack(
+        StackName=f"{name}2",
+        TemplateBody=conditional_output_conditional_value,
+        Parameters=param,
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    waiter = cf.get_waiter("stack_create_complete")
+    waiter.wait(StackName=f"{name}2")
+    stack = cf.describe_stacks()["Stacks"][0]
+    assert "Outputs" not in stack
+
+    cf.delete_stack(StackName=f"{name}2")
+
+
+permanent_output_conditional_value = """
+Outputs:
+  roleArn:
+    Value: !GetAtt role.Arn
+
+Parameters:
+  roleName:
+    Type: String
+    Description: Predefined role name
+
+Conditions:
+  RoleCondition: !Equals [ !Ref roleName, "myrole" ]
+
+Resources:
+  role:
+    Condition: RoleCondition
+    Type: 'AWS::IAM::Role'
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service:
+                - ec2.amazonaws.com
+            Action:
+              - 'sts:AssumeRole'
+"""
+
+
+@pytest.mark.aws_verified
+@cloudformation_aws_verified()
+def test_permanent_output_conditional_value():
+    cf = boto3.client("cloudformation", region_name="us-east-1")
+    param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}]
+
+    name = f"stack{str(uuid4())[0:6]}"
+    cf.create_stack(
+        StackName=name,
+        TemplateBody=permanent_output_conditional_value,
+        Parameters=param,
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    waiter = cf.get_waiter("stack_create_complete")
+    waiter.wait(StackName=name)
+
+    # Works - because role exists
+    stack = cf.describe_stacks(StackName=name)["Stacks"][0]
+    assert stack["Outputs"][0]["OutputKey"] == "roleArn"
+
+    cf.delete_stack(StackName=name)
+
+    # Role does not exist with different name
+    # So we can't get the Output either
+    param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}]
+    with pytest.raises(ClientError) as exc:
+        cf.create_stack(
+            StackName=f"{name}2",
+            TemplateBody=permanent_output_conditional_value,
+            Parameters=param,
+            Capabilities=["CAPABILITY_IAM"],
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationError"
+    assert (
+        err["Message"]
+        == "Unresolved resource dependencies [role] in the Outputs block of the template"
+    )
+
+
+conditional_output_of_permanent_value = """
+Outputs:
+  roleArn:
+    Condition: RoleCondition
+    Value: !GetAtt role.Arn
+
+Parameters:
+  roleName:
+    Type: String
+    Description: Predefined role name
+
+Conditions:
+  RoleCondition: !Equals [ !Ref roleName, "myrole" ]
+
+Resources:
+  role:
+    Type: 'AWS::IAM::Role'
+    Properties:
+      AssumeRolePolicyDocument:
+        Version: 2012-10-17
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service:
+                - ec2.amazonaws.com
+            Action:
+              - 'sts:AssumeRole'
+"""
+
+
+@pytest.mark.aws_verified
+@cloudformation_aws_verified()
+def test_conditional_output_of_permanent_value():
+    cf = boto3.client("cloudformation", region_name="us-east-1")
+    param = [{"ParameterKey": "roleName", "ParameterValue": "myrole"}]
+
+    name = f"stack{str(uuid4())[0:6]}"
+    cf.create_stack(
+        StackName=name,
+        TemplateBody=conditional_output_of_permanent_value,
+        Parameters=param,
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    waiter = cf.get_waiter("stack_create_complete")
+    waiter.wait(StackName=name)
+
+    stack = cf.describe_stacks(StackName=name)["Stacks"][0]
+    assert stack["Outputs"][0]["OutputKey"] == "roleArn"
+
+    cf.delete_stack(StackName=name)
+
+    # verify role does not exist with different name
+    param = [{"ParameterKey": "roleName", "ParameterValue": "diffname"}]
+    cf.create_stack(
+        StackName=f"{name}2",
+        TemplateBody=conditional_output_of_permanent_value,
+        Parameters=param,
+        Capabilities=["CAPABILITY_IAM"],
+    )
+    waiter = cf.get_waiter("stack_create_complete")
+    waiter.wait(StackName=f"{name}2")
+    stack = cf.describe_stacks(StackName=f"{name}2")["Stacks"][0]
+    # The Role does exist
+    # But Output is conditional, and just doesn't return anything
+    assert "Outputs" not in stack
+
+    cf.delete_stack(StackName=f"{name}2")
diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py
index 1ff151bfb..ee4691d99 100644
--- a/tests/test_cognitoidp/test_cognitoidp.py
+++ b/tests/test_cognitoidp/test_cognitoidp.py
@@ -6,6 +6,7 @@ import json
 import os
 import random
 import re
+import time
 import uuid
 from unittest import SkipTest, mock
 
@@ -2346,6 +2347,65 @@ def test_admin_get_missing_user_with_username_attributes():
     assert err["Message"] == "User does not exist."
 
 
+@mock_aws
+def test_new_user_contains_same_created_and_updated_dates():
+    conn = boto3.client("cognito-idp", "us-west-2")
+
+    username = "test@example.com"
+    user_pool_id = conn.create_user_pool(
+        PoolName=str(uuid.uuid4()), UsernameAttributes=["email"]
+    )["UserPool"]["Id"]
+
+    resp = conn.admin_create_user(UserPoolId=user_pool_id, Username=username)
+    new_user = resp["User"]
+
+    assert new_user["UserCreateDate"] == new_user["UserLastModifiedDate"]
+
+
+@mock_aws
+def test_update_user_attributes_also_changes_last_modified_date():
+    conn = boto3.client("cognito-idp", "us-west-2")
+
+    username = "test@example.com"
+    user_pool_id = conn.create_user_pool(
+        PoolName=str(uuid.uuid4()), UsernameAttributes=["email"]
+    )["UserPool"]["Id"]
+    conn.admin_create_user(UserPoolId=user_pool_id, Username=username)
+
+    time.sleep(1)  # Wait enough to account for a different in the time format
+    conn.admin_update_user_attributes(
+        UserPoolId=user_pool_id,
+        Username=username,
+        UserAttributes=[{"Name": "phone_number", "Value": "+123456789"}],
+    )
+
+    user = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
+    assert user["UserCreateDate"] < user["UserLastModifiedDate"]
+
+
+@mock_aws
+def test_delete_user_attributes_also_changes_last_modified_date():
+    conn = boto3.client("cognito-idp", "us-west-2")
+
+    username = "test@example.com"
+    user_pool_id = conn.create_user_pool(
+        PoolName=str(uuid.uuid4()), UsernameAttributes=["email"]
+    )["UserPool"]["Id"]
+    conn.admin_create_user(
+        UserPoolId=user_pool_id,
+        Username=username,
+        UserAttributes=[{"Name": "phone_number", "Value": "+123456789"}],
+    )
+
+    time.sleep(1)  # Wait enough to account for a different in the time format
+    conn.admin_delete_user_attributes(
+        UserPoolId=user_pool_id, Username=username, UserAttributeNames=["phone_number"]
+    )
+
+    user = conn.admin_get_user(UserPoolId=user_pool_id, Username=username)
+    assert user["UserCreateDate"] < user["UserLastModifiedDate"]
+
+
 @mock_aws
 def test_get_user():
     conn = boto3.client("cognito-idp", "us-west-2")