Repository: getmoto/moto
Pull request #6425 (2023-06-19 14:35:43)

EC2: Return appropriate error when calling EC2.Client.replace_route() with missing route
Currently the `EC2.Client.replace_route()` API does not error if a missing route is given, which is inconsistent with AWS behaviour.

The expected error code is `InvalidRoute.NotFound`, in line with what's returned from `delete_route()` and similar cases such as `InvalidRouteTable.NotFound` being returned if an invalid route table ID is given. However, AWS current behaviour is to return `InvalidParameterValue` instead, so that's what this PR gets moto to do (the [docs](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ReplaceRoute.html) are vague about what error code to expect, but this has been manually checked). This issue has been raised with AWS via a case (unfortunately no public link available), but there is not yet agreement to fix this.


Patch:
diff --git a/.github/workflows/payment_notification.yml b/.github/workflows/payment_notification.yml
index f2c51e27f..d12770498 100644
--- a/.github/workflows/payment_notification.yml
+++ b/.github/workflows/payment_notification.yml
@@ -22,7 +22,8 @@ jobs:
             issue_number: context.issue.number,
             owner: context.repo.owner,
             repo: context.repo.repo,
-            body: `Hi @${{ github.event.pull_request.user.login }},
+            body: |
+              Hi @${{ github.event.pull_request.user.login }},
 
               Thank you for contributing to Moto!
 
@@ -32,5 +33,5 @@ jobs:
               https://payments.getmoto.org/
 
               This website is still in beta, but everything should work. Feel free to open a bug or discussion if you run into any problems:
-              https://github.com/getmoto/payments`
+              https://github.com/getmoto/payments
           })
diff --git a/moto/core/base_backend.py b/moto/core/base_backend.py
index b5ce8a377..87f7e5909 100644
--- a/moto/core/base_backend.py
+++ b/moto/core/base_backend.py
@@ -55,22 +55,13 @@ class BaseBackend:
         urls = {}
         for url_base in url_bases:
             # The default URL_base will look like: http://service.[..].amazonaws.com/...
-            # This extension ensures support for the China & ISO regions
-            alt_dns_suffixes = {
-                "cn": "amazonaws.com.cn",
-                "iso": "c2s.ic.gov",
-                "isob": "sc2s.sgov.gov",
-                "isoe": "cloud.adc-e.uk",
-                "isof": "csp.hci.ic.gov",
-            }
-
+            # This extension ensures support for the China regions
+            cn_url_base = re.sub(r"amazonaws\\?.com$", "amazonaws.com.cn", url_base)
             for url_path, handler in unformatted_paths.items():
                 url = url_path.format(url_base)
                 urls[url] = handler
-                for dns_suffix in alt_dns_suffixes.values():
-                    alt_url_base = re.sub(r"amazonaws\\?.com$", dns_suffix, url_base)
-                    alt_url = url_path.format(alt_url_base)
-                    urls[alt_url] = handler
+                cn_url = url_path.format(cn_url_base)
+                urls[cn_url] = handler
 
         return urls
 
@@ -197,10 +188,13 @@ class AccountSpecificBackend(Dict[str, SERVICE_BACKEND]):
         self.regions = []
         if use_boto3_regions:
             sess = Session()
-            for partition in sess.get_available_partitions():
-                self.regions.extend(
-                    sess.get_available_regions(service_name, partition_name=partition)
-                )
+            self.regions.extend(sess.get_available_regions(service_name))
+            self.regions.extend(
+                sess.get_available_regions(service_name, partition_name="aws-us-gov")
+            )
+            self.regions.extend(
+                sess.get_available_regions(service_name, partition_name="aws-cn")
+            )
         self.regions.extend(additional_regions or [])
         self._id = str(uuid4())
 
diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py
index 6eb8d515e..780ed933c 100644
--- a/moto/ec2/exceptions.py
+++ b/moto/ec2/exceptions.py
@@ -444,6 +444,14 @@ class InvalidParameterValueErrorTagSpotFleetRequest(EC2ClientError):
         )
 
 
