Repository: getmoto/moto (python)

Issue:
route53.list_hosted_zones() does not page results
When calling `route53.list_hosted_zones()`, moto returns all hosted zones regardless of the `MaxItems` count passed to it, whereas this same call directly to AWS pages the results into multiple pages of `N` items (where `MaxItems=N` and `N <= 100`.

Steps to reproduce:

- Create greater than 100 hosted zones in Route53 while mocking with moto.
- Call `r53.list_hosted_zones(MaxItems=100))`
- The length of the returned set will equal the number originally created, NOT the value of `MaxItems`.

Pull request:
Route53: list_hosted_zones() now supports pagination
Fixes #7319 

Patch:
diff --git a/moto/route53/models.py b/moto/route53/models.py
index e8f9047cd..05a08a876 100644
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -723,7 +723,11 @@ class Route53Backend(BaseBackend):
                     the_zone.delete_rrset(record_set)
             the_zone.rr_changes.append(original_change)
 
+    @paginate(pagination_model=PAGINATION_MODEL)  # type: ignore[misc]
     def list_hosted_zones(self) -> List[FakeZone]:
+        """
+        The parameters DelegationSetId and HostedZoneType are not yet implemented
+        """
         return list(self.zones.values())
 
     def list_hosted_zones_by_name(
@@ -733,7 +737,7 @@ class Route53Backend(BaseBackend):
             dnsname = dnsnames[0]
             if dnsname[-1] != ".":
                 dnsname += "."
-            zones = [zone for zone in self.list_hosted_zones() if zone.name == dnsname]
+            zones = [zone for zone in self.zones.values() if zone.name == dnsname]
         else:
             dnsname = None
             # sort by names, but with domain components reversed
@@ -745,8 +749,7 @@ class Route53Backend(BaseBackend):
                     domains = domains[-1:] + domains[:-1]
                 return ".".join(reversed(domains))
 
-            zones = self.list_hosted_zones()
-            zones = sorted(zones, key=sort_key)
+            zones = sorted(self.zones.values(), key=sort_key)
         return dnsname, zones
 
     def list_hosted_zones_by_vpc(self, vpc_id: str) -> List[Dict[str, Any]]:
@@ -754,7 +757,7 @@ class Route53Backend(BaseBackend):
         Pagination is not yet implemented
         """
         zone_list = []
-        for zone in self.list_hosted_zones():
+        for zone in self.zones.values():
             if zone.private_zone is True:
                 this_zone = self.get_hosted_zone(zone.id)
                 for vpc in this_zone.vpcs:
@@ -776,10 +779,10 @@ class Route53Backend(BaseBackend):
         return the_zone
 
     def get_hosted_zone_count(self) -> int:
-        return len(self.list_hosted_zones())
+        return len(self.zones.values())
 
     def get_hosted_zone_by_name(self, name: str) -> Optional[FakeZone]:
-        for zone in self.list_hosted_zones():
+        for zone in self.zones.values():
             if zone.name == name:
                 return zone
         return None
@@ -875,8 +878,7 @@ class Route53Backend(BaseBackend):
     ) -> QueryLoggingConfig:
         """Process the create_query_logging_config request."""
         # Does the hosted_zone_id exist?
-        response = self.list_hosted_zones()
-        zones = list(response) if response else []
+        zones = list(self.zones.values())
         for zone in zones:
             if zone.id == hosted_zone_id:
                 break
@@ -940,8 +942,7 @@ class Route53Backend(BaseBackend):
         """Return a list of query logging configs."""
         if hosted_zone_id:
             # Does the hosted_zone_id exist?
-            response = self.list_hosted_zones()
-            zones = list(response) if response else []
+            zones = list(self.zones.values())
             for zone in zones:
                 if zone.id == hosted_zone_id:
                     break
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
index 6350c71bc..c7af8deb7 100644
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -81,16 +81,27 @@ class Route53(BaseResponse):
                 vpcregion=vpcregion,
                 delegation_set_id=delegation_set_id,
             )
-            template = Template(CREATE_HOSTED_ZONE_RESPONSE)
+            template = Template(CREATE_HOSTED_ZONE_RESPONSE).render(zone=new_zone)
             headers = {
                 "Location": f"https://route53.amazonaws.com/2013-04-01/hostedzone/{new_zone.id}"
             }
-            return 201, headers, template.render(zone=new_zone)
+            return 201, headers, template
 
         elif request.method == "GET":
-            all_zones = self.backend.list_hosted_zones()
-            template = Template(LIST_HOSTED_ZONES_RESPONSE)
-            return 200, headers, template.render(zones=all_zones)
+            max_size = self.querystring.get("maxitems", [None])[0]
+            if max_size:
+                max_size = int(max_size)
+            marker = self.querystring.get("marker", [None])[0]
+            zone_page, next_marker = self.backend.list_hosted_zones(
+                marker=marker, max_size=max_size
+            )
+            template = Template(LIST_HOSTED_ZONES_RESPONSE).render(
+                zones=zone_page,
+                marker=marker,
+                next_marker=next_marker,
+                max_items=max_size,
+            )
+            return 200, headers, template
 
     def list_hosted_zones_by_name_response(
         self, request: Any, full_url: str, headers: Any
@@ -704,7 +715,10 @@ LIST_HOSTED_ZONES_RESPONSE = """<ListHostedZonesResponse xmlns="https://route53.
       </HostedZone>
       {% endfor %}
    </HostedZones>
-   <IsTruncated>false</IsTruncated>
+   {% if marker %}<Marker>{{ marker }}</Marker>{% endif %}
+   {%if next_marker %}<NextMarker>{{ next_marker }}</NextMarker>{% endif %}
+   {%if max_items %}<MaxItems>{{ max_items }}</MaxItems>{% endif %}
+   <IsTruncated>{{ 'true' if next_marker else 'false'}}</IsTruncated>
 </ListHostedZonesResponse>"""
 
 LIST_HOSTED_ZONES_BY_NAME_RESPONSE = """<ListHostedZonesByNameResponse xmlns="{{ xmlns }}">
diff --git a/moto/route53/utils.py b/moto/route53/utils.py
index 2e7ae4387..31060101a 100644
--- a/moto/route53/utils.py
+++ b/moto/route53/utils.py
@@ -2,6 +2,12 @@
 from .exceptions import InvalidPaginationToken
 
 PAGINATION_MODEL = {
+    "list_hosted_zones": {
+        "input_token": "marker",
+        "limit_key": "max_size",
+        "limit_default": 100,
+        "unique_attribute": "id",
+    },
     "list_query_logging_configs": {
         "input_token": "next_token",
         "limit_key": "max_results",


Test patch:
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
index 1ad8b90f7..2c692eb4a 100644
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -602,6 +602,44 @@ def test_list_hosted_zones_by_dns_name():
     assert zones["HostedZones"][3]["CallerReference"] == str(hash("bar"))
 
 
+@mock_aws
+def test_list_hosted_zones_pagination():
+    conn = boto3.client("route53", region_name="us-east-1")
+
+    for idx in range(150):
+        conn.create_hosted_zone(
+            Name=f"test{idx}.com.", CallerReference=str(hash(f"h{idx}"))
+        )
+
+    page1 = conn.list_hosted_zones()
+    assert "Marker" not in page1
+    assert page1["IsTruncated"] is True
+    assert "NextMarker" in page1
+    assert "MaxItems" not in page1
+    assert len(page1["HostedZones"]) == 100
+
+    page2 = conn.list_hosted_zones(Marker=page1["NextMarker"])
+    assert page2["Marker"] == page1["NextMarker"]
+    assert page2["IsTruncated"] is False
+    assert "NextMarker" not in page2
+    assert "MaxItems" not in page2
+    assert len(page2["HostedZones"]) == 50
+
+    small_page = conn.list_hosted_zones(MaxItems="75")
+    assert "Marker" not in small_page
+    assert small_page["IsTruncated"] is True
+    assert "NextMarker" in small_page
+    assert small_page["MaxItems"] == "75"
+    assert len(small_page["HostedZones"]) == 75
+
+    remainer = conn.list_hosted_zones(Marker=small_page["NextMarker"])
+    assert remainer["Marker"] == small_page["NextMarker"]
+    assert remainer["IsTruncated"] is False
+    assert "NextMarker" not in remainer
+    assert "MaxItems" not in remainer
+    assert len(remainer["HostedZones"]) == 75
+
+
 @mock_aws
 def test_change_resource_record_sets_crud_valid():
     conn = boto3.client("route53", region_name="us-east-1")