Repository: getmoto/moto (python)

Issue:
WAFV2.Client.list_ip_sets always returns a NextMarker
The `list_ip_sets` function on the wafv2 client will always return a (randomized) NextMarker, even if the list of ip sets is empty or all items have been returned.

[AWS specs](https://docs.aws.amazon.com/waf/latest/APIReference/API_ListIPSets.html#API_ListIPSets_ResponseSyntax) say "if the number of objects that are still available for retrieval exceeds the limit, AWS WAF returns a NextMarker value in the response".

So if you rely on that parameter to paginate, you'll end up in an infinite loop in your tests.

```
@mock_aws
def test_list_ip_sets():
    client = boto3.client("wafv2")

    response = client.list_ip_sets(Scope="REGIONAL")

    assert "NextMarker" not in response
```

See https://github.com/getmoto/moto/blob/60328d54ee14c390f00d83167da94bf273a58a6b/moto/wafv2/responses.py#L241

Pull request:
WAFv2: Paginate all list_ methods
Fixes #8341 

Patch:
diff --git a/docs/docs/services/medialive.rst b/docs/docs/services/medialive.rst
index 4de881aac..be7400cc1 100644
--- a/docs/docs/services/medialive.rst
+++ b/docs/docs/services/medialive.rst
@@ -86,10 +86,6 @@ medialive
 - [ ] get_signal_map
 - [ ] list_channel_placement_groups
 - [X] list_channels
-  
-        Pagination is not yet implemented
-        
-
 - [ ] list_cloud_watch_alarm_template_groups
 - [ ] list_cloud_watch_alarm_templates
 - [ ] list_clusters
@@ -99,10 +95,6 @@ medialive
 - [ ] list_input_devices
 - [ ] list_input_security_groups
 - [X] list_inputs
-  
-        Pagination is not yet implemented
-        
-
 - [ ] list_multiplex_programs
 - [ ] list_multiplexes
 - [ ] list_networks
diff --git a/docs/docs/services/wafv2.rst b/docs/docs/services/wafv2.rst
index 2dec4b6e2..65e6fc181 100644
--- a/docs/docs/services/wafv2.rst
+++ b/docs/docs/services/wafv2.rst
@@ -59,10 +59,6 @@ wafv2
 - [ ] list_resources_for_web_acl
 - [X] list_rule_groups
 - [X] list_tags_for_resource
-  
-        Pagination is not yet implemented
-        
-
 - [X] list_web_acls
 - [X] put_logging_configuration
 - [ ] put_managed_rule_set_versions
diff --git a/moto/medialive/models.py b/moto/medialive/models.py
index a5dd85a9c..e09777634 100644
--- a/moto/medialive/models.py
+++ b/moto/medialive/models.py
@@ -4,8 +4,24 @@ from typing import Any, Dict, List, Optional
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
 from moto.moto_api._internal import mock_random
+from moto.utilities.paginator import paginate
 from moto.utilities.utils import get_partition
 
+PAGINATION_MODEL = {
+    "list_channels": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "unique_attribute": "arn",
+    },
+    "list_inputs": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "unique_attribute": "arn",
+    },
+}
+
 
 class Input(BaseModel):
     def __init__(self, **kwargs: Any):
@@ -157,13 +173,9 @@ class MediaLiveBackend(BaseBackend):
         self._channels[channel_id] = channel
         return channel
 
-    def list_channels(self, max_results: Optional[int]) -> List[Dict[str, Any]]:
-        """
-        Pagination is not yet implemented
-        """
+    @paginate(pagination_model=PAGINATION_MODEL)  # type: ignore[misc]
+    def list_channels(self) -> List[Dict[str, Any]]:
         channels = list(self._channels.values())
-        if max_results is not None:
-            channels = channels[:max_results]
         return [
             c.to_dict(exclude=["encoderSettings", "pipelineDetails"]) for c in channels
         ]
@@ -255,13 +267,9 @@ class MediaLiveBackend(BaseBackend):
         a_input._resolve_transient_states()
         return a_input
 