+class InvalidParameterValueErrorReplaceRoute(EC2ClientError):
+    def __init__(self, cidr: str):
+        super().__init__(
+            "InvalidParameterValue",
+            f"There is no route defined for '{cidr}' in the route table. Use CreateRoute instead.",
+        )
+
+
 class EmptyTagSpecError(EC2ClientError):
     def __init__(self) -> None:
         super().__init__(
diff --git a/moto/ec2/models/route_tables.py b/moto/ec2/models/route_tables.py
index de6217d09..9582f1167 100644
--- a/moto/ec2/models/route_tables.py
+++ b/moto/ec2/models/route_tables.py
@@ -18,6 +18,7 @@ from ..exceptions import (
     InvalidRouteTableIdError,
     InvalidAssociationIdError,
     InvalidDestinationCIDRBlockParameterError,
+    InvalidParameterValueErrorReplaceRoute,
     RouteAlreadyExistsError,
     RouteNotSupportedError,
 )
@@ -447,11 +448,22 @@ class RouteBackend:
         interface_id: Optional[str] = None,
         vpc_peering_connection_id: Optional[str] = None,
     ) -> Route:
+        cidr = destination_cidr_block
+        if destination_ipv6_cidr_block:
+            cidr = destination_ipv6_cidr_block
+        if destination_prefix_list_id:
+            cidr = destination_prefix_list_id
         route_table = self.get_route_table(route_table_id)
         route_id = generate_route_id(
             route_table.id, destination_cidr_block, destination_ipv6_cidr_block
         )
-        route = route_table.routes[route_id]
+        try:
+            route = route_table.routes[route_id]
+        except KeyError:
+            # This should be 'raise InvalidRouteError(route_table_id, cidr)' in
+            # line with the delete_route() equivalent, but for some reason AWS
+            # returns InvalidParameterValue instead in this case.
+            raise InvalidParameterValueErrorReplaceRoute(cidr) from None
 
         route.gateway = None
         route.nat_gateway = None
diff --git a/moto/iam/models.py b/moto/iam/models.py
index a41c76214..c1d666d0c 100644
--- a/moto/iam/models.py
+++ b/moto/iam/models.py
@@ -2375,12 +2375,7 @@ class IAMBackend(BaseBackend):
     def add_role_to_instance_profile(self, profile_name: str, role_name: str) -> None:
         profile = self.get_instance_profile(profile_name)
         role = self.get_role(role_name)
-        if not profile.roles:
-            profile.roles.append(role)
-        else:
-            raise IAMLimitExceededException(
-                "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1"
-            )
+        profile.roles.append(role)
 
     def remove_role_from_instance_profile(
         self, profile_name: str, role_name: str
diff --git a/moto/ses/models.py b/moto/ses/models.py
index 0bae8295a..ffc8bf6e5 100644
--- a/moto/ses/models.py
+++ b/moto/ses/models.py
@@ -2,7 +2,7 @@ import json
 import email
 import datetime
 from email.mime.base import MIMEBase
-from email.utils import formataddr, getaddresses, parseaddr
+from email.utils import parseaddr
 from email.mime.multipart import MIMEMultipart
 from email.encoders import encode_7or8bit
 from typing import Any, Dict, List, Optional
@@ -350,12 +350,8 @@ class SESBackend(BaseBackend):
                     f"Did not have authority to send from email {source}"
                 )
 
-        fieldvalues = [message.get(header, "") for header in ["TO", "CC", "BCC"]]
-        destinations += [
-            formataddr((realname, email_address))
-            for realname, email_address in getaddresses(fieldvalues)
-            if email_address
-        ]
+        for header in "TO", "CC", "BCC":
+            destinations += [d.strip() for d in message.get(header, "").split(",") if d]
         if len(destinations) > RECIPIENT_LIMIT:
             raise MessageRejectedError("Too many recipients.")
         for address in [addr for addr in [source, *destinations] if addr is not None]:
