second first commit
This commit is contained in:
+112
@@ -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")
|
||||
Reference in New Issue
Block a user