-    def list_inputs(self, max_results: Optional[int]) -> List[Dict[str, Any]]:
-        """
-        Pagination is not yet implemented
-        """
+    @paginate(PAGINATION_MODEL)  # type: ignore[misc]
+    def list_inputs(self) -> List[Dict[str, Any]]:
         inputs = list(self._inputs.values())
-        if max_results is not None:
-            inputs = inputs[:max_results]
         return [i.to_dict() for i in inputs]
 
     def delete_input(self, input_id: str) -> None:
diff --git a/moto/medialive/responses.py b/moto/medialive/responses.py
index f9515fe8a..2356bfa70 100644
--- a/moto/medialive/responses.py
+++ b/moto/medialive/responses.py
@@ -43,9 +43,12 @@ class MediaLiveResponse(BaseResponse):
 
     def list_channels(self) -> str:
         max_results = self._get_int_param("maxResults")
-        channels = self.medialive_backend.list_channels(max_results=max_results)
+        next_token = self._get_param("nextToken")
+        channels, next_token = self.medialive_backend.list_channels(
+            max_results=max_results, next_token=next_token
+        )
 
-        return json.dumps(dict(channels=channels, nextToken=None))
+        return json.dumps(dict(channels=channels, nextToken=next_token))
 
     def describe_channel(self) -> str:
         channel_id = self._get_param("channelId")
@@ -120,9 +123,12 @@ class MediaLiveResponse(BaseResponse):
 
     def list_inputs(self) -> str:
         max_results = self._get_int_param("maxResults")
-        inputs = self.medialive_backend.list_inputs(max_results=max_results)
+        next_token = self._get_param("nextToken")
+        inputs, next_token = self.medialive_backend.list_inputs(
+            max_results=max_results, next_token=next_token
+        )
 
-        return json.dumps(dict(inputs=inputs, nextToken=None))
+        return json.dumps(dict(inputs=inputs, nextToken=next_token))
 
     def delete_input(self) -> str:
         input_id = self._get_param("inputId")
diff --git a/moto/wafv2/models.py b/moto/wafv2/models.py
index 4c0e1b87b..b704f1635 100644
--- a/moto/wafv2/models.py
+++ b/moto/wafv2/models.py
@@ -9,6 +9,7 @@ from moto.core.utils import (
     iso_8601_datetime_with_milliseconds,
 )
 from moto.moto_api._internal import mock_random
+from moto.utilities.paginator import paginate
 from moto.utilities.tagging_service import TaggingService
 from moto.utilities.utils import ARN_PARTITION_REGEX, PARTITION_NAMES
 
@@ -31,6 +32,39 @@ APIGATEWAY_REGEX = (
     + r":apigateway:[a-zA-Z0-9-]+::/restapis/[a-zA-Z0-9]+/stages/[a-zA-Z0-9]+"
 )
 
