diff --git a/aws_lambda_powertools/utilities/idempotency/persistence/base.py b/aws_lambda_powertools/utilities/idempotency/persistence/base.py index 2271520139d..739ba95dc23 100644 --- a/aws_lambda_powertools/utilities/idempotency/persistence/base.py +++ b/aws_lambda_powertools/utilities/idempotency/persistence/base.py @@ -157,7 +157,10 @@ def _get_hashed_payload(self, data: dict[str, Any]) -> str: """ if not self.payload_validation_enabled: return "" - data = self.validation_key_jmespath.search(data) + data = self.validation_key_jmespath.search( + data, + options=jmespath.Options(**(self.jmespath_options or {})), + ) return self._generate_hash(data=data) def _generate_hash(self, data: Any) -> str: diff --git a/tests/functional/idempotency/_boto3/test_idempotency.py b/tests/functional/idempotency/_boto3/test_idempotency.py index 56b317a9b5a..a4fb8db9132 100644 --- a/tests/functional/idempotency/_boto3/test_idempotency.py +++ b/tests/functional/idempotency/_boto3/test_idempotency.py @@ -9,6 +9,7 @@ import pytest from botocore import stub from botocore.config import Config +from jmespath import functions from pytest import FixtureRequest from pytest_mock import MockerFixture @@ -1177,6 +1178,28 @@ def test_custom_jmespath_function_overrides_builtin_functions( persistence_store._get_hashed_idempotency_key({}) +def test_payload_validation_jmespath_with_custom_jmespath_options(persistence_store: DynamoDBPersistenceLayer): + # GIVEN custom jmespath_options + # AND a payload_validation_jmespath using one of its custom functions + class CustomFunctions(functions.Functions): + @functions.signature({"types": ["string"]}) + def _func_to_upper(self, value): + return value.upper() + + idempotency_config = IdempotencyConfig( + event_key_jmespath="order_id", + payload_validation_jmespath="to_upper(currency)", + jmespath_options={"custom_functions": CustomFunctions()}, + ) + persistence_store.configure(idempotency_config) + + # WHEN calling _get_hashed_payload + result = persistence_store._get_hashed_payload({"order_id": "1", "currency": "eur"}) + + # THEN the hashed payload should match the custom function result generated hash + assert result == persistence_store._generate_hash("EUR") + + def test_idempotent_lambda_save_inprogress_error(persistence_store: DynamoDBPersistenceLayer, lambda_context): # GIVEN a miss configured persistence layer # like no table was created for the idempotency persistence layer @@ -1933,6 +1956,112 @@ def lambda_handler(event, context): assert cache_spy.call_count == 0 +def test_idempotency_payload_validation_with_powertools_json( + persistence_store: DynamoDBPersistenceLayer, + timestamp_future, + lambda_context, + request: FixtureRequest, +): + # GIVEN an idempotency config where both the idempotency key and the payload validation key + # read a JSON string body with the powertools_json built-in function + idempotency_config = IdempotencyConfig( + event_key_jmespath="powertools_json(body).order_id", + payload_validation_jmespath="powertools_json(body).amount", + use_local_cache=False, + ) + + # AND a previous order already processed in the persistent store + order = {"order_id": "ffd11882-d476-4598-bbf1-643f2be5addf", "amount": 100} + + stubber = stub.Stubber(persistence_store.client) + ddb_response = build_idempotency_put_item_response_stub( + data=order, + expiration=timestamp_future, + status="COMPLETED", + request=request, + validation_data=order["amount"], + ) + + stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response) + stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response) + stubber.activate() + + @idempotent(config=idempotency_config, persistence_store=persistence_store) + def lambda_handler(event, context): + return event + + # WHEN the same order is sent again + # THEN we should return the stored response + assert lambda_handler({"body": json_serialize(order)}, lambda_context) == order + + # WHEN the same order is sent again with a tampered amount + # THEN we should raise + tampered_order = {**order, "amount": 1} + with pytest.raises(IdempotencyValidationError): + lambda_handler({"body": json_serialize(tampered_order)}, lambda_context) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + +@pytest.mark.parametrize( + "stored_validation_value,should_match", + [ + pytest.param(2, False, id="record-created-with-native-function"), + pytest.param(3, True, id="record-created-with-custom-function"), + ], +) +def test_idempotency_payload_validation_with_overridden_builtin_function( + persistence_store: DynamoDBPersistenceLayer, + timestamp_future, + lambda_context, + stored_validation_value, + should_match, +): + # GIVEN a custom function that overrides the native JMESPath length function + class CustomFunctions(functions.Functions): + @functions.signature({"types": ["array"]}) + def _func_length(self, value): + return len(value) + 1 + + idempotency_config = IdempotencyConfig( + event_key_jmespath="order_id", + payload_validation_jmespath="length(items)", + jmespath_options={"custom_functions": CustomFunctions()}, + use_local_cache=False, + ) + order = {"order_id": "order-1", "items": ["a", "b"]} + stored_response = {"status": "completed"} + + # AND a completed record whose validation hash was created before or after options were honored + ddb_response = { + "Item": { + "id": {"S": f"orders#{hash_idempotency_key(order['order_id'])}"}, + "expiration": {"N": timestamp_future}, + "status": {"S": "COMPLETED"}, + "data": {"S": json_serialize(stored_response)}, + "validation": {"S": hash_idempotency_key(stored_validation_value)}, + }, + } + + @idempotent(config=idempotency_config, persistence_store=persistence_store, key_prefix="orders") + def lambda_handler(event, context): + pytest.fail("A completed request must not execute the handler again") + + with stub.Stubber(persistence_store.client) as stubber: + stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response) + + # WHEN the identical order is replayed, THEN honor the configured override + if should_match: + assert lambda_handler(order, lambda_context) == stored_response + else: + # Records created with the native function have a different hash even for unchanged input. + with pytest.raises(IdempotencyValidationError, match="Payload does not match stored record"): + lambda_handler(order, lambda_context) + + stubber.assert_no_pending_responses() + + @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_responsehook_lambda_first_execution( idempotency_config: IdempotencyConfig,