Repository: getmoto/moto (python)

Issue:
Limit shouldn't be applied before Filter when listing users
Using the current version of moto, if you run cognito-idp's list_users() with both Limit and Filter keywords, the limit is applied before the filter. So, if you are searching for a user with a particular sub that isn't the first item in the user pool, and you set Limit to 1, then it will always fail to find it because in `moto/cognito-idp/responses.py`, it calls the backend for the unfiltered list of users limited by the Limit keyword, and then filters that.

Pull request:
CognitoIDP: Fix Limit and Filter params for list_users()
Fixes #8363

* move `filt` to the backend list_users logic so that the paginator decorator can pick up the right filter users
* Also added the test case for this specific issue

Patch:
diff --git a/moto/athena/responses.py b/moto/athena/responses.py
index 23b074160..727711167 100644
--- a/moto/athena/responses.py
+++ b/moto/athena/responses.py
@@ -89,7 +89,7 @@ class AthenaResponse(BaseResponse):
                     "QueryPlanningTimeInMillis": 0,
                     "ServiceProcessingTimeInMillis": 0,
                 },
-                "WorkGroup": execution.workgroup,
+                "WorkGroup": execution.workgroup.name if execution.workgroup else None,
             }
         }
         if execution.execution_parameters is not None:
diff --git a/moto/cognitoidp/models.py b/moto/cognitoidp/models.py
index dd750542c..4439041a7 100644
--- a/moto/cognitoidp/models.py
+++ b/moto/cognitoidp/models.py
@@ -1354,10 +1354,54 @@ class CognitoIdpBackend(BaseBackend):
         raise NotAuthorizedError("Invalid token")
 
     @paginate(pagination_model=PAGINATION_MODEL)
-    def list_users(self, user_pool_id: str) -> List[CognitoIdpUser]:
+    def list_users(self, user_pool_id: str, filt: str) -> List[CognitoIdpUser]:
         user_pool = self.describe_user_pool(user_pool_id)
+        users = list(user_pool.users.values())
+        if filt:
+            inherent_attributes: Dict[str, Any] = {
+                "cognito:user_status": lambda u: u.status,
+                "status": lambda u: "Enabled" if u.enabled else "Disabled",
+                "username": lambda u: u.username,
+            }
+            comparisons: Dict[str, Any] = {
+                "=": lambda x, y: x == y,
+                "^=": lambda x, y: x.startswith(y),
+            }
+            allowed_attributes = [
+                "username",
+                "email",
+                "phone_number",
+                "name",
+                "given_name",
+                "family_name",
+                "preferred_username",
+                "cognito:user_status",
+                "status",
+                "sub",
+            ]
 
-        return list(user_pool.users.values())
+            match = re.match(r"([\w:]+)\s*(=|\^=)\s*\"(.*)\"", filt)
+            if match:
+                name, op, value = match.groups()
+            else:
+                raise InvalidParameterException("Error while parsing filter")
+            if name not in allowed_attributes:
+                raise InvalidParameterException(f"Invalid search attribute: {name}")
+            compare = comparisons[op]
+            users = [
+                user
+                for user in users
+                if [
+                    attr
+                    for attr in user.attributes
+                    if attr["Name"] == name and compare(attr["Value"], value)
+                ]
+                or (
+                    name in inherent_attributes
+                    and compare(inherent_attributes[name](user), value)
+                )
+            ]
+        return users
 
     def admin_disable_user(self, user_pool_id: str, username: str) -> None:
         user = self.admin_get_user(user_pool_id, username)
diff --git a/moto/cognitoidp/responses.py b/moto/cognitoidp/responses.py
index 33245817a..afdee5081 100644
--- a/moto/cognitoidp/responses.py
+++ b/moto/cognitoidp/responses.py
@@ -1,5 +1,4 @@
 import json
-import re
 from typing import Any, Dict
 
 from moto.core.responses import TYPE_RESPONSE, BaseResponse