+PAGINATION_MODEL = {
+    "list_ip_sets": {
+        "input_token": "next_marker",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "arn",
+    },
+    "list_logging_configurations": {
+        "input_token": "next_marker",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "arn",
+    },
+    "list_rule_groups": {
+        "input_token": "next_marker",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "arn",
+    },
+    "list_tags_for_resource": {
+        "input_token": "next_marker",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "Key",
+    },
+    "list_web_acls": {
+        "input_token": "next_marker",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "ARN",
+    },
+}
+
 
 class FakeRule(BaseModel):
     def __init__(
@@ -479,6 +513,7 @@ class WAFV2Backend(BaseBackend):
                 return wacl
         raise WAFNonexistentItemException
 
+    @paginate(PAGINATION_MODEL)  # type: ignore
     def list_web_acls(self) -> List[Dict[str, Any]]:
         return [wacl.to_short_dict() for wacl in self.wacls.values()]
 
@@ -486,16 +521,15 @@ class WAFV2Backend(BaseBackend):
         all_wacl_names = set(wacl.name for wacl in self.wacls.values())
         return name in all_wacl_names
 
+    @paginate(PAGINATION_MODEL)  # type: ignore
     def list_rule_groups(self, scope: str) -> List[Any]:
         rule_groups = [
             group for group in self.rule_groups.values() if group.scope == scope
         ]
         return rule_groups
 
+    @paginate(PAGINATION_MODEL)  # type: ignore
     def list_tags_for_resource(self, arn: str) -> List[Dict[str, str]]:
-        """
-        Pagination is not yet implemented
-        """
         return self.tagging_service.list_tags_for_resource(arn)["Tags"]
 
     def tag_resource(self, arn: str, tags: List[Dict[str, str]]) -> None:
@@ -591,6 +625,7 @@ class WAFV2Backend(BaseBackend):
 
         self.ip_sets.pop(arn)
 
+    @paginate(PAGINATION_MODEL)  # type: ignore
     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
@@ -665,6 +700,7 @@ class WAFV2Backend(BaseBackend):
             raise WAFNonexistentItemException()
         return logging_configuration
 
+    @paginate(PAGINATION_MODEL)  # type: ignore
     def list_logging_configurations(self, scope: str) -> List[FakeLoggingConfiguration]:
         if scope == "CLOUDFRONT":
             scope = "global"
diff --git a/moto/wafv2/responses.py b/moto/wafv2/responses.py
index b8b03c22e..c00812708 100644
--- a/moto/wafv2/responses.py
+++ b/moto/wafv2/responses.py
@@ -3,7 +3,6 @@ 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
 
 
@@ -98,8 +97,12 @@ class WAFV2Response(BaseResponse):
         scope = self._get_param("Scope")
         if scope == "CLOUDFRONT":
             self.region = GLOBAL_REGION
-        all_web_acls = self.wafv2_backend.list_web_acls()
-        response = {"NextMarker": "Not Implemented", "WebACLs": all_web_acls}
+        limit = self._get_int_param("Limit")
+        next_marker = self._get_param("NextMarker")
+        web_acls, next_marker = self.wafv2_backend.list_web_acls(
+            limit=limit, next_marker=next_marker
+        )
+        response = {"NextMarker": next_marker, "WebACLs": web_acls}
         response_headers = {"Content-Type": "application/json"}
         return 200, response_headers, json.dumps(response)
 
@@ -107,16 +110,30 @@ class WAFV2Response(BaseResponse):
         scope = self._get_param("Scope")
         if scope == "CLOUDFRONT":
             self.region = GLOBAL_REGION
-        rule_groups = self.wafv2_backend.list_rule_groups(scope)
-        response = {"RuleGroups": [rg.to_short_dict() for rg in rule_groups]}
+        limit = self._get_int_param("Limit")
+        next_marker = self._get_param("NextMarker")
+        rule_groups, next_marker = self.wafv2_backend.list_rule_groups(
+            scope, limit=limit, next_marker=next_marker
+        )
+        response = {
+            "RuleGroups": [rg.to_short_dict() for rg in rule_groups],
+            "NextMarker": next_marker,
+        }
         response_headers = {"Content-Type": "application/json"}
         return 200, response_headers, json.dumps(response)
 
     def list_tags_for_resource(self) -> TYPE_RESPONSE:
         arn = self._get_param("ResourceARN")
         self.region = arn.split(":")[3]
-        tags = self.wafv2_backend.list_tags_for_resource(arn)
-        response = {"TagInfoForResource": {"ResourceARN": arn, "TagList": tags}}
+        limit = self._get_int_param("Limit")
+        next_marker = self._get_param("NextMarker")
+        tags, next_marker = self.wafv2_backend.list_tags_for_resource(
+            arn, limit=limit, next_marker=next_marker
+        )
+        response = {
+            "TagInfoForResource": {"ResourceARN": arn, "TagList": tags},
+            "NextMarker": next_marker,
+        }
         response_headers = {"Content-Type": "application/json"}
         return 200, response_headers, json.dumps(response)
 
@@ -217,11 +234,15 @@ class WAFV2Response(BaseResponse):
 
         return 200, {}, "{}"
 
-    def list_ip_sets(self) -> TYPE_RESPONSE:
+    def list_ip_sets(self) -> str:
         scope = self._get_param("Scope")
         if scope == "CLOUDFRONT":
             self.region = GLOBAL_REGION
-        ip_sets = self.wafv2_backend.list_ip_sets(scope)
+        next_marker = self._get_param("NextMarker")
+        limit = self._get_int_param("Limit")
+        ip_sets, next_token = self.wafv2_backend.list_ip_sets(
+            scope, next_marker=next_marker, limit=limit
+        )
 
         formatted_ip_sets = [
             {
@@ -234,13 +255,7 @@ class WAFV2Response(BaseResponse):
             for ip_set in ip_sets
         ]
 
-        return (
-            200,
-            {},
-            json.dumps(
-                {"NextMarker": str(mock_random.uuid4()), "IPSets": formatted_ip_sets}
-            ),
-        )
+        return json.dumps({"NextMarker": next_token, "IPSets": formatted_ip_sets})
 
     def get_ip_set(self) -> TYPE_RESPONSE:
         scope = self._get_param("Scope")
@@ -327,16 +342,22 @@ class WAFV2Response(BaseResponse):
             ),
         )
 