diff --git a/moto/sesv2/responses.py b/moto/sesv2/responses.py
index 84dc36cff..3eca20ba1 100644
--- a/moto/sesv2/responses.py
+++ b/moto/sesv2/responses.py
@@ -6,7 +6,7 @@ from moto.core.responses import BaseResponse
 from .models import sesv2_backends
 from ..ses.responses import SEND_EMAIL_RESPONSE
 from .models import SESV2Backend
-from typing import List
+from typing import List, Dict, Any
 from urllib.parse import unquote
 
 
@@ -56,7 +56,7 @@ class SESV2Response(BaseResponse):
         return template.render(message=message)
 
     def create_contact_list(self) -> str:
-        params = json.loads(self.body)
+        params = get_params_dict(self.data)
         self.sesv2_backend.create_contact_list(params)
         return json.dumps({})
 
@@ -76,7 +76,7 @@ class SESV2Response(BaseResponse):
 
     def create_contact(self) -> str:
         contact_list_name = self._get_param("ContactListName")
-        params = json.loads(self.body)
+        params = get_params_dict(self.data)
         self.sesv2_backend.create_contact(contact_list_name, params)
         return json.dumps({})
 
@@ -96,3 +96,8 @@ class SESV2Response(BaseResponse):
         contact_list_name = self._get_param("ContactListName")
         self.sesv2_backend.delete_contact(unquote(email), contact_list_name)
         return json.dumps({})
+
+
+def get_params_dict(odict: Dict[str, Any]) -> Any:
+    # parsing of these params is nasty, hopefully there is a tidier way
+    return json.loads(list(dict(odict.items()).keys())[0])


Test patch:
diff --git a/tests/test_awslambda/test_lambda.py b/tests/test_awslambda/test_lambda.py
index e6a1a9e56..7b3fb6782 100644
--- a/tests/test_awslambda/test_lambda.py
+++ b/tests/test_awslambda/test_lambda.py
@@ -25,7 +25,7 @@ _lambda_region = "us-west-2"
 boto3.setup_default_session(region_name=_lambda_region)
 
 
-@pytest.mark.parametrize("region", ["us-west-2", "cn-northwest-1", "us-isob-east-1"])
+@pytest.mark.parametrize("region", ["us-west-2", "cn-northwest-1"])
 @mock_lambda
 def test_lambda_regions(region):
     client = boto3.client("lambda", region_name=region)
diff --git a/tests/test_ec2/test_route_tables.py b/tests/test_ec2/test_route_tables.py
index 70b70bd67..a20e5b68c 100644
--- a/tests/test_ec2/test_route_tables.py
+++ b/tests/test_ec2/test_route_tables.py
@@ -643,6 +643,18 @@ def test_routes_replace():
     ex.value.response["ResponseMetadata"].should.have.key("RequestId")
     ex.value.response["Error"]["Code"].should.equal("InvalidRouteTableID.NotFound")
 
+    with pytest.raises(ClientError) as ex:
+        client.replace_route(
+            RouteTableId=main_route_table.id,
+            DestinationCidrBlock="1.1.1.1/32",
+            NetworkInterfaceId=eni.id,
+        )
+    ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.value.response["ResponseMetadata"].should.have.key("RequestId")
+    # This should be 'InvalidRoute.NotFound' in line with the delete_route()
+    # equivalent, but for some reason AWS returns InvalidParameterValue instead.
+    ex.value.response["Error"]["Code"].should.equal("InvalidParameterValue")
+
 
 @mock_ec2
 def test_routes_already_exist():
diff --git a/tests/test_iam/test_iam.py b/tests/test_iam/test_iam.py
index a73c40064..bbc8f6ac3 100644
--- a/tests/test_iam/test_iam.py
+++ b/tests/test_iam/test_iam.py
@@ -69,23 +69,6 @@ MOCK_POLICY_3 = """
 }
 """
 