@@ -371,52 +370,8 @@ class CognitoIdpResponse(BaseResponse):
         filt = self._get_param("Filter")
         attributes_to_get = self._get_param("AttributesToGet")
         users, token = self.backend.list_users(
-            user_pool_id, limit=limit, pagination_token=token
-        )
-        if filt:
-            inherent_attributes: Dict[str, Any] = {
-                "cognito:user_status": lambda u: u.status,
-                "status": lambda u: "Enabled" if u.enabled else "Disabled",
-                "username": lambda u: u.username,
-            }
-            comparisons: Dict[str, Any] = {
-                "=": lambda x, y: x == y,
-                "^=": lambda x, y: x.startswith(y),
-            }
-            allowed_attributes = [
-                "username",
-                "email",
-                "phone_number",
-                "name",
-                "given_name",
-                "family_name",
-                "preferred_username",
-                "cognito:user_status",
-                "status",
-                "sub",
-            ]
-
-            match = re.match(r"([\w:]+)\s*(=|\^=)\s*\"(.*)\"", filt)
-            if match:
-                name, op, value = match.groups()
-            else:
-                raise InvalidParameterException("Error while parsing filter")
-            if name not in allowed_attributes:
-                raise InvalidParameterException(f"Invalid search attribute: {name}")
-            compare = comparisons[op]
-            users = [
-                user
-                for user in users
-                if [
-                    attr
-                    for attr in user.attributes
-                    if attr["Name"] == name and compare(attr["Value"], value)
-                ]
-                or (
-                    name in inherent_attributes
-                    and compare(inherent_attributes[name](user), value)
-                )
-            ]
+            user_pool_id, filt, limit=limit, pagination_token=token
+        )
         response: Dict[str, Any] = {
             "Users": [
                 user.to_json(extended=True, attributes_to_get=attributes_to_get)
diff --git a/moto/dynamodb/models/__init__.py b/moto/dynamodb/models/__init__.py
index d3d1a71e8..74e76f1fa 100644
--- a/moto/dynamodb/models/__init__.py
+++ b/moto/dynamodb/models/__init__.py
@@ -87,7 +87,7 @@ class DynamoDBBackend(BaseBackend):
         billing_mode: str,
         sse_specification: Optional[Dict[str, Any]],
         tags: List[Dict[str, str]],
-        deletion_protection_enabled: bool,
+        deletion_protection_enabled: Optional[bool],
     ) -> Table:
         if name in self.tables:
             raise ResourceInUseException(f"Table already exists: {name}")
@@ -182,7 +182,7 @@ class DynamoDBBackend(BaseBackend):
         throughput: Dict[str, Any],
         billing_mode: str,
         stream_spec: Dict[str, Any],
-        deletion_protection_enabled: bool,
+        deletion_protection_enabled: Optional[bool],
     ) -> Table:
         table = self.get_table(name)
         if attr_definitions:
@@ -195,7 +195,7 @@ class DynamoDBBackend(BaseBackend):
             table.billing_mode = billing_mode
         if stream_spec:
             self.update_table_streams(table, stream_spec)
-        if deletion_protection_enabled:
+        if deletion_protection_enabled in {True, False}:
             table.deletion_protection_enabled = deletion_protection_enabled
         return table
 
