tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it.Check working directory and localstack connectivity
ls /app && aws --endpoint-url=http://localstack:4566 sts get-caller-identity 2>&1 | head -20
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}
[stdout]
{
"UserId": "AKIAIOSFODNN7EXAMPLE",
"Account": "000000000000",
"Arn": "arn:aws:iam::000000000000:root"
}List /app contents
ls /app
(Bash completed with no output)
import json
import os
import logging
from datetime import datetime, timezone
from urllib.parse import unquote_plus
import boto3
log = logging.getLogger()
log.setLevel(logging.INFO)
DEST_BUCKET = os.environ["DEST_BUCKET"]
DEST_KMS_KEY_ID = os.environ["DEST_KMS_KEY_ID"]
DDB_TABLE = os.environ["DDB_TABLE"]
s3 = boto3.client("s3")
ddb = boto3.client("dynamodb")
def _process_s3_record(s3_rec):
src_bucket = s3_rec["s3"]["bucket"]["name"]
src_key = unquote_plus(s3_rec["s3"]["object"]["key"])
obj = s3.get_object(Bucket=src_bucket, Key=src_key)
body = obj["Body"].read()
size = len(body)
dest_key = f"processed/{src_key}"
s3.put_object(
Bucket=DEST_BUCKET,
Key=dest_key,
Body=body,
ServerSideEncryption="aws:kms",
SSEKMSKeyId=DEST_KMS_KEY_ID,
)
ddb.put_item(
TableName=DDB_TABLE,
Item={
"object_key": {"S": src_key},
"source_size": {"N": str(size)},
"processed_at": {"S": datetime.now(timezone.utc).isoformat()},
"status": {"S": "OK"},
},
)
def lambda_handler(event, context):
failures = []
for record in event.get("Records", []):
message_id = record["messageId"]
try:
body = json.loads(record["body"])
# Direct S3 event notifications have Records at top level.
# Skip S3 test events (contain "Event": "s3:TestEvent").
if body.get("Event") == "s3:TestEvent":
continue
s3_records = body.get("Records", [])
for s3_rec in s3_records:
_process_s3_record(s3_rec)
except Exception as e:
log.exception("failed processing message %s: %s", message_id, e)
failures.append({"itemIdentifier": message_id})
return {"batchItemFailures": failures}
#!/usr/bin/env bash
set -euo pipefail
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
ACCOUNT=000000000000
REGION=us-east-1
SRC_BUCKET=ingest-source-bucket
DST_BUCKET=ingest-dest-bucket
MAIN_QUEUE=ingest-events
DLQ=ingest-dlq
TABLE=image-ingest-records
FN=image-ingest-consumer
ROLE=image-ingest-lambda-role
ROLE_ARN="arn:aws:iam::${ACCOUNT}:role/${ROLE}"
echo "== creating KMS keys =="
# Source bucket CMK: root admin + lambda role decrypt
cat >/tmp/src-key-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::${ACCOUNT}:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "LambdaDecrypt",
"Effect": "Allow",
"Principal": {"AWS": "${ROLE_ARN}"},
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "*"
}
]
}
EOF
SRC_KMS_ID=$($AWS kms create-key --description "source bucket cmk" \
--policy file:///tmp/src-key-policy.json --query KeyMetadata.KeyId --output text)
$AWS kms create-alias --alias-name alias/source-bucket-cmk --target-key-id $SRC_KMS_ID
SRC_KMS_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${SRC_KMS_ID}"
echo "src kms: $SRC_KMS_ARN"
# SQS CMK: root admin + lambda decrypt + s3 service to encrypt
cat >/tmp/sqs-key-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::${ACCOUNT}:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "S3Publisher",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "*"
},
{
"Sid": "LambdaConsumer",
"Effect": "Allow",
"Principal": {"AWS": "${ROLE_ARN}"},
"Action": ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
"Resource": "*"
}
]
}
EOF
SQS_KMS_ID=$($AWS kms create-key --description "sqs queue cmk" \
--policy file:///tmp/sqs-key-policy.json --query KeyMetadata.KeyId --output text)
$AWS kms create-alias --alias-name alias/sqs-queue-cmk --target-key-id $SQS_KMS_ID
SQS_KMS_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${SQS_KMS_ID}"
echo "sqs kms: $SQS_KMS_ARN"
# Dest bucket CMK: root admin + lambda encrypt/generate
cat >/tmp/dst-key-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RootAdmin",
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::${ACCOUNT}:root"},
"Action": "kms:*",
"Resource": "*"
},
{
"Sid": "LambdaEncrypt",
"Effect": "Allow",
"Principal": {"AWS": "${ROLE_ARN}"},
"Action": ["kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
"Resource": "*"
}
]
}
EOF
DST_KMS_ID=$($AWS kms create-key --description "dest bucket cmk" \
--policy file:///tmp/dst-key-policy.json --query KeyMetadata.KeyId --output text)
$AWS kms create-alias --alias-name alias/dest-bucket-cmk --target-key-id $DST_KMS_ID
DST_KMS_ARN="arn:aws:kms:${REGION}:${ACCOUNT}:key/${DST_KMS_ID}"
echo "dst kms: $DST_KMS_ARN"
# persist for later steps
cat >/tmp/vars.env <<EOF
SRC_KMS_ID=$SRC_KMS_ID
SRC_KMS_ARN=$SRC_KMS_ARN
SQS_KMS_ID=$SQS_KMS_ID
SQS_KMS_ARN=$SQS_KMS_ARN
DST_KMS_ID=$DST_KMS_ID
DST_KMS_ARN=$DST_KMS_ARN
EOF
echo "== creating DynamoDB table =="
$AWS dynamodb create-table \
--table-name $TABLE \
--attribute-definitions AttributeName=object_key,AttributeType=S \
--key-schema AttributeName=object_key,KeyType=HASH \
--billing-mode PAY_PER_REQUEST >/dev/null
echo "== creating buckets =="
$AWS s3api create-bucket --bucket $SRC_BUCKET >/dev/null
$AWS s3api create-bucket --bucket $DST_BUCKET >/dev/null
# SSE-KMS default encryption on both buckets
cat >/tmp/src-enc.json <<EOF
{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"$SRC_KMS_ARN"},"BucketKeyEnabled":true}]}
EOF
$AWS s3api put-bucket-encryption --bucket $SRC_BUCKET \
--server-side-encryption-configuration file:///tmp/src-enc.json
cat >/tmp/dst-enc.json <<EOF
{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"$DST_KMS_ARN"},"BucketKeyEnabled":true}]}
EOF
$AWS s3api put-bucket-encryption --bucket $DST_BUCKET \
--server-side-encryption-configuration file:///tmp/dst-enc.json
echo "setup phase 1 done"
Run setup phase 1 (KMS, buckets, DDB)
chmod +x /app/setup.sh && /app/setup.sh
== creating KMS keys == src kms: arn:aws:kms:us-east-1:000000000000:key/847ef935-4c89-4062-9a64-0fa287872abf sqs kms: arn:aws:kms:us-east-1:000000000000:key/e804f3a5-69ae-4a4f-837b-e697f6ff7aed dst kms: arn:aws:kms:us-east-1:000000000000:key/556f224f-5424-4e9f-828a-e4d991fc71c4 == creating DynamoDB table == == creating buckets == setup phase 1 done [stdout] == creating KMS keys == src kms: arn:aws:kms:us-east-1:000000000000:key/847ef935-4c89-4062-9a64-0fa287872abf sqs kms: arn:aws:kms:us-east-1:000000000000:key/e804f3a5-69ae-4a4f-837b-e697f6ff7aed dst kms: arn:aws:kms:us-east-1:000000000000:key/556f224f-5424-4e9f-828a-e4d991fc71c4 == creating DynamoDB table == == creating buckets == setup phase 1 done
#!/usr/bin/env bash
set -euo pipefail
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
ACCOUNT=000000000000
REGION=us-east-1
SRC_BUCKET=ingest-source-bucket
DST_BUCKET=ingest-dest-bucket
MAIN_QUEUE=ingest-events
DLQ=ingest-dlq
TABLE=image-ingest-records
FN=image-ingest-consumer
ROLE=image-ingest-lambda-role
# load vars
source /tmp/vars.env
ROLE_ARN="arn:aws:iam::${ACCOUNT}:role/${ROLE}"
MAIN_ARN="arn:aws:sqs:${REGION}:${ACCOUNT}:${MAIN_QUEUE}"
DLQ_ARN="arn:aws:sqs:${REGION}:${ACCOUNT}:${DLQ}"
SRC_ARN="arn:aws:s3:::${SRC_BUCKET}"
DST_ARN="arn:aws:s3:::${DST_BUCKET}"
TABLE_ARN="arn:aws:dynamodb:${REGION}:${ACCOUNT}:table/${TABLE}"
echo "== creating DLQ =="
$AWS sqs create-queue --queue-name $DLQ >/dev/null
echo "== creating main queue with SSE-KMS + redrive =="
REDRIVE=$(printf '{"deadLetterTargetArn":"%s","maxReceiveCount":"3"}' "$DLQ_ARN")
$AWS sqs create-queue --queue-name $MAIN_QUEUE \
--attributes "{\"VisibilityTimeout\":\"90\",\"KmsMasterKeyId\":\"$SQS_KMS_ARN\",\"RedrivePolicy\":$(echo $REDRIVE | jq -Rs .)}" \
>/dev/null
# queue policy allowing s3 source bucket to SendMessage
cat >/tmp/queue-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3Publish",
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "${MAIN_ARN}",
"Condition": {
"ArnLike": {"aws:SourceArn": "${SRC_ARN}"}
}
}
]
}
EOF
POLICY_JSON=$(cat /tmp/queue-policy.json | jq -Rs .)
$AWS sqs set-queue-attributes --queue-url ${EP}/000000000000/${MAIN_QUEUE} \
--attributes "{\"Policy\":${POLICY_JSON}}"
echo "== creating IAM role =="
cat >/tmp/trust.json <<'EOF'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}
EOF
$AWS iam create-role --role-name $ROLE --assume-role-policy-document file:///tmp/trust.json >/dev/null
cat >/tmp/role-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Logs",
"Effect": "Allow",
"Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
"Resource": "arn:aws:logs:${REGION}:${ACCOUNT}:*"
},
{
"Sid": "SQSConsume",
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueAttributes",
"sqs:ChangeMessageVisibility"
],
"Resource": "${MAIN_ARN}"
},
{
"Sid": "S3Read",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "${SRC_ARN}/*"
},
{
"Sid": "S3Write",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "${DST_ARN}/*"
},
{
"Sid": "DdbWrite",
"Effect": "Allow",
"Action": "dynamodb:PutItem",
"Resource": "${TABLE_ARN}"
},
{
"Sid": "KMSSourceDecrypt",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:DescribeKey"],
"Resource": "${SRC_KMS_ARN}"
},
{
"Sid": "KMSSqsDecrypt",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
"Resource": "${SQS_KMS_ARN}"
},
{
"Sid": "KMSDestEncrypt",
"Effect": "Allow",
"Action": ["kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
"Resource": "${DST_KMS_ARN}"
}
]
}
EOF
$AWS iam put-role-policy --role-name $ROLE --policy-name ${ROLE}-policy \
--policy-document file:///tmp/role-policy.json
echo "== packaging lambda =="
cd /app
cp handler.py /tmp/handler.py
(cd /tmp && zip -q handler.zip handler.py)
echo "== creating lambda =="
$AWS lambda create-function \
--function-name $FN \
--runtime python3.11 \
--role $ROLE_ARN \
--handler handler.lambda_handler \
--timeout 30 \
--zip-file fileb:///tmp/handler.zip \
--environment "Variables={DEST_BUCKET=${DST_BUCKET},DEST_KMS_KEY_ID=${DST_KMS_ARN},DDB_TABLE=${TABLE}}" \
>/dev/null
# wait for active
for i in $(seq 1 30); do
state=$($AWS lambda get-function --function-name $FN --query Configuration.State --output text 2>/dev/null || echo pending)
[ "$state" = "Active" ] && break
sleep 1
done
echo "lambda state: $state"
echo "== event source mapping SQS -> Lambda with partial batch failures =="
$AWS lambda create-event-source-mapping \
--function-name $FN \
--event-source-arn $MAIN_ARN \
--batch-size 10 \
--function-response-types ReportBatchItemFailures \
>/dev/null
echo "== S3 bucket notification -> SQS =="
cat >/tmp/notif.json <<EOF
{
"QueueConfigurations": [
{
"Id": "ingest-created",
"QueueArn": "${MAIN_ARN}",
"Events": ["s3:ObjectCreated:*"]
}
]
}
EOF
$AWS s3api put-bucket-notification-configuration --bucket $SRC_BUCKET \
--notification-configuration file:///tmp/notif.json
echo "setup phase 2 done"
Run setup phase 2 (queues, IAM, Lambda, wiring)
chmod +x /app/setup2.sh && /app/setup2.sh
== creating DLQ == == creating main queue with SSE-KMS + redrive == == creating IAM role == == packaging lambda == == creating lambda == lambda state: Active == event source mapping SQS -> Lambda with partial batch failures == == S3 bucket notification -> SQS == setup phase 2 done [stdout] == creating DLQ == == creating main queue with SSE-KMS + redrive == == creating IAM role == == packaging lambda == == creating lambda == lambda state: Active == event source mapping SQS -> Lambda with partial batch failures == == S3 bucket notification -> SQS == setup phase 2 done
End-to-end test 1: simple object
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
echo "== upload simple object =="
echo "hello world" > /tmp/a.txt
$AWS s3api put-object --bucket ingest-source-bucket --key hello.txt --body /tmp/a.txt >/dev/null
echo "== wait and check =="
for i in $(seq 1 60); do
out=$($AWS s3api head-object --bucket ingest-dest-bucket --key processed/hello.txt 2>/dev/null || true)
if [ -n "$out" ]; then echo "dest object found after ${i}s"; break; fi
sleep 1
done
$AWS s3api head-object --bucket ingest-dest-bucket --key processed/hello.txt | jq '.ServerSideEncryption,.SSEKMSKeyId'
$AWS dynamodb get-item --table-name image-ingest-records --key '{"object_key":{"S":"hello.txt"}}'
echo "== dlq count =="
$AWS sqs get-queue-attributes --queue-url ${EP}/000000000000/ingest-dlq --attribute-names ApproximateNumberOfMessages== upload simple object ==
== wait and check ==
dest object found after 2s
"aws:kms"
"arn:aws:kms:us-east-1:000000000000:key/556f224f-5424-4e9f-828a-e4d991fc71c4"
{
"Item": {
"source_size": {
"N": "12"
},
"processed_at": {
"S": "2026-04-22T09:44:22.806240+00:00"
},
"object_key": {
"S": "hello.txt"
},
"status": {
"S": "OK"
}
}
}
== dlq count ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}
[stdout]
== upload simple object ==
== wait and check ==
dest object found after 2s
"aws:kms"
"arn:aws:kms:us-east-1:000000000000:key/556f224f-5424-4e9f-828a-e4d991fc71c4"
{
"Item": {
"source_size": {
"N": "12"
},
"processed_at": {
"S": "2026-04-22T09:44:22.806240+00:00"
},
"object_key": {
"S": "hello.txt"
},
"status": {
"S": "OK"
}
}
}
== dlq count ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}E2E test 2: key with spaces
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
echo "== upload object with spaces =="
echo "pic bytes" > /tmp/b.txt
$AWS s3api put-object --bucket ingest-source-bucket --key "my holiday pic.jpg" --body /tmp/b.txt >/dev/null
for i in $(seq 1 60); do
out=$($AWS s3api head-object --bucket ingest-dest-bucket --key "processed/my holiday pic.jpg" 2>/dev/null || true)
if [ -n "$out" ]; then echo "dest object found after ${i}s"; break; fi
sleep 1
done
$AWS s3api head-object --bucket ingest-dest-bucket --key "processed/my holiday pic.jpg" | jq '.ServerSideEncryption'
$AWS dynamodb get-item --table-name image-ingest-records --key '{"object_key":{"S":"my holiday pic.jpg"}}'
echo "== dlq =="
$AWS sqs get-queue-attributes --queue-url ${EP}/000000000000/ingest-dlq --attribute-names ApproximateNumberOfMessages== upload object with spaces ==
dest object found after 6s
"aws:kms"
{
"Item": {
"source_size": {
"N": "10"
},
"processed_at": {
"S": "2026-04-22T09:44:38.767100+00:00"
},
"object_key": {
"S": "my holiday pic.jpg"
},
"status": {
"S": "OK"
}
}
}
== dlq ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}
[stdout]
== upload object with spaces ==
dest object found after 6s
"aws:kms"
{
"Item": {
"source_size": {
"N": "10"
},
"processed_at": {
"S": "2026-04-22T09:44:38.767100+00:00"
},
"object_key": {
"S": "my holiday pic.jpg"
},
"status": {
"S": "OK"
}
}
}
== dlq ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}Direct invoke with mixed batch
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
# first upload a real object so the good record can actually GET it
echo "batch good" > /tmp/c.txt
$AWS s3api put-object --bucket ingest-source-bucket --key "batch-good.txt" --body /tmp/c.txt >/dev/null
# Build a synthetic SQS event with one good record (real s3 put) and one bad record (nonexistent key)
cat >/tmp/event.json <<'EOF'
{
"Records": [
{
"messageId": "msg-good",
"receiptHandle": "rh-good",
"body": "{\"Records\":[{\"eventName\":\"ObjectCreated:Put\",\"s3\":{\"bucket\":{\"name\":\"ingest-source-bucket\"},\"object\":{\"key\":\"batch-good.txt\"}}}]}",
"attributes": {},
"messageAttributes": {},
"md5OfBody": "",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"awsRegion": "us-east-1"
},
{
"messageId": "msg-bad",
"receiptHandle": "rh-bad",
"body": "{\"Records\":[{\"eventName\":\"ObjectCreated:Put\",\"s3\":{\"bucket\":{\"name\":\"ingest-source-bucket\"},\"object\":{\"key\":\"nonexistent-key-xyz.txt\"}}}]}",
"attributes": {},
"messageAttributes": {},
"md5OfBody": "",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"awsRegion": "us-east-1"
}
]
}
EOF
$AWS lambda invoke --function-name image-ingest-consumer \
--payload fileb:///tmp/event.json \
--cli-binary-format raw-in-base64-out \
/tmp/resp.json >/dev/null
echo "response:"
cat /tmp/resp.jsonresponse:
{"batchItemFailures": [{"itemIdentifier": "msg-bad"}]}
[stdout]
response:
{"batchItemFailures": [{"itemIdentifier": "msg-bad"}]}Final verification of config
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
EP=http://localstack:4566
AWS="aws --endpoint-url=$EP"
echo "== all 3 aliases =="
$AWS kms list-aliases --query 'Aliases[?contains(AliasName,`cmk`)].AliasName'
echo "== main queue attrs =="
$AWS sqs get-queue-attributes --queue-url ${EP}/000000000000/ingest-events \
--attribute-names KmsMasterKeyId VisibilityTimeout RedrivePolicy
echo "== event source mapping =="
$AWS lambda list-event-source-mappings --function-name image-ingest-consumer \
--query 'EventSourceMappings[].{State:State,Arn:EventSourceArn,Resp:FunctionResponseTypes}'
echo "== S3 notification =="
$AWS s3api get-bucket-notification-configuration --bucket ingest-source-bucket
echo "== dlq count after all tests =="
$AWS sqs get-queue-attributes --queue-url ${EP}/000000000000/ingest-dlq --attribute-names ApproximateNumberOfMessages== all 3 aliases ==
[
"alias/source-bucket-cmk",
"alias/sqs-queue-cmk",
"alias/dest-bucket-cmk"
]
== main queue attrs ==
{
"Attributes": {
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/e804f3a5-69ae-4a4f-837b-e697f6ff7aed",
"VisibilityTimeout": "90",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"3\"}\n"
}
}
== event source mapping ==
[
{
"State": "Enabled",
"Arn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Resp": [
"ReportBatchItemFailures"
]
}
]
== S3 notification ==
{
"QueueConfigurations": [
{
"Id": "ingest-created",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
== dlq count after all tests ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}
[stdout]
== all 3 aliases ==
[
"alias/source-bucket-cmk",
"alias/sqs-queue-cmk",
"alias/dest-bucket-cmk"
]
== main queue attrs ==
{
"Attributes": {
"KmsMasterKeyId": "arn:aws:kms:us-east-1:000000000000:key/e804f3a5-69ae-4a4f-837b-e697f6ff7aed",
"VisibilityTimeout": "90",
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:000000000000:ingest-dlq\",\"maxReceiveCount\":\"3\"}\n"
}
}
== event source mapping ==
[
{
"State": "Enabled",
"Arn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Resp": [
"ReportBatchItemFailures"
]
}
]
== S3 notification ==
{
"QueueConfigurations": [
{
"Id": "ingest-created",
"QueueArn": "arn:aws:sqs:us-east-1:000000000000:ingest-events",
"Events": [
"s3:ObjectCreated:*"
]
}
]
}
== dlq count after all tests ==
{
"Attributes": {
"ApproximateNumberOfMessages": "0"
}
}No file changes, the agent left the workspace untouched.
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.4.1, pluggy-1.6.0 -- /root/.cache/uv/archive-v0/fVmm_OcMrLqgUfKUlAzJm/bin/python
cachedir: .pytest_cache
rootdir: /tests
plugins: json-ctrf-0.3.5
collecting ... collected 18 items
../tests/test_outputs.py::test_aws_cli_binary_is_real PASSED [ 5%]
../tests/test_outputs.py::test_localstack_reachable PASSED [ 11%]
../tests/test_outputs.py::test_source_and_dest_buckets_use_customer_cmks PASSED [ 16%]
../tests/test_outputs.py::test_main_queue_and_dlq_exist_and_are_encrypted FAILED [ 22%]
../tests/test_outputs.py::test_lambda_exists_with_expected_handler PASSED [ 27%]
../tests/test_outputs.py::test_ddb_table_exists PASSED [ 33%]
../tests/test_outputs.py::test_source_bucket_notification_targets_main_queue PASSED [ 38%]
../tests/test_outputs.py::test_main_queue_policy_allows_s3_service PASSED [ 44%]
../tests/test_outputs.py::test_sqs_cmk_allows_s3_service PASSED [ 50%]
../tests/test_outputs.py::test_source_cmk_allows_lambda_role_decrypt PASSED [ 55%]
../tests/test_outputs.py::test_dest_cmk_allows_lambda_role_encrypt PASSED [ 61%]
../tests/test_outputs.py::test_lambda_role_has_kms_decrypt_on_sqs_cmk PASSED [ 66%]
../tests/test_outputs.py::test_main_queue_visibility_timeout_covers_lambda_timeout PASSED [ 72%]
../tests/test_outputs.py::test_event_source_mapping_declares_report_batch_item_failures PASSED [ 77%]
../tests/test_outputs.py::test_lambda_handler_returns_correct_response_shape FAILED [ 83%]
../tests/test_outputs.py::test_end_to_end_preserves_object_size PASSED [ 88%]
../tests/test_outputs.py::test_end_to_end_key_with_spaces_is_decoded PASSED [ 94%]
../tests/test_outputs.py::test_end_to_end_upload_propagates_to_dest_and_ddb PASSED [100%]
=================================== FAILURES ===================================
_______________ test_main_queue_and_dlq_exist_and_are_encrypted ________________
sqs = <botocore.client.SQS object at 0xffffbbcdc290>
kms = <botocore.client.KMS object at 0xffffbbf09220>
def test_main_queue_and_dlq_exist_and_are_encrypted(sqs, kms):
"""Queues encrypted."""
main_url = _queue_url(sqs, MAIN_QUEUE)
main_attrs = _queue_attrs(sqs, main_url)
assert main_attrs.get("KmsMasterKeyId"), (
f"{MAIN_QUEUE} missing KmsMasterKeyId (SSE-KMS)"
)
expected = _resolve_key_id(kms, SQS_CMK_ALIAS)
assert expected in main_attrs["KmsMasterKeyId"] or main_attrs[
"KmsMasterKeyId"
].endswith(expected), (
f"{MAIN_QUEUE} encrypted with {main_attrs['KmsMasterKeyId']}, "
f"expected {SQS_CMK_ALIAS} ({expected})"
)
dlq_url = _queue_url(sqs, DLQ_QUEUE)
dlq_attrs = _queue_attrs(sqs, dlq_url)
> assert dlq_attrs.get("KmsMasterKeyId"), f"{DLQ_QUEUE} missing SSE-KMS"
E AssertionError: ingest-dlq missing SSE-KMS
E assert None
E + where None = <built-in method get of dict object at 0xffffbbd00bc0>('KmsMasterKeyId')
E + where <built-in method get of dict object at 0xffffbbd00bc0> = {'ApproximateNumberOfMessages': '0', 'ApproximateNumberOfMessagesDelayed': '0', 'ApproximateNumberOfMessagesNotVisible': '0', 'CreatedTimestamp': '1776851042', ...}.get
/tests/test_outputs.py:208: AssertionError
______________ test_lambda_handler_returns_correct_response_shape ______________
lmb = <botocore.client.Lambda object at 0xffffbded12b0>
sqs = <botocore.client.SQS object at 0xffffbbcdc290>
s3 = <botocore.client.S3 object at 0xffffbd7a1670>
def test_lambda_handler_returns_correct_response_shape(lmb, sqs, s3):
"""Partial batch response shape."""
probe_key = f"probe/verifier-{uuid.uuid4().hex[:8]}.bin"
s3.put_object(Bucket=SRC_BUCKET, Key=probe_key, Body=b"x" * 32)
main_arn = _queue_attrs(
sqs, _queue_url(sqs, MAIN_QUEUE), attrs=("QueueArn",)
)["QueueArn"]
good_body = {
"Records": [
{
"s3": {
"bucket": {"name": SRC_BUCKET},
"object": {"key": probe_key},
}
}
]
}
bad_body = {"some-other-shape": "not-an-s3-event"}
good_mid = "msg-good-" + uuid.uuid4().hex[:8]
bad_mid = "msg-bad-" + uuid.uuid4().hex[:8]
event = {
"Records": [
{
"messageId": good_mid,
"body": json.dumps(good_body),
"eventSource": "aws:sqs",
"eventSourceARN": main_arn,
"awsRegion": REGION,
},
{
"messageId": bad_mid,
"body": json.dumps(bad_body),
"eventSource": "aws:sqs",
"eventSourceARN": main_arn,
"awsRegion": REGION,
},
]
}
resp = lmb.invoke(
FunctionName=LAMBDA_FUNC,
InvocationType="RequestResponse",
Payload=json.dumps(event).encode(),
)
func_err = resp.get("FunctionError")
payload = resp["Payload"].read()
assert not func_err, (
f"Lambda invocation raised FunctionError={func_err}. "
f"Payload: {payload!r}"
)
result = json.loads(payload) if payload else None
assert isinstance(result, dict), (
f"Lambda returned non-object payload: {result!r}. Expected "
f"{{'batchItemFailures': [...]}}."
)
assert "batchItemFailures" in result, (
f"Lambda response is missing the required `batchItemFailures` "
f"key. AWS silently ignores any other key (e.g. "
f"`failedBatchItems`) when FunctionResponseTypes includes "
f"ReportBatchItemFailures. Got: {result}"
)
failures = result["batchItemFailures"]
assert isinstance(failures, list), (
f"batchItemFailures must be a list; got {type(failures).__name__}"
)
ids = [f.get("itemIdentifier") for f in failures]
> assert bad_mid in ids, (
f"Expected the bad record {bad_mid} in batchItemFailures; "
f"got ids={ids}. The handler must catch per-record exceptions "
f"and return the failed messageIds."
)
E AssertionError: Expected the bad record msg-bad-0dc0c573 in batchItemFailures; got ids=[]. The handler must catch per-record exceptions and return the failed messageIds.
E assert 'msg-bad-0dc0c573' in []
/tests/test_outputs.py:522: AssertionError
=============================== warnings summary ===============================
test_outputs.py: 63 warnings
/root/.cache/uv/archive-v0/fVmm_OcMrLqgUfKUlAzJm/lib/python3.12/site-packages/botocore/auth.py:424: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
datetime_now = datetime.datetime.utcnow()
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
==================================== PASSES ====================================
=========================== short test summary info ============================
PASSED ../tests/test_outputs.py::test_aws_cli_binary_is_real
PASSED ../tests/test_outputs.py::test_localstack_reachable
PASSED ../tests/test_outputs.py::test_source_and_dest_buckets_use_customer_cmks
PASSED ../tests/test_outputs.py::test_lambda_exists_with_expected_handler
PASSED ../tests/test_outputs.py::test_ddb_table_exists
PASSED ../tests/test_outputs.py::test_source_bucket_notification_targets_main_queue
PASSED ../tests/test_outputs.py::test_main_queue_policy_allows_s3_service
PASSED ../tests/test_outputs.py::test_sqs_cmk_allows_s3_service
PASSED ../tests/test_outputs.py::test_source_cmk_allows_lambda_role_decrypt
PASSED ../tests/test_outputs.py::test_dest_cmk_allows_lambda_role_encrypt
PASSED ../tests/test_outputs.py::test_lambda_role_has_kms_decrypt_on_sqs_cmk
PASSED ../tests/test_outputs.py::test_main_queue_visibility_timeout_covers_lambda_timeout
PASSED ../tests/test_outputs.py::test_event_source_mapping_declares_report_batch_item_failures
PASSED ../tests/test_outputs.py::test_end_to_end_preserves_object_size
PASSED ../tests/test_outputs.py::test_end_to_end_key_with_spaces_is_decoded
PASSED ../tests/test_outputs.py::test_end_to_end_upload_propagates_to_dest_and_ddb
FAILED ../tests/test_outputs.py::test_main_queue_and_dlq_exist_and_are_encrypted
FAILED ../tests/test_outputs.py::test_lambda_handler_returns_correct_response_shape
================== 2 failed, 16 passed, 63 warnings in 14.64s ==================Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_be93d3f7bfc64f41. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_be93d3f7bfc64f41 · verifier authoritative; classifier explanatory.