Repository: getmoto/moto
Pull request #8980 (2025-06-14 11:45:01)

DynamoDB: Verify KEYS_ONLY projection type against indexes
See #8916 where this behaviour was questioned. Both tests pass against AWS (as-is), indicating that Moto does behave as it should.


Patch:
diff --git a/AUTHORS.md b/AUTHORS.md
index 88ec8b5af..f4c9dfcd1 100644
--- a/AUTHORS.md
+++ b/AUTHORS.md
@@ -64,4 +64,5 @@ Moto is written by Steve Pulec with contributions from:
 * [Bryan Chen](https://github.com/bchen1116)
 * [Jonas Bulik](https://github.com/MrGreenTea)
 * [Rita Cheung](https://github.com/rita-cheung)
-* [Damon Williams](https://github.com/footballencarta)
\ No newline at end of file
+* [David Acevedo](https://github.com/dacevedo12)
+* [Damon Williams](https://github.com/footballencarta)
diff --git a/moto/core/responses.py b/moto/core/responses.py
index 7bf6c8495..be15dab19 100644
--- a/moto/core/responses.py
+++ b/moto/core/responses.py
@@ -104,6 +104,9 @@ def _get_method_urls(service_name: str, region: str) -> Dict[str, Dict[str, str]
             elif request_uri.endswith("/functions/"):
                 # AWS JS SDK behaves differently from other SDK's, does not send a trailing slash
                 request_uri += "?"
+        if service_name == "opensearch" and request_uri.endswith("/tags/"):
+            # AWS GO SDK behaves differently from other SDK's, does not send a trailing slash
+            request_uri += "?"
         uri_regexp = BaseResponse.uri_to_regexp(request_uri)
         method_urls[_method][uri_regexp] = op_model.name
 
diff --git a/moto/kms/models.py b/moto/kms/models.py
index 61a95990c..24a487ac2 100644
--- a/moto/kms/models.py
+++ b/moto/kms/models.py
@@ -9,7 +9,7 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel, CloudFormationModel
 from moto.core.exceptions import JsonRESTError
-from moto.core.utils import unix_time
+from moto.core.utils import unix_time, utcnow
 from moto.moto_api._internal import mock_random
 from moto.utilities.paginator import paginate
 from moto.utilities.tagging_service import TaggingService
@@ -501,7 +501,7 @@ class KmsBackend(BaseBackend):
         if 7 <= pending_window_in_days <= 30:
             self.keys[key_id].enabled = False
             self.keys[key_id].key_state = "PendingDeletion"
-            self.keys[key_id].deletion_date = datetime.now() + timedelta(
+            self.keys[key_id].deletion_date = utcnow() + timedelta(
                 days=pending_window_in_days
             )
             return unix_time(self.keys[key_id].deletion_date)
@@ -749,7 +749,7 @@ class KmsBackend(BaseBackend):
 
         rotation = {
             "KeyId": key_id,
-            "RotationDate": datetime.now().timestamp(),
+            "RotationDate": unix_time(),
             "RotationType": "ON_DEMAND",
         }
 
diff --git a/moto/opensearch/models.py b/moto/opensearch/models.py
index 4927dcbb5..cec5ce8a7 100644
--- a/moto/opensearch/models.py
+++ b/moto/opensearch/models.py
@@ -150,7 +150,6 @@ class OpenSearchDomain(BaseModel):
         return {
             "Endpoint": self.endpoint,
             "Endpoints": self.endpoints,
-            "EngineVersion": self.engine_version.to_dict(),
             "ClusterConfig": self.cluster_config,
             "EBSOptions": self.ebs_options,
             "AccessPolicies": self.access_policies,
@@ -177,6 +176,7 @@ class OpenSearchDomain(BaseModel):
             "ARN": self.arn,
             "Created": True,
             "Deleted": self.deleted,
+            "EngineVersion": self.engine_version.options,
             "Processing": self.processing,
             "UpgradeProcessing": False,
         }
@@ -186,7 +186,9 @@ class OpenSearchDomain(BaseModel):
         return dct
 
     def to_config_dict(self) -> Dict[str, Any]:
-        dct: Dict[str, Any] = dict()
+        dct = {
+            "EngineVersion": self.engine_version.to_dict(),
+        }
         for key, value in self.dct_options().items():
             if value is not None:
                 dct[key] = {"Options": value}
@@ -303,8 +305,10 @@ class OpenSearchServiceBackend(BaseBackend):
             self.add_tags(domain.arn, tag_list)
         return domain
 
-    def get_compatible_versions(self, domain_name: str) -> List[Dict[str, Any]]:
-        if domain_name not in self.domains:
+    def get_compatible_versions(
+        self, domain_name: Optional[str]
+    ) -> List[Dict[str, Any]]:
+        if domain_name and domain_name not in self.domains:
             raise ResourceNotFoundException(domain_name)
         return compatible_versions
 
diff --git a/moto/opensearch/responses.py b/moto/opensearch/responses.py
index 3c3828e3c..228b18376 100644
--- a/moto/opensearch/responses.py
+++ b/moto/opensearch/responses.py
@@ -137,7 +137,7 @@ class OpenSearchServiceResponse(BaseResponse):
         return json.dumps(dict(DomainStatus=domain.to_dict()))
 
     def describe_domain_config(self) -> str:
-        domain_name = self.path.split("/")[-1]
+        domain_name = self.path.split("/")[-2]
         domain = self.opensearch_backend.describe_domain_config(
             domain_name=domain_name,
         )


Test patch:
diff --git a/tests/test_dynamodb/__init__.py b/tests/test_dynamodb/__init__.py
index c1d4c585a..38fe0f6e1 100644
--- a/tests/test_dynamodb/__init__.py
+++ b/tests/test_dynamodb/__init__.py
@@ -13,7 +13,9 @@ def dynamodb_aws_verified(
     numeric_range: bool = False,
     add_gsi: bool = False,
     add_gsi_range: bool = False,
+    gsi_projection_type: str = "ALL",
     add_lsi: bool = False,
+    lsi_projection_type: str = "ALL",
     numeric_gsi_range: bool = False,
     numeric_lsi_range: bool = False,
 ):
@@ -64,7 +66,7 @@ def dynamodb_aws_verified(
                             "KeySchema": [
                                 {"AttributeName": "gsi_pk", "KeyType": "HASH"},
                             ],
-                            "Projection": {"ProjectionType": "ALL"},
+                            "Projection": {"ProjectionType": gsi_projection_type},
                             "ProvisionedThroughput": {
                                 "ReadCapacityUnits": 1,
                                 "WriteCapacityUnits": 1,
@@ -82,7 +84,7 @@ def dynamodb_aws_verified(
                                 {"AttributeName": "gsi_pk", "KeyType": "HASH"},
                                 {"AttributeName": "gsi_sk", "KeyType": "RANGE"},
                             ],
-                            "Projection": {"ProjectionType": "ALL"},
+                            "Projection": {"ProjectionType": gsi_projection_type},
                             "ProvisionedThroughput": {
                                 "ReadCapacityUnits": 1,
                                 "WriteCapacityUnits": 1,
@@ -103,7 +105,7 @@ def dynamodb_aws_verified(
                                 {"AttributeName": "pk", "KeyType": "HASH"},
                                 {"AttributeName": "lsi_sk", "KeyType": "RANGE"},
                             ],
-                            "Projection": {"ProjectionType": "ALL"},
+                            "Projection": {"ProjectionType": lsi_projection_type},
                         }
                     ]
                     db_kwargs["AttributeDefinitions"].append(
diff --git a/tests/test_dynamodb/test_dynamodb.py b/tests/test_dynamodb/test_dynamodb.py
index ea0b180f1..674c3a55b 100644
--- a/tests/test_dynamodb/test_dynamodb.py
+++ b/tests/test_dynamodb/test_dynamodb.py
@@ -3549,50 +3549,30 @@ def test_update_item_add_to_non_existent_number_set():
     assert updated_item["s_i"]["NS"] == ["3"]
 
 
-@mock_aws
-def test_gsi_projection_type_keys_only():
-    table_schema = {
-        "KeySchema": [{"AttributeName": "partitionKey", "KeyType": "HASH"}],
-        "GlobalSecondaryIndexes": [
-            {
-                "IndexName": "GSI-K1",
-                "KeySchema": [
-                    {"AttributeName": "gsiK1PartitionKey", "KeyType": "HASH"},
-                    {"AttributeName": "gsiK1SortKey", "KeyType": "RANGE"},
-                ],
-                "Projection": {"ProjectionType": "KEYS_ONLY"},
-            }
-        ],
-        "AttributeDefinitions": [
-            {"AttributeName": "partitionKey", "AttributeType": "S"},
-            {"AttributeName": "gsiK1PartitionKey", "AttributeType": "S"},
-            {"AttributeName": "gsiK1SortKey", "AttributeType": "S"},
-        ],
-    }
-
+@pytest.mark.aws_verified
+@dynamodb_aws_verified(add_gsi_range=True, gsi_projection_type="KEYS_ONLY")
+def test_gsi_projection_type_keys_only(table_name=None):
     item = {
-        "partitionKey": "pk-1",
-        "gsiK1PartitionKey": "gsi-pk",
-        "gsiK1SortKey": "gsi-sk",
+        "pk": "pk-1",
+        "gsi_pk": "gsi-pk",
+        "gsi_sk": "gsi-sk",
         "someAttribute": "lore ipsum",
     }
 
     dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
-    dynamodb.create_table(
-        TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
-    )
-    table = dynamodb.Table("test-table")
+
+    table = dynamodb.Table(table_name)
     table.put_item(Item=item)
 
     items = table.query(
-        KeyConditionExpression=Key("gsiK1PartitionKey").eq("gsi-pk"), IndexName="GSI-K1"
+        KeyConditionExpression=Key("gsi_pk").eq("gsi-pk"), IndexName="test_gsi"
     )["Items"]
     assert len(items) == 1
     # Item should only include GSI Keys and Table Keys, as per the ProjectionType
     assert items[0] == {
-        "gsiK1PartitionKey": "gsi-pk",
-        "gsiK1SortKey": "gsi-sk",
-        "partitionKey": "pk-1",
+        "gsi_pk": "gsi-pk",
+        "gsi_sk": "gsi-sk",
+        "pk": "pk-1",
     }
 
 
@@ -3658,58 +3638,32 @@ def test_gsi_projection_type_include():
     }
 
 
-@mock_aws
-def test_lsi_projection_type_keys_only():
-    table_schema = {
-        "KeySchema": [
-            {"AttributeName": "partitionKey", "KeyType": "HASH"},
-            {"AttributeName": "sortKey", "KeyType": "RANGE"},
-        ],
-        "LocalSecondaryIndexes": [
-            {
-                "IndexName": "LSI",
-                "KeySchema": [
-                    {"AttributeName": "partitionKey", "KeyType": "HASH"},
-                    {"AttributeName": "lsiK1SortKey", "KeyType": "RANGE"},
-                ],
-                "Projection": {"ProjectionType": "KEYS_ONLY"},
-            }
-        ],
-        "AttributeDefinitions": [
-            {"AttributeName": "partitionKey", "AttributeType": "S"},
-            {"AttributeName": "sortKey", "AttributeType": "S"},
-            {"AttributeName": "lsiK1SortKey", "AttributeType": "S"},
-        ],
-    }
-
+@pytest.mark.aws_verified
+@dynamodb_aws_verified(add_range=True, add_lsi=True, lsi_projection_type="KEYS_ONLY")
+def test_lsi_projection_type_keys_only(table_name=None):
     item = {
-        "partitionKey": "pk-1",
-        "sortKey": "sk-1",
-        "lsiK1SortKey": "lsi-sk",
+        "pk": "pk-1",
+        "sk": "sk-1",
+        "lsi_sk": "lsi-sk",
         "someAttribute": "lore ipsum",
     }
 
     dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
-    dynamodb.create_table(
-        TableName="test-table", BillingMode="PAY_PER_REQUEST", **table_schema
-    )
-    table = dynamodb.Table("test-table")
+    table = dynamodb.Table(table_name)
     table.put_item(Item=item)
 
     items = table.query(
-        KeyConditionExpression=Key("partitionKey").eq("pk-1"), IndexName="LSI"
+        KeyConditionExpression=Key("pk").eq("pk-1"), IndexName="test_lsi"
     )["Items"]
     # Item should only include GSI Keys and Table Keys, as per the ProjectionType
-    assert items == [
-        {"partitionKey": "pk-1", "sortKey": "sk-1", "lsiK1SortKey": "lsi-sk"}
-    ]
+    assert items == [{"pk": "pk-1", "sk": "sk-1", "lsi_sk": "lsi-sk"}]
 
     # Same when scanning the table
-    items = table.scan(IndexName="LSI")["Items"]
+    items = table.scan(IndexName="test_lsi")["Items"]
     assert items[0] == {
-        "lsiK1SortKey": "lsi-sk",
-        "partitionKey": "pk-1",
-        "sortKey": "sk-1",
+        "lsi_sk": "lsi-sk",
+        "pk": "pk-1",
+        "sk": "sk-1",
     }
 
 
diff --git a/tests/test_opensearch/test_opensearch.py b/tests/test_opensearch/test_opensearch.py
index 7dd80864a..878acabcc 100644
--- a/tests/test_opensearch/test_opensearch.py
+++ b/tests/test_opensearch/test_opensearch.py
@@ -1,6 +1,9 @@
+from datetime import datetime
+
 import boto3
 import pytest
 from botocore.exceptions import ClientError
+from dateutil.tz import tzlocal
 from freezegun import freeze_time
 
 from moto import mock_aws, settings
@@ -48,7 +51,7 @@ def test_create_domain_with_some_options():
         EngineVersion="OpenSearch_1.1",
     )["DomainStatus"]
     assert status["Created"]
-    assert status["EngineVersion"]["Options"] == "OpenSearch_1.1"
+    assert status["EngineVersion"] == "OpenSearch_1.1"
     assert status["DomainEndpointOptions"] == {
         "EnforceHTTPS": True,
         "TLSSecurityPolicy": "Policy-Min-TLS-1-0-2019-07",
@@ -63,6 +66,8 @@ def test_get_compatible_versions():
     client = boto3.client("opensearch", region_name="us-east-2")
     client.create_domain(DomainName="testdn")
 
+    versions = client.get_compatible_versions()["CompatibleVersions"]
+    assert len(versions) == 22
     versions = client.get_compatible_versions(DomainName="testdn")["CompatibleVersions"]
     assert len(versions) == 22
 
@@ -90,7 +95,6 @@ def test_describe_unknown_domain():
 
 
 @mock_aws
-@freeze_time("2015-01-01 12:00:00")
 def test_describe_domain():
     # Setup
     client = boto3.client("opensearch", region_name="eu-west-1")
@@ -98,19 +102,33 @@ def test_describe_domain():
 
     # Execute
     status = client.describe_domain(DomainName="testdn")["DomainStatus"]
-    ev_status = status["EngineVersion"]["Status"]
 
     # Verify
     assert "DomainId" in status
     assert "DomainName" in status
     assert status["DomainName"] == "testdn"
-    assert status["EngineVersion"]["Options"] == "OpenSearch_2.5"
+    assert status["EngineVersion"] == "OpenSearch_2.5"
+
+
+@mock_aws
+@freeze_time("2015-01-01 12:00:00")
+def test_describe_domain_config():
+    # Setup
+    client = boto3.client("opensearch", region_name="eu-west-1")
+    client.create_domain(DomainName="testdn")
+
+    # Execute
+    config = client.describe_domain_config(DomainName="testdn")["DomainConfig"]
+    ev_status = config["EngineVersion"]["Status"]
+
+    # Verify
+    assert config["EngineVersion"]["Options"] == "OpenSearch_2.5"
     assert ev_status["State"] == "Active"
     assert ev_status["PendingDeletion"] is False
 
     if not settings.TEST_SERVER_MODE:
-        assert ev_status["CreationDate"] == 1420113600.0
-        assert ev_status["UpdateDate"] == 1420113600.0
+        assert ev_status["CreationDate"] == datetime(2015, 1, 1, 12, tzinfo=tzlocal())
+        assert ev_status["UpdateDate"] == datetime(2015, 1, 1, 12, tzinfo=tzlocal())
 
 
 @mock_aws