second first commit

This commit is contained in:
Zakaria
2026-09-22 08:57:00 -04:00
parent e88d7e2ff3
commit 0aef7fb2d8
4766 changed files with 1236986 additions and 0 deletions
@@ -0,0 +1,112 @@
{
"verification": "Verified on Live AWS",
"observedDate": "2026-09-16",
"region": "eu-west-1",
"awsCliVersion": "2.36.45",
"description": "Normalized observations from isolated synthetic PutMetricFilter/PutLogEvents/GetMetricStatistics probes. Values are independent AWS results, not emulator-generated expectations.",
"period": 60,
"observedThrough": "2026-09-16T09:06:51Z",
"metricDefaults": {
"filterPattern": "ERROR",
"metricValue": "3",
"defaultValue": 7,
"cases": [
{
"name": "mixed events in one batch",
"batches": [["ERROR", "INFO", "ERROR"]],
"expected": {"Sum": 13, "SampleCount": 3, "Minimum": 3, "Maximum": 7}
},
{
"name": "hit then miss in separate batches",
"batches": [["ERROR"], ["INFO"]],
"expected": {"Sum": 10, "SampleCount": 2, "Minimum": 3, "Maximum": 7}
},
{
"name": "miss then hit in separate batches",
"batches": [["INFO"], ["ERROR"]],
"expected": {"Sum": 10, "SampleCount": 2, "Minimum": 3, "Maximum": 7}
}
],
"qualification": "Each case used one isolated event-time minute. Results converged through repeated reads over approximately 107 seconds. No minute-wide suppression or default deduplication was observed."
},
"extraction": {
"filterPattern": "{ $.probe = \"value\" || $.value = * }",
"metricValue": "$.value",
"defaultValue": 7,
"cases": [
{
"name": "matching event with missing value",
"message": {"probe": "value", "case": "missing"},
"expected": {"Sum": 7, "SampleCount": 1}
},
{
"name": "matching event with null value",
"message": {"probe": "value", "value": null, "case": "null"},
"expected": {"Sum": 7, "SampleCount": 1}
},
{
"name": "matching event with nonnumeric string",
"message": {"probe": "value", "value": "not-a-number", "case": "nonnumeric"},
"expected": {"datapoints": []}
},
{
"name": "matching numeric control",
"message": {"probe": "value", "value": 3, "case": "numeric-control"},
"expected": {"Sum": 3, "SampleCount": 1}
}
],
"qualification": "Cases occupied distinct event-time minutes. Matching did not depend on the value field being present. The numeric control succeeded; negative results remained absent through the completed observation window."
},
"ordinaryDimensions": {
"filterPattern": "{ $.probe = \"dimension\" || $.a = * || $.b = * }",
"metricValue": "1",
"dimensions": {"A": "$.a", "B": "$.b"},
"messages": [
{"probe": "dimension", "a": "alpha", "b": "beta"},
{"probe": "dimension", "a": "alpha"},
{"probe": "dimension", "b": "beta"},
{"probe": "dimension"}
],
"expectedSeries": [
{"dimensions": {"A": "alpha", "B": "beta"}, "Sum": 1, "SampleCount": 1},
{"dimensions": {}, "Sum": 3, "SampleCount": 3}
],
"absentSeries": [
{"dimensions": {"A": "alpha"}},
{"dimensions": {"B": "beta"}}
],
"qualification": "No default configured. All events occupied one minute. ListMetrics confirmed complete and dimensionless series, not partial sets. Null or empty dimension values were not measured."
},
"systemDimensions": {
"filterPattern": "ERROR",
"metricValue": "3",
"defaultValue": 7,
"messages": ["ERROR", "INFO"],
"accountTransformerPolicyCount": 0,
"groupTransformerConfig": [],
"cases": [
{
"emitSystemFieldDimensions": ["@aws.account"],
"expectedSeries": [
{"dimensions": {"@aws.account": "<CALLER_ACCOUNT>"}, "Sum": 3, "SampleCount": 1},
{"dimensions": {}, "Sum": 7, "SampleCount": 1}
]
},
{
"emitSystemFieldDimensions": ["@aws.region"],
"expectedSeries": [
{"dimensions": {"@aws.region": "eu-west-1"}, "Sum": 3, "SampleCount": 1},
{"dimensions": {}, "Sum": 7, "SampleCount": 1}
]
},
{
"emitSystemFieldDimensions": ["@aws.account", "@aws.region"],
"expectedSeries": [
{"dimensions": {"@aws.account": "<CALLER_ACCOUNT>", "@aws.region": "eu-west-1"}, "Sum": 3, "SampleCount": 1},
{"dimensions": {}, "Sum": 7, "SampleCount": 1}
]
}
],
"qualification": "Regular noncentralized group. Matching events receive requested system dimensions; pattern-nonmatch defaults are dimensionless. This does not measure extraction-fallback defaults or centralized source provenance."
}
}
@@ -0,0 +1,234 @@
"""Opt-in stateful replay. Import and normal pytest execution never contact AWS."""
import argparse
from contextlib import contextmanager, ExitStack
from datetime import datetime, timedelta, timezone
import json
from pathlib import Path
import sys
import time
from urllib.parse import urlsplit
import uuid
import boto3
import botocore.session
from botocore.config import Config
FIXTURE = Path(__file__).resolve().parent / "fixtures/metric-filter-publishing-aws.json"
OWNER_TAG = "floci-metric-replay"
class ReplayError(Exception):
"""Only fixed, sanitized diagnostics belong in this exception."""
class SafeParser(argparse.ArgumentParser):
def error(self, message):
self.exit(2, "Invalid replay arguments; use --help. No replay was started.\n")
def parse_args(argv=None):
parser = SafeParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--aws", action="store_true", help="Write to real commercial-partition AWS")
mode.add_argument("--endpoint", help="Explicit loopback Floci endpoint, with dummy credentials")
parser.add_argument("--profile")
parser.add_argument("--region")
parser.add_argument("--ack-live-writes", choices=["I_ACCEPT_AWS_WRITES"])
parser.add_argument("--timeout", type=float, default=240, help="Maximum observation seconds")
parser.add_argument("--stable-seconds", type=float, default=30, help="Complete stable observation window")
parser.add_argument("--poll-interval", type=float, default=5)
args = parser.parse_args(argv)
if args.aws:
if not args.profile or not args.region or not args.ack_live_writes:
parser.error("AWS requires explicit profile, region and acknowledgement")
if args.region not in botocore.session.get_session().get_available_regions("logs", partition_name="aws"):
parser.error("Only known commercial AWS regions are supported")
if args.stable_seconds < 30 or args.timeout < 120:
parser.error("AWS observations require at least 120 seconds and a 30-second stable window")
else:
try:
endpoint = urlsplit(args.endpoint)
except ValueError:
parser.error("Invalid local endpoint")
if (endpoint.scheme not in ("http", "https")
or endpoint.hostname not in ("localhost", "127.0.0.1", "::1")
or endpoint.username or endpoint.password or endpoint.path not in ("", "/")
or endpoint.query or endpoint.fragment or args.profile or args.ack_live_writes):
parser.error("Local mode requires a credential-free loopback endpoint")
args.region = args.region or "eu-west-1"
if not (0 < args.poll_interval <= args.stable_seconds < args.timeout <= 900):
parser.error("Invalid bounded observation window")
return args
def clients(args, session=None):
session = session or (boto3.Session(profile_name=args.profile, region_name=args.region)
if args.aws else boto3.Session(region_name=args.region))
config = Config(ignore_configured_endpoint_urls=True, connect_timeout=5, read_timeout=15,
retries={"max_attempts": 2, "mode": "standard"})
result = []
for service, endpoint_prefix in (("logs", "logs"), ("cloudwatch", "monitoring"), ("sts", "sts")):
endpoint = f"https://{endpoint_prefix}.{args.region}.amazonaws.com" if args.aws else args.endpoint
credentials = {} if args.aws else {
"aws_access_key_id": "test", "aws_secret_access_key": "test", "aws_session_token": ""}
result.append(session.client(service, region_name=args.region, endpoint_url=endpoint,
config=config, **credentials))
return tuple(result)
@contextmanager
def owned_group(logs, group, token):
# A rejected create must never cause deletion of an existing resource.
logs.create_log_group(logGroupName=group, tags={OWNER_TAG: token})
try:
if logs.list_tags_log_group(logGroupName=group).get("tags", {}).get(OWNER_TAG) != token:
raise ReplayError("created group ownership could not be verified")
logs.create_log_stream(logGroupName=group, logStreamName="probe")
yield
finally:
try:
if logs.list_tags_log_group(logGroupName=group).get("tags", {}).get(OWNER_TAG) != token:
raise ReplayError("cleanup refused: group ownership could not be verified")
logs.delete_log_group(logGroupName=group)
except Exception:
raise ReplayError("cleanup failed or refused; inspect the owned run's log group") from None
def await_stable(read, timeout, stable_seconds, interval, monotonic=None, sleep=None):
monotonic, sleep = monotonic or time.monotonic, sleep or time.sleep
deadline = monotonic() + timeout
stable_since = None
while True:
complete = read()
now = monotonic()
if now > deadline:
raise ReplayError("observations did not converge within the bounded window")
stable_since = (now if stable_since is None else stable_since) if complete else None
if stable_since is not None and now - stable_since >= stable_seconds:
return
if now >= deadline:
raise ReplayError("observations did not converge within the bounded window")
sleep(min(interval, deadline - now))
def safe_error(error):
return str(error) if isinstance(error, ReplayError) else type(error).__name__
def scenarios(fixture, start, account, region):
cases = []
def add(name, definition, batches, expected, system_fields=None):
cases.append({"name": name, "minute": start + timedelta(minutes=len(cases)),
"definition": definition, "batches": batches,
"expected": expected, "system_fields": system_fields or []})
defaults = fixture["metricDefaults"]
for i, scenario in enumerate(defaults["cases"]):
add(f"default-{i}", defaults, scenario["batches"], [{"dimensions": {}, **scenario["expected"]}])
extraction = fixture["extraction"]
for i, scenario in enumerate(extraction["cases"]):
add(f"extraction-{i}", extraction, [[json.dumps(scenario["message"])]],
[{"dimensions": {}, **scenario["expected"]}])
ordinary = fixture["ordinaryDimensions"]
add("dimensions", ordinary, [[json.dumps(m) for m in ordinary["messages"]]],
ordinary["expectedSeries"] + [{"datapoints": [], **s} for s in ordinary["absentSeries"]])
system = fixture["systemDimensions"]
for i, scenario in enumerate(system["cases"]):
expected = []
for series in scenario["expectedSeries"]:
dimensions = {k: account if k == "@aws.account" else region for k in series["dimensions"]}
expected.append({**series, "dimensions": dimensions})
add(f"system-{i}", system, [system["messages"]], expected, scenario["emitSystemFieldDimensions"])
add("quiet", defaults, [["INFO", "INFO", "INFO"]], [{"dimensions": {}, "Sum": 21, "SampleCount": 3}])
return cases
def observe(cw, namespace, cases):
complete = True
observations = []
for case in cases:
for expected in case["expected"]:
dimensions = expected["dimensions"]
stats = [name for name in ("Sum", "SampleCount", "Minimum", "Maximum") if name in expected]
points = cw.get_metric_statistics(Namespace=namespace, MetricName=case["name"],
Dimensions=[{"Name": k, "Value": v} for k, v in dimensions.items()],
StartTime=case["minute"], EndTime=case["minute"] + timedelta(minutes=1),
Period=60, Statistics=stats or ["Sum", "SampleCount"])["Datapoints"]
present = "Sum" in expected
matches = len(points) == (1 if present else 0)
if matches and present:
matches = (points[0]["Timestamp"] == case["minute"]
and all(points[0].get(stat) == expected[stat] for stat in stats))
complete = complete and matches
observations.append({"case": case["name"], "timestamp": case["minute"].isoformat(),
"dimensions": {k: "<CALLER_ACCOUNT>" if k == "@aws.account" else v for k, v in dimensions.items()},
"datapoints": [{"timestamp": p["Timestamp"].isoformat(),
**{s: p[s] for s in ("Sum", "SampleCount", "Minimum", "Maximum") if s in p}}
for p in points]})
return complete, observations
def main(argv=None):
args = parse_args(argv)
token = uuid.uuid4().hex
group = "/floci-metric-replay/" + token
namespace = "FlociMetricReplay/" + token
print(json.dumps({"mode": "AWS LIVE WRITES" if args.aws else "local Floci only",
"group": group, "namespace": namespace,
"warning": "Metric series cannot be deleted. They remain until the service ages them out."}))
try:
with ExitStack() as stack:
logs, cw, sts = clients(args)
for client in (logs, cw, sts):
stack.callback(client.close)
account = sts.get_caller_identity()["Account"]
start = datetime.now(timezone.utc).replace(second=0, microsecond=0) - timedelta(minutes=40)
cases = scenarios(json.loads(FIXTURE.read_text()), start, account, args.region)
observations = []
with owned_group(logs, group, token):
for case in cases:
definition = case["definition"]
transformation = {"metricNamespace": namespace, "metricName": case["name"],
"metricValue": definition["metricValue"], "unit": "Count"}
for field in ("defaultValue", "dimensions"):
if field in definition:
transformation[field] = definition[field]
request = {"logGroupName": group, "filterName": "probe",
"filterPattern": definition["filterPattern"],
"metricTransformations": [transformation]}
if case["system_fields"]:
request["emitSystemFieldDimensions"] = case["system_fields"]
logs.put_metric_filter(**request)
for batch in case["batches"]:
response = logs.put_log_events(logGroupName=group, logStreamName="probe",
logEvents=[{"timestamp": int(case["minute"].timestamp() * 1000) + i, "message": message}
for i, message in enumerate(batch)])
if response.get("rejectedLogEventsInfo"):
raise ReplayError("replay log events were rejected")
checks = cases + [{"name": "quiet", "minute": cases[-1]["minute"] + timedelta(minutes=1),
"expected": [{"dimensions": {}, "datapoints": []}]}]
def read():
nonlocal observations
complete, observations = observe(cw, namespace, checks)
return complete
try:
await_stable(read, args.timeout, args.stable_seconds, args.poll_interval)
finally:
print(json.dumps({"observations": observations}))
print(json.dumps({"result": "converged", "cleanup": "owned log group deleted",
"qualification": "Local results are not live AWS verification."
if not args.aws else "Only these replayed scenarios were observed."}))
return 0
except Exception as error:
print(json.dumps({"result": "failed", "error": safe_error(error),
"cleanup_hint": group}), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,361 @@
"""ACM integration tests."""
import datetime
import re
import pytest
from botocore.exceptions import ClientError
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.x509.oid import NameOID
FAKE_ARN = "arn:aws:acm:us-east-1:000000000000:certificate/00000000-0000-0000-0000-000000000000"
class TestACMCertificateLifecycle:
"""Test ACM certificate lifecycle operations."""
def test_request_certificate(self, acm_client):
"""Test RequestCertificate creates a certificate and returns a valid ARN."""
response = acm_client.request_certificate(DomainName="test.example.com")
arn = response["CertificateArn"]
try:
assert arn
assert re.match(r"arn:aws:acm:.*:.*:certificate/.*", arn)
finally:
acm_client.delete_certificate(CertificateArn=arn)
@pytest.mark.parametrize(
"key_algorithm,key_type",
[("RSA_2048", rsa.RSAPublicKey), ("EC_prime256v1", ec.EllipticCurvePublicKey)],
)
def test_request_certificate_key_algorithms(
self, acm_client, key_algorithm, key_type
):
"""Test RequestCertificate issues a parseable certificate for each key algorithm."""
response = acm_client.request_certificate(
DomainName=f"{key_algorithm.lower().replace('_', '-')}.example.com",
KeyAlgorithm=key_algorithm,
)
arn = response["CertificateArn"]
try:
response = acm_client.get_certificate(CertificateArn=arn)
certificate = x509.load_pem_x509_certificate(response["Certificate"].encode())
assert isinstance(certificate.public_key(), key_type)
# As on AWS, the certificate is signed by the issuing CA, returned first in
# CertificateChain, not by its own key: check that signature, not just the PEM.
issuer = x509.load_pem_x509_certificate(response["CertificateChain"].encode())
assert certificate.issuer == issuer.subject
certificate.verify_directly_issued_by(issuer)
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_describe_certificate(self, acm_client):
"""Test DescribeCertificate returns certificate details."""
response = acm_client.request_certificate(DomainName="describe.example.com")
arn = response["CertificateArn"]
try:
response = acm_client.describe_certificate(CertificateArn=arn)
cert = response["Certificate"]
assert cert["DomainName"] == "describe.example.com"
assert cert["Status"] == "ISSUED"
assert cert["Type"] == "AMAZON_ISSUED"
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_get_certificate(self, acm_client):
"""Test GetCertificate returns certificate body in PEM format."""
response = acm_client.request_certificate(DomainName="get.example.com")
arn = response["CertificateArn"]
try:
response = acm_client.get_certificate(CertificateArn=arn)
assert response["Certificate"]
assert "BEGIN CERTIFICATE" in response["Certificate"]
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_list_certificates(self, acm_client):
"""Test ListCertificates includes created certificate."""
response = acm_client.request_certificate(DomainName="list.example.com")
arn = response["CertificateArn"]
try:
response = acm_client.list_certificates()
arns = [
c["CertificateArn"]
for c in response["CertificateSummaryList"]
]
assert arn in arns
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_delete_certificate(self, acm_client):
"""Test DeleteCertificate removes certificate."""
response = acm_client.request_certificate(DomainName="delete.example.com")
arn = response["CertificateArn"]
acm_client.delete_certificate(CertificateArn=arn)
with pytest.raises(ClientError) as exc_info:
acm_client.describe_certificate(CertificateArn=arn)
assert "ResourceNotFoundException" in str(exc_info.value)
class TestACMImportExport:
"""Test ACM import and export operations."""
@staticmethod
def _generate_self_signed_cert():
"""Generate a self-signed certificate and private key.
Returns:
Tuple of (cert_pem_bytes, key_pem_bytes).
"""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name(
[x509.NameAttribute(NameOID.COMMON_NAME, "test.example.com")]
)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.now(datetime.timezone.utc))
.not_valid_after(
datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=365)
)
.sign(key, hashes.SHA256())
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
key_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
return cert_pem, key_pem
def test_import_certificate(self, acm_client):
"""Test ImportCertificate with self-signed cert returns ARN."""
cert_pem, key_pem = self._generate_self_signed_cert()
response = acm_client.import_certificate(
Certificate=cert_pem, PrivateKey=key_pem
)
arn = response["CertificateArn"]
try:
assert arn
assert re.match(r"arn:aws:acm:.*:.*:certificate/.*", arn)
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_import_certificate_with_chain(self, acm_client):
"""Test ImportCertificate with certificate chain returns ARN."""
cert_pem, key_pem = self._generate_self_signed_cert()
response = acm_client.import_certificate(
Certificate=cert_pem,
PrivateKey=key_pem,
CertificateChain=cert_pem,
)
arn = response["CertificateArn"]
try:
assert arn
assert re.match(r"arn:aws:acm:.*:.*:certificate/.*", arn)
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_get_imported_certificate(self, acm_client):
"""Test GetCertificate on imported cert returns matching body."""
cert_pem, key_pem = self._generate_self_signed_cert()
import_response = acm_client.import_certificate(
Certificate=cert_pem, PrivateKey=key_pem
)
arn = import_response["CertificateArn"]
try:
response = acm_client.get_certificate(CertificateArn=arn)
assert response["Certificate"]
assert "BEGIN CERTIFICATE" in response["Certificate"]
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_export_certificate(self, acm_client):
"""Test ExportCertificate returns the imported key, encrypted under the passphrase."""
cert_pem, key_pem = self._generate_self_signed_cert()
passphrase = b"test-passphrase"
import_response = acm_client.import_certificate(
Certificate=cert_pem, PrivateKey=key_pem
)
arn = import_response["CertificateArn"]
try:
response = acm_client.export_certificate(
CertificateArn=arn, Passphrase=passphrase
)
assert response["Certificate"]
assert response["PrivateKey"]
# Asserting on the PEM header alone would pass for a key no client can
# open. Decrypt it with the passphrase and check it is the key that was
# imported, which is the whole point of the export.
exported_key = serialization.load_pem_private_key(
response["PrivateKey"].encode(), password=passphrase
)
imported_key = serialization.load_pem_private_key(key_pem, password=None)
assert (
exported_key.private_numbers() == imported_key.private_numbers()
)
# The passphrase has to actually be doing the work.
with pytest.raises(ValueError):
serialization.load_pem_private_key(
response["PrivateKey"].encode(), password=b"wrong-passphrase"
)
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_export_requested_certificate_fails(self, acm_client):
"""Test ExportCertificate on a requested (non-imported) cert raises error."""
response = acm_client.request_certificate(DomainName="export.example.com")
arn = response["CertificateArn"]
try:
with pytest.raises(ClientError):
acm_client.export_certificate(
CertificateArn=arn, Passphrase=b"test-passphrase"
)
finally:
acm_client.delete_certificate(CertificateArn=arn)
class TestACMTagging:
"""Test ACM tagging operations."""
def test_add_tags(self, acm_client):
"""Test AddTagsToCertificate and ListTagsForCertificate."""
response = acm_client.request_certificate(DomainName="tags.example.com")
arn = response["CertificateArn"]
try:
acm_client.add_tags_to_certificate(
CertificateArn=arn,
Tags=[
{"Key": "Env", "Value": "test"},
{"Key": "Project", "Value": "floci"},
],
)
response = acm_client.list_tags_for_certificate(CertificateArn=arn)
tags = response["Tags"]
tag_map = {t["Key"]: t["Value"] for t in tags}
assert tag_map["Env"] == "test"
assert tag_map["Project"] == "floci"
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_remove_tags(self, acm_client):
"""Test RemoveTagsFromCertificate removes specified tags."""
response = acm_client.request_certificate(DomainName="rmtags.example.com")
arn = response["CertificateArn"]
try:
acm_client.add_tags_to_certificate(
CertificateArn=arn,
Tags=[
{"Key": "Env", "Value": "test"},
{"Key": "Project", "Value": "floci"},
],
)
acm_client.remove_tags_from_certificate(
CertificateArn=arn,
Tags=[{"Key": "Env", "Value": "test"}],
)
response = acm_client.list_tags_for_certificate(CertificateArn=arn)
tags = response.get("Tags", [])
tag_keys = [t["Key"] for t in tags]
assert "Env" not in tag_keys
assert "Project" in tag_keys
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_list_tags_empty(self, acm_client):
"""Test ListTagsForCertificate on fresh cert returns empty tags."""
response = acm_client.request_certificate(DomainName="notags.example.com")
arn = response["CertificateArn"]
try:
response = acm_client.list_tags_for_certificate(CertificateArn=arn)
tags = response.get("Tags", [])
assert len(tags) == 0
finally:
acm_client.delete_certificate(CertificateArn=arn)
class TestACMAccountConfiguration:
"""Test ACM account configuration operations."""
def test_put_and_get_account_configuration(self, acm_client, unique_name):
"""Test PutAccountConfiguration and GetAccountConfiguration."""
acm_client.put_account_configuration(
ExpiryEvents={"DaysBeforeExpiry": 45},
IdempotencyToken=unique_name,
)
response = acm_client.get_account_configuration()
assert response["ExpiryEvents"]["DaysBeforeExpiry"] == 45
class TestACMErrorHandling:
"""Test ACM error handling."""
def test_describe_nonexistent_certificate(self, acm_client):
"""Test DescribeCertificate with fake ARN raises error."""
with pytest.raises(ClientError):
acm_client.describe_certificate(CertificateArn=FAKE_ARN)
def test_delete_nonexistent_certificate(self, acm_client):
"""Test DeleteCertificate with fake ARN raises error."""
with pytest.raises(ClientError):
acm_client.delete_certificate(CertificateArn=FAKE_ARN)
def test_request_certificate_with_sans(self, acm_client):
"""Test RequestCertificate with SubjectAlternativeNames."""
response = acm_client.request_certificate(
DomainName="sans.example.com",
SubjectAlternativeNames=[
"sans.example.com",
"www.sans.example.com",
"api.sans.example.com",
],
)
arn = response["CertificateArn"]
try:
response = acm_client.describe_certificate(CertificateArn=arn)
sans = response["Certificate"]["SubjectAlternativeNames"]
assert "sans.example.com" in sans
assert "www.sans.example.com" in sans
assert "api.sans.example.com" in sans
finally:
acm_client.delete_certificate(CertificateArn=arn)
def test_import_invalid_pem(self, acm_client):
"""Test ImportCertificate with invalid PEM raises error."""
with pytest.raises(ClientError):
acm_client.import_certificate(
Certificate=b"not-a-valid-certificate",
PrivateKey=b"not-a-valid-key",
)
@@ -0,0 +1,385 @@
"""CloudFormation resource naming compatibility tests.
Tests that CloudFormation correctly handles:
- Auto-generated resource names (when no explicit name is provided)
- Explicit resource names (when specified in Properties)
- Cross-stack references using intrinsic functions
"""
import json
import logging
import time
logger = logging.getLogger(__name__)
import pytest
def wait_for_stack_terminal_state(cfn_client, stack_name, timeout=60):
"""Wait for stack to reach a terminal state.
Returns (success: bool, status: str).
"""
success_states = {"CREATE_COMPLETE", "UPDATE_COMPLETE"}
failure_states = {
"CREATE_FAILED",
"ROLLBACK_IN_PROGRESS",
"ROLLBACK_FAILED",
"ROLLBACK_COMPLETE",
"DELETE_FAILED",
"DELETE_COMPLETE",
}
start = time.time()
while time.time() - start < timeout:
resp = cfn_client.describe_stacks(StackName=stack_name)
status = resp.get("Stacks", [{}])[0].get("StackStatus", "")
if status in success_states:
return True, status
if status in failure_states:
return False, status
time.sleep(1)
return False, "TIMEOUT"
def get_physical_id(resources, logical_id):
"""Get PhysicalResourceId for a given LogicalResourceId."""
for resource in resources:
if resource.get("LogicalResourceId") == logical_id:
return resource.get("PhysicalResourceId")
return None
class TestCloudFormationAutoNaming:
"""Test CloudFormation auto-generated resource names."""
@pytest.fixture
def auto_naming_stack(self, cloudformation_client, unique_name):
"""Create a stack with auto-generated resource names."""
stack_name = f"cfn-auto-naming-{unique_name}"
template = {
"Resources": {
"AutoBucket": {"Type": "AWS::S3::Bucket"},
"AutoQueue": {"Type": "AWS::SQS::Queue"},
"AutoTopic": {"Type": "AWS::SNS::Topic"},
"AutoParameter": {
"Type": "AWS::SSM::Parameter",
"Properties": {"Type": "String", "Value": "v1"},
},
"CrossRefQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": {"Fn::Sub": "${AutoBucket}-cross"}},
},
}
}
cloudformation_client.create_stack(
StackName=stack_name,
TemplateBody=json.dumps(template),
)
yield stack_name
# Cleanup
try:
cloudformation_client.delete_stack(StackName=stack_name)
except Exception as e:
logger.warning("Failed to clean up CloudFormation stack %s: %s", stack_name, e)
def test_auto_naming_create_stack(self, cloudformation_client, auto_naming_stack):
"""Test CreateStack succeeds with auto-named resources."""
ok, status = wait_for_stack_terminal_state(
cloudformation_client, auto_naming_stack
)
assert ok, f"Stack creation failed with status: {status}"
def test_auto_naming_describe_stack_resources(
self, cloudformation_client, auto_naming_stack
):
"""Test DescribeStackResources returns created resources."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
assert len(resources) > 0, "No resources found in stack"
def test_auto_naming_s3_bucket_generated(
self, cloudformation_client, auto_naming_stack
):
"""Test S3 bucket gets auto-generated name."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
bucket_name = get_physical_id(resources, "AutoBucket")
assert bucket_name, "S3 bucket physical ID not found"
def test_auto_naming_s3_bucket_constraints(
self, cloudformation_client, auto_naming_stack
):
"""Test S3 bucket name follows naming constraints."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
bucket_name = get_physical_id(resources, "AutoBucket")
assert bucket_name, "S3 bucket physical ID not found"
# S3 bucket naming constraints
assert 3 <= len(bucket_name) <= 63, f"Bucket name length invalid: {len(bucket_name)}"
assert bucket_name == bucket_name.lower(), "Bucket name must be lowercase"
assert all(
ch.islower() or ch.isdigit() or ch in ".-" for ch in bucket_name
), "Bucket name contains invalid characters"
def test_auto_naming_sqs_queue_generated(
self, cloudformation_client, auto_naming_stack
):
"""Test SQS queue gets auto-generated name."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
queue_url = get_physical_id(resources, "AutoQueue")
assert queue_url, "SQS queue physical ID not found"
def test_auto_naming_sqs_queue_constraints(
self, cloudformation_client, auto_naming_stack
):
"""Test SQS queue name follows naming constraints."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
queue_url = get_physical_id(resources, "AutoQueue")
assert queue_url, "SQS queue physical ID not found"
# Extract queue name from URL
queue_name = queue_url.rsplit("/", 1)[-1]
assert 0 < len(queue_name) <= 80, f"Queue name length invalid: {len(queue_name)}"
def test_auto_naming_sns_topic_generated(
self, cloudformation_client, auto_naming_stack
):
"""Test SNS topic gets auto-generated name."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
topic_arn = get_physical_id(resources, "AutoTopic")
assert topic_arn, "SNS topic physical ID not found"
def test_auto_naming_sns_topic_constraints(
self, cloudformation_client, auto_naming_stack
):
"""Test SNS topic name follows naming constraints."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
topic_arn = get_physical_id(resources, "AutoTopic")
assert topic_arn, "SNS topic physical ID not found"
# Extract topic name from ARN
topic_name = topic_arn.rsplit(":", 1)[-1]
assert 0 < len(topic_name) <= 256, f"Topic name length invalid: {len(topic_name)}"
def test_auto_naming_ssm_parameter_generated(
self, cloudformation_client, auto_naming_stack
):
"""Test SSM parameter gets auto-generated name."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
param_name = get_physical_id(resources, "AutoParameter")
assert param_name, "SSM parameter physical ID not found"
def test_auto_naming_ssm_parameter_constraints(
self, cloudformation_client, auto_naming_stack
):
"""Test SSM parameter name follows naming constraints."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
param_name = get_physical_id(resources, "AutoParameter")
assert param_name, "SSM parameter physical ID not found"
assert len(param_name) <= 2048, f"Parameter name too long: {len(param_name)}"
def test_auto_naming_cross_reference_queue_generated(
self, cloudformation_client, auto_naming_stack
):
"""Test cross-reference queue gets generated with Fn::Sub."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
cross_queue = get_physical_id(resources, "CrossRefQueue")
assert cross_queue, "Cross-reference queue physical ID not found"
def test_auto_naming_cross_reference_uses_bucket_name(
self, cloudformation_client, auto_naming_stack
):
"""Test cross-reference queue name includes auto-generated bucket name."""
wait_for_stack_terminal_state(cloudformation_client, auto_naming_stack)
resources = cloudformation_client.describe_stack_resources(
StackName=auto_naming_stack
).get("StackResources", [])
bucket_name = get_physical_id(resources, "AutoBucket")
cross_queue = get_physical_id(resources, "CrossRefQueue")
assert bucket_name and cross_queue, "Missing resource physical IDs"
cross_queue_name = cross_queue.rsplit("/", 1)[-1]
assert cross_queue_name.startswith(
f"{bucket_name}-cross"
), f"Cross-ref queue '{cross_queue_name}' should start with '{bucket_name}-cross'"
class TestCloudFormationExplicitNaming:
"""Test CloudFormation explicit resource names."""
@pytest.fixture
def explicit_naming_stack(self, cloudformation_client, unique_name):
"""Create a stack with explicit resource names."""
stack_name = f"cfn-explicit-naming-{unique_name}"
explicit_bucket = f"cfn-explicit-{unique_name}"
explicit_queue = f"cfn-explicit-{unique_name}"
explicit_topic = f"cfn-explicit-{unique_name}"
explicit_param = f"/cfn-explicit/{unique_name}"
template = {
"Resources": {
"NamedBucket": {
"Type": "AWS::S3::Bucket",
"Properties": {"BucketName": explicit_bucket},
},
"NamedQueue": {
"Type": "AWS::SQS::Queue",
"Properties": {"QueueName": explicit_queue},
},
"NamedTopic": {
"Type": "AWS::SNS::Topic",
"Properties": {"TopicName": explicit_topic},
},
"NamedParameter": {
"Type": "AWS::SSM::Parameter",
"Properties": {
"Name": explicit_param,
"Type": "String",
"Value": "explicit",
},
},
}
}
cloudformation_client.create_stack(
StackName=stack_name,
TemplateBody=json.dumps(template),
)
yield {
"stack_name": stack_name,
"expected_bucket": explicit_bucket,
"expected_queue": explicit_queue,
"expected_topic": explicit_topic,
"expected_param": explicit_param,
}
# Cleanup
try:
cloudformation_client.delete_stack(StackName=stack_name)
except Exception as e:
logger.warning("Failed to clean up CloudFormation stack %s: %s", stack_name, e)
def test_explicit_naming_create_stack(
self, cloudformation_client, explicit_naming_stack
):
"""Test CreateStack succeeds with explicit resource names."""
ok, status = wait_for_stack_terminal_state(
cloudformation_client, explicit_naming_stack["stack_name"]
)
assert ok, f"Stack creation failed with status: {status}"
def test_explicit_naming_s3_bucket(
self, cloudformation_client, explicit_naming_stack
):
"""Test S3 bucket uses explicit name."""
wait_for_stack_terminal_state(
cloudformation_client, explicit_naming_stack["stack_name"]
)
resources = cloudformation_client.describe_stack_resources(
StackName=explicit_naming_stack["stack_name"]
).get("StackResources", [])
actual_bucket = get_physical_id(resources, "NamedBucket")
assert actual_bucket == explicit_naming_stack["expected_bucket"]
def test_explicit_naming_sqs_queue(
self, cloudformation_client, explicit_naming_stack
):
"""Test SQS queue uses explicit name."""
wait_for_stack_terminal_state(
cloudformation_client, explicit_naming_stack["stack_name"]
)
resources = cloudformation_client.describe_stack_resources(
StackName=explicit_naming_stack["stack_name"]
).get("StackResources", [])
actual_queue = get_physical_id(resources, "NamedQueue")
assert actual_queue and explicit_naming_stack["expected_queue"] in actual_queue
def test_explicit_naming_sns_topic(
self, cloudformation_client, explicit_naming_stack
):
"""Test SNS topic uses explicit name."""
wait_for_stack_terminal_state(
cloudformation_client, explicit_naming_stack["stack_name"]
)
resources = cloudformation_client.describe_stack_resources(
StackName=explicit_naming_stack["stack_name"]
).get("StackResources", [])
actual_topic = get_physical_id(resources, "NamedTopic")
assert actual_topic and explicit_naming_stack["expected_topic"] in actual_topic
def test_explicit_naming_ssm_parameter(
self, cloudformation_client, explicit_naming_stack
):
"""Test SSM parameter uses explicit name."""
wait_for_stack_terminal_state(
cloudformation_client, explicit_naming_stack["stack_name"]
)
resources = cloudformation_client.describe_stack_resources(
StackName=explicit_naming_stack["stack_name"]
).get("StackResources", [])
actual_param = get_physical_id(resources, "NamedParameter")
assert actual_param == explicit_naming_stack["expected_param"]
@@ -0,0 +1,254 @@
"""CloudWatch Metrics integration tests."""
import datetime
import pytest
class TestCloudWatchMetrics:
"""Test CloudWatch metrics operations."""
def test_put_metric_data(self, cloudwatch_client, unique_name):
"""Test PutMetricData writes metric."""
namespace = f"TestApp/PytestMetrics/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
cloudwatch_client.put_metric_data(
Namespace=namespace,
MetricData=[
{
"MetricName": "RequestCount",
"Value": 42.0,
"Unit": "Count",
"Timestamp": now,
}
],
)
# If no exception, test passes
def test_put_metric_data_with_dimensions(self, cloudwatch_client, unique_name):
"""Test PutMetricData with dimensions."""
namespace = f"TestApp/PytestMetrics/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
cloudwatch_client.put_metric_data(
Namespace=namespace,
MetricData=[
{
"MetricName": "Latency",
"Value": 125.5,
"Unit": "Milliseconds",
"Timestamp": now,
"Dimensions": [{"Name": "Host", "Value": "web01"}],
}
],
)
# If no exception, test passes
def test_list_metrics(self, cloudwatch_client, unique_name):
"""Test ListMetrics returns written metrics."""
namespace = f"TestApp/PytestMetrics/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
cloudwatch_client.put_metric_data(
Namespace=namespace,
MetricData=[
{"MetricName": "RequestCount", "Value": 42.0, "Unit": "Count", "Timestamp": now},
{"MetricName": "Latency", "Value": 125.5, "Unit": "Milliseconds", "Timestamp": now},
],
)
response = cloudwatch_client.list_metrics(Namespace=namespace)
has_rc = any(m["MetricName"] == "RequestCount" for m in response["Metrics"])
has_lat = any(m["MetricName"] == "Latency" for m in response["Metrics"])
assert has_rc and has_lat
def test_list_metrics_namespace_isolation(self, cloudwatch_client, unique_name):
"""Test ListMetrics respects namespace isolation."""
namespace_a = f"TestApp/PytestMetricsA/{unique_name}"
namespace_b = f"TestApp/PytestMetricsB/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
cloudwatch_client.put_metric_data(
Namespace=namespace_a,
MetricData=[{"MetricName": "MetricA", "Value": 1.0, "Unit": "Count", "Timestamp": now}],
)
cloudwatch_client.put_metric_data(
Namespace=namespace_b,
MetricData=[{"MetricName": "MetricB", "Value": 1.0, "Unit": "Count", "Timestamp": now}],
)
response_a = cloudwatch_client.list_metrics(Namespace=namespace_a)
response_b = cloudwatch_client.list_metrics(Namespace=namespace_b)
no_a_in_b = all(m["Namespace"] != namespace_a for m in response_b["Metrics"])
no_b_in_a = all(m["Namespace"] != namespace_b for m in response_a["Metrics"])
assert no_a_in_b and no_b_in_a
def test_get_metric_statistics(self, cloudwatch_client, unique_name):
"""Test GetMetricStatistics returns aggregated data."""
namespace = f"TestApp/PytestMetrics/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
# Put 5 data points
data_points = [
{
"MetricName": "CPUUtil",
"Value": v,
"Unit": "Percent",
"Timestamp": now - datetime.timedelta(seconds=s),
}
for v, s in [(10.0, 250), (20.0, 200), (30.0, 150), (40.0, 100), (50.0, 50)]
]
cloudwatch_client.put_metric_data(Namespace=namespace, MetricData=data_points)
response = cloudwatch_client.get_metric_statistics(
Namespace=namespace,
MetricName="CPUUtil",
StartTime=now - datetime.timedelta(hours=1),
EndTime=now + datetime.timedelta(seconds=60),
Period=3600,
Statistics=["Sum", "SampleCount"],
)
assert len(response["Datapoints"]) > 0
total_sum = sum(dp["Sum"] for dp in response["Datapoints"])
total_sc = sum(dp["SampleCount"] for dp in response["Datapoints"])
assert abs(total_sum - 150.0) < 0.001
assert abs(total_sc - 5.0) < 0.001
def test_put_metric_data_with_statistic_values(self, cloudwatch_client, unique_name):
"""Test PutMetricData with pre-calculated StatisticValues."""
namespace = f"TestApp/StatisticValues/{unique_name}"
now = datetime.datetime.now(datetime.timezone.utc)
# Put metric data with pre-calculated statistics
cloudwatch_client.put_metric_data(
Namespace=namespace,
MetricData=[
{
"MetricName": "AggregatedMetric",
"StatisticValues": {
"SampleCount": 5,
"Sum": 150.0,
"Minimum": 20.0,
"Maximum": 40.0,
},
"Unit": "Count",
}
],
)
# Query back the statistics
response = cloudwatch_client.get_metric_statistics(
Namespace=namespace,
MetricName="AggregatedMetric",
StartTime=now - datetime.timedelta(hours=1),
EndTime=now + datetime.timedelta(seconds=60),
Period=3600,
Statistics=["Sum", "Average", "Minimum", "Maximum", "SampleCount"],
)
assert response["Datapoints"]
dp = response["Datapoints"][0]
assert dp["Sum"] == 150.0
assert dp["SampleCount"] == 5
assert dp["Minimum"] == 20.0
assert dp["Maximum"] == 40.0
assert dp["Average"] == 30.0 # sum / sampleCount
class TestCloudWatchAlarms:
"""Test CloudWatch alarm operations."""
def test_put_metric_alarm(self, cloudwatch_client, unique_name):
"""Test PutMetricAlarm creates an alarm."""
alarm_name = f"pytest-alarm-{unique_name}"
try:
cloudwatch_client.put_metric_alarm(
AlarmName=alarm_name,
MetricName="CPUUtilization",
Namespace="AWS/EC2",
Statistic="Average",
Period=60,
Threshold=80.0,
ComparisonOperator="GreaterThanThreshold",
EvaluationPeriods=1,
AlarmActions=["arn:aws:sns:us-east-1:000000000000:my-topic"],
)
# If no exception, test passes
finally:
cloudwatch_client.delete_alarms(AlarmNames=[alarm_name])
def test_describe_alarms(self, cloudwatch_client, unique_name):
"""Test DescribeAlarms returns alarm details."""
alarm_name = f"pytest-alarm-{unique_name}"
cloudwatch_client.put_metric_alarm(
AlarmName=alarm_name,
MetricName="CPUUtilization",
Namespace="AWS/EC2",
Statistic="Average",
Period=60,
Threshold=80.0,
ComparisonOperator="GreaterThanThreshold",
EvaluationPeriods=1,
)
try:
response = cloudwatch_client.describe_alarms(AlarmNames=[alarm_name])
alarms = response["MetricAlarms"]
assert any(a["AlarmName"] == alarm_name for a in alarms)
alarm = next(a for a in alarms if a["AlarmName"] == alarm_name)
assert alarm["StateValue"] == "INSUFFICIENT_DATA"
finally:
cloudwatch_client.delete_alarms(AlarmNames=[alarm_name])
def test_set_alarm_state(self, cloudwatch_client, unique_name):
"""Test SetAlarmState changes alarm state."""
alarm_name = f"pytest-alarm-{unique_name}"
cloudwatch_client.put_metric_alarm(
AlarmName=alarm_name,
MetricName="CPUUtilization",
Namespace="AWS/EC2",
Statistic="Average",
Period=60,
Threshold=80.0,
ComparisonOperator="GreaterThanThreshold",
EvaluationPeriods=1,
)
try:
cloudwatch_client.set_alarm_state(
AlarmName=alarm_name,
StateValue="ALARM",
StateReason="Threshold breached",
)
response = cloudwatch_client.describe_alarms(AlarmNames=[alarm_name])
assert response["MetricAlarms"][0]["StateValue"] == "ALARM"
finally:
cloudwatch_client.delete_alarms(AlarmNames=[alarm_name])
def test_delete_alarms(self, cloudwatch_client, unique_name):
"""Test DeleteAlarms removes alarms."""
alarm_name = f"pytest-alarm-{unique_name}"
cloudwatch_client.put_metric_alarm(
AlarmName=alarm_name,
MetricName="CPUUtilization",
Namespace="AWS/EC2",
Statistic="Average",
Period=60,
Threshold=80.0,
ComparisonOperator="GreaterThanThreshold",
EvaluationPeriods=1,
)
cloudwatch_client.delete_alarms(AlarmNames=[alarm_name])
response = cloudwatch_client.describe_alarms(AlarmNames=[alarm_name])
assert len(response["MetricAlarms"]) == 0
@@ -0,0 +1,501 @@
"""Cognito Identity Provider integration tests."""
import pytest
class TestCognitoUserPool:
"""Test Cognito user pool operations."""
def test_create_user_pool(self, cognito_client, unique_name):
"""Test CreateUserPool creates a pool."""
pool_name = f"pytest-pool-{unique_name}"
try:
response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = response["UserPool"]["Id"]
assert pool_id
finally:
cognito_client.delete_user_pool(UserPoolId=response["UserPool"]["Id"])
def test_delete_user_pool(self, cognito_client, unique_name):
"""Test DeleteUserPool removes pool."""
pool_name = f"pytest-pool-{unique_name}"
response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = response["UserPool"]["Id"]
cognito_client.delete_user_pool(UserPoolId=pool_id)
# If no exception, test passes
class TestCognitoUserPoolClient:
"""Test Cognito user pool client operations."""
def test_create_user_pool_client(self, cognito_client, unique_name):
"""Test CreateUserPoolClient creates a client."""
pool_name = f"pytest-pool-{unique_name}"
client_name = f"pytest-client-{unique_name}"
response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = response["UserPool"]["Id"]
try:
response = cognito_client.create_user_pool_client(
UserPoolId=pool_id, ClientName=client_name
)
client_id = response["UserPoolClient"]["ClientId"]
assert client_id
finally:
cognito_client.delete_user_pool(UserPoolId=pool_id)
def test_delete_user_pool_client(self, cognito_client, unique_name):
"""Test DeleteUserPoolClient removes client."""
pool_name = f"pytest-pool-{unique_name}"
client_name = f"pytest-client-{unique_name}"
pool_response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = pool_response["UserPool"]["Id"]
client_response = cognito_client.create_user_pool_client(
UserPoolId=pool_id, ClientName=client_name
)
client_id = client_response["UserPoolClient"]["ClientId"]
try:
cognito_client.delete_user_pool_client(
UserPoolId=pool_id, ClientId=client_id
)
# If no exception, test passes
finally:
cognito_client.delete_user_pool(UserPoolId=pool_id)
class TestCognitoUser:
"""Test Cognito user operations."""
def test_admin_create_user(self, cognito_client, unique_name):
"""Test AdminCreateUser creates a user."""
pool_name = f"pytest-pool-{unique_name}"
username = f"pytest-user-{unique_name}"
pool_response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = pool_response["UserPool"]["Id"]
try:
response = cognito_client.admin_create_user(
UserPoolId=pool_id,
Username=username,
UserAttributes=[{"Name": "email", "Value": "pytest@example.com"}],
)
assert response["User"]["Username"] == username
finally:
cognito_client.admin_delete_user(UserPoolId=pool_id, Username=username)
cognito_client.delete_user_pool(UserPoolId=pool_id)
def test_admin_delete_user(self, cognito_client, unique_name):
"""Test AdminDeleteUser removes user."""
pool_name = f"pytest-pool-{unique_name}"
username = f"pytest-user-{unique_name}"
pool_response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = pool_response["UserPool"]["Id"]
cognito_client.admin_create_user(
UserPoolId=pool_id,
Username=username,
UserAttributes=[{"Name": "email", "Value": "pytest@example.com"}],
)
try:
cognito_client.admin_delete_user(UserPoolId=pool_id, Username=username)
# If no exception, test passes
finally:
cognito_client.delete_user_pool(UserPoolId=pool_id)
class TestCognitoAuth:
"""Test Cognito authentication operations."""
def test_admin_initiate_auth(self, cognito_client, unique_name):
"""Test AdminInitiateAuth returns tokens."""
pool_name = f"pytest-pool-{unique_name}"
client_name = f"pytest-client-{unique_name}"
username = f"pytest-user-{unique_name}"
pool_response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = pool_response["UserPool"]["Id"]
client_response = cognito_client.create_user_pool_client(
UserPoolId=pool_id,
ClientName=client_name,
ExplicitAuthFlows=["ALLOW_ADMIN_USER_PASSWORD_AUTH"],
)
client_id = client_response["UserPoolClient"]["ClientId"]
cognito_client.admin_create_user(
UserPoolId=pool_id,
Username=username,
UserAttributes=[{"Name": "email", "Value": "pytest@example.com"}],
)
cognito_client.admin_set_user_password(
UserPoolId=pool_id, Username=username, Password="Pytest#1234", Permanent=True
)
try:
response = cognito_client.admin_initiate_auth(
UserPoolId=pool_id,
ClientId=client_id,
AuthFlow="ADMIN_NO_SRP_AUTH",
AuthParameters={"USERNAME": username, "PASSWORD": "Pytest#1234"},
)
access_token = response["AuthenticationResult"]["AccessToken"]
assert access_token
finally:
cognito_client.admin_delete_user(UserPoolId=pool_id, Username=username)
cognito_client.delete_user_pool_client(
UserPoolId=pool_id, ClientId=client_id
)
cognito_client.delete_user_pool(UserPoolId=pool_id)
def test_get_user(self, cognito_client, unique_name):
"""Test GetUser returns user details from access token."""
pool_name = f"pytest-pool-{unique_name}"
client_name = f"pytest-client-{unique_name}"
username = f"pytest-user-{unique_name}"
pool_response = cognito_client.create_user_pool(PoolName=pool_name)
pool_id = pool_response["UserPool"]["Id"]
client_response = cognito_client.create_user_pool_client(
UserPoolId=pool_id,
ClientName=client_name,
ExplicitAuthFlows=["ALLOW_ADMIN_USER_PASSWORD_AUTH"],
)
client_id = client_response["UserPoolClient"]["ClientId"]
cognito_client.admin_create_user(
UserPoolId=pool_id,
Username=username,
UserAttributes=[{"Name": "email", "Value": "pytest@example.com"}],
)
cognito_client.admin_set_user_password(
UserPoolId=pool_id, Username=username, Password="Pytest#1234", Permanent=True
)
auth_response = cognito_client.admin_initiate_auth(
UserPoolId=pool_id,
ClientId=client_id,
AuthFlow="ADMIN_NO_SRP_AUTH",
AuthParameters={"USERNAME": username, "PASSWORD": "Pytest#1234"},
)
access_token = auth_response["AuthenticationResult"]["AccessToken"]
try:
response = cognito_client.get_user(AccessToken=access_token)
assert response["Username"] == username
finally:
cognito_client.admin_delete_user(UserPoolId=pool_id, Username=username)
cognito_client.delete_user_pool_client(
UserPoolId=pool_id, ClientId=client_id
)
cognito_client.delete_user_pool(UserPoolId=pool_id)
class TestCognitoLogDeliveryConfiguration:
"""Test Cognito log delivery configuration operations.
``LogConfigurations`` is always present on the response, as ``[]`` when nothing
is configured, and ``SetLogDeliveryConfiguration`` replaces the list rather than
merging into it.
"""
LOG_GROUP_ARN = "arn:aws:logs:us-east-1:000000000000:log-group:pytest-cognito-logs"
@pytest.fixture
def pool_id(self, cognito_client, unique_name):
response = cognito_client.create_user_pool(PoolName=f"pytest-log-pool-{unique_name}")
pool_id = response["UserPool"]["Id"]
yield pool_id
cognito_client.delete_user_pool(UserPoolId=pool_id)
def test_get_returns_an_empty_list_before_anything_is_configured(
self, cognito_client, pool_id
):
"""An unconfigured pool still returns the LogConfigurations member."""
config = cognito_client.get_log_delivery_configuration(UserPoolId=pool_id)[
"LogDeliveryConfiguration"
]
assert config["UserPoolId"] == pool_id
assert config["LogConfigurations"] == []
def test_set_round_trips_and_replaces(self, cognito_client, pool_id):
"""Set stores the configuration, and a second Set replaces rather than merges."""
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id,
LogConfigurations=[
{
"LogLevel": "ERROR",
"EventSource": "userNotification",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
],
)
config = cognito_client.get_log_delivery_configuration(UserPoolId=pool_id)[
"LogDeliveryConfiguration"
]
assert len(config["LogConfigurations"]) == 1
assert config["LogConfigurations"][0]["EventSource"] == "userNotification"
assert (
config["LogConfigurations"][0]["CloudWatchLogsConfiguration"]["LogGroupArn"]
== self.LOG_GROUP_ARN
)
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id,
LogConfigurations=[
{
"LogLevel": "INFO",
"EventSource": "userAuthEvents",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
],
)
replaced = cognito_client.get_log_delivery_configuration(UserPoolId=pool_id)[
"LogDeliveryConfiguration"
]
assert len(replaced["LogConfigurations"]) == 1
assert replaced["LogConfigurations"][0]["EventSource"] == "userAuthEvents"
def test_empty_list_clears_the_configuration(self, cognito_client, pool_id):
"""An empty LogConfigurations clears what was stored."""
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id,
LogConfigurations=[
{
"LogLevel": "ERROR",
"EventSource": "userNotification",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
],
)
cognito_client.set_log_delivery_configuration(UserPoolId=pool_id, LogConfigurations=[])
config = cognito_client.get_log_delivery_configuration(UserPoolId=pool_id)[
"LogDeliveryConfiguration"
]
assert config["LogConfigurations"] == []
def test_set_rejects_a_configuration_with_no_destination(self, cognito_client, pool_id):
"""Every event source in the request must name a destination."""
with pytest.raises(cognito_client.exceptions.InvalidParameterException):
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id,
LogConfigurations=[{"LogLevel": "ERROR", "EventSource": "userNotification"}],
)
def test_set_rejects_more_than_two_configurations(self, cognito_client, pool_id):
"""LogConfigurations is bounded at 2 entries."""
config = {
"LogLevel": "ERROR",
"EventSource": "userNotification",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
with pytest.raises(cognito_client.exceptions.InvalidParameterException) as excinfo:
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id, LogConfigurations=[config, config, config]
)
assert "Member must have length less than or equal to 2" in str(excinfo.value)
def test_set_rejects_a_repeated_event_source(self, cognito_client, pool_id):
"""An event source may appear at most once across the configurations."""
config = {
"LogLevel": "ERROR",
"EventSource": "userNotification",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
with pytest.raises(cognito_client.exceptions.InvalidParameterException) as excinfo:
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id, LogConfigurations=[config, config]
)
assert "appear more then once in a request" in str(excinfo.value)
def test_a_rejected_request_leaves_the_configuration_alone(self, cognito_client, pool_id):
"""An oversized request must not be stored."""
config = {
"LogLevel": "ERROR",
"EventSource": "userNotification",
"CloudWatchLogsConfiguration": {"LogGroupArn": self.LOG_GROUP_ARN},
}
with pytest.raises(cognito_client.exceptions.InvalidParameterException):
cognito_client.set_log_delivery_configuration(
UserPoolId=pool_id, LogConfigurations=[config, config, config]
)
stored = cognito_client.get_log_delivery_configuration(UserPoolId=pool_id)[
"LogDeliveryConfiguration"
]
assert stored["LogConfigurations"] == []
class TestCognitoDescribeUserPoolStandardAttributes:
"""DescribeUserPool must return all 20 standard OIDC attributes."""
STANDARD_ATTRIBUTES = [
"sub", "name", "given_name", "family_name", "middle_name", "nickname",
"preferred_username", "profile", "picture", "website", "email",
"email_verified", "gender", "birthdate", "zoneinfo", "locale",
"phone_number", "phone_number_verified", "address", "updated_at",
]
def test_describe_user_pool_returns_all_standard_schema_attributes(self, cognito_client, unique_name):
response = cognito_client.create_user_pool(PoolName=f"pytest-schema-{unique_name}")
pool_id = response["UserPool"]["Id"]
try:
described = cognito_client.describe_user_pool(UserPoolId=pool_id)
schema = described["UserPool"]["SchemaAttributes"]
names = [a["Name"] for a in schema]
assert len(schema) == 20
for attr in self.STANDARD_ATTRIBUTES:
assert attr in names, f"Missing standard attribute: {attr}"
sub = next(a for a in schema if a["Name"] == "sub")
assert sub["Required"] is True
assert sub["Mutable"] is False
finally:
cognito_client.delete_user_pool(UserPoolId=pool_id)
class TestCognitoIdentityProvider:
"""Test Cognito identity provider configuration operations.
The response shapes asserted here were measured against the live Cognito API.
``IdpIdentifiers`` is echoed by CreateIdentityProvider only when the request
supplied it, while DescribeIdentityProvider always returns it.
"""
OIDC_DETAILS = {
"client_id": "pytest-client",
"client_secret": "pytest-secret",
"attributes_request_method": "GET",
"oidc_issuer": "https://issuer.example.com",
"authorize_scopes": "openid",
}
@pytest.fixture
def pool_id(self, cognito_client, unique_name):
response = cognito_client.create_user_pool(PoolName=f"pytest-idp-pool-{unique_name}")
pool_id = response["UserPool"]["Id"]
yield pool_id
cognito_client.delete_user_pool(UserPoolId=pool_id)
def test_create_defaults_attribute_mapping_and_omits_idp_identifiers(
self, cognito_client, pool_id
):
"""Create defaults AttributeMapping and omits IdpIdentifiers when not supplied."""
provider = cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)["IdentityProvider"]
assert provider["ProviderType"] == "OIDC"
assert provider["AttributeMapping"] == {"username": "sub"}
assert "IdpIdentifiers" not in provider
def test_describe_always_returns_idp_identifiers(self, cognito_client, pool_id):
"""Describe returns IdpIdentifiers even when the stored list is empty."""
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)
provider = cognito_client.describe_identity_provider(
UserPoolId=pool_id, ProviderName="PytestOidc"
)["IdentityProvider"]
assert provider["IdpIdentifiers"] == []
assert provider["ProviderDetails"]["client_id"] == "pytest-client"
def test_update_preserves_members_the_request_omits(self, cognito_client, pool_id):
"""Omitting AttributeMapping/IdpIdentifiers on update leaves them unchanged."""
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
AttributeMapping={"email": "email", "username": "sub"},
IdpIdentifiers=["pytest-alias"],
)
cognito_client.update_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderDetails=dict(self.OIDC_DETAILS),
)
provider = cognito_client.describe_identity_provider(
UserPoolId=pool_id, ProviderName="PytestOidc"
)["IdentityProvider"]
assert provider["IdpIdentifiers"] == ["pytest-alias"]
assert provider["AttributeMapping"] == {"email": "email", "username": "sub"}
def test_list_returns_summaries_without_provider_details(self, cognito_client, pool_id):
"""ListIdentityProviders returns summaries, never provider credentials."""
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)
providers = cognito_client.list_identity_providers(UserPoolId=pool_id)["Providers"]
assert len(providers) == 1
assert providers[0]["ProviderName"] == "PytestOidc"
assert providers[0]["ProviderType"] == "OIDC"
assert "ProviderDetails" not in providers[0]
def test_create_rejects_duplicate_provider_name(self, cognito_client, pool_id):
"""A second provider with the same name raises DuplicateProviderException."""
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)
with pytest.raises(cognito_client.exceptions.DuplicateProviderException):
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)
def test_delete_removes_the_provider(self, cognito_client, pool_id):
"""Delete removes the provider and a second delete raises."""
cognito_client.create_identity_provider(
UserPoolId=pool_id,
ProviderName="PytestOidc",
ProviderType="OIDC",
ProviderDetails=dict(self.OIDC_DETAILS),
)
cognito_client.delete_identity_provider(UserPoolId=pool_id, ProviderName="PytestOidc")
assert cognito_client.list_identity_providers(UserPoolId=pool_id)["Providers"] == []
with pytest.raises(cognito_client.exceptions.ResourceNotFoundException):
cognito_client.delete_identity_provider(
UserPoolId=pool_id, ProviderName="PytestOidc"
)
@@ -0,0 +1,291 @@
"""AWS Config integration tests."""
import datetime
import pytest
from botocore.exceptions import ClientError
LAMBDA_SOURCE = "arn:aws:lambda:us-east-1:000000000000:function:compliance-checker"
def _put_custom_rule(config_client, rule_name, **overrides):
"""Create a minimal CUSTOM_LAMBDA config rule."""
rule = {
"ConfigRuleName": rule_name,
"Source": {"Owner": "CUSTOM_LAMBDA", "SourceIdentifier": LAMBDA_SOURCE},
}
rule.update(overrides)
config_client.put_config_rule(ConfigRule=rule)
class TestConfigRuleLifecycle:
"""Test config rule CRUD with a full-shape round-trip."""
def test_put_describe_round_trip(self, config_client, unique_name):
"""Test PutConfigRule stores every field and DescribeConfigRules returns them."""
rule_name = f"rule-{unique_name}"
_put_custom_rule(
config_client,
rule_name,
Description="Checks bucket policies",
Scope={"ComplianceResourceTypes": ["AWS::S3::Bucket"], "TagKey": "env"},
Source={
"Owner": "CUSTOM_LAMBDA",
"SourceIdentifier": LAMBDA_SOURCE,
"SourceDetails": [
{"EventSource": "aws.config", "MessageType": "ConfigurationItemChangeNotification"}
],
},
InputParameters='{"maxPolicySize": 5}',
MaximumExecutionFrequency="Six_Hours",
)
try:
rules = config_client.describe_config_rules(ConfigRuleNames=[rule_name])["ConfigRules"]
assert len(rules) == 1
rule = rules[0]
assert rule["ConfigRuleName"] == rule_name
assert rule["ConfigRuleArn"].startswith("arn:aws:config:")
assert rule["ConfigRuleId"]
assert rule["ConfigRuleState"] == "ACTIVE"
assert rule["Description"] == "Checks bucket policies"
assert rule["Scope"]["ComplianceResourceTypes"] == ["AWS::S3::Bucket"]
assert rule["Scope"]["TagKey"] == "env"
assert rule["Source"]["Owner"] == "CUSTOM_LAMBDA"
assert rule["Source"]["SourceIdentifier"] == LAMBDA_SOURCE
assert rule["Source"]["SourceDetails"][0]["EventSource"] == "aws.config"
assert rule["InputParameters"] == '{"maxPolicySize": 5}'
assert rule["MaximumExecutionFrequency"] == "Six_Hours"
assert rule["EvaluationModes"] == [{"Mode": "DETECTIVE"}]
finally:
config_client.delete_config_rule(ConfigRuleName=rule_name)
def test_describe_unknown_rule_fails(self, config_client, unique_name):
"""Test DescribeConfigRules with an unknown name raises NoSuchConfigRuleException."""
with pytest.raises(ClientError) as exc:
config_client.describe_config_rules(ConfigRuleNames=[f"missing-{unique_name}"])
assert exc.value.response["Error"]["Code"] == "NoSuchConfigRuleException"
def test_delete_unknown_rule_fails(self, config_client, unique_name):
"""Test DeleteConfigRule with an unknown name raises NoSuchConfigRuleException."""
with pytest.raises(ClientError) as exc:
config_client.delete_config_rule(ConfigRuleName=f"missing-{unique_name}")
assert exc.value.response["Error"]["Code"] == "NoSuchConfigRuleException"
class TestComplianceLoop:
"""Test the evaluation-driven compliance loop."""
def test_evaluations_drive_compliance(self, config_client, unique_name):
"""Test PutEvaluations records results that compliance reads aggregate."""
rule_name = f"loop-{unique_name}"
resource_id = f"bucket-{unique_name}"
_put_custom_rule(config_client, rule_name)
try:
# TestMode validates but persists nothing
config_client.put_evaluations(
ResultToken=rule_name,
TestMode=True,
Evaluations=[
{
"ComplianceResourceType": "AWS::S3::Bucket",
"ComplianceResourceId": resource_id,
"ComplianceType": "NON_COMPLIANT",
"OrderingTimestamp": datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
}
],
)
compliance = config_client.describe_compliance_by_config_rule(
ConfigRuleNames=[rule_name]
)["ComplianceByConfigRules"][0]["Compliance"]
assert compliance["ComplianceType"] == "INSUFFICIENT_DATA"
# Real evaluations flip the rule to NON_COMPLIANT
response = config_client.put_evaluations(
ResultToken=rule_name,
Evaluations=[
{
"ComplianceResourceType": "AWS::S3::Bucket",
"ComplianceResourceId": resource_id,
"ComplianceType": "NON_COMPLIANT",
"Annotation": "Bucket policy too permissive",
"OrderingTimestamp": datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
}
],
)
assert response["FailedEvaluations"] == []
compliance = config_client.describe_compliance_by_config_rule(
ConfigRuleNames=[rule_name]
)["ComplianceByConfigRules"][0]["Compliance"]
assert compliance["ComplianceType"] == "NON_COMPLIANT"
assert compliance["ComplianceContributorCount"]["CappedCount"] == 1
assert compliance["ComplianceContributorCount"]["CapExceeded"] is False
details = config_client.get_compliance_details_by_config_rule(
ConfigRuleName=rule_name
)["EvaluationResults"]
assert len(details) == 1
qualifier = details[0]["EvaluationResultIdentifier"]["EvaluationResultQualifier"]
assert qualifier["ConfigRuleName"] == rule_name
assert qualifier["ResourceType"] == "AWS::S3::Bucket"
assert qualifier["ResourceId"] == resource_id
assert details[0]["ComplianceType"] == "NON_COMPLIANT"
assert details[0]["Annotation"] == "Bucket policy too permissive"
assert isinstance(details[0]["ResultRecordedTime"], datetime.datetime)
by_resource = config_client.get_compliance_details_by_resource(
ResourceType="AWS::S3::Bucket", ResourceId=resource_id
)["EvaluationResults"]
assert len(by_resource) == 1
assert (
by_resource[0]["EvaluationResultIdentifier"]["EvaluationResultQualifier"]["ConfigRuleName"]
== rule_name
)
resources = config_client.describe_compliance_by_resource(
ResourceType="AWS::S3::Bucket", ResourceId=resource_id
)["ComplianceByResources"]
assert len(resources) == 1
assert resources[0]["Compliance"]["ComplianceType"] == "NON_COMPLIANT"
summary = config_client.get_compliance_summary_by_config_rule()["ComplianceSummary"]
assert summary["NonCompliantResourceCount"]["CappedCount"] >= 1
assert isinstance(summary["ComplianceSummaryTimestamp"], datetime.datetime)
by_type = config_client.get_compliance_summary_by_resource_type(
ResourceTypes=["AWS::S3::Bucket"]
)["ComplianceSummariesByResourceType"]
assert len(by_type) == 1
assert by_type[0]["ResourceType"] == "AWS::S3::Bucket"
assert by_type[0]["ComplianceSummary"]["NonCompliantResourceCount"]["CappedCount"] >= 1
# DeleteEvaluationResults returns the rule to INSUFFICIENT_DATA
config_client.delete_evaluation_results(ConfigRuleName=rule_name)
compliance = config_client.describe_compliance_by_config_rule(
ConfigRuleNames=[rule_name]
)["ComplianceByConfigRules"][0]["Compliance"]
assert compliance["ComplianceType"] == "INSUFFICIENT_DATA"
finally:
config_client.delete_config_rule(ConfigRuleName=rule_name)
def test_put_external_evaluation(self, config_client, unique_name):
"""Test PutExternalEvaluation records a result for the named rule."""
rule_name = f"external-{unique_name}"
_put_custom_rule(config_client, rule_name)
try:
config_client.put_external_evaluation(
ConfigRuleName=rule_name,
ExternalEvaluation={
"ComplianceResourceType": "AWS::EC2::Instance",
"ComplianceResourceId": f"i-{unique_name}",
"ComplianceType": "COMPLIANT",
"OrderingTimestamp": datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
},
)
compliance = config_client.describe_compliance_by_config_rule(
ConfigRuleNames=[rule_name]
)["ComplianceByConfigRules"][0]["Compliance"]
assert compliance["ComplianceType"] == "COMPLIANT"
finally:
config_client.delete_config_rule(ConfigRuleName=rule_name)
def test_put_evaluations_error_cases(self, config_client, unique_name):
"""Test PutEvaluations validation errors surface AWS error codes."""
evaluation = {
"ComplianceResourceType": "AWS::S3::Bucket",
"ComplianceResourceId": f"bucket-{unique_name}",
"ComplianceType": "COMPLIANT",
"OrderingTimestamp": datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
}
with pytest.raises(ClientError) as exc:
config_client.put_evaluations(ResultToken="", Evaluations=[evaluation])
assert exc.value.response["Error"]["Code"] == "InvalidResultTokenException"
with pytest.raises(ClientError) as exc:
config_client.put_evaluations(
ResultToken=f"missing-{unique_name}", Evaluations=[evaluation]
)
assert exc.value.response["Error"]["Code"] == "NoSuchConfigRuleException"
class TestRecorderChannelLifecycle:
"""Test configuration recorder and delivery channel lifecycle."""
def test_recorder_and_channel_lifecycle(self, config_client):
"""Test recorder/channel setup, the guarded channel delete, and both deletes."""
config_client.put_configuration_recorder(
ConfigurationRecorder={
"name": "default",
"roleARN": "arn:aws:iam::000000000000:role/config-role",
"recordingGroup": {"allSupported": True},
}
)
try:
config_client.put_delivery_channel(
DeliveryChannel={"name": "default", "s3BucketName": "config-bucket"}
)
config_client.start_configuration_recorder(ConfigurationRecorderName="default")
with pytest.raises(ClientError) as exc:
config_client.delete_delivery_channel(DeliveryChannelName="default")
assert exc.value.response["Error"]["Code"] == "LastDeliveryChannelDeleteFailedException"
config_client.stop_configuration_recorder(ConfigurationRecorderName="default")
config_client.delete_delivery_channel(DeliveryChannelName="default")
with pytest.raises(ClientError) as exc:
config_client.delete_delivery_channel(DeliveryChannelName="default")
assert exc.value.response["Error"]["Code"] == "NoSuchDeliveryChannelException"
finally:
config_client.delete_configuration_recorder(ConfigurationRecorderName="default")
assert config_client.describe_configuration_recorders()["ConfigurationRecorders"] == []
class TestRetention:
"""Test retention configuration lifecycle."""
def test_retention_lifecycle(self, config_client):
"""Test put/describe/delete retention configuration."""
retention = config_client.put_retention_configuration(RetentionPeriodInDays=365)
assert retention["RetentionConfiguration"]["Name"] == "default"
assert retention["RetentionConfiguration"]["RetentionPeriodInDays"] == 365
try:
configurations = config_client.describe_retention_configurations()["RetentionConfigurations"]
assert len(configurations) == 1
assert configurations[0]["RetentionPeriodInDays"] == 365
finally:
config_client.delete_retention_configuration(RetentionConfigurationName="default")
assert config_client.describe_retention_configurations()["RetentionConfigurations"] == []
class TestPagination:
"""Test NextToken pagination through the boto3 paginator."""
def test_describe_config_rules_paginates(self, config_client, unique_name):
"""Test the boto3 paginator walks every page of DescribeConfigRules."""
rule_names = [f"pg-{unique_name}-{i:03d}" for i in range(30)]
for rule_name in rule_names:
_put_custom_rule(config_client, rule_name)
try:
first_page = config_client.describe_config_rules()
assert len(first_page["ConfigRules"]) == 25
assert first_page["NextToken"]
collected = []
paginator = config_client.get_paginator("describe_config_rules")
for page in paginator.paginate():
collected.extend(rule["ConfigRuleName"] for rule in page["ConfigRules"])
for rule_name in rule_names:
assert rule_name in collected
finally:
for rule_name in rule_names:
config_client.delete_config_rule(ConfigRuleName=rule_name)
@@ -0,0 +1,605 @@
"""DynamoDB table and item integration tests."""
import pytest
class TestDynamoDBTable:
"""Test DynamoDB table operations."""
def test_create_table(self, dynamodb_client, unique_name):
"""Test CreateTable creates a table."""
table_name = f"pytest-ddb-{unique_name}"
try:
response = dynamodb_client.create_table(
TableName=table_name,
KeySchema=[
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
],
BillingMode="PAY_PER_REQUEST",
)
assert response["TableDescription"]["TableStatus"] == "ACTIVE"
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_describe_table(self, dynamodb_client, test_table):
"""Test DescribeTable returns table info."""
response = dynamodb_client.describe_table(TableName=test_table)
assert response["Table"]["TableName"] == test_table
def test_list_tables(self, dynamodb_client, test_table):
"""Test ListTables includes created table."""
response = dynamodb_client.list_tables()
assert test_table in response["TableNames"]
def test_update_table(self, dynamodb_client, unique_name):
"""Test UpdateTable modifies provisioned throughput."""
table_name = f"pytest-ddb-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
)
try:
response = dynamodb_client.update_table(
TableName=table_name,
ProvisionedThroughput={"ReadCapacityUnits": 10, "WriteCapacityUnits": 10},
)
assert (
response["TableDescription"]["ProvisionedThroughput"]["ReadCapacityUnits"]
== 10
)
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_describe_time_to_live(self, dynamodb_client, test_table):
"""Test DescribeTimeToLive returns TTL status."""
response = dynamodb_client.describe_time_to_live(TableName=test_table)
assert response["TimeToLiveDescription"]["TimeToLiveStatus"] == "DISABLED"
def test_update_and_describe_continuous_backups(self, dynamodb_client, unique_name):
"""Test PITR can be enabled and described through the SDK."""
table_name = f"pytest-ddb-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
try:
response = dynamodb_client.describe_continuous_backups(TableName=table_name)
assert (
response["ContinuousBackupsDescription"]["ContinuousBackupsStatus"]
== "ENABLED"
)
assert (
response["ContinuousBackupsDescription"][
"PointInTimeRecoveryDescription"
]["PointInTimeRecoveryStatus"]
== "DISABLED"
)
assert (
"RecoveryPeriodInDays"
not in response["ContinuousBackupsDescription"][
"PointInTimeRecoveryDescription"
]
)
response = dynamodb_client.update_continuous_backups(
TableName=table_name,
PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True},
)
assert (
response["ContinuousBackupsDescription"][
"PointInTimeRecoveryDescription"
]["PointInTimeRecoveryStatus"]
== "ENABLED"
)
assert (
response["ContinuousBackupsDescription"][
"PointInTimeRecoveryDescription"
]["RecoveryPeriodInDays"]
== 35
)
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_delete_table(self, dynamodb_client, unique_name):
"""Test DeleteTable removes table."""
table_name = f"pytest-ddb-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
dynamodb_client.delete_table(TableName=table_name)
response = dynamodb_client.list_tables()
assert table_name not in response["TableNames"]
class TestDynamoDBItem:
"""Test DynamoDB item operations."""
def test_put_item(self, dynamodb_client, test_table):
"""Test PutItem adds item to table."""
dynamodb_client.put_item(
TableName=test_table,
Item={"pk": {"S": "user-1"}, "data": {"S": "value-1"}},
)
response = dynamodb_client.get_item(
TableName=test_table, Key={"pk": {"S": "user-1"}}
)
assert response.get("Item", {}).get("data", {}).get("S") == "value-1"
def test_get_item(self, dynamodb_client, test_table):
"""Test GetItem retrieves item."""
dynamodb_client.put_item(
TableName=test_table,
Item={"pk": {"S": "user-1"}, "data": {"S": "test-value"}},
)
response = dynamodb_client.get_item(
TableName=test_table, Key={"pk": {"S": "user-1"}}
)
assert response.get("Item", {}).get("data", {}).get("S") == "test-value"
def test_update_item(self, dynamodb_client, test_table):
"""Test UpdateItem modifies item."""
dynamodb_client.put_item(
TableName=test_table,
Item={"pk": {"S": "user-1"}, "data": {"S": "original"}},
)
response = dynamodb_client.update_item(
TableName=test_table,
Key={"pk": {"S": "user-1"}},
UpdateExpression="SET #d = :newVal",
ExpressionAttributeNames={"#d": "data"},
ExpressionAttributeValues={":newVal": {"S": "updated"}},
ReturnValues="ALL_NEW",
)
assert response["Attributes"]["data"]["S"] == "updated"
def test_delete_item(self, dynamodb_client, test_table):
"""Test DeleteItem removes item."""
dynamodb_client.put_item(
TableName=test_table,
Item={"pk": {"S": "to-delete"}, "data": {"S": "value"}},
)
dynamodb_client.delete_item(
TableName=test_table, Key={"pk": {"S": "to-delete"}}
)
response = dynamodb_client.get_item(
TableName=test_table, Key={"pk": {"S": "to-delete"}}
)
assert "Item" not in response
class TestDynamoDBQuery:
"""Test DynamoDB query and scan operations."""
def test_query(self, dynamodb_client, unique_name):
"""Test Query returns matching items."""
table_name = f"pytest-ddb-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
],
BillingMode="PAY_PER_REQUEST",
)
try:
for i in range(1, 4):
dynamodb_client.put_item(
TableName=table_name,
Item={
"pk": {"S": "user-1"},
"sk": {"S": f"item-{i}"},
"data": {"S": f"value-{i}"},
},
)
response = dynamodb_client.query(
TableName=table_name,
KeyConditionExpression="pk = :pk",
ExpressionAttributeValues={":pk": {"S": "user-1"}},
)
assert response["Count"] == 3
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_scan(self, dynamodb_client, test_table):
"""Test Scan returns all items."""
for i in range(1, 4):
dynamodb_client.put_item(
TableName=test_table,
Item={"pk": {"S": f"user-{i}"}, "data": {"S": f"value-{i}"}},
)
response = dynamodb_client.scan(TableName=test_table)
assert response["Count"] == 3
class TestDynamoDBBatch:
"""Test DynamoDB batch operations."""
def test_batch_write_item_put(self, dynamodb_client, test_table):
"""Test BatchWriteItem puts multiple items."""
dynamodb_client.batch_write_item(
RequestItems={
test_table: [
{
"PutRequest": {
"Item": {
"pk": {"S": "batch-1"},
"data": {"S": "value-1"},
}
}
},
{
"PutRequest": {
"Item": {
"pk": {"S": "batch-2"},
"data": {"S": "value-2"},
}
}
},
]
}
)
response = dynamodb_client.scan(TableName=test_table)
assert response["Count"] == 2
def test_batch_write_item_delete(self, dynamodb_client, test_table):
"""Test BatchWriteItem deletes multiple items."""
# Setup items
dynamodb_client.batch_write_item(
RequestItems={
test_table: [
{"PutRequest": {"Item": {"pk": {"S": "del-1"}, "data": {"S": "v1"}}}},
{"PutRequest": {"Item": {"pk": {"S": "del-2"}, "data": {"S": "v2"}}}},
]
}
)
# Delete items
dynamodb_client.batch_write_item(
RequestItems={
test_table: [
{"DeleteRequest": {"Key": {"pk": {"S": "del-1"}}}},
{"DeleteRequest": {"Key": {"pk": {"S": "del-2"}}}},
]
}
)
response = dynamodb_client.scan(TableName=test_table)
assert response["Count"] == 0
def test_batch_get_item(self, dynamodb_client, test_table):
"""Test BatchGetItem retrieves multiple items."""
dynamodb_client.batch_write_item(
RequestItems={
test_table: [
{"PutRequest": {"Item": {"pk": {"S": "get-1"}, "data": {"S": "v1"}}}},
{"PutRequest": {"Item": {"pk": {"S": "get-2"}, "data": {"S": "v2"}}}},
]
}
)
response = dynamodb_client.batch_get_item(
RequestItems={
test_table: {
"Keys": [
{"pk": {"S": "get-1"}},
{"pk": {"S": "get-2"}},
]
}
}
)
items = response["Responses"].get(test_table, [])
assert len(items) == 2
class TestDynamoDBTagging:
"""Test DynamoDB table tagging operations."""
def test_tag_resource(self, dynamodb_client, unique_name):
"""Test TagResource adds tags to table."""
table_name = f"pytest-ddb-{unique_name}"
response = dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
table_arn = response["TableDescription"]["TableArn"]
try:
dynamodb_client.tag_resource(
ResourceArn=table_arn,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
# If no exception, test passes
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_list_tags_of_resource(self, dynamodb_client, unique_name):
"""Test ListTagsOfResource returns tags."""
table_name = f"pytest-ddb-{unique_name}"
response = dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
table_arn = response["TableDescription"]["TableArn"]
dynamodb_client.tag_resource(
ResourceArn=table_arn,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
try:
response = dynamodb_client.list_tags_of_resource(ResourceArn=table_arn)
tags = {t["Key"]: t["Value"] for t in response["Tags"]}
assert tags.get("env") == "test"
assert tags.get("team") == "backend"
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_untag_resource(self, dynamodb_client, unique_name):
"""Test UntagResource removes tags."""
table_name = f"pytest-ddb-{unique_name}"
response = dynamodb_client.create_table(
TableName=table_name,
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
BillingMode="PAY_PER_REQUEST",
)
table_arn = response["TableDescription"]["TableArn"]
dynamodb_client.tag_resource(
ResourceArn=table_arn,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
try:
dynamodb_client.untag_resource(ResourceArn=table_arn, TagKeys=["team"])
response = dynamodb_client.list_tags_of_resource(ResourceArn=table_arn)
tags = {t["Key"]: t["Value"] for t in response["Tags"]}
assert tags.get("env") == "test"
assert "team" not in tags
finally:
dynamodb_client.delete_table(TableName=table_name)
class TestDynamoDBGSI:
"""Test DynamoDB Global Secondary Index and Local Secondary Index operations."""
def test_create_table_with_gsi_and_lsi(self, dynamodb_client, unique_name):
"""Test CreateTable with GSI and LSI."""
table_name = f"pytest-gsi-{unique_name}"
try:
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "gsiPk", "AttributeType": "S"},
{"AttributeName": "lsiSk", "AttributeType": "S"},
],
GlobalSecondaryIndexes=[
{
"IndexName": "gsi-1",
"KeySchema": [
{"AttributeName": "gsiPk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "ALL"},
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5,
},
}
],
LocalSecondaryIndexes=[
{
"IndexName": "lsi-1",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "lsiSk", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "KEYS_ONLY"},
}
],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
)
# Verify indexes exist
desc = dynamodb_client.describe_table(TableName=table_name)["Table"]
gsis = desc.get("GlobalSecondaryIndexes", [])
lsis = desc.get("LocalSecondaryIndexes", [])
assert len(gsis) == 1
assert gsis[0]["IndexName"] == "gsi-1"
assert gsis[0]["Projection"]["ProjectionType"] == "ALL"
assert len(lsis) == 1
assert lsis[0]["IndexName"] == "lsi-1"
assert lsis[0]["Projection"]["ProjectionType"] == "KEYS_ONLY"
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_query_gsi_sparse_index(self, dynamodb_client, unique_name):
"""Test querying GSI excludes items without GSI key (sparse index)."""
table_name = f"pytest-gsi-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "gsiPk", "AttributeType": "S"},
],
GlobalSecondaryIndexes=[
{
"IndexName": "gsi-1",
"KeySchema": [
{"AttributeName": "gsiPk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "ALL"},
"ProvisionedThroughput": {
"ReadCapacityUnits": 5,
"WriteCapacityUnits": 5,
},
}
],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
)
try:
# Put 2 items with gsiPk
dynamodb_client.put_item(
TableName=table_name,
Item={
"pk": {"S": "item-1"},
"sk": {"S": "rev-1"},
"gsiPk": {"S": "group-A"},
},
)
dynamodb_client.put_item(
TableName=table_name,
Item={
"pk": {"S": "item-2"},
"sk": {"S": "rev-1"},
"gsiPk": {"S": "group-A"},
},
)
# Put 1 item without gsiPk (sparse)
dynamodb_client.put_item(
TableName=table_name,
Item={"pk": {"S": "item-3"}, "sk": {"S": "rev-1"}, "data": {"S": "no-gsi"}},
)
# Query GSI - should return only the 2 items with gsiPk
resp = dynamodb_client.query(
TableName=table_name,
IndexName="gsi-1",
KeyConditionExpression="gsiPk = :gpk",
ExpressionAttributeValues={":gpk": {"S": "group-A"}},
)
assert resp["Count"] == 2
pks = {item["pk"]["S"] for item in resp["Items"]}
assert "item-3" not in pks
finally:
dynamodb_client.delete_table(TableName=table_name)
def test_query_lsi(self, dynamodb_client, unique_name):
"""Test querying Local Secondary Index."""
table_name = f"pytest-lsi-{unique_name}"
dynamodb_client.create_table(
TableName=table_name,
KeySchema=[
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "sk", "KeyType": "RANGE"},
],
AttributeDefinitions=[
{"AttributeName": "pk", "AttributeType": "S"},
{"AttributeName": "sk", "AttributeType": "S"},
{"AttributeName": "lsiSk", "AttributeType": "S"},
],
LocalSecondaryIndexes=[
{
"IndexName": "lsi-1",
"KeySchema": [
{"AttributeName": "pk", "KeyType": "HASH"},
{"AttributeName": "lsiSk", "KeyType": "RANGE"},
],
"Projection": {"ProjectionType": "KEYS_ONLY"},
}
],
ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
)
try:
dynamodb_client.put_item(
TableName=table_name,
Item={
"pk": {"S": "item-1"},
"sk": {"S": "rev-1"},
"lsiSk": {"S": "2024-01-01"},
},
)
dynamodb_client.put_item(
TableName=table_name,
Item={
"pk": {"S": "item-1"},
"sk": {"S": "rev-2"},
"lsiSk": {"S": "2024-01-02"},
},
)
# Query LSI with range condition
resp = dynamodb_client.query(
TableName=table_name,
IndexName="lsi-1",
KeyConditionExpression="pk = :pk AND lsiSk > :d",
ExpressionAttributeValues={
":pk": {"S": "item-1"},
":d": {"S": "2024-01-00"},
},
)
assert resp["Count"] == 2
finally:
dynamodb_client.delete_table(TableName=table_name)
@@ -0,0 +1,109 @@
"""ECR control-plane compatibility tests.
Test-first: this file is committed before the server-side ECR implementation
lands. With ECR unimplemented, every test below should fail.
"""
import base64
import logging
import re
import pytest
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
REPO_NAME = "floci-it/app-py"
@pytest.fixture
def repo(ecr_client):
"""Create a repository for the test and clean it up afterwards."""
try:
ecr_client.create_repository(repositoryName=REPO_NAME)
except ClientError as e:
if e.response["Error"]["Code"] != "RepositoryAlreadyExistsException":
raise
yield REPO_NAME
try:
ecr_client.delete_repository(repositoryName=REPO_NAME, force=True)
except ClientError as e:
logger.warning("Failed to clean up ECR repository %s: %s", REPO_NAME, e)
class TestECRRepositoryLifecycle:
def test_create_repository_returns_loopback_uri(self, ecr_client):
try:
resp = ecr_client.create_repository(repositoryName=REPO_NAME)
repo = resp["repository"]
assert repo["repositoryName"] == REPO_NAME
assert re.match(r"arn:aws:ecr:.*:.*:repository/" + REPO_NAME, repo["repositoryArn"])
assert REPO_NAME in repo["repositoryUri"]
assert "localhost:" in repo["repositoryUri"]
finally:
try:
ecr_client.delete_repository(repositoryName=REPO_NAME, force=True)
except ClientError as e:
logger.warning("Failed to clean up ECR repository %s: %s", REPO_NAME, e)
def test_create_duplicate_raises(self, ecr_client, repo):
with pytest.raises(ClientError) as exc:
ecr_client.create_repository(repositoryName=repo)
assert exc.value.response["Error"]["Code"] == "RepositoryAlreadyExistsException"
def test_describe_repositories(self, ecr_client, repo):
resp = ecr_client.describe_repositories(repositoryNames=[repo])
assert any(r["repositoryName"] == repo for r in resp["repositories"])
def test_describe_missing_raises(self, ecr_client):
with pytest.raises(ClientError) as exc:
ecr_client.describe_repositories(repositoryNames=["does-not-exist-py"])
assert exc.value.response["Error"]["Code"] == "RepositoryNotFoundException"
def test_delete_force(self, ecr_client):
ecr_client.create_repository(repositoryName="floci-it/del-py")
ecr_client.delete_repository(repositoryName="floci-it/del-py", force=True)
with pytest.raises(ClientError) as exc:
ecr_client.describe_repositories(repositoryNames=["floci-it/del-py"])
assert exc.value.response["Error"]["Code"] == "RepositoryNotFoundException"
class TestECRAuthorization:
def test_get_authorization_token(self, ecr_client):
resp = ecr_client.get_authorization_token()
assert resp["authorizationData"]
data = resp["authorizationData"][0]
assert data["authorizationToken"]
assert data["proxyEndpoint"].startswith("http")
assert "expiresAt" in data
decoded = base64.b64decode(data["authorizationToken"]).decode("utf-8")
assert decoded.startswith("AWS:")
class TestECRImageOperations:
def test_list_images_empty(self, ecr_client, repo):
resp = ecr_client.list_images(repositoryName=repo)
assert resp["imageIds"] == []
class TestECRPolicies:
def test_lifecycle_policy_round_trip(self, ecr_client, repo):
policy = '{"rules":[{"rulePriority":1,"selection":{"tagStatus":"untagged","countType":"imageCountMoreThan","countNumber":5},"action":{"type":"expire"}}]}'
ecr_client.put_lifecycle_policy(repositoryName=repo, lifecyclePolicyText=policy)
resp = ecr_client.get_lifecycle_policy(repositoryName=repo)
assert resp["lifecyclePolicyText"] == policy
def test_repository_policy_round_trip(self, ecr_client, repo):
policy = '{"Version":"2012-10-17","Statement":[{"Sid":"AllowAll","Effect":"Allow","Principal":"*","Action":"ecr:*"}]}'
ecr_client.set_repository_policy(repositoryName=repo, policyText=policy)
resp = ecr_client.get_repository_policy(repositoryName=repo)
assert resp["policyText"] == policy
def test_image_tag_mutability_round_trip(self, ecr_client, repo):
resp = ecr_client.put_image_tag_mutability(
repositoryName=repo, imageTagMutability="IMMUTABLE"
)
assert resp["imageTagMutability"] == "IMMUTABLE"
desc = ecr_client.describe_repositories(repositoryNames=[repo])
assert desc["repositories"][0]["imageTagMutability"] == "IMMUTABLE"
@@ -0,0 +1,210 @@
"""Firehose S3 delivery compression (issue #2328).
The delivered S3 object must be compressed the way the stream's CompressionFormat
says, with the key extension and Content-Encoding real AWS uses. In CI this suite
also runs against the amd64 native image, where the two Snappy formats depend on
a JNI library bundled per platform, so this test is the proof that the x86_64
library survived the native build.
Both Snappy variants are container formats around raw Snappy blocks: ``Snappy`` is
the xerial snappy-java stream format (not the official framing format) and
``HADOOP_SNAPPY`` is Hadoop's block framing. Neither has a Python reader without
native system libraries, so the framing is parsed here and only the raw blocks go
through cramjam.
"""
import gzip
import io
import logging
import struct
import time
import uuid
import zipfile
import boto3
import cramjam
import pytest
from botocore.exceptions import ClientError
logger = logging.getLogger(__name__)
RECORD = '{"seq":1}\n'
RECORD_COUNT = 5
EXPECTED_PAYLOAD = (RECORD * RECORD_COUNT).encode()
# The buffering interval has to elapse before delivery happens, so it is kept short.
# AWS accepts 0..900, so this is still a contract-faithful stream; against Floci the
# flush lands on the next tick of its background flusher.
BUFFERING_INTERVAL_SECONDS = 5
POLL_INTERVAL_SECONDS = 5
POLL_TIMEOUT_SECONDS = 240
ROLE_ARN = "arn:aws:iam::000000000000:role/firehose-role"
XERIAL_MAGIC = b"\x82SNAPPY\x00"
def _unzip(body):
with zipfile.ZipFile(io.BytesIO(body)) as archive:
names = archive.namelist()
assert len(names) == 1, f"expected a single zip entry, got {names}"
return archive.read(names[0])
def _raw_snappy(chunk):
return bytes(cramjam.snappy.decompress_raw(chunk))
def _xerial_snappy(body):
"""snappy-java's SnappyOutputStream format.
A 16 byte header (8 byte magic, 4 byte version, 4 byte compatible version), then
chunks of a big-endian length and a raw Snappy block. A negative length marks an
uncompressed chunk of that many bytes, which snappy-java emits when compression
would not shrink a block.
"""
assert body[:8] == XERIAL_MAGIC, f"not a snappy-java stream: {body[:8]!r}"
out = bytearray()
offset = 16
while offset < len(body):
(length,) = struct.unpack(">i", body[offset:offset + 4])
offset += 4
if length < 0:
out += body[offset:offset - length]
offset -= length
else:
out += _raw_snappy(body[offset:offset + length])
offset += length
return bytes(out)
def _hadoop_snappy(body):
"""Hadoop's block framing.
Per block, a big-endian uncompressed block length followed by one or more chunks
of a big-endian compressed length and a raw Snappy block. The leading length
belongs to the first block, not the whole payload, so anything above the 32 KiB
block size carries several blocks and must be looped over.
"""
out = bytearray()
offset = 0
while offset < len(body):
(block_length,) = struct.unpack(">i", body[offset:offset + 4])
offset += 4
produced = 0
while produced < block_length:
(chunk_length,) = struct.unpack(">i", body[offset:offset + 4])
offset += 4
plain = _raw_snappy(body[offset:offset + chunk_length])
offset += chunk_length
out += plain
produced += len(plain)
assert produced == block_length, f"block decoded to {produced} bytes, header said {block_length}"
return bytes(out)
# (CompressionFormat as the API spells it, S3 key extension, Content-Encoding, decoder).
# HADOOP_SNAPPY shares the .snappy extension with Snappy and differs only in framing
# and Content-Encoding: the S3 object name documentation tables .hsnappy, but the
# service delivers .snappy (verified against real AWS in issue #2328).
FORMATS = [
("UNCOMPRESSED", "", None, bytes),
("GZIP", ".gz", "gzip", gzip.decompress),
("ZIP", ".zip", "zip", _unzip),
("Snappy", ".snappy", "snappy-java", _xerial_snappy),
("HADOOP_SNAPPY", ".snappy", "hadoop-snappy", _hadoop_snappy),
]
def _slug(compression_format):
return compression_format.lower().replace("_", "-")
@pytest.fixture(scope="class")
def delivered(aws_config, client_config):
"""One bucket and one stream per format, all created and filled up front.
A single buffering interval is then shared by every format instead of being
waited out once per test.
"""
firehose = boto3.client("firehose", config=client_config, **aws_config)
s3 = boto3.client("s3", config=client_config, **aws_config)
suffix = uuid.uuid4().hex[:8]
bucket = f"firehose-compression-{suffix}"
stream_prefix = f"sdk-compression-{suffix}"
streams = {}
s3.create_bucket(Bucket=bucket)
for compression_format, _, _, _ in FORMATS:
stream_name = f"{stream_prefix}-{_slug(compression_format)}"
streams[compression_format] = stream_name
firehose.create_delivery_stream(
DeliveryStreamName=stream_name,
DeliveryStreamType="DirectPut",
ExtendedS3DestinationConfiguration={
"RoleARN": ROLE_ARN,
"BucketARN": f"arn:aws:s3:::{bucket}",
"Prefix": f"{_slug(compression_format)}/",
"CompressionFormat": compression_format,
"BufferingHints": {
"IntervalInSeconds": BUFFERING_INTERVAL_SECONDS,
"SizeInMBs": 1,
},
},
)
for stream_name in streams.values():
response = firehose.put_record_batch(
DeliveryStreamName=stream_name,
Records=[{"Data": RECORD.encode()} for _ in range(RECORD_COUNT)],
)
assert response["FailedPutCount"] == 0, response
yield {"s3": s3, "bucket": bucket}
for stream_name in streams.values():
try:
firehose.delete_delivery_stream(DeliveryStreamName=stream_name)
except ClientError as e:
logger.warning("delete_delivery_stream %s failed: %s", stream_name, e)
try:
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for obj in page.get("Contents", []):
s3.delete_object(Bucket=bucket, Key=obj["Key"])
s3.delete_bucket(Bucket=bucket)
except ClientError as e:
logger.warning("bucket cleanup %s failed: %s", bucket, e)
def _wait_for_delivered_key(s3, bucket, prefix):
deadline = time.time() + POLL_TIMEOUT_SECONDS
while time.time() < deadline:
contents = s3.list_objects_v2(Bucket=bucket, Prefix=prefix).get("Contents", [])
if contents:
return contents[0]["Key"]
time.sleep(POLL_INTERVAL_SECONDS)
raise AssertionError(f"no object delivered under {prefix} within {POLL_TIMEOUT_SECONDS}s")
@pytest.mark.timeout(POLL_TIMEOUT_SECONDS + 60)
class TestFirehoseCompressionDelivery:
@pytest.mark.parametrize(
"compression_format,extension,content_encoding,decode",
FORMATS,
ids=[_slug(f[0]) for f in FORMATS],
)
def test_delivered_object_is_compressed_as_declared(
self, delivered, compression_format, extension, content_encoding, decode
):
s3 = delivered["s3"]
bucket = delivered["bucket"]
key = _wait_for_delivered_key(s3, bucket, f"{_slug(compression_format)}/")
assert key.endswith(extension), f"{key} should end with {extension!r}"
if not extension:
assert not key.endswith((".gz", ".zip", ".snappy")), key
response = s3.get_object(Bucket=bucket, Key=key)
assert response["ContentType"] == "application/octet-stream"
assert response.get("ContentEncoding") == content_encoding
body = response["Body"].read()
assert decode(body) == EXPECTED_PAYLOAD, "delivered body must decode to the records that were put"
@@ -0,0 +1,33 @@
"""Glue integration tests."""
def test_create_database_with_tags_visible_via_resource_groups_tagging_api(
glue_client, resourcegroupstaggingapi_client, unique_name
):
"""Test Glue CreateDatabase tags are discoverable via GetResources."""
database_name = f"glue-db-{unique_name}"
database_arn = (
f"arn:aws:glue:{glue_client.meta.region_name}:000000000000:database/{database_name}"
)
try:
glue_client.create_database(
DatabaseInput={
"Name": database_name,
"Description": "python glue database tags",
},
Tags={"Environment": "dev", "Project": "project1"},
)
response = resourcegroupstaggingapi_client.get_resources(
ResourceARNList=[database_arn]
)
mappings = response["ResourceTagMappingList"]
assert len(mappings) == 1
assert mappings[0]["ResourceARN"] == database_arn
tags = {tag["Key"]: tag["Value"] for tag in mappings[0]["Tags"]}
assert tags["Environment"] == "dev"
assert tags["Project"] == "project1"
finally:
glue_client.delete_database(Name=database_name)
@@ -0,0 +1,410 @@
"""IAM integration tests."""
import json
import pytest
from botocore.exceptions import ClientError
TRUST_POLICY = json.dumps(
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole",
}
],
}
)
POLICY_DOC = json.dumps(
{
"Version": "2012-10-17",
"Statement": [{"Effect": "Allow", "Action": "s3:GetObject", "Resource": "*"}],
}
)
class TestIAMUser:
"""Test IAM user operations."""
def test_create_user(self, iam_client, unique_name):
"""Test CreateUser creates a user."""
user_name = f"pytest-user-{unique_name}"
try:
response = iam_client.create_user(UserName=user_name, Path="/")
assert response["User"]["UserName"] == user_name
assert response["User"]["Arn"].endswith(user_name)
finally:
iam_client.delete_user(UserName=user_name)
def test_get_user(self, iam_client, unique_name):
"""Test GetUser returns user details."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
try:
response = iam_client.get_user(UserName=user_name)
assert response["User"]["UserName"] == user_name
finally:
iam_client.delete_user(UserName=user_name)
def test_list_users(self, iam_client, unique_name):
"""Test ListUsers includes created user."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
try:
response = iam_client.list_users()
assert any(u["UserName"] == user_name for u in response["Users"])
finally:
iam_client.delete_user(UserName=user_name)
def test_get_user_not_found(self, iam_client):
"""Test GetUser returns 404 for non-existent user."""
with pytest.raises(ClientError) as exc_info:
iam_client.get_user(UserName="nonexistent-user-pytest-xyz")
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
class TestIAMUserTags:
"""Test IAM user tagging operations."""
def test_tag_user(self, iam_client, unique_name):
"""Test TagUser adds tags to user."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
try:
iam_client.tag_user(
UserName=user_name, Tags=[{"Key": "env", "Value": "sdk-test"}]
)
# If no exception, test passes
finally:
iam_client.delete_user(UserName=user_name)
def test_list_user_tags(self, iam_client, unique_name):
"""Test ListUserTags returns user tags."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
iam_client.tag_user(
UserName=user_name, Tags=[{"Key": "env", "Value": "sdk-test"}]
)
try:
response = iam_client.list_user_tags(UserName=user_name)
assert any(t["Key"] == "env" for t in response["Tags"])
finally:
iam_client.delete_user(UserName=user_name)
def test_untag_user(self, iam_client, unique_name):
"""Test UntagUser removes tags from user."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
iam_client.tag_user(
UserName=user_name, Tags=[{"Key": "env", "Value": "sdk-test"}]
)
try:
iam_client.untag_user(UserName=user_name, TagKeys=["env"])
# If no exception, test passes
finally:
iam_client.delete_user(UserName=user_name)
class TestIAMAccessKey:
"""Test IAM access key operations."""
def test_create_access_key(self, iam_client, unique_name):
"""Test CreateAccessKey creates access key."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
try:
response = iam_client.create_access_key(UserName=user_name)
key_id = response["AccessKey"]["AccessKeyId"]
assert key_id.startswith("AKIA")
assert response["AccessKey"]["Status"] == "Active"
# Cleanup
iam_client.delete_access_key(UserName=user_name, AccessKeyId=key_id)
finally:
iam_client.delete_user(UserName=user_name)
def test_list_access_keys(self, iam_client, unique_name):
"""Test ListAccessKeys returns access keys."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
response = iam_client.create_access_key(UserName=user_name)
key_id = response["AccessKey"]["AccessKeyId"]
try:
response = iam_client.list_access_keys(UserName=user_name)
assert len(response["AccessKeyMetadata"]) > 0
finally:
iam_client.delete_access_key(UserName=user_name, AccessKeyId=key_id)
iam_client.delete_user(UserName=user_name)
def test_update_access_key(self, iam_client, unique_name):
"""Test UpdateAccessKey changes key status."""
user_name = f"pytest-user-{unique_name}"
iam_client.create_user(UserName=user_name)
response = iam_client.create_access_key(UserName=user_name)
key_id = response["AccessKey"]["AccessKeyId"]
try:
iam_client.update_access_key(
UserName=user_name, AccessKeyId=key_id, Status="Inactive"
)
# If no exception, test passes
finally:
iam_client.delete_access_key(UserName=user_name, AccessKeyId=key_id)
iam_client.delete_user(UserName=user_name)
class TestIAMGroup:
"""Test IAM group operations."""
def test_create_group(self, iam_client, unique_name):
"""Test CreateGroup creates a group."""
group_name = f"pytest-group-{unique_name}"
try:
response = iam_client.create_group(GroupName=group_name)
assert response["Group"]["GroupName"] == group_name
finally:
iam_client.delete_group(GroupName=group_name)
def test_add_user_to_group(self, iam_client, unique_name):
"""Test AddUserToGroup adds user to group."""
user_name = f"pytest-user-{unique_name}"
group_name = f"pytest-group-{unique_name}"
iam_client.create_user(UserName=user_name)
iam_client.create_group(GroupName=group_name)
try:
iam_client.add_user_to_group(GroupName=group_name, UserName=user_name)
# If no exception, test passes
finally:
iam_client.remove_user_from_group(GroupName=group_name, UserName=user_name)
iam_client.delete_group(GroupName=group_name)
iam_client.delete_user(UserName=user_name)
def test_get_group(self, iam_client, unique_name):
"""Test GetGroup returns group with users."""
user_name = f"pytest-user-{unique_name}"
group_name = f"pytest-group-{unique_name}"
iam_client.create_user(UserName=user_name)
iam_client.create_group(GroupName=group_name)
iam_client.add_user_to_group(GroupName=group_name, UserName=user_name)
try:
response = iam_client.get_group(GroupName=group_name)
assert any(u["UserName"] == user_name for u in response["Users"])
finally:
iam_client.remove_user_from_group(GroupName=group_name, UserName=user_name)
iam_client.delete_group(GroupName=group_name)
iam_client.delete_user(UserName=user_name)
def test_list_groups_for_user(self, iam_client, unique_name):
"""Test ListGroupsForUser returns user's groups."""
user_name = f"pytest-user-{unique_name}"
group_name = f"pytest-group-{unique_name}"
iam_client.create_user(UserName=user_name)
iam_client.create_group(GroupName=group_name)
iam_client.add_user_to_group(GroupName=group_name, UserName=user_name)
try:
response = iam_client.list_groups_for_user(UserName=user_name)
assert any(g["GroupName"] == group_name for g in response["Groups"])
finally:
iam_client.remove_user_from_group(GroupName=group_name, UserName=user_name)
iam_client.delete_group(GroupName=group_name)
iam_client.delete_user(UserName=user_name)
class TestIAMRole:
"""Test IAM role operations."""
def test_create_role(self, iam_client, unique_name):
"""Test CreateRole creates a role."""
role_name = f"pytest-role-{unique_name}"
try:
response = iam_client.create_role(
RoleName=role_name,
AssumeRolePolicyDocument=TRUST_POLICY,
Description="pytest test role",
)
assert response["Role"]["RoleName"] == role_name
assert role_name in response["Role"]["Arn"]
finally:
iam_client.delete_role(RoleName=role_name)
def test_get_role(self, iam_client, unique_name):
"""Test GetRole returns role details."""
role_name = f"pytest-role-{unique_name}"
iam_client.create_role(
RoleName=role_name, AssumeRolePolicyDocument=TRUST_POLICY
)
try:
response = iam_client.get_role(RoleName=role_name)
assert response["Role"]["RoleName"] == role_name
finally:
iam_client.delete_role(RoleName=role_name)
def test_list_roles(self, iam_client, unique_name):
"""Test ListRoles includes created role."""
role_name = f"pytest-role-{unique_name}"
iam_client.create_role(
RoleName=role_name, AssumeRolePolicyDocument=TRUST_POLICY
)
try:
response = iam_client.list_roles()
assert any(r["RoleName"] == role_name for r in response["Roles"])
finally:
iam_client.delete_role(RoleName=role_name)
class TestIAMPolicy:
"""Test IAM policy operations."""
def test_create_policy(self, iam_client, unique_name):
"""Test CreatePolicy creates a policy."""
policy_name = f"pytest-policy-{unique_name}"
try:
response = iam_client.create_policy(
PolicyName=policy_name,
PolicyDocument=POLICY_DOC,
Description="pytest test policy",
)
policy_arn = response["Policy"]["Arn"]
assert response["Policy"]["PolicyName"] == policy_name
assert policy_arn
finally:
iam_client.delete_policy(PolicyArn=response["Policy"]["Arn"])
def test_get_policy(self, iam_client, unique_name):
"""Test GetPolicy returns policy details."""
policy_name = f"pytest-policy-{unique_name}"
response = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=POLICY_DOC
)
policy_arn = response["Policy"]["Arn"]
try:
response = iam_client.get_policy(PolicyArn=policy_arn)
assert response["Policy"]["PolicyName"] == policy_name
finally:
iam_client.delete_policy(PolicyArn=policy_arn)
def test_attach_role_policy(self, iam_client, unique_name):
"""Test AttachRolePolicy attaches policy to role."""
role_name = f"pytest-role-{unique_name}"
policy_name = f"pytest-policy-{unique_name}"
iam_client.create_role(
RoleName=role_name, AssumeRolePolicyDocument=TRUST_POLICY
)
response = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=POLICY_DOC
)
policy_arn = response["Policy"]["Arn"]
try:
iam_client.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
response = iam_client.list_attached_role_policies(RoleName=role_name)
assert any(
p["PolicyArn"] == policy_arn for p in response["AttachedPolicies"]
)
finally:
iam_client.detach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
iam_client.delete_role(RoleName=role_name)
iam_client.delete_policy(PolicyArn=policy_arn)
def test_put_role_policy(self, iam_client, unique_name):
"""Test PutRolePolicy adds inline policy to role."""
role_name = f"pytest-role-{unique_name}"
inline_policy_name = "inline-exec"
iam_client.create_role(
RoleName=role_name, AssumeRolePolicyDocument=TRUST_POLICY
)
try:
iam_client.put_role_policy(
RoleName=role_name,
PolicyName=inline_policy_name,
PolicyDocument='{"Version":"2012-10-17"}',
)
response = iam_client.get_role_policy(
RoleName=role_name, PolicyName=inline_policy_name
)
assert response["PolicyName"] == inline_policy_name
response = iam_client.list_role_policies(RoleName=role_name)
assert inline_policy_name in response["PolicyNames"]
finally:
iam_client.delete_role_policy(
RoleName=role_name, PolicyName=inline_policy_name
)
iam_client.delete_role(RoleName=role_name)
class TestIAMUserPolicy:
"""Test IAM user policy operations."""
def test_attach_user_policy(self, iam_client, unique_name):
"""Test AttachUserPolicy attaches managed policy to user."""
user_name = f"pytest-user-{unique_name}"
policy_name = f"pytest-policy-{unique_name}"
iam_client.create_user(UserName=user_name)
response = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=POLICY_DOC
)
policy_arn = response["Policy"]["Arn"]
try:
iam_client.attach_user_policy(UserName=user_name, PolicyArn=policy_arn)
# If no exception, attachment succeeded
finally:
iam_client.detach_user_policy(UserName=user_name, PolicyArn=policy_arn)
iam_client.delete_user(UserName=user_name)
iam_client.delete_policy(PolicyArn=policy_arn)
def test_list_attached_user_policies(self, iam_client, unique_name):
"""Test ListAttachedUserPolicies returns attached managed policies."""
user_name = f"pytest-user-{unique_name}"
policy_name = f"pytest-policy-{unique_name}"
iam_client.create_user(UserName=user_name)
response = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=POLICY_DOC
)
policy_arn = response["Policy"]["Arn"]
iam_client.attach_user_policy(UserName=user_name, PolicyArn=policy_arn)
try:
response = iam_client.list_attached_user_policies(UserName=user_name)
assert any(
p["PolicyArn"] == policy_arn for p in response["AttachedPolicies"]
)
finally:
iam_client.detach_user_policy(UserName=user_name, PolicyArn=policy_arn)
iam_client.delete_user(UserName=user_name)
iam_client.delete_policy(PolicyArn=policy_arn)
@@ -0,0 +1,614 @@
from botocore.exceptions import ClientError
import json
import socket
import struct
def _csr_pem(common_name):
"""A real PKCS#10 request: the device keeps its key, Floci signs its public key as AWS does."""
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
csr = (
x509.CertificateSigningRequestBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
.sign(key, hashes.SHA256())
)
return csr.public_bytes(serialization.Encoding.PEM).decode("ascii")
def test_describe_endpoint(iot_client):
response = iot_client.describe_endpoint(endpointType="iot:Data-ATS")
assert response["endpointAddress"]
def test_thing_registry_crud(iot_client, unique_name):
thing_name = f"{unique_name}-thing"
other_thing_name = f"{unique_name}-other-thing"
missing = False
try:
iot_client.describe_thing(thingName=thing_name)
except ClientError as exc:
missing = exc.response["Error"]["Code"] == "ResourceNotFoundException"
assert missing
created = iot_client.create_thing(
thingName=thing_name,
attributePayload={"attributes": {"env": "python"}},
)
assert created["thingName"] == thing_name
assert created["thingArn"].endswith(f":thing/{thing_name}")
idempotent = iot_client.create_thing(
thingName=thing_name,
attributePayload={"attributes": {"env": "python"}},
)
assert idempotent["thingName"] == thing_name
try:
iot_client.create_thing(thingName=thing_name)
raise AssertionError("duplicate create should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceAlreadyExistsException"
described = iot_client.describe_thing(thingName=thing_name)
assert described["attributes"]["env"] == "python"
iot_client.create_thing(thingName=other_thing_name)
listed = iot_client.list_things()
assert any(thing["thingName"] == thing_name for thing in listed["things"])
first_page = iot_client.list_things(maxResults=1)
assert len(first_page["things"]) == 1
assert first_page.get("nextToken")
second_page = iot_client.list_things(maxResults=1, nextToken=first_page["nextToken"])
assert len(second_page["things"]) == 1
iot_client.update_thing(
thingName=thing_name,
attributePayload={"attributes": {"env": "updated", "owner": "iot"}},
)
iot_client.update_thing(
thingName=thing_name,
expectedVersion=2,
attributePayload={"attributes": {"env": "versioned", "owner": "iot"}},
)
try:
iot_client.update_thing(
thingName=thing_name,
expectedVersion=2,
attributePayload={"attributes": {"env": "stale"}},
)
raise AssertionError("stale expectedVersion should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "VersionConflictException"
updated = iot_client.describe_thing(thingName=thing_name)
assert updated["attributes"] == {"env": "versioned", "owner": "iot"}
iot_client.delete_thing(thingName=thing_name)
iot_client.delete_thing(thingName=other_thing_name)
try:
iot_client.describe_thing(thingName=thing_name)
raise AssertionError("describe after delete should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
def test_thing_tags(iot_client, unique_name):
thing_name = f"{unique_name}-tagged-thing"
created = iot_client.create_thing(thingName=thing_name)
thing_arn = created["thingArn"]
listed = iot_client.list_tags_for_resource(resourceArn=thing_arn)
assert listed["tags"] == []
iot_client.tag_resource(
resourceArn=thing_arn,
tags=[{"Key": "env", "Value": "python"}, {"Key": "owner", "Value": "iot"}],
)
tags = iot_client.list_tags_for_resource(resourceArn=thing_arn)["tags"]
assert {tag["Key"]: tag["Value"] for tag in tags} == {"env": "python", "owner": "iot"}
iot_client.untag_resource(resourceArn=thing_arn, tagKeys=["env"])
tags = iot_client.list_tags_for_resource(resourceArn=thing_arn)["tags"]
assert {tag["Key"]: tag["Value"] for tag in tags} == {"owner": "iot"}
try:
iot_client.list_tags_for_resource(
resourceArn="arn:aws:iot:us-east-1:000000000000:thing/missing-tagged-thing"
)
raise AssertionError("listing tags on a missing thing should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
def test_certificates_policies_and_attachments(iot_client, unique_name):
created_cert = iot_client.create_keys_and_certificate(setAsActive=True)
cert_id = created_cert["certificateId"]
cert_arn = created_cert["certificateArn"]
assert "BEGIN CERTIFICATE" in created_cert["certificatePem"]
assert "PublicKey" in created_cert["keyPair"]
assert "PrivateKey" in created_cert["keyPair"]
described = iot_client.describe_certificate(certificateId=cert_id)
assert described["certificateDescription"]["status"] == "ACTIVE"
listed = iot_client.list_certificates()
assert any(cert["certificateArn"] == cert_arn for cert in listed["certificates"])
iot_client.update_certificate(certificateId=cert_id, newStatus="INACTIVE")
described = iot_client.describe_certificate(certificateId=cert_id)
assert described["certificateDescription"]["status"] == "INACTIVE"
policy_name = f"{unique_name}-policy"
policy_document = json.dumps({"Version": "2012-10-17", "Statement": []})
created_policy = iot_client.create_policy(policyName=policy_name, policyDocument=policy_document)
assert created_policy["policyName"] == policy_name
got_policy = iot_client.get_policy(policyName=policy_name)
assert json.loads(got_policy["policyDocument"])["Version"] == "2012-10-17"
listed_policies = iot_client.list_policies()
assert any(policy["policyName"] == policy_name for policy in listed_policies["policies"])
iot_client.attach_policy(policyName=policy_name, target=cert_arn)
iot_client.detach_policy(policyName=policy_name, target=cert_arn)
thing_name = f"{unique_name}-principal-thing"
iot_client.create_thing(thingName=thing_name)
iot_client.attach_thing_principal(thingName=thing_name, principal=cert_arn)
principals = iot_client.list_thing_principals(thingName=thing_name)
assert cert_arn in principals["principals"]
principal_things = iot_client.list_principal_things(principal=cert_arn)
assert thing_name in principal_things["things"]
iot_client.detach_thing_principal(thingName=thing_name, principal=cert_arn)
def test_certificate_delete_csr_policy_versions_and_attachment_reads(iot_client, unique_name):
active_cert = iot_client.create_keys_and_certificate(setAsActive=True)
try:
iot_client.delete_certificate(certificateId=active_cert["certificateId"])
raise AssertionError("active certificate delete should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "InvalidRequestException"
try:
iot_client.update_certificate(
certificateId=active_cert["certificateId"], newStatus="PENDING_TRANSFER"
)
raise AssertionError("unsupported certificate status should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "InvalidRequestException"
iot_client.update_certificate(certificateId=active_cert["certificateId"], newStatus="INACTIVE")
iot_client.delete_certificate(certificateId=active_cert["certificateId"])
try:
iot_client.describe_certificate(certificateId=active_cert["certificateId"])
raise AssertionError("deleted certificate should be missing")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
csr_cert = iot_client.create_certificate_from_csr(
certificateSigningRequest=_csr_pem("device-from-csr"),
setAsActive=False,
)
assert "BEGIN CERTIFICATE" in csr_cert["certificatePem"]
assert "keyPair" not in csr_cert
policy_name = f"{unique_name}-versioned-policy"
policy_document = json.dumps({"Version": "2012-10-17", "Statement": []})
iot_client.create_policy(policyName=policy_name, policyDocument=policy_document)
try:
iot_client.create_policy(policyName=policy_name, policyDocument=policy_document)
raise AssertionError("duplicate policy create should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceAlreadyExistsException"
version = iot_client.create_policy_version(
policyName=policy_name,
policyDocument=json.dumps(
{"Version": "2012-10-17", "Statement": [{"Effect": "Allow"}]}
),
setAsDefault=True,
)
version_id = version["policyVersionId"]
listed_versions = iot_client.list_policy_versions(policyName=policy_name)
assert any(item["versionId"] == version_id for item in listed_versions["policyVersions"])
got_version = iot_client.get_policy_version(policyName=policy_name, policyVersionId=version_id)
assert "Allow" in got_version["policyDocument"]
iot_client.set_default_policy_version(policyName=policy_name, policyVersionId="1")
iot_client.delete_policy_version(policyName=policy_name, policyVersionId=version_id)
iot_client.attach_policy(policyName=policy_name, target=csr_cert["certificateArn"])
attached = iot_client.list_attached_policies(target=csr_cert["certificateArn"])
assert any(policy["policyName"] == policy_name for policy in attached["policies"])
targets = iot_client.list_targets_for_policy(policyName=policy_name)
assert csr_cert["certificateArn"] in targets["targets"]
try:
iot_client.delete_policy(policyName=policy_name)
raise AssertionError("attached policy delete should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "InvalidRequestException"
iot_client.detach_policy(policyName=policy_name, target=csr_cert["certificateArn"])
iot_client.delete_policy(policyName=policy_name)
try:
iot_client.get_policy(policyName=policy_name)
raise AssertionError("deleted policy should be missing")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
iot_client.delete_certificate(certificateId=csr_cert["certificateId"])
def test_iot_data_shadows_and_publish(iot_data_client, unique_name):
thing_name = f"{unique_name}-shadow-thing"
try:
iot_data_client.get_thing_shadow(thingName=thing_name)
raise AssertionError("missing shadow should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
updated = iot_data_client.update_thing_shadow(
thingName=thing_name,
payload=json.dumps({"state": {"desired": {"color": "blue"}}}).encode(),
)
assert json.loads(updated["payload"].read())["version"] == 1
iot_data_client.update_thing_shadow(
thingName=thing_name,
payload=json.dumps({"state": {"reported": {"color": "green"}}}).encode(),
)
got = json.loads(iot_data_client.get_thing_shadow(thingName=thing_name)["payload"].read())
assert got["state"]["desired"]["color"] == "blue"
assert got["state"]["reported"]["color"] == "green"
iot_data_client.update_thing_shadow(
thingName=thing_name,
shadowName="settings",
payload=json.dumps({"state": {"desired": {"mode": "auto"}}}).encode(),
)
named = iot_data_client.list_named_shadows_for_thing(thingName=thing_name)
assert "settings" in named["results"]
iot_data_client.publish(topic=f"devices/{thing_name}/events", payload=b"payload")
iot_data_client.delete_thing_shadow(thingName=thing_name, shadowName="settings")
iot_data_client.delete_thing_shadow(thingName=thing_name)
def test_iot_data_retained_messages_and_shadow_conflicts(iot_data_client, unique_name):
topic = f"devices/{unique_name}/retained"
iot_data_client.publish(topic=topic, payload=b"retained-payload", retain=True, qos=1)
retained = iot_data_client.get_retained_message(topic=topic)
assert retained["topic"] == topic
assert retained["payload"] == b"retained-payload"
assert retained["qos"] == 1
other_topic = f"devices/{unique_name}/retained-other"
iot_data_client.publish(topic=other_topic, payload=b"other", retain=True)
first_page = iot_data_client.list_retained_messages(maxResults=1)
assert len(first_page["retainedTopics"]) == 1
assert first_page.get("nextToken")
second_page = iot_data_client.list_retained_messages(maxResults=1, nextToken=first_page["nextToken"])
assert len(second_page["retainedTopics"]) == 1
iot_data_client.publish(topic=topic, payload=b"", retain=True)
try:
iot_data_client.get_retained_message(topic=topic)
raise AssertionError("empty retained publish should delete retained message")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceNotFoundException"
thing_name = f"{unique_name}-shadow-version"
iot_data_client.update_thing_shadow(
thingName=thing_name,
payload=json.dumps({"state": {"desired": {"color": "blue", "mode": "auto"}}}).encode(),
)
updated = iot_data_client.update_thing_shadow(
thingName=thing_name,
payload=json.dumps({"version": 1, "state": {"desired": {"color": None}}}).encode(),
)
document = json.loads(updated["payload"].read())
assert "color" not in document["state"]["desired"]
assert document["state"]["desired"]["mode"] == "auto"
try:
iot_data_client.update_thing_shadow(
thingName=thing_name,
payload=json.dumps({"version": 1, "state": {"desired": {"mode": "manual"}}}).encode(),
)
raise AssertionError("stale shadow update should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "VersionConflictException"
def test_topic_rule_crud_and_sqs_action(iot_client, iot_data_client, sqs_client, unique_name):
rule_name = f"{unique_name}-rule"
queue_name = f"{unique_name}-iot-rule-queue"
queue_url = sqs_client.create_queue(QueueName=queue_name)["QueueUrl"]
try:
iot_client.create_topic_rule(
ruleName=rule_name,
topicRulePayload={
"sql": f"SELECT * FROM 'devices/{unique_name}/rules'",
"description": "python topic rule",
"ruleDisabled": False,
"actions": [
{
"sqs": {
"roleArn": "arn:aws:iam::000000000000:role/iot-rule-role",
"queueUrl": queue_url,
"useBase64": False,
}
}
],
},
)
got = iot_client.get_topic_rule(ruleName=rule_name)
assert got["rule"]["ruleName"] == rule_name
assert got["rule"]["sql"] == f"SELECT * FROM 'devices/{unique_name}/rules'"
assert got["rule"]["actions"][0]["sqs"]["queueUrl"] == queue_url
try:
iot_client.create_topic_rule(
ruleName=rule_name,
topicRulePayload={"sql": f"SELECT * FROM 'devices/{unique_name}/rules'", "actions": []},
)
raise AssertionError("duplicate topic rule create should fail")
except ClientError as exc:
assert exc.response["Error"]["Code"] == "ResourceAlreadyExistsException"
iot_client.replace_topic_rule(
ruleName=rule_name,
topicRulePayload={"sql": f"SELECT * FROM 'devices/{unique_name}/rules'", "actions": got["rule"]["actions"]},
)
iot_client.disable_topic_rule(ruleName=rule_name)
listed = iot_client.list_topic_rules()
assert any(rule["ruleName"] == rule_name and rule["ruleDisabled"] for rule in listed["rules"])
iot_client.enable_topic_rule(ruleName=rule_name)
iot_data_client.publish(topic=f"devices/{unique_name}/rules", payload=b"python-rule-payload")
received = sqs_client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=1)
assert received["Messages"][0]["Body"] == "python-rule-payload"
finally:
try:
iot_client.delete_topic_rule(ruleName=rule_name)
except ClientError:
pass
sqs_client.delete_queue(QueueUrl=queue_url)
def test_thing_types_groups_and_jobs(iot_client, iot_jobs_data_client, unique_name):
thing_type = f"{unique_name}-type"
thing_name = f"{unique_name}-typed-thing"
group_name = f"{unique_name}-group"
job_id = f"{unique_name}-job"
endpoint = iot_client.describe_endpoint(endpointType="iot:Jobs")
assert endpoint["endpointAddress"]
created_type = iot_client.create_thing_type(
thingTypeName=thing_type,
thingTypeProperties={"thingTypeDescription": "python type", "searchableAttributes": ["model"]},
)
assert created_type["thingTypeName"] == thing_type
described_type = iot_client.describe_thing_type(thingTypeName=thing_type)
assert described_type["thingTypeProperties"]["thingTypeDescription"] == "python type"
assert "model" in described_type["thingTypeProperties"]["searchableAttributes"]
assert any(item["thingTypeName"] == thing_type for item in iot_client.list_thing_types()["thingTypes"])
iot_client.update_thing_type(
thingTypeName=thing_type,
thingTypeProperties={"thingTypeDescription": "python type updated", "searchableAttributes": ["model", "fw"]},
)
thing = iot_client.create_thing(
thingName=thing_name,
thingTypeName=thing_type,
attributePayload={"attributes": {"model": "p1"}},
)
thing_arn = thing["thingArn"]
assert iot_client.describe_thing(thingName=thing_name)["thingTypeName"] == thing_type
group = iot_client.create_thing_group(
thingGroupName=group_name,
thingGroupProperties={
"thingGroupDescription": "python group",
"attributePayload": {"attributes": {"fleet": "python"}},
},
)
assert group["thingGroupName"] == group_name
described_group = iot_client.describe_thing_group(thingGroupName=group_name)
assert described_group["thingGroupProperties"]["attributePayload"]["attributes"]["fleet"] == "python"
iot_client.add_thing_to_thing_group(thingGroupName=group_name, thingName=thing_name)
assert thing_name in iot_client.list_things_in_thing_group(thingGroupName=group_name)["things"]
assert any(item["groupName"] == group_name for item in iot_client.list_thing_groups_for_thing(thingName=thing_name)["thingGroups"])
created_job = iot_client.create_job(
jobId=job_id,
targets=[thing_arn],
document=json.dumps({"operation": "reboot"}),
description="python job",
)
assert created_job["jobId"] == job_id
assert iot_client.describe_job(jobId=job_id)["job"]["status"] == "IN_PROGRESS"
assert any(job["jobId"] == job_id for job in iot_client.list_jobs()["jobs"])
assert any(item["jobId"] == job_id for item in iot_client.list_job_executions_for_thing(thingName=thing_name)["executionSummaries"])
pending = iot_jobs_data_client.get_pending_job_executions(thingName=thing_name)
assert any(job["jobId"] == job_id for job in pending["queuedJobs"])
started = iot_jobs_data_client.start_next_pending_job_execution(
thingName=thing_name,
statusDetails={"phase": "download"},
)
assert started["execution"]["status"] == "IN_PROGRESS"
updated = iot_jobs_data_client.update_job_execution(
thingName=thing_name,
jobId=job_id,
status="SUCCEEDED",
expectedVersion=2,
includeJobExecutionState=True,
includeJobDocument=True,
)
assert updated["executionState"]["status"] == "SUCCEEDED"
assert json.loads(updated["jobDocument"])["operation"] == "reboot"
iot_client.remove_thing_from_thing_group(thingGroupName=group_name, thingName=thing_name)
iot_client.delete_thing_group(thingGroupName=group_name)
iot_client.delete_thing(thingName=thing_name)
iot_client.deprecate_thing_type(thingTypeName=thing_type)
iot_client.delete_thing_type(thingTypeName=thing_type)
def test_mqtt_connect_publish_subscribe(unique_name):
topic = f"devices/{unique_name}/mqtt"
payload = b"python-mqtt"
with mqtt_connect(f"{unique_name}-sub") as subscriber:
mqtt_subscribe(subscriber, topic)
with mqtt_connect(f"{unique_name}-pub") as publisher:
mqtt_publish(publisher, topic, payload)
received_topic, received_payload = mqtt_read_publish(subscriber)
assert received_topic == topic
assert received_payload == payload
def test_mqtt5_connect(unique_name):
with mqtt5_connect(f"{unique_name}-mqtt5") as client:
assert client
def test_mqtt_shadow_reserved_topics(unique_name):
thing_name = f"{unique_name}-shadow"
with mqtt_connect(f"{unique_name}-shadow-sub") as subscriber:
mqtt_subscribe(subscriber, f"$aws/things/{thing_name}/shadow/update/accepted")
mqtt_subscribe(subscriber, f"$aws/things/{thing_name}/shadow/get/accepted")
mqtt_subscribe(subscriber, f"$aws/things/{thing_name}/shadow/delete/accepted")
with mqtt_connect(f"{unique_name}-shadow-pub") as publisher:
mqtt_publish(
publisher,
f"$aws/things/{thing_name}/shadow/update",
json.dumps({"state": {"desired": {"color": "blue"}}, "clientToken": "update-token"}).encode(),
)
topic, payload = mqtt_read_publish(subscriber)
assert topic == f"$aws/things/{thing_name}/shadow/update/accepted"
accepted = json.loads(payload)
assert accepted["state"]["desired"]["color"] == "blue"
assert accepted["clientToken"] == "update-token"
mqtt_publish(publisher, f"$aws/things/{thing_name}/shadow/get", b'{"clientToken":"get-token"}')
topic, payload = mqtt_read_publish(subscriber)
assert topic == f"$aws/things/{thing_name}/shadow/get/accepted"
got = json.loads(payload)
assert got["state"]["desired"]["color"] == "blue"
assert got["clientToken"] == "get-token"
mqtt_publish(publisher, f"$aws/things/{thing_name}/shadow/delete", b'{"clientToken":"delete-token"}')
topic, payload = mqtt_read_publish(subscriber)
assert topic == f"$aws/things/{thing_name}/shadow/delete/accepted"
deleted = json.loads(payload)
assert deleted["state"]["desired"]["color"] == "blue"
assert deleted["clientToken"] == "delete-token"
def mqtt_connect(client_id):
client = socket.create_connection(("floci", 1883), timeout=5)
client.settimeout(5)
body = mqtt_utf8("MQTT") + bytes([4, 2, 0, 60]) + mqtt_utf8(client_id)
client.sendall(bytes([0x10]) + mqtt_remaining_length(len(body)) + body)
assert mqtt_read_packet(client) == b"\x20\x02\x00\x00"
return client
def mqtt5_connect(client_id):
client = socket.create_connection(("floci", 1883), timeout=5)
client.settimeout(5)
body = mqtt_utf8("MQTT") + bytes([5, 2, 0, 60, 0]) + mqtt_utf8(client_id)
client.sendall(bytes([0x10]) + mqtt_remaining_length(len(body)) + body)
mqtt_assert_v5_connack(mqtt_read_packet(client))
return client
def mqtt_assert_v5_connack(packet):
assert packet[0] == 0x20
index = 1
while packet[index] & 0x80:
index += 1
index += 1
assert packet[index] == 0x00
assert packet[index + 1] == 0x00
def mqtt_subscribe(client, topic):
body = b"\x00\x01" + mqtt_utf8(topic) + b"\x00"
client.sendall(bytes([0x82]) + mqtt_remaining_length(len(body)) + body)
assert mqtt_read_packet(client) == b"\x90\x03\x00\x01\x00"
def mqtt_publish(client, topic, payload):
body = mqtt_utf8(topic) + payload
client.sendall(bytes([0x30]) + mqtt_remaining_length(len(body)) + body)
def mqtt_read_publish(client):
packet = mqtt_read_packet(client)
assert packet[0] & 0xF0 == 0x30
index = 1
while packet[index] & 0x80:
index += 1
index += 1
topic_length = struct.unpack("!H", packet[index:index + 2])[0]
index += 2
topic = packet[index:index + topic_length].decode()
index += topic_length
return topic, packet[index:]
def mqtt_read_packet(client):
first = client.recv(1)
assert first
remaining = 0
multiplier = 1
encoded_bytes = bytearray()
while True:
encoded = client.recv(1)
assert encoded
encoded_bytes.extend(encoded)
remaining += (encoded[0] & 127) * multiplier
multiplier *= 128
if encoded[0] & 128 == 0:
break
body = client.recv(remaining)
assert len(body) == remaining
return first + bytes(encoded_bytes) + body
def mqtt_utf8(value):
encoded = value.encode()
return struct.pack("!H", len(encoded)) + encoded
def mqtt_remaining_length(length):
encoded = bytearray()
value = length
while True:
byte = value % 128
value //= 128
if value:
byte |= 128
encoded.append(byte)
if not value:
return bytes(encoded)
@@ -0,0 +1,161 @@
"""Kinesis integration tests."""
import os
import pytest
# Disable CBOR — use JSON 1.1 as the emulator supports
os.environ["AWS_CBOR_DISABLE"] = "true"
class TestKinesisStream:
"""Test Kinesis stream operations."""
def test_create_stream(self, kinesis_client, unique_name):
"""Test CreateStream creates a stream."""
stream_name = f"pytest-stream-{unique_name}"
try:
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
# If no exception, test passes
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_list_streams(self, kinesis_client, unique_name):
"""Test ListStreams includes created stream."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
response = kinesis_client.list_streams()
assert stream_name in response["StreamNames"]
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_describe_stream(self, kinesis_client, unique_name):
"""Test DescribeStream returns stream details."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
response = kinesis_client.describe_stream(StreamName=stream_name)
assert response["StreamDescription"]["StreamName"] == stream_name
assert response["StreamDescription"]["Shards"]
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_describe_stream_summary(self, kinesis_client, unique_name):
"""Test DescribeStreamSummary returns summary."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
response = kinesis_client.describe_stream_summary(StreamName=stream_name)
assert response["StreamDescriptionSummary"]["StreamName"] == stream_name
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_delete_stream(self, kinesis_client, unique_name):
"""Test DeleteStream removes stream."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
kinesis_client.delete_stream(StreamName=stream_name)
response = kinesis_client.list_streams()
assert stream_name not in response.get("StreamNames", [])
class TestKinesisRecords:
"""Test Kinesis record operations."""
def test_put_record(self, kinesis_client, unique_name):
"""Test PutRecord writes record to stream."""
stream_name = f"pytest-stream-{unique_name}"
data = b"hello kinesis pytest"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
response = kinesis_client.put_record(
StreamName=stream_name, Data=data, PartitionKey="pk1"
)
assert response.get("SequenceNumber")
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_get_records(self, kinesis_client, unique_name):
"""Test GetRecords retrieves records from stream."""
stream_name = f"pytest-stream-{unique_name}"
data = b"hello kinesis pytest"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
# Put a record
kinesis_client.put_record(
StreamName=stream_name, Data=data, PartitionKey="pk1"
)
# Get shard iterator
describe_response = kinesis_client.describe_stream(StreamName=stream_name)
shard_id = describe_response["StreamDescription"]["Shards"][0]["ShardId"]
iterator_response = kinesis_client.get_shard_iterator(
StreamName=stream_name,
ShardId=shard_id,
ShardIteratorType="TRIM_HORIZON",
)
shard_iterator = iterator_response["ShardIterator"]
# Get records
response = kinesis_client.get_records(ShardIterator=shard_iterator)
found = any(rec["Data"] == data for rec in response["Records"])
assert found
finally:
kinesis_client.delete_stream(StreamName=stream_name)
def test_put_records_batch(self, kinesis_client, unique_name):
"""Test PutRecords writes multiple records."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
response = kinesis_client.put_records(
StreamName=stream_name,
Records=[
{"Data": b"batch1", "PartitionKey": "pk1"},
{"Data": b"batch2", "PartitionKey": "pk2"},
],
)
assert response["FailedRecordCount"] == 0
assert len(response["Records"]) == 2
finally:
kinesis_client.delete_stream(StreamName=stream_name)
class TestKinesisTags:
"""Test Kinesis tagging operations."""
def test_add_tags_to_stream(self, kinesis_client, unique_name):
"""Test AddTagsToStream adds tags."""
stream_name = f"pytest-stream-{unique_name}"
kinesis_client.create_stream(StreamName=stream_name, ShardCount=1)
try:
kinesis_client.add_tags_to_stream(
StreamName=stream_name, Tags={"project": "floci"}
)
response = kinesis_client.list_tags_for_stream(StreamName=stream_name)
assert any(
t["Key"] == "project" and t["Value"] == "floci" for t in response["Tags"]
)
finally:
kinesis_client.delete_stream(StreamName=stream_name)
@@ -0,0 +1,438 @@
"""KMS integration tests."""
import hashlib
import boto3
import pytest
from botocore.exceptions import ClientError
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding
# Every signing algorithm each key spec declares. RSA key generation is the only
# real cost here and the fixture below creates one key per spec, so covering the
# full matrix is cheaper than the per-case keys it replaced.
RSA_SIGNING_ALGORITHMS = [
("RSASSA_PSS_SHA_256", hashes.SHA256),
("RSASSA_PSS_SHA_384", hashes.SHA384),
("RSASSA_PSS_SHA_512", hashes.SHA512),
("RSASSA_PKCS1_V1_5_SHA_256", hashes.SHA256),
("RSASSA_PKCS1_V1_5_SHA_384", hashes.SHA384),
("RSASSA_PKCS1_V1_5_SHA_512", hashes.SHA512),
]
SIGNING_CASES = [
(key_spec, algorithm, digest)
for key_spec in ("RSA_2048", "RSA_3072", "RSA_4096")
for algorithm, digest in RSA_SIGNING_ALGORITHMS
] + [
("ECC_NIST_P256", "ECDSA_SHA_256", hashes.SHA256),
("ECC_NIST_P384", "ECDSA_SHA_384", hashes.SHA384),
("ECC_NIST_P521", "ECDSA_SHA_512", hashes.SHA512),
# secp256k1 is signed with BouncyCastle's lightweight signer, reached directly
# rather than through a JCA provider, and the JDK dropped the curve. It is the
# crypto path most likely to break in a native image.
("ECC_SECG_P256K1", "ECDSA_SHA_256", hashes.SHA256),
]
class TestKMSKey:
"""Test KMS key operations."""
def test_create_key(self, kms_client):
"""Test CreateKey creates a key."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
assert key_id
# Cleanup
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_describe_key(self, kms_client):
"""Test DescribeKey returns key metadata."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
response = kms_client.describe_key(KeyId=key_id)
assert response["KeyMetadata"]["KeyId"] == key_id
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_schedule_key_deletion(self, kms_client):
"""Test ScheduleKeyDeletion marks key for deletion."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
response = kms_client.describe_key(KeyId=key_id)
assert response["KeyMetadata"]["KeyState"] == "PendingDeletion"
def test_create_key_rejects_incompatible_key_usage(self, kms_client):
"""CreateKey rejects a KeyUsage that AWS KMS does not allow for the KeySpec."""
with pytest.raises(ClientError) as excinfo:
kms_client.create_key(
KeySpec="ECC_NIST_EDWARDS25519",
KeyUsage="ENCRYPT_DECRYPT",
)
assert excinfo.value.response["Error"]["Code"] == "ValidationException"
class TestKMSGrants:
"""Test KMS grant operations."""
def test_list_grants(self, kms_client):
"""Test ListGrants returns an empty grant list for a new key."""
response = kms_client.create_key(Description="pytest-grant-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
response = kms_client.list_grants(KeyId=key_id)
assert response["Grants"] == []
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_list_grants_paginator(self, kms_client):
"""Test ListGrants paginator returns pages with Grants."""
response = kms_client.create_key(Description="pytest-grant-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
paginator = kms_client.get_paginator("list_grants")
pages = list(paginator.paginate(KeyId=key_id))
assert pages
assert all("Grants" in page for page in pages)
assert [grant for page in pages for grant in page["Grants"]] == []
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
class TestKMSAlias:
"""Test KMS alias operations."""
def test_create_alias(self, kms_client, unique_name):
"""Test CreateAlias creates an alias."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
alias_name = f"alias/pytest-key-{unique_name}"
try:
kms_client.create_alias(AliasName=alias_name, TargetKeyId=key_id)
# If no exception, test passes
finally:
kms_client.delete_alias(AliasName=alias_name)
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_list_aliases(self, kms_client, unique_name):
"""Test ListAliases returns aliases."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
alias_name = f"alias/pytest-key-{unique_name}"
kms_client.create_alias(AliasName=alias_name, TargetKeyId=key_id)
try:
response = kms_client.list_aliases()
assert any(a["AliasName"] == alias_name for a in response["Aliases"])
finally:
kms_client.delete_alias(AliasName=alias_name)
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_delete_alias(self, kms_client, unique_name):
"""Test DeleteAlias removes alias."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
alias_name = f"alias/pytest-key-{unique_name}"
kms_client.create_alias(AliasName=alias_name, TargetKeyId=key_id)
kms_client.delete_alias(AliasName=alias_name)
response = kms_client.list_aliases()
assert not any(
a["AliasName"] == alias_name for a in response.get("Aliases", [])
)
# Cleanup
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
class TestKMSEncryption:
"""Test KMS encryption operations."""
def test_encrypt_decrypt(self, kms_client):
"""Test Encrypt and Decrypt roundtrip."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
plaintext = b"secret data"
try:
encrypt_response = kms_client.encrypt(KeyId=key_id, Plaintext=plaintext)
ciphertext = encrypt_response["CiphertextBlob"]
assert ciphertext
decrypt_response = kms_client.decrypt(CiphertextBlob=ciphertext)
assert decrypt_response["Plaintext"] == plaintext
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_encrypt_using_alias(self, kms_client, unique_name):
"""Test Encrypt using alias."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
alias_name = f"alias/pytest-key-{unique_name}"
kms_client.create_alias(AliasName=alias_name, TargetKeyId=key_id)
try:
response = kms_client.encrypt(KeyId=alias_name, Plaintext=b"alias data")
assert response.get("CiphertextBlob")
finally:
kms_client.delete_alias(AliasName=alias_name)
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_generate_data_key(self, kms_client):
"""Test GenerateDataKey generates plaintext and ciphertext."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
response = kms_client.generate_data_key(KeyId=key_id, KeySpec="AES_256")
assert response.get("Plaintext")
assert response.get("CiphertextBlob")
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_generate_data_key_without_plaintext(self, kms_client):
"""Test GenerateDataKeyWithoutPlaintext returns only ciphertext."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
response = kms_client.generate_data_key_without_plaintext(
KeyId=key_id, KeySpec="AES_256"
)
assert response.get("CiphertextBlob")
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
def test_re_encrypt(self, kms_client):
"""Test ReEncrypt re-encrypts data with different key."""
response1 = kms_client.create_key(Description="pytest-test-key-1")
key_id1 = response1["KeyMetadata"]["KeyId"]
response2 = kms_client.create_key(Description="pytest-test-key-2")
key_id2 = response2["KeyMetadata"]["KeyId"]
plaintext = b"secret data"
encrypt_response = kms_client.encrypt(KeyId=key_id1, Plaintext=plaintext)
ciphertext = encrypt_response["CiphertextBlob"]
try:
reencrypt_response = kms_client.re_encrypt(
CiphertextBlob=ciphertext, DestinationKeyId=key_id2
)
new_ciphertext = reencrypt_response["CiphertextBlob"]
assert new_ciphertext
decrypt_response = kms_client.decrypt(CiphertextBlob=new_ciphertext)
assert decrypt_response["Plaintext"] == plaintext
finally:
kms_client.schedule_key_deletion(KeyId=key_id1, PendingWindowInDays=7)
kms_client.schedule_key_deletion(KeyId=key_id2, PendingWindowInDays=7)
class TestKMSSigning:
"""Test KMS signing operations."""
@pytest.fixture(scope="class")
@staticmethod
def signing_key(aws_config, client_config):
"""Return one signing key per key spec, created on first use.
RSA key generation dominates the runtime of this class, so the key is
shared across every algorithm tested against it.
"""
client = boto3.client("kms", config=client_config, **aws_config)
created = {}
def key_for(key_spec):
if key_spec not in created:
created[key_spec] = client.create_key(
Description="pytest-signing-key",
KeyUsage="SIGN_VERIFY",
KeySpec=key_spec,
)["KeyMetadata"]["KeyId"]
return created[key_spec]
yield key_for
for key_id in created.values():
client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
@pytest.mark.parametrize("key_spec,signing_algorithm,digest", SIGNING_CASES)
def test_sign_and_verify(
self, kms_client, signing_key, key_spec, signing_algorithm, digest
):
"""Test Sign and Verify, and check the signature against an outside library."""
key_id = signing_key(key_spec)
message = b"message to sign"
# Sign the message
sign_response = kms_client.sign(
KeyId=key_id,
Message=message,
SigningAlgorithm=signing_algorithm,
)
signature = sign_response["Signature"]
assert signature
# Verify the signature
verify_response = kms_client.verify(
KeyId=key_id,
Message=message,
Signature=signature,
SigningAlgorithm=signing_algorithm,
)
assert verify_response["SignatureValid"]
# Sign and Verify are both Floci, so agreeing with each other proves
# nothing about the padding, the mask generation function or the salt
# length. Check the signature against GetPublicKey with the parameters
# AWS documents for this algorithm instead.
public_key = serialization.load_der_public_key(
kms_client.get_public_key(KeyId=key_id)["PublicKey"]
)
if signing_algorithm.startswith("ECDSA"):
public_key.verify(signature, message, ec.ECDSA(digest()))
else:
if signing_algorithm.startswith("RSASSA_PSS"):
scheme = padding.PSS(
mgf=padding.MGF1(digest()), salt_length=digest.digest_size
)
else:
scheme = padding.PKCS1v15()
public_key.verify(signature, message, scheme, digest())
def test_ed25519_sign_and_verify(self, kms_client):
"""Test Ed25519 signing, checked against an outside library and real KMS rules."""
key_id = kms_client.create_key(
Description="pytest-ed25519-key",
KeyUsage="SIGN_VERIFY",
KeySpec="ECC_NIST_EDWARDS25519",
)["KeyMetadata"]["KeyId"]
message = b"message to sign"
digest = hashlib.sha512(message).digest()
try:
# Real KMS returns a 44 byte SubjectPublicKeyInfo holding a 32 byte Ed25519
# point. A NIST P-521 key, which this key spec used to produce, is far larger.
public_key = serialization.load_der_public_key(
kms_client.get_public_key(KeyId=key_id)["PublicKey"]
)
assert type(public_key).__name__ == "Ed25519PublicKey"
# ED25519_SHA_512 is pure Ed25519 over the message, so an outside library
# verifies it directly.
signature = kms_client.sign(
KeyId=key_id,
Message=message,
MessageType="RAW",
SigningAlgorithm="ED25519_SHA_512",
)["Signature"]
assert len(signature) == 64
public_key.verify(signature, message)
assert kms_client.verify(
KeyId=key_id,
Message=message,
MessageType="RAW",
Signature=signature,
SigningAlgorithm="ED25519_SHA_512",
)["SignatureValid"]
# ED25519_PH_SHA_512 requires DIGEST and pre-hashes the bytes it is given,
# so it is a different signature over the same input.
prehash_signature = kms_client.sign(
KeyId=key_id,
Message=digest,
MessageType="DIGEST",
SigningAlgorithm="ED25519_PH_SHA_512",
)["Signature"]
assert len(prehash_signature) == 64
assert prehash_signature != signature
assert kms_client.verify(
KeyId=key_id,
Message=digest,
MessageType="DIGEST",
Signature=prehash_signature,
SigningAlgorithm="ED25519_PH_SHA_512",
)["SignatureValid"]
# Each algorithm takes one message type, the way real KMS enforces it.
for algorithm, message_type in [
("ED25519_SHA_512", "DIGEST"),
("ED25519_PH_SHA_512", "RAW"),
]:
with pytest.raises(ClientError) as excinfo:
kms_client.sign(
KeyId=key_id,
Message=digest if message_type == "DIGEST" else message,
MessageType=message_type,
SigningAlgorithm=algorithm,
)
assert excinfo.value.response["Error"]["Code"] == "ValidationException"
# A DIGEST for the pre-hash algorithm has to be one SHA-512 digest. Real KMS
# checks the length on Sign and on Verify.
with pytest.raises(ClientError) as excinfo:
kms_client.sign(
KeyId=key_id,
Message=b"not a sha-512 digest",
MessageType="DIGEST",
SigningAlgorithm="ED25519_PH_SHA_512",
)
assert "Digest is invalid length" in excinfo.value.response["Error"]["Message"]
with pytest.raises(ClientError) as excinfo:
kms_client.verify(
KeyId=key_id,
Message=b"not a sha-512 digest",
MessageType="DIGEST",
Signature=prehash_signature,
SigningAlgorithm="ED25519_PH_SHA_512",
)
assert "Digest is invalid length" in excinfo.value.response["Error"]["Message"]
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
class TestKMSTagging:
"""Test KMS tagging operations."""
def test_tag_resource(self, kms_client):
"""Test TagResource and ListResourceTags."""
response = kms_client.create_key(Description="pytest-test-key")
key_id = response["KeyMetadata"]["KeyId"]
try:
kms_client.tag_resource(
KeyId=key_id, Tags=[{"TagKey": "Project", "TagValue": "Floci"}]
)
response = kms_client.list_resource_tags(KeyId=key_id)
assert any(
t["TagKey"] == "Project" and t["TagValue"] == "Floci"
for t in response["Tags"]
)
finally:
kms_client.schedule_key_deletion(KeyId=key_id, PendingWindowInDays=7)
class TestKMSGenerateRandom:
"""Test KMS GenerateRandom operation."""
def test_generate_random(self, kms_client):
"""Test GenerateRandom returns random bytes."""
response = kms_client.generate_random(NumberOfBytes=32)
plaintext = response["Plaintext"]
assert plaintext
assert len(plaintext) == 32
@@ -0,0 +1,222 @@
"""Lambda function integration tests."""
import pytest
from botocore.exceptions import ClientError
class TestLambdaFunction:
"""Test Lambda function operations."""
def test_create_function(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test CreateFunction creates a function."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
try:
response = lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Timeout=30,
MemorySize=256,
Code={"ZipFile": minimal_lambda_zip},
)
assert response["FunctionName"] == fn_name
assert response["FunctionArn"]
assert fn_name in response["FunctionArn"]
assert response["State"] == "Active"
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_get_function(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test GetFunction returns function details."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.get_function(FunctionName=fn_name)
assert response["Configuration"]["FunctionName"] == fn_name
assert response["Configuration"]["Role"] == role
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_get_function_configuration(
self, lambda_client, minimal_lambda_zip, unique_name
):
"""Test GetFunctionConfiguration returns configuration."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Timeout=30,
MemorySize=256,
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.get_function_configuration(FunctionName=fn_name)
assert response["Timeout"] == 30
assert response["MemorySize"] == 256
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_list_functions(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test ListFunctions includes created function."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.list_functions()
assert any(f["FunctionName"] == fn_name for f in response["Functions"])
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_update_function_code(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test UpdateFunctionCode updates code."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.update_function_code(
FunctionName=fn_name, ZipFile=minimal_lambda_zip
)
assert response["FunctionName"] == fn_name
assert response.get("RevisionId")
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_delete_function(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test DeleteFunction removes function."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
lambda_client.delete_function(FunctionName=fn_name)
with pytest.raises(ClientError) as exc_info:
lambda_client.get_function(FunctionName=fn_name)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
class TestLambdaInvoke:
"""Test Lambda invocation."""
def test_invoke_dry_run(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test Invoke with DryRun returns 204."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.invoke(
FunctionName=fn_name,
InvocationType="DryRun",
Payload=b'{"key":"value"}',
)
assert response["StatusCode"] == 204
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_invoke_event_async(self, lambda_client, minimal_lambda_zip, unique_name):
"""Test Invoke with Event type returns 202."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
response = lambda_client.invoke(
FunctionName=fn_name,
InvocationType="Event",
Payload=b'{"key":"async"}',
)
assert response["StatusCode"] == 202
finally:
lambda_client.delete_function(FunctionName=fn_name)
class TestLambdaErrors:
"""Test Lambda error handling."""
def test_create_function_duplicate(
self, lambda_client, minimal_lambda_zip, unique_name
):
"""Test CreateFunction returns 409 for duplicate."""
fn_name = f"pytest-lambda-{unique_name}"
role = "arn:aws:iam::000000000000:role/lambda-role"
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
try:
with pytest.raises(ClientError) as exc_info:
lambda_client.create_function(
FunctionName=fn_name,
Runtime="nodejs20.x",
Role=role,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 409
finally:
lambda_client.delete_function(FunctionName=fn_name)
def test_get_function_not_found(self, lambda_client):
"""Test GetFunction returns 404 for non-existent function."""
with pytest.raises(ClientError) as exc_info:
lambda_client.get_function(FunctionName="does-not-exist-pytest")
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
@@ -0,0 +1,374 @@
"""#471 — FunctionConfiguration fields compatibility tests.
Verifies that CreateFunction and UpdateFunctionConfiguration accept and round-trip
Architectures, EphemeralStorage, TracingConfig, DeadLetterConfig, Environment,
CodeSha256, and LastModified via the AWS SDK for Python (boto3).
"""
import re
import pytest
from botocore.exceptions import ClientError
ROLE = "arn:aws:iam::000000000000:role/lambda-role"
# ISO-8601 pattern AWS Lambda returns: 2024-01-15T10:30:00.000+0000
_LAST_MODIFIED_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$")
class TestFunctionConfigurationDefaults:
"""createFunction response includes all required fields with correct defaults."""
def test_create_function_has_code_sha256(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
assert resp.get("CodeSha256"), "CodeSha256 must be a non-empty string"
finally:
lambda_client.delete_function(FunctionName=fn)
def test_create_function_has_iso8601_last_modified(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
last_modified = resp.get("LastModified", "")
assert _LAST_MODIFIED_RE.match(last_modified), (
f"LastModified must match yyyy-MM-dd'T'HH:mm:ss.SSSZ, got: {last_modified!r}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_get_function_configuration_default_architectures(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.get_function_configuration(FunctionName=fn)
assert resp.get("Architectures") == ["x86_64"], (
f"default Architectures must be ['x86_64'], got: {resp.get('Architectures')}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_get_function_configuration_default_ephemeral_storage(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.get_function_configuration(FunctionName=fn)
ephemeral = resp.get("EphemeralStorage")
assert ephemeral is not None, "EphemeralStorage must always be present"
assert ephemeral.get("Size") == 512, (
f"default EphemeralStorage.Size must be 512, got: {ephemeral.get('Size')}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_get_function_configuration_default_tracing_config(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.get_function_configuration(FunctionName=fn)
tracing = resp.get("TracingConfig")
assert tracing is not None, "TracingConfig must always be present"
assert tracing.get("Mode") == "PassThrough", (
f"default TracingConfig.Mode must be PassThrough, got: {tracing.get('Mode')}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_get_function_configuration_omits_environment_when_unset(
self, lambda_client, minimal_lambda_zip, unique_name
):
"""AWS omits Environment entirely for a function with no variables.
Verified against lambda.us-east-1.amazonaws.com and
lambda.ap-southeast-1.amazonaws.com: a function created without
--environment comes back with no Environment key at all, the same way
Layers and VpcConfig are omitted rather than returned empty.
"""
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.get_function_configuration(FunctionName=fn)
assert "Environment" not in resp, (
"Environment must be omitted when no variables are set; an empty "
"block gives Terraform a permanent `- environment {}` diff"
)
finally:
lambda_client.delete_function(FunctionName=fn)
class TestCreateFunctionWithNewFields:
"""createFunction accepts and persists new fields."""
def test_create_function_with_arm64_architecture(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Architectures=["arm64"],
Code={"ZipFile": minimal_lambda_zip},
)
assert resp.get("Architectures") == ["arm64"], (
f"Architectures must be ['arm64'], got: {resp.get('Architectures')}"
)
# Verify it persists
cfg = lambda_client.get_function_configuration(FunctionName=fn)
assert cfg.get("Architectures") == ["arm64"]
finally:
lambda_client.delete_function(FunctionName=fn)
def test_create_function_with_ephemeral_storage(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
EphemeralStorage={"Size": 2048},
Code={"ZipFile": minimal_lambda_zip},
)
assert resp.get("EphemeralStorage", {}).get("Size") == 2048
finally:
lambda_client.delete_function(FunctionName=fn)
def test_create_function_with_tracing_config(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
TracingConfig={"Mode": "Active"},
Code={"ZipFile": minimal_lambda_zip},
)
assert resp.get("TracingConfig", {}).get("Mode") == "Active"
finally:
lambda_client.delete_function(FunctionName=fn)
class TestUpdateFunctionConfiguration:
"""updateFunctionConfiguration round-trips new fields."""
def test_update_ephemeral_storage_and_tracing(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.update_function_configuration(
FunctionName=fn,
Timeout=60,
EphemeralStorage={"Size": 1024},
TracingConfig={"Mode": "Active"},
)
assert resp.get("Timeout") == 60
assert resp.get("EphemeralStorage", {}).get("Size") == 1024
assert resp.get("TracingConfig", {}).get("Mode") == "Active"
# Verify persistence
cfg = lambda_client.get_function_configuration(FunctionName=fn)
assert cfg.get("EphemeralStorage", {}).get("Size") == 1024
assert cfg.get("TracingConfig", {}).get("Mode") == "Active"
finally:
lambda_client.delete_function(FunctionName=fn)
def test_update_environment_variables_round_trip(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
resp = lambda_client.update_function_configuration(
FunctionName=fn,
Environment={"Variables": {"KEY_A": "value-a", "KEY_B": "value-b"}},
)
variables = resp.get("Environment", {}).get("Variables", {})
assert variables.get("KEY_A") == "value-a"
assert variables.get("KEY_B") == "value-b"
# Clear environment — AWS drops the block again. Measured live:
# update_function_configuration(Environment={"Variables": {}}) returns
# no Environment key, and the following get_function_configuration
# agrees, so a cleared function is indistinguishable from one that
# never had variables.
cleared = lambda_client.update_function_configuration(
FunctionName=fn,
Environment={"Variables": {}},
)
assert "Environment" not in cleared, (
"Environment must be omitted once variables are cleared"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_stale_revision_id_returns_412(
self, lambda_client, minimal_lambda_zip, unique_name
):
fn = f"pytest-cfg-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
Runtime="nodejs20.x",
Role=ROLE,
Handler="index.handler",
Code={"ZipFile": minimal_lambda_zip},
)
with pytest.raises(ClientError) as exc_info:
lambda_client.update_function_configuration(
FunctionName=fn,
Timeout=10,
RevisionId="00000000-0000-0000-0000-000000000000",
)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 412, (
"Stale RevisionId must return 412 PreconditionFailedException"
)
finally:
lambda_client.delete_function(FunctionName=fn)
class TestImageConfigWorkingDirectory:
"""ImageConfig.WorkingDirectory is persisted and returned correctly."""
IMAGE_URI = "000000000000.dkr.ecr.us-east-1.amazonaws.com/fake-repo:latest"
def test_create_image_function_with_working_directory(
self, lambda_client, unique_name
):
fn = f"pytest-imgwd-{unique_name}"
try:
resp = lambda_client.create_function(
FunctionName=fn,
PackageType="Image",
Role=ROLE,
Code={"ImageUri": self.IMAGE_URI},
ImageConfig={"WorkingDirectory": "/app"},
)
wd = (
resp.get("ImageConfigResponse", {})
.get("ImageConfig", {})
.get("WorkingDirectory")
)
assert wd == "/app", (
f"CreateFunction response must include ImageConfig.WorkingDirectory='/app', got: {wd!r}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_get_function_configuration_persists_working_directory(
self, lambda_client, unique_name
):
fn = f"pytest-imgwd-get-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
PackageType="Image",
Role=ROLE,
Code={"ImageUri": self.IMAGE_URI},
ImageConfig={"WorkingDirectory": "/workspace"},
)
resp = lambda_client.get_function_configuration(FunctionName=fn)
wd = (
resp.get("ImageConfigResponse", {})
.get("ImageConfig", {})
.get("WorkingDirectory")
)
assert wd == "/workspace", (
f"GetFunctionConfiguration must persist WorkingDirectory='/workspace', got: {wd!r}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
def test_update_function_configuration_updates_working_directory(
self, lambda_client, unique_name
):
fn = f"pytest-imgwd-upd-{unique_name}"
try:
lambda_client.create_function(
FunctionName=fn,
PackageType="Image",
Role=ROLE,
Code={"ImageUri": self.IMAGE_URI},
ImageConfig={"WorkingDirectory": "/initial"},
)
resp = lambda_client.update_function_configuration(
FunctionName=fn,
ImageConfig={"WorkingDirectory": "/updated"},
)
wd = (
resp.get("ImageConfigResponse", {})
.get("ImageConfig", {})
.get("WorkingDirectory")
)
assert wd == "/updated", (
f"UpdateFunctionConfiguration must update WorkingDirectory to '/updated', got: {wd!r}"
)
finally:
lambda_client.delete_function(FunctionName=fn)
@@ -0,0 +1,247 @@
"""Offline checks only: no AWS clients or sockets are opened by these tests."""
import importlib.util
from pathlib import Path
from datetime import datetime, timedelta, timezone
import json
import socket
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
SPEC = importlib.util.spec_from_file_location(
"metric_filter_replay", Path(__file__).resolve().parent / "metric_filter_replay.py")
replay = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(replay)
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
def reject(*args, **kwargs):
pytest.fail("offline replay checks must not open a network connection")
monkeypatch.setattr(socket.socket, "connect", reject)
@pytest.fixture
def orchestration(monkeypatch):
logs, cw, sts = Mock(), Mock(), Mock()
state = SimpleNamespace(metric=None, ingestions=[], observations=0, now=0)
sts.get_caller_identity.return_value = {"Account": "123456789012"}
logs.list_tags_log_group.side_effect = lambda **kwargs: {"tags": logs.create_log_group.call_args.kwargs["tags"]}
def put_filter(**request):
state.metric = request["metricTransformations"][0]["metricName"]
def put_events(**request):
state.ingestions.append((state.metric, request["logEvents"]))
return {}
def sleep(seconds):
state.now += seconds
logs.put_metric_filter.side_effect = put_filter
logs.put_log_events.side_effect = put_events
monkeypatch.setattr(replay, "clients", lambda args: (logs, cw, sts))
monkeypatch.setattr(replay.time, "monotonic", lambda: state.now)
monkeypatch.setattr(replay.time, "sleep", sleep)
return state
def test_fixture_is_packaged_beside_the_module_not_in_a_repository_ancestor():
expected = Path(__file__).resolve().parent / "fixtures/metric-filter-publishing-aws.json"
assert replay.FIXTURE == expected
assert replay.FIXTURE.is_file()
def test_quiet_ingestion_is_last_through_successful_stable_observation(monkeypatch, orchestration):
state = orchestration
def observe(*args):
state.observations += 1
assert state.ingestions[-1][0] == "quiet", "later traffic could flush a broken quiet publisher"
assert [e["message"] for e in state.ingestions[-1][1]] == ["INFO", "INFO", "INFO"]
assert sum(metric == "quiet" for metric, _ in state.ingestions) == 1
return state.observations > 1, []
monkeypatch.setattr(replay, "observe", observe)
assert replay.main(["--endpoint", "http://127.0.0.1:4566", "--timeout", "10",
"--stable-seconds", "2", "--poll-interval", "1"]) == 0
assert state.observations >= 4
assert state.ingestions[-1][0] == "quiet"
def test_idle_window_is_distinct_unwritten_and_checked_through_convergence(monkeypatch, orchestration):
state = orchestration
def observe(cw, namespace, cases):
state.observations += 1
quiet = [case for case in cases if case["name"] == "quiet"]
assert len(quiet) == 2, "check the positive quiet minute and a separate empty minute"
populated, idle = sorted(quiet, key=lambda case: case["minute"])
assert idle["minute"] == populated["minute"] + timedelta(minutes=1)
assert populated["expected"][0]["Sum"] == 21
assert idle["expected"] == [{"dimensions": {}, "datapoints": []}]
start = int(idle["minute"].timestamp() * 1000)
assert all(not start <= event["timestamp"] < start + 60_000
for _, events in state.ingestions for event in events)
return state.observations > 1, []
monkeypatch.setattr(replay, "observe", observe)
assert replay.main(["--endpoint", "http://127.0.0.1:4566", "--timeout", "10",
"--stable-seconds", "2", "--poll-interval", "1"]) == 0
assert state.observations >= 4
@pytest.mark.parametrize("args", [
[], ["--aws"], ["--aws", "--profile", "synthetic", "--region", "eu-west-1"],
["--aws", "--ack-live-writes", "I_ACCEPT_AWS_WRITES", "--region", "eu-west-1"],
["--aws", "--ack-live-writes", "I_ACCEPT_AWS_WRITES", "--profile", "synthetic"],
["--endpoint", "https://logs.eu-west-1.amazonaws.com"],
["--endpoint", "http://127.0.0.1:4566@evil.example"],
["--endpoint", "http://localhost:4566", "--profile", "synthetic"],
["--endpoint", "http://127.0.0.1:4566/path"],
["--endpoint", "http://[malformed-private-endpoint"],
# GovCloud and China are real botocore Regions but not the commercial partition the endpoints pin.
["--aws", "--profile", "synthetic", "--region", "us-gov-west-1", "--ack-live-writes", "I_ACCEPT_AWS_WRITES"],
["--aws", "--profile", "synthetic", "--region", "cn-north-1", "--ack-live-writes", "I_ACCEPT_AWS_WRITES"],
])
def test_rejects_unguarded_or_remote_writes_before_creating_clients(args):
with pytest.raises(SystemExit):
replay.parse_args(args)
def test_live_clients_pin_official_endpoints_despite_environment(monkeypatch):
monkeypatch.setenv("AWS_ENDPOINT_URL", "https://untrusted.invalid")
monkeypatch.setenv("AWS_ENDPOINT_URL_CLOUDWATCH_LOGS", "https://untrusted.invalid")
args = replay.parse_args(["--aws", "--profile", "synthetic", "--region", "eu-west-1",
"--ack-live-writes", "I_ACCEPT_AWS_WRITES"])
session = Mock()
replay.clients(args, session)
calls = {call.args[0]: call.kwargs for call in session.client.call_args_list}
assert calls["logs"]["endpoint_url"] == "https://logs.eu-west-1.amazonaws.com"
assert calls["cloudwatch"]["endpoint_url"] == "https://monitoring.eu-west-1.amazonaws.com"
assert calls["sts"]["endpoint_url"] == "https://sts.eu-west-1.amazonaws.com"
assert all(c["config"].ignore_configured_endpoint_urls for c in calls.values())
def test_local_clients_use_only_explicit_loopback_and_dummy_credentials():
args = replay.parse_args(["--endpoint", "http://127.0.0.1:18084"])
session = Mock()
replay.clients(args, session)
for call in session.client.call_args_list:
assert call.kwargs["endpoint_url"] == "http://127.0.0.1:18084"
assert call.kwargs["aws_access_key_id"] == "test"
assert call.kwargs["aws_secret_access_key"] == "test"
assert call.kwargs["aws_session_token"] == ""
def test_owned_group_is_verified_again_before_cleanup_even_when_body_fails():
logs = Mock()
logs.list_tags_log_group.return_value = {"tags": {"floci-metric-replay": "token"}}
with pytest.raises(RuntimeError, match="body"):
with replay.owned_group(logs, "/owned", "token"):
raise RuntimeError("body")
assert logs.list_tags_log_group.call_count == 2
logs.delete_log_group.assert_called_once_with(logGroupName="/owned")
def test_cleanup_does_not_delete_a_group_whose_ownership_changed():
logs = Mock()
logs.list_tags_log_group.side_effect = [
{"tags": {"floci-metric-replay": "token"}}, {"tags": {"floci-metric-replay": "someone-else"}}]
with pytest.raises(replay.ReplayError, match="cleanup"):
with replay.owned_group(logs, "/owned", "token"):
pass
logs.delete_log_group.assert_not_called()
def test_failed_create_never_deletes_an_existing_group():
logs = Mock()
logs.create_log_group.side_effect = RuntimeError("private endpoint and credentials")
with pytest.raises(RuntimeError, match="private endpoint"):
with replay.owned_group(logs, "/owned", "token"):
pytest.fail("must not start replay")
logs.delete_log_group.assert_not_called()
def test_cleanup_failure_is_visible_and_does_not_leak_service_exception():
logs = Mock()
logs.list_tags_log_group.return_value = {"tags": {"floci-metric-replay": "token"}}
logs.delete_log_group.side_effect = RuntimeError("private account credentials")
with pytest.raises(replay.ReplayError, match="cleanup failed") as error:
with replay.owned_group(logs, "/owned", "token"):
raise ValueError("private profile")
assert "private" not in str(error.value)
def test_polling_requires_positive_controls_then_a_stable_complete_window():
clock = SimpleNamespace(now=0)
def sleep(seconds):
clock.now += seconds
observations = iter([False, False, True, False, True, True, True])
reads = []
def read():
reads.append(clock.now)
return next(observations)
replay.await_stable(read, timeout=20, stable_seconds=2, interval=1,
monotonic=lambda: clock.now, sleep=sleep)
assert reads == [0, 1, 2, 3, 4, 5, 6]
def test_never_converging_observations_fail_at_a_bounded_deadline():
clock = SimpleNamespace(now=0)
def sleep(seconds):
clock.now += seconds
with pytest.raises(replay.ReplayError, match="converge"):
replay.await_stable(lambda: False, timeout=3, stable_seconds=2, interval=1,
monotonic=lambda: clock.now, sleep=sleep)
assert clock.now == 3
def test_slow_final_read_cannot_succeed_after_the_deadline():
clock = SimpleNamespace(now=0)
def sleep(seconds):
clock.now += seconds
def read():
if clock.now:
clock.now += 3
return True
with pytest.raises(replay.ReplayError, match="converge"):
replay.await_stable(read, timeout=3, stable_seconds=2, interval=1,
monotonic=lambda: clock.now, sleep=sleep)
def test_invalid_argument_output_never_echoes_private_values(capsys):
with pytest.raises(SystemExit):
replay.parse_args(["--unknown", "private-profile-name"])
assert "private-profile-name" not in capsys.readouterr().err
def test_error_output_is_sanitized():
assert replay.safe_error(RuntimeError("profile secret account 123456789012")) == "RuntimeError"
assert replay.safe_error(replay.ReplayError("cleanup failed")) == "cleanup failed"
def test_scenarios_use_immutable_recorded_values_and_distinct_event_minutes():
fixture = json.loads(replay.FIXTURE.read_text())
before = json.dumps(fixture)
cases = replay.scenarios(fixture, datetime(2026, 9, 16, tzinfo=timezone.utc), "123456789012", "eu-west-1")
by_name = {case["name"]: case for case in cases}
assert len(cases) == 12
assert len({case["minute"] for case in cases}) == 12
assert [by_name[name]["expected"][0]["Sum"] for name in ("default-0", "default-1", "default-2", "quiet")] == [13, 10, 10, 21]
assert by_name["extraction-0"]["expected"][0]["Sum"] == 7
assert by_name["extraction-1"]["expected"][0]["Sum"] == 7
assert by_name["extraction-2"]["expected"][0] == {"dimensions": {}, "datapoints": []}
assert by_name["extraction-3"]["expected"][0]["Sum"] == 3
assert len(by_name["dimensions"]["expected"]) == 4
assert json.dumps(fixture) == before
def test_observation_rejects_wrong_timestamps_partial_counts_and_sanitizes_account():
minute = datetime(2026, 9, 16, tzinfo=timezone.utc)
case = {"name": "system", "minute": minute, "expected": [
{"dimensions": {"@aws.account": "123456789012"}, "Sum": 3, "SampleCount": 1}]}
cw = Mock()
point = {"Timestamp": minute, "Sum": 3.0, "SampleCount": 1.0, "Unit": "Count"}
cw.get_metric_statistics.return_value = {"Datapoints": [point]}
complete, observations = replay.observe(cw, "namespace", [case])
assert complete
assert "123456789012" not in json.dumps(observations)
assert "<CALLER_ACCOUNT>" in json.dumps(observations)
point["SampleCount"] = 2
assert not replay.observe(cw, "namespace", [case])[0]
point["SampleCount"] = 1
point["Timestamp"] = datetime(2026, 9, 16, 0, 1, tzinfo=timezone.utc)
assert not replay.observe(cw, "namespace", [case])[0]
@@ -0,0 +1,186 @@
"""Neptune cluster and instance integration tests."""
import pytest
from botocore.exceptions import ClientError
class TestNeptuneCluster:
"""Test Neptune DB cluster operations."""
def test_create_cluster(self, neptune_client, unique_name):
"""Test CreateDBCluster returns a valid cluster descriptor."""
cluster_id = f"pytest-{unique_name}"
try:
response = neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id,
Engine="neptune",
)
cluster = response["DBCluster"]
assert cluster["DBClusterIdentifier"] == cluster_id
assert cluster["Engine"] == "neptune"
assert cluster["Status"] == "available"
assert cluster["DBClusterArn"].startswith("arn:aws:neptune:")
assert cluster["Port"] > 0
finally:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
def test_create_cluster_duplicate_fails(self, neptune_client, unique_name):
"""Test CreateDBCluster fails with DBClusterAlreadyExistsFault on duplicate."""
cluster_id = f"pytest-{unique_name}"
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
try:
with pytest.raises(ClientError) as exc_info:
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
assert exc_info.value.response["Error"]["Code"] == "DBClusterAlreadyExistsFault"
finally:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
def test_describe_clusters(self, neptune_client, unique_name):
"""Test DescribeDBClusters lists created cluster."""
cluster_id = f"pytest-{unique_name}"
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
try:
response = neptune_client.describe_db_clusters(
DBClusterIdentifier=cluster_id
)
clusters = response["DBClusters"]
assert len(clusters) == 1
assert clusters[0]["DBClusterIdentifier"] == cluster_id
finally:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
def test_modify_cluster(self, neptune_client, unique_name):
"""Test ModifyDBCluster updates cluster properties."""
cluster_id = f"pytest-{unique_name}"
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
try:
response = neptune_client.modify_db_cluster(
DBClusterIdentifier=cluster_id,
EnableIAMDatabaseAuthentication=True,
)
assert response["DBCluster"]["IAMDatabaseAuthenticationEnabled"] is True
finally:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
def test_delete_cluster_with_instances_fails(self, neptune_client, unique_name):
"""Test DeleteDBCluster fails when instances still exist."""
cluster_id = f"pytest-{unique_name}"
instance_id = f"pytest-inst-{unique_name}"
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
neptune_client.create_db_instance(
DBInstanceIdentifier=instance_id,
DBClusterIdentifier=cluster_id,
DBInstanceClass="db.r5.large",
Engine="neptune",
)
try:
with pytest.raises(ClientError) as exc_info:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
assert exc_info.value.response["Error"]["Code"] == "InvalidDBClusterStateFault"
finally:
neptune_client.delete_db_instance(DBInstanceIdentifier=instance_id)
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
class TestNeptuneInstance:
"""Test Neptune DB instance operations."""
@pytest.fixture
def neptune_cluster(self, neptune_client, unique_name):
"""Create and tear down a Neptune cluster for instance tests."""
cluster_id = f"pytest-cluster-{unique_name}"
neptune_client.create_db_cluster(
DBClusterIdentifier=cluster_id, Engine="neptune"
)
yield cluster_id
try:
neptune_client.delete_db_cluster(
DBClusterIdentifier=cluster_id, SkipFinalSnapshot=True
)
except ClientError:
pass
def test_create_instance(self, neptune_client, neptune_cluster, unique_name):
"""Test CreateDBInstance returns a valid instance descriptor."""
instance_id = f"pytest-inst-{unique_name}"
try:
response = neptune_client.create_db_instance(
DBInstanceIdentifier=instance_id,
DBClusterIdentifier=neptune_cluster,
DBInstanceClass="db.r5.large",
Engine="neptune",
)
instance = response["DBInstance"]
assert instance["DBInstanceIdentifier"] == instance_id
assert instance["DBClusterIdentifier"] == neptune_cluster
assert instance["DBInstanceStatus"] == "available"
assert instance["DBInstanceArn"].startswith("arn:aws:neptune:")
finally:
neptune_client.delete_db_instance(DBInstanceIdentifier=instance_id)
def test_describe_instances(self, neptune_client, neptune_cluster, unique_name):
"""Test DescribeDBInstances lists created instance."""
instance_id = f"pytest-inst-{unique_name}"
neptune_client.create_db_instance(
DBInstanceIdentifier=instance_id,
DBClusterIdentifier=neptune_cluster,
DBInstanceClass="db.r5.large",
Engine="neptune",
)
try:
response = neptune_client.describe_db_instances(
DBInstanceIdentifier=instance_id
)
instances = response["DBInstances"]
assert len(instances) == 1
assert instances[0]["DBInstanceIdentifier"] == instance_id
finally:
neptune_client.delete_db_instance(DBInstanceIdentifier=instance_id)
def test_modify_instance(self, neptune_client, neptune_cluster, unique_name):
"""Test ModifyDBInstance updates instance class."""
instance_id = f"pytest-inst-{unique_name}"
neptune_client.create_db_instance(
DBInstanceIdentifier=instance_id,
DBClusterIdentifier=neptune_cluster,
DBInstanceClass="db.r5.large",
Engine="neptune",
)
try:
response = neptune_client.modify_db_instance(
DBInstanceIdentifier=instance_id,
DBInstanceClass="db.r5.xlarge",
)
assert response["DBInstance"]["DBInstanceClass"] == "db.r5.xlarge"
finally:
neptune_client.delete_db_instance(DBInstanceIdentifier=instance_id)
@@ -0,0 +1,412 @@
"""EventBridge Pipes integration tests."""
import time
import pytest
from botocore.exceptions import ClientError
ACCOUNT_ID = "000000000000"
REGION = "us-east-1"
ROLE_ARN = f"arn:aws:iam::{ACCOUNT_ID}:role/pipe-role"
def sqs_arn(queue_name):
return f"arn:aws:sqs:{REGION}:{ACCOUNT_ID}:{queue_name}"
class TestPipesCRUD:
"""Test Pipes create, describe, list, update, delete operations."""
def test_create_pipe(self, pipes_client, sqs_client, unique_name):
"""Test CreatePipe creates a pipe in STOPPED state."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
response = pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
assert response["CurrentState"] == "STOPPED"
assert pipe_name in response["Arn"]
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
def test_describe_pipe(self, pipes_client, sqs_client, unique_name):
"""Test DescribePipe returns pipe details."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
response = pipes_client.describe_pipe(Name=pipe_name)
assert response["Name"] == pipe_name
assert response["Source"] == sqs_arn(src_name)
assert response["Target"] == sqs_arn(tgt_name)
assert response["CurrentState"] == "STOPPED"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
def test_list_pipes(self, pipes_client, sqs_client, unique_name):
"""Test ListPipes returns created pipe."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
response = pipes_client.list_pipes()
names = [p["Name"] for p in response["Pipes"]]
assert pipe_name in names
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
def test_update_pipe(self, pipes_client, sqs_client, unique_name):
"""Test UpdatePipe updates pipe description."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
pipes_client.update_pipe(
Name=pipe_name,
RoleArn=ROLE_ARN,
Description="updated via SDK",
DesiredState="STOPPED",
)
response = pipes_client.describe_pipe(Name=pipe_name)
assert response["Description"] == "updated via SDK"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
def test_delete_pipe(self, pipes_client, sqs_client, unique_name):
"""Test DeletePipe removes the pipe."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
pipes_client.delete_pipe(Name=pipe_name)
with pytest.raises(ClientError) as exc_info:
pipes_client.describe_pipe(Name=pipe_name)
assert exc_info.value.response["Error"]["Code"] == "NotFoundException"
finally:
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
def test_describe_nonexistent_pipe(self, pipes_client):
"""Test DescribePipe for non-existent pipe returns NotFoundException."""
with pytest.raises(ClientError) as exc_info:
pipes_client.describe_pipe(Name="nonexistent-pipe")
assert exc_info.value.response["Error"]["Code"] == "NotFoundException"
class TestPipesLifecycle:
"""Test Pipes start/stop operations."""
def test_start_and_stop_pipe(self, pipes_client, sqs_client, unique_name):
"""Test StartPipe and StopPipe transitions."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
sqs_client.create_queue(QueueName=src_name)
sqs_client.create_queue(QueueName=tgt_name)
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
response = pipes_client.start_pipe(Name=pipe_name)
assert response["CurrentState"] == "RUNNING"
response = pipes_client.stop_pipe(Name=pipe_name)
assert response["CurrentState"] == "STOPPED"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, src_name))
sqs_client.delete_queue(QueueUrl=_queue_url(sqs_client, tgt_name))
class TestPipesPolling:
"""Test Pipes source polling and target invocation."""
def test_sqs_to_sqs_forwarding(self, pipes_client, sqs_client, unique_name):
"""Test that a RUNNING pipe forwards SQS messages to SQS target."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
src_resp = sqs_client.create_queue(QueueName=src_name)
src_url = src_resp["QueueUrl"]
tgt_resp = sqs_client.create_queue(QueueName=tgt_name)
tgt_url = tgt_resp["QueueUrl"]
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="RUNNING",
)
sqs_client.send_message(QueueUrl=src_url, MessageBody="hello from pipes")
found = False
for _ in range(15):
time.sleep(1)
resp = sqs_client.receive_message(
QueueUrl=tgt_url, MaxNumberOfMessages=1, WaitTimeSeconds=1
)
msgs = resp.get("Messages", [])
if msgs and "hello from pipes" in msgs[0]["Body"]:
found = True
break
assert found, "Target queue should receive forwarded message"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=src_url)
sqs_client.delete_queue(QueueUrl=tgt_url)
def test_filter_criteria_filters_messages(self, pipes_client, sqs_client, unique_name):
"""Test that FilterCriteria only forwards matching messages."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
src_resp = sqs_client.create_queue(QueueName=src_name)
src_url = src_resp["QueueUrl"]
tgt_resp = sqs_client.create_queue(QueueName=tgt_name)
tgt_url = tgt_resp["QueueUrl"]
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="RUNNING",
SourceParameters={
"FilterCriteria": {
"Filters": [
{"Pattern": '{"body": {"status": ["active"]}}'},
],
},
},
)
sqs_client.send_message(
QueueUrl=src_url,
MessageBody='{"status": "active", "id": "match-1"}',
)
sqs_client.send_message(
QueueUrl=src_url,
MessageBody='{"status": "inactive", "id": "no-match"}',
)
found = False
for _ in range(15):
time.sleep(1)
resp = sqs_client.receive_message(
QueueUrl=tgt_url, MaxNumberOfMessages=10, WaitTimeSeconds=1
)
msgs = resp.get("Messages", [])
if any("match-1" in m["Body"] for m in msgs):
assert not any("no-match" in m["Body"] for m in msgs), \
"Non-matching message should not be forwarded"
found = True
break
assert found, "Target queue should receive matching message"
attrs = sqs_client.get_queue_attributes(
QueueUrl=src_url, AttributeNames=["ApproximateNumberOfMessages"]
)
assert attrs["Attributes"]["ApproximateNumberOfMessages"] == "0"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=src_url)
sqs_client.delete_queue(QueueUrl=tgt_url)
def test_batch_size_in_source_parameters(self, pipes_client, sqs_client, unique_name):
"""Test that BatchSize in SourceParameters is respected."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
src_resp = sqs_client.create_queue(QueueName=src_name)
src_url = src_resp["QueueUrl"]
tgt_resp = sqs_client.create_queue(QueueName=tgt_name)
tgt_url = tgt_resp["QueueUrl"]
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="RUNNING",
SourceParameters={
"SqsQueueParameters": {
"BatchSize": 1,
},
},
)
for i in range(1, 4):
sqs_client.send_message(QueueUrl=src_url, MessageBody=f"batch-msg-{i}")
found_messages = set()
for _ in range(20):
if len(found_messages) >= 3:
break
time.sleep(1)
resp = sqs_client.receive_message(
QueueUrl=tgt_url, MaxNumberOfMessages=10, WaitTimeSeconds=1
)
for msg in resp.get("Messages", []):
for j in range(1, 4):
if f"batch-msg-{j}" in msg["Body"]:
found_messages.add(f"batch-msg-{j}")
assert len(found_messages) == 3, "All 3 messages should arrive at target"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=src_url)
sqs_client.delete_queue(QueueUrl=tgt_url)
def test_stopped_pipe_does_not_forward(self, pipes_client, sqs_client, unique_name):
"""Test that a STOPPED pipe does not forward messages."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
src_resp = sqs_client.create_queue(QueueName=src_name)
src_url = src_resp["QueueUrl"]
tgt_resp = sqs_client.create_queue(QueueName=tgt_name)
tgt_url = tgt_resp["QueueUrl"]
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="STOPPED",
)
sqs_client.send_message(QueueUrl=src_url, MessageBody="should not forward")
time.sleep(3)
attrs = sqs_client.get_queue_attributes(
QueueUrl=src_url, AttributeNames=["ApproximateNumberOfMessages"]
)
assert attrs["Attributes"]["ApproximateNumberOfMessages"] == "1"
resp = sqs_client.receive_message(
QueueUrl=tgt_url, MaxNumberOfMessages=1, WaitTimeSeconds=1
)
assert len(resp.get("Messages", [])) == 0
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=src_url)
sqs_client.delete_queue(QueueUrl=tgt_url)
def test_source_queue_drained_after_forwarding(
self, pipes_client, sqs_client, unique_name
):
"""Test that source queue messages are deleted after forwarding."""
src_name = f"pipe-src-{unique_name}"
tgt_name = f"pipe-tgt-{unique_name}"
pipe_name = f"pipe-{unique_name}"
src_resp = sqs_client.create_queue(QueueName=src_name)
src_url = src_resp["QueueUrl"]
tgt_resp = sqs_client.create_queue(QueueName=tgt_name)
tgt_url = tgt_resp["QueueUrl"]
try:
pipes_client.create_pipe(
Name=pipe_name,
Source=sqs_arn(src_name),
Target=sqs_arn(tgt_name),
RoleArn=ROLE_ARN,
DesiredState="RUNNING",
)
sqs_client.send_message(QueueUrl=src_url, MessageBody="drain test")
drained = False
for _ in range(15):
time.sleep(1)
attrs = sqs_client.get_queue_attributes(
QueueUrl=src_url, AttributeNames=["ApproximateNumberOfMessages"]
)
if attrs["Attributes"]["ApproximateNumberOfMessages"] == "0":
drained = True
break
assert drained, "Source queue should be drained after pipe forwards messages"
finally:
pipes_client.delete_pipe(Name=pipe_name)
sqs_client.delete_queue(QueueUrl=src_url)
sqs_client.delete_queue(QueueUrl=tgt_url)
def _queue_url(sqs_client, queue_name):
"""Get queue URL by name, ignoring errors."""
try:
return sqs_client.get_queue_url(QueueName=queue_name)["QueueUrl"]
except Exception:
return ""
@@ -0,0 +1,499 @@
"""S3 bucket and object integration tests."""
import base64
import hashlib
import random
import zlib
import pytest
from boto3.s3.transfer import TransferConfig
from botocore.exceptions import ClientError
MIB = 1024 * 1024
class TestS3Bucket:
"""Test S3 bucket operations."""
def test_create_bucket(self, s3_client, unique_name):
"""Test CreateBucket creates a bucket."""
bucket_name = f"pytest-s3-{unique_name}"
try:
s3_client.create_bucket(Bucket=bucket_name)
# Verify bucket exists
s3_client.head_bucket(Bucket=bucket_name)
finally:
s3_client.delete_bucket(Bucket=bucket_name)
def test_create_bucket_with_location_constraint(self, s3_client, unique_name):
"""Test CreateBucket with LocationConstraint (regression: issue #11)."""
bucket_name = f"pytest-s3-eu-{unique_name}"
try:
s3_client.create_bucket(
Bucket=bucket_name,
CreateBucketConfiguration={"LocationConstraint": "eu-central-1"},
)
response = s3_client.get_bucket_location(Bucket=bucket_name)
assert response.get("LocationConstraint") == "eu-central-1"
finally:
s3_client.delete_bucket(Bucket=bucket_name)
def test_list_buckets(self, s3_client, unique_name):
"""Test ListBuckets returns created bucket."""
bucket_name = f"pytest-s3-{unique_name}"
s3_client.create_bucket(Bucket=bucket_name)
try:
response = s3_client.list_buckets()
assert any(b["Name"] == bucket_name for b in response["Buckets"])
finally:
s3_client.delete_bucket(Bucket=bucket_name)
def test_head_bucket(self, s3_client, test_bucket):
"""Test HeadBucket succeeds for existing bucket."""
s3_client.head_bucket(Bucket=test_bucket)
# If no exception, test passes
def test_head_bucket_non_existent(self, s3_client):
"""Test HeadBucket returns 404 for non-existent bucket."""
with pytest.raises(ClientError) as exc_info:
s3_client.head_bucket(Bucket="non-existent-bucket-pytest-xyz")
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
def test_get_bucket_location(self, s3_client, test_bucket):
"""Test GetBucketLocation returns location constraint."""
response = s3_client.get_bucket_location(Bucket=test_bucket)
assert "LocationConstraint" in response
def test_delete_bucket(self, s3_client, unique_name):
"""Test DeleteBucket removes bucket."""
bucket_name = f"pytest-s3-{unique_name}"
s3_client.create_bucket(Bucket=bucket_name)
s3_client.delete_bucket(Bucket=bucket_name)
with pytest.raises(ClientError):
s3_client.head_bucket(Bucket=bucket_name)
class TestS3Object:
"""Test S3 object operations."""
def test_put_object(self, s3_client, test_bucket):
"""Test PutObject uploads object."""
key = "test-file.txt"
content = b"Hello from pytest!"
s3_client.put_object(
Bucket=test_bucket, Key=key, Body=content, ContentType="text/plain"
)
# Verify object exists
response = s3_client.head_object(Bucket=test_bucket, Key=key)
assert response["ContentLength"] == len(content)
def test_list_objects(self, s3_client, test_bucket):
"""Test ListObjectsV2 returns uploaded objects."""
key = "test-file.txt"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=b"content")
response = s3_client.list_objects_v2(Bucket=test_bucket)
assert any(o["Key"] == key for o in response.get("Contents", []))
def test_get_object(self, s3_client, test_bucket):
"""Test GetObject retrieves correct content."""
key = "test-file.txt"
content = b"Hello from pytest!"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=content)
response = s3_client.get_object(Bucket=test_bucket, Key=key)
data = response["Body"].read()
assert data == content
def test_head_object(self, s3_client, test_bucket):
"""Test HeadObject returns metadata."""
key = "test-file.txt"
content = b"Hello from pytest!"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=content)
response = s3_client.head_object(Bucket=test_bucket, Key=key)
assert response["ContentLength"] == len(content)
# LastModified should have second precision (microsecond == 0)
assert response["LastModified"].microsecond == 0
def test_delete_object(self, s3_client, test_bucket):
"""Test DeleteObject removes object."""
key = "test-file.txt"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=b"content")
s3_client.delete_object(Bucket=test_bucket, Key=key)
response = s3_client.list_objects_v2(Bucket=test_bucket)
assert not any(o["Key"] == key for o in response.get("Contents", []))
def test_delete_objects_batch(self, s3_client, test_bucket):
"""Test DeleteObjects batch deletes multiple objects."""
for i in range(1, 4):
s3_client.put_object(
Bucket=test_bucket, Key=f"batch-{i}.txt", Body=f"batch {i}".encode()
)
response = s3_client.delete_objects(
Bucket=test_bucket,
Delete={"Objects": [{"Key": f"batch-{i}.txt"} for i in range(1, 4)]},
)
assert len(response.get("Deleted", [])) == 3
class TestS3CopyObject:
"""Test S3 copy operations."""
def test_copy_object_same_bucket(self, s3_client, test_bucket):
"""Test CopyObject within same bucket."""
src_key = "src-file.txt"
dst_key = "dst-file.txt"
content = b"content to copy"
s3_client.put_object(Bucket=test_bucket, Key=src_key, Body=content)
response = s3_client.copy_object(
CopySource={"Bucket": test_bucket, "Key": src_key},
Bucket=test_bucket,
Key=dst_key,
)
assert response.get("CopyObjectResult")
# Verify copy
get_response = s3_client.get_object(Bucket=test_bucket, Key=dst_key)
assert get_response["Body"].read() == content
def test_copy_object_cross_bucket(self, s3_client, test_bucket, unique_name):
"""Test CopyObject across buckets."""
dest_bucket = f"pytest-s3-copy-{unique_name}"
src_key = "src-file.txt"
dst_key = "dst-file.txt"
content = b"content to copy"
s3_client.put_object(Bucket=test_bucket, Key=src_key, Body=content)
s3_client.create_bucket(Bucket=dest_bucket)
try:
response = s3_client.copy_object(
CopySource={"Bucket": test_bucket, "Key": src_key},
Bucket=dest_bucket,
Key=dst_key,
)
assert response.get("CopyObjectResult")
# Verify copy
get_response = s3_client.get_object(Bucket=dest_bucket, Key=dst_key)
assert get_response["Body"].read() == content
finally:
s3_client.delete_object(Bucket=dest_bucket, Key=dst_key)
s3_client.delete_bucket(Bucket=dest_bucket)
def test_copy_object_non_ascii_key(self, s3_client, test_bucket):
"""Test CopyObject with non-ASCII characters in key."""
src_key = "src/テスト画像.png"
dst_key = "dst/テスト画像.png"
content = b"non-ascii content"
s3_client.put_object(Bucket=test_bucket, Key=src_key, Body=content)
response = s3_client.copy_object(
CopySource={"Bucket": test_bucket, "Key": src_key},
Bucket=test_bucket,
Key=dst_key,
)
assert response.get("CopyObjectResult")
# Verify copy
get_response = s3_client.get_object(Bucket=test_bucket, Key=dst_key)
assert get_response["Body"].read() == content
class TestS3ObjectTagging:
"""Test S3 object tagging operations."""
def test_put_object_tagging(self, s3_client, test_bucket):
"""Test PutObjectTagging adds tags to object."""
key = "tagged-file.txt"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=b"content")
s3_client.put_object_tagging(
Bucket=test_bucket,
Key=key,
Tagging={
"TagSet": [
{"Key": "env", "Value": "test"},
{"Key": "project", "Value": "floci"},
]
},
)
# If no exception, test passes
def test_get_object_tagging(self, s3_client, test_bucket):
"""Test GetObjectTagging returns tags."""
key = "tagged-file.txt"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=b"content")
s3_client.put_object_tagging(
Bucket=test_bucket,
Key=key,
Tagging={
"TagSet": [
{"Key": "env", "Value": "test"},
{"Key": "project", "Value": "floci"},
]
},
)
response = s3_client.get_object_tagging(Bucket=test_bucket, Key=key)
tags = {t["Key"]: t["Value"] for t in response["TagSet"]}
assert tags.get("env") == "test"
assert tags.get("project") == "floci"
def test_delete_object_tagging(self, s3_client, test_bucket):
"""Test DeleteObjectTagging removes tags."""
key = "tagged-file.txt"
s3_client.put_object(Bucket=test_bucket, Key=key, Body=b"content")
s3_client.put_object_tagging(
Bucket=test_bucket,
Key=key,
Tagging={"TagSet": [{"Key": "env", "Value": "test"}]},
)
s3_client.delete_object_tagging(Bucket=test_bucket, Key=key)
response = s3_client.get_object_tagging(Bucket=test_bucket, Key=key)
assert len(response["TagSet"]) == 0
class TestS3BucketTagging:
"""Test S3 bucket tagging operations."""
def test_put_bucket_tagging(self, s3_client, test_bucket):
"""Test PutBucketTagging adds tags to bucket."""
s3_client.put_bucket_tagging(
Bucket=test_bucket,
Tagging={
"TagSet": [
{"Key": "team", "Value": "backend"},
{"Key": "cost-center", "Value": "123"},
]
},
)
# If no exception, test passes
def test_get_bucket_tagging(self, s3_client, test_bucket):
"""Test GetBucketTagging returns tags."""
s3_client.put_bucket_tagging(
Bucket=test_bucket,
Tagging={
"TagSet": [
{"Key": "team", "Value": "backend"},
{"Key": "cost-center", "Value": "123"},
]
},
)
response = s3_client.get_bucket_tagging(Bucket=test_bucket)
tags = {t["Key"]: t["Value"] for t in response["TagSet"]}
assert tags.get("team") == "backend"
assert tags.get("cost-center") == "123"
def test_delete_bucket_tagging(self, s3_client, test_bucket):
"""Test DeleteBucketTagging removes tags."""
s3_client.put_bucket_tagging(
Bucket=test_bucket,
Tagging={"TagSet": [{"Key": "team", "Value": "backend"}]},
)
s3_client.delete_bucket_tagging(Bucket=test_bucket)
# Either empty tags or NoSuchTagSet error
try:
response = s3_client.get_bucket_tagging(Bucket=test_bucket)
assert len(response.get("TagSet", [])) == 0
except ClientError:
pass # NoSuchTagSet is also acceptable
class TestS3LargeObject:
"""Test S3 large object operations."""
def test_put_object_25mb(self, s3_client, test_bucket):
"""Test PutObject with 25 MB payload."""
key = "large-object-25mb.bin"
large_payload = b"\x00" * (25 * 1024 * 1024)
s3_client.put_object(
Bucket=test_bucket,
Key=key,
Body=large_payload,
ContentType="application/octet-stream",
)
response = s3_client.head_object(Bucket=test_bucket, Key=key)
assert response["ContentLength"] == 25 * 1024 * 1024
class TestS3MultipartChecksums:
"""Multipart uploads carry composite checksums, computed as on AWS.
AWS combines the part-level checksums of a multipart upload into a single object-level value:
the algorithm applied to the concatenated binary part checksums, Base64 encoded, followed by
``-<part count>``. HeadObject/GetObject return that suffixed value, GetObjectAttributes returns
it without the suffix. SHA1/SHA256 uploads are always COMPOSITE, and so are CRC32 uploads
unless FULL_OBJECT is requested on CreateMultipartUpload.
"""
PART_SIZE = 5 * MIB
TRANSFER_CONFIG = TransferConfig(multipart_threshold=5 * MIB, multipart_chunksize=5 * MIB)
@staticmethod
def _payload(size=12 * MIB):
return random.Random(20260903).randbytes(size)
@staticmethod
def _parts(payload, part_size):
return [payload[i:i + part_size] for i in range(0, len(payload), part_size)]
@staticmethod
def _sha256(data):
return hashlib.sha256(data).digest()
@staticmethod
def _crc32(data):
return (zlib.crc32(data) & 0xFFFFFFFF).to_bytes(4, "big")
@classmethod
def _composite(cls, digest, parts):
combined = digest(b"".join(digest(part) for part in parts))
return base64.b64encode(combined).decode() + f"-{len(parts)}"
def _upload(self, s3_client, bucket, key, payload, tmp_path, **extra_args):
source = tmp_path / key
source.write_bytes(payload)
s3_client.upload_file(
str(source), bucket, key,
ExtraArgs=extra_args or None,
Config=self.TRANSFER_CONFIG,
)
def test_multipart_upload_sha256_reports_composite_checksum(self, s3_client, test_bucket, tmp_path):
"""Multipart upload with SHA256 returns the composite checksum, stable across reads."""
key = "multipart-sha256.bin"
payload = self._payload()
parts = self._parts(payload, self.PART_SIZE)
composite = self._composite(self._sha256, parts)
assert composite.endswith("-3")
self._upload(s3_client, test_bucket, key, payload, tmp_path, ChecksumAlgorithm="SHA256")
head = s3_client.head_object(Bucket=test_bucket, Key=key, ChecksumMode="ENABLED")
assert head["ChecksumSHA256"] == composite
assert head["ChecksumType"] == "COMPOSITE"
assert head["ETag"].endswith('-3"')
# The same value on every read: a client comparing checksums must not see drift
again = s3_client.head_object(Bucket=test_bucket, Key=key, ChecksumMode="ENABLED")
assert again["ChecksumSHA256"] == composite
attributes = s3_client.get_object_attributes(
Bucket=test_bucket, Key=key, ObjectAttributes=["Checksum", "ObjectParts"]
)
assert attributes["Checksum"]["ChecksumSHA256"] == composite.rsplit("-", 1)[0]
assert attributes["Checksum"]["ChecksumType"] == "COMPOSITE"
assert attributes["ObjectParts"]["TotalPartsCount"] == 3
assert [p["ChecksumSHA256"] for p in attributes["ObjectParts"]["Parts"]] == [
base64.b64encode(self._sha256(part)).decode() for part in parts
]
body = s3_client.get_object(Bucket=test_bucket, Key=key)["Body"].read()
assert body == payload
def test_multipart_upload_default_checksum_is_composite_crc32(self, s3_client, test_bucket, tmp_path):
"""Without an explicit algorithm the SDK declares CRC32, which AWS stores as COMPOSITE."""
key = "multipart-default.bin"
payload = self._payload()
parts = self._parts(payload, self.PART_SIZE)
self._upload(s3_client, test_bucket, key, payload, tmp_path)
head = s3_client.head_object(Bucket=test_bucket, Key=key, ChecksumMode="ENABLED")
assert head["ChecksumType"] == "COMPOSITE"
assert head["ChecksumCRC32"] == self._composite(self._crc32, parts)
attributes = s3_client.get_object_attributes(
Bucket=test_bucket, Key=key, ObjectAttributes=["Checksum", "ObjectParts"]
)
assert attributes["Checksum"]["ChecksumCRC32"] == head["ChecksumCRC32"].rsplit("-", 1)[0]
assert [p["ChecksumCRC32"] for p in attributes["ObjectParts"]["Parts"]] == [
base64.b64encode(self._crc32(part)).decode() for part in parts
]
def test_single_part_upload_keeps_full_object_checksum(self, s3_client, test_bucket, tmp_path):
"""A file below the multipart threshold is a plain PutObject: FULL_OBJECT, no suffix."""
key = "single-part-sha256.bin"
payload = self._payload(1 * MIB)
self._upload(s3_client, test_bucket, key, payload, tmp_path, ChecksumAlgorithm="SHA256")
head = s3_client.head_object(Bucket=test_bucket, Key=key, ChecksumMode="ENABLED")
assert head["ChecksumSHA256"] == base64.b64encode(self._sha256(payload)).decode()
assert head["ChecksumType"] == "FULL_OBJECT"
assert "-" not in head["ETag"]
def test_copy_of_multipart_object_is_a_single_full_object(self, s3_client, test_bucket, tmp_path):
"""CopyObject writes one object: FULL_OBJECT checksum, an MD5 ETag without part count, no ObjectParts."""
key, copy_key = "multipart-copy-source.bin", "multipart-copy.bin"
payload = self._payload()
self._upload(s3_client, test_bucket, key, payload, tmp_path, ChecksumAlgorithm="SHA256")
source = s3_client.head_object(Bucket=test_bucket, Key=key, ChecksumMode="ENABLED")
assert source["ChecksumType"] == "COMPOSITE"
result = s3_client.copy_object(
Bucket=test_bucket, Key=copy_key, CopySource={"Bucket": test_bucket, "Key": key}
)["CopyObjectResult"]
full = base64.b64encode(self._sha256(payload)).decode()
assert result["ChecksumSHA256"] == full
assert result["ChecksumType"] == "FULL_OBJECT"
assert result["ETag"] == f'"{hashlib.md5(payload).hexdigest()}"'
assert result["ETag"] != source["ETag"]
head = s3_client.head_object(Bucket=test_bucket, Key=copy_key, ChecksumMode="ENABLED")
assert (head["ChecksumSHA256"], head["ChecksumType"], head["ETag"]) == (full, "FULL_OBJECT", result["ETag"])
attributes = s3_client.get_object_attributes(
Bucket=test_bucket, Key=copy_key, ObjectAttributes=["Checksum", "ObjectParts"]
)
assert attributes["Checksum"]["ChecksumType"] == "FULL_OBJECT"
assert "ObjectParts" not in attributes
def test_get_object_attributes_etag_has_no_quotes(self, s3_client, test_bucket, tmp_path):
"""S3 quotes the ETag everywhere except in GetObjectAttributes."""
key = "etag-quotes.bin"
self._upload(s3_client, test_bucket, key, self._payload(), tmp_path, ChecksumAlgorithm="SHA256")
head_etag = s3_client.head_object(Bucket=test_bucket, Key=key)["ETag"]
attributes = s3_client.get_object_attributes(Bucket=test_bucket, Key=key, ObjectAttributes=["ETag"])
assert head_etag.startswith('"') and head_etag.endswith('-3"')
assert attributes["ETag"] == head_etag.strip('"')
def test_download_with_checksum_mode_works_for_composite_and_full_object(self, s3_client, test_bucket, tmp_path):
"""botocore validates a full-object checksum on GetObject and skips a composite one (the -N suffix)."""
payload, small = self._payload(), self._payload(1 * MIB)
self._upload(s3_client, test_bucket, "download-composite.bin", payload, tmp_path, ChecksumAlgorithm="SHA256")
self._upload(s3_client, test_bucket, "download-full.bin", small, tmp_path, ChecksumAlgorithm="SHA256")
composite = s3_client.get_object(Bucket=test_bucket, Key="download-composite.bin", ChecksumMode="ENABLED")
assert composite["ChecksumSHA256"].endswith("-3")
assert composite["ChecksumType"] == "COMPOSITE"
assert composite["Body"].read() == payload
full = s3_client.get_object(Bucket=test_bucket, Key="download-full.bin", ChecksumMode="ENABLED")
assert full["ChecksumType"] == "FULL_OBJECT"
assert full["Body"].read() == small
@@ -0,0 +1,334 @@
"""S3 CORS enforcement integration tests."""
import logging
import urllib.request
import urllib.error
import pytest
logger = logging.getLogger(__name__)
class TestS3CORS:
"""Test S3 CORS enforcement."""
@pytest.fixture(autouse=True)
def setup_bucket(self, s3_client, unique_name, endpoint_url):
"""Set up a test bucket with an object for CORS testing."""
self.bucket = f"pytest-cors-{unique_name}"
self.endpoint = endpoint_url
self.s3 = s3_client
s3_client.create_bucket(Bucket=self.bucket)
s3_client.put_object(
Bucket=self.bucket,
Key="cors-test.txt",
Body=b"hello cors",
ContentType="text/plain",
)
yield
# Cleanup
try:
s3_client.delete_bucket_cors(Bucket=self.bucket)
except Exception as e:
logger.warning("Failed to clean up CORS config for bucket %s: %s", self.bucket, e)
try:
s3_client.delete_object(Bucket=self.bucket, Key="cors-test.txt")
s3_client.delete_bucket(Bucket=self.bucket)
except Exception as e:
logger.warning("Failed to clean up S3 bucket %s: %s", self.bucket, e)
def _raw_request(self, method, path, headers=None):
"""Make a raw HTTP request; returns (status_code, lowercase_headers_dict)."""
url = f"{self.endpoint}/{self.bucket}{path}"
req = urllib.request.Request(url, method=method)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as resp:
return resp.status, {k.lower(): v for k, v in resp.headers.items()}
except urllib.error.HTTPError as e:
return e.code, {k.lower(): v for k, v in e.headers.items()}
def test_preflight_without_cors_config_returns_403(self):
"""Test preflight request without CORS config returns 403."""
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
)
assert status == 403
def test_put_bucket_cors_wildcard(self):
"""Test PutBucketCors with wildcard origin."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000,
}
]
},
)
def test_wildcard_preflight_returns_200(self):
"""Test wildcard CORS preflight returns 200."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000,
}
]
},
)
status, headers = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
)
assert status == 200
assert headers.get("access-control-allow-origin") == "*"
assert headers.get("access-control-max-age") == "3000"
assert "GET" in headers.get("access-control-allow-methods", "").upper()
def test_wildcard_actual_get_returns_cors_headers(self):
"""Test actual GET with Origin header returns CORS headers."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000,
}
]
},
)
status, headers = self._raw_request(
"GET", "/cors-test.txt", {"Origin": "http://localhost:3000"}
)
assert headers.get("access-control-allow-origin") == "*"
vary = headers.get("vary", "")
assert any(t.strip().lower() == "origin" for t in vary.split(","))
assert "ETag" in headers.get("access-control-expose-headers", "")
def test_get_without_origin_has_no_cors_headers(self):
"""Test GET without Origin header has no CORS headers."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
_, headers = self._raw_request("GET", "/cors-test.txt")
assert "access-control-allow-origin" not in headers
def test_options_without_origin_has_no_cors_headers(self):
"""Test OPTIONS without Origin header has no CORS headers."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
_, headers = self._raw_request("OPTIONS", "/cors-test.txt")
assert "access-control-allow-origin" not in headers
def test_specific_origin_echoes_origin(self):
"""Test specific origin CORS config echoes the origin."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type", "Authorization"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 600,
}
]
},
)
status, headers = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{
"Origin": "https://example.com",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "Content-Type",
},
)
assert status == 200
assert headers.get("access-control-allow-origin") == "https://example.com"
assert headers.get("access-control-max-age") == "600"
def test_non_matching_origin_returns_403(self):
"""Test non-matching origin returns 403."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "https://attacker.evil.com", "Access-Control-Request-Method": "GET"},
)
assert status == 403
def test_non_matching_method_returns_403(self):
"""Test non-matching method returns 403."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
}
]
},
)
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "https://example.com", "Access-Control-Request-Method": "DELETE"},
)
assert status == 403
def test_delete_bucket_cors(self):
"""Test DeleteBucketCors restores 403 on preflight."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
self.s3.delete_bucket_cors(Bucket=self.bucket)
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
)
assert status == 403
def test_subdomain_wildcard_matches(self):
"""Test subdomain wildcard pattern matches subdomains."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["http://*.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 120,
}
]
},
)
status, headers = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "http://app.example.com", "Access-Control-Request-Method": "GET"},
)
assert status == 200
assert headers.get("access-control-allow-origin") == "http://app.example.com"
def test_subdomain_wildcard_rejects_wrong_scheme(self):
"""Test subdomain wildcard rejects different scheme."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["http://*.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "https://app.example.com", "Access-Control-Request-Method": "GET"},
)
assert status == 403
def test_subdomain_wildcard_rejects_different_domain(self):
"""Test subdomain wildcard rejects different domain."""
self.s3.put_bucket_cors(
Bucket=self.bucket,
CORSConfiguration={
"CORSRules": [
{
"AllowedOrigins": ["http://*.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
}
]
},
)
status, _ = self._raw_request(
"OPTIONS",
"/cors-test.txt",
{"Origin": "http://app.other.com", "Access-Control-Request-Method": "GET"},
)
assert status == 403
@@ -0,0 +1,157 @@
"""S3 notification filter integration tests."""
import logging
import pytest
logger = logging.getLogger(__name__)
class TestS3Notifications:
"""Test S3 bucket notification configuration with filters."""
@pytest.fixture(autouse=True)
def setup_resources(self, s3_client, sqs_client, sns_client, unique_name):
"""Set up test resources for S3 notification tests."""
self.s3 = s3_client
self.sqs = sqs_client
self.sns = sns_client
self.account_id = "000000000000"
self.queue_name = f"s3-notif-{unique_name}-queue"
self.topic_name = f"s3-notif-{unique_name}-topic"
self.bucket_name = f"s3-notif-{unique_name}-bucket"
self.queue_arn = f"arn:aws:sqs:us-east-1:{self.account_id}:{self.queue_name}"
# Create SQS queue
sqs_client.create_queue(QueueName=self.queue_name)
# Create SNS topic
response = sns_client.create_topic(Name=self.topic_name)
self.topic_arn = response["TopicArn"]
# Create S3 bucket
s3_client.create_bucket(Bucket=self.bucket_name)
yield
# Cleanup
try:
s3_client.delete_bucket(Bucket=self.bucket_name)
except Exception as e:
logger.warning("Failed to clean up S3 bucket %s: %s", self.bucket_name, e)
try:
sns_client.delete_topic(TopicArn=self.topic_arn)
except Exception as e:
logger.warning("Failed to clean up SNS topic %s: %s", self.topic_arn, e)
try:
queue_url = sqs_client.get_queue_url(QueueName=self.queue_name)["QueueUrl"]
sqs_client.delete_queue(QueueUrl=queue_url)
except Exception as e:
logger.warning("Failed to clean up SQS queue %s: %s", self.queue_name, e)
def test_put_bucket_notification_configuration_with_filters(self):
"""Test PutBucketNotificationConfiguration with prefix/suffix filters."""
self.s3.put_bucket_notification_configuration(
Bucket=self.bucket_name,
NotificationConfiguration={
"QueueConfigurations": [
{
"Id": "sqs-filtered",
"QueueArn": self.queue_arn,
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": "incoming/"},
{"Name": "suffix", "Value": ".csv"},
]
}
},
}
],
"TopicConfigurations": [
{
"Id": "sns-filtered",
"TopicArn": self.topic_arn,
"Events": ["s3:ObjectRemoved:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": ""},
{"Name": "suffix", "Value": ".txt"},
]
}
},
}
],
},
)
def test_get_bucket_notification_configuration_sqs_filter_roundtrip(self):
"""Test SQS notification filter configuration round-trip."""
self.s3.put_bucket_notification_configuration(
Bucket=self.bucket_name,
NotificationConfiguration={
"QueueConfigurations": [
{
"Id": "sqs-filtered",
"QueueArn": self.queue_arn,
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": "incoming/"},
{"Name": "suffix", "Value": ".csv"},
]
}
},
}
],
},
)
response = self.s3.get_bucket_notification_configuration(Bucket=self.bucket_name)
queue_configs = response.get("QueueConfigurations", [])
sqs_entry = next(
(c for c in queue_configs if c.get("QueueArn") == self.queue_arn), None
)
assert sqs_entry is not None
sqs_rules = sqs_entry.get("Filter", {}).get("Key", {}).get("FilterRules", [])
assert len(sqs_rules) == 2
def test_get_bucket_notification_configuration_sns_filter_roundtrip(self):
"""Test SNS notification filter configuration round-trip."""
self.s3.put_bucket_notification_configuration(
Bucket=self.bucket_name,
NotificationConfiguration={
"TopicConfigurations": [
{
"Id": "sns-filtered",
"TopicArn": self.topic_arn,
"Events": ["s3:ObjectRemoved:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": ""},
{"Name": "suffix", "Value": ".txt"},
]
}
},
}
],
},
)
response = self.s3.get_bucket_notification_configuration(Bucket=self.bucket_name)
topic_configs = response.get("TopicConfigurations", [])
sns_entry = next(
(c for c in topic_configs if c.get("TopicArn") == self.topic_arn), None
)
assert sns_entry is not None
sns_rules = sns_entry.get("Filter", {}).get("Key", {}).get("FilterRules", [])
assert len(sns_rules) == 2
@@ -0,0 +1,74 @@
import os
import time
import uuid
import boto3
import pytest
from botocore.config import Config
def client(service):
return boto3.client(
service,
# Every other test file gets this from the conftest.py fixtures, which read
# FLOCI_ENDPOINT (the test runner container's compat-net hostname for floci, e.g.
# http://floci:4566). This file has no fixture of its own, so it must read the same
# variable directly instead of hardcoding localhost, which only resolves to floci
# when running the suite straight on the host rather than in its own CI container.
endpoint_url=os.environ.get("FLOCI_ENDPOINT", "http://localhost:4566"),
region_name="us-east-1",
aws_access_key_id="test",
aws_secret_access_key="test",
config=Config(retries={"max_attempts": 1}),
)
def test_sagemaker_control_plane_and_training():
sm = client("sagemaker")
s3 = client("s3")
suffix = uuid.uuid4().hex[:8]
model = f"compat-sm-{suffix}"
cfg = f"compat-sm-cfg-{suffix}"
bucket = f"compat-sm-{suffix}"
sm.create_model(ModelName=model, PrimaryContainer={"Image": "public.ecr.aws/docker/library/busybox:stable"}, ExecutionRoleArn="arn:aws:iam::000000000000:role/r")
assert sm.describe_model(ModelName=model)["ModelName"] == model
sm.create_endpoint_config(EndpointConfigName=cfg, ProductionVariants=[{"VariantName": "AllTraffic", "ModelName": model, "InitialInstanceCount": 1, "InstanceType": "ml.t2.medium"}])
assert any(c["EndpointConfigName"] == cfg for c in sm.list_endpoint_configs()["EndpointConfigs"])
s3.create_bucket(Bucket=bucket)
s3.put_object(Bucket=bucket, Key="input/data.txt", Body=b"hello")
job = f"compat-train-{suffix}"
sm.create_training_job(
TrainingJobName=job,
RoleArn="arn:aws:iam::000000000000:role/r",
AlgorithmSpecification={
"TrainingImage": "public.ecr.aws/docker/library/busybox:stable",
"TrainingInputMode": "File",
"ContainerEntrypoint": ["/bin/sh", "-c"],
"ContainerArguments": ["mkdir -p /opt/ml/model && echo ok > /opt/ml/model/model.txt"],
},
InputDataConfig=[{
"ChannelName": "train",
"DataSource": {"S3DataSource": {"S3DataType": "S3Prefix", "S3Uri": f"s3://{bucket}/input"}},
}],
OutputDataConfig={"S3OutputPath": f"s3://{bucket}/output"},
ResourceConfig={"InstanceType": "ml.m5.large", "InstanceCount": 1, "VolumeSizeInGB": 1},
StoppingCondition={"MaxRuntimeInSeconds": 60},
)
deadline = time.time() + 120
while time.time() < deadline:
desc = sm.describe_training_job(TrainingJobName=job)
if desc["TrainingJobStatus"] == "Completed":
artifact = desc["ModelArtifacts"]["S3ModelArtifacts"]
key = artifact.split(f"s3://{bucket}/", 1)[1]
assert s3.get_object(Bucket=bucket, Key=key)["Body"].read()
break
if desc["TrainingJobStatus"] == "Failed":
pytest.fail(desc.get("FailureReason", "training failed"))
time.sleep(1)
else:
pytest.fail("Timed out waiting for SageMaker training job")
sm.delete_endpoint_config(EndpointConfigName=cfg)
sm.delete_model(ModelName=model)
@@ -0,0 +1,251 @@
"""Secrets Manager integration tests."""
import pytest
from botocore.exceptions import ClientError
class TestSecretsManagerSecret:
"""Test Secrets Manager secret operations."""
def test_create_secret(self, secretsmanager_client, unique_name):
"""Test CreateSecret creates a secret."""
secret_name = f"pytest-secret-{unique_name}"
secret_value = "my-super-secret-value"
try:
response = secretsmanager_client.create_secret(
Name=secret_name,
SecretString=secret_value,
Description="Test secret",
Tags=[{"Key": "env", "Value": "test"}],
)
assert response["ARN"]
assert secret_name in response["ARN"]
assert response["Name"] == secret_name
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_get_secret_value_by_name(self, secretsmanager_client, unique_name):
"""Test GetSecretValue by name returns secret."""
secret_name = f"pytest-secret-{unique_name}"
secret_value = "my-super-secret-value"
secretsmanager_client.create_secret(Name=secret_name, SecretString=secret_value)
try:
response = secretsmanager_client.get_secret_value(SecretId=secret_name)
assert response["SecretString"] == secret_value
assert response["Name"] == secret_name
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_get_secret_value_by_arn(self, secretsmanager_client, unique_name):
"""Test GetSecretValue by ARN returns secret."""
secret_name = f"pytest-secret-{unique_name}"
secret_value = "my-super-secret-value"
response = secretsmanager_client.create_secret(
Name=secret_name, SecretString=secret_value
)
secret_arn = response["ARN"]
try:
response = secretsmanager_client.get_secret_value(SecretId=secret_arn)
assert response["SecretString"] == secret_value
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_put_secret_value(self, secretsmanager_client, unique_name):
"""Test PutSecretValue updates secret."""
secret_name = f"pytest-secret-{unique_name}"
response = secretsmanager_client.create_secret(
Name=secret_name, SecretString="original"
)
original_version = response["VersionId"]
try:
response = secretsmanager_client.put_secret_value(
SecretId=secret_name, SecretString="updated"
)
new_version = response["VersionId"]
assert new_version
assert new_version != original_version
response = secretsmanager_client.get_secret_value(SecretId=secret_name)
assert response["SecretString"] == "updated"
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_describe_secret(self, secretsmanager_client, unique_name):
"""Test DescribeSecret returns secret metadata."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(
Name=secret_name,
SecretString="value",
Tags=[{"Key": "env", "Value": "test"}],
)
try:
response = secretsmanager_client.describe_secret(SecretId=secret_name)
assert response.get("Tags")
assert not response.get("RotationEnabled", True)
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_update_secret(self, secretsmanager_client, unique_name):
"""Test UpdateSecret updates secret description."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(
Name=secret_name, SecretString="value", Description="Original"
)
try:
secretsmanager_client.update_secret(
SecretId=secret_name, Description="Updated description"
)
response = secretsmanager_client.describe_secret(SecretId=secret_name)
assert response.get("Description") == "Updated description"
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_list_secrets(self, secretsmanager_client, unique_name):
"""Test ListSecrets includes created secret."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(Name=secret_name, SecretString="value")
try:
response = secretsmanager_client.list_secrets()
assert any(s["Name"] == secret_name for s in response["SecretList"])
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_delete_secret(self, secretsmanager_client, unique_name):
"""Test DeleteSecret removes secret."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(Name=secret_name, SecretString="value")
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
with pytest.raises(ClientError) as exc_info:
secretsmanager_client.get_secret_value(SecretId=secret_name)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
class TestSecretsManagerTags:
"""Test Secrets Manager tagging operations."""
def test_tag_resource(self, secretsmanager_client, unique_name):
"""Test TagResource adds tags to secret."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(Name=secret_name, SecretString="value")
try:
secretsmanager_client.tag_resource(
SecretId=secret_name, Tags=[{"Key": "team", "Value": "backend"}]
)
response = secretsmanager_client.describe_secret(SecretId=secret_name)
tags = {t["Key"]: t["Value"] for t in response.get("Tags", [])}
assert tags.get("team") == "backend"
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_untag_resource(self, secretsmanager_client, unique_name):
"""Test UntagResource removes tags from secret."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(
Name=secret_name,
SecretString="value",
Tags=[{"Key": "team", "Value": "backend"}],
)
try:
secretsmanager_client.untag_resource(
SecretId=secret_name, TagKeys=["team"]
)
response = secretsmanager_client.describe_secret(SecretId=secret_name)
tags = {t["Key"] for t in response.get("Tags", [])}
assert "team" not in tags
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
class TestSecretsManagerVersions:
"""Test Secrets Manager version operations."""
def test_list_secret_version_ids(self, secretsmanager_client, unique_name):
"""Test ListSecretVersionIds returns version stages."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(Name=secret_name, SecretString="v1")
secretsmanager_client.put_secret_value(SecretId=secret_name, SecretString="v2")
try:
response = secretsmanager_client.list_secret_version_ids(
SecretId=secret_name
)
stages = [
stage for v in response["Versions"] for stage in v.get("VersionStages", [])
]
assert "AWSCURRENT" in stages
assert "AWSPREVIOUS" in stages
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
class TestSecretsManagerErrors:
"""Test Secrets Manager error handling."""
def test_create_secret_duplicate(self, secretsmanager_client, unique_name):
"""Test CreateSecret returns error for duplicate name."""
secret_name = f"pytest-secret-{unique_name}"
secretsmanager_client.create_secret(Name=secret_name, SecretString="v1")
try:
with pytest.raises(ClientError) as exc_info:
secretsmanager_client.create_secret(
Name=secret_name, SecretString="v2"
)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
finally:
secretsmanager_client.delete_secret(
SecretId=secret_name, ForceDeleteWithoutRecovery=True
)
def test_get_secret_value_non_existent(self, secretsmanager_client, unique_name):
"""Test GetSecretValue returns error for non-existent secret."""
with pytest.raises(ClientError) as exc_info:
secretsmanager_client.get_secret_value(
SecretId=f"non-existent-secret-{unique_name}"
)
assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
@@ -0,0 +1,481 @@
"""SES email template SDK compatibility tests.
Exercises both the V1 SES client (Query protocol) and the V2 SESv2 client
(REST JSON protocol) via boto3, covering CRUD and templated send with
Mustache-style variable substitution.
"""
import json
import pytest
from botocore.exceptions import ClientError
# ============================================
# SES V2 (sesv2) - REST JSON
# ============================================
class TestSesV2EmailTemplateCrud:
"""Template CRUD via the SESv2 REST JSON API."""
def test_create_and_get_email_template(self, sesv2_client, unique_name):
name = f"pytest-tpl-{unique_name}"
try:
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={
"Subject": "Hello {{name}}",
"Text": "Hi {{name}}!",
"Html": "<p>Hi <b>{{name}}</b>!</p>",
},
)
response = sesv2_client.get_email_template(TemplateName=name)
assert response["TemplateName"] == name
assert response["TemplateContent"]["Subject"] == "Hello {{name}}"
assert response["TemplateContent"]["Text"] == "Hi {{name}}!"
assert "{{name}}" in response["TemplateContent"]["Html"]
finally:
_safe_delete_v2(sesv2_client, name)
def test_list_email_templates_includes_created(self, sesv2_client, unique_name):
name = f"pytest-tpl-{unique_name}"
try:
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={"Subject": "s", "Text": "t"},
)
response = sesv2_client.list_email_templates()
names = [t["TemplateName"] for t in response.get("TemplatesMetadata", [])]
assert name in names
finally:
_safe_delete_v2(sesv2_client, name)
def test_update_email_template(self, sesv2_client, unique_name):
name = f"pytest-tpl-{unique_name}"
try:
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={"Subject": "original", "Text": "original body"},
)
sesv2_client.update_email_template(
TemplateName=name,
TemplateContent={
"Subject": "updated {{who}}",
"Text": "updated body for {{who}}",
},
)
response = sesv2_client.get_email_template(TemplateName=name)
assert response["TemplateContent"]["Subject"] == "updated {{who}}"
assert "{{who}}" in response["TemplateContent"]["Text"]
finally:
_safe_delete_v2(sesv2_client, name)
def test_delete_email_template(self, sesv2_client, unique_name):
name = f"pytest-tpl-{unique_name}"
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={"Subject": "s", "Text": "t"},
)
sesv2_client.delete_email_template(TemplateName=name)
with pytest.raises(ClientError) as exc_info:
sesv2_client.get_email_template(TemplateName=name)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
)
class TestSesV2EmailTemplateErrors:
"""Error paths for V2 template operations."""
def test_create_duplicate_rejected(self, sesv2_client, unique_name):
name = f"pytest-tpl-{unique_name}"
try:
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={"Subject": "s", "Text": "t"},
)
with pytest.raises(ClientError) as exc_info:
sesv2_client.create_email_template(
TemplateName=name,
TemplateContent={"Subject": "dup", "Text": "dup"},
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
finally:
_safe_delete_v2(sesv2_client, name)
def test_get_nonexistent(self, sesv2_client, unique_name):
with pytest.raises(ClientError) as exc_info:
sesv2_client.get_email_template(
TemplateName=f"pytest-missing-{unique_name}"
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
)
class TestSesV2SendTemplatedEmail:
"""Content.Template substitution via SESv2 SendEmail."""
def test_send_email_substitutes_template_variables(
self, sesv2_client, unique_name
):
template_name = f"pytest-tpl-{unique_name}"
sender = f"pytest-sender-{unique_name}@example.com"
recipient = f"pytest-recipient-{unique_name}@example.com"
try:
sesv2_client.create_email_identity(EmailIdentity=sender)
sesv2_client.create_email_template(
TemplateName=template_name,
TemplateContent={
"Subject": "Welcome {{name}}",
"Text": "Hello {{name}}, from {{team}}",
"Html": "<p>Welcome <b>{{name}}</b> ({{team}})</p>",
},
)
response = sesv2_client.send_email(
FromEmailAddress=sender,
Destination={"ToAddresses": [recipient]},
Content={
"Template": {
"TemplateName": template_name,
"TemplateData": json.dumps(
{"name": "Alice", "team": "floci"}
),
}
},
)
assert response["MessageId"]
finally:
_safe_delete_v2(sesv2_client, template_name)
_safe_delete_identity_v2(sesv2_client, sender)
def test_send_email_with_inline_template_substitutes_variables(
self, sesv2_client, unique_name
):
sender = f"pytest-inline-sender-{unique_name}@example.com"
try:
sesv2_client.create_email_identity(EmailIdentity=sender)
response = sesv2_client.send_email(
FromEmailAddress=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Content={
"Template": {
"TemplateContent": {
"Subject": "Inline {{name}}",
"Text": "Hello inline {{name}}",
"Html": "<p>Hello inline <b>{{name}}</b></p>",
},
"TemplateData": json.dumps({"name": "Alice"}),
}
},
)
assert response["MessageId"]
finally:
_safe_delete_identity_v2(sesv2_client, sender)
def test_send_email_with_both_name_and_inline_raises(
self, sesv2_client, unique_name
):
sender = f"pytest-both-sender-{unique_name}@example.com"
try:
sesv2_client.create_email_identity(EmailIdentity=sender)
with pytest.raises(ClientError) as exc_info:
sesv2_client.send_email(
FromEmailAddress=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Content={
"Template": {
"TemplateName": "whatever",
"TemplateContent": {"Subject": "s", "Text": "t"},
"TemplateData": "{}",
}
},
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
finally:
_safe_delete_identity_v2(sesv2_client, sender)
def test_send_email_with_template_arn_resolves_stored_template(
self, sesv2_client, unique_name
):
template_name = f"pytest-arn-tpl-{unique_name}"
sender = f"pytest-arn-sender-{unique_name}@example.com"
arn = f"arn:aws:ses:us-east-1:000000000000:template/{template_name}"
try:
sesv2_client.create_email_identity(EmailIdentity=sender)
sesv2_client.create_email_template(
TemplateName=template_name,
TemplateContent={"Subject": "Hi {{name}}", "Text": "Hello {{name}}"},
)
response = sesv2_client.send_email(
FromEmailAddress=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Content={
"Template": {
"TemplateArn": arn,
"TemplateData": json.dumps({"name": "Alice"}),
}
},
)
assert response["MessageId"]
finally:
_safe_delete_v2(sesv2_client, template_name)
_safe_delete_identity_v2(sesv2_client, sender)
def test_send_email_with_unknown_template_raises(
self, sesv2_client, unique_name
):
sender = f"pytest-sender-{unique_name}@example.com"
try:
sesv2_client.create_email_identity(EmailIdentity=sender)
with pytest.raises(ClientError) as exc_info:
sesv2_client.send_email(
FromEmailAddress=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Content={
"Template": {
"TemplateName": f"pytest-missing-{unique_name}",
"TemplateData": "{}",
}
},
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 404
)
finally:
_safe_delete_identity_v2(sesv2_client, sender)
# ============================================
# SES V1 (ses) - Query / XML
# ============================================
class TestSesV1TemplateCrud:
"""Template CRUD via the V1 SES Query API."""
def test_create_and_get_template(self, ses_client, unique_name):
name = f"pytest-v1-tpl-{unique_name}"
try:
ses_client.create_template(
Template={
"TemplateName": name,
"SubjectPart": "Hello {{name}}",
"TextPart": "Hi {{name}}!",
"HtmlPart": "<p>Hi <b>{{name}}</b>!</p>",
}
)
response = ses_client.get_template(TemplateName=name)
template = response["Template"]
assert template["TemplateName"] == name
assert template["SubjectPart"] == "Hello {{name}}"
assert template["TextPart"] == "Hi {{name}}!"
assert "{{name}}" in template["HtmlPart"]
finally:
_safe_delete_v1(ses_client, name)
def test_list_templates_includes_created(self, ses_client, unique_name):
name = f"pytest-v1-tpl-{unique_name}"
try:
ses_client.create_template(
Template={
"TemplateName": name,
"SubjectPart": "s",
"TextPart": "t",
}
)
response = ses_client.list_templates()
names = [
t["Name"] for t in response.get("TemplatesMetadata", [])
]
assert name in names
finally:
_safe_delete_v1(ses_client, name)
def test_update_template(self, ses_client, unique_name):
name = f"pytest-v1-tpl-{unique_name}"
try:
ses_client.create_template(
Template={
"TemplateName": name,
"SubjectPart": "original",
"TextPart": "original",
}
)
ses_client.update_template(
Template={
"TemplateName": name,
"SubjectPart": "updated {{who}}",
"TextPart": "updated body {{who}}",
}
)
response = ses_client.get_template(TemplateName=name)
assert response["Template"]["SubjectPart"] == "updated {{who}}"
finally:
_safe_delete_v1(ses_client, name)
def test_delete_template(self, ses_client, unique_name):
name = f"pytest-v1-tpl-{unique_name}"
ses_client.create_template(
Template={"TemplateName": name, "SubjectPart": "s", "TextPart": "t"}
)
ses_client.delete_template(TemplateName=name)
with pytest.raises(ClientError) as exc_info:
ses_client.get_template(TemplateName=name)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
class TestSesV1TemplateErrors:
"""Error paths for V1 template operations."""
def test_create_duplicate_rejected(self, ses_client, unique_name):
name = f"pytest-v1-tpl-{unique_name}"
try:
ses_client.create_template(
Template={
"TemplateName": name,
"SubjectPart": "s",
"TextPart": "t",
}
)
with pytest.raises(ClientError) as exc_info:
ses_client.create_template(
Template={
"TemplateName": name,
"SubjectPart": "dup",
"TextPart": "dup",
}
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
finally:
_safe_delete_v1(ses_client, name)
def test_get_nonexistent(self, ses_client, unique_name):
with pytest.raises(ClientError) as exc_info:
ses_client.get_template(TemplateName=f"pytest-missing-{unique_name}")
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
class TestSesV1SendTemplatedEmail:
"""SendTemplatedEmail resolves stored templates via the V1 Query API."""
def test_send_templated_email(self, ses_client, unique_name):
template_name = f"pytest-v1-tpl-{unique_name}"
sender = f"pytest-v1-sender-{unique_name}@example.com"
recipient = f"pytest-v1-recipient-{unique_name}@example.com"
try:
ses_client.verify_email_identity(EmailAddress=sender)
ses_client.create_template(
Template={
"TemplateName": template_name,
"SubjectPart": "Welcome {{name}}",
"TextPart": "Hello {{name}}, from {{team}}",
}
)
response = ses_client.send_templated_email(
Source=sender,
Destination={"ToAddresses": [recipient]},
Template=template_name,
TemplateData=json.dumps({"name": "Alice", "team": "floci"}),
)
assert response["MessageId"]
finally:
_safe_delete_v1(ses_client, template_name)
_safe_delete_identity_v1(ses_client, sender)
def test_send_templated_email_with_template_arn_and_name(
self, ses_client, unique_name
):
"""boto3 requires Template (name) on SendTemplatedEmail; TemplateArn is
supplied alongside for cross-account addressing on real AWS. Floci
accepts both and resolves via the name."""
template_name = f"pytest-v1-arn-tpl-{unique_name}"
sender = f"pytest-v1-arn-sender-{unique_name}@example.com"
arn = f"arn:aws:ses:us-east-1:000000000000:template/{template_name}"
try:
ses_client.verify_email_identity(EmailAddress=sender)
ses_client.create_template(
Template={
"TemplateName": template_name,
"SubjectPart": "Hi {{name}}",
"TextPart": "Hello {{name}}",
}
)
response = ses_client.send_templated_email(
Source=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Template=template_name,
TemplateArn=arn,
TemplateData=json.dumps({"name": "Alice"}),
)
assert response["MessageId"]
finally:
_safe_delete_v1(ses_client, template_name)
_safe_delete_identity_v1(ses_client, sender)
def test_send_templated_email_with_unknown_template_raises(
self, ses_client, unique_name
):
sender = f"pytest-v1-sender-{unique_name}@example.com"
try:
ses_client.verify_email_identity(EmailAddress=sender)
with pytest.raises(ClientError) as exc_info:
ses_client.send_templated_email(
Source=sender,
Destination={"ToAddresses": ["recipient@example.com"]},
Template=f"pytest-missing-{unique_name}",
TemplateData="{}",
)
assert (
exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
)
finally:
_safe_delete_identity_v1(ses_client, sender)
# ============================================
# Cleanup helpers
# ============================================
def _safe_delete_v2(client, template_name):
try:
client.delete_email_template(TemplateName=template_name)
except ClientError:
pass
def _safe_delete_v1(client, template_name):
try:
client.delete_template(TemplateName=template_name)
except ClientError:
pass
def _safe_delete_identity_v2(client, email):
try:
client.delete_email_identity(EmailIdentity=email)
except ClientError:
pass
def _safe_delete_identity_v1(client, email):
try:
client.delete_identity(Identity=email)
except ClientError:
pass
@@ -0,0 +1,240 @@
"""SNS topic integration tests."""
import time
import pytest
class TestSNSTopic:
"""Test SNS topic operations."""
def test_create_topic(self, sns_client, unique_name):
"""Test CreateTopic creates a topic with correct ARN."""
topic_name = f"pytest-sns-{unique_name}"
try:
response = sns_client.create_topic(Name=topic_name)
topic_arn = response["TopicArn"]
assert topic_name in topic_arn
finally:
sns_client.delete_topic(TopicArn=response["TopicArn"])
def test_list_topics(self, sns_client, unique_name):
"""Test ListTopics returns created topic."""
topic_name = f"pytest-sns-{unique_name}"
response = sns_client.create_topic(Name=topic_name)
topic_arn = response["TopicArn"]
try:
response = sns_client.list_topics()
assert any(t["TopicArn"] == topic_arn for t in response["Topics"])
finally:
sns_client.delete_topic(TopicArn=topic_arn)
def test_get_topic_attributes(self, sns_client, unique_name):
"""Test GetTopicAttributes returns topic details."""
topic_name = f"pytest-sns-{unique_name}"
response = sns_client.create_topic(Name=topic_name)
topic_arn = response["TopicArn"]
try:
response = sns_client.get_topic_attributes(TopicArn=topic_arn)
assert "TopicArn" in response["Attributes"]
finally:
sns_client.delete_topic(TopicArn=topic_arn)
def test_delete_topic(self, sns_client, unique_name):
"""Test DeleteTopic removes topic."""
topic_name = f"pytest-sns-{unique_name}"
response = sns_client.create_topic(Name=topic_name)
topic_arn = response["TopicArn"]
sns_client.delete_topic(TopicArn=topic_arn)
# Topic should not be in list
response = sns_client.list_topics()
assert not any(t["TopicArn"] == topic_arn for t in response.get("Topics", []))
class TestSNSSubscription:
"""Test SNS subscription operations."""
def test_subscribe_sqs(self, sns_client, sqs_client, unique_name):
"""Test Subscribe with SQS endpoint."""
topic_name = f"pytest-sns-{unique_name}"
queue_name = f"pytest-sns-queue-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
queue_response = sqs_client.create_queue(QueueName=queue_name)
queue_url = queue_response["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
try:
response = sns_client.subscribe(
TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn
)
sub_arn = response["SubscriptionArn"]
assert sub_arn
finally:
sns_client.delete_topic(TopicArn=topic_arn)
sqs_client.delete_queue(QueueUrl=queue_url)
def test_list_subscriptions_by_topic(self, sns_client, sqs_client, unique_name):
"""Test ListSubscriptionsByTopic returns subscriptions."""
topic_name = f"pytest-sns-{unique_name}"
queue_name = f"pytest-sns-queue-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
queue_response = sqs_client.create_queue(QueueName=queue_name)
queue_url = queue_response["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sub_response = sns_client.subscribe(
TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn
)
sub_arn = sub_response["SubscriptionArn"]
try:
response = sns_client.list_subscriptions_by_topic(TopicArn=topic_arn)
assert any(
s["SubscriptionArn"] == sub_arn for s in response["Subscriptions"]
)
finally:
sns_client.unsubscribe(SubscriptionArn=sub_arn)
sns_client.delete_topic(TopicArn=topic_arn)
sqs_client.delete_queue(QueueUrl=queue_url)
def test_unsubscribe(self, sns_client, sqs_client, unique_name):
"""Test Unsubscribe removes subscription."""
topic_name = f"pytest-sns-{unique_name}"
queue_name = f"pytest-sns-queue-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
queue_response = sqs_client.create_queue(QueueName=queue_name)
queue_url = queue_response["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sub_response = sns_client.subscribe(
TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn
)
sub_arn = sub_response["SubscriptionArn"]
try:
sns_client.unsubscribe(SubscriptionArn=sub_arn)
# Unsubscribe should succeed without exception
finally:
sns_client.delete_topic(TopicArn=topic_arn)
sqs_client.delete_queue(QueueUrl=queue_url)
class TestSNSPublish:
"""Test SNS publish operations."""
def test_publish(self, sns_client, unique_name):
"""Test Publish sends message and returns MessageId."""
topic_name = f"pytest-sns-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
try:
response = sns_client.publish(
TopicArn=topic_arn,
Message="hello from pytest sns",
Subject="test",
)
assert response.get("MessageId")
finally:
sns_client.delete_topic(TopicArn=topic_arn)
def test_publish_sqs_delivery(self, sns_client, sqs_client, unique_name):
"""Test message is delivered to SQS subscription."""
topic_name = f"pytest-sns-{unique_name}"
queue_name = f"pytest-sns-queue-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
queue_response = sqs_client.create_queue(QueueName=queue_name)
queue_url = queue_response["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sub_response = sns_client.subscribe(
TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn
)
sub_arn = sub_response["SubscriptionArn"]
try:
sns_client.publish(
TopicArn=topic_arn,
Message="hello from pytest sns",
Subject="test",
)
time.sleep(0.5)
response = sqs_client.receive_message(
QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=2
)
msgs = response.get("Messages", [])
assert len(msgs) > 0
assert "hello from pytest sns" in msgs[0]["Body"]
finally:
sns_client.unsubscribe(SubscriptionArn=sub_arn)
sns_client.delete_topic(TopicArn=topic_arn)
sqs_client.delete_queue(QueueUrl=queue_url)
def test_publish_with_message_attributes(self, sns_client, sqs_client, unique_name):
"""Test publish with message attributes delivered to SQS."""
topic_name = f"pytest-sns-{unique_name}"
queue_name = f"pytest-sns-queue-{unique_name}"
topic_response = sns_client.create_topic(Name=topic_name)
topic_arn = topic_response["TopicArn"]
queue_response = sqs_client.create_queue(QueueName=queue_name)
queue_url = queue_response["QueueUrl"]
queue_arn = sqs_client.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sub_response = sns_client.subscribe(
TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn
)
sub_arn = sub_response["SubscriptionArn"]
try:
sns_client.publish(
TopicArn=topic_arn,
Message="msg with attrs",
MessageAttributes={
"my-attr": {"DataType": "String", "StringValue": "my-value"}
},
)
time.sleep(0.5)
response = sqs_client.receive_message(
QueueUrl=queue_url, MaxNumberOfMessages=1, WaitTimeSeconds=2
)
msgs = response.get("Messages", [])
assert len(msgs) > 0
assert "my-value" in msgs[0]["Body"]
finally:
sns_client.unsubscribe(SubscriptionArn=sub_arn)
sns_client.delete_topic(TopicArn=topic_arn)
sqs_client.delete_queue(QueueUrl=queue_url)
@@ -0,0 +1,301 @@
"""SQS queue integration tests."""
import json
import time
import pytest
class TestSQSQueue:
"""Test SQS queue operations."""
def test_create_queue(self, sqs_client, unique_name):
"""Test CreateQueue creates a queue with correct URL."""
queue_name = f"pytest-sdk-{unique_name}"
try:
response = sqs_client.create_queue(QueueName=queue_name)
queue_url = response["QueueUrl"]
assert queue_name in queue_url
finally:
sqs_client.delete_queue(QueueUrl=response["QueueUrl"])
def test_get_queue_url(self, sqs_client, unique_name):
"""Test GetQueueUrl returns correct URL."""
queue_name = f"pytest-sdk-{unique_name}"
response = sqs_client.create_queue(QueueName=queue_name)
queue_url = response["QueueUrl"]
try:
response = sqs_client.get_queue_url(QueueName=queue_name)
assert response["QueueUrl"] == queue_url
finally:
sqs_client.delete_queue(QueueUrl=queue_url)
def test_list_queues(self, sqs_client, unique_name):
"""Test ListQueues returns created queue."""
queue_name = f"pytest-sdk-{unique_name}"
response = sqs_client.create_queue(QueueName=queue_name)
queue_url = response["QueueUrl"]
try:
response = sqs_client.list_queues(QueueNamePrefix=queue_name)
assert any(queue_name in u for u in response.get("QueueUrls", []))
finally:
sqs_client.delete_queue(QueueUrl=queue_url)
class TestSQSMessages:
"""Test SQS message operations."""
def test_send_message(self, sqs_client, test_queue):
"""Test SendMessage sends message with MessageId."""
response = sqs_client.send_message(
QueueUrl=test_queue, MessageBody="Hello from pytest!"
)
assert response.get("MessageId")
def test_receive_message(self, sqs_client, test_queue):
"""Test ReceiveMessage receives sent message."""
sqs_client.send_message(QueueUrl=test_queue, MessageBody="Hello from pytest!")
response = sqs_client.receive_message(
QueueUrl=test_queue, MaxNumberOfMessages=1
)
msgs = response.get("Messages", [])
assert len(msgs) == 1
assert msgs[0]["Body"] == "Hello from pytest!"
# Cleanup
sqs_client.delete_message(
QueueUrl=test_queue, ReceiptHandle=msgs[0]["ReceiptHandle"]
)
def test_delete_message(self, sqs_client, test_queue):
"""Test DeleteMessage removes message from queue."""
sqs_client.send_message(QueueUrl=test_queue, MessageBody="To delete")
response = sqs_client.receive_message(
QueueUrl=test_queue, MaxNumberOfMessages=1
)
receipt = response["Messages"][0]["ReceiptHandle"]
sqs_client.delete_message(QueueUrl=test_queue, ReceiptHandle=receipt)
# Queue should be empty
response = sqs_client.receive_message(
QueueUrl=test_queue, MaxNumberOfMessages=1
)
assert len(response.get("Messages", [])) == 0
def test_send_message_batch(self, sqs_client, test_queue):
"""Test SendMessageBatch sends multiple messages."""
response = sqs_client.send_message_batch(
QueueUrl=test_queue,
Entries=[
{"Id": "m1", "MessageBody": "Batch 1"},
{"Id": "m2", "MessageBody": "Batch 2"},
{"Id": "m3", "MessageBody": "Batch 3"},
],
)
assert len(response["Successful"]) == 3
assert len(response.get("Failed", [])) == 0
def test_delete_message_batch(self, sqs_client, test_queue):
"""Test DeleteMessageBatch deletes multiple messages."""
sqs_client.send_message_batch(
QueueUrl=test_queue,
Entries=[
{"Id": "m1", "MessageBody": "Batch 1"},
{"Id": "m2", "MessageBody": "Batch 2"},
{"Id": "m3", "MessageBody": "Batch 3"},
],
)
response = sqs_client.receive_message(
QueueUrl=test_queue, MaxNumberOfMessages=3
)
msgs = response.get("Messages", [])
entries = [
{"Id": f"d{i}", "ReceiptHandle": m["ReceiptHandle"]}
for i, m in enumerate(msgs)
]
response = sqs_client.delete_message_batch(QueueUrl=test_queue, Entries=entries)
assert len(response["Successful"]) == 3
def test_message_attributes(self, sqs_client, test_queue):
"""Test message with custom attributes."""
sqs_client.send_message(
QueueUrl=test_queue,
MessageBody="msg-attrs",
MessageAttributes={"myattr": {"DataType": "String", "StringValue": "myval"}},
)
response = sqs_client.receive_message(
QueueUrl=test_queue,
MaxNumberOfMessages=1,
MessageAttributeNames=["All"],
)
msgs = response.get("Messages", [])
assert len(msgs) == 1
assert (
msgs[0].get("MessageAttributes", {}).get("myattr", {}).get("StringValue")
== "myval"
)
# Cleanup
sqs_client.delete_message(
QueueUrl=test_queue, ReceiptHandle=msgs[0]["ReceiptHandle"]
)
class TestSQSAttributes:
"""Test SQS queue attribute operations."""
def test_set_queue_attributes(self, sqs_client, test_queue):
"""Test SetQueueAttributes modifies queue attributes."""
sqs_client.set_queue_attributes(
QueueUrl=test_queue, Attributes={"VisibilityTimeout": "60"}
)
response = sqs_client.get_queue_attributes(
QueueUrl=test_queue, AttributeNames=["VisibilityTimeout"]
)
assert response["Attributes"].get("VisibilityTimeout") == "60"
class TestSQSTags:
"""Test SQS queue tagging operations."""
def test_tag_queue(self, sqs_client, test_queue):
"""Test TagQueue adds tags to queue."""
sqs_client.tag_queue(
QueueUrl=test_queue, Tags={"env": "test", "team": "backend"}
)
# If no exception, test passes
def test_list_queue_tags(self, sqs_client, test_queue):
"""Test ListQueueTags returns queue tags."""
sqs_client.tag_queue(
QueueUrl=test_queue, Tags={"env": "test", "team": "backend"}
)
response = sqs_client.list_queue_tags(QueueUrl=test_queue)
tags = response.get("Tags", {})
assert tags.get("env") == "test"
assert tags.get("team") == "backend"
def test_untag_queue(self, sqs_client, test_queue):
"""Test UntagQueue removes tags from queue."""
sqs_client.tag_queue(
QueueUrl=test_queue, Tags={"env": "test", "team": "backend"}
)
sqs_client.untag_queue(QueueUrl=test_queue, TagKeys=["team"])
response = sqs_client.list_queue_tags(QueueUrl=test_queue)
tags = response.get("Tags", {})
assert tags.get("env") == "test"
assert "team" not in tags
class TestSQSLongPolling:
"""Test SQS long polling behavior."""
def test_long_polling(self, sqs_client, test_queue):
"""Test long polling waits for messages."""
start = time.time()
sqs_client.receive_message(
QueueUrl=test_queue, MaxNumberOfMessages=1, WaitTimeSeconds=2
)
elapsed = time.time() - start
assert elapsed >= 1.8
class TestSQSDeadLetterQueue:
"""Test SQS dead letter queue operations."""
def test_dlq_routing(self, sqs_client, unique_name):
"""Test messages are moved to DLQ after maxReceiveCount."""
# Create main queue and DLQ
main_queue_name = f"pytest-sdk-{unique_name}-main"
dlq_name = f"pytest-sdk-{unique_name}-dlq"
dlq_response = sqs_client.create_queue(QueueName=dlq_name)
dlq_url = dlq_response["QueueUrl"]
dlq_arn = sqs_client.get_queue_attributes(
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
main_response = sqs_client.create_queue(QueueName=main_queue_name)
main_url = main_response["QueueUrl"]
try:
# Set redrive policy
redrive = json.dumps(
{"maxReceiveCount": "2", "deadLetterTargetArn": dlq_arn}
)
sqs_client.set_queue_attributes(
QueueUrl=main_url, Attributes={"RedrivePolicy": redrive}
)
# Send message and receive twice (to exceed maxReceiveCount)
sqs_client.send_message(QueueUrl=main_url, MessageBody="dlq-test")
m1 = sqs_client.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1)[
"Messages"
][0]
sqs_client.change_message_visibility(
QueueUrl=main_url, ReceiptHandle=m1["ReceiptHandle"], VisibilityTimeout=0
)
m2 = sqs_client.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1)[
"Messages"
][0]
sqs_client.change_message_visibility(
QueueUrl=main_url, ReceiptHandle=m2["ReceiptHandle"], VisibilityTimeout=0
)
# Main queue should be empty now
r3 = sqs_client.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1)
assert len(r3.get("Messages", [])) == 0
# Message should be in DLQ
dlq_recv = sqs_client.receive_message(
QueueUrl=dlq_url, MaxNumberOfMessages=1
)
dlq_msgs = dlq_recv.get("Messages", [])
assert len(dlq_msgs) == 1
assert dlq_msgs[0]["Body"] == "dlq-test"
finally:
sqs_client.delete_queue(QueueUrl=main_url)
sqs_client.delete_queue(QueueUrl=dlq_url)
def test_list_dead_letter_source_queues(self, sqs_client, unique_name):
"""Test ListDeadLetterSourceQueues returns source queues."""
main_queue_name = f"pytest-sdk-{unique_name}-main"
dlq_name = f"pytest-sdk-{unique_name}-dlq"
dlq_response = sqs_client.create_queue(QueueName=dlq_name)
dlq_url = dlq_response["QueueUrl"]
dlq_arn = sqs_client.get_queue_attributes(
QueueUrl=dlq_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
main_response = sqs_client.create_queue(QueueName=main_queue_name)
main_url = main_response["QueueUrl"]
try:
redrive = json.dumps(
{"maxReceiveCount": "2", "deadLetterTargetArn": dlq_arn}
)
sqs_client.set_queue_attributes(
QueueUrl=main_url, Attributes={"RedrivePolicy": redrive}
)
response = sqs_client.list_dead_letter_source_queues(QueueUrl=dlq_url)
assert any(main_url == u for u in response.get("queueUrls", []))
finally:
sqs_client.delete_queue(QueueUrl=main_url)
sqs_client.delete_queue(QueueUrl=dlq_url)
@@ -0,0 +1,191 @@
"""SSM Parameter Store integration tests."""
import pytest
from botocore.exceptions import ClientError
class TestSSMParameter:
"""Test SSM Parameter Store operations."""
def test_put_parameter(self, ssm_client, unique_name):
"""Test PutParameter creates a parameter with version > 0."""
param_name = f"/pytest-sdk-test/{unique_name}"
param_value = "param-value-boto3"
try:
response = ssm_client.put_parameter(
Name=param_name, Value=param_value, Type="String", Overwrite=True
)
assert response["Version"] > 0
finally:
ssm_client.delete_parameter(Name=param_name)
def test_get_parameter(self, ssm_client, unique_name):
"""Test GetParameter retrieves correct value."""
param_name = f"/pytest-sdk-test/{unique_name}"
param_value = "param-value-boto3"
ssm_client.put_parameter(Name=param_name, Value=param_value, Type="String")
try:
response = ssm_client.get_parameter(Name=param_name, WithDecryption=False)
assert response["Parameter"]["Value"] == param_value
finally:
ssm_client.delete_parameter(Name=param_name)
def test_label_parameter_version(self, ssm_client, unique_name):
"""Test LabelParameterVersion adds label to parameter."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
try:
ssm_client.label_parameter_version(
Name=param_name, Labels=["py-label"], ParameterVersion=1
)
# If no exception, test passes
finally:
ssm_client.delete_parameter(Name=param_name)
def test_get_parameter_history(self, ssm_client, unique_name):
"""Test GetParameterHistory returns parameter versions."""
param_name = f"/pytest-sdk-test/{unique_name}"
param_value = "param-value-boto3"
ssm_client.put_parameter(Name=param_name, Value=param_value, Type="String")
try:
response = ssm_client.get_parameter_history(
Name=param_name, WithDecryption=False
)
found = any(p["Value"] == param_value for p in response["Parameters"])
assert found
finally:
ssm_client.delete_parameter(Name=param_name)
def test_get_parameters(self, ssm_client, unique_name):
"""Test GetParameters retrieves multiple parameters."""
param_name = f"/pytest-sdk-test/{unique_name}"
param_value = "param-value-boto3"
ssm_client.put_parameter(Name=param_name, Value=param_value, Type="String")
try:
response = ssm_client.get_parameters(Names=[param_name])
found = any(
p["Name"] == param_name and p["Value"] == param_value
for p in response["Parameters"]
)
assert found
finally:
ssm_client.delete_parameter(Name=param_name)
def test_describe_parameters(self, ssm_client, unique_name):
"""Test DescribeParameters lists parameters."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
try:
response = ssm_client.describe_parameters()
found = any(p["Name"] == param_name for p in response["Parameters"])
assert found
finally:
ssm_client.delete_parameter(Name=param_name)
def test_get_parameters_by_path(self, ssm_client, unique_name):
"""Test GetParametersByPath retrieves parameters under a path."""
path = f"/pytest-sdk-test/{unique_name}"
param_name = f"{path}/param"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
try:
response = ssm_client.get_parameters_by_path(Path=path, Recursive=True)
found = any(p["Name"] == param_name for p in response["Parameters"])
assert found
finally:
ssm_client.delete_parameter(Name=param_name)
def test_add_tags_to_resource(self, ssm_client, unique_name):
"""Test AddTagsToResource adds tags to parameter."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
try:
ssm_client.add_tags_to_resource(
ResourceType="Parameter",
ResourceId=param_name,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
# If no exception, test passes
finally:
ssm_client.delete_parameter(Name=param_name)
def test_list_tags_for_resource(self, ssm_client, unique_name):
"""Test ListTagsForResource returns tags."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
ssm_client.add_tags_to_resource(
ResourceType="Parameter",
ResourceId=param_name,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
try:
response = ssm_client.list_tags_for_resource(
ResourceType="Parameter", ResourceId=param_name
)
tags = {t["Key"]: t["Value"] for t in response["TagList"]}
assert tags.get("env") == "test"
assert tags.get("team") == "backend"
finally:
ssm_client.delete_parameter(Name=param_name)
def test_remove_tags_from_resource(self, ssm_client, unique_name):
"""Test RemoveTagsFromResource removes tags."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
ssm_client.add_tags_to_resource(
ResourceType="Parameter",
ResourceId=param_name,
Tags=[
{"Key": "env", "Value": "test"},
{"Key": "team", "Value": "backend"},
],
)
try:
ssm_client.remove_tags_from_resource(
ResourceType="Parameter", ResourceId=param_name, TagKeys=["team"]
)
response = ssm_client.list_tags_for_resource(
ResourceType="Parameter", ResourceId=param_name
)
tags = {t["Key"]: t["Value"] for t in response["TagList"]}
assert tags.get("env") == "test"
assert "team" not in tags
finally:
ssm_client.delete_parameter(Name=param_name)
def test_delete_parameter(self, ssm_client, unique_name):
"""Test DeleteParameter removes parameter."""
param_name = f"/pytest-sdk-test/{unique_name}"
ssm_client.put_parameter(Name=param_name, Value="value", Type="String")
ssm_client.delete_parameter(Name=param_name)
with pytest.raises(ClientError) as exc_info:
ssm_client.get_parameter(Name=param_name, WithDecryption=False)
assert exc_info.value.response["Error"]["Code"] == "ParameterNotFound"
def test_delete_parameters(self, ssm_client, unique_name):
"""Test DeleteParameters removes multiple parameters."""
p1 = f"/pytest-sdk-test/{unique_name}/p1"
p2 = f"/pytest-sdk-test/{unique_name}/p2"
ssm_client.put_parameter(Name=p1, Value="v1", Type="String")
ssm_client.put_parameter(Name=p2, Value="v2", Type="String")
response = ssm_client.delete_parameters(Names=[p1, p2])
assert len(response["DeletedParameters"]) == 2
@@ -0,0 +1,99 @@
"""STS integration tests."""
import pytest
from botocore.exceptions import ClientError, ParamValidationError
class TestSTSIdentity:
"""Test STS identity operations."""
def test_get_caller_identity(self, sts_client):
"""Test GetCallerIdentity returns identity details."""
response = sts_client.get_caller_identity()
assert response.get("Account")
assert response.get("Arn")
assert response.get("UserId")
def test_get_caller_identity_account_id(self, sts_client):
"""Test GetCallerIdentity returns expected account ID."""
response = sts_client.get_caller_identity()
assert response["Account"] == "000000000000"
class TestSTSAssumeRole:
"""Test STS assume role operations."""
def test_assume_role(self, sts_client):
"""Test AssumeRole returns temporary credentials."""
response = sts_client.assume_role(
RoleArn="arn:aws:iam::000000000000:role/pytest-assumed-role",
RoleSessionName="pytest-session",
DurationSeconds=3600,
)
creds = response["Credentials"]
assert creds["AccessKeyId"].startswith("ASIA")
assert creds["SecretAccessKey"]
assert creds["SessionToken"]
def test_assume_role_assumed_role_user(self, sts_client):
"""Test AssumeRole returns AssumedRoleUser with correct ARN."""
response = sts_client.assume_role(
RoleArn="arn:aws:iam::000000000000:role/my-role",
RoleSessionName="my-session",
)
assert "assumed-role/my-role/my-session" in response["AssumedRoleUser"]["Arn"]
def test_assume_role_with_web_identity(self, sts_client):
"""Test AssumeRoleWithWebIdentity returns credentials."""
response = sts_client.assume_role_with_web_identity(
RoleArn="arn:aws:iam::000000000000:role/web-identity-role",
RoleSessionName="web-session",
WebIdentityToken="dummy-token",
DurationSeconds=3600,
)
creds = response["Credentials"]
assert creds["AccessKeyId"].startswith("ASIA")
assert (
"assumed-role/web-identity-role/web-session"
in response["AssumedRoleUser"]["Arn"]
)
def test_assume_role_missing_role_arn(self, sts_client):
"""Test AssumeRole validates required RoleArn parameter."""
with pytest.raises((ClientError, ParamValidationError)):
sts_client.assume_role(RoleSessionName="s")
class TestSTSSessionToken:
"""Test STS session token operations."""
def test_get_session_token(self, sts_client):
"""Test GetSessionToken returns temporary credentials."""
response = sts_client.get_session_token(DurationSeconds=7200)
creds = response["Credentials"]
assert creds["AccessKeyId"].startswith("ASIA")
assert creds["SessionToken"]
class TestSTSFederation:
"""Test STS federation operations."""
def test_get_federation_token(self, sts_client):
"""Test GetFederationToken returns credentials with federated user."""
response = sts_client.get_federation_token(
Name="pytest-fed-user", DurationSeconds=3600
)
creds = response["Credentials"]
assert creds["AccessKeyId"].startswith("ASIA")
assert "federated-user/pytest-fed-user" in response["FederatedUser"]["Arn"]
class TestSTSUtilities:
"""Test STS utility operations."""
def test_decode_authorization_message(self, sts_client):
"""Test DecodeAuthorizationMessage decodes message."""
response = sts_client.decode_authorization_message(
EncodedMessage="test-encoded-message"
)
assert response.get("DecodedMessage")