Repository: getmoto/moto

Issue:
Route53 `change_resource_record_sets` delete calls succeed when it should fail
## Description
Deleting a resource record set should require that we specify all the same values that we specified when we created it according to these [docs](https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html#API_ChangeResourceRecordSets_RequestSyntax).

If a record was created with `Name`, `Type`, `TTL`, and `ResourceRecords`, deleting should fail unless all of the values are specified. This is true in `boto3`, and missing `TTL` and `ResourceRecords` results in this error:

```
botocore.errorfactory.InvalidInput: An error occurred (InvalidInput) when calling the ChangeResourceRecordSets operation: Invalid request: Expected exactly one of [AliasTarget, all of [TTL, and ResourceRecords], or TrafficPolicyInstanceId], but found none in Change with [Action=DELETE, Name=example.com, Type=A, SetIdentifier=null]
exit status 1
```

In `moto`, this succeeds. The code snippet below provides an example that should error at `delete_call`, but does not.

## Steps to reproduce
```
import boto3
import moto


@moto.mock_route53
def test():
    client = boto3.client("route53")
    name = "example.com"
    hosted_zone_id = client.create_hosted_zone(Name=name, CallerReference=name)["HostedZone"]["Id"]

    create_call = client.change_resource_record_sets(
        HostedZoneId=hosted_zone_id,
        ChangeBatch={
            "Changes": [
                {
                    "Action": "DELETE",
                    "ResourceRecordSet": {
                        "Name": name,
                        "Type": "A",
                        "TTL": 300,
                        "ResourceRecords": [{"Value": "192.168.0.1"}],
                    },
                }
            ]
        },
    )
    waiter = client.get_waiter("resource_record_sets_changed")
    waiter.wait(Id=create_call["ChangeInfo"]["Id"])
    
    # This call should fail
    delete_call = client.change_resource_record_sets(
        HostedZoneId=hosted_zone_id,
        ChangeBatch={
            "Changes": [
                {
                    "Action": "DELETE",
                    "ResourceRecordSet": {
                        "Name": name,
                        "Type": "A",
                        # Missing TTL and ResourceRecords
                    },
                }
            ]
        },
    )


test()

```
`delete_call` should raise `InvalidInput` when insufficient arguments are provided.

### Versions:
moto: 4.0.10
boto3: 1.17.59



Discussion:
Hi @k6l3, welcome to Moto! Thanks for raising this and for providing a reproducible test case.

Would you like to submit a PR for this yourself?
Bandwidth to fix is low; I'll raise a PR if I get the time but it may take a while.

Resolving patch:
diff --git a/moto/route53/models.py b/moto/route53/models.py
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -20,6 +20,7 @@
     QueryLoggingConfigAlreadyExists,
     DnsNameInvalidForZone,
     ChangeSetAlreadyExists,
+    InvalidInput,
 )
 from moto.core import BaseBackend, BackendDict, BaseModel, CloudFormationModel
 from moto.moto_api._internal import mock_random as random
@@ -262,6 +263,20 @@ def reverse_domain_name(domain_name):
     return ".".join(reversed(domain_name.split(".")))
 
 
