Repository: getmoto/moto
Pull request #7680 (2024-05-07 21:51:25)

WAFv2: Add support for IP sets and logging configuration
This pull request introduces support for IP Sets and Logging configurations resources within the WAFv2 service. It includes the implementation of new operations:

- create_ip_set
- delete_ip_set
- list_ip_sets
- get_ip_set
- update_ip_set
- put_logging_configuration
- get_logging_configuration
- list_logging_configurations
- delete_logging_configuration


Additionally, two CRUD tests have been added for these resources. These tests have been successfully executed against AWS without any complications.

Partially addresses localstack/localstack#10558



Patch:
diff --git a/moto/ec2/models/network_acls.py b/moto/ec2/models/network_acls.py
index 5e9808778..aa11829e6 100644
--- a/moto/ec2/models/network_acls.py
+++ b/moto/ec2/models/network_acls.py
@@ -1,6 +1,7 @@
 from typing import Any, Dict, List, Optional
 
 from ..exceptions import (
+    DependencyViolationError,
     InvalidNetworkAclIdError,
     InvalidRouteTableIdError,
     NetworkAclEntryAlreadyExistsError,
@@ -61,6 +62,14 @@ class NetworkAclBackend:
             )
 
     def delete_network_acl(self, network_acl_id: str) -> "NetworkAcl":
+        if any(
+            network_acl.id == network_acl_id and len(network_acl.associations) > 0
+            for network_acl in self.network_acls.values()
+        ):
+            raise DependencyViolationError(
+                f"The network ACL '{network_acl_id}' has dependencies and cannot be deleted."
+            )
+
         deleted = self.network_acls.pop(network_acl_id, None)
         if not deleted:
             raise InvalidNetworkAclIdError(network_acl_id)
diff --git a/moto/ec2/models/route_tables.py b/moto/ec2/models/route_tables.py
index c063b359c..c557b5a70 100644
--- a/moto/ec2/models/route_tables.py
+++ b/moto/ec2/models/route_tables.py
@@ -116,6 +116,12 @@ class RouteTable(TaggedEC2Resource, CloudFormationModel):
                 for route in self.routes.values()
                 if route.vpc_pcx is not None
             ]
+        elif filter_name == "route.nat-gateway-id":
+            return [
+                route.nat_gateway.id
+                for route in self.routes.values()
+                if route.nat_gateway is not None
+            ]
         else:
             return super().get_filter_value(filter_name, "DescribeRouteTables")
 
diff --git a/moto/wafv2/exceptions.py b/moto/wafv2/exceptions.py
index 218816d94..6a9810eeb 100644
--- a/moto/wafv2/exceptions.py
+++ b/moto/wafv2/exceptions.py
@@ -19,3 +19,11 @@ class WAFNonexistentItemException(WAFv2ClientError):
             "WAFNonexistentItemException",
             "AWS WAF couldn’t perform the operation because your resource doesn’t exist.",
         )
+
+
+class WAFOptimisticLockException(WAFv2ClientError):
+    def __init__(self) -> None:
+        super().__init__(
+            "WAFOptimisticLockException",
+            "AWS WAF couldn’t save your changes because someone changed the resource after you started to edit it. Reapply your changes.",
+        )
diff --git a/moto/wafv2/models.py b/moto/wafv2/models.py
index f3b24076e..6bead554e 100644
--- a/moto/wafv2/models.py
+++ b/moto/wafv2/models.py
@@ -8,8 +8,12 @@ from moto.core.utils import iso_8601_datetime_with_milliseconds
 from moto.moto_api._internal import mock_random
 from moto.utilities.tagging_service import TaggingService
 
-from .exceptions import WAFNonexistentItemException, WAFV2DuplicateItemException
-from .utils import make_arn_for_wacl
+from .exceptions import (
+    WAFNonexistentItemException,
+    WAFOptimisticLockException,
+    WAFV2DuplicateItemException,
+)
+from .utils import make_arn_for_ip_set, make_arn_for_wacl
 
 if TYPE_CHECKING:
     from moto.apigateway.models import Stage
