Skip to content

chore(tests): increase idempotency coverage with nested payload tampering tests #3809

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import os
import uuid

from aws_lambda_powertools.utilities.idempotency import (
DynamoDBPersistenceLayer,
IdempotencyConfig,
idempotent,
)

TABLE_NAME = os.getenv("IdempotencyTable", "")
persistence_layer = DynamoDBPersistenceLayer(table_name=TABLE_NAME)
config = IdempotencyConfig(event_key_jmespath='["refund_id", "customer_id"]', payload_validation_jmespath="details")


@idempotent(config=config, persistence_store=persistence_layer)
def lambda_handler(event, context):
return {"request": str(uuid.uuid4())}
1 change: 1 addition & 0 deletions tests/e2e/idempotency/infrastructure.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def create_resources(self):
table.grant_read_write_data(functions["ParallelExecutionHandler"])
table.grant_read_write_data(functions["FunctionThreadSafetyHandler"])
table.grant_read_write_data(functions["OptionalIdempotencyKeyHandler"])
table.grant_read_write_data(functions["PayloadTamperingValidationHandler"])

def _create_dynamodb_table(self) -> Table:
table = dynamodb.Table(
Expand Down
35 changes: 34 additions & 1 deletion tests/e2e/idempotency/test_idempotency_dynamodb.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import json
from copy import deepcopy
from time import sleep

import pytest

from tests.e2e.utils import data_fetcher
from tests.e2e.utils.data_fetcher.common import GetLambdaResponseOptions, get_lambda_response_in_parallel
from tests.e2e.utils.data_fetcher.common import (
GetLambdaResponseOptions,
get_lambda_response_in_parallel,
)


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


@pytest.fixture
def payload_tampering_validation_fn_arn(infrastructure: dict) -> str:
return infrastructure.get("PayloadTamperingValidationHandlerArn", "")


@pytest.fixture
def idempotency_table_name(infrastructure: dict) -> str:
return infrastructure.get("DynamoDBTable", "")
Expand Down Expand Up @@ -186,3 +195,27 @@ def test_optional_idempotency_key(optional_idempotency_key_fn_arn: str):
assert first_execution_response != second_execution_response
assert first_execution_response != third_execution_response
assert second_execution_response != third_execution_response


@pytest.mark.xdist_group(name="idempotency")
def test_payload_tampering_validation(payload_tampering_validation_fn_arn: str):
# GIVEN a transaction with the idempotency key on refund and customer IDs
transaction = {
"refund_id": "ffd11882-d476-4598-bbf1-643f2be5addf",
"customer_id": "9e9fc440-9e65-49b5-9e71-1382ea1b1658",
"details": {"company_name": "Parker, Johnson and Rath", "currency": "Turkish Lira"},
}

# AND a second transaction with the exact idempotency key but different currency
tampered_transaction = deepcopy(transaction)
tampered_transaction["details"]["currency"] = "Euro"

# WHEN we make both requests to a Lambda Function that enabled payload validation
data_fetcher.get_lambda_response(lambda_arn=payload_tampering_validation_fn_arn, payload=json.dumps(transaction))

# THEN we should receive a payload validation error in the second request
with pytest.raises(RuntimeError, match="Payload does not match stored record"):
data_fetcher.get_lambda_response(
lambda_arn=payload_tampering_validation_fn_arn,
payload=json.dumps(tampered_transaction),
)
84 changes: 76 additions & 8 deletions tests/functional/idempotency/test_idempotency.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from botocore import stub
from botocore.config import Config
from pydantic import BaseModel
from pytest import FixtureRequest

from aws_lambda_powertools.utilities.data_classes import (
APIGatewayProxyEventV2,
Expand Down Expand Up @@ -38,11 +39,18 @@
BasePersistenceLayer,
DataRecord,
)
from aws_lambda_powertools.utilities.idempotency.serialization.custom_dict import CustomDictSerializer
from aws_lambda_powertools.utilities.idempotency.serialization.dataclass import DataclassSerializer
from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import PydanticSerializer
from aws_lambda_powertools.utilities.idempotency.serialization.custom_dict import (
CustomDictSerializer,
)
from aws_lambda_powertools.utilities.idempotency.serialization.dataclass import (
DataclassSerializer,
)
from aws_lambda_powertools.utilities.idempotency.serialization.pydantic import (
PydanticSerializer,
)
from aws_lambda_powertools.utilities.validation import envelopes, validator
from tests.functional.idempotency.utils import (
build_idempotency_put_item_response_stub,
build_idempotency_put_item_stub,
build_idempotency_update_item_stub,
hash_idempotency_key,
Expand Down Expand Up @@ -406,6 +414,8 @@ def test_idempotent_lambda_already_completed_with_validation_bad_payload(
Test idempotent decorator where event with matching event key has already been successfully processed
"""

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

# WHEN the subsequent request is the same but validated field is tampered
lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload

# THEN we should raise
with pytest.raises(IdempotencyValidationError):
lambda_apigw_event["requestContext"]["accountId"] += "1" # Alter the request payload
lambda_handler(lambda_apigw_event, lambda_context)

stubber.assert_no_pending_responses()
Expand Down Expand Up @@ -1172,11 +1185,9 @@ def _put_record(self, data_record: DataRecord) -> None:
def _update_record(self, data_record: DataRecord) -> None:
assert data_record.idempotency_key == self.expected_idempotency_key

def _get_record(self, idempotency_key) -> DataRecord:
...
def _get_record(self, idempotency_key) -> DataRecord: ...

def _delete_record(self, data_record: DataRecord) -> None:
...
def _delete_record(self, data_record: DataRecord) -> None: ...


def test_idempotent_lambda_event_source(lambda_context):
Expand Down Expand Up @@ -1860,3 +1871,60 @@ def lambda_handler(event, context):

stubber.assert_no_pending_responses()
stubber.deactivate()


def test_idempotency_payload_validation_with_tampering_nested_object(
persistence_store: DynamoDBPersistenceLayer,
timestamp_future,
lambda_context,
request: FixtureRequest,
):
# GIVEN an idempotency config with a compound idempotency key (refund, customer_id)
# AND with payload validation key to prevent tampering

validation_key = "details"
idempotency_config = IdempotencyConfig(
event_key_jmespath='["refund_id", "customer_id"]',
payload_validation_jmespath=validation_key,
use_local_cache=False,
)

# AND a previous transaction already processed in the persistent store
transaction = {
"refund_id": "ffd11882-d476-4598-bbf1-643f2be5addf",
"customer_id": "9e9fc440-9e65-49b5-9e71-1382ea1b1658",
"details": [
{
"company_name": "Parker, Johnson and Rath",
"currency": "Turkish Lira",
},
],
}

stubber = stub.Stubber(persistence_store.client)
ddb_response = build_idempotency_put_item_response_stub(
data=transaction,
expiration=timestamp_future,
status="COMPLETED",
request=request,
validation_data=transaction[validation_key],
)

stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response)
stubber.activate()

# AND an upcoming tampered transaction
tampered_transaction = copy.deepcopy(transaction)
tampered_transaction["details"][0]["currency"] = "Euro"

@idempotent(config=idempotency_config, persistence_store=persistence_store)
def lambda_handler(event, context):
return event

# WHEN the tampered request is made
# THEN we should raise
with pytest.raises(IdempotencyValidationError):
lambda_handler(tampered_transaction, lambda_context)

stubber.assert_no_pending_responses()
stubber.deactivate()
31 changes: 31 additions & 0 deletions tests/functional/idempotency/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import hashlib
import json
from typing import Any, Dict

from botocore import stub
from pytest import FixtureRequest

from tests.functional.utils import json_serialize

Expand Down Expand Up @@ -75,3 +79,30 @@ def build_idempotency_update_item_stub(
"TableName": "TEST_TABLE",
"UpdateExpression": "SET #response_data = :response_data, #expiry = :expiry, #status = :status",
}


def build_idempotency_key_id(data: Dict, request: FixtureRequest):
return f"test-func.{request.function.__module__}.{request.function.__qualname__}.<locals>.lambda_handler#{hash_idempotency_key(data)}" # noqa: E501


def build_idempotency_put_item_response_stub(
data: Dict,
expiration: int,
status: str,
request: FixtureRequest,
validation_data: Any | None,
):
response = {
"Item": {
"id": {"S": build_idempotency_key_id(data, request)},
"expiration": {"N": expiration},
"data": {"S": json.dumps(data)},
"status": {"S": status},
"validation": {"S": hash_idempotency_key(validation_data)},
},
}

if validation_data is not None:
response["Item"]["validation"] = {"S": hash_idempotency_key(validation_data)}

return response