-MOCK_STS_EC2_POLICY_DOCUMENT = """{
-      "Version": "2012-10-17",
-      "Statement": [
-        {
-          "Effect": "Allow",
-          "Principal": {
-            "Service": [
-              "ec2.amazonaws.com"
-            ]
-          },
-          "Action": [
-            "sts:AssumeRole"
-          ]
-        }
-      ]
-    }"""
-
 
 @mock_iam
 def test_get_role__should_throw__when_role_does_not_exist():
@@ -170,35 +153,6 @@ def test_create_instance_profile_should_throw_when_name_is_not_unique():
         conn.create_instance_profile(InstanceProfileName="unique-instance-profile")
 
 
-@mock_iam
-def test_create_add_additional_roles_to_instance_profile_error():
-
-    # Setup
-    iam = boto3.client("iam", region_name="us-east-1")
-    name = "test_profile"
-    role_name = "test_role"
-    role_name2 = "test_role2"
-    iam.create_instance_profile(InstanceProfileName=name)
-    iam.create_role(
-        RoleName=role_name, AssumeRolePolicyDocument=MOCK_STS_EC2_POLICY_DOCUMENT
-    )
-    iam.create_role(
-        RoleName=role_name2, AssumeRolePolicyDocument=MOCK_STS_EC2_POLICY_DOCUMENT
-    )
-    iam.add_role_to_instance_profile(InstanceProfileName=name, RoleName=role_name)
-
-    # Execute
-    with pytest.raises(ClientError) as exc:
-        iam.add_role_to_instance_profile(InstanceProfileName=name, RoleName=role_name2)
-
-    # Verify
-    err = exc.value.response["Error"]
-    assert err["Code"].should.equal("LimitExceeded")
-    assert err["Message"].should.equal(
-        "Cannot exceed quota for InstanceSessionsPerInstanceProfile: 1"
-    )
-
-
 @mock_iam
 def test_remove_role_from_instance_profile():
     conn = boto3.client("iam", region_name="us-east-1")
@@ -466,7 +420,20 @@ def test_update_assume_role_valid_policy():
     conn.create_role(
         RoleName="my-role", AssumeRolePolicyDocument="some policy", Path="my-path"
     )