@@ -79,6 +83,75 @@ class FakeWebACL(BaseModel):
         }
 
 
+class FakeIPSet(BaseModel):
+    """
+    https://docs.aws.amazon.com/waf/latest/APIReference/API_IPSet.html
+    """
+
+    def __init__(
+        self,
+        arn: str,
+        ip_set_id: str,
+        ip_address_version: str,
+        addresses: List[str],
+        name: str,
+        description: str,
+        scope: str,
+    ):
+        self.name = name
+        self.ip_set_id = ip_set_id
+        self.arn = arn
+        self.addresses = addresses
+        self.description = description
+        self.ip_address_version = ip_address_version
+        self.scope = scope
+
+        self.lock_token = str(mock_random.uuid4())[0:6]
+
+    def update(self, description: Optional[str], addresses: List[str]) -> None:
+        if description is not None:
+            self.description = description
+        self.addresses = addresses
+
+        self.lock_token = str(mock_random.uuid4())[0:6]
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "Name": self.name,
+            "Id": self.ip_set_id,
+            "ARN": self.arn,
+            "Description": self.description,
+            "IPAddressVersion": self.ip_address_version,
+            "Addresses": self.addresses,
+            "LockToken": self.lock_token,
+        }
+
+
+class FakeLoggingConfiguration:
+    def __init__(
+        self,
+        arn: str,
+        log_destination_configs: List[str],
+        redacted_fields: Optional[Dict[str, Any]],
+        managed_gy_firewall_manager: Optional[bool],
+        logging_filter: Optional[Dict[str, Any]],
+    ):
+        self.arn = arn
+        self.log_destination_configs = log_destination_configs
+        self.redacted_fields = redacted_fields
+        self.managed_by_firewall_manager = managed_gy_firewall_manager or False
+        self.logging_filter = logging_filter
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "ResourceArn": self.arn,
+            "LogDestinationConfigs": self.log_destination_configs,
+            "RedactedFields": self.redacted_fields,
+            "ManagedByFirewallManager": self.managed_by_firewall_manager,
+            "LoggingFilter": self.logging_filter,
+        }
+
+
 class WAFV2Backend(BaseBackend):
     """
     https://docs.aws.amazon.com/waf/latest/APIReference/API_Operations_AWS_WAFV2.html
@@ -87,6 +160,8 @@ class WAFV2Backend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self.wacls: Dict[str, FakeWebACL] = OrderedDict()
+        self.ip_sets: Dict[str, FakeIPSet] = OrderedDict()
+        self.logging_configurations: Dict[str, FakeLoggingConfiguration] = OrderedDict()
         self.tagging_service = TaggingService()
         # TODO: self.load_balancers = OrderedDict()
 
@@ -210,6 +285,140 @@ class WAFV2Backend(BaseBackend):
         acl.update(default_action, rules, description, visibility_config)
         return acl.lock_token
 
+    def create_ip_set(
+        self,
+        name: str,
+        scope: str,
+        description: str,
+        ip_address_version: str,
+        addresses: List[str],
+        tags: List[Dict[str, str]],
+    ) -> FakeIPSet:
+        ip_set_id = str(mock_random.uuid4())
+        arn = make_arn_for_ip_set(
+            name=name,
+            account_id=self.account_id,
+            region_name=self.region_name,
+            _id=ip_set_id,
+            scope=scope,
+        )
+
+        new_ip_set = FakeIPSet(
+            arn,
+            ip_set_id,
+            ip_address_version,
+            addresses,
+            name,
+            description,
+            scope,
+        )
+        self.ip_sets[arn] = new_ip_set
+        self.tag_resource(arn, tags)
+        return new_ip_set
+
+    def delete_ip_set(self, name: str, scope: str, _id: str, lock_token: str) -> None:
+        arn = make_arn_for_ip_set(
+            name=name,
+            account_id=self.account_id,
+            region_name=self.region_name,
+            _id=_id,
+            scope=scope,
+        )
+
+        if arn not in self.ip_sets:
+            raise WAFNonexistentItemException()
+
+        if lock_token != self.ip_sets[arn].lock_token:
+            raise WAFOptimisticLockException()
+
+        self.ip_sets.pop(arn)
+
+    def list_ip_sets(self, scope: str) -> List[FakeIPSet]:
+        ip_sets = [
+            ip_set for arn, ip_set in self.ip_sets.items() if ip_set.scope == scope
+        ]
+        return ip_sets
+
+    def get_ip_set(self, name: str, scope: str, _id: str) -> FakeIPSet:
+        arn = make_arn_for_ip_set(
+            name=name,
+            account_id=self.account_id,
+            region_name=self.region_name,
+            _id=_id,
+            scope=scope,
+        )
+        if arn not in self.ip_sets:
+            raise WAFNonexistentItemException()
+
+        return self.ip_sets[arn]
+
+    def update_ip_set(
+        self,
+        name: str,
+        scope: str,
+        _id: str,
+        description: Optional[str],
+        addresses: List[str],
+        lock_token: str,
+    ) -> FakeIPSet:
+        arn = make_arn_for_ip_set(
+            name=name,
+            account_id=self.account_id,
+            region_name=self.region_name,
+            _id=_id,
+            scope=scope,
+        )
+
+        if not (ip_set := self.ip_sets.get(arn)):
+            raise WAFNonexistentItemException()
+
+        if ip_set.lock_token != lock_token:
+            raise WAFOptimisticLockException()
+
+        ip_set.update(description, addresses)
+
+        return ip_set
+
+    def put_logging_configuration(
+        self,
+        arn: str,
+        log_destination_configs: List[str],
+        redacted_fields: Optional[Dict[str, Any]],
+        managed_gy_firewall_manager: bool,
+        logging_filter: Dict[str, Any],
+    ) -> FakeLoggingConfiguration:
+        logging_configuration = FakeLoggingConfiguration(
+            arn,
+            log_destination_configs,
+            redacted_fields,
+            managed_gy_firewall_manager,
+            logging_filter,
+        )
+        self.logging_configurations[arn] = logging_configuration
+        return logging_configuration
+
+    def delete_logging_configuration(self, arn: str) -> None:
+        if not self.logging_configurations.get(arn):
+            raise WAFNonexistentItemException()
+        self.logging_configurations.pop(arn)
+
+    def get_logging_configuration(self, arn: str) -> FakeLoggingConfiguration:
+        if not (logging_configuration := self.logging_configurations.get(arn)):
+            raise WAFNonexistentItemException()
+        return logging_configuration
+
+    def list_logging_configurations(self, scope: str) -> List[FakeLoggingConfiguration]:
+        if scope == "CLOUDFRONT":
+            scope = "global"
+        else:
+            scope = self.region_name
+
+        return [
+            logging_configuration
+            for arn, logging_configuration in self.logging_configurations.items()
+            if f":{scope}:" in arn
+        ]
+
 
 wafv2_backends = BackendDict(
     WAFV2Backend, "waf-regional", additional_regions=["global"]
diff --git a/moto/wafv2/responses.py b/moto/wafv2/responses.py
index ea81e45c7..68b496059 100644
--- a/moto/wafv2/responses.py
+++ b/moto/wafv2/responses.py
@@ -3,6 +3,7 @@ import json
 from moto.core.common_types import TYPE_RESPONSE
 from moto.core.responses import BaseResponse
 
+from ..moto_api._internal import mock_random
 from .models import GLOBAL_REGION, WAFV2Backend, wafv2_backends
 
 
@@ -140,6 +141,181 @@ class WAFV2Response(BaseResponse):
         )
         return 200, {}, json.dumps({"NextLockToken": lock_token})
 
+    def create_ip_set(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+
+        name = body.get("Name")
+        scope = body.get("Scope")
+
+        if scope == "CLOUDFRONT":
+            self.region = GLOBAL_REGION
+
+        description = body.get("Description")
+        ip_address_version = body.get("IPAddressVersion")
+        addresses = body.get("Addresses")
+        tags = body.get("Tags")
+
+        ip_set = self.wafv2_backend.create_ip_set(
+            name, scope, description, ip_address_version, addresses, tags
+        )
+        return (
+            200,
+            {},
+            json.dumps(
+                {
+                    "Summary": {
+                        "Name": ip_set.name,
+                        "Id": ip_set.ip_set_id,
+                        "Description": ip_set.description,
+                        "LockToken": ip_set.lock_token,
+                        "ARN": ip_set.arn,
+                    }
+                }
+            ),
+        )
+
+    def delete_ip_set(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+
+        name = body.get("Name")
+        scope = body.get("Scope")
+        if scope == "CLOUDFRONT":
+            self.region = GLOBAL_REGION
+
+        _id = body.get("Id")
+        lock_token = body.get("LockToken")
+
+        self.wafv2_backend.delete_ip_set(name, scope, _id, lock_token)
+
+        return 200, {}, "{}"
+
+    def list_ip_sets(self) -> TYPE_RESPONSE:
+        scope = self._get_param("Scope")
+        if scope == "CLOUDFRONT":
+            self.region = GLOBAL_REGION
+        ip_sets = self.wafv2_backend.list_ip_sets(scope)
+
+        formatted_ip_sets = [
+            {
+                "Name": ip_set.name,
+                "Id": ip_set.ip_set_id,
+                "Description": ip_set.description,
+                "LockToken": ip_set.lock_token,
+                "ARN": ip_set.arn,
+            }
+            for ip_set in ip_sets
+        ]
+
+        return (
+            200,
+            {},
+            json.dumps(
+                {"NextMarker": str(mock_random.uuid4()), "IPSets": formatted_ip_sets}
+            ),
+        )
+
+    def get_ip_set(self) -> TYPE_RESPONSE:
+        scope = self._get_param("Scope")
+        if scope == "CLOUDFRONT":
+            self.region = GLOBAL_REGION
+
+        ip_set = self.wafv2_backend.get_ip_set(
+            name=self._get_param("Name"), scope=scope, _id=self._get_param("Id")
+        )
+
+        dict_ip = ip_set.to_dict()
+        lock_token = dict_ip.pop("LockToken")
+        return 200, {}, json.dumps({"IPSet": dict_ip, "LockToken": lock_token})
+
+    def update_ip_set(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+
+        name = body.get("Name")
+        scope = body.get("Scope")
+        _id = body.get("Id")
+
+        if scope == "CLOUDFRONT":
+            self.region = GLOBAL_REGION
+
+        description = body.get("Description")
+        addresses = body.get("Addresses")
+        lock_token = body.get("LockToken")
+        updated_ip_set = self.wafv2_backend.update_ip_set(
+            name, scope, _id, description, addresses, lock_token
+        )
+
+        return 200, {}, json.dumps({"NextLockToken": updated_ip_set.lock_token})
+
+    def put_logging_configuration(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+        logging_configuration_parameter = body["LoggingConfiguration"]
+        resource_arn = logging_configuration_parameter["ResourceArn"]
+        log_destination_configs = logging_configuration_parameter[
+            "LogDestinationConfigs"
+        ]
+        redacted_fields = logging_configuration_parameter.get("RedactedFields")
+        managed_by_firewall_manager = logging_configuration_parameter.get(
+            "ManagedByFirewallManager"
+        )
+        logging_filter = logging_configuration_parameter.get("LoggingFilter")
+        logging_configuration = self.wafv2_backend.put_logging_configuration(
+            resource_arn,
+            log_destination_configs,
+            redacted_fields,
+            managed_by_firewall_manager,
+            logging_filter,
+        )
+        return (
+            200,
+            {},
+            json.dumps(
+                {
+                    "LoggingConfiguration": {
+                        k: v
+                        for k, v in logging_configuration.to_dict().items()
+                        if v is not None
+                    }
+                }
+            ),
+        )
+
+    def get_logging_configuration(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+        resource_arn = body["ResourceArn"]
+        logging_configuration = self.wafv2_backend.get_logging_configuration(
+            resource_arn
+        )
+        return (
+            200,
+            {},
+            json.dumps(
+                {
+                    "LoggingConfiguration": {
+                        k: v
+                        for k, v in logging_configuration.to_dict().items()
+                        if v is not None
+                    }
+                }
+            ),
+        )
+
+    def list_logging_configurations(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+        scope = body.get("Scope")
+        log_configs = self.wafv2_backend.list_logging_configurations(scope)
+
+        formatted = [
+            {k: v for k, v in config.to_dict().items() if v is not None}
+            for config in log_configs
+        ]
+        return 200, {}, json.dumps({"LoggingConfigurations": formatted})
+
+    def delete_logging_configuration(self) -> TYPE_RESPONSE:
+        body = json.loads(self.body)
+        resource_arn = body["ResourceArn"]
+        self.wafv2_backend.delete_logging_configuration(resource_arn)
+        return 200, {}, "{}"
+
 
 # notes about region and scope
 # --scope = CLOUDFRONT is ALWAYS us-east-1 (but we use "global" instead to differentiate between REGIONAL us-east-1)
diff --git a/moto/wafv2/utils.py b/moto/wafv2/utils.py
index c746ff614..a893ec28a 100644
--- a/moto/wafv2/utils.py
+++ b/moto/wafv2/utils.py
@@ -2,9 +2,27 @@ def make_arn_for_wacl(
     name: str, account_id: str, region_name: str, wacl_id: str, scope: str
 ) -> str:
     """https://docs.aws.amazon.com/waf/latest/developerguide/how-aws-waf-works.html - explains --scope (cloudfront vs regional)"""
