diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index e5ebf6935..1ac73fc8e 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -3703,12 +3703,12 @@ ## route53resolver
-21% implemented +27% implemented - [ ] associate_firewall_rule_group - [ ] associate_resolver_endpoint_ip_address - [ ] associate_resolver_query_log_config -- [ ] associate_resolver_rule +- [X] associate_resolver_rule - [ ] create_firewall_domain_list - [ ] create_firewall_rule - [ ] create_firewall_rule_group @@ -3724,7 +3724,7 @@ - [ ] disassociate_firewall_rule_group - [ ] disassociate_resolver_endpoint_ip_address - [ ] disassociate_resolver_query_log_config -- [ ] disassociate_resolver_rule +- [X] disassociate_resolver_rule - [ ] get_firewall_config - [ ] get_firewall_domain_list - [ ] get_firewall_rule_group @@ -3737,7 +3737,7 @@ - [ ] get_resolver_query_log_config_association - [ ] get_resolver_query_log_config_policy - [X] get_resolver_rule -- [ ] get_resolver_rule_association +- [X] get_resolver_rule_association - [ ] get_resolver_rule_policy - [ ] import_firewall_domains - [ ] list_firewall_configs @@ -3752,7 +3752,7 @@ - [X] list_resolver_endpoints - [ ] list_resolver_query_log_config_associations - [ ] list_resolver_query_log_configs -- [ ] list_resolver_rule_associations +- [X] list_resolver_rule_associations - [X] list_resolver_rules - [X] list_tags_for_resource - [ ] put_firewall_rule_group_policy diff --git a/docs/docs/services/route53resolver.rst b/docs/docs/services/route53resolver.rst index 66ef60bf6..3f5cf23c9 100644 --- a/docs/docs/services/route53resolver.rst +++ b/docs/docs/services/route53resolver.rst @@ -30,7 +30,7 @@ route53resolver - [ ] associate_firewall_rule_group - [ ] associate_resolver_endpoint_ip_address - [ ] associate_resolver_query_log_config -- [ ] associate_resolver_rule +- [X] associate_resolver_rule - [ ] create_firewall_domain_list - [ ] create_firewall_rule - [ ] create_firewall_rule_group @@ -54,7 +54,7 @@ route53resolver - [ ] disassociate_firewall_rule_group - [ ] disassociate_resolver_endpoint_ip_address - [ ] disassociate_resolver_query_log_config -- [ ] disassociate_resolver_rule +- [X] disassociate_resolver_rule - [ ] get_firewall_config - [ ] get_firewall_domain_list - [ ] get_firewall_rule_group @@ -69,7 +69,7 @@ route53resolver - [ ] get_resolver_query_log_config_association - [ ] get_resolver_query_log_config_policy - [X] get_resolver_rule -- [ ] get_resolver_rule_association +- [X] get_resolver_rule_association - [ ] get_resolver_rule_policy - [ ] import_firewall_domains - [ ] list_firewall_configs @@ -88,7 +88,7 @@ route53resolver - [ ] list_resolver_query_log_config_associations - [ ] list_resolver_query_log_configs -- [ ] list_resolver_rule_associations +- [X] list_resolver_rule_associations - [X] list_resolver_rules - [X] list_tags_for_resource List all tags for the given resource. diff --git a/moto/route53resolver/exceptions.py b/moto/route53resolver/exceptions.py index 64c2337cc..8daf7efdf 100644 --- a/moto/route53resolver/exceptions.py +++ b/moto/route53resolver/exceptions.py @@ -77,6 +77,15 @@ class ResourceExistsException(JsonRESTError): super().__init__("ResourceExistsException", message) +class ResourceInUseException(JsonRESTError): + """The resource has other resources associated with it.""" + + code = 400 + + def __init__(self, message): + super().__init__("ResourceInUseException", message) + + class ResourceNotFoundException(JsonRESTError): """The specified resource doesn't exist.""" diff --git a/moto/route53resolver/models.py b/moto/route53resolver/models.py index faff83471..d573fd5f8 100644 --- a/moto/route53resolver/models.py +++ b/moto/route53resolver/models.py @@ -17,6 +17,7 @@ from moto.route53resolver.exceptions import ( InvalidRequestException, LimitExceededException, ResourceExistsException, + ResourceInUseException, ResourceNotFoundException, TagValidationException, ) @@ -29,6 +30,46 @@ from moto.utilities.tagging_service import TaggingService CAMEL_TO_SNAKE_PATTERN = re.compile(r"(? ResolverRuleAssociation.MAX_RULE_ASSOCIATIONS_PER_REGION: + # This error message was not verified to be the same for AWS. + raise LimitExceededException( + f"Account '{ACCOUNT_ID}' has exceeded 'max-rule-association'" + ) + + if resolver_rule_id not in self.resolver_rules: + raise ResourceNotFoundException( + f"Resolver rule with ID '{resolver_rule_id}' does not exist." + ) + + vpcs = ec2_backends[region].describe_vpcs() + if vpc_id not in [x.id for x in vpcs]: + raise InvalidParameterException(f"The vpc ID '{vpc_id}' does not exist") + + # Can't duplicate resolver rule, vpc id associations. + for association in self.resolver_rule_associations.values(): + if ( + resolver_rule_id == association.resolver_rule_id + and vpc_id == association.vpc_id + ): + raise InvalidRequestException( + f"Cannot associate rules with same domain name with same " + f"VPC. Conflict with resolver rule '{resolver_rule_id}'" + ) + + rule_association_id = f"rslvr-rrassoc-{get_random_hex(17)}" + rule_association = ResolverRuleAssociation( + region, rule_association_id, resolver_rule_id, vpc_id, name + ) + self.resolver_rule_associations[rule_association_id] = rule_association + return rule_association + @staticmethod def _verify_subnet_ips(region, ip_addresses): """Perform additional checks on the IPAddresses. @@ -493,6 +577,20 @@ class Route53ResolverBackend(BaseBackend): def delete_resolver_endpoint(self, resolver_endpoint_id): """Delete a resolver endpoint.""" self._validate_resolver_endpoint_id(resolver_endpoint_id) + + # Can't delete an endpoint if there are rules associated with it. + rules = [ + x.id + for x in self.resolver_rules.values() + if x.resolver_endpoint_id == resolver_endpoint_id + ] + if rules: + raise InvalidRequestException( + f"Cannot delete resolver endpoint unless its related resolver " + f"rules are deleted. The following rules still exist for " + f"this resolver endpoint: {','.join(rules)}" + ) + self.tagger.delete_all_tags_for_resource(resolver_endpoint_id) resolver_endpoint = self.resolver_endpoints.pop(resolver_endpoint_id) resolver_endpoint.status = "DELETING" @@ -512,6 +610,19 @@ class Route53ResolverBackend(BaseBackend): def delete_resolver_rule(self, resolver_rule_id): """Delete a resolver rule.""" self._validate_resolver_rule_id(resolver_rule_id) + + # Can't delete an rule unless VPC's are disassociated. + associations = [ + x.id + for x in self.resolver_rule_associations.values() + if x.resolver_rule_id == resolver_rule_id + ] + if associations: + raise ResourceInUseException( + "Please disassociate this resolver rule from VPC first " + "before deleting" + ) + self.tagger.delete_all_tags_for_resource(resolver_rule_id) resolver_rule = self.resolver_rules.pop(resolver_rule_id) resolver_rule.status = "DELETING" @@ -520,6 +631,36 @@ class Route53ResolverBackend(BaseBackend): ) return resolver_rule + def disassociate_resolver_rule(self, resolver_rule_id, vpc_id): + """Removes association between a resolver rule and a VPC.""" + validate_args([("resolverRuleId", resolver_rule_id), ("vPCId", vpc_id)]) + + # Non-existent rule or vpc ids? + if resolver_rule_id not in self.resolver_rules: + raise ResourceNotFoundException( + f"Resolver rule with ID '{resolver_rule_id}' does not exist" + ) + + # Find the matching association for this rule and vpc. + rule_association_id = None + for association in self.resolver_rule_associations.values(): + if ( + resolver_rule_id == association.resolver_rule_id + and vpc_id == association.vpc_id + ): + rule_association_id = association.id + break + else: + raise ResourceNotFoundException( + f"Resolver Rule Association between Resolver Rule " + f"'{resolver_rule_id}' and VPC '{vpc_id}' does not exist" + ) + + rule_association = self.resolver_rule_associations.pop(rule_association_id) + rule_association.status = "DELETING" + rule_association.status_message = "Deleting Association" + return rule_association + def get_resolver_endpoint(self, resolver_endpoint_id): """Return info for specified resolver endpoint.""" self._validate_resolver_endpoint_id(resolver_endpoint_id) @@ -530,6 +671,15 @@ class Route53ResolverBackend(BaseBackend): self._validate_resolver_rule_id(resolver_rule_id) return self.resolver_rules[resolver_rule_id] + def get_resolver_rule_association(self, resolver_rule_association_id): + """Return info for specified resolver rule association.""" + validate_args([("resolverRuleAssociationId", resolver_rule_association_id)]) + if resolver_rule_association_id not in self.resolver_rule_associations: + raise ResourceNotFoundException( + f"ResolverRuleAssociation '{resolver_rule_association_id}' does not Exist" + ) + return self.resolver_rule_associations[resolver_rule_association_id] + @paginate(pagination_model=PAGINATION_MODEL) def list_resolver_endpoint_ip_addresses( self, resolver_endpoint_id, next_token=None, max_results=None, @@ -550,10 +700,12 @@ class Route53ResolverBackend(BaseBackend): for rr_filter in filters: filter_name = rr_filter["Name"] if "_" not in filter_name: - if filter_name == "HostVPCId": - filter_name = "host_vpc_id" - elif filter_name == "HostVpcId": + if "Vpc" in filter_name: filter_name = "WRONG" + elif filter_name == "HostVPCId": + filter_name = "host_vpc_id" + elif filter_name == "VPCId": + filter_name = "vpc_id" elif filter_name in ["Type", "TYPE"]: filter_name = "rule_type" elif not filter_name.isupper(): @@ -624,6 +776,25 @@ class Route53ResolverBackend(BaseBackend): rules.append(rule) return rules + @paginate(pagination_model=PAGINATION_MODEL) + def list_resolver_rule_associations( + self, filters, next_token=None, max_results=None, + ): # pylint: disable=unused-argument + """List all resolver rule associations, using filters if specified.""" + if not filters: + filters = [] + + self._add_field_name_to_filter(filters) + self._validate_filters(filters, ResolverRuleAssociation.FILTER_NAMES) + + rules = [] + for rule in sorted( + self.resolver_rule_associations.values(), key=lambda x: x.name + ): + if self._matches_all_filters(rule, filters): + rules.append(rule) + return rules + def _matched_arn(self, resource_arn): """Given ARN, raise exception if there is no corresponding resource.""" for resolver_endpoint in self.resolver_endpoints.values(): diff --git a/moto/route53resolver/responses.py b/moto/route53resolver/responses.py index a1b1d1428..7aee42ee6 100644 --- a/moto/route53resolver/responses.py +++ b/moto/route53resolver/responses.py @@ -16,6 +16,21 @@ class Route53ResolverResponse(BaseResponse): """Return backend instance specific for this region.""" return route53resolver_backends[self.region] + def associate_resolver_rule(self): + """Associate a Resolver rule with a VPC.""" + resolver_rule_id = self._get_param("ResolverRuleId") + name = self._get_param("Name") + vpc_id = self._get_param("VPCId") + resolver_rule_association = self.route53resolver_backend.associate_resolver_rule( + region=self.region, + resolver_rule_id=resolver_rule_id, + name=name, + vpc_id=vpc_id, + ) + return json.dumps( + {"ResolverRuleAssociation": resolver_rule_association.description()} + ) + def create_resolver_endpoint(self): """Create an inbound or outbound Resolver endpoint.""" creator_request_id = self._get_param("CreatorRequestId") @@ -72,6 +87,17 @@ class Route53ResolverResponse(BaseResponse): ) return json.dumps({"ResolverRule": resolver_rule.description()}) + def disassociate_resolver_rule(self): + """Remove the association between a Resolver rule and a VPC.""" + vpc_id = self._get_param("VPCId") + resolver_rule_id = self._get_param("ResolverRuleId") + resolver_rule_association = self.route53resolver_backend.disassociate_resolver_rule( + vpc_id=vpc_id, resolver_rule_id=resolver_rule_id, + ) + return json.dumps( + {"ResolverRuleAssociation": resolver_rule_association.description()} + ) + def get_resolver_endpoint(self): """Return info about a specific Resolver endpoint.""" resolver_endpoint_id = self._get_param("ResolverEndpointId") @@ -88,6 +114,16 @@ class Route53ResolverResponse(BaseResponse): ) return json.dumps({"ResolverRule": resolver_rule.description()}) + def get_resolver_rule_association(self): + """Return info about association between a Resolver rule and a VPC.""" + resolver_rule_association_id = self._get_param("ResolverRuleAssociationId") + resolver_rule_association = self.route53resolver_backend.get_resolver_rule_association( + resolver_rule_association_id=resolver_rule_association_id + ) + return json.dumps( + {"ResolverRuleAssociation": resolver_rule_association.description()} + ) + def list_resolver_endpoint_ip_addresses(self): """Returns list of IP addresses for specified Resolver endpoint.""" resolver_endpoint_id = self._get_param("ResolverEndpointId") @@ -159,6 +195,30 @@ class Route53ResolverResponse(BaseResponse): response["NextToken"] = next_token return json.dumps(response) + def list_resolver_rule_associations(self): + """Returns list of all Resolver associations, 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: + ( + associations, + next_token, + ) = self.route53resolver_backend.list_resolver_rule_associations( + filters, next_token=next_token, max_results=max_results + ) + except InvalidToken as exc: + raise InvalidNextTokenException() from exc + + response = { + "ResolverRuleAssociations": [x.description() for x in associations], + "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") diff --git a/moto/route53resolver/utils.py b/moto/route53resolver/utils.py index 8ac90d6b0..0a4df012e 100644 --- a/moto/route53resolver/utils.py +++ b/moto/route53resolver/utils.py @@ -19,6 +19,12 @@ PAGINATION_MODEL = { "limit_default": 100, "page_ending_range_keys": ["id"], }, + "list_resolver_rule_associations": { + "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", diff --git a/moto/route53resolver/validations.py b/moto/route53resolver/validations.py index 535d2d8f3..768f0a46c 100644 --- a/moto/route53resolver/validations.py +++ b/moto/route53resolver/validations.py @@ -24,10 +24,12 @@ def validate_args(validators): "maxResults": validate_max_results, "name": validate_name, "resolverEndpointId": validate_endpoint_id, + "resolverRuleAssociationId": validate_rule_association_id, "resolverRuleId": validate_rule_id, "ruleType": validate_rule_type, "securityGroupIds": validate_security_group_ids, "targetIps.port": validate_target_port, + "vPCId": validate_vpc_id, } err_msgs = [] @@ -94,6 +96,13 @@ def validate_name(value): return "" +def validate_rule_association_id(value): + """Raise exception if resolver rule association id has invalid length.""" + if value and len(value) > 64: + return "have length less than or equal to 64" + return "" + + def validate_rule_id(value): """Raise exception if resolver rule id has invalid length.""" if value and len(value) > 64: @@ -133,3 +142,10 @@ def validate_target_port(value): if value and value["Port"] > 65535: return "have value less than or equal to 65535" return "" + + +def validate_vpc_id(value): + """Raise exception if VPC id has invalid length.""" + if len(value) > 64: + return "have length less than or equal to 64" + return "" diff --git a/tests/test_route53resolver/test_route53resolver_endpoint.py b/tests/test_route53resolver/test_route53resolver_endpoint.py index c485689ce..323aaef3c 100644 --- a/tests/test_route53resolver/test_route53resolver_endpoint.py +++ b/tests/test_route53resolver/test_route53resolver_endpoint.py @@ -416,10 +416,12 @@ def test_route53resolver_delete_resolver_endpoint(): assert endpoint["CreationTime"] == created_endpoint["CreationTime"] +@mock_ec2 @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) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) random_num = get_random_hex(10) # Use a resolver endpoint id that is too long. @@ -441,6 +443,25 @@ def test_route53resolver_bad_delete_resolver_endpoint(): assert err["Code"] == "ResourceNotFoundException" assert f"Resolver endpoint with ID '{random_num}' does not exist" in err["Message"] + # Create an endpoint and a rule referencing that endpoint. Verify the + # endpoint can't be deleted due to that rule. + endpoint = create_test_endpoint(client, ec2_client) + resolver_rule = client.create_resolver_rule( + CreatorRequestId=random_num, + RuleType="FORWARD", + DomainName=f"X{random_num}.com", + ResolverEndpointId=endpoint["Id"], + ) + with pytest.raises(ClientError) as exc: + client.delete_resolver_endpoint(ResolverEndpointId=endpoint["Id"]) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidRequestException" + assert ( + f"Cannot delete resolver endpoint unless its related resolver rules " + f"are deleted. The following rules still exist for this resolver " + f"endpoint: {resolver_rule['ResolverRule']['Id']}" + ) in err["Message"] + @mock_ec2 @mock_route53resolver diff --git a/tests/test_route53resolver/test_route53resolver_rule.py b/tests/test_route53resolver/test_route53resolver_rule.py index 11e2a81cc..d2f59f967 100644 --- a/tests/test_route53resolver/test_route53resolver_rule.py +++ b/tests/test_route53resolver/test_route53resolver_rule.py @@ -11,7 +11,7 @@ from moto.core import ACCOUNT_ID from moto.core.utils import get_random_hex from moto.ec2 import mock_ec2 -from .test_route53resolver_endpoint import TEST_REGION, create_test_endpoint +from .test_route53resolver_endpoint import TEST_REGION, create_test_endpoint, create_vpc def create_test_rule(client, name=None, tags=None): @@ -299,10 +299,12 @@ def test_route53resolver_delete_resolver_rule(): assert rule["CreationTime"] == created_rule["CreationTime"] +@mock_ec2 @mock_route53resolver def test_route53resolver_bad_delete_resolver_rule(): """Test delete_resolver_rule API calls with a bad ID.""" client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) random_num = get_random_hex(10) # Use a resolver rule id that is too long. @@ -324,6 +326,18 @@ def test_route53resolver_bad_delete_resolver_rule(): assert err["Code"] == "ResourceNotFoundException" assert f"Resolver rule with ID '{random_num}' does not exist" in err["Message"] + # Verify a rule can't be deleted if VPCs are associated with it. + test_rule = create_test_rule(client) + vpc_id = create_vpc(ec2_client) + client.associate_resolver_rule(ResolverRuleId=test_rule["Id"], VPCId=vpc_id) + with pytest.raises(ClientError) as exc: + client.delete_resolver_rule(ResolverRuleId=test_rule["Id"]) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceInUseException" + assert ( + "Please disassociate this resolver rule from VPC first before deleting" + ) in err["Message"] + @mock_route53resolver def test_route53resolver_get_resolver_rule(): diff --git a/tests/test_route53resolver/test_route53resolver_rule_associations.py b/tests/test_route53resolver/test_route53resolver_rule_associations.py new file mode 100644 index 000000000..99cbb0ead --- /dev/null +++ b/tests/test_route53resolver/test_route53resolver_rule_associations.py @@ -0,0 +1,412 @@ +"""Unit tests for route53resolver rule association-related APIs.""" +import boto3 +from botocore.exceptions import ClientError + +import pytest + +from moto import mock_route53resolver +from moto.core.utils import get_random_hex +from moto.ec2 import mock_ec2 + +from .test_route53resolver_endpoint import TEST_REGION, create_vpc +from .test_route53resolver_rule import create_test_rule + + +def create_test_rule_association( + client, ec2_client, resolver_rule_id=None, name=None, vpc_id=None +): + """Create a Resolver Rule Association for testing purposes.""" + if not resolver_rule_id: + resolver_rule_id = create_test_rule(client)["Id"] + name = name if name else "R" + get_random_hex(10) + if not vpc_id: + vpc_id = create_vpc(ec2_client) + return client.associate_resolver_rule( + ResolverRuleId=resolver_rule_id, Name=name, VPCId=vpc_id + )["ResolverRuleAssociation"] + + +@mock_route53resolver +def test_route53resolver_invalid_associate_resolver_rule_args(): + """Test invalid arguments to the associate_resolver_rule API.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + random_num = get_random_hex(10) + + # Verify ValidationException error messages are accumulated properly: + # - resolver rule ID that exceeds the allowed length of 64. + # - name that exceeds the allowed length of 64. + # - vpc_id that exceeds the allowed length of 64. + long_id = random_num * 7 + long_name = random_num * 6 + "abcde" + long_vpc_id = random_num * 6 + "fghij" + with pytest.raises(ClientError) as exc: + client.associate_resolver_rule( + ResolverRuleId=long_id, Name=long_name, VPCId=long_vpc_id, + ) + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert "3 validation errors detected" in err["Message"] + assert ( + f"Value '{long_id}' at 'resolverRuleId' failed to satisfy " + f"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 '{long_vpc_id}' at 'vPCId' failed to satisfy " + f"constraint: Member must have length less than or equal to 64" + ) in err["Message"] + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_associate_resolver_rule(): + """Test good associate_resolver_rule API calls.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + + resolver_rule_id = create_test_rule(client)["Id"] + name = "X" + get_random_hex(10) + vpc_id = create_vpc(ec2_client) + rule_association = client.associate_resolver_rule( + ResolverRuleId=resolver_rule_id, Name=name, VPCId=vpc_id, + )["ResolverRuleAssociation"] + assert rule_association["Id"].startswith("rslvr-rrassoc-") + assert rule_association["ResolverRuleId"] == resolver_rule_id + assert rule_association["Name"] == name + assert rule_association["VPCId"] == vpc_id + assert rule_association["Status"] == "COMPLETE" + assert "StatusMessage" in rule_association + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_other_associate_resolver_rule_errors(): + """Test good associate_resolver_rule API calls.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + + # Resolver referenced by resolver_rule_id doesn't exist. + with pytest.raises(ClientError) as exc: + create_test_rule_association(client, ec2_client, resolver_rule_id="foo") + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert "Resolver rule with ID 'foo' does not exist" in err["Message"] + + # Invalid vpc_id + with pytest.raises(ClientError) as exc: + create_test_rule_association(client, ec2_client, vpc_id="foo") + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParameterException" + assert "The vpc ID 'foo' does not exist" in err["Message"] + + # Same resolver_rule_id and vpc_id pair for an association. + resolver_rule_id = create_test_rule(client)["Id"] + vpc_id = create_vpc(ec2_client) + create_test_rule_association( + client, ec2_client, resolver_rule_id=resolver_rule_id, vpc_id=vpc_id + ) + with pytest.raises(ClientError) as exc: + create_test_rule_association( + client, ec2_client, resolver_rule_id=resolver_rule_id, vpc_id=vpc_id + ) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidRequestException" + assert ( + f"Cannot associate rules with same domain name with same VPC. " + f"Conflict with resolver rule '{resolver_rule_id}'" + ) in err["Message"] + + # Not testing "Too many rule associations" as it takes too long to create + # 2000 VPCs and rule associations. + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_disassociate_resolver_rule(): + """Test good disassociate_resolver_rule API calls.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + created_association = create_test_rule_association(client, ec2_client) + + # Disassociate the resolver rule and verify the response. + response = client.disassociate_resolver_rule( + ResolverRuleId=created_association["ResolverRuleId"], + VPCId=created_association["VPCId"], + ) + association = response["ResolverRuleAssociation"] + assert association["Id"] == created_association["Id"] + assert association["ResolverRuleId"] == created_association["ResolverRuleId"] + assert association["Name"] == created_association["Name"] + assert association["VPCId"] == created_association["VPCId"] + assert association["Status"] == "DELETING" + assert "Deleting" in association["StatusMessage"] + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_bad_disassociate_resolver_rule(): + """Test disassociate_resolver_rule API calls with a bad ID.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + random_num = get_random_hex(10) + + # Use a resolver rule id and vpc id that is too long. + long_id = "0123456789" * 6 + "xxxxx" + long_vpc_id = random_num * 6 + "12345" + with pytest.raises(ClientError) as exc: + client.disassociate_resolver_rule(ResolverRuleId=long_id, VPCId=long_vpc_id) + err = exc.value.response["Error"] + assert err["Code"] == "ValidationException" + assert "2 validation errors detected" in err["Message"] + assert ( + f"Value '{long_id}' at 'resolverRuleId' failed to satisfy " + f"constraint: Member must have length less than or equal to 64" + ) in err["Message"] + assert ( + f"Value '{long_vpc_id}' at 'vPCId' failed to satisfy " + f"constraint: Member must have length less than or equal to 64" + ) in err["Message"] + + # Create a test association. + test_association = create_test_rule_association(client, ec2_client) + test_rule_id = test_association["ResolverRuleId"] + test_vpc_id = test_association["VPCId"] + + # Disassociate from a non-existent resolver rule id. + with pytest.raises(ClientError) as exc: + client.disassociate_resolver_rule(ResolverRuleId=random_num, VPCId=test_vpc_id) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert f"Resolver rule with ID '{random_num}' does not exist" in err["Message"] + + # Disassociate using a non-existent vpc id. + with pytest.raises(ClientError) as exc: + client.disassociate_resolver_rule(ResolverRuleId=test_rule_id, VPCId=random_num) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert ( + f"Resolver Rule Association between Resolver Rule " + f"'{test_rule_id}' and VPC '{random_num}' does not exist" + ) in err["Message"] + + # Disassociate successfully, then test that it's not possible to + # disassociate again. + client.disassociate_resolver_rule(ResolverRuleId=test_rule_id, VPCId=test_vpc_id) + with pytest.raises(ClientError) as exc: + client.disassociate_resolver_rule( + ResolverRuleId=test_rule_id, VPCId=test_vpc_id + ) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert ( + f"Resolver Rule Association between Resolver Rule " + f"'{test_rule_id}' and VPC '{test_vpc_id}' does not exist" + ) in err["Message"] + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_get_resolver_rule_association(): + """Test good get_resolver_rule_association API calls.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + + # Create a good association for testing purposes. + created_association = create_test_rule_association(client, ec2_client) + + # Now get the resolver rule association and verify the response. + response = client.get_resolver_rule_association( + ResolverRuleAssociationId=created_association["Id"] + ) + association = response["ResolverRuleAssociation"] + assert association["Id"] == created_association["Id"] + assert association["ResolverRuleId"] == created_association["ResolverRuleId"] + assert association["Name"] == created_association["Name"] + assert association["VPCId"] == created_association["VPCId"] + assert association["Status"] == created_association["Status"] + assert association["StatusMessage"] == created_association["StatusMessage"] + + +@mock_route53resolver +def test_route53resolver_bad_get_resolver_rule_association(): + """Test get_resolver_rule_association API calls with a bad ID.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + random_num = get_random_hex(10) + + # Use a resolver rule association id that is too long. + long_id = "0123456789" * 6 + "xxxxx" + with pytest.raises(ClientError) as exc: + client.get_resolver_rule_association(ResolverRuleAssociationId=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 'resolverRuleAssociationId' failed to satisfy " + f"constraint: Member must have length less than or equal to 64" + ) in err["Message"] + + # Get a non-existent resolver rule association. + with pytest.raises(ClientError) as exc: + client.get_resolver_rule_association(ResolverRuleAssociationId=random_num) + err = exc.value.response["Error"] + assert err["Code"] == "ResourceNotFoundException" + assert f"ResolverRuleAssociation '{random_num}' does not Exist" in err["Message"] + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_list_resolver_rule_associations(): + """Test good list_resolver_rule_associations 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 rule associations when there are none. + response = client.list_resolver_rule_associations() + assert len(response["ResolverRuleAssociations"]) == 0 + assert response["MaxResults"] == 10 + assert "NextToken" not in response + + # Create 4 associations, verify all are listed when no filters, max_results. + for idx in range(4): + create_test_rule_association(client, ec2_client, name=f"A{idx}-{random_num}") + response = client.list_resolver_rule_associations() + associations = response["ResolverRuleAssociations"] + assert len(associations) == 4 + assert response["MaxResults"] == 10 + for idx in range(4): + assert associations[idx]["Name"].startswith(f"A{idx}") + + # Set max_results to return 1 association, use next_token to get + # remaining 3. + response = client.list_resolver_rule_associations(MaxResults=1) + associations = response["ResolverRuleAssociations"] + assert len(associations) == 1 + assert response["MaxResults"] == 1 + assert "NextToken" in response + assert associations[0]["Name"].startswith("A0") + + response = client.list_resolver_rule_associations(NextToken=response["NextToken"]) + associations = response["ResolverRuleAssociations"] + assert len(associations) == 3 + assert response["MaxResults"] == 10 + assert "NextToken" not in response + for idx, association in enumerate(associations): + assert association["Name"].startswith(f"A{idx + 1}") + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_list_resolver_rule_associations_filters(): + """Test good list_resolver_rule_associations API calls that use filters.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + random_num = get_random_hex(10) + + # Create some rule associations for testing purposes + vpc_id1 = create_vpc(ec2_client) + vpc_id2 = create_vpc(ec2_client) + associations = [] + rule_ids = ["zero_offset"] + for idx in range(1, 5): + association = create_test_rule_association( + client, + ec2_client, + name=f"F{idx}-{random_num}", + vpc_id=vpc_id1 if idx % 2 else vpc_id2, + ) + associations.append(association) + rule_ids.append(association["ResolverRuleId"]) + + # Try all the valid filter names, including some of the old style names. + response = client.list_resolver_rule_associations( + Filters=[{"Name": "ResolverRuleId", "Values": [rule_ids[3]]}] + ) + assert len(response["ResolverRuleAssociations"]) == 1 + assert response["ResolverRuleAssociations"][0]["ResolverRuleId"] == rule_ids[3] + + response = client.list_resolver_rule_associations( + Filters=[{"Name": "RESOLVER_RULE_ID", "Values": [rule_ids[2], rule_ids[4]]}] + ) + assert len(response["ResolverRuleAssociations"]) == 2 + assert response["ResolverRuleAssociations"][0]["ResolverRuleId"] == rule_ids[2] + assert response["ResolverRuleAssociations"][1]["ResolverRuleId"] == rule_ids[4] + + response = client.list_resolver_rule_associations( + Filters=[{"Name": "VPCId", "Values": [vpc_id2]}] + ) + assert len(response["ResolverRuleAssociations"]) == 2 + + response = client.list_resolver_rule_associations( + Filters=[{"Name": "Name", "Values": [f"F1-{random_num}"]}] + ) + assert len(response["ResolverRuleAssociations"]) == 1 + assert response["ResolverRuleAssociations"][0]["Name"] == f"F1-{random_num}" + + response = client.list_resolver_rule_associations( + Filters=[ + {"Name": "VPC_ID", "Values": [vpc_id1]}, + {"Name": "NAME", "Values": [f"F3-{random_num}"]}, + ] + ) + assert len(response["ResolverRuleAssociations"]) == 1 + assert response["ResolverRuleAssociations"][0]["Name"] == f"F3-{random_num}" + + response = client.list_resolver_rule_associations( + Filters=[{"Name": "Status", "Values": ["COMPLETE"]}] + ) + assert len(response["ResolverRuleAssociations"]) == 4 + response = client.list_resolver_rule_associations( + Filters=[{"Name": "Status", "Values": ["CREATING"]}] + ) + assert len(response["ResolverRuleAssociations"]) == 0 + + +@mock_route53resolver +def test_route53resolver_bad_list_resolver_rule_associations_filters(): + """Test bad list_resolver_rule_associations API calls that use filters.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + + # botocore barfs on an empty "Values": + # TypeError: list_resolver_rule_associations() only accepts keyword arguments. + # client.list_resolver_rule_associations([{"Name": "Direction", "Values": []}]) + # client.list_resolver_rule_associations([{"Values": []}]) + + with pytest.raises(ClientError) as exc: + client.list_resolver_rule_associations( + Filters=[{"Name": "foo", "Values": ["bar"]}] + ) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParameterException" + assert "The filter 'foo' is invalid" in err["Message"] + + with pytest.raises(ClientError) as exc: + client.list_resolver_rule_associations( + Filters=[{"Name": "VpcId", "Values": ["bar"]}] + ) + err = exc.value.response["Error"] + assert err["Code"] == "InvalidParameterException" + assert "The filter 'VpcId' is invalid" in err["Message"] + + +@mock_ec2 +@mock_route53resolver +def test_route53resolver_bad_list_resolver_rule_associations(): + """Test bad list_resolver_rule_associations API calls.""" + client = boto3.client("route53resolver", region_name=TEST_REGION) + ec2_client = boto3.client("ec2", region_name=TEST_REGION) + + # Bad max_results. + create_test_rule_association(client, ec2_client) + with pytest.raises(ClientError) as exc: + client.list_resolver_rule_associations(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"]