From 958a129f97b52a580144648183209b0ee900cb55 Mon Sep 17 00:00:00 2001
From: kbalk <7536198+kbalk@users.noreply.github.com>
Date: Wed, 17 Nov 2021 15:06:35 -0500
Subject: [PATCH] Route53Resolver: Add resolver endpoint-related APIs (#4559)
---
IMPLEMENTATION_COVERAGE.md | 72 +-
docs/docs/services/route53resolver.rst | 92 +++
moto/__init__.py | 3 +
moto/backend_index.py | 8 +-
moto/route53/urls.py | 2 +-
moto/route53resolver/__init__.py | 5 +
moto/route53resolver/exceptions.py | 95 +++
moto/route53resolver/models.py | 397 ++++++++++
moto/route53resolver/responses.py | 146 ++++
moto/route53resolver/urls.py | 11 +
moto/route53resolver/utils.py | 22 +
moto/route53resolver/validations.py | 103 +++
moto/server.py | 3 +-
moto/utilities/tagging_service.py | 10 +-
setup.py | 1 +
tests/test_route53resolver/__init__.py | 0
.../test_route53resolver_endpoint.py | 691 ++++++++++++++++++
.../test_route53resolver_tags.py | 150 ++++
18 files changed, 1804 insertions(+), 7 deletions(-)
create mode 100644 docs/docs/services/route53resolver.rst
create mode 100644 moto/route53resolver/__init__.py
create mode 100644 moto/route53resolver/exceptions.py
create mode 100644 moto/route53resolver/models.py
create mode 100644 moto/route53resolver/responses.py
create mode 100644 moto/route53resolver/urls.py
create mode 100644 moto/route53resolver/utils.py
create mode 100644 moto/route53resolver/validations.py
create mode 100644 tests/test_route53resolver/__init__.py
create mode 100644 tests/test_route53resolver/test_route53resolver_endpoint.py
create mode 100644 tests/test_route53resolver/test_route53resolver_tags.py
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index edcd10374..5ad269377 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -3668,6 +3668,75 @@
- [ ] update_traffic_policy_instance
+## route53resolver
+14% implemented
+
+
+- [ ] associate_firewall_rule_group
+- [ ] associate_resolver_endpoint_ip_address
+- [ ] associate_resolver_query_log_config
+- [ ] associate_resolver_rule
+- [ ] create_firewall_domain_list
+- [ ] create_firewall_rule
+- [ ] create_firewall_rule_group
+- [X] create_resolver_endpoint
+- [ ] create_resolver_query_log_config
+- [ ] create_resolver_rule
+- [ ] delete_firewall_domain_list
+- [ ] delete_firewall_rule
+- [ ] delete_firewall_rule_group
+- [X] delete_resolver_endpoint
+- [ ] delete_resolver_query_log_config
+- [ ] delete_resolver_rule
+- [ ] disassociate_firewall_rule_group
+- [ ] disassociate_resolver_endpoint_ip_address
+- [ ] disassociate_resolver_query_log_config
+- [ ] disassociate_resolver_rule
+- [ ] get_firewall_config
+- [ ] get_firewall_domain_list
+- [ ] get_firewall_rule_group
+- [ ] get_firewall_rule_group_association
+- [ ] get_firewall_rule_group_policy
+- [ ] get_resolver_config
+- [ ] get_resolver_dnssec_config
+- [X] get_resolver_endpoint
+- [ ] get_resolver_query_log_config
+- [ ] get_resolver_query_log_config_association
+- [ ] get_resolver_query_log_config_policy
+- [ ] get_resolver_rule
+- [ ] get_resolver_rule_association
+- [ ] get_resolver_rule_policy
+- [ ] import_firewall_domains
+- [ ] list_firewall_configs
+- [ ] list_firewall_domain_lists
+- [ ] list_firewall_domains
+- [ ] list_firewall_rule_group_associations
+- [ ] list_firewall_rule_groups
+- [ ] list_firewall_rules
+- [ ] list_resolver_configs
+- [ ] list_resolver_dnssec_configs
+- [X] list_resolver_endpoint_ip_addresses
+- [X] list_resolver_endpoints
+- [ ] list_resolver_query_log_config_associations
+- [ ] list_resolver_query_log_configs
+- [ ] list_resolver_rule_associations
+- [ ] list_resolver_rules
+- [X] list_tags_for_resource
+- [ ] put_firewall_rule_group_policy
+- [ ] put_resolver_query_log_config_policy
+- [ ] put_resolver_rule_policy
+- [X] tag_resource
+- [X] untag_resource
+- [ ] update_firewall_config
+- [ ] update_firewall_domains
+- [ ] update_firewall_rule
+- [ ] update_firewall_rule_group_association
+- [ ] update_resolver_config
+- [ ] update_resolver_dnssec_config
+- [X] update_resolver_endpoint
+- [ ] update_resolver_rule
+
+
## s3
59% implemented
@@ -4727,7 +4796,6 @@
- route53-recovery-control-config
- route53-recovery-readiness
- route53domains
-- route53resolver
- s3control
- s3outposts
- sagemaker-a2i-runtime
@@ -4772,4 +4840,4 @@
- workmailmessageflow
- workspaces
- xray
-
\ No newline at end of file
+
diff --git a/docs/docs/services/route53resolver.rst b/docs/docs/services/route53resolver.rst
new file mode 100644
index 000000000..88b6666a0
--- /dev/null
+++ b/docs/docs/services/route53resolver.rst
@@ -0,0 +1,92 @@
+.. _implementedservice_route53resolver:
+
+.. |start-h3| raw:: html
+
+
+
+.. |end-h3| raw:: html
+
+
+
+===============
+route53resolver
+===============
+
+
+
+|start-h3| Example usage |end-h3|
+
+.. sourcecode:: python
+
+ @mock_route53resolver
+ def test_route53resolver_behaviour:
+ boto3.client("route53resolver")
+ ...
+
+
+
+|start-h3| Implemented features for this service |end-h3|
+
+- [ ] associate_firewall_rule_group
+- [ ] associate_resolver_endpoint_ip_address
+- [ ] associate_resolver_query_log_config
+- [ ] associate_resolver_rule
+- [ ] create_firewall_domain_list
+- [ ] create_firewall_rule
+- [ ] create_firewall_rule_group
+- [X] create_resolver_endpoint
+- [ ] create_resolver_query_log_config
+- [ ] create_resolver_rule
+- [ ] delete_firewall_domain_list
+- [ ] delete_firewall_rule
+- [ ] delete_firewall_rule_group
+- [X] delete_resolver_endpoint
+- [ ] delete_resolver_query_log_config
+- [ ] delete_resolver_rule
+- [ ] disassociate_firewall_rule_group
+- [ ] disassociate_resolver_endpoint_ip_address
+- [ ] disassociate_resolver_query_log_config
+- [ ] disassociate_resolver_rule
+- [ ] get_firewall_config
+- [ ] get_firewall_domain_list
+- [ ] get_firewall_rule_group
+- [ ] get_firewall_rule_group_association
+- [ ] get_firewall_rule_group_policy
+- [ ] get_resolver_dnssec_config
+- [ ] get_resolver_config
+- [X] get_resolver_endpoint
+- [ ] get_resolver_query_log_config
+- [ ] get_resolver_query_log_config_association
+- [ ] get_resolver_query_log_config_policy
+- [ ] get_resolver_rule
+- [ ] get_resolver_rule_association
+- [ ] get_resolver_rule_policy
+- [ ] import_firewall_domains
+- [ ] list_firewall_configs
+- [ ] list_firewall_domain_lists
+- [ ] list_firewall_domains
+- [ ] list_firewall_rule_group_associations
+- [ ] list_firewall_rule_groups
+- [ ] list_firewall_rules
+- [ ] list_resolver_config
+- [ ] list_resolver_dnssec_configs
+- [X] list_resolver_endpoint_ip_addresses
+- [X] list_resolver_endpoints
+- [ ] list_resolver_query_log_config_associations
+- [ ] list_resolver_query_log_configs
+- [ ] list_resolver_rule_associations
+- [ ] list_resolver_rules
+- [X] list_tags_for_resource
+- [ ] put_firewall_rule_group_policy
+- [ ] put_resolver_query_log_config_policy
+- [ ] put_resolver_rule_policy
+- [X] tag_resource
+- [X] untag_resource
+- [ ] update_firewall_config
+- [ ] update_firewall_domains
+- [ ] update_firewall_rule
+- [ ] update_firewall_rule_group_association
+- [ ] update_resolver_config
+- [ ] update_resolver_dnssec_config
+- [X] update_resolver_endpoint
+- [ ] update_resolver_rule
diff --git a/moto/__init__.py b/moto/__init__.py
index db92f9701..20d0f5784 100644
--- a/moto/__init__.py
+++ b/moto/__init__.py
@@ -123,6 +123,9 @@ mock_resourcegroupstaggingapi = lazy_load(
)
mock_route53 = lazy_load(".route53", "mock_route53")
mock_route53_deprecated = lazy_load(".route53", "mock_route53_deprecated")
+mock_route53resolver = lazy_load(
+ ".route53resolver", "mock_route53resolver", boto3_name="route53resolver"
+)
mock_s3 = lazy_load(".s3", "mock_s3")
mock_s3_deprecated = lazy_load(".s3", "mock_s3_deprecated")
mock_sagemaker = lazy_load(".sagemaker", "mock_sagemaker")
diff --git a/moto/backend_index.py b/moto/backend_index.py
index b91759e86..59157f58a 100644
--- a/moto/backend_index.py
+++ b/moto/backend_index.py
@@ -1,4 +1,4 @@
-# autogenerated by /Users/markus/git_projects_os/moto/scripts/update_backend_index.py
+# autogenerated by scripts/update_backend_index.py
import re
backend_url_patterns = [
@@ -96,7 +96,11 @@ backend_url_patterns = [
re.compile("https?://resource-groups(-fips)?\\.(.+)\\.amazonaws.com"),
),
("resourcegroupstaggingapi", re.compile("https?://tagging\\.(.+)\\.amazonaws.com")),
- ("route53", re.compile("https?://route53(.*)\\.amazonaws.com")),
+ ("route53", re.compile("https?://route53(\\..+)?\\.amazonaws.com")),
+ (
+ "route53resolver",
+ re.compile("https?://route53resolver\\.(.+)\\.amazonaws\\.com"),
+ ),
("s3", re.compile("https?://s3(.*)\\.amazonaws.com")),
(
"s3",
diff --git a/moto/route53/urls.py b/moto/route53/urls.py
index 1b498207c..89d2fd5be 100644
--- a/moto/route53/urls.py
+++ b/moto/route53/urls.py
@@ -1,7 +1,7 @@
"""Route53 base URL and path."""
from .responses import Route53
-url_bases = [r"https?://route53(.*)\.amazonaws.com"]
+url_bases = [r"https?://route53(\..+)?\.amazonaws.com"]
def tag_response1(*args, **kwargs):
diff --git a/moto/route53resolver/__init__.py b/moto/route53resolver/__init__.py
new file mode 100644
index 000000000..ae5d8b000
--- /dev/null
+++ b/moto/route53resolver/__init__.py
@@ -0,0 +1,5 @@
+"""route53resolver module initialization; sets value for base decorator."""
+from .models import route53resolver_backends
+from ..core.models import base_decorator
+
+mock_route53resolver = base_decorator(route53resolver_backends)
diff --git a/moto/route53resolver/exceptions.py b/moto/route53resolver/exceptions.py
new file mode 100644
index 000000000..64c2337cc
--- /dev/null
+++ b/moto/route53resolver/exceptions.py
@@ -0,0 +1,95 @@
+"""Exceptions raised by the route53resolver service."""
+from moto.core.exceptions import JsonRESTError
+
+
+class RRValidationException(JsonRESTError):
+ """Report one of more parameter validation errors."""
+
+ code = 400
+
+ def __init__(self, error_tuples):
+ """Validation errors are concatenated into one exception message.
+
+ error_tuples is a list of tuples. Each tuple contains:
+
+ - name of invalid parameter,
+ - value of invalid parameter,
+ - string describing the constraints for that parameter.
+ """
+ msg_leader = (
+ f"{len(error_tuples)} "
+ f"validation error{'s' if len(error_tuples) > 1 else ''} detected: "
+ )
+ msgs = []
+ for arg_name, arg_value, constraint in error_tuples:
+ msgs.append(
+ f"Value '{arg_value}' at '{arg_name}' failed to satisfy "
+ f"constraint: Member must {constraint}"
+ )
+ super().__init__("ValidationException", msg_leader + "; ".join(msgs))
+
+
+class InvalidNextTokenException(JsonRESTError):
+ """Invalid next token parameter used to return a list of entities."""
+
+ code = 400
+
+ def __init__(self):
+ super().__init__(
+ "InvalidNextTokenException",
+ "Invalid value passed for the NextToken parameter",
+ )
+
+
+class InvalidParameterException(JsonRESTError):
+ """One or more parameters in request are not valid."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("InvalidParameterException", message)
+
+
+class InvalidRequestException(JsonRESTError):
+ """The request is invalid."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("InvalidRequestException", message)
+
+
+class LimitExceededException(JsonRESTError):
+ """The request caused one or more limits to be exceeded."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("LimitExceededException", message)
+
+
+class ResourceExistsException(JsonRESTError):
+ """The resource already exists."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("ResourceExistsException", message)
+
+
+class ResourceNotFoundException(JsonRESTError):
+ """The specified resource doesn't exist."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("ResourceNotFoundException", message)
+
+
+class TagValidationException(JsonRESTError):
+ """Tag validation failed."""
+
+ code = 400
+
+ def __init__(self, message):
+ super().__init__("ValidationException", message)
diff --git a/moto/route53resolver/models.py b/moto/route53resolver/models.py
new file mode 100644
index 000000000..36254c0f0
--- /dev/null
+++ b/moto/route53resolver/models.py
@@ -0,0 +1,397 @@
+"""Route53ResolverBackend class with methods for supported APIs."""
+from collections import defaultdict
+from datetime import datetime, timezone
+from ipaddress import ip_address, ip_network
+
+from boto3 import Session
+
+from moto.core import ACCOUNT_ID
+from moto.core import BaseBackend, BaseModel
+from moto.core.utils import get_random_hex
+from moto.ec2 import ec2_backends
+from moto.ec2.exceptions import InvalidSubnetIdError
+from moto.ec2.exceptions import InvalidSecurityGroupNotFoundError
+from moto.route53resolver.exceptions import (
+ InvalidParameterException,
+ InvalidRequestException,
+ LimitExceededException,
+ ResourceExistsException,
+ ResourceNotFoundException,
+ TagValidationException,
+)
+from moto.route53resolver.utils import PAGINATION_MODEL
+from moto.route53resolver.validations import validate_args
+
+from moto.utilities.paginator import paginate
+from moto.utilities.tagging_service import TaggingService
+
+
+class ResolverEndpoint(BaseModel): # pylint: disable=too-many-instance-attributes
+ """Representation of a fake Route53 Resolver Endpoint."""
+
+ MAX_TAGS_PER_RESOLVER_ENDPOINT = 200
+ MAX_ENDPOINTS_PER_REGION = 4
+
+ def __init__(
+ self,
+ region,
+ endpoint_id,
+ creator_request_id,
+ security_group_ids,
+ direction,
+ ip_addresses,
+ name=None,
+ ): # pylint: disable=too-many-arguments
+ self.region = region
+ self.creator_request_id = creator_request_id
+ self.name = name
+ self.security_group_ids = security_group_ids
+ self.direction = direction
+ self.ip_addresses = ip_addresses
+
+ # Constructed members.
+ self.id = endpoint_id # pylint: disable=invalid-name
+
+ # NOTE; This currently doesn't reflect IPv6 addresses.
+ self.subnets = self._build_subnet_info()
+ self.ip_address_count = len(ip_addresses)
+
+ self.host_vpc_id = self._vpc_id_from_subnet()
+ self.status = "OPERATIONAL"
+
+ # The status message should contain a trace Id which is the value
+ # of X-Amzn-Trace-Id. We don't have that info, so a random number
+ # of similar format and length will be used.
+ self.status_message = (
+ f"[Trace id: 1-{get_random_hex(8)}-{get_random_hex(24)}] "
+ f"Creating the Resolver Endpoint"
+ )
+ self.creation_time = datetime.now(timezone.utc).isoformat()
+ self.modification_time = datetime.now(timezone.utc).isoformat()
+
+ @property
+ def arn(self):
+ """Return ARN for this resolver endpoint."""
+ return f"arn:aws:route53resolver:{self.region}:{ACCOUNT_ID}:resolver-endpoint/{self.id}"
+
+ def _vpc_id_from_subnet(self):
+ """Return VPC Id associated with the subnet.
+
+ The assumption is that all of the subnets are associated with the
+ same VPC. We don't check that assumption, but otherwise the existence
+ of the subnets has already been checked.
+ """
+ first_subnet_id = self.ip_addresses[0]["SubnetId"]
+ subnet_info = ec2_backends[self.region].get_all_subnets(
+ subnet_ids=[first_subnet_id]
+ )[0]
+ return subnet_info.vpc_id
+
+ def _build_subnet_info(self):
+ """Create a dict of subnet info, including ip addrs and ENI ids.
+
+ self.subnets[subnet_id][ip_addr1] = eni-id1 ...
+ """
+ subnets = defaultdict(dict)
+ for entry in self.ip_addresses:
+ subnets[entry["SubnetId"]][entry["Ip"]] = f"rni-{get_random_hex(17)}"
+ return subnets
+
+ def create_eni(self):
+ """Create a VPC ENI for each combo of AZ, subnet and IP."""
+ for subnet, ip_info in self.subnets.items():
+ for ip_addr, eni_id in ip_info.items():
+ ec2_backends[self.region].create_network_interface(
+ description=f"Route 53 Resolver: {self.id}:{eni_id}",
+ group_ids=self.security_group_ids,
+ interface_type="interface",
+ private_ip_address=ip_addr,
+ private_ip_addresses=[
+ {"Primary": True, "PrivateIpAddress": ip_addr}
+ ],
+ subnet=subnet,
+ )
+
+ def description(self):
+ """Return a dictionary of relevant info for this resolver endpoint."""
+ return {
+ "Id": self.id,
+ "CreatorRequestId": self.creator_request_id,
+ "Arn": self.arn,
+ "Name": self.name,
+ "SecurityGroupIds": self.security_group_ids,
+ "Direction": self.direction,
+ "IpAddressCount": self.ip_address_count,
+ "HostVPCId": self.host_vpc_id,
+ "Status": self.status,
+ "StatusMessage": self.status_message,
+ "CreationTime": self.creation_time,
+ "ModificationTime": self.modification_time,
+ }
+
+ def ip_descriptions(self):
+ """Return a list of dicts describing resolver endpoint IP addresses."""
+ description = []
+ for subnet_id, ip_info in self.subnets.items():
+ for ip_addr, eni_id in ip_info.items():
+ description.append(
+ {
+ "IpId": eni_id,
+ "SubnetId": subnet_id,
+ "Ip": ip_addr,
+ "Status": "ATTACHED",
+ "StatusMessage": "This IP address is operational.",
+ "CreationTime": self.creation_time,
+ "ModificationTime": self.modification_time,
+ }
+ )
+ return description
+
+ def update_name(self, name):
+ """Replace existing name with new name."""
+ self.name = name
+ self.modification_time = datetime.now(timezone.utc).isoformat()
+
+
+class Route53ResolverBackend(BaseBackend):
+ """Implementation of Route53Resolver APIs."""
+
+ def __init__(self, region_name=None):
+ self.region_name = region_name
+ self.resolver_endpoints = {} # Key is self-generated ID (endpoint_id)
+ self.tagger = TaggingService()
+
+ def reset(self):
+ """Re-initialize all attributes for this instance."""
+ region_name = self.region_name
+ self.__dict__ = {}
+ self.__init__(region_name)
+
+ @staticmethod
+ def default_vpc_endpoint_service(service_region, zones):
+ """List of dicts representing default VPC endpoints for this service."""
+ return BaseBackend.default_vpc_endpoint_service_factory(
+ service_region, zones, "route53resolver"
+ )
+
+ @staticmethod
+ def _verify_subnet_ips(region, ip_addresses):
+ """Perform additional checks on the IPAddresses.
+
+ NOTE: This does not include IPv6 addresses.
+ """
+ if len(ip_addresses) < 2:
+ raise InvalidRequestException(
+ "Resolver endpoint needs to have at least 2 IP addresses"
+ )
+
+ subnets = defaultdict(set)
+ for subnet_id, ip_addr in [(x["SubnetId"], x["Ip"]) for x in ip_addresses]:
+ try:
+ subnet_info = ec2_backends[region].get_all_subnets(
+ subnet_ids=[subnet_id]
+ )[0]
+ except InvalidSubnetIdError as exc:
+ raise InvalidParameterException(
+ f"The subnet ID '{subnet_id}' does not exist"
+ ) from exc
+
+ # IP in IPv4 CIDR range and not reserved?
+ if ip_address(ip_addr) in subnet_info.reserved_ips or ip_address(
+ ip_addr
+ ) not in ip_network(subnet_info.cidr_block):
+ raise InvalidRequestException(
+ f"IP address '{ip_addr}' is either not in subnet "
+ f"'{subnet_id}' CIDR range or is reserved"
+ )
+
+ if ip_addr in subnets[subnet_id]:
+ raise ResourceExistsException(
+ f"The IP address '{ip_addr}' in subnet '{subnet_id}' is already in use"
+ )
+ subnets[subnet_id].add(ip_addr)
+
+ @staticmethod
+ def _verify_security_group_ids(region, security_group_ids):
+ """Perform additional checks on the security groups."""
+ if len(security_group_ids) > 10:
+ raise InvalidParameterException("Maximum of 10 security groups are allowed")
+
+ for group_id in security_group_ids:
+ if not group_id.startswith("sg-"):
+ raise InvalidParameterException(
+ f"Malformed security group ID: Invalid id: '{group_id}' "
+ f"(expecting 'sg-...')"
+ )
+ try:
+ ec2_backends[region].describe_security_groups(group_ids=[group_id])
+ except InvalidSecurityGroupNotFoundError as exc:
+ raise ResourceNotFoundException(
+ f"The security group '{group_id}' does not exist"
+ ) from exc
+
+ def create_resolver_endpoint(
+ self,
+ region,
+ creator_request_id,
+ name,
+ security_group_ids,
+ direction,
+ ip_addresses,
+ tags,
+ ): # pylint: disable=too-many-arguments
+ """Return description for a newly created resolver endpoint.
+
+ NOTE: IPv6 IPs are currently not being filtered when
+ calculating the create_resolver_endpoint() IpAddresses.
+ """
+ validate_args(
+ [
+ ("creatorRequestId", creator_request_id),
+ ("direction", direction),
+ ("ipAddresses", ip_addresses),
+ ("name", name),
+ ("securityGroupIds", security_group_ids),
+ ("ipAddresses.subnetId", ip_addresses),
+ ]
+ )
+ errmsg = self.tagger.validate_tags(
+ tags or [], limit=ResolverEndpoint.MAX_TAGS_PER_RESOLVER_ENDPOINT,
+ )
+ if errmsg:
+ raise TagValidationException(errmsg)
+
+ endpoints = [x for x in self.resolver_endpoints.values() if x.region == region]
+ if len(endpoints) > ResolverEndpoint.MAX_ENDPOINTS_PER_REGION:
+ raise LimitExceededException(
+ f"Account '{ACCOUNT_ID}' has exceeded 'max-endpoints'"
+ )
+
+ self._verify_subnet_ips(region, ip_addresses)
+ self._verify_security_group_ids(region, security_group_ids)
+ if creator_request_id in [
+ x.creator_request_id for x in self.resolver_endpoints.values()
+ ]:
+ raise ResourceExistsException(
+ f"Resolver endpoint with creator request ID "
+ f"'{creator_request_id}' already exists"
+ )
+
+ endpoint_id = (
+ f"rslvr-{'in' if direction == 'INBOUND' else 'out'}-{get_random_hex(17)}"
+ )
+ resolver_endpoint = ResolverEndpoint(
+ region,
+ endpoint_id,
+ creator_request_id,
+ security_group_ids,
+ direction,
+ ip_addresses,
+ name,
+ )
+ resolver_endpoint.create_eni()
+
+ self.resolver_endpoints[endpoint_id] = resolver_endpoint
+ self.tagger.tag_resource(resolver_endpoint.arn, tags or [])
+ return resolver_endpoint
+
+ def _validate_resolver_endpoint_id(self, resolver_endpoint_id):
+ """Raise an exception if the id is invalid or unknown."""
+ validate_args([("resolverEndpointId", resolver_endpoint_id)])
+ if resolver_endpoint_id not in self.resolver_endpoints:
+ raise ResourceNotFoundException(
+ f"Resolver endpoint with ID '{resolver_endpoint_id}' does not exist"
+ )
+
+ def delete_resolver_endpoint(self, resolver_endpoint_id):
+ """Delete a resolver endpoint."""
+ self._validate_resolver_endpoint_id(resolver_endpoint_id)
+ self.tagger.delete_all_tags_for_resource(resolver_endpoint_id)
+ resolver_endpoint = self.resolver_endpoints.pop(resolver_endpoint_id)
+ resolver_endpoint.status = "DELETING"
+ resolver_endpoint.status_message = resolver_endpoint.status_message.replace(
+ "Creating", "Deleting"
+ )
+ return resolver_endpoint
+
+ def get_resolver_endpoint(self, resolver_endpoint_id):
+ """Return info for specified resolver endpoint."""
+ self._validate_resolver_endpoint_id(resolver_endpoint_id)
+ return self.resolver_endpoints[resolver_endpoint_id]
+
+ @paginate(pagination_model=PAGINATION_MODEL)
+ def list_resolver_endpoint_ip_addresses(
+ self, resolver_endpoint_id, next_token=None, max_results=None,
+ ): # pylint: disable=unused-argument
+ """List IP endresses for specified resolver endpoint."""
+ self._validate_resolver_endpoint_id(resolver_endpoint_id)
+ endpoint = self.resolver_endpoints[resolver_endpoint_id]
+ return endpoint.ip_descriptions()
+
+ @paginate(pagination_model=PAGINATION_MODEL)
+ def list_resolver_endpoints(
+ self, filters=None, next_token=None, max_results=None,
+ ): # pylint: disable=unused-argument
+ """List all resolver endpoints, using filters if specified."""
+ # TODO - check subsequent filters
+ # TODO - validate name, values for filters
+ return sorted(self.resolver_endpoints.values(), key=lambda x: x.name)
+
+ @paginate(pagination_model=PAGINATION_MODEL)
+ def list_tags_for_resource(
+ self, resource_arn, next_token=None, max_results=None,
+ ): # pylint: disable=unused-argument
+ """List all tags for the given resource."""
+ self._matched_arn(resource_arn)
+ return self.tagger.list_tags_for_resource(resource_arn).get("Tags")
+
+ def _matched_arn(self, resource_arn):
+ """Given ARN, raise exception if there is no corresponding resource."""
+ for resolver_endpoint in self.resolver_endpoints.values():
+ if resolver_endpoint.arn == resource_arn:
+ return
+ raise ResourceNotFoundException(
+ f"Resolver endpoint with ID '{resource_arn}' does not exist"
+ )
+
+ def tag_resource(self, resource_arn, tags):
+ """Add or overwrite one or more tags for specified resource."""
+ self._matched_arn(resource_arn)
+ errmsg = self.tagger.validate_tags(
+ tags, limit=ResolverEndpoint.MAX_TAGS_PER_RESOLVER_ENDPOINT,
+ )
+ if errmsg:
+ raise TagValidationException(errmsg)
+ self.tagger.tag_resource(resource_arn, tags)
+
+ def untag_resource(self, resource_arn, tag_keys):
+ """Removes tags from a resource."""
+ self._matched_arn(resource_arn)
+ self.tagger.untag_resource_using_names(resource_arn, tag_keys)
+
+ def update_resolver_endpoint(self, resolver_endpoint_id, name):
+ """Update name of Resolver endpoint."""
+ self._validate_resolver_endpoint_id(resolver_endpoint_id)
+ validate_args([("name", name)])
+ resolver_endpoint = self.resolver_endpoints[resolver_endpoint_id]
+ resolver_endpoint.update_name(name)
+ return resolver_endpoint
+
+
+route53resolver_backends = {}
+for available_region in Session().get_available_regions("route53resolver"):
+ route53resolver_backends[available_region] = Route53ResolverBackend(
+ available_region
+ )
+for available_region in Session().get_available_regions(
+ "route53resolver", partition_name="aws-us-gov"
+):
+ route53resolver_backends[available_region] = Route53ResolverBackend(
+ available_region
+ )
+for available_region in Session().get_available_regions(
+ "route53resolver", partition_name="aws-cn"
+):
+ route53resolver_backends[available_region] = Route53ResolverBackend(
+ available_region
+ )
diff --git a/moto/route53resolver/responses.py b/moto/route53resolver/responses.py
new file mode 100644
index 000000000..3a4d8af6a
--- /dev/null
+++ b/moto/route53resolver/responses.py
@@ -0,0 +1,146 @@
+"""Handles incoming route53resolver requests/responses."""
+import json
+
+from moto.core.exceptions import InvalidToken
+from moto.core.responses import BaseResponse
+from moto.route53resolver.exceptions import InvalidNextTokenException
+from moto.route53resolver.models import route53resolver_backends
+from moto.route53resolver.validations import validate_args
+
+
+class Route53ResolverResponse(BaseResponse):
+ """Handler for Route53Resolver requests and responses."""
+
+ @property
+ def route53resolver_backend(self):
+ """Return backend instance specific for this region."""
+ return route53resolver_backends[self.region]
+
+ def create_resolver_endpoint(self):
+ """Create an inbound or outbound Resolver endpoint."""
+ creator_request_id = self._get_param("CreatorRequestId")
+ name = self._get_param("Name")
+ security_group_ids = self._get_param("SecurityGroupIds")
+ direction = self._get_param("Direction")
+ ip_addresses = self._get_param("IpAddresses")
+ tags = self._get_param("Tags", [])
+ resolver_endpoint = self.route53resolver_backend.create_resolver_endpoint(
+ region=self.region,
+ creator_request_id=creator_request_id,
+ name=name,
+ security_group_ids=security_group_ids,
+ direction=direction,
+ ip_addresses=ip_addresses,
+ tags=tags,
+ )
+ return json.dumps({"ResolverEndpoint": resolver_endpoint.description()})
+
+ def delete_resolver_endpoint(self):
+ """Delete a Resolver endpoint."""
+ resolver_endpoint_id = self._get_param("ResolverEndpointId")
+ resolver_endpoint = self.route53resolver_backend.delete_resolver_endpoint(
+ resolver_endpoint_id=resolver_endpoint_id,
+ )
+ return json.dumps({"ResolverEndpoint": resolver_endpoint.description()})
+
+ def get_resolver_endpoint(self):
+ """Return info about a specific Resolver endpoint."""
+ resolver_endpoint_id = self._get_param("ResolverEndpointId")
+ resolver_endpoint = self.route53resolver_backend.get_resolver_endpoint(
+ resolver_endpoint_id=resolver_endpoint_id,
+ )
+ return json.dumps({"ResolverEndpoint": resolver_endpoint.description()})
+
+ def list_resolver_endpoint_ip_addresses(self):
+ """Returns list of IP addresses for specified Resolver endpoint."""
+ resolver_endpoint_id = self._get_param("ResolverEndpointId")
+ next_token = self._get_param("NextToken")
+ max_results = self._get_param("MaxResults", 10)
+ validate_args([("maxResults", max_results)])
+ try:
+ (
+ ip_addresses,
+ next_token,
+ ) = self.route53resolver_backend.list_resolver_endpoint_ip_addresses(
+ resolver_endpoint_id=resolver_endpoint_id,
+ next_token=next_token,
+ max_results=max_results,
+ )
+ except InvalidToken as exc:
+ raise InvalidNextTokenException() from exc
+
+ response = {
+ "IpAddresses": ip_addresses,
+ "MaxResults": max_results,
+ }
+ if next_token:
+ response["NextToken"] = next_token
+ return json.dumps(response)
+
+ def list_resolver_endpoints(self):
+ """Returns list of all Resolver endpoints, filtered if specified."""
+ filters = self._get_param("Filters")
+ next_token = self._get_param("NextToken")
+ max_results = self._get_param("MaxResults", 10)
+ validate_args([("maxResults", max_results)])
+ try:
+ (
+ endpoints,
+ next_token,
+ ) = self.route53resolver_backend.list_resolver_endpoints(
+ filters=filters, next_token=next_token, max_results=max_results
+ )
+ except InvalidToken as exc:
+ raise InvalidNextTokenException() from exc
+
+ response = {
+ "ResolverEndpoints": [x.description() for x in endpoints],
+ "MaxResults": max_results,
+ }
+ if next_token:
+ response["NextToken"] = next_token
+ return json.dumps(response)
+
+ def list_tags_for_resource(self):
+ """Lists all tags for the given resource."""
+ resource_arn = self._get_param("ResourceArn")
+ next_token = self._get_param("NextToken")
+ max_results = self._get_param("MaxResults")
+ try:
+ (tags, next_token) = self.route53resolver_backend.list_tags_for_resource(
+ resource_arn=resource_arn,
+ next_token=next_token,
+ max_results=max_results,
+ )
+ except InvalidToken as exc:
+ raise InvalidNextTokenException() from exc
+
+ response = {"Tags": tags}
+ if next_token:
+ response["NextToken"] = next_token
+ return json.dumps(response)
+
+ def tag_resource(self):
+ """Add one or more tags to a specified resource."""
+ resource_arn = self._get_param("ResourceArn")
+ tags = self._get_param("Tags")
+ self.route53resolver_backend.tag_resource(resource_arn=resource_arn, tags=tags)
+ return ""
+
+ def untag_resource(self):
+ """Removes one or more tags from the specified resource."""
+ resource_arn = self._get_param("ResourceArn")
+ tag_keys = self._get_param("TagKeys")
+ self.route53resolver_backend.untag_resource(
+ resource_arn=resource_arn, tag_keys=tag_keys
+ )
+ return ""
+
+ def update_resolver_endpoint(self):
+ """Update name of Resolver endpoint."""
+ resolver_endpoint_id = self._get_param("ResolverEndpointId")
+ name = self._get_param("Name")
+ resolver_endpoint = self.route53resolver_backend.update_resolver_endpoint(
+ resolver_endpoint_id=resolver_endpoint_id, name=name,
+ )
+ return json.dumps({"ResolverEndpoint": resolver_endpoint.description()})
diff --git a/moto/route53resolver/urls.py b/moto/route53resolver/urls.py
new file mode 100644
index 000000000..61a8ee576
--- /dev/null
+++ b/moto/route53resolver/urls.py
@@ -0,0 +1,11 @@
+"""route53resolver base URL and path."""
+from .responses import Route53ResolverResponse
+
+url_bases = [
+ r"https?://route53resolver\.(.+)\.amazonaws\.com",
+]
+
+
+url_paths = {
+ "{0}/$": Route53ResolverResponse.dispatch,
+}
diff --git a/moto/route53resolver/utils.py b/moto/route53resolver/utils.py
new file mode 100644
index 000000000..f27fbee2d
--- /dev/null
+++ b/moto/route53resolver/utils.py
@@ -0,0 +1,22 @@
+"""Pagination control model for Route53Resolver."""
+
+PAGINATION_MODEL = {
+ "list_resolver_endpoint_ip_addresses": {
+ "input_token": "next_token",
+ "limit_key": "max_results",
+ "limit_default": 100,
+ "page_ending_range_keys": ["IpId"],
+ },
+ "list_resolver_endpoints": {
+ "input_token": "next_token",
+ "limit_key": "max_results",
+ "limit_default": 100,
+ "page_ending_range_keys": ["id"],
+ },
+ "list_tags_for_resource": {
+ "input_token": "next_token",
+ "limit_key": "max_results",
+ "limit_default": 100,
+ "page_ending_range_keys": ["Key"],
+ },
+}
diff --git a/moto/route53resolver/validations.py b/moto/route53resolver/validations.py
new file mode 100644
index 000000000..633deae69
--- /dev/null
+++ b/moto/route53resolver/validations.py
@@ -0,0 +1,103 @@
+"""Route53ResolverBackend validations that result in ValidationException.
+
+Note that ValidationExceptions are accumulative.
+"""
+import re
+
+from moto.route53resolver.exceptions import RRValidationException
+
+
+def validate_args(validators):
+ """Raise exception if any of the validations fails.
+
+ validators is a list of tuples each containing the following:
+ (printable field name, field value)
+
+ The error messages are accumulated before the exception is raised.
+ """
+ validation_map = {
+ "creatorRequestId": validate_creator_request_id,
+ "direction": validate_direction,
+ "resolverEndpointId": validate_endpoint_id,
+ "ipAddresses": validate_ip_addresses,
+ "maxResults": validate_max_results,
+ "name": validate_name,
+ "securityGroupIds": validate_security_group_ids,
+ "ipAddresses.subnetId": validate_subnets,
+ }
+
+ err_msgs = []
+ # This eventually could be a switch (python 3.10), eliminating the need
+ # for the above map and individual functions.
+ for (fieldname, value) in validators:
+ msg = validation_map[fieldname](value)
+ if msg:
+ err_msgs.append((fieldname, value, msg))
+ if err_msgs:
+ raise RRValidationException(err_msgs)
+
+
+def validate_creator_request_id(value):
+ """Raise exception if the creator_request id has invalid length."""
+ if value and len(value) > 255:
+ return "have length less than or equal to 255"
+ return ""
+
+
+def validate_direction(value):
+ """Raise exception if direction not one of the allowed values."""
+ if value and value not in ["INBOUND", "OUTBOUND"]:
+ return "satisfy enum value set: [INBOUND, OUTBOUND]"
+ return ""
+
+
+def validate_endpoint_id(value):
+ """Raise exception if resolver endpoint id has invalid length."""
+ if len(value) > 64:
+ return "have length less than or equal to 64"
+ return ""
+
+
+def validate_ip_addresses(value):
+ """Raise exception if IPs fail to match length constraint."""
+ if len(value) > 10:
+ return "have length less than or equal to 10"
+ return ""
+
+
+def validate_max_results(value):
+ """Raise exception if number of endpoints or IPs is too large."""
+ if value and value > 100:
+ return "have length less than or equal to 100"
+ return ""
+
+
+def validate_name(value):
+ """Raise exception if name fails to match constraints."""
+ if value:
+ if len(value) > 64:
+ return "have length less than or equal to 64"
+ name_pattern = r"^(?!^[0-9]+$)([a-zA-Z0-9-_' ']+)$"
+ if not re.match(name_pattern, value):
+ return fr"satisfy regular expression pattern: {name_pattern}"
+ return ""
+
+
+def validate_security_group_ids(value):
+ """Raise exception if IPs fail to match length constraint."""
+ # Too many security group IDs is an InvalidParameterException.
+ for group_id in value:
+ if len(group_id) > 64:
+ return (
+ "have length less than or equal to 64 and Member must have "
+ "length greater than or equal to 1"
+ )
+ return ""
+
+
+def validate_subnets(value):
+ """Raise exception if subnets fail to match length constraint."""
+ for subnet_id in [x["SubnetId"] for x in value]:
+ if len(subnet_id) > 32:
+ return "have length less than or equal to 32"
+ return ""
diff --git a/moto/server.py b/moto/server.py
index 92f9c3d1a..28cd5d123 100644
--- a/moto/server.py
+++ b/moto/server.py
@@ -74,7 +74,7 @@ class DomainDispatcherApplication(object):
if "amazonaws.com" in host:
print(
"Unable to find appropriate backend for {}."
- "Remember to add the URL to urls.py, and run script/update_backend_index.py to index it.".format(
+ "Remember to add the URL to urls.py, and run scripts/update_backend_index.py to index it.".format(
host
)
)
@@ -91,6 +91,7 @@ class DomainDispatcherApplication(object):
credential_scope = auth.split(",")[0].split()[1]
_, _, region, service, _ = credential_scope.split("/")
service = SIGNING_ALIASES.get(service.lower(), service)
+ service = service.lower()
except ValueError:
# Signature format does not match, this is exceptional and we can't
# infer a service-region. A reduced set of services still use
diff --git a/moto/utilities/tagging_service.py b/moto/utilities/tagging_service.py
index be5ec49d3..772b9922c 100644
--- a/moto/utilities/tagging_service.py
+++ b/moto/utilities/tagging_service.py
@@ -101,12 +101,14 @@ class TaggingService:
result[tag[self.key_name]] = None
return result
- def validate_tags(self, tags):
+ def validate_tags(self, tags, limit=0):
"""Returns error message if tags in 'tags' list of dicts are invalid.
The validation does not include a check for duplicate keys.
Duplicate keys are not always an error and the error message isn't
consistent across services, so this should be a separate check.
+
+ If limit is provided, then the number of tags will be checked.
"""
errors = []
key_regex = re.compile(r"^(?!aws:)([\w\s\d_.:/=+\-@]*)$")
@@ -147,6 +149,12 @@ class TaggingService:
r"^[{a-zA-Z0-9 }_.://=+-@%]*$"
)
+ if limit and len(tags) > limit:
+ errors.append(
+ f"Value '{tags}' at 'tags' failed to satisfy constraint: "
+ f"Member must have length less than or equal to {limit}"
+ )
+
errors_len = len(errors)
return (
(
diff --git a/setup.py b/setup.py
index c7faa15ab..2d2de467c 100755
--- a/setup.py
+++ b/setup.py
@@ -105,6 +105,7 @@ extras_per_service["dynamodbstreams"] = extras_per_service["awslambda"]
extras_per_service["efs"] = extras_per_service["ec2"]
# DirectoryService needs EC2 to verify VPCs and subnets.
extras_per_service["ds"] = extras_per_service["ec2"]
+extras_per_service["route53resolver"] = extras_per_service["ec2"]
extras_require = {
"all": all_extra_deps,
"server": all_server_deps,
diff --git a/tests/test_route53resolver/__init__.py b/tests/test_route53resolver/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_route53resolver/test_route53resolver_endpoint.py b/tests/test_route53resolver/test_route53resolver_endpoint.py
new file mode 100644
index 000000000..bca5d270b
--- /dev/null
+++ b/tests/test_route53resolver/test_route53resolver_endpoint.py
@@ -0,0 +1,691 @@
+"""Unit tests for route53resolver endpoint-related APIs."""
+from datetime import datetime, timezone
+
+import boto3
+from botocore.exceptions import ClientError
+
+import pytest
+
+from moto import mock_route53resolver
+from moto import settings
+from moto.core import ACCOUNT_ID
+from moto.core.utils import get_random_hex
+from moto.ec2 import mock_ec2
+
+TEST_REGION = "us-east-1" if settings.TEST_SERVER_MODE else "us-west-2"
+
+
+def create_security_group(ec2_client):
+ """Return a security group ID."""
+ group_name = "RRUnitTests"
+
+ # Does the security group already exist?
+ groups = ec2_client.describe_security_groups(
+ Filters=[{"Name": "group-name", "Values": [group_name]}]
+ )
+
+ # If so, we're done. Otherwise, create it.
+ if groups["SecurityGroups"]:
+ return groups["SecurityGroups"][0]["GroupId"]
+
+ response = ec2_client.create_security_group(
+ Description="Security group used by unit tests", GroupName=group_name,
+ )
+ return response["GroupId"]
+
+
+def create_vpc(ec2_client):
+ """Return the ID for a valid VPC."""
+ return ec2_client.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"]["VpcId"]
+
+
+def create_subnets(ec2_client, vpc_id):
+ """Returns the IDs for two valid subnets."""
+ subnet_ids = []
+ for cidr_block in ["10.0.1.0/24", "10.0.0.0/24"]:
+ subnet_ids.append(
+ ec2_client.create_subnet(
+ VpcId=vpc_id, CidrBlock=cidr_block, AvailabilityZone=f"{TEST_REGION}a",
+ )["Subnet"]["SubnetId"]
+ )
+ return subnet_ids
+
+
+def create_test_endpoint(client, ec2_client, name=None, tags=None):
+ """Create an endpoint that can be used for testing purposes.
+
+ Can't be used for unit tests that need to know/test the arguments.
+ """
+ if not tags:
+ tags = []
+ random_num = get_random_hex(10)
+ subnet_ids = create_subnets(ec2_client, create_vpc(ec2_client))
+ resolver_endpoint = client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name=name if name else "X" + random_num,
+ SecurityGroupIds=[create_security_group(ec2_client)],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[1], "Ip": "10.0.0.20"},
+ ],
+ Tags=tags,
+ )
+ return resolver_endpoint["ResolverEndpoint"]
+
+
+@mock_route53resolver
+def test_route53resolver_invalid_create_endpoint_args():
+ """Test invalid arguments to the create_resolver_endpoint API."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ # Verify ValidationException error messages are accumulated properly:
+ # - creator requestor ID that exceeds the allowed length of 255.
+ # - name that exceeds the allowed length of 64.
+ # - direction that's neither INBOUND or OUTBOUND.
+ # - more than 10 IP Address sets.
+ # - too many security group IDs.
+ long_id = random_num * 25 + "123456"
+ long_name = random_num * 6 + "abcde"
+ too_many_security_groups = ["sg-" + get_random_hex(63)]
+ bad_direction = "foo"
+ too_many_ip_addresses = [{"SubnetId": f"{x}", "Ip": f"{x}" * 7} for x in range(11)]
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=long_id,
+ Name=long_name,
+ SecurityGroupIds=too_many_security_groups,
+ Direction=bad_direction,
+ IpAddresses=too_many_ip_addresses,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "5 validation errors detected" in err["Message"]
+ assert (
+ f"Value '{long_id}' at 'creatorRequestId' failed to satisfy constraint: "
+ f"Member must have length less than or equal to 255"
+ ) in err["Message"]
+ assert (
+ f"Value '{too_many_security_groups}' at 'securityGroupIds' failed to "
+ f"satisfy constraint: Member must have length less than or equal to 64"
+ ) in err["Message"]
+ assert (
+ f"Value '{long_name}' at 'name' failed to satisfy constraint: "
+ f"Member must have length less than or equal to 64"
+ ) in err["Message"]
+ assert (
+ f"Value '{bad_direction}' at 'direction' failed to satisfy constraint: "
+ f"Member must satisfy enum value set: [INBOUND, OUTBOUND]"
+ ) in err["Message"]
+ assert (
+ f"Value '{too_many_ip_addresses}' at 'ipAddresses' failed to satisfy "
+ f"constraint: Member must have length less than or equal to 10"
+ ) in err["Message"]
+
+ # Some single ValidationException errors ...
+ bad_chars_in_name = "0@*3"
+ ok_group_ids = ["sg-" + random_num]
+ ok_ip_addrs = [{"SubnetId": f"{x}", "Ip": f"{x}" * 7} for x in range(10)]
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name=bad_chars_in_name,
+ SecurityGroupIds=ok_group_ids,
+ Direction="INBOUND",
+ IpAddresses=ok_ip_addrs,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ fr"Value '{bad_chars_in_name}' at 'name' failed to satisfy constraint: "
+ fr"Member must satisfy regular expression pattern: "
+ fr"^(?!^[0-9]+$)([a-zA-Z0-9-_' ']+)$"
+ ) in err["Message"]
+
+ subnet_too_long = [{"SubnetId": "a" * 33, "Ip": "1.2.3.4"}]
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=ok_group_ids,
+ Direction="OUTBOUND",
+ IpAddresses=subnet_too_long,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ f"Value '{subnet_too_long}' at 'ipAddresses.subnetId' failed to "
+ f"satisfy constraint: Member must have length less than or equal to 32"
+ ) in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_bad_create_endpoint_subnets():
+ """Test bad subnet scenarios for create_resolver_endpoint API."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ # Need 2 IP addresses at the minimum.
+ subnet_ids = create_subnets(ec2_client, create_vpc(ec2_client))
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=[f"sg-{random_num}"],
+ Direction="INBOUND",
+ IpAddresses=[{"SubnetId": subnet_ids[0], "Ip": "1.2.3.4"}],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidRequestException"
+ assert "Resolver endpoint needs to have at least 2 IP addresses" in err["Message"]
+
+ # Need an IP that's within in the subnet.
+ bad_ip_addr = "1.2.3.4"
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=[f"sg-{random_num}"],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": subnet_ids[0], "Ip": bad_ip_addr},
+ {"SubnetId": subnet_ids[1], "Ip": bad_ip_addr},
+ ],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidRequestException"
+ assert (
+ f"IP address '{bad_ip_addr}' is either not in subnet "
+ f"'{subnet_ids[0]}' CIDR range or is reserved"
+ ) in err["Message"]
+
+ # Bad subnet ID.
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=[f"sg-{random_num}"],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": "foo", "Ip": "1.2.3.4"},
+ {"SubnetId": subnet_ids[1], "Ip": "1.2.3.4"},
+ ],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterException"
+ assert "The subnet ID 'foo' does not exist" in err["Message"]
+
+ # Can't reuse a ip address in a subnet.
+ subnet_ids = create_subnets(ec2_client, create_vpc(ec2_client))
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId="B" + random_num,
+ Name="B" + random_num,
+ SecurityGroupIds=[create_security_group(ec2_client)],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ ],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceExistsException"
+ assert (
+ f"The IP address '10.0.1.200' in subnet '{subnet_ids[0]}' is already in use"
+ ) in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_bad_create_endpoint_security_groups():
+ """Test bad security group scenarios for create_resolver_endpoint API."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ subnet_ids = create_subnets(ec2_client, create_vpc(ec2_client))
+ ip_addrs = [
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[1], "Ip": "10.0.0.20"},
+ ]
+
+ # Subnet must begin with "sg-".
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=["foo"],
+ Direction="INBOUND",
+ IpAddresses=ip_addrs,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterException"
+ assert (
+ "Malformed security group ID: Invalid id: 'foo' (expecting 'sg-...')"
+ ) in err["Message"]
+
+ # Non-existent security group id.
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=["sg-abc"],
+ Direction="INBOUND",
+ IpAddresses=ip_addrs,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert "The security group 'sg-abc' does not exist" in err["Message"]
+
+ # Too many security group ids.
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=random_num,
+ Name="X" + random_num,
+ SecurityGroupIds=["sg-abc"] * 11,
+ Direction="INBOUND",
+ IpAddresses=ip_addrs,
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidParameterException"
+ assert "Maximum of 10 security groups are allowed" in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_create_resolver_endpoint(): # pylint: disable=too-many-locals
+ """Test good create_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ vpc_id = create_vpc(ec2_client)
+ subnet_ids = create_subnets(ec2_client, vpc_id)
+ ip_addrs = [
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[1], "Ip": "10.0.0.20"},
+ ]
+ security_group_id = create_security_group(ec2_client)
+
+ creator_request_id = random_num
+ name = "X" + random_num
+ response = client.create_resolver_endpoint(
+ CreatorRequestId=creator_request_id,
+ Name=name,
+ SecurityGroupIds=[security_group_id],
+ Direction="INBOUND",
+ IpAddresses=ip_addrs,
+ )
+ endpoint = response["ResolverEndpoint"]
+ id_value = endpoint["Id"]
+ assert id_value.startswith("rslvr-in-")
+ assert endpoint["CreatorRequestId"] == creator_request_id
+ assert (
+ endpoint["Arn"]
+ == f"arn:aws:route53resolver:{TEST_REGION}:{ACCOUNT_ID}:resolver-endpoint/{id_value}"
+ )
+ assert endpoint["Name"] == name
+ assert endpoint["SecurityGroupIds"] == [security_group_id]
+ assert endpoint["Direction"] == "INBOUND"
+ assert endpoint["IpAddressCount"] == 2
+ assert endpoint["HostVPCId"] == vpc_id
+ assert endpoint["Status"] == "OPERATIONAL"
+ assert "Creating the Resolver Endpoint" in endpoint["StatusMessage"]
+
+ time_format = "%Y-%m-%dT%H:%M:%S.%f+00:00"
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
+ creation_time = datetime.strptime(endpoint["CreationTime"], time_format)
+ creation_time = creation_time.replace(tzinfo=None)
+ assert creation_time <= now
+
+ modification_time = datetime.strptime(endpoint["ModificationTime"], time_format)
+ modification_time = modification_time.replace(tzinfo=None)
+ assert modification_time <= now
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_other_create_resolver_endpoint_errors():
+ """Test good delete_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Create a good endpoint that we can use to test.
+ created_endpoint = create_test_endpoint(client, ec2_client)
+ request_id = created_endpoint["CreatorRequestId"]
+
+ # Attempt to create another endpoint with the same creator request id.
+ vpc_id = create_vpc(ec2_client)
+ subnet_ids = create_subnets(ec2_client, vpc_id)
+ with pytest.raises(ClientError) as exc:
+ client.create_resolver_endpoint(
+ CreatorRequestId=created_endpoint["CreatorRequestId"],
+ Name="X" + get_random_hex(10),
+ SecurityGroupIds=created_endpoint["SecurityGroupIds"],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[1], "Ip": "10.0.0.20"},
+ ],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceExistsException"
+ assert (
+ f"Resolver endpoint with creator request ID '{request_id}' already exists"
+ ) in err["Message"]
+
+ # Too many endpoints.
+ random_num = get_random_hex(10)
+ for idx in range(4):
+ create_test_endpoint(client, ec2_client, name=f"A{idx}-{random_num}")
+ with pytest.raises(ClientError) as exc:
+ create_test_endpoint(client, ec2_client, name=f"A5-{random_num}")
+ err = exc.value.response["Error"]
+ assert err["Code"] == "LimitExceededException"
+ assert f"Account '{ACCOUNT_ID}' has exceeded 'max-endpoints" in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_delete_resolver_endpoint():
+ """Test good delete_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ created_endpoint = create_test_endpoint(client, ec2_client)
+
+ # Now delete the resolver endpoint and verify the response.
+ response = client.delete_resolver_endpoint(
+ ResolverEndpointId=created_endpoint["Id"]
+ )
+ endpoint = response["ResolverEndpoint"]
+ assert endpoint["CreatorRequestId"] == created_endpoint["CreatorRequestId"]
+ assert endpoint["Id"] == created_endpoint["Id"]
+ assert endpoint["Arn"] == created_endpoint["Arn"]
+ assert endpoint["Name"] == created_endpoint["Name"]
+ assert endpoint["SecurityGroupIds"] == created_endpoint["SecurityGroupIds"]
+ assert endpoint["Direction"] == created_endpoint["Direction"]
+ assert endpoint["IpAddressCount"] == created_endpoint["IpAddressCount"]
+ assert endpoint["HostVPCId"] == created_endpoint["HostVPCId"]
+ assert endpoint["Status"] == "DELETING"
+ assert "Deleting" in endpoint["StatusMessage"]
+ assert endpoint["CreationTime"] == created_endpoint["CreationTime"]
+
+
+@mock_route53resolver
+def test_route53resolver_bad_delete_resolver_endpoint():
+ """Test delete_resolver_endpoint API calls with a bad ID."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ # Use a resolver endpoint id that is too long.
+ long_id = "0123456789" * 6 + "xxxxx"
+ with pytest.raises(ClientError) as exc:
+ client.delete_resolver_endpoint(ResolverEndpointId=long_id)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ f"Value '{long_id}' at 'resolverEndpointId' failed to satisfy "
+ f"constraint: Member must have length less than or equal to 64"
+ ) in err["Message"]
+
+ # Delete a non-existent resolver endpoint.
+ with pytest.raises(ClientError) as exc:
+ client.delete_resolver_endpoint(ResolverEndpointId=random_num)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert f"Resolver endpoint with ID '{random_num}' does not exist" in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_get_resolver_endpoint():
+ """Test good get_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ created_endpoint = create_test_endpoint(client, ec2_client)
+
+ # Now get the resolver endpoint and verify the response.
+ response = client.get_resolver_endpoint(ResolverEndpointId=created_endpoint["Id"])
+ endpoint = response["ResolverEndpoint"]
+ assert endpoint["CreatorRequestId"] == created_endpoint["CreatorRequestId"]
+ assert endpoint["Id"] == created_endpoint["Id"]
+ assert endpoint["Arn"] == created_endpoint["Arn"]
+ assert endpoint["Name"] == created_endpoint["Name"]
+ assert endpoint["SecurityGroupIds"] == created_endpoint["SecurityGroupIds"]
+ assert endpoint["Direction"] == created_endpoint["Direction"]
+ assert endpoint["IpAddressCount"] == created_endpoint["IpAddressCount"]
+ assert endpoint["HostVPCId"] == created_endpoint["HostVPCId"]
+ assert endpoint["Status"] == created_endpoint["Status"]
+ assert endpoint["StatusMessage"] == created_endpoint["StatusMessage"]
+ assert endpoint["CreationTime"] == created_endpoint["CreationTime"]
+ assert endpoint["ModificationTime"] == created_endpoint["ModificationTime"]
+
+
+@mock_route53resolver
+def test_route53resolver_bad_get_resolver_endpoint():
+ """Test get_resolver_endpoint API calls with a bad ID."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ # Use a resolver endpoint id that is too long.
+ long_id = "0123456789" * 6 + "xxxxx"
+ with pytest.raises(ClientError) as exc:
+ client.get_resolver_endpoint(ResolverEndpointId=long_id)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ f"Value '{long_id}' at 'resolverEndpointId' failed to satisfy "
+ f"constraint: Member must have length less than or equal to 64"
+ ) in err["Message"]
+
+ # Delete a non-existent resolver endpoint.
+ with pytest.raises(ClientError) as exc:
+ client.get_resolver_endpoint(ResolverEndpointId=random_num)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert f"Resolver endpoint with ID '{random_num}' does not exist" in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_update_resolver_endpoint():
+ """Test good update_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ created_endpoint = create_test_endpoint(client, ec2_client)
+
+ # Now update the resolver endpoint name and verify the response.
+ new_name = "NewName" + get_random_hex(6)
+ response = client.update_resolver_endpoint(
+ ResolverEndpointId=created_endpoint["Id"], Name=new_name,
+ )
+ endpoint = response["ResolverEndpoint"]
+ assert endpoint["CreatorRequestId"] == created_endpoint["CreatorRequestId"]
+ assert endpoint["Id"] == created_endpoint["Id"]
+ assert endpoint["Arn"] == created_endpoint["Arn"]
+ assert endpoint["Name"] == new_name
+ assert endpoint["SecurityGroupIds"] == created_endpoint["SecurityGroupIds"]
+ assert endpoint["Direction"] == created_endpoint["Direction"]
+ assert endpoint["IpAddressCount"] == created_endpoint["IpAddressCount"]
+ assert endpoint["HostVPCId"] == created_endpoint["HostVPCId"]
+ assert endpoint["Status"] == created_endpoint["Status"]
+ assert endpoint["StatusMessage"] == created_endpoint["StatusMessage"]
+ assert endpoint["CreationTime"] == created_endpoint["CreationTime"]
+ assert endpoint["ModificationTime"] != created_endpoint["ModificationTime"]
+
+
+@mock_route53resolver
+def test_route53resolver_bad_update_resolver_endpoint():
+ """Test update_resolver_endpoint API calls with a bad ID."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+ random_name = "Z" + get_random_hex(10)
+
+ # Use a resolver endpoint id that is too long.
+ long_id = "0123456789" * 6 + "xxxxx"
+ with pytest.raises(ClientError) as exc:
+ client.update_resolver_endpoint(ResolverEndpointId=long_id, Name=random_name)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ f"Value '{long_id}' at 'resolverEndpointId' failed to satisfy "
+ f"constraint: Member must have length less than or equal to 64"
+ ) in err["Message"]
+
+ # Delete a non-existent resolver endpoint.
+ with pytest.raises(ClientError) as exc:
+ client.update_resolver_endpoint(ResolverEndpointId=random_num, Name=random_name)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert f"Resolver endpoint with ID '{random_num}' does not exist" in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_list_resolver_endpoint_ip_addresses():
+ """Test good list_resolver_endpoint_ip_addresses API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ subnet_ids = create_subnets(ec2_client, create_vpc(ec2_client))
+ response = client.create_resolver_endpoint(
+ CreatorRequestId="B" + random_num,
+ Name="B" + random_num,
+ SecurityGroupIds=[create_security_group(ec2_client)],
+ Direction="INBOUND",
+ IpAddresses=[
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.200"},
+ {"SubnetId": subnet_ids[1], "Ip": "10.0.0.20"},
+ {"SubnetId": subnet_ids[0], "Ip": "10.0.1.201"},
+ ],
+ )
+ endpoint_id = response["ResolverEndpoint"]["Id"]
+ response = client.list_resolver_endpoint_ip_addresses(
+ ResolverEndpointId=endpoint_id
+ )
+ assert len(response["IpAddresses"]) == 3
+ assert response["MaxResults"] == 10
+
+ # Set max_results to return 1 address, use next_token to get remaining.
+ response = client.list_resolver_endpoint_ip_addresses(
+ ResolverEndpointId=endpoint_id, MaxResults=1
+ )
+ ip_addresses = response["IpAddresses"]
+ assert len(ip_addresses) == 1
+ assert response["MaxResults"] == 1
+ assert "NextToken" in response
+ assert ip_addresses[0]["IpId"].startswith("rni-")
+ assert ip_addresses[0]["SubnetId"] == subnet_ids[0]
+ assert ip_addresses[0]["Ip"] == "10.0.1.200"
+ assert ip_addresses[0]["Status"] == "ATTACHED"
+ assert ip_addresses[0]["StatusMessage"] == "This IP address is operational."
+ assert "CreationTime" in ip_addresses[0]
+ assert "ModificationTime" in ip_addresses[0]
+
+ response = client.list_resolver_endpoint_ip_addresses(
+ ResolverEndpointId=endpoint_id, NextToken=response["NextToken"]
+ )
+ assert len(response["IpAddresses"]) == 2
+ assert response["MaxResults"] == 10
+ assert "NextToken" not in response
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_bad_list_resolver_endpoint_ip_addresses():
+ """Test bad list_resolver_endpoint_ip_addresses API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Bad endpoint id.
+ with pytest.raises(ClientError) as exc:
+ client.list_resolver_endpoint_ip_addresses(ResolverEndpointId="foo")
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert "Resolver endpoint with ID 'foo' does not exist" in err["Message"]
+
+ # Good endpoint id, but bad max_results.
+ random_num = get_random_hex(10)
+ response = create_test_endpoint(client, ec2_client, name=f"A-{random_num}")
+ with pytest.raises(ClientError) as exc:
+ client.list_resolver_endpoint_ip_addresses(
+ ResolverEndpointId=response["Id"], MaxResults=250
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ "Value '250' at 'maxResults' failed to satisfy constraint: Member "
+ "must have length less than or equal to 100"
+ ) in err["Message"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_list_resolver_endpoints():
+ """Test good list_resolver_endpoint API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ random_num = get_random_hex(10)
+
+ # List endpoints when there are none.
+ response = client.list_resolver_endpoints()
+ assert len(response["ResolverEndpoints"]) == 0
+ assert response["MaxResults"] == 10
+ assert "NextToken" not in response
+
+ # Create 5 endpoints, verify all 5 are listed when no filters, max_results.
+ for idx in range(4):
+ create_test_endpoint(client, ec2_client, name=f"A{idx}-{random_num}")
+ response = client.list_resolver_endpoints()
+ endpoints = response["ResolverEndpoints"]
+ assert len(endpoints) == 4
+ assert response["MaxResults"] == 10
+ for idx in range(4):
+ assert endpoints[idx]["Name"].startswith(f"A{idx}")
+
+ # Set max_results to return 1 endpoint, use next_token to get remaining 3.
+ response = client.list_resolver_endpoints(MaxResults=1)
+ endpoints = response["ResolverEndpoints"]
+ assert len(endpoints) == 1
+ assert response["MaxResults"] == 1
+ assert "NextToken" in response
+ assert endpoints[0]["Name"].startswith("A0")
+
+ response = client.list_resolver_endpoints(NextToken=response["NextToken"])
+ endpoints = response["ResolverEndpoints"]
+ assert len(endpoints) == 3
+ assert response["MaxResults"] == 10
+ assert "NextToken" not in response
+ for idx, endpoint in enumerate(endpoints):
+ assert endpoint["Name"].startswith(f"A{idx + 1}")
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_bad_list_resolver_endpoints():
+ """Test bad list_resolver_endpoints API calls."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Bad max_results.
+ random_num = get_random_hex(10)
+ create_test_endpoint(client, ec2_client, name=f"A-{random_num}")
+ with pytest.raises(ClientError) as exc:
+ client.list_resolver_endpoints(MaxResults=250)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert "1 validation error detected" in err["Message"]
+ assert (
+ "Value '250' at 'maxResults' failed to satisfy constraint: Member "
+ "must have length less than or equal to 100"
+ ) in err["Message"]
diff --git a/tests/test_route53resolver/test_route53resolver_tags.py b/tests/test_route53resolver/test_route53resolver_tags.py
new file mode 100644
index 000000000..2dea0b76c
--- /dev/null
+++ b/tests/test_route53resolver/test_route53resolver_tags.py
@@ -0,0 +1,150 @@
+"""Route53Resolver-related unit tests focusing on tag-related functionality."""
+import boto3
+from botocore.exceptions import ClientError
+
+import pytest
+
+from moto import mock_route53resolver
+from moto.ec2 import mock_ec2
+from moto.route53resolver.models import ResolverEndpoint
+
+from .test_route53resolver_endpoint import TEST_REGION, create_test_endpoint
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_tag_resource():
+ """Test the addition of tags to a resource."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+ resolver_endpoint = create_test_endpoint(client, ec2_client)
+
+ # Unknown resolver endpoint id.
+ bad_arn = "foobar"
+ with pytest.raises(ClientError) as exc:
+ client.tag_resource(ResourceArn=bad_arn, Tags=[{"Key": "foo", "Value": "bar"}])
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert f"Resolver endpoint with ID '{bad_arn}' does not exist" in err["Message"]
+
+ # Too many tags.
+ tags = [
+ {"Key": f"{x}", "Value": f"{x}"}
+ for x in range(ResolverEndpoint.MAX_TAGS_PER_RESOLVER_ENDPOINT + 1)
+ ]
+ with pytest.raises(ClientError) as exc:
+ client.tag_resource(ResourceArn=resolver_endpoint["Arn"], Tags=tags)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert (
+ f"'tags' failed to satisfy constraint: Member must have length less "
+ f"than or equal to {ResolverEndpoint.MAX_TAGS_PER_RESOLVER_ENDPOINT}"
+ ) in err["Message"]
+
+ # Bad tags.
+ with pytest.raises(ClientError) as exc:
+ client.tag_resource(
+ ResourceArn=resolver_endpoint["Arn"],
+ Tags=[{"Key": "foo!", "Value": "bar"}],
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ValidationException"
+ assert (
+ "1 validation error detected: Value 'foo!' at 'tags.1.member.key' "
+ "failed to satisfy constraint: Member must satisfy regular "
+ "expression pattern"
+ ) in err["Message"]
+
+ # Successful addition of tags.
+ added_tags = [{"Key": f"{x}", "Value": f"{x}"} for x in range(10)]
+ client.tag_resource(ResourceArn=resolver_endpoint["Arn"], Tags=added_tags)
+ result = client.list_tags_for_resource(ResourceArn=resolver_endpoint["Arn"])
+ assert len(result["Tags"]) == 10
+ assert result["Tags"] == added_tags
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_untag_resource():
+ """Test the removal of tags to a resource."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Create a resolver endpoint for testing purposes.
+ tag_list = [
+ {"Key": "one", "Value": "1"},
+ {"Key": "two", "Value": "2"},
+ {"Key": "three", "Value": "3"},
+ ]
+ resolver_endpoint = create_test_endpoint(client, ec2_client, tags=tag_list)
+
+ # Untag all of the tags. Verify there are no more tags.
+ client.untag_resource(
+ ResourceArn=resolver_endpoint["Arn"], TagKeys=[x["Key"] for x in tag_list]
+ )
+ result = client.list_tags_for_resource(ResourceArn=resolver_endpoint["Arn"])
+ assert not result["Tags"]
+ assert "NextToken" not in result
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_list_tags_for_resource():
+ """Test ability to list all tags for a resource."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Create a resolver endpoint to work with.
+ tags = [
+ {"Key": f"{x}_k", "Value": f"{x}_v"}
+ for x in range(1, ResolverEndpoint.MAX_TAGS_PER_RESOLVER_ENDPOINT)
+ ]
+ resolver_endpoint = create_test_endpoint(client, ec2_client, tags=tags)
+
+ # Verify limit and next token works.
+ result = client.list_tags_for_resource(
+ ResourceArn=resolver_endpoint["Arn"], MaxResults=1
+ )
+ assert len(result["Tags"]) == 1
+ assert result["Tags"] == [{"Key": "1_k", "Value": "1_v"}]
+ assert result["NextToken"]
+
+ result = client.list_tags_for_resource(
+ ResourceArn=resolver_endpoint["Arn"],
+ MaxResults=10,
+ NextToken=result["NextToken"],
+ )
+ assert len(result["Tags"]) == 10
+ assert result["Tags"] == [
+ {"Key": f"{x}_k", "Value": f"{x}_v"} for x in range(2, 12)
+ ]
+ assert result["NextToken"]
+
+
+@mock_ec2
+@mock_route53resolver
+def test_route53resolver_bad_list_tags_for_resource():
+ """Test ability to list all tags for a resource."""
+ client = boto3.client("route53resolver", region_name=TEST_REGION)
+ ec2_client = boto3.client("ec2", region_name=TEST_REGION)
+
+ # Create a resolver endpoint to work with.
+ tags = [{"Key": "foo", "Value": "foobar"}]
+ resolver_endpoint = create_test_endpoint(client, ec2_client, tags=tags)
+
+ # Bad resolver endpoint ARN.
+ bad_arn = "xyz"
+ with pytest.raises(ClientError) as exc:
+ client.list_tags_for_resource(ResourceArn=bad_arn)
+ err = exc.value.response["Error"]
+ assert err["Code"] == "ResourceNotFoundException"
+ assert f"Resolver endpoint with ID '{bad_arn}' does not exist" in err["Message"]
+
+ # Bad next token.
+ with pytest.raises(ClientError) as exc:
+ client.list_tags_for_resource(
+ ResourceArn=resolver_endpoint["Arn"], NextToken="foo"
+ )
+ err = exc.value.response["Error"]
+ assert err["Code"] == "InvalidNextTokenException"
+ assert "Invalid value passed for the NextToken parameter" in err["Message"]