+    return make_arn(name, account_id, region_name, wacl_id, scope, "webacl")
 
+
+def make_arn_for_ip_set(
+    name: str, account_id: str, region_name: str, _id: str, scope: str
+) -> str:
+    return make_arn(name, account_id, region_name, _id, scope, "ipset")
+
+
+def make_arn_for_logging_configuration(
+    name: str, account_id: str, region_name: str, _id: str, scope: str
+) -> str:
+    return make_arn(name, account_id, region_name, _id, scope, "logingconfiguration")
+
+
+def make_arn(
+    name: str, account_id: str, region_name: str, _id: str, scope: str, resource: str
+) -> str:
     if scope == "REGIONAL":
         scope = "regional"
     elif scope == "CLOUDFRONT":
         scope = "global"
-    return f"arn:aws:wafv2:{region_name}:{account_id}:{scope}/webacl/{name}/{wacl_id}"
+
+    return f"arn:aws:wafv2:{region_name}:{account_id}:{scope}/{resource}/{name}/{_id}"


Test patch:
diff --git a/.github/workflows/tests_sdk_ruby.yml b/.github/workflows/tests_sdk_ruby.yml
index fea7721c7..749ab8763 100644
--- a/.github/workflows/tests_sdk_ruby.yml
+++ b/.github/workflows/tests_sdk_ruby.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Ruby ${{ matrix.ruby-version }}
-        uses: ruby/setup-ruby@1198b074305f9356bd56dd4b311757cc0dab2f1c
+        uses: ruby/setup-ruby@cacc9f1c0b3f4eb8a16a6bb0ed10897b43b9de49
         with:
           ruby-version: ${{ matrix.ruby-version }}
       - name: Set up Python 3.8