diff --git a/moto/es/models.py b/moto/es/models.py
index 6c7631752..4cc65d6fb 100644
--- a/moto/es/models.py
+++ b/moto/es/models.py
@@ -142,5 +142,14 @@ class ElasticsearchServiceBackend(BaseBackend):
         """
         return [{"DomainName": domain.domain_name} for domain in self.domains.values()]
 
+    def describe_elasticsearch_domains(
+        self, domain_names: List[str]
+    ) -> List[Dict[str, Any]]:
+        queried_domains = []
+        for domain_name in domain_names:
+            if domain_name in self.domains:
+                queried_domains.append(self.domains[domain_name].to_json())
+        return queried_domains
+
 
 es_backends = BackendDict(ElasticsearchServiceBackend, "es")
diff --git a/moto/es/responses.py b/moto/es/responses.py
index d8e7ac813..35293b33d 100644
--- a/moto/es/responses.py
+++ b/moto/es/responses.py
@@ -13,7 +13,7 @@ class ElasticsearchServiceResponse(BaseResponse):
     """Handler for ElasticsearchService requests and responses."""
 
     def __init__(self) -> None:
-        super().__init__(service_name="elasticsearch")
+        super().__init__(service_name="es")
 
     @property
     def es_backend(self) -> ElasticsearchServiceBackend:
@@ -98,3 +98,10 @@ class ElasticsearchServiceResponse(BaseResponse):
     def list_domain_names(self) -> TYPE_RESPONSE:
         domain_names = self.es_backend.list_domain_names()
         return 200, {}, json.dumps({"DomainNames": domain_names})
+
+    def describe_elasticsearch_domains(self) -> TYPE_RESPONSE:
+        domain_names = self._get_param("DomainNames")
+        domain_list = self.es_backend.describe_elasticsearch_domains(
+            domain_names=domain_names,
+        )
+        return 200, {}, json.dumps({"DomainStatusList": domain_list})
diff --git a/moto/es/urls.py b/moto/es/urls.py
index d125e9a09..fbee7fcb7 100644
--- a/moto/es/urls.py
+++ b/moto/es/urls.py
@@ -11,11 +11,13 @@ url_paths = {
     "{0}/2015-01-01/domain$": ElasticsearchServiceResponse.list_domains,
     "{0}/2015-01-01/es/domain$": ElasticsearchServiceResponse.domains,
     "{0}/2015-01-01/es/domain/(?P<domainname>[^/]+)": ElasticsearchServiceResponse.domain,
+    "{0}/2015-01-01/es/domain-info$": ElasticsearchServiceResponse.dispatch,
     "{0}/2021-01-01/domain$": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/opensearch/compatibleVersions": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/opensearch/domain": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/opensearch/domain/(?P<domainname>[^/]+)": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/opensearch/domain/(?P<domainname>[^/]+)/config": OpenSearchServiceResponse.dispatch,
+    "{0}/2021-01-01/opensearch/domain-info$": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/tags/?": OpenSearchServiceResponse.dispatch,
     "{0}/2021-01-01/tags-removal/": OpenSearchServiceResponse.dispatch,
 }
diff --git a/moto/opensearch/models.py b/moto/opensearch/models.py
index 31c91821d..2e7242983 100644
--- a/moto/opensearch/models.py
+++ b/moto/opensearch/models.py
@@ -375,5 +375,12 @@ class OpenSearchServiceBackend(BaseBackend):
                 )
         return domains
 
+    def describe_domains(self, domain_names: List[str]) -> List[OpenSearchDomain]:
+        queried_domains = []
+        for domain_name in domain_names:
+            if domain_name in self.domains:
+                queried_domains.append(self.domains[domain_name])
+        return queried_domains
+
 
 opensearch_backends = BackendDict(OpenSearchServiceBackend, "opensearch")
diff --git a/moto/opensearch/responses.py b/moto/opensearch/responses.py
index e8f102e14..4034a16b2 100644
--- a/moto/opensearch/responses.py
+++ b/moto/opensearch/responses.py
@@ -147,3 +147,11 @@ class OpenSearchServiceResponse(BaseResponse):
             engine_type=engine_type,
         )
         return json.dumps(dict(DomainNames=domain_names))
+
+    def describe_domains(self) -> str:
+        domain_names = self._get_param("DomainNames")
+        domains = self.opensearch_backend.describe_domains(
+            domain_names=domain_names,
+        )
+        domain_list = [domain.to_dict() for domain in domains]
+        return json.dumps({"DomainStatusList": domain_list})
diff --git a/moto/opensearch/urls.py b/moto/opensearch/urls.py
index 0d7d1a4cf..c62d8c04c 100644
--- a/moto/opensearch/urls.py
+++ b/moto/opensearch/urls.py
@@ -8,4 +8,6 @@ url_bases = [r"https?://es\.(.+)\.amazonaws\.com"]
 response = OpenSearchServiceResponse()
 
 
-url_paths = {"{0}/.*$": response.dispatch}
+url_paths = {
+    "{0}/.*$": response.dispatch,
+}


Test patch:
diff --git a/tests/test_athena/test_athena.py b/tests/test_athena/test_athena.py
index e539c42b1..1e75ebc86 100644
--- a/tests/test_athena/test_athena.py
+++ b/tests/test_athena/test_athena.py
@@ -237,6 +237,29 @@ def test_stop_query_execution():
     assert details["Status"]["State"] == "CANCELLED"
 
 
+@mock_aws
+def test_start_execution_with_workgroup():
+    client = boto3.client("athena", region_name="us-east-1")
+
+    client.create_work_group(
+        Name="myworkgroup",
+        Description="Test work group",
+        Configuration={
+            "ResultConfiguration": {"OutputLocation": "s3://bucket-name/prefix/"}
+        },
+    )
+
+    exec_id = client.start_query_execution(
+        QueryString="SELECT stuff",
+        QueryExecutionContext={"Database": "database"},
+        ResultConfiguration={"OutputLocation": "s3://bucket-name/prefix/"},
+        WorkGroup="myworkgroup",
+    )["QueryExecutionId"]
+
+    execution = client.get_query_execution(QueryExecutionId=exec_id)["QueryExecution"]
+    assert execution["WorkGroup"] == "myworkgroup"
+
+
 @mock_aws
 def test_create_named_query():
     client = boto3.client("athena", region_name="us-east-1")
diff --git a/tests/test_cognitoidp/test_cognitoidp.py b/tests/test_cognitoidp/test_cognitoidp.py
index 0e5077a78..fdf00253b 100644
--- a/tests/test_cognitoidp/test_cognitoidp.py
+++ b/tests/test_cognitoidp/test_cognitoidp.py
@@ -2519,6 +2519,28 @@ def test_list_users():
     result = conn.list_users(UserPoolId=user_pool_id, Filter='family_name=""')
     assert len(result["Users"]) == 0
 
+    # checking Limit and Filter work correctly together
+    user1_username = "user1@example.com"
+    conn.admin_create_user(
+        UserPoolId=user_pool_id,
+        Username=user1_username,
+        UserAttributes=[{"Name": "phone_number", "Value": "+48555555555"}],
+    )
+
+    result = conn.list_users(
+        UserPoolId=user_pool_id, Filter='phone_number ^= "+48"', Limit=1
+    )
+    assert len(result["Users"]) == 1
+    assert result["PaginationToken"] is not None
+
+    result = conn.list_users(
+        UserPoolId=user_pool_id,
+        Filter='phone_number ^= "+48"',
+        Limit=1,
+        PaginationToken=result["PaginationToken"],
+    )
+    assert len(result["Users"]) == 1
+
 
 @mock_aws
 def test_list_users_incorrect_filter():
diff --git a/tests/test_dynamodb/test_dynamodb_update_table.py b/tests/test_dynamodb/test_dynamodb_update_table.py
index a1f2ad819..de4243c34 100644
--- a/tests/test_dynamodb/test_dynamodb_update_table.py
+++ b/tests/test_dynamodb/test_dynamodb_update_table.py
@@ -70,6 +70,23 @@ def test_update_table_deletion_protection_enabled():
     assert table.deletion_protection_enabled
 
 
+@mock_aws
+def test_update_table_deletion_protection_disabled():
+    conn = boto3.resource("dynamodb", region_name="us-west-2")
+    table = conn.create_table(
+        TableName="messages",
+        KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
+        AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
+        BillingMode="PAY_PER_REQUEST",
+        DeletionProtectionEnabled=True,
+    )
+    assert table.deletion_protection_enabled
+
+    table.update(DeletionProtectionEnabled=False)
+
+    assert not table.deletion_protection_enabled
+
+
 @mock_aws
 def test_update_table__enable_stream():
     conn = boto3.client("dynamodb", region_name="us-east-1")
diff --git a/tests/test_es/test_es.py b/tests/test_es/test_es.py
index 3ee124388..259f444da 100644
--- a/tests/test_es/test_es.py
+++ b/tests/test_es/test_es.py
@@ -215,3 +215,28 @@ def test_list_domain_names_with_multiple_domains():
     assert len(resp["DomainNames"]) == 4
     for name in domain_names:
         assert {"DomainName": name} in resp["DomainNames"]
+
+
+@mock_aws
+def test_describe_elasticsearch_domains():
+    client = boto3.client("es", region_name="us-east-1")
+    domain_names = [f"env{i}" for i in range(1, 5)]
+    for name in domain_names:
+        client.create_elasticsearch_domain(
+            DomainName=name,
+            ElasticsearchVersion="7.10",
+            AdvancedOptions={"option": "value"},
+            AdvancedSecurityOptions={"Enabled": False},
+        )
+    resp = client.describe_elasticsearch_domains(DomainNames=domain_names)
+
+    assert len(resp["DomainStatusList"]) == 4
+    for domain in resp["DomainStatusList"]:
+        assert domain["DomainName"] in domain_names
+        assert domain["ElasticsearchVersion"] == "7.10"
+        assert "AdvancedSecurityOptions" in domain.keys()
+        assert "AdvancedOptions" in domain.keys()
+
+    # Test for invalid domain name
+    resp = client.describe_elasticsearch_domains(DomainNames=["invalid"])
+    assert len(resp["DomainStatusList"]) == 0
diff --git a/tests/test_opensearch/test_opensearch.py b/tests/test_opensearch/test_opensearch.py
index 1ce66fef1..22955de1b 100644
--- a/tests/test_opensearch/test_opensearch.py
+++ b/tests/test_opensearch/test_opensearch.py
@@ -217,3 +217,23 @@ def test_list_unknown_domain_names_engine_type():
     err = exc.value.response["Error"]
     assert err["Code"] == "EngineTypeNotFoundException"
     assert err["Message"] == "Engine Type not found: testdn"
+
+
+@mock_aws
+def test_describe_domains():
+    client = boto3.client("opensearch", region_name="us-east-1")
+    domain_names = [f"env{i}" for i in range(1, 5)]
+    opensearch_engine_version = "OpenSearch_1.0"
+    for name in domain_names:
+        client.create_domain(DomainName=name, EngineVersion=opensearch_engine_version)
+    resp = client.describe_domains(DomainNames=domain_names)
+
+    assert len(resp["DomainStatusList"]) == 4
+    for domain in resp["DomainStatusList"]:
+        assert domain["DomainName"] in domain_names
+        assert "AdvancedSecurityOptions" in domain.keys()
+        assert "AdvancedOptions" in domain.keys()
+
+    # Test for invalid domain name
+    resp = client.describe_domains(DomainNames=["invalid"])
+    assert len(resp["DomainStatusList"]) == 0