-    def list_logging_configurations(self) -> TYPE_RESPONSE:
+    def list_logging_configurations(self) -> str:
         body = json.loads(self.body)
         scope = body.get("Scope")
-        log_configs = self.wafv2_backend.list_logging_configurations(scope)
+        limit = self._get_int_param("Limit")
+        next_marker = self._get_param("NextMarker")
+        log_configs, next_marker = self.wafv2_backend.list_logging_configurations(
+            scope, limit=limit, next_marker=next_marker
+        )
 
         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})
+        return json.dumps(
+            {"LoggingConfigurations": formatted, "NextMarker": next_marker}
+        )
 
     def delete_logging_configuration(self) -> TYPE_RESPONSE:
         body = json.loads(self.body)


Test patch:
diff --git a/tests/test_medialive/test_medialive.py b/tests/test_medialive/test_medialive.py
index e3c0568fa..a49cb9a21 100644
--- a/tests/test_medialive/test_medialive.py
+++ b/tests/test_medialive/test_medialive.py
@@ -126,24 +126,41 @@ def test_create_channel_succeeds():
 @mock_aws
 def test_list_channels_succeeds():
     client = boto3.client("medialive", region_name=region)
-    channel1_config = _create_channel_config("test channel 1", request_id="request-1")
-    channel2_config = _create_channel_config("test channel 2", request_id="request-2")
-    channel2_config["ChannelClass"] = "SINGLE_PIPELINE"
+    channel_configs = [
+        _create_channel_config(f"test {idx}", request_id=f"req-{idx}")
+        for idx in range(10)
+    ]
+    channel_configs[1]["ChannelClass"] = "SINGLE_PIPELINE"
 
-    client.create_channel(**channel1_config)
-    client.create_channel(**channel2_config)
+    for config in channel_configs:
+        client.create_channel(**config)
 
     response = client.list_channels()
-    assert len(response["Channels"]) == 2
+    assert len(response["Channels"]) == 10
 
-    assert response["Channels"][0]["Name"] == "test channel 1"
+    assert response["Channels"][0]["Name"] == "test 0"
     assert response["Channels"][0]["ChannelClass"] == "STANDARD"
     assert response["Channels"][0]["PipelinesRunningCount"] == 2
 
-    assert response["Channels"][1]["Name"] == "test channel 2"
+    assert response["Channels"][1]["Name"] == "test 1"
     assert response["Channels"][1]["ChannelClass"] == "SINGLE_PIPELINE"
     assert response["Channels"][1]["PipelinesRunningCount"] == 1
 
+    page1 = client.list_channels(MaxResults=2)
+    assert len(page1["Channels"]) == 2
+    channel_names = [c["Name"] for c in page1["Channels"]]
+    assert ["test 0", "test 1"] == sorted(channel_names)
+
+    page2 = client.list_channels(MaxResults=5, NextToken=page1["NextToken"])
+    assert len(page2["Channels"]) == 5
+    channel_names = [c["Name"] for c in page2["Channels"]]
+    assert ["test 2", "test 3", "test 4", "test 5", "test 6"] == sorted(channel_names)
+
+    page3 = client.list_channels(NextToken=page2["NextToken"])
+    assert len(page3["Channels"]) == 3
+    channel_names = [c["Name"] for c in page3["Channels"]]
+    assert ["test 7", "test 8", "test 9"] == sorted(channel_names)
+
 
 @mock_aws
 def test_delete_channel_moves_channel_in_deleted_state():