-    policy_document = MOCK_STS_EC2_POLICY_DOCUMENT
+    policy_document = """
+    {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Effect": "Allow",
+                "Principal": {
+                    "Service": ["ec2.amazonaws.com"]
+                },
+                "Action": ["sts:AssumeRole"]
+            }
+        ]
+    }
+"""
     conn.update_assume_role_policy(RoleName="my-role", PolicyDocument=policy_document)
     role = conn.get_role(RoleName="my-role")["Role"]
     role["AssumeRolePolicyDocument"]["Statement"][0]["Action"][0].should.equal(
@@ -3414,7 +3381,21 @@ def test_list_user_tags():
 def test_delete_role_with_instance_profiles_present():
     iam = boto3.client("iam", region_name="us-east-1")
 
-    trust_policy = MOCK_STS_EC2_POLICY_DOCUMENT.strip()
+    trust_policy = """
+    {
+      "Version": "2012-10-17",
+      "Statement": [
+        {
+          "Effect": "Allow",
+          "Principal": {
+            "Service": "ec2.amazonaws.com"
+          },
+          "Action": "sts:AssumeRole"
+        }
+      ]
+    }
+        """
+    trust_policy = trust_policy.strip()
 
     iam.create_role(RoleName="Role1", AssumeRolePolicyDocument=trust_policy)
     iam.create_instance_profile(InstanceProfileName="IP1")
diff --git a/tests/test_sesv2/test_sesv2.py b/tests/test_sesv2/test_sesv2.py
index 7f9179d02..2cab6638f 100644
--- a/tests/test_sesv2/test_sesv2.py
+++ b/tests/test_sesv2/test_sesv2.py
@@ -102,41 +102,6 @@ def test_send_raw_email__with_specific_message(
         assert msg.destinations == ["to@example.com", "foo@example.com"]
 
 
-@mock_sesv2
-def test_send_raw_email__with_to_address_display_name(
-    ses_v1,
-):  # pylint: disable=redefined-outer-name
-    # Setup
-    conn = boto3.client("sesv2", region_name="us-east-1")
-    message = get_raw_email()
-    # This particular message means that to-address with display-name which contains many ','
-    del message["To"]
-    display_name = ",".join(["c" for _ in range(50)])
-    message["To"] = f""""{display_name}" <to@example.com>, foo@example.com"""
-    kwargs = dict(
-        Content={"Raw": {"Data": message.as_bytes()}},
-    )
-
-    # Execute
-    ses_v1.verify_email_identity(EmailAddress="test@example.com")
-    conn.send_email(**kwargs)
-
-    # Verify
-    send_quota = ses_v1.get_send_quota()
-    sent_count = int(send_quota["SentLast24Hours"])
-    assert sent_count == 2
-
-    if not settings.TEST_SERVER_MODE:
-        backend = ses_backends[DEFAULT_ACCOUNT_ID]["us-east-1"]
-        msg: RawMessage = backend.sent_messages[0]
-        assert message.as_bytes() == msg.raw_data.encode("utf-8")
-        assert msg.source == "test@example.com"
-        assert msg.destinations == [
-            """"c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c" <to@example.com>""",
-            "foo@example.com",
-        ]
-
-
 @mock_sesv2
 def test_create_contact_list():
     # Setup
@@ -154,30 +119,6 @@ def test_create_contact_list():
     assert result["ContactLists"][0]["ContactListName"] == contact_list_name
 
 
-@mock_sesv2
-def test_create_contact_list__with_topics():
-    # Setup
-    conn = boto3.client("sesv2", region_name="us-east-1")
-    contact_list_name = "test3"
-
-    # Execute
-    conn.create_contact_list(
-        ContactListName=contact_list_name,
-        Topics=[
-            {
-                "TopicName": "test-topic",
-                "DisplayName": "display=name",
-                "DefaultSubscriptionStatus": "OPT_OUT",
-            }
-        ],
-    )
-    result = conn.list_contact_lists()
-
-    # Verify
-    assert len(result["ContactLists"]) == 1
-    assert result["ContactLists"][0]["ContactListName"] == contact_list_name
-
-
 @mock_sesv2
 def test_list_contact_lists():
     # Setup
diff --git a/tests/test_stepfunctions/test_stepfunctions.py b/tests/test_stepfunctions/test_stepfunctions.py
index 04b129702..d68e17c2f 100644
--- a/tests/test_stepfunctions/test_stepfunctions.py
+++ b/tests/test_stepfunctions/test_stepfunctions.py
@@ -888,9 +888,7 @@ def test_state_machine_get_execution_history_contains_expected_success_events_wh
     execution_history["events"].should.equal(expected_events)
 
 
-@pytest.mark.parametrize(
-    "test_region", ["us-west-2", "cn-northwest-1", "us-isob-east-1"]
-)
+@pytest.mark.parametrize("test_region", ["us-west-2", "cn-northwest-1"])
 @mock_stepfunctions
 def test_stepfunction_regions(test_region):
     client = boto3.client("stepfunctions", region_name=test_region)
diff --git a/tests/test_sts/test_sts.py b/tests/test_sts/test_sts.py
index 417089cd5..e1c446a05 100644
--- a/tests/test_sts/test_sts.py
+++ b/tests/test_sts/test_sts.py
@@ -707,7 +707,7 @@ def test_federation_token_with_too_long_policy():
     )
 
 
-@pytest.mark.parametrize("region", ["us-west-2", "cn-northwest-1", "us-isob-east-1"])
+@pytest.mark.parametrize("region", ["us-west-2", "cn-northwest-1"])
 @mock_sts
 def test_sts_regions(region):
     client = boto3.client("sts", region_name=region)