Skip to content

Commit 539cb78

Browse files
authored
chore(tests): increase idempotency coverage with nested payload tampering tests (#3809)
1 parent 3a54750 commit 539cb78

File tree

5 files changed

+159
-9
lines changed

5 files changed

+159
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import os
2+
import uuid
3+
4+
from aws_lambda_powertools.utilities.idempotency import (
5+
DynamoDBPersistenceLayer,
6+
IdempotencyConfig,
7+
idempotent,
8+
)
9+
10+
TABLE_NAME = os.getenv("IdempotencyTable", "")
11+
persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME)
12+
config = IdempotencyConfig(event_key_jmespath='["refund_id", "customer_id"]', payload_validation_jmespath="details")
13+
14+
15+
@idempotent(config=config, persistence_store=persistence_layer)
16+
def lambda_handler(event, context):
17+
return {"request": str(uuid.uuid4())}

tests/e2e/idempotency/infrastructure.py

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def create_resources(self):
1717
table.grant_read_write_data(functions["ParallelExecutionHandler"])
1818
table.grant_read_write_data(functions["FunctionThreadSafetyHandler"])
1919
table.grant_read_write_data(functions["OptionalIdempotencyKeyHandler"])
20+
table.grant_read_write_data(functions["PayloadTamperingValidationHandler"])
2021