@@ -272,16 +289,27 @@ def test_describe_input_succeeds():
 @mock_aws
 def test_list_inputs_succeeds():
     client = boto3.client("medialive", region_name=region)
-    input_config1 = _create_input_config("Input One")
-    client.create_input(**input_config1)
-    input_config2 = _create_input_config("Input Two")
-    client.create_input(**input_config2)
+    configs = [_create_input_config(f"Input {idx}") for idx in range(10)]
+    for config in configs:
+        client.create_input(**config)
 
     inputs = client.list_inputs()["Inputs"]
-    assert len(inputs) == 2
-
-    assert inputs[0]["Name"] == "Input One"
-    assert inputs[1]["Name"] == "Input Two"
+    assert len(inputs) == 10
+
+    page1 = client.list_inputs(MaxResults=2)
+    assert len(page1["Inputs"]) == 2
+    names = [i["Name"] for i in page1["Inputs"]]
+    assert sorted(names) == ["Input 0", "Input 1"]
+
+    page2 = client.list_inputs(MaxResults=5, NextToken=page1["NextToken"])
+    assert len(page2["Inputs"]) == 5
+    names = [i["Name"] for i in page2["Inputs"]]
+    assert sorted(names) == ["Input 2", "Input 3", "Input 4", "Input 5", "Input 6"]
+
+    page3 = client.list_inputs(NextToken=page2["NextToken"])
+    assert len(page3["Inputs"]) == 3
+    names = [i["Name"] for i in page3["Inputs"]]
+    assert sorted(names) == ["Input 7", "Input 8", "Input 9"]
 
 
 @mock_aws
diff --git a/tests/test_wafv2/test_helper_functions.py b/tests/test_wafv2/test_helper_functions.py
index 2ab8fbd22..51d30f7df 100644
--- a/tests/test_wafv2/test_helper_functions.py
+++ b/tests/test_wafv2/test_helper_functions.py
@@ -9,7 +9,3 @@ def CREATE_WEB_ACL_BODY(name: str, scope: str) -> dict:
             "MetricName": "idk",
         },
     }
-
-
-def LIST_WEB_ACL_BODY(scope: str) -> dict:
-    return {"Scope": scope}
diff --git a/tests/test_wafv2/test_server.py b/tests/test_wafv2/test_server.py
index 11f1efcc6..4a805b4bb 100644
--- a/tests/test_wafv2/test_server.py
+++ b/tests/test_wafv2/test_server.py
@@ -6,7 +6,7 @@ import moto.server as server
 from moto import mock_aws, settings
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
-from .test_helper_functions import CREATE_WEB_ACL_BODY, LIST_WEB_ACL_BODY
+from .test_helper_functions import CREATE_WEB_ACL_BODY
 
 CREATE_WEB_ACL_HEADERS = {
     "X-Amz-Target": "AWSWAF_20190729.CreateWebACL",
@@ -89,7 +89,7 @@ def test_list_web_ac_ls():
         json=CREATE_WEB_ACL_BODY("Sarah", "CLOUDFRONT"),
     )
     res = test_client.post(
-        "/", headers=LIST_WEB_ACL_HEADERS, json=LIST_WEB_ACL_BODY("REGIONAL")
+        "/", headers=LIST_WEB_ACL_HEADERS, json={"Scope": "REGIONAL"}
     )
     assert res.status_code == 200
 
@@ -99,7 +99,7 @@ def test_list_web_ac_ls():
     assert web_acls[1]["Name"] == "JohnSon"
 
     res = test_client.post(
-        "/", headers=LIST_WEB_ACL_HEADERS, json=LIST_WEB_ACL_BODY("CLOUDFRONT")
+        "/", headers=LIST_WEB_ACL_HEADERS, json={"Scope": "CLOUDFRONT"}
     )
     assert res.status_code == 200
     web_acls = res.json["WebACLs"]