diff --git a/tests/test_ec2/test_network_acls.py b/tests/test_ec2/test_network_acls.py
index f861974f2..bde8c726c 100644
--- a/tests/test_ec2/test_network_acls.py
+++ b/tests/test_ec2/test_network_acls.py
@@ -421,6 +421,94 @@ def test_describe_network_acls():
     )
 
 
+@mock_aws
+def test_describe_network_acls_associations_by_subnet():
+    ec2_client = boto3.client("ec2", region_name="us-west-2")
+    vpc = ec2_client.create_vpc(CidrBlock="10.0.0.0/16")
+    vpc_id = vpc["Vpc"]["VpcId"]
+    all_acls = ec2_client.describe_network_acls(
+        Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]
+    )["NetworkAcls"]
+    subnet_1_id = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.1.0/24")[
+        "Subnet"
+    ]["SubnetId"]
+    subnet_2_id = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.2.0/24")[
+        "Subnet"
+    ]["SubnetId"]
+    subnet_3_id = ec2_client.create_subnet(VpcId=vpc_id, CidrBlock="10.0.3.0/24")[
+        "Subnet"
+    ]["SubnetId"]
+    default_acl_id = next(acl["NetworkAclId"] for acl in all_acls if acl["IsDefault"])
+    custom_acl_id = ec2_client.create_network_acl(VpcId=vpc_id)["NetworkAcl"][
+        "NetworkAclId"
+    ]
+
+    # Subnets should be associated with the default ACL by default
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_1_id, default_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_3_id, default_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_2_id, default_acl_id)
+
+    associate_subnet_to_acl(ec2_client, subnet_1_id, custom_acl_id)
+    associate_subnet_to_acl(ec2_client, subnet_3_id, custom_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_1_id, custom_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_3_id, custom_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_2_id, default_acl_id)
+
+    # Re-associate Subnet 1 with the default ACL to test re-association
+    associate_subnet_to_acl(ec2_client, subnet_1_id, default_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_1_id, default_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_2_id, default_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_3_id, custom_acl_id)
+
+    # Attempting to associate a non-existent ACL with a subnet should raise an error
+    with pytest.raises(ClientError):
+        associate_subnet_to_acl(ec2_client, subnet_1_id, "non-existent-acl")
+
+    # Attempting to delete a custom network ACL should fail if it has any subnets associated
+    with pytest.raises(ClientError) as exc:
+        ec2_client.delete_network_acl(NetworkAclId=custom_acl_id)
+    err = exc.value.response["Error"]
+    assert err["Code"] == "DependencyViolation"
+    assert (
+        err["Message"]
+        == f"The network ACL '{custom_acl_id}' has dependencies and cannot be deleted."
+    )
+
+    # Re-associate Subnet 3 with the default ACL to test deletion of custom ACL
+    associate_subnet_to_acl(ec2_client, subnet_3_id, default_acl_id)
+    ec2_client.delete_network_acl(NetworkAclId=custom_acl_id)
+    assert_subnet_is_associated_to_acl(ec2_client, subnet_3_id, default_acl_id)
+
+
+def associate_subnet_to_acl(ec2_client, subnet_id: str, acl_id: str):
+    assoc_id = get_subnet_acl_association_id(ec2_client, subnet_id)
+    ec2_client.replace_network_acl_association(
+        NetworkAclId=acl_id, AssociationId=assoc_id
+    )
+
+
+def get_subnet_acl_association_id(ec2_client, subnet_id: str):
+    network_acls_description = ec2_client.describe_network_acls(
+        Filters=[{"Name": "association.subnet-id", "Values": [subnet_id]}]
+    )
+    associations = network_acls_description["NetworkAcls"][0]["Associations"]
+    return next(
+        assoc["NetworkAclAssociationId"]
+        for assoc in associations
+        if assoc["SubnetId"] == subnet_id
+    )
+
+
+def assert_subnet_is_associated_to_acl(ec2_client, subnet_id: str, acl_id: str):
+    network_acl_description = ec2_client.describe_network_acls(
+        Filters=[{"Name": "association.subnet-id", "Values": [subnet_id]}]
+    )
+    acls_subnet = network_acl_description["NetworkAcls"]
+    # There should be only one ACL associated with the subnet at any given time
+    assert len(acls_subnet) == 1
+    assert acls_subnet[0]["NetworkAclId"] == acl_id
+
+
 @mock_aws
 def test_create_network_acl_with_tags():
     conn = boto3.client("ec2", region_name="us-west-2")