2122
def _create_dynamodb_table(self) -> Table:
2223
table = dynamodb.Table(

tests/e2e/idempotency/test_idempotency_dynamodb.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import json
2+
from copy import deepcopy
23
from time import sleep
34

45
import pytest
56

67
from tests.e2e.utils import data_fetcher
7-
from tests.e2e.utils.data_fetcher.common import GetLambdaResponseOptions, get_lambda_response_in_parallel
8+
from tests.e2e.utils.data_fetcher.common import (
9+
GetLambdaResponseOptions,
10+
get_lambda_response_in_parallel,
11+
)
812

913

1014
@pytest.fixture
@@ -32,6 +36,11 @@ def optional_idempotency_key_fn_arn(infrastructure: dict) -> str:
3236
return infrastructure.get("OptionalIdempotencyKeyHandlerArn", "")
3337

3438

39+
@pytest.fixture
40+
def payload_tampering_validation_fn_arn(infrastructure: dict) -> str:
41+
return infrastructure.get("PayloadTamperingValidationHandlerArn", "")
42+
43+
3544
@pytest.fixture
3645
def idempotency_table_name(infrastructure: dict) -> str:
3746
return infrastructure.get("DynamoDBTable", "")
@@ -186,3 +195,27 @@ def test_optional_idempotency_key(optional_idempotency_key_fn_arn: str):
186195
assert first_execution_response != second_execution_response
187196
assert first_execution_response != third_execution_response
188197
assert second_execution_response != third_execution_response
198+
199+
200+
@pytest.mark.xdist_group(name="idempotency")
201+
def test_payload_tampering_validation(payload_tampering_validation_fn_arn: str):
202+
# GIVEN a transaction with the idempotency key on refund and customer IDs
203+
transaction = {
204+
"refund_id": "ffd11882-d476-4598-bbf1-643f2be5addf",
205+
"customer_id": "9e9fc440-9e65-49b5-9e71-1382ea1b1658",
206+
"details": {"company_name": "Parker, Johnson and Rath", "currency": "Turkish Lira"},
207+
}
208+
209+
# AND a second transaction with the exact idempotency key but different currency
210+
tampered_transaction = deepcopy(transaction)
211+
tampered_transaction["details"]["currency"] = "Euro"
212+
213+
# WHEN we make both requests to a Lambda Function that enabled payload validation
214+
data_fetcher.get_lambda_response(lambda_arn=payload_tampering_validation_fn_arn, payload=json.dumps(transaction))
215+
216+
# THEN we should receive a payload validation error in the second request
217+
with pytest.raises(RuntimeError, match="Payload does not match stored record"):
218+
data_fetcher.get_lambda_response(
219+
lambda_arn=payload_tampering_validation_fn_arn,
220+
payload=json.dumps(tampered_transaction),
221+
)

tests/functional/idempotency/test_idempotency.py

+76-8
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from botocore import stub
99
from botocore.config import Config
1010
from pydantic import BaseModel
11+
from pytest import FixtureRequest
1112

1213
from aws_lambda_powertools.utilities.data_classes import (
1314
APIGatewayProxyEventV2,
@@ -38,11 +39,18 @@
3839
BasePersistenceLayer,
3940
DataRecord,
4041
)
41-
from aws_lambda_powertools.utilities.idempotency.serialization.custom_dict import CustomDictSerializer
42-
from aws_lambda_powertools.utilities.idempotency.serialization.dataclass import DataclassSerializer
43-
from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer
42+
from aws_lambda_powertools.utilities.idempotency.serialization.custom_dict import (
43+
CustomDictSerializer,
44+
)
45+
from aws_lambda_powertools.utilities.idempotency.serialization.dataclass import (
46+
DataclassSerializer,
47+
)
48+
from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import (
49+
PydanticSerializer,
50+
)
4451
from aws_lambda_powertools.utilities.validation import envelopes, validator
4552
from tests.functional.idempotency.utils import (
53+
build_idempotency_put_item_response_stub,
4654
build_idempotency_put_item_stub,
4755
build_idempotency_update_item_stub,
4856
hash_idempotency_key,
@@ -406,6 +414,8 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload(
406414
Test idempotent decorator where event with matching event key has already been successfully processed
407415
"""
408416

417+
# GIVEN an idempotent record already exists for the same transaction
418+
# and payload validation was enabled ('validation' key)
409419
stubber = stub.Stubber(persistence_store.client)
410420
ddb_response = {
411421
"Item": {
@@ -423,8 +433,11 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload(
423433
def lambda_handler(event, context):
424434
return lambda_response
425435

436+
# WHEN the subsequent request is the same but validated field is tampered
437+
lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload
438+
439+
# THEN we should raise
426440
with pytest.raises(IdempotencyValidationError):
427-
lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload
428441
lambda_handler(lambda_apigw_event, lambda_context)
429442

430443
stubber.assert_no_pending_responses()
@@ -1172,11 +1185,9 @@ def _put_record(self, data_record: DataRecord) -> None:
11721185
def _update_record(self, data_record: DataRecord) -> None:
11731186
assert data_record.idempotency_key == self.expected_idempotency_key
11741187

1175-
def _get_record(self, idempotency_key) -> DataRecord:
1176-
...
1188+
def _get_record(self, idempotency_key) -> DataRecord: ...
11771189

1178-
def _delete_record(self, data_record: DataRecord) -> None:
1179-
...
1190+
def _delete_record(self, data_record: DataRecord) -> None: ...
11801191

11811192

11821193
def test_idempotent_lambda_event_source(lambda_context):
@@ -1860,3 +1871,60 @@ def lambda_handler(event, context):
18601871

18611872
stubber.assert_no_pending_responses()
18621873
stubber.deactivate()
1874+
1875+
1876+
def test_idempotency_payload_validation_with_tampering_nested_object(
1877+
persistence_store: DynamoDBPersistenceLayer,
1878+
timestamp_future,
1879+
lambda_context,
1880+
request: FixtureRequest,
1881+
):
1882+
# GIVEN an idempotency config with a compound idempotency key (refund, customer_id)
1883+
# AND with payload validation key to prevent tampering
1884+
1885+
validation_key = "details"
1886+
idempotency_config = IdempotencyConfig(
1887+
event_key_jmespath='["refund_id", "customer_id"]',
1888+
payload_validation_jmespath=validation_key,
1889+
use_local_cache=False,
1890+
)
1891+
1892+
# AND a previous transaction already processed in the persistent store
1893+
transaction = {
1894+
"refund_id": "ffd11882-d476-4598-bbf1-643f2be5addf",
1895+
"customer_id": "9e9fc440-9e65-49b5-9e71-1382ea1b1658",
1896+
"details": [
1897+
{
1898+
"company_name": "Parker, Johnson and Rath",
1899+
"currency": "Turkish Lira",
1900+
},
1901+
],
1902+
}
1903+
1904+
stubber = stub.Stubber(persistence_store.client)
1905+
ddb_response = build_idempotency_put_item_response_stub(
1906+
data=transaction,
1907+
expiration=timestamp_future,
1908+
status="COMPLETED",
1909+
request=request,
1910+
validation_data=transaction[validation_key],
1911+
)
1912+
1913+
stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response)
1914+
stubber.activate()
1915+
1916+
# AND an upcoming tampered transaction
1917+
tampered_transaction = copy.deepcopy(transaction)
1918+
tampered_transaction["details"][0]["currency"] = "Euro"
1919+
1920+
@idempotent(config=idempotency_config, persistence_store=persistence_store)
1921+
def lambda_handler(event, context):
1922+
return event
1923+
1924+
# WHEN the tampered request is made
1925+
# THEN we should raise
1926+
with pytest.raises(IdempotencyValidationError):
1927+
lambda_handler(tampered_transaction, lambda_context)
1928+
1929+
stubber.assert_no_pending_responses()
1930+
stubber.deactivate()

tests/functional/idempotency/utils.py

+31
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
from __future__ import annotations
2+
13
import hashlib
4+
import json
25
from typing import Any, Dict
36

47
from botocore import stub
8+
from pytest import FixtureRequest
59

610
from tests.functional.utils import json_serialize
711

@@ -75,3 +79,30 @@ def build_idempotency_update_item_stub(
7579
"TableName": "TEST_TABLE",
7680
"UpdateExpression": "SET #response_data = :response_data, #expiry = :expiry, #status = :status",
7781
}
82+
83+
84+
def build_idempotency_key_id(data: Dict, request: FixtureRequest):
85+
return f"test-func.{request.function.__module__}.{request.function.__qualname__}.<locals>.lambda_handler#{hash_idempotency_key(data)}" # noqa: E501
86+
87+
88+
def build_idempotency_put_item_response_stub(
89+
data: Dict,
90+
expiration: int,
91+
status: str,
92+
request: FixtureRequest,
93+
validation_data: Any | None,
94+
):
95+
response = {
96+
"Item": {
97+
"id": {"S": build_idempotency_key_id(data, request)},
98+
"expiration": {"N": expiration},
99+
"data": {"S": json.dumps(data)},
100+
"status": {"S": status},
101+
"validation": {"S": hash_idempotency_key(validation_data)},
102+
},
103+
}
104+
105+
if validation_data is not None:
106+
response["Item"]["validation"] = {"S": hash_idempotency_key(validation_data)}
107+
108+
return response

0 commit comments

Comments
 (0)