diff --git a/tests/test_wafv2/test_wafv2.py b/tests/test_wafv2/test_wafv2.py
index 5c8af466f..a4d0bfeb8 100644
--- a/tests/test_wafv2/test_wafv2.py
+++ b/tests/test_wafv2/test_wafv2.py
@@ -5,7 +5,7 @@ from botocore.exceptions import ClientError
 from moto import mock_aws
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
-from .test_helper_functions import CREATE_WEB_ACL_BODY, LIST_WEB_ACL_BODY
+from .test_helper_functions import CREATE_WEB_ACL_BODY
 
 
 @mock_aws
@@ -163,18 +163,30 @@ def test_list_web_acl():
     conn = boto3.client("wafv2", region_name="us-east-1")
     conn.create_web_acl(**CREATE_WEB_ACL_BODY("Daphne", "REGIONAL"))
     conn.create_web_acl(**CREATE_WEB_ACL_BODY("Penelope", "CLOUDFRONT"))
-    conn.create_web_acl(**CREATE_WEB_ACL_BODY("Sarah", "REGIONAL"))
-    res = conn.list_web_acls(**LIST_WEB_ACL_BODY("REGIONAL"))
+    for idx in range(5):
+        conn.create_web_acl(**CREATE_WEB_ACL_BODY(f"Sarah {idx}", "REGIONAL"))
+    res = conn.list_web_acls(Scope="REGIONAL")
     web_acls = res["WebACLs"]
-    assert len(web_acls) == 2
+    assert len(web_acls) == 6
     assert web_acls[0]["Name"] == "Daphne"
-    assert web_acls[1]["Name"] == "Sarah"
+    assert web_acls[1]["Name"] == "Sarah 0"
 
-    res = conn.list_web_acls(**LIST_WEB_ACL_BODY("CLOUDFRONT"))
+    res = conn.list_web_acls(Scope="CLOUDFRONT")
     web_acls = res["WebACLs"]
     assert len(web_acls) == 1
     assert web_acls[0]["Name"] == "Penelope"
 
+    page1 = conn.list_web_acls(Scope="REGIONAL", Limit=2)
+    assert len(page1["WebACLs"]) == 2
+
+    page2 = conn.list_web_acls(
+        Scope="REGIONAL", Limit=1, NextMarker=page1["NextMarker"]
+    )
+    assert len(page2["WebACLs"]) == 1
+
+    page3 = conn.list_web_acls(Scope="REGIONAL", NextMarker=page2["NextMarker"])
+    assert len(page3["WebACLs"]) == 3
+
 
 @mock_aws
 def test_delete_web_acl():
@@ -191,7 +203,7 @@ def test_delete_web_acl():
         Name="Daphne", Id=wacl["Id"], Scope="REGIONAL", LockToken=wacl["LockToken"]
     )
 
-    res = conn.list_web_acls(**LIST_WEB_ACL_BODY("REGIONAL"))
+    res = conn.list_web_acls(Scope="REGIONAL")
     assert len(res["WebACLs"]) == 0
 
     with pytest.raises(ClientError) as exc:
@@ -347,7 +359,6 @@ def test_ip_set_crud():
             for key in ["ARN", "Description", "Id", "LockToken", "Name"]
         ]
     )
-    assert "NextMarker" in list_response
 
     client.delete_ip_set(
         Name=summary["Name"],
@@ -365,55 +376,29 @@ def test_ip_set_crud():
 
 
 @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"]],
-        }
-    )
+def test_list_ip_sets_pagination():
+    client = boto3.client("wafv2", region_name="us-east-1")
 
-    assert "LoggingConfiguration" in create_response
-    logging_configuration = create_response["LoggingConfiguration"]
-    assert logging_configuration["ResourceArn"]
+    for idx in range(10):
+        client.create_ip_set(
+            Name=f"test-ip-set-{idx}",
+            Scope="CLOUDFRONT",
+            Description="Test IP set",
+            IPAddressVersion="IPV4",
+            Addresses=["192.168.0.1/32", "10.0.0.0/8"],
+            Tags=[{"Key": "Environment", "Value": "Test"}],
+        )
 