diff --git a/tests/test_ec2/test_route_tables.py b/tests/test_ec2/test_route_tables.py
index dc13d84f3..4187103c4 100644
--- a/tests/test_ec2/test_route_tables.py
+++ b/tests/test_ec2/test_route_tables.py
@@ -919,6 +919,47 @@ def test_describe_route_tables_with_nat_gateway():
     assert nat_gw_routes[0]["State"] == "active"
 
 
+@mock_aws
+def test_describe_route_tables_filter_with_nat_gateway_id():
+    ec2 = boto3.client("ec2", region_name="us-west-1")
+    vpc_id = ec2.create_vpc(CidrBlock="192.168.0.0/23")["Vpc"]["VpcId"]
+    az = ec2.describe_availability_zones()["AvailabilityZones"][0]["ZoneName"]
+    sn_id = ec2.create_subnet(
+        AvailabilityZone=az, CidrBlock="192.168.0.0/24", VpcId=vpc_id
+    )["Subnet"]["SubnetId"]
+    route_table_id = ec2.create_route_table(VpcId=vpc_id)["RouteTable"]["RouteTableId"]
+    ec2.associate_route_table(SubnetId=sn_id, RouteTableId=route_table_id)
+    alloc_id = ec2.allocate_address(Domain="vpc")["AllocationId"]
+    nat_gw_id = ec2.create_nat_gateway(SubnetId=sn_id, AllocationId=alloc_id)[
+        "NatGateway"
+    ]["NatGatewayId"]
+
+    route_tables_by_nat_gateway_id = ec2.describe_route_tables(
+        Filters=[{"Name": "route.nat-gateway-id", "Values": [nat_gw_id]}]
+    )["RouteTables"]
+    assert len(route_tables_by_nat_gateway_id) == 0
+
+    ec2.create_route(
+        DestinationCidrBlock="0.0.0.0/0",
+        NatGatewayId=nat_gw_id,
+        RouteTableId=route_table_id,
+    )
+
+    route_tables_by_nat_gateway_id = ec2.describe_route_tables(
+        Filters=[{"Name": "route.nat-gateway-id", "Values": [nat_gw_id]}]
+    )["RouteTables"][0]
+    nat_gw_routes = [
+        route
+        for route in route_tables_by_nat_gateway_id["Routes"]
+        if route["DestinationCidrBlock"] == "0.0.0.0/0"
+    ]
+
+    assert len(nat_gw_routes) == 1
+    assert nat_gw_routes[0]["DestinationCidrBlock"] == "0.0.0.0/0"
+    assert nat_gw_routes[0]["NatGatewayId"] == nat_gw_id
+    assert nat_gw_routes[0]["State"] == "active"
+
+
 @mock_aws
 def test_create_vpc_end_point():
     ec2 = boto3.client("ec2", region_name="us-west-1")
