aws2-9 Exercise 2: SNS Fan-Out and Filters, and EventBridge Event Patterns ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with moto 5.2.4 (server mode on port 5000), boto3 1.43.112, cfn-lint 1.57.2, PyYAML; Step Functions Local from the Docker image amazon/aws-stepfunctions-local on port 8083 with a mock config (Docker Desktop 29.8.2); the SAM CLI 1.168.0 from the chapter 8 environment for the Lambda run. Scratch folder C:/lwaws/c9, nothing installed globally. Dummy credentials (test/test) are used with the emulators. VERIFICATION LEVEL: 1 (no AWS account, nothing deployed); statements about the real services come from the AWS documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. --- sns_tests.py --- """SNS fan-out to SQS, raw delivery and subscription filter policies, against the moto emulator. The accept/reject examples are the ones printed on the SNS 'example filter policies' page (read 2026-10-13).""" import json, time from common import client, uniq sns, sqs = client("sns"), client("sqs") results = [] def mkq(prefix): url = sqs.create_queue(QueueName=uniq(prefix))["QueueUrl"] return url, sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"] def drain(url): out = [] for _ in range(3): r = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=0).get("Messages", []) if not r: break out += [m["Body"] for m in r] return out def say(name, docs, got, ok): results.append(ok) print(f"{'ok ' if ok else 'DIFF'} {name}\n docs: {docs}\n emulator: {got}") # 1. fan-out, envelope versus raw delivery topic = sns.create_topic(Name=uniq("t"))["TopicArn"] q_env, a_env = mkq("env") q_raw, a_raw = mkq("raw") sns.subscribe(TopicArn=topic, Protocol="sqs", Endpoint=a_env) raw_sub = sns.subscribe(TopicArn=topic, Protocol="sqs", Endpoint=a_raw, ReturnSubscriptionArn=True)["SubscriptionArn"] sns.set_subscription_attributes(SubscriptionArn=raw_sub, AttributeName="RawMessageDelivery", AttributeValue="true") sns.publish(TopicArn=topic, Message="This is a test message.", Subject="TestSubject") env, raw = drain(q_env), drain(q_raw) env_doc = json.loads(env[0]) if env else {} say("one publish reaches every subscribed queue", "an SNS topic fans a message out to all subscribers", f"queue 1 got {len(env)}, queue 2 got {len(raw)}", len(env) == 1 and len(raw) == 1) say("default delivery wraps the message in an SNS JSON envelope", "envelope fields Type, MessageId, TopicArn, Subject, Message, Timestamp, SignatureVersion, Signature, SigningCertURL, UnsubscribeURL", "envelope keys: " + ", ".join(env_doc), {"Type", "MessageId", "TopicArn", "Subject", "Message", "Timestamp"} <= set(env_doc)) say("raw message delivery sends the message 'as is'", "'any Amazon SNS metadata is stripped from the published message and the message is sent as is'", f"body: {raw[0] if raw else None!r}", raw == ["This is a test message."]) envelope_extra = {"SignatureVersion", "Signature", "SigningCertURL", "UnsubscribeURL"} - set(env_doc) print(f" note: envelope keys the docs example shows but the emulator omitted: {sorted(envelope_extra) or 'none'}") # 2. filter policies: the page's example message and its two policies ATTRS = {"customer_interests": {"DataType": "String.Array", "StringValue": json.dumps(["soccer", "rugby", "hockey"])}, "store": {"DataType": "String", "StringValue": "example_corp"}, "event": {"DataType": "String", "StringValue": "order_placed"}, "price_usd": {"DataType": "Number", "StringValue": "210.75"}} BODY = {"customer_interests": ["soccer", "rugby", "hockey"], "store": "example_corp", "event": "order_placed", "price_usd": 210.75} ACCEPT = {"store": ["example_corp"], "event": [{"anything-but": "order_cancelled"}], "customer_interests": ["rugby", "football", "baseball"], "price_usd": [{"numeric": [">=", 100]}]} REJECT = {"store": ["example_corp"], "event": ["order_cancelled"], "encrypted": [False], "customer_interests": ["basketball", "baseball"]} def delivered(policy, scope): t = sns.create_topic(Name=uniq("f"))["TopicArn"] qu, qa = mkq("fq") attrs = {"RawMessageDelivery": "true"} sub = sns.subscribe(TopicArn=t, Protocol="sqs", Endpoint=qa, ReturnSubscriptionArn=True)["SubscriptionArn"] sns.set_subscription_attributes(SubscriptionArn=sub, AttributeName="RawMessageDelivery", AttributeValue="true") if policy is not None: sns.set_subscription_attributes(SubscriptionArn=sub, AttributeName="FilterPolicyScope", AttributeValue=scope) sns.set_subscription_attributes(SubscriptionArn=sub, AttributeName="FilterPolicy", AttributeValue=json.dumps(policy)) if scope == "MessageAttributes": sns.publish(TopicArn=t, Message="order", MessageAttributes=ATTRS) else: sns.publish(TopicArn=t, Message=json.dumps(BODY)) return len(drain(qu)) for scope in ("MessageAttributes", "MessageBody"): a, r, n = delivered(ACCEPT, scope), delivered(REJECT, scope), delivered(None, scope) say(f"filter policy scope {scope}: docs' accepting policy, rejecting policy, no policy", "the first policy accepts the example message; the second rejects it ('encrypted' is absent); no policy = every message", f"accepting policy delivered {a}, rejecting policy delivered {r}, no policy delivered {n}", (a, r, n) == (1, 0, 1)) # 3. operators, one at a time on the same message (expected outcome follows from the docs' wording) OPS = [ ("exact match on a string", {"store": ["example_corp"]}, 1), ("exact match, wrong value", {"store": ["other"]}, 0), ("array attribute: any listed value matches", {"customer_interests": ["rugby"]}, 1), ("anything-but the actual value", {"event": [{"anything-but": "order_placed"}]}, 0), ("anything-but a different value", {"event": [{"anything-but": "order_cancelled"}]}, 1), ("numeric >= below the value", {"price_usd": [{"numeric": [">=", 100]}]}, 1), ("numeric > above the value", {"price_usd": [{"numeric": [">", 300]}]}, 0), ("numeric range (100, 300]", {"price_usd": [{"numeric": [">", 100, "<=", 300]}]}, 1), ("prefix", {"store": [{"prefix": "example"}]}, 1), ("exists true on a present key", {"store": [{"exists": True}]}, 1), ("exists true on an absent key", {"encrypted": [{"exists": True}]}, 0), ("exists false on an absent key", {"encrypted": [{"exists": False}]}, 1), ] print("\noperator checks (message-attribute scope): expected outcome from the docs' wording, then what the emulator did") bad = 0 for label, pol, want in OPS: got = delivered(pol, "MessageAttributes") bad += got != want print(f" {'ok ' if got == want else 'DIFF'} {label:44s} expected {want} emulator {got}") results.append(bad == 0) print(f"\n{sum(results)} of {len(results)} groups agree with the documentation ({bad} operator differences)") --- py sns_tests.py (saved output of the real run) --- ok one publish reaches every subscribed queue docs: an SNS topic fans a message out to all subscribers emulator: queue 1 got 1, queue 2 got 1 ok default delivery wraps the message in an SNS JSON envelope docs: envelope fields Type, MessageId, TopicArn, Subject, Message, Timestamp, SignatureVersion, Signature, SigningCertURL, UnsubscribeURL emulator: envelope keys: Message, MessageId, Signature, SignatureVersion, SigningCertURL, Subject, Timestamp, TopicArn, Type, UnsubscribeURL ok raw message delivery sends the message 'as is' docs: 'any Amazon SNS metadata is stripped from the published message and the message is sent as is' emulator: body: 'This is a test message.' note: envelope keys the docs example shows but the emulator omitted: none ok filter policy scope MessageAttributes: docs' accepting policy, rejecting policy, no policy docs: the first policy accepts the example message; the second rejects it ('encrypted' is absent); no policy = every message emulator: accepting policy delivered 1, rejecting policy delivered 0, no policy delivered 1 ok filter policy scope MessageBody: docs' accepting policy, rejecting policy, no policy docs: the first policy accepts the example message; the second rejects it ('encrypted' is absent); no policy = every message emulator: accepting policy delivered 1, rejecting policy delivered 0, no policy delivered 1 operator checks (message-attribute scope): expected outcome from the docs' wording, then what the emulator did ok exact match on a string expected 1 emulator 1 ok exact match, wrong value expected 0 emulator 0 ok array attribute: any listed value matches expected 1 emulator 1 ok anything-but the actual value expected 0 emulator 0 ok anything-but a different value expected 1 emulator 1 ok numeric >= below the value expected 1 emulator 1 ok numeric > above the value expected 0 emulator 0 ok numeric range (100, 300] expected 1 emulator 1 ok prefix expected 1 emulator 1 ok exists true on a present key expected 1 emulator 1 ok exists true on an absent key expected 0 emulator 0 ok exists false on an absent key expected 1 emulator 1 6 of 6 groups agree with the documentation (0 operator differences) --- ebpattern.py --- """A reference matcher for EventBridge event patterns, written from the documentation pages read on 2026-10-13 (event patterns, content filtering operators, arrays, null values). It models only what those pages state. Rules used: - a pattern has the same shape as the event; every field in the pattern must match (AND); - the value of a pattern field is an array: the field matches if any element matches (OR); - if the event's value is an array, the pattern matches if the intersection with the pattern array is non-empty; - operators work on leaf nodes: prefix, suffix, equals-ignore-case, anything-but (value, list, prefix, suffix, equals-ignore-case, wildcard), numeric (IEEE-754 double), exists, cidr, wildcard (* with \\* and \\\\ escapes), null, "" ; - "$or": [ {pattern}, {pattern} ] matches if any sub-pattern matches; - null and "" are not interchangeable. NOT modelled (not read): IPv6 details, how pattern keys that are not in the event behave for operators other than exists, size limits, the wildcard complexity limit.""" import ipaddress, json, re MISSING = object() def _wild(pattern, s): # '*' matches any run of characters; backslash escapes * and backslash rx, i = "", 0 while i < len(pattern): c = pattern[i] if c == "\\" and i + 1 < len(pattern) and pattern[i + 1] in "*\\": rx += re.escape(pattern[i + 1]); i += 2; continue rx += ".*" if c == "*" else re.escape(c); i += 1 return re.fullmatch(rx, s, re.S) is not None def _num(v): return isinstance(v, (int, float)) and not isinstance(v, bool) def _exact_double(v): return _num(v) and abs(v) <= 2 ** 53 def _op(cond, v): """cond is one element of a pattern array; v is a single (non-array) event value or MISSING.""" if not isinstance(cond, dict): return v is not MISSING and (cond == v and type(cond) == type(v) if not (_num(cond) and _num(v)) else float(cond) == float(v)) (k, arg), = cond.items() if k == "exists": return (v is not MISSING) == bool(arg) if v is MISSING: return False if k == "prefix": if isinstance(arg, dict): return isinstance(v, str) and v.lower().startswith(arg["equals-ignore-case"].lower()) return isinstance(v, str) and v.startswith(arg) if k == "suffix": if isinstance(arg, dict): return isinstance(v, str) and v.lower().endswith(arg["equals-ignore-case"].lower()) return isinstance(v, str) and v.endswith(arg) if k == "equals-ignore-case": return isinstance(v, str) and v.lower() == arg.lower() if k == "wildcard": return isinstance(v, str) and _wild(arg, v) if k == "cidr": try: return ipaddress.ip_address(v) in ipaddress.ip_network(arg, strict=False) except (ValueError, TypeError): return False if k == "numeric": if not _exact_double(v): return False ok, i = True, 0 while i < len(arg): op, n = arg[i], arg[i + 1] ok &= {"=": v == n, ">": v > n, ">=": v >= n, "<": v < n, "<=": v <= n}[op] i += 2 return ok if k == "anything-but": if isinstance(arg, dict): (kk, aa), = arg.items() aa = aa if isinstance(aa, list) else [aa] if kk == "equals-ignore-case": return isinstance(v, str) and all(v.lower() != a.lower() for a in aa) if kk == "prefix": return isinstance(v, str) and not any(v.startswith(a) for a in aa) if kk == "suffix": return isinstance(v, str) and not any(v.endswith(a) for a in aa) if kk == "wildcard": return isinstance(v, str) and not any(_wild(a, v) for a in aa) raise ValueError(kk) aa = arg if isinstance(arg, list) else [arg] return all(not (a == v and type(a) == type(v)) for a in aa) raise ValueError("unknown operator " + k) def _field(conds, v): vals = v if isinstance(v, list) else [v] if isinstance(v, list) and not v: vals = [MISSING] if any(isinstance(c, dict) and "exists" in c for c in conds) else [] return any(_op(c, x) for c in conds for x in vals) def matches(pattern, event): for key, sub in pattern.items(): if key == "$or": if not any(matches(p, event) for p in sub): return False continue v = event.get(key, MISSING) if isinstance(event, dict) else MISSING if isinstance(sub, dict): if v is MISSING or not isinstance(v, dict): return False if not matches(sub, v): return False else: if not _field(sub, v): return False return True def parse_last_wins(text): """The docs: if a key appears more than once in a pattern, the last one is used (json.loads already does this).""" return json.loads(text) --- eb_pattern_tests.py --- """EventBridge event patterns: expected outcome (from the docs' examples and wording), my reference matcher, and the moto emulator (rule + SQS target + put_events, because moto's TestEventPattern is a stub that returns an empty 200 for every call).""" import json, time from common import client, uniq from ebpattern import matches eb, sqs = client("events"), client("sqs") EC2 = {"version": "0", "id": "6a7e8feb", "detail-type": "EC2 Instance State-change Notification", "source": "aws.ec2", "account": "111122223333", "time": "2017-12-22T18:43:48Z", "region": "us-west-1", "resources": ["arn:aws:ec2:us-west-1:123456789012:instance/i-1234567890abcdef0"], "detail": {"instance-id": "i-1234567890abcdef0", "state": "terminated"}} def ev(**detail): e = json.loads(json.dumps(EC2)); e["detail"] = detail; return e # (label, event, pattern (dict or raw JSON text), expected, where the expectation comes from) CASES = [ ("docs' main example: source + detail-type + state", EC2, {"source": ["aws.ec2"], "detail-type": ["EC2 Instance State-change Notification"], "detail": {"state": ["terminated"]}}, True, "event patterns page"), ("same, state running", EC2, {"source": ["aws.ec2"], "detail": {"state": ["running"]}}, False, "event patterns page"), ("one wrong field among right ones", EC2, {"source": ["aws.s3"], "detail": {"state": ["terminated"]}}, False, "AND of all fields"), ("OR: values in one array", EC2, {"detail": {"state": ["pending", "terminated"]}}, True, "arrays page"), ("event array field, non-empty intersection", EC2, {"resources": ["arn:aws:ec2:us-west-1:123456789012:instance/i-1234567890abcdef0", "arn:other"]}, True, "arrays page"), ("event array field, empty intersection", EC2, {"resources": ["arn:other"]}, False, "arrays page"), ("prefix on time", EC2, {"time": [{"prefix": "2017-12-22"}]}, True, "operators page"), ("prefix, no match", EC2, {"time": [{"prefix": "2018"}]}, False, "operators page"), ("anything-but a different string", EC2, {"detail": {"state": [{"anything-but": "initializing"}]}}, True, "operators page"), ("anything-but the actual string", EC2, {"detail": {"state": [{"anything-but": "terminated"}]}}, False, "operators page"), ("anything-but a list containing it", EC2, {"detail": {"state": [{"anything-but": ["stopped", "terminated"]}]}}, False, "operators page"), ("anything-but a list without it", EC2, {"detail": {"state": [{"anything-but": ["stopped", "overloaded"]}]}}, True, "operators page"), ("anything-but prefix, value has the prefix", ev(state="initializing"), {"detail": {"state": [{"anything-but": {"prefix": "init"}}]}}, False, "operators page"), ("anything-but prefix, value lacks it", EC2, {"detail": {"state": [{"anything-but": {"prefix": "init"}}]}}, True, "operators page"), ("anything-but equals-ignore-case, other casing", ev(state="INITIALIZING"), {"detail": {"state": [{"anything-but": {"equals-ignore-case": "initializing"}}]}}, False, "operators page"), ("suffix .png", ev(FileName="a.png"), {"detail": {"FileName": [{"suffix": ".png"}]}}, True, "operators page"), ("suffix is case-sensitive", ev(FileName="a.PNG"), {"detail": {"FileName": [{"suffix": ".png"}]}}, False, "operators page"), ("suffix with equals-ignore-case", ev(FileName="a.PNG"), {"detail": {"FileName": [{"suffix": {"equals-ignore-case": ".png"}}]}}, True, "operators page"), ("anything-but suffix, matching file", ev(FileName="notes.txt"), {"detail": {"FileName": [{"anything-but": {"suffix": ".txt"}}]}}, False, "operators page"), ("equals-ignore-case on detail-type", EC2, {"detail-type": [{"equals-ignore-case": "ec2 instance state-change notification"}]}, True, "operators page"), ("prefix with equals-ignore-case", ev(service="EVENTBRIDGE"), {"detail": {"service": [{"prefix": {"equals-ignore-case": "eventb"}}]}}, True, "operators page"), ("exists true, field present", EC2, {"detail": {"state": [{"exists": True}]}}, True, "operators page"), ("exists true, field absent", ev(**{"c-count": {"c1": 100}}), {"detail": {"state": [{"exists": True}]}}, False, "operators page"), ("exists false, field absent", ev(x=1), {"detail": {"state": [{"exists": False}]}}, True, "operators page"), ("numeric equals", ev(Price=100), {"detail": {"Price": [{"numeric": ["=", 100]}]}}, True, "operators page"), ("numeric range (10, 20], value 20", ev(Price=20), {"detail": {"Price": [{"numeric": [">", 10, "<=", 20]}]}}, True, "operators page"), ("numeric range (10, 20], value 10", ev(Price=10), {"detail": {"Price": [{"numeric": [">", 10, "<=", 20]}]}}, False, "operators page"), ("numeric range (10, 20], value 21", ev(Price=21), {"detail": {"Price": [{"numeric": [">", 10, "<=", 20]}]}}, False, "operators page"), ("numeric 3.018e2 equals 301.8", ev(**{"x-limit": 301.8}), {"detail": {"x-limit": [{"numeric": ["=", 3.018e2]}]}}, True, "operators page"), ("numeric against a string value", ev(Price="100"), {"detail": {"Price": [{"numeric": ["=", 100]}]}}, False, "numbers vs strings"), ("$or, second branch true", ev(Location="LA", Day="Monday"), {"detail": {"$or": [{"Location": ["New York"]}, {"Day": ["Monday"]}]}}, True, "operators page"), ("$or, neither branch true", ev(Location="LA", Day="Friday"), {"detail": {"$or": [{"Location": ["New York"]}, {"Day": ["Monday"]}]}}, False, "operators page"), ("wildcard dir/*.png", ev(FileName="dir/a.png"), {"detail": {"FileName": [{"wildcard": "dir/*.png"}]}}, True, "operators page"), ("wildcard dir/*.png, other folder", ev(FileName="other/a.png"), {"detail": {"FileName": [{"wildcard": "dir/*.png"}]}}, False, "operators page"), ("anything-but wildcard */lib/*, path contains /lib/", ev(FilePath="/usr/lib/x"), {"detail": {"FilePath": [{"anything-but": {"wildcard": "*/lib/*"}}]}}, False, "operators page"), ("anything-but wildcard */lib/*, path without it", ev(FilePath="/usr/bin/x"), {"detail": {"FilePath": [{"anything-but": {"wildcard": "*/lib/*"}}]}}, True, "operators page"), ("cidr 10.0.0.0/24 inside", ev(sourceIPAddress="10.0.0.77"), {"detail": {"sourceIPAddress": [{"cidr": "10.0.0.0/24"}]}}, True, "operators page"), ("cidr 10.0.0.0/24 outside", ev(sourceIPAddress="10.0.1.5"), {"detail": {"sourceIPAddress": [{"cidr": "10.0.0.0/24"}]}}, False, "operators page"), ("empty string matches [\"\"]", ev(eventVersion="", responseElements=None), {"detail": {"eventVersion": [""]}}, True, "null values page"), ("null matches [null]", ev(eventVersion="", responseElements=None), {"detail": {"responseElements": [None]}}, True, "null values page"), ("[\"\"] does not match null", ev(eventVersion="", responseElements=None), {"detail": {"responseElements": [""]}}, False, "null values page: not interchangeable"), ("[null] does not match an empty string", ev(eventVersion="", responseElements=None), {"detail": {"eventVersion": [None]}}, False, "null values page: not interchangeable"), ("repeated key: the last one is used (raw JSON)", ev(location="eu-west"), '{"detail":{"location":[{"prefix":"us-"}],"location":[{"anything-but":"us-east"}]}}', True, "operators page note"), ] results = {"model": 0, "moto": 0, "rejected": []} print(f"{'case':58s} {'docs':5s} {'model':6s} {'moto':6s}") for label, event, pat, want, src in CASES: pat_text = pat if isinstance(pat, str) else json.dumps(pat) m = matches(json.loads(pat_text), event) bus = eb.create_event_bus(Name=uniq("b"))["EventBusArn"].split("/")[-1] qurl = sqs.create_queue(QueueName=uniq("e"))["QueueUrl"] qarn = sqs.get_queue_attributes(QueueUrl=qurl, AttributeNames=["QueueArn"])["Attributes"]["QueueArn"] try: eb.put_rule(Name="r", EventBusName=bus, EventPattern=pat_text) eb.put_targets(Rule="r", EventBusName=bus, Targets=[{"Id": "t", "Arn": qarn}]) eb.put_events(Entries=[{"EventBusName": bus, "Source": event["source"], "DetailType": event["detail-type"], "Detail": json.dumps(event["detail"]), "Resources": event.get("resources", []), "Time": "2017-12-22T18:43:48Z"}]) got = len(sqs.receive_message(QueueUrl=qurl, WaitTimeSeconds=0).get("Messages", [])) == 1 mm = str(got) except Exception as e: mm, got = "rejected", None results["rejected"].append(label) results["model"] += m == want results["moto"] += got == want flag = "" if (m == want and got == want) else " <--" print(f"{label[:58]:58s} {str(want):5s} {str(m):6s} {mm:6s}{flag}") n = len(CASES) print(f"\n{n} cases: reference matcher agrees with the docs on {results['model']}, moto agrees on {results['moto']}; moto rejected the pattern for: {results['rejected'] or 'none'}") --- py eb_pattern_tests.py (saved output of the real run) --- case docs model moto docs' main example: source + detail-type + state True True True same, state running False False False one wrong field among right ones False False False OR: values in one array True True True event array field, non-empty intersection True True True event array field, empty intersection False False False prefix on time True True False <-- prefix, no match False False False anything-but a different string True True True anything-but the actual string False False False anything-but a list containing it False False False anything-but a list without it True True True anything-but prefix, value has the prefix False False False anything-but prefix, value lacks it True True True anything-but equals-ignore-case, other casing False False False suffix .png True True True suffix is case-sensitive False False False suffix with equals-ignore-case True True True anything-but suffix, matching file False False False equals-ignore-case on detail-type True True True prefix with equals-ignore-case True True True exists true, field present True True True exists true, field absent False False False exists false, field absent True True True numeric equals True True True numeric range (10, 20], value 20 True True True numeric range (10, 20], value 10 False False False numeric range (10, 20], value 21 False False False numeric 3.018e2 equals 301.8 True True True numeric against a string value False False False $or, second branch true True True True $or, neither branch true False False False wildcard dir/*.png True True True wildcard dir/*.png, other folder False False False anything-but wildcard */lib/*, path contains /lib/ False False False anything-but wildcard */lib/*, path without it True True True cidr 10.0.0.0/24 inside True True True cidr 10.0.0.0/24 outside False False False empty string matches [""] True True True null matches [null] True True True [""] does not match null False False False [null] does not match an empty string False False False repeated key: the last one is used (raw JSON) True True True 43 cases: reference matcher agrees with the docs on 43, moto agrees on 42; moto rejected the pattern for: none --- probe of time-field matching (inline script) (saved output of the real run) --- prefix on source ('app.orders', prefix 'app.') delivered=1 prefix on detail-type delivered=1 prefix on time ('2017-12-22T18:43:48Z') delivered=0 exact value on time delivered=0 exists on time delivered=1 prefix on a detail string (same text) delivered=1 -------------------------- Reading the result: the reference matcher agreed with the docs on 43 of 43 cases; moto agreed on 42. moto's TestEventPattern is a stub (an empty 200 for any pattern), so the test used a rule, an SQS target and put_events. moto cannot match the top-level 'time' field by exact value or prefix (the docs' own example), though exists works and the same text matched inside 'detail'. moto delivered SNS messages to a queue with no queue policy; the real service needs the policy (my understanding: the fan-out page did not load, so that is not from a page I read). Mistake made and fixed: I first assumed moto's TestEventPattern would work as the comparison; it returned the same empty reply for a matching and a non-matching pattern. How to avoid it: call the API once with a pattern that must match and one that must not before building a test on it.