-    get_response = wafv2_client.get_logging_configuration(
-        ResourceArn=logging_configuration["ResourceArn"]
-    )
-    assert "LoggingConfiguration" in get_response
+    list_all = client.list_ip_sets(Scope="CLOUDFRONT")["IPSets"]
+    assert len(list_all) == 10
 
-    list_response = wafv2_client.list_logging_configurations(
-        Scope="REGIONAL",
-    )
-    assert len(list_response["LoggingConfigurations"]) > 0
+    page1 = client.list_ip_sets(Scope="CLOUDFRONT", Limit=2)
+    assert len(page1["IPSets"]) == 2
 
-    wafv2_client.delete_logging_configuration(
-        ResourceArn=logging_configuration["ResourceArn"],
+    page2 = client.list_ip_sets(
+        Scope="CLOUDFRONT", Limit=5, NextMarker=page1["NextMarker"]
     )
+    assert len(page2["IPSets"]) == 5
 
-    with pytest.raises(ClientError) as e:
-        wafv2_client.get_logging_configuration(
-            ResourceArn=logging_configuration["ResourceArn"]
-        )
-    assert e.value.response["Error"]["Code"] == "WAFNonexistentItemException"
+    page3 = client.list_ip_sets(Scope="CLOUDFRONT", NextMarker=page2["NextMarker"])
+    assert len(page3["IPSets"]) == 3
diff --git a/tests/test_wafv2/test_wafv2_logging_configuration.py b/tests/test_wafv2/test_wafv2_logging_configuration.py
new file mode 100644
index 000000000..7f584c954
--- /dev/null
+++ b/tests/test_wafv2/test_wafv2_logging_configuration.py
@@ -0,0 +1,95 @@
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from moto import mock_aws
+
+
+@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
+
+    wafv2_client.delete_logging_configuration(
+        ResourceArn=logging_configuration["ResourceArn"],
+    )
+
+    with pytest.raises(ClientError) as e:
+        wafv2_client.get_logging_configuration(
+            ResourceArn=logging_configuration["ResourceArn"]
+        )
+    assert e.value.response["Error"]["Code"] == "WAFNonexistentItemException"
+
+
+@mock_aws
+def test_list_logging_config_pagination():
+    wafv2_client = boto3.client("wafv2", region_name="us-east-1")
+
+    for idx in range(10):
+        web_acl_arn = wafv2_client.create_web_acl(
+            Name=f"TestWebACL{idx}",
+            Scope="REGIONAL",
+            DefaultAction={"Allow": {}},
+            VisibilityConfig={
+                "SampledRequestsEnabled": True,
+                "CloudWatchMetricsEnabled": True,
+                "MetricName": "TestWebACLMetric",
+            },
+            Rules=[],
+        )["Summary"]["ARN"]
+        wafv2_client.put_logging_configuration(
+            LoggingConfiguration={
+                "ResourceArn": web_acl_arn,
+                "LogDestinationConfigs": ["arn:aws:us-east-1:logs:arn"],
+            }
+        )
+
+    list_all = wafv2_client.list_logging_configurations(Scope="REGIONAL")
+    assert len(list_all["LoggingConfigurations"]) == 10
+
+    page1 = wafv2_client.list_logging_configurations(Scope="REGIONAL", Limit=2)
+    assert len(page1["LoggingConfigurations"]) == 2
+
+    page2 = wafv2_client.list_logging_configurations(
+        Scope="REGIONAL", Limit=3, NextMarker=page1["NextMarker"]
+    )
+    assert len(page2["LoggingConfigurations"]) == 3
+
+    page3 = wafv2_client.list_logging_configurations(
+        Scope="REGIONAL", NextMarker=page2["NextMarker"]
+    )
+    assert len(page3["LoggingConfigurations"]) == 5
diff --git a/tests/test_wafv2/test_wafv2_rules.py b/tests/test_wafv2/test_wafv2_rules.py
index 1d4f7fba2..910c4aa1c 100644
--- a/tests/test_wafv2/test_wafv2_rules.py
+++ b/tests/test_wafv2/test_wafv2_rules.py
@@ -334,25 +334,41 @@ def test_list_rule_groups():
             "MetricName": "test-group-1",
         },
     )["Summary"]