diff --git a/tests/test_wafv2/test_wafv2.py b/tests/test_wafv2/test_wafv2.py
index 56aacd9f3..54ba198be 100644
--- a/tests/test_wafv2/test_wafv2.py
+++ b/tests/test_wafv2/test_wafv2.py
@@ -197,3 +197,150 @@ def test_update_web_acl():
         "CloudWatchMetricsEnabled": True,
         "MetricName": "updated",
     }
+
+
+@mock_aws
+def test_ip_set_crud():
+    client = boto3.client("wafv2", region_name="us-east-1")
+
+    create_response = client.create_ip_set(
+        Name="test-ip-set",
+        Scope="CLOUDFRONT",
+        Description="Test IP set",
+        IPAddressVersion="IPV4",
+        Addresses=["192.168.0.1/32", "10.0.0.0/8"],
+        Tags=[{"Key": "Environment", "Value": "Test"}],
+    )
+
+    assert "Summary" in create_response
+    summary = create_response["Summary"]
+
+    assert summary["Id"]
+    assert summary["LockToken"]
+    assert summary["ARN"]
+    assert summary["Name"] == "test-ip-set"
+    assert "Test IP set" in summary["Description"]
+
+    get_response = client.get_ip_set(
+        Name=summary["Name"], Scope="CLOUDFRONT", Id=summary["Id"]
+    )
+    assert "IPSet" in get_response
+    assert "LockToken" in get_response
+
+    ip_set = get_response["IPSet"]
+    assert ip_set["IPAddressVersion"] == "IPV4"
+
+    with pytest.raises(ClientError) as e:
+        client.update_ip_set(
+            Name="test-ip-set",
+            Scope="CLOUDFRONT",
+            Id=summary["Id"],
+            Addresses=["10.0.0.0/8"],
+            LockToken="aaaaaaaaaaaaaaaaaa",  # invalid lock token
+        )
+    e.value.response["Error"]["Code"] == "WAFOptimisticLockException"
+    e.value.response["Error"][
+        "Message"
+    ] == "AWS WAF couldn’t save your changes because someone changed the resource after you started to edit it. Reapply your changes."
+
+    update_response = client.update_ip_set(
+        Name="test-ip-set",
+        Scope="CLOUDFRONT",
+        Id=summary["Id"],
+        Description="Updated test IP set",
+        Addresses=["192.168.1.0/24", "10.0.0.0/8"],
+        LockToken=get_response["LockToken"],
+    )
+
+    assert "NextLockToken" in update_response
+
+    updated_get_response = client.get_ip_set(
+        Name=summary["Name"], Scope="CLOUDFRONT", Id=summary["Id"]
+    )
+
+    updated_ip_set = updated_get_response["IPSet"]
+    assert updated_ip_set["Description"] == "Updated test IP set"
+    assert updated_get_response["LockToken"] == update_response["NextLockToken"]
+    assert all(
+        addr in ["192.168.1.0/24", "10.0.0.0/8"] for addr in updated_ip_set["Addresses"]
+    )
+
+    list_response = client.list_ip_sets(Scope="CLOUDFRONT")
+    assert len(list_response["IPSets"]) == 1
+
+    assert all(
+        [
+            key in list_response["IPSets"][0]
+            for key in ["ARN", "Description", "Id", "LockToken", "Name"]
+        ]
+    )
+    assert "NextMarker" in list_response
+
+    client.delete_ip_set(
+        Name=summary["Name"],
+        Scope="CLOUDFRONT",
+        Id=summary["Id"],
+        LockToken=updated_get_response["LockToken"],
+    )
+
+    with pytest.raises(ClientError) as e:
+        client.get_ip_set(Name=summary["Name"], Scope="CLOUDFRONT", Id=summary["Id"])
+    e.value.response["Error"]["Code"] == "WAFNonexistentItemException"
+    e.value.response["Error"][
+        "Message"
+    ] == "AWS WAF couldn’t perform the operation because your resource doesn’t exist."
+
+
+@mock_aws
+def test_logging_configuration_crud():
+    wafv2_client = boto3.client("wafv2", region_name="us-east-1")
+    create_web_acl_response = wafv2_client.create_web_acl(
+        Name="TestWebACL",
+        Scope="REGIONAL",
+        DefaultAction={"Allow": {}},
+        VisibilityConfig={
+            "SampledRequestsEnabled": True,
+            "CloudWatchMetricsEnabled": True,
+            "MetricName": "TestWebACLMetric",
+        },
+        Rules=[],
+    )
+    web_acl_arn = create_web_acl_response["Summary"]["ARN"]
+
+    # Create log groups
+    logs_client = boto3.client("logs", region_name="us-east-1")
+    logs_client.create_log_group(logGroupName="aws-waf-logs-test")
+    log_group = logs_client.describe_log_groups(logGroupNamePrefix="aws-waf-logs-test")[
+        "logGroups"
+    ][0]
+
+    create_response = wafv2_client.put_logging_configuration(
+        LoggingConfiguration={
+            "ResourceArn": web_acl_arn,
+            "LogDestinationConfigs": [log_group["arn"]],
+        }
+    )
+
+    assert "LoggingConfiguration" in create_response
+    logging_configuration = create_response["LoggingConfiguration"]
+    assert logging_configuration["ResourceArn"]
+
+    get_response = wafv2_client.get_logging_configuration(
+        ResourceArn=logging_configuration["ResourceArn"]
+    )
+    assert "LoggingConfiguration" in get_response
+
+    list_response = wafv2_client.list_logging_configurations(
+        Scope="REGIONAL",
+    )
+    assert len(list_response["LoggingConfigurations"]) > 0
+
+    wafv2_client.delete_logging_configuration(
+        ResourceArn=logging_configuration["ResourceArn"],
+    )
+
+    with pytest.raises(ClientError) as e:
+        wafv2_client.get_logging_configuration(
+            ResourceArn=logging_configuration["ResourceArn"]
+        )
+    e.value.response["Error"]["Code"] == "WAFNonexistentItemException"