Repository: getmoto/moto
Pull request #8825 (2025-04-18 08:38:50)

feat(ses): implement `GetIdentityDkimAttributes` API
See https://github.com/localstack/localstack/issues/5743

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8825?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.82%. Comparing base [(`107da42`)](https://app.codecov.io/gh/getmoto/moto/commit/107da425c278a6aece68ee27d72f135494876ad4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`1a7669b`)](https://app.codecov.io/gh/getmoto/moto/commit/1a7669bb662fef4ab826e351a2c00e38c6962580?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #8825   +/-   ##
=======================================
  Coverage   92.82%   92.82%           
=======================================
  Files        1266     1266           
  Lines      110520   110540   +20     
=======================================
+ Hits       102586   102608   +22     
+ Misses       7934     7932    -2     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8825/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8825/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.15% <20.00%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8825/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.79% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8825?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/moto/ec2/models/vpcs.py b/moto/ec2/models/vpcs.py
index 1ea59d839..cc66ad297 100644
--- a/moto/ec2/models/vpcs.py
+++ b/moto/ec2/models/vpcs.py
@@ -775,6 +775,15 @@ class VPCBackend:
         return matches
 
     def delete_vpc(self, vpc_id: str) -> VPC:
+        # Refuse to delete if there are dependent subnets
+        subnets = self.describe_subnets(filters={"vpc-id": vpc_id})  # type: ignore[attr-defined]
+        if subnets:
+            raise DependencyViolationError(
+                f"The vpc '{vpc_id}' has dependencies and cannot be deleted."
+            )
+
+        # TODO: Refuse to delete if there are dependent IGW
+
         # Do not delete if any VPN Gateway is attached
         vpn_gateways = self.describe_vpn_gateways(filters={"attachment.vpc-id": vpc_id})  # type: ignore[attr-defined]
         vpn_gateways = [
diff --git a/moto/rds/models.py b/moto/rds/models.py
index 3a291b6d6..de0855cab 100644
--- a/moto/rds/models.py
+++ b/moto/rds/models.py
@@ -1454,7 +1454,9 @@ class DBInstance(EventMixin, CloudFormationModel, RDSBaseModel):
             self.master_user_secret = MasterUserSecret(
                 self, master_user_secret_kms_key_id
             )
-        elif manage_master_user_password is False:
+        elif manage_master_user_password is False and hasattr(
+            self, "master_user_secret"
+        ):
             self.master_user_secret.delete_secret()
             del self.master_user_secret
 
@@ -3141,7 +3143,9 @@ class RDSBackend(BaseBackend):
             if manage_master_user_password:
                 kms_key_id = kwargs.pop("master_user_secret_kms_key_id", None)
                 cluster.master_user_secret = MasterUserSecret(cluster, kms_key_id)
-            else:
+            elif not manage_master_user_password and hasattr(
+                cluster, "master_user_secret"
+            ):
                 cluster.master_user_secret.delete_secret()
                 del cluster.master_user_secret
 
diff --git a/moto/ses/models.py b/moto/ses/models.py
index 566ecf951..899bf8694 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -206,6 +206,7 @@ class SESBackend(BaseBackend):
         self.identity_mail_from_domains: Dict[str, Dict[str, Any]] = {}
         self.templates: Dict[str, Dict[str, str]] = {}
         self.receipt_rule_set: Dict[str, List[Dict[str, Any]]] = {}
+        self.dkim_tokens: Dict[str, List[str]] = {}
 
     def _is_verified_address(self, source: str) -> bool:
         _, address = parseaddr(source)
@@ -740,5 +741,42 @@ class SESBackend(BaseBackend):
             config_set.reputation_options = {}
         config_set.reputation_options["ReputationMetricsEnabled"] = enabled
 
+    def get_identity_dkim_attributes(
+        self, identities: List[str]
+    ) -> Dict[str, Dict[str, Any]]:
+        """
+        Returns the current status of Easy DKIM signing for an entity.
+        For domain name identities, this operation also returns the DKIM tokens.
+        """
+        result = {}
+        for identity in identities:
+            is_domain = "@" not in identity
+            dkim_enabled = True
+            verification_status = (
+                "Success"
+                if identity in (self.domains + self.addresses)
+                else "NotStarted"
+            )
+
+            dkim_data = {
+                "DkimEnabled": dkim_enabled,
+                "DkimVerificationStatus": verification_status,
+            }
+
+            # Only include tokens for domain identities
+            if is_domain and verification_status == "Success":
+                if identity not in self.dkim_tokens:
+                    # Generate new DKIM tokens for the domain
+                    self.dkim_tokens[identity] = [
+                        "vvjuipp74whm76gqoni7qmwwn4w4qusjiainivf6sf",
+                        "3frqe7jn4obpuxjpwpolz6ipb3k5nvt2nhjpik2oy",
+                        "wrqplteh7oodxnad7hsl4mixg2uavzneazxv5sxi2",
+                    ]
+                dkim_data["DkimTokens"] = self.dkim_tokens[identity]
+
+            result[identity] = dkim_data
+
+        return result
+
 
 ses_backends = BackendDict(SESBackend, "ses")
diff --git a/moto/ses/responses.py b/moto/ses/responses.py
index 38ddc04b6..b369ba611 100644
--- a/moto/ses/responses.py
+++ b/moto/ses/responses.py
@@ -414,6 +414,12 @@ class EmailResponse(BaseResponse):
         )
         return template.render()
 
+    def get_identity_dkim_attributes(self) -> str:
+        identities = self._get_multi_param("Identities.member.")
+        dkim_attributes = self.backend.get_identity_dkim_attributes(identities)
+        template = self.response_template(GET_IDENTITY_DKIM_ATTRIBUTES_RESPONSE)
+        return template.render(dkim_attributes=dkim_attributes)
+
 
 VERIFY_EMAIL_IDENTITY = """<VerifyEmailIdentityResponse xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
   <VerifyEmailIdentityResult/>
@@ -880,3 +886,29 @@ UPDATE_CONFIGURATION_SET_REPUTATION_METRICS_ENABLED_RESPONSE = """<UpdateConfigu
     <RequestId>47e0ef1a-9bf2-11e1-9279-0100e8cf109a</RequestId>
   </ResponseMetadata>
 </UpdateConfigurationSetReputationMetricsEnabledResponse>"""
+
+GET_IDENTITY_DKIM_ATTRIBUTES_RESPONSE = """<GetIdentityDkimAttributesResponse xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
+  <GetIdentityDkimAttributesResult>
+    <DkimAttributes>
+      {% for identity, attributes in dkim_attributes.items() %}
+      <entry>
+        <key>{{ identity }}</key>
+        <value>
+          <DkimEnabled>{{ attributes.DkimEnabled|lower }}</DkimEnabled>
+          <DkimVerificationStatus>{{ attributes.DkimVerificationStatus }}</DkimVerificationStatus>
+          {% if attributes.get('DkimTokens') %}
+          <DkimTokens>
+            {% for token in attributes.DkimTokens %}
+            <member>{{ token }}</member>
+            {% endfor %}
+          </DkimTokens>
+          {% endif %}
+        </value>
+      </entry>
+      {% endfor %}
+    </DkimAttributes>
+  </GetIdentityDkimAttributesResult>
+  <ResponseMetadata>
+    <RequestId>9662c15b-c469-11e1-99d1-797d6ecd6414</RequestId>
+  </ResponseMetadata>
+</GetIdentityDkimAttributesResponse>"""


Test patch:
diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
index 26df7c3bc..63e33af54 100644
--- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
+++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py
@@ -1361,14 +1361,14 @@ def test_update_stack_deleted_resources_can_reference_deleted_resources():
             "AWSTemplateFormatVersion": "2010-09-09",
             "Parameters": {"TimeoutParameter": {"Default": 61, "Type": "String"}},
             "Resources": {
-                "VPC": {
-                    "Type": "AWS::EC2::VPC",
-                    "Properties": {"CidrBlock": "10.0.0.0/16"},
-                },
                 "Subnet": {
                     "Type": "AWS::EC2::Subnet",
                     "Properties": {"VpcId": {"Ref": "VPC"}, "CidrBlock": "10.0.0.0/24"},
                 },
+                "VPC": {
+                    "Type": "AWS::EC2::VPC",
+                    "Properties": {"CidrBlock": "10.0.0.0/16"},
+                },
             },
         }
     )
diff --git a/tests/test_ec2/test_vpcs.py b/tests/test_ec2/test_vpcs.py
index 221b66a31..68e12fe55 100644
--- a/tests/test_ec2/test_vpcs.py
+++ b/tests/test_ec2/test_vpcs.py
@@ -23,6 +23,11 @@ def test_creating_a_vpc_in_empty_region_does_not_make_this_vpc_the_default():
     client = boto3.client("ec2", region_name="eu-north-1")
     all_vpcs = retrieve_all_vpcs(client)
     for vpc in all_vpcs:
+        subnets = client.describe_subnets(
+            Filters=[{"Name": "vpc-id", "Values": [vpc["VpcId"]]}]
+        )["Subnets"]
+        for subnet in subnets:
+            client.delete_subnet(SubnetId=subnet["SubnetId"])
         client.delete_vpc(VpcId=vpc["VpcId"])
     # create vpc
     client.create_vpc(CidrBlock="10.0.0.0/16")
@@ -40,6 +45,11 @@ def test_create_default_vpc():
     client = boto3.client("ec2", region_name="eu-north-1")
     all_vpcs = retrieve_all_vpcs(client)
     for vpc in all_vpcs:
+        subnets = client.describe_subnets(
+            Filters=[{"Name": "vpc-id", "Values": [vpc["VpcId"]]}]
+        )["Subnets"]
+        for subnet in subnets:
+            client.delete_subnet(SubnetId=subnet["SubnetId"])
         client.delete_vpc(VpcId=vpc["VpcId"])
     # create default vpc
     client.create_default_vpc()
@@ -1238,3 +1248,21 @@ def test_describe_prefix_lists():
         ]
     )
     assert len(result_filtered["PrefixLists"]) == 1
+
+
+@mock_aws
+def test_delete_vpc_with_subnet_dependency():
+    client = boto3.client("ec2", region_name="us-east-1")
+    vpc = client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]
+    subnet = client.create_subnet(VpcId=vpc["VpcId"], CidrBlock="10.0.1.0/24")["Subnet"]
+
+    with pytest.raises(ClientError) as exc:
+        client.delete_vpc(VpcId=vpc["VpcId"])
+    assert exc.value.response["Error"]["Code"] == "DependencyViolation"
+    assert (
+        exc.value.response["Error"]["Message"]
+        == f"The vpc '{vpc['VpcId']}' has dependencies and cannot be deleted."
+    )
+
+    client.delete_subnet(SubnetId=subnet["SubnetId"])
+    client.delete_vpc(VpcId=vpc["VpcId"])
diff --git a/tests/test_rds/test_rds.py b/tests/test_rds/test_rds.py
index 7fdf0446c..a9650033e 100644
--- a/tests/test_rds/test_rds.py
+++ b/tests/test_rds/test_rds.py
@@ -486,6 +486,10 @@ def test_modify_db_instance_manage_master_user_password(
         DBInstanceIdentifier=db_id, ManageMasterUserPassword=False
     )
 
+    retry_revert_modification_response = client.modify_db_instance(
+        DBInstanceIdentifier=db_id, ManageMasterUserPassword=False
+    )
+
     assert db_instance.get("MasterUserSecret") is None
     master_user_secret = modify_response["DBInstance"]["MasterUserSecret"]
     assert len(master_user_secret.keys()) == 3
@@ -510,6 +514,9 @@ def test_modify_db_instance_manage_master_user_password(
         == describe_response["DBInstances"][0]["MasterUserSecret"]["SecretArn"]
     )
     assert revert_modification_response["DBInstance"].get("MasterUserSecret") is None
+    assert (
+        retry_revert_modification_response["DBInstance"].get("MasterUserSecret") is None
+    )
 
 
 @pytest.mark.parametrize("with_apply_immediately", [True, False])
diff --git a/tests/test_rds/test_rds_clusters.py b/tests/test_rds/test_rds_clusters.py
index dd30ab133..1570656c2 100644
--- a/tests/test_rds/test_rds_clusters.py
+++ b/tests/test_rds/test_rds_clusters.py
@@ -189,6 +189,10 @@ def test_modify_db_cluster_manage_master_user_password(client, with_custom_kms_k
         DBClusterIdentifier=cluster_id, ManageMasterUserPassword=False
     )
 
+    retry_revert_modification_response = client.modify_db_cluster(
+        DBClusterIdentifier=cluster_id, ManageMasterUserPassword=False
+    )
+
     assert create_response["DBCluster"].get("MasterUserSecret") is None
     master_user_secret = modify_response["DBCluster"]["MasterUserSecret"]
     assert len(master_user_secret.keys()) == 3
@@ -213,6 +217,9 @@ def test_modify_db_cluster_manage_master_user_password(client, with_custom_kms_k
         == describe_response["DBClusters"][0]["MasterUserSecret"]["SecretArn"]
     )
     assert revert_modification_response["DBCluster"].get("MasterUserSecret") is None
+    assert (
+        retry_revert_modification_response["DBCluster"].get("MasterUserSecret") is None
+    )
 
 
 @pytest.mark.parametrize("with_apply_immediately", [True, False])
diff --git a/tests/test_ses/test_ses_boto3.py b/tests/test_ses/test_ses_boto3.py
index f35075da5..c4a1a4b40 100644
--- a/tests/test_ses/test_ses_boto3.py
+++ b/tests/test_ses/test_ses_boto3.py
@@ -1574,3 +1574,46 @@ def test_update_configuration_set_reputation_metrics():
             ConfigurationSetName="non-existent", Enabled=True
         )
     assert e.value.response["Error"]["Code"] == "ConfigurationSetDoesNotExist"
+
+
+@mock_aws
+def test_get_identity_dkim_attributes():
+    conn = boto3.client("ses", region_name="us-east-1")
+
+    # Create domain identity
+    domain = "example.com"
+    conn.verify_domain_identity(Domain=domain)
+
+    # Create email identity
+    email = "test@example.com"
+    conn.verify_email_identity(EmailAddress=email)
+
+    # Test getting DKIM attributes for both domain and email
+    response = conn.get_identity_dkim_attributes(Identities=[domain, email])
+
+    # Verify response structure
+    attributes = response["DkimAttributes"]
+    assert len(attributes) == 2
+
+    # Verify domain attributes
+    domain_attrs = attributes[domain]
+    assert domain_attrs["DkimEnabled"] is True
+    assert domain_attrs["DkimVerificationStatus"] == "Success"
+    assert len(domain_attrs["DkimTokens"]) == 3
+    for token in domain_attrs["DkimTokens"]:
+        assert isinstance(token, str)
+        assert len(token) > 0
+
+    # Verify email attributes
+    email_attrs = attributes[email]
+    assert email_attrs["DkimEnabled"] is True
+    assert email_attrs["DkimVerificationStatus"] == "Success"
+    assert "DkimTokens" not in email_attrs  # Email identities don't have DKIM tokens
+
+    # Test unverified identity
+    unverified = "unverified.com"
+    response = conn.get_identity_dkim_attributes(Identities=[unverified])
+    unverified_attrs = response["DkimAttributes"][unverified]
+    assert unverified_attrs["DkimEnabled"] is True
+    assert unverified_attrs["DkimVerificationStatus"] == "NotStarted"
+    assert "DkimTokens" not in unverified_attrs