-    cf_group = client.create_rule_group(
-        Capacity=100,
-        Name="test-group-2",
-        Scope="CLOUDFRONT",
-        VisibilityConfig={
-            "SampledRequestsEnabled": True,
-            "CloudWatchMetricsEnabled": True,
-            "MetricName": "test-group-2",
-        },
-    )["Summary"]
+    for idx in range(2, 10):
+        client.create_rule_group(
+            Capacity=100,
+            Name=f"test-group-{idx}",
+            Scope="CLOUDFRONT",
+            VisibilityConfig={
+                "SampledRequestsEnabled": True,
+                "CloudWatchMetricsEnabled": True,
+                "MetricName": "test-group-2",
+            },
+        )["Summary"]
 
     reg_groups = client.list_rule_groups(Scope="REGIONAL")["RuleGroups"]
-    cf_groups = client.list_rule_groups(Scope="CLOUDFRONT")["RuleGroups"]
-    assert len(reg_groups) == len(cf_groups) == 1
+    assert len(reg_groups) == 1
     regional = reg_groups[0]
     assert regional["ARN"] == regional_group["ARN"]
-    assert cf_groups[0]["ARN"] == cf_group["ARN"]
     assert regional["Name"] == "test-group-1"
     assert "Scope" not in regional
     assert is_valid_uuid(regional["LockToken"])
     assert is_valid_uuid(regional["Id"]) and regional["Id"] == regional_group["Id"]
     assert "VisibilityConfig" not in regional
+
+    cf_groups = client.list_rule_groups(Scope="CLOUDFRONT")["RuleGroups"]
+    assert len(cf_groups) == 8
+    assert cf_groups[0]["ARN"].startswith(
+        f"arn:aws:wafv2:global:{ACCOUNT_ID}:global/rulegroup/test-group-2/"
+    )
+
+    page1 = client.list_rule_groups(Scope="CLOUDFRONT", Limit=2)
+    assert len(page1["RuleGroups"]) == 2
+
+    page2 = client.list_rule_groups(
+        Scope="CLOUDFRONT", Limit=1, NextMarker=page1["NextMarker"]
+    )
+    assert len(page2["RuleGroups"]) == 1
+
+    page3 = client.list_rule_groups(Scope="CLOUDFRONT", NextMarker=page2["NextMarker"])
+    assert len(page3["RuleGroups"]) == 5
diff --git a/tests/test_wafv2/test_wafv2_tags.py b/tests/test_wafv2/test_wafv2_tags.py
index 50ac16e69..57c6b4f19 100644
--- a/tests/test_wafv2/test_wafv2_tags.py
+++ b/tests/test_wafv2/test_wafv2_tags.py
@@ -23,7 +23,7 @@ def test_list_tags_for_resource():
         Name="test",
         Scope="CLOUDFRONT",
         DefaultAction={"Allow": {}},
-        Tags=[{"Key": "k1", "Value": "v1"}],
+        Tags=[{"Key": f"k{idx}", "Value": "v1"} for idx in range(10)],
         VisibilityConfig={
             "SampledRequestsEnabled": False,
             "CloudWatchMetricsEnabled": False,
@@ -32,7 +32,18 @@ def test_list_tags_for_resource():
     )["Summary"]["ARN"]
 
     tag_info = conn.list_tags_for_resource(ResourceARN=arn)["TagInfoForResource"]
-    assert tag_info["TagList"] == [{"Key": "k1", "Value": "v1"}]
+    assert len(tag_info["TagList"]) == 10
+
+    page1 = conn.list_tags_for_resource(ResourceARN=arn, Limit=2)
+    assert len(page1["TagInfoForResource"]["TagList"]) == 2
+
+    page2 = conn.list_tags_for_resource(
+        ResourceARN=arn, Limit=3, NextMarker=page1["NextMarker"]
+    )
+    assert len(page2["TagInfoForResource"]["TagList"]) == 3
+
+    page3 = conn.list_tags_for_resource(ResourceARN=arn, NextMarker=page2["NextMarker"])
+    assert len(page3["TagInfoForResource"]["TagList"]) == 5
 
 
 @mock_aws