Repository: getmoto/moto
Pull request #6313 (2023-05-10 19:58:53)

Secrets: add NextRotationDate and LastRotatedDate to secrets
Closes https://github.com/getmoto/moto/issues/6285


Patch:
diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py
index 9731008a2..cf4f06113 100644
--- a/moto/cloudformation/parsing.py
+++ b/moto/cloudformation/parsing.py
@@ -793,17 +793,7 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
         for logical_name in resource_names_by_action["Remove"]:
             resource_json = self._resource_json_map[logical_name]
             resource = self._parsed_resources[logical_name]
-            # ToDo: Standardize this.
-            if hasattr(resource, "physical_resource_id"):
-                resource_name = self._parsed_resources[
-                    logical_name
-                ].physical_resource_id
-            else:
-                resource_name = None
-            parse_and_delete_resource(
-                resource_name, resource_json, self._account_id, self._region_name
-            )
-            self._parsed_resources.pop(logical_name)
+            self._delete_resource(resource, resource_json)
 
         self._template = template
         if parameters:
@@ -859,24 +849,12 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
                         not isinstance(parsed_resource, str)
                         and parsed_resource is not None
                     ):
-                        try:
-                            parsed_resource.delete(self._account_id, self._region_name)
-                        except (TypeError, AttributeError):
-                            if hasattr(parsed_resource, "physical_resource_id"):
-                                resource_name = parsed_resource.physical_resource_id
-                            else:
-                                resource_name = None
-
-                            resource_json = self._resource_json_map[
-                                parsed_resource.logical_resource_id
-                            ]
-
-                            parse_and_delete_resource(
-                                resource_name,
-                                resource_json,
-                                self._account_id,
-                                self._region_name,
-                            )
+
+                        resource_json = self._resource_json_map[
+                            parsed_resource.logical_resource_id
+                        ]
+
+                        self._delete_resource(parsed_resource, resource_json)
 
                         self._parsed_resources.pop(parsed_resource.logical_resource_id)
                 except Exception as e:
@@ -889,6 +867,24 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
         if tries == 5:
             raise last_exception
 
+    def _delete_resource(
+        self, parsed_resource: Any, resource_json: Dict[str, Any]
+    ) -> None:
+        try:
+            parsed_resource.delete(self._account_id, self._region_name)
+        except (TypeError, AttributeError):
+            if hasattr(parsed_resource, "physical_resource_id"):
+                resource_name = parsed_resource.physical_resource_id
+            else:
+                resource_name = None
+
+            parse_and_delete_resource(
+                resource_name,
+                resource_json,
+                self._account_id,
+                self._region_name,
+            )
+
 
 class OutputMap(collections_abc.Mapping):  # type: ignore[type-arg]
     def __init__(self, resources: ResourceMap, template: Dict[str, Any], stack_id: str):
diff --git a/moto/ecs/models.py b/moto/ecs/models.py
index b89bd8a7c..6e230c9b9 100644
--- a/moto/ecs/models.py
+++ b/moto/ecs/models.py
@@ -2,6 +2,7 @@ import re
 from copy import copy
 from datetime import datetime, timezone
 from typing import Any, Dict, Iterator, List, Optional, Tuple
+from os import getenv
 
 from moto import settings
 from moto.core import BaseBackend, BackendDict, BaseModel, CloudFormationModel
@@ -488,6 +489,14 @@ class CapacityProviderFailure(BaseObject):
 
 
 class Service(BaseObject, CloudFormationModel):
+    """Set the environment variable MOTO_ECS_SERVICE_RUNNING to a number of running tasks you want
+    the service to transition to, ie if set to 2:
+
+    MOTO_ECS_SERVICE_RUNNING=2
+
+    then describe_services call to return runningCount of the service AND deployment to 2
+    """
+
     def __init__(
         self,
         cluster: Cluster,
@@ -507,7 +516,6 @@ class Service(BaseObject, CloudFormationModel):
         self.cluster_arn = cluster.arn
         self.name = service_name
         self.status = "ACTIVE"
-        self.running_count = 0
         self.task_definition = task_definition.arn if task_definition else None
         self.desired_count = desired_count
         self.task_sets: List[TaskSet] = []
@@ -515,6 +523,25 @@ class Service(BaseObject, CloudFormationModel):
         self.events: List[Dict[str, Any]] = []
         self.launch_type = launch_type
         self.service_registries = service_registries or []
+        self.load_balancers = load_balancers if load_balancers is not None else []
+        self.scheduling_strategy = (
+            scheduling_strategy if scheduling_strategy is not None else "REPLICA"
+        )
+        self.platform_version = platform_version
+        self.tags = tags if tags is not None else []
+        self.region_name = cluster.region_name
+        self._account_id = backend.account_id
+        self._backend = backend
+
+        try:
+            # negative running count not allowed, set to 0 if so
+            ecs_running_count = max(int(getenv("MOTO_ECS_SERVICE_RUNNING", 0)), 0)
+        except ValueError:
+            # Unable to parse value of MOTO_ECS_SERVICE_RUNNING as an integer, set to default 0
+            ecs_running_count = 0
+
+        self.running_count = ecs_running_count
+        self.pending_count = desired_count - ecs_running_count
         if self.deployment_controller["type"] == "ECS":
             self.deployments = [
                 {
@@ -522,8 +549,8 @@ class Service(BaseObject, CloudFormationModel):
                     "desiredCount": self.desired_count,
                     "id": f"ecs-svc/{mock_random.randint(0, 32**12)}",
                     "launchType": self.launch_type,
-                    "pendingCount": self.desired_count,
-                    "runningCount": 0,
+                    "pendingCount": self.pending_count,
+                    "runningCount": ecs_running_count,
                     "status": "PRIMARY",
                     "taskDefinition": self.task_definition,
                     "updatedAt": datetime.now(timezone.utc),
@@ -531,16 +558,6 @@ class Service(BaseObject, CloudFormationModel):
             ]
         else:
             self.deployments = []
-        self.load_balancers = load_balancers if load_balancers is not None else []
-        self.scheduling_strategy = (
-            scheduling_strategy if scheduling_strategy is not None else "REPLICA"
-        )
-        self.platform_version = platform_version
-        self.tags = tags if tags is not None else []
-        self.pending_count = 0
-        self.region_name = cluster.region_name
-        self._account_id = backend.account_id
-        self._backend = backend
 
     @property
     def arn(self) -> str:
@@ -1641,6 +1658,7 @@ class EC2ContainerServiceBackend(BaseBackend):
             # It is only deleted later on
             # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#ECS.Client.delete_service
             service.status = "INACTIVE"
+            service.pending_count = 0
             return service
 
     def register_container_instance(
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index 7bf70dfbf..c0f24fe85 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -2102,10 +2102,10 @@ class S3Response(BaseResponse):
         ps = minidom.parseString(body).getElementsByTagName("Part")
         prev = 0
         for p in ps:
-            pn = int(p.getElementsByTagName("PartNumber")[0].firstChild.wholeText)
+            pn = int(p.getElementsByTagName("PartNumber")[0].firstChild.wholeText)  # type: ignore[union-attr]
             if pn <= prev:
                 raise InvalidPartOrder()
-            yield (pn, p.getElementsByTagName("ETag")[0].firstChild.wholeText)
+            yield (pn, p.getElementsByTagName("ETag")[0].firstChild.wholeText)  # type: ignore[union-attr]
 
     def _key_response_post(
         self,
diff --git a/moto/secretsmanager/models.py b/moto/secretsmanager/models.py
index bb492c4ab..53d049ef0 100644
--- a/moto/secretsmanager/models.py
+++ b/moto/secretsmanager/models.py
@@ -88,6 +88,8 @@ class FakeSecret:
         self.auto_rotate_after_days = 0
         self.deleted_date: Optional[float] = None
         self.policy: Optional[str] = None
+        self.next_rotation_date: Optional[int] = None
+        self.last_rotation_date: Optional[int] = None
 
     def update(
         self,
@@ -167,9 +169,10 @@ class FakeSecret:
             "RotationEnabled": self.rotation_enabled,
             "RotationLambdaARN": self.rotation_lambda_arn,
             "RotationRules": {"AutomaticallyAfterDays": self.auto_rotate_after_days},
-            "LastRotatedDate": None,
+            "LastRotatedDate": self.last_rotation_date,
             "LastChangedDate": self.last_changed_date,
             "LastAccessedDate": None,
+            "NextRotationDate": self.next_rotation_date,
             "DeletedDate": self.deleted_date,
             "Tags": self.tags,
             "VersionIdsToStages": version_id_to_stages,
@@ -535,6 +538,10 @@ class SecretsManagerBackend(BaseBackend):
                     )
                     raise InvalidParameterException(msg)
 
+                self.secrets[secret_id].next_rotation_date = int(time.time()) + (
+                    int(rotation_period) * 86400
+                )
+
         secret = self.secrets[secret_id]
 
         # The rotation function must end with the versions of the secret in
@@ -625,7 +632,7 @@ class SecretsManagerBackend(BaseBackend):
                 secret.versions[new_version_id], new_version_id
             )
             secret.versions[new_version_id]["version_stages"] = ["AWSCURRENT"]
-
+        self.secrets[secret_id].last_rotation_date = int(time.time())
         return secret.to_short_dict()
 
     def get_random_password(
diff --git a/moto/sns/models.py b/moto/sns/models.py
index a3cf2afd0..fd43eb181 100644
--- a/moto/sns/models.py
+++ b/moto/sns/models.py
@@ -139,36 +139,20 @@ class Topic(CloudFormationModel):
     @classmethod
     def update_from_cloudformation_json(  # type: ignore[misc]
         cls,
-        original_resource: Any,
+        original_resource: "Topic",
         new_resource_name: str,
         cloudformation_json: Any,
         account_id: str,
         region_name: str,
     ) -> "Topic":
-        cls.delete_from_cloudformation_json(
-            original_resource.name, cloudformation_json, account_id, region_name
-        )
+        original_resource.delete(account_id, region_name)
         return cls.create_from_cloudformation_json(
             new_resource_name, cloudformation_json, account_id, region_name
         )
 
-    @classmethod
-    def delete_from_cloudformation_json(  # type: ignore[misc]
-        cls,
-        resource_name: str,
-        cloudformation_json: Any,
-        account_id: str,
-        region_name: str,
-    ) -> None:
-        sns_backend = sns_backends[account_id][region_name]
-        properties = cloudformation_json["Properties"]
-
-        topic_name = properties.get(cls.cloudformation_name_type()) or resource_name
-        topic_arn = make_arn_for_topic(account_id, topic_name, sns_backend.region_name)
-        subscriptions, _ = sns_backend.list_subscriptions(topic_arn)
-        for subscription in subscriptions:
-            sns_backend.unsubscribe(subscription.arn)
-        sns_backend.delete_topic(topic_arn)
+    def delete(self, account_id: str, region_name: str) -> None:
+        sns_backend: SNSBackend = sns_backends[account_id][region_name]
+        sns_backend.delete_topic(self.arn)
 
     def _create_default_topic_policy(
         self, region_name: str, account_id: str, name: str


Test patch:
diff --git a/tests/test_ecs/test_ecs_boto3.py b/tests/test_ecs/test_ecs_boto3.py
index d834162f0..5ef1374f6 100644
--- a/tests/test_ecs/test_ecs_boto3.py
+++ b/tests/test_ecs/test_ecs_boto3.py
@@ -589,7 +589,7 @@ def test_create_service():
     response["service"]["desiredCount"].should.equal(2)
     len(response["service"]["events"]).should.equal(0)
     len(response["service"]["loadBalancers"]).should.equal(0)
-    response["service"]["pendingCount"].should.equal(0)
+    response["service"]["pendingCount"].should.equal(2)
     response["service"]["runningCount"].should.equal(0)
     response["service"]["serviceArn"].should.equal(
         f"arn:aws:ecs:us-east-1:{ACCOUNT_ID}:service/test_ecs_cluster/test_ecs_service"
@@ -604,6 +604,77 @@ def test_create_service():
     response["service"]["platformVersion"].should.equal("2")
 
 
+@mock_ecs
+@mock_ec2
+def test_create_running_service():
+    if settings.TEST_SERVER_MODE:
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
+    running_service_count = 3
+    with mock.patch.dict(
+        os.environ, {"MOTO_ECS_SERVICE_RUNNING": str(running_service_count)}
+    ):
+        client = boto3.client("ecs", region_name="us-east-1")
+        ec2 = boto3.resource("ec2", region_name="us-east-1")
+        setup_ecs(client, ec2)
+
+        response = client.create_service(
+            cluster="test_ecs_cluster",
+            serviceName="test_ecs_service",
+            taskDefinition="test_ecs_task",
+            desiredCount=4,
+            platformVersion="2",
+        )
+
+        assert response["service"]["runningCount"] == running_service_count
+        assert response["service"]["pendingCount"] == 1
+
+
+@mock_ecs
+@mock_ec2
+def test_create_running_service_bad_env_var():
+    running_service_count = "ALSDHLHA;''"
+    with mock.patch.dict(
+        os.environ, {"MOTO_ECS_SERVICE_RUNNING": str(running_service_count)}
+    ):
+        client = boto3.client("ecs", region_name="us-east-1")
+        ec2 = boto3.resource("ec2", region_name="us-east-1")
+        setup_ecs(client, ec2)
+
+        response = client.create_service(
+            cluster="test_ecs_cluster",
+            serviceName="test_ecs_service",
+            taskDefinition="test_ecs_task",
+            desiredCount=2,
+            platformVersion="2",
+        )
+
+        assert response["service"]["runningCount"] == 0
+
+
+@mock_ecs
+@mock_ec2
+def test_create_running_service_negative_env_var():
+    running_service_count = "-20"
+    with mock.patch.dict(
+        os.environ, {"MOTO_ECS_SERVICE_RUNNING": str(running_service_count)}
+    ):
+        client = boto3.client("ecs", region_name="us-east-1")
+        ec2 = boto3.resource("ec2", region_name="us-east-1")
+        setup_ecs(client, ec2)
+
+        response = client.create_service(
+            cluster="test_ecs_cluster",
+            serviceName="test_ecs_service",
+            taskDefinition="test_ecs_task",
+            desiredCount=2,
+            platformVersion="2",
+        )
+
+        assert response["service"]["runningCount"] == 0
+
+
 @mock_ecs
 def test_create_service_errors():
     # given
@@ -680,7 +751,7 @@ def test_create_service_scheduling_strategy():
     response["service"]["desiredCount"].should.equal(2)
     len(response["service"]["events"]).should.equal(0)
     len(response["service"]["loadBalancers"]).should.equal(0)
-    response["service"]["pendingCount"].should.equal(0)
+    response["service"]["pendingCount"].should.equal(2)
     response["service"]["runningCount"].should.equal(0)
     response["service"]["serviceArn"].should.equal(
         f"arn:aws:ecs:us-east-1:{ACCOUNT_ID}:service/test_ecs_cluster/test_ecs_service"
@@ -870,7 +941,9 @@ def test_describe_services():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_describe_services_new_arn():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     client = boto3.client("ecs", region_name="us-east-1")
     _ = client.create_cluster(clusterName="test_ecs_cluster")
     _ = client.register_task_definition(
@@ -1338,7 +1411,9 @@ def test_register_container_instance():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_register_container_instance_new_arn_format():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     ecs_client = boto3.client("ecs", region_name="us-east-1")
     ec2 = boto3.resource("ec2", region_name="us-east-1")
 
@@ -1876,7 +1951,9 @@ def test_run_task_default_cluster():
 @mock.patch.dict(os.environ, {"MOTO_ECS_NEW_ARN": "TrUe"})
 def test_run_task_default_cluster_new_arn_format():
     if settings.TEST_SERVER_MODE:
-        raise SkipTest("Cant set environment variables in server mode")
+        raise SkipTest(
+            "Can't set environment variables in server mode for a single test"
+        )
     client = boto3.client("ecs", region_name="us-east-1")
     ec2 = boto3.resource("ec2", region_name="us-east-1")
 
@@ -2953,7 +3030,7 @@ def test_create_service_load_balancing():
         "test_container_name"
     )
     response["service"]["loadBalancers"][0]["containerPort"].should.equal(123)
-    response["service"]["pendingCount"].should.equal(0)
+    response["service"]["pendingCount"].should.equal(2)
     response["service"]["runningCount"].should.equal(0)
     response["service"]["serviceArn"].should.equal(
         f"arn:aws:ecs:us-east-1:{ACCOUNT_ID}:service/test_ecs_cluster/test_ecs_service"
diff --git a/tests/test_secretsmanager/test_secretsmanager.py b/tests/test_secretsmanager/test_secretsmanager.py
index 6eee8b762..f2f78ec22 100644
--- a/tests/test_secretsmanager/test_secretsmanager.py
+++ b/tests/test_secretsmanager/test_secretsmanager.py
@@ -711,21 +711,41 @@ def test_cancel_rotate_secret():
 
 @mock_secretsmanager
 def test_rotate_secret():
-    conn = boto3.client("secretsmanager", region_name="us-west-2")
-    conn.create_secret(
-        Name=DEFAULT_SECRET_NAME, SecretString="foosecret", Description="foodescription"
-    )
-
-    rotated_secret = conn.rotate_secret(SecretId=DEFAULT_SECRET_NAME)
-
-    assert rotated_secret
-    assert rotated_secret["ARN"] != ""  # Test arn not empty
-    assert rotated_secret["Name"] == DEFAULT_SECRET_NAME
-    assert rotated_secret["VersionId"] != ""
-
-    describe_secret = conn.describe_secret(SecretId=DEFAULT_SECRET_NAME)
+    # Setup
+    frozen_time = datetime(2023, 5, 20, 10, 20, 30, tzinfo=tzlocal())
+    rotate_after_days = 10
+    with freeze_time(frozen_time):
+        conn = boto3.client("secretsmanager", region_name="us-west-2")
+        conn.create_secret(
+            Name=DEFAULT_SECRET_NAME,
+            SecretString="foosecret",
+            Description="foodescription",
+        )
 
-    assert describe_secret["Description"] == "foodescription"
+        # Execute
+        rotated_secret = conn.rotate_secret(
+            SecretId=DEFAULT_SECRET_NAME,
+            RotationRules={"AutomaticallyAfterDays": rotate_after_days},
+        )
+        describe_secret = conn.describe_secret(SecretId=DEFAULT_SECRET_NAME)
+
+        # Verify
+        assert rotated_secret
+        assert rotated_secret["ARN"] != ""  # Test arn not empty
+        assert rotated_secret["Name"] == DEFAULT_SECRET_NAME
+        assert rotated_secret["VersionId"] != ""
+        assert describe_secret["Description"] == "foodescription"
+        assert "NextRotationDate" in describe_secret
+        assert "LastRotatedDate" in describe_secret
+
+        # can't do freeze time tests in servermode tests
+        if settings.TEST_SERVER_MODE:
+            return
+
+        assert describe_secret["LastChangedDate"] == frozen_time
+        assert describe_secret["NextRotationDate"] == frozen_time + timedelta(
+            days=rotate_after_days
+        )
 
 
 @mock_secretsmanager
diff --git a/tests/test_sns/test_sns_cloudformation.py b/tests/test_sns/test_sns_cloudformation.py
index 17cd25e05..df8e1eb03 100644
--- a/tests/test_sns/test_sns_cloudformation.py
+++ b/tests/test_sns/test_sns_cloudformation.py
@@ -1,6 +1,7 @@
 import boto3
 import json
 import sure  # noqa # pylint: disable=unused-import
+import pytest
 
 from moto import mock_cloudformation, mock_sns
 
@@ -8,24 +9,7 @@ from moto import mock_cloudformation, mock_sns
 @mock_cloudformation
 @mock_sns
 def test_sns_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+    dummy_template = get_template(with_properties=True)
     template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=template_json)
@@ -56,24 +40,7 @@ def test_sns_topic():
 @mock_cloudformation
 @mock_sns
 def test_sns_update_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+    dummy_template = get_template(with_properties=True)
     sns_template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
@@ -104,25 +71,9 @@ def test_sns_update_topic():
 
 @mock_cloudformation
 @mock_sns
-def test_sns_update_remove_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+@pytest.mark.parametrize("with_properties", [True, False])
+def test_sns_update_remove_topic(with_properties):
+    dummy_template = get_template(with_properties)
     sns_template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
@@ -142,26 +93,9 @@ def test_sns_update_remove_topic():
 
 @mock_cloudformation
 @mock_sns
-def test_sns_delete_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
-    sns_template_json = json.dumps(dummy_template)
+@pytest.mark.parametrize("with_properties", [True, False])
+def test_sns_delete_topic(with_properties):
+    sns_template_json = json.dumps(get_template(with_properties))
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
 
@@ -173,3 +107,20 @@ def test_sns_delete_topic():
 
     topics = sns.list_topics()["Topics"]
     topics.should.have.length_of(0)
+
+
+def get_template(with_properties):
+    dummy_template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Resources": {"MySNSTopic": {"Type": "AWS::SNS::Topic"}},
+        "Outputs": {
+            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
+            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
+        },
+    }
+    if with_properties:
+        dummy_template["Resources"]["MySNSTopic"]["Properties"] = {
+            "Subscription": [{"Endpoint": "https://example.com", "Protocol": "https"}],
+            "TopicName": "my_topics",
+        }
+    return dummy_template