+class ChangeList(list):
+    """
+    Contains a 'clean' list of ResourceRecordChangeSets
+    """
+
+    def append(self, item) -> None:
+        item["ResourceRecordSet"]["Name"] = item["ResourceRecordSet"]["Name"].strip(".")
+        super().append(item)
+
+    def __contains__(self, item):
+        item["ResourceRecordSet"]["Name"] = item["ResourceRecordSet"]["Name"].strip(".")
+        return super().__contains__(item)
+
+
 class FakeZone(CloudFormationModel):
     def __init__(
         self,
@@ -279,7 +294,7 @@ def __init__(
         self.private_zone = private_zone
         self.rrsets = []
         self.delegation_set = delegation_set
-        self.rr_changes = []
+        self.rr_changes = ChangeList()
 
     def add_rrset(self, record_set):
         record_set = RecordSet(record_set)
@@ -535,6 +550,20 @@ def change_resource_record_sets(self, zoneid, change_list) -> None:
         if any([rr for rr in change_list if rr in the_zone.rr_changes]):
             raise ChangeSetAlreadyExists
 
+        for value in change_list:
+            if value["Action"] == "DELETE":
+                # To delete a resource record set, you must specify all the same values that you specified when you created it.
+                corresponding_create = copy.deepcopy(value)
+                corresponding_create["Action"] = "CREATE"
+                corresponding_upsert = copy.deepcopy(value)
+                corresponding_upsert["Action"] = "UPSERT"
+                if (
+                    corresponding_create not in the_zone.rr_changes
+                    and corresponding_upsert not in the_zone.rr_changes
+                ):
+                    msg = f"Invalid request: Expected exactly one of [AliasTarget, all of [TTL, and ResourceRecords], or TrafficPolicyInstanceId], but found none in Change with [Action=DELETE, Name={value['ResourceRecordSet']['Name']}, Type={value['ResourceRecordSet']['Type']}, SetIdentifier={value['ResourceRecordSet'].get('SetIdentifier', 'null')}]"
+                    raise InvalidInput(msg)
+
         for value in change_list:
             original_change = copy.deepcopy(value)
             action = value["Action"]


Test patch:
diff --git a/tests/test_route53/test_change_set_model.py b/tests/test_route53/test_change_set_model.py
new file mode 100644
--- /dev/null
+++ b/tests/test_route53/test_change_set_model.py
@@ -0,0 +1,58 @@
+import copy
+import sure  # noqa # pylint: disable=unused-import
+from moto.route53.models import ChangeList
+
+
+def test_last_dot_in_name_is_ignored():
+    change1 = {
+        "Action": "CREATE",
+        "ResourceRecordSet": {
+            "Name": "test.without.dot",
+            "Type": "CNAME",
+            "TTL": 600,
+            "ResourceRecords": [{"Value": "0.0.0.0"}],
+        },
+    }
+    change1_alt = copy.deepcopy(change1)
+    change1_alt["ResourceRecordSet"]["Name"] = "test.without.dot."
+
+    change2 = copy.deepcopy(change1)
+    change2["ResourceRecordSet"]["Name"] = "test.with.dot."
+    change2_alt = copy.deepcopy(change1)
+    change2_alt["ResourceRecordSet"]["Name"] = "test.with.dot"
+
+    change_list = ChangeList()
+    change_list.append(change1)
+    change_list.append(change2)
+
+    change_list.should.contain(change1)
+    change_list.should.contain(change1_alt)
+    change_list.should.contain(change2)
+    change_list.should.contain(change2_alt)
+
+
+def test_last_dot_is_not_stored():
+    change1 = {
+        "Action": "CREATE",
+        "ResourceRecordSet": {
+            "Name": "test.google.com.",
+            "Type": "CNAME",
+            "TTL": 600,
+            "ResourceRecords": [{"Value": "0.0.0.0"}],
+        },
+    }
+    change_list = ChangeList()
+    change_list.append(change1)
+
+    change_list[0]["ResourceRecordSet"]["Name"].should.equal("test.google.com")
+
+
+def test_optional_fields():
+    change = {
+        "Action": "UPSERT",
+        "ResourceRecordSet": {"Name": "test.google.com.", "Type": "CNAME"},
+    }
+    change_list = ChangeList()
+    change_list.append(change)
+
+    change_list.should.equal([change])
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -308,6 +308,7 @@ def test_deleting_weighted_route():
                         "Name": "cname.testdns.aws.com",
                         "Type": "CNAME",
                         "SetIdentifier": "success-test-foo",
+                        "Weight": 50,
                     },
                 }
             ]
@@ -374,6 +375,8 @@ def test_deleting_latency_route():
                         "Name": "cname.testdns.aws.com",
                         "Type": "CNAME",
                         "SetIdentifier": "success-test-foo",
+                        "Region": "us-west-2",
+                        "ResourceRecords": [{"Value": "example.com"}],
                     },
                 }
             ]
@@ -708,29 +711,18 @@ def test_change_resource_record_sets_crud_valid():
     )
     cname_alias_record_detail.should_not.contain("ResourceRecords")
 
-    # Delete record with wrong type.
-    delete_payload = {
-        "Comment": "delete prod.redis.db",
-        "Changes": [
-            {
-                "Action": "DELETE",
-                "ResourceRecordSet": {"Name": "prod.redis.db", "Type": "CNAME"},
-            }
-        ],
-    }
-    conn.change_resource_record_sets(
-        HostedZoneId=hosted_zone_id, ChangeBatch=delete_payload
-    )
-    response = conn.list_resource_record_sets(HostedZoneId=hosted_zone_id)
-    len(response["ResourceRecordSets"]).should.equal(2)
-
     # Delete record.
     delete_payload = {
         "Comment": "delete prod.redis.db",
         "Changes": [
             {
                 "Action": "DELETE",
-                "ResourceRecordSet": {"Name": "prod.redis.db", "Type": "A"},
+                "ResourceRecordSet": {
+                    "Name": "prod.redis.db.",
+                    "Type": "A",
+                    "TTL": 60,
+                    "ResourceRecords": [{"Value": "192.168.1.1"}],
+                },
             }
         ],
     }
@@ -818,7 +810,12 @@ def test_change_resource_record_sets_crud_valid_with_special_xml_chars():
         "Changes": [
             {
                 "Action": "DELETE",
-                "ResourceRecordSet": {"Name": "prod.redis.db", "Type": "TXT"},
+                "ResourceRecordSet": {
+                    "Name": "prod.redis.db.",
+                    "Type": "TXT",
+                    "TTL": 10,
+                    "ResourceRecords": [{"Value": "SomeInitialValue"}],
+                },
             }
         ],
     }
@@ -829,6 +826,57 @@ def test_change_resource_record_sets_crud_valid_with_special_xml_chars():
     len(response["ResourceRecordSets"]).should.equal(1)
 
 
+@mock_route53
+def test_change_resource_record_set__delete_should_match_create():
+    # To delete a resource record set, you must specify all the same values that you specified when you created it.
+    client = boto3.client("route53", region_name="us-east-1")
+    name = "example.com"
+    hosted_zone_id = client.create_hosted_zone(Name=name, CallerReference=name)[
+        "HostedZone"
+    ]["Id"]
+
+    create_call = client.change_resource_record_sets(
+        HostedZoneId=hosted_zone_id,
+        ChangeBatch={
+            "Changes": [
+                {
+                    "Action": "CREATE",
+                    "ResourceRecordSet": {
+                        "Name": name,
+                        "Type": "A",
+                        "TTL": 300,
+                        "ResourceRecords": [{"Value": "192.168.0.1"}],
+                    },
+                }
+            ]
+        },
+    )
+    waiter = client.get_waiter("resource_record_sets_changed")
+    waiter.wait(Id=create_call["ChangeInfo"]["Id"])
+
+    with pytest.raises(ClientError) as exc:
+        client.change_resource_record_sets(
+            HostedZoneId=hosted_zone_id,
+            ChangeBatch={
+                "Changes": [
+                    {
+                        "Action": "DELETE",
+                        "ResourceRecordSet": {
+                            "Name": name,
+                            "Type": "A"
+                            # Missing TTL and ResourceRecords
+                        },
+                    }
+                ]
+            },
+        )
+    err = exc.value.response["Error"]
+    err["Code"].should.equal("InvalidInput")
+    err["Message"].should.equal(
+        "Invalid request: Expected exactly one of [AliasTarget, all of [TTL, and ResourceRecords], or TrafficPolicyInstanceId], but found none in Change with [Action=DELETE, Name=example.com, Type=A, SetIdentifier=null]"
+    )
+
+
 @mock_route53
 def test_change_weighted_resource_record_sets():
     conn = boto3.client("route53", region_name="us-east-2")