diff --git a/aws_lambda_powertools/utilities/idempotency/base.py b/aws_lambda_powertools/utilities/idempotency/base.py index dddc36b426d..9fff5415b8a 100644 --- a/aws_lambda_powertools/utilities/idempotency/base.py +++ b/aws_lambda_powertools/utilities/idempotency/base.py @@ -1,5 +1,6 @@ import logging from typing import Any, Callable, Dict, Optional, Tuple +from copy import deepcopy from aws_lambda_powertools.utilities.idempotency.config import IdempotencyConfig from aws_lambda_powertools.utilities.idempotency.exceptions import ( @@ -69,7 +70,7 @@ def __init__( Function keyword arguments """ self.function = function - self.data = _prepare_data(function_payload) + self.data = deepcopy(_prepare_data(function_payload)) self.fn_args = function_args self.fn_kwargs = function_kwargs diff --git a/tests/functional/idempotency/conftest.py b/tests/functional/idempotency/conftest.py index 017445ab348..74deecef123 100644 --- a/tests/functional/idempotency/conftest.py +++ b/tests/functional/idempotency/conftest.py @@ -14,7 +14,8 @@ from aws_lambda_powertools.utilities.idempotency.idempotency import IdempotencyConfig from aws_lambda_powertools.utilities.jmespath_utils import extract_data_from_envelope from aws_lambda_powertools.utilities.validation import envelopes -from tests.functional.utils import hash_idempotency_key, json_serialize, load_event +from tests.functional.idempotency.utils import hash_idempotency_key +from tests.functional.utils import json_serialize, load_event TABLE_NAME = "TEST_TABLE" diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 5b76cda0475..40cee10e4f7 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -22,7 +22,12 @@ from aws_lambda_powertools.utilities.idempotency.idempotency import idempotent, idempotent_function from aws_lambda_powertools.utilities.idempotency.persistence.base import BasePersistenceLayer, DataRecord from aws_lambda_powertools.utilities.validation import envelopes, validator -from tests.functional.utils import hash_idempotency_key, json_serialize, load_event +from tests.functional.idempotency.utils import ( + build_idempotency_put_item_stub, + build_idempotency_update_item_stub, + hash_idempotency_key, +) +from tests.functional.utils import json_serialize, load_event TABLE_NAME = "TEST_TABLE" @@ -275,6 +280,40 @@ def lambda_handler(event, context): stubber.deactivate() +@pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True, "event_key_jmespath": "body"}], indirect=True) +def test_idempotent_lambda_first_execution_event_mutation( + idempotency_config: IdempotencyConfig, + persistence_store: DynamoDBPersistenceLayer, + lambda_apigw_event, + lambda_response, + lambda_context, +): + """ + Test idempotent decorator where lambda_handler mutates the event. + Ensures we're passing data by value, not reference. + """ + event = copy.deepcopy(lambda_apigw_event) + stubber = stub.Stubber(persistence_store.table.meta.client) + ddb_response = {} + stubber.add_response("put_item", ddb_response, build_idempotency_put_item_stub(data=event["body"])) + stubber.add_response( + "update_item", + ddb_response, + build_idempotency_update_item_stub(data=event["body"], handler_response=lambda_response), + ) + stubber.activate() + + @idempotent(config=idempotency_config, persistence_store=persistence_store) + def lambda_handler(event, context): + event.pop("body") # remove exact key we're using for idempotency + return lambda_response + + lambda_handler(event, lambda_context) + + stubber.assert_no_pending_responses() + stubber.deactivate() + + @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": False}, {"use_local_cache": True}], indirect=True) def test_idempotent_lambda_expired( idempotency_config: IdempotencyConfig, diff --git a/tests/functional/idempotency/utils.py b/tests/functional/idempotency/utils.py new file mode 100644 index 00000000000..ca3862a2d8c --- /dev/null +++ b/tests/functional/idempotency/utils.py @@ -0,0 +1,45 @@ +import hashlib +from typing import Any, Dict + +from botocore import stub + +from tests.functional.utils import json_serialize + + +def hash_idempotency_key(data: Any): + """Serialize data to JSON, encode, and hash it for idempotency key""" + return hashlib.md5(json_serialize(data).encode()).hexdigest() + + +def build_idempotency_put_item_stub( + data: Dict, function_name: str = "test-func", handler_name: str = "lambda_handler" +) -> Dict: + idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}" + return { + "ConditionExpression": "attribute_not_exists(#id) OR #now < :now", + "ExpressionAttributeNames": {"#id": "id", "#now": "expiration"}, + "ExpressionAttributeValues": {":now": stub.ANY}, + "Item": {"expiration": stub.ANY, "id": idempotency_key_hash, "status": "INPROGRESS"}, + "TableName": "TEST_TABLE", + } + + +def build_idempotency_update_item_stub( + data: Dict, + handler_response: Dict, + function_name: str = "test-func", + handler_name: str = "lambda_handler", +) -> Dict: + idempotency_key_hash = f"{function_name}.{handler_name}#{hash_idempotency_key(data)}" + serialized_lambda_response = json_serialize(handler_response) + return { + "ExpressionAttributeNames": {"#expiry": "expiration", "#response_data": "data", "#status": "status"}, + "ExpressionAttributeValues": { + ":expiry": stub.ANY, + ":response_data": serialized_lambda_response, + ":status": "COMPLETED", + }, + "Key": {"id": idempotency_key_hash}, + "TableName": "TEST_TABLE", + "UpdateExpression": "SET #response_data = :response_data, " "#expiry = :expiry, #status = :status", + } diff --git a/tests/functional/utils.py b/tests/functional/utils.py index 5f1f21afc51..6b73053e0d0 100644 --- a/tests/functional/utils.py +++ b/tests/functional/utils.py @@ -1,5 +1,4 @@ import base64 -import hashlib import json from pathlib import Path from typing import Any @@ -22,8 +21,3 @@ def b64_to_str(data: str) -> str: def json_serialize(data): return json.dumps(data, sort_keys=True, cls=Encoder) - - -def hash_idempotency_key(data: Any): - """Serialize data to JSON, encode, and hash it for idempotency key""" - return hashlib.md5(json_serialize(data).encode()).hexdigest()