lambda-preflight 0.1.0__tar.gz → 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. lambda_preflight-0.1.1/.github/workflows/ci.yml +27 -0
  2. lambda_preflight-0.1.1/.gitignore +11 -0
  3. lambda_preflight-0.1.1/CHANGELOG.md +34 -0
  4. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/PKG-INFO +3 -3
  5. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/pyproject.toml +4 -3
  6. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/__init__.py +1 -1
  7. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/harness.py +11 -3
  8. lambda_preflight-0.1.1/tests/conftest.py +6 -0
  9. lambda_preflight-0.1.1/tests/handlers.py +93 -0
  10. lambda_preflight-0.1.1/tests/test_adversarial.py +137 -0
  11. lambda_preflight-0.1.1/tests/test_events_exhaustive.py +165 -0
  12. lambda_preflight-0.1.1/tests/test_iam_exhaustive.py +232 -0
  13. lambda_preflight-0.1.1/tests/test_intercept_exhaustive.py +99 -0
  14. lambda_preflight-0.1.1/tests/test_puller_exhaustive.py +63 -0
  15. lambda_preflight-0.1.1/tests/test_runtime_exhaustive.py +187 -0
  16. lambda_preflight-0.1.0/tests/sample_handlers.py +0 -34
  17. lambda_preflight-0.1.0/tests/test_events.py +0 -85
  18. lambda_preflight-0.1.0/tests/test_iam.py +0 -138
  19. lambda_preflight-0.1.0/tests/test_runtime.py +0 -68
  20. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/LICENSE +0 -0
  21. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/README.md +0 -0
  22. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/events/__init__.py +0 -0
  23. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/events/builders.py +0 -0
  24. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/__init__.py +0 -0
  25. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/conditions.py +0 -0
  26. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/evaluator.py +0 -0
  27. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/intercept.py +0 -0
  28. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/model.py +0 -0
  29. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/iam/puller.py +0 -0
  30. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/__init__.py +0 -0
  31. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/context.py +0 -0
  32. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/errors.py +0 -0
  33. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/runtime/loader.py +0 -0
  34. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/testing/__init__.py +0 -0
  35. {lambda_preflight-0.1.0 → lambda_preflight-0.1.1}/src/lambda_preflight/testing/fixtures.py +0 -0
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Set up Python ${{ matrix.python-version }}
19
+ uses: actions/setup-python@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Install
23
+ run: |
24
+ python -m pip install --upgrade pip
25
+ pip install -e ".[dev]"
26
+ - name: Run tests
27
+ run: pytest tests/ -v
@@ -0,0 +1,11 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ .venv/
10
+ venv/
11
+ .DS_Store
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/).
5
+
6
+ ## [0.1.1] - 2026-07-21
7
+
8
+ ### Fixed
9
+ - Handler-load failures (bad handler string, missing module, missing function)
10
+ are now returned as an error `InvocationResult` with `ok=False` and
11
+ `errorType="HandlerLoadError"`, instead of raising out of `invoke()`. This
12
+ gives callers a single consistent `result.ok` contract for both load-time and
13
+ runtime failures, mirroring how AWS surfaces initialization errors. A handler
14
+ that fails to load no longer marks the harness as "warm".
15
+
16
+ ### Added
17
+ - Comprehensive test suite (128 tests) covering the runtime shim, event
18
+ builders, IAM evaluator, boto3 intercept, policy puller/cache, and a set of
19
+ adversarial cases that document known fidelity limits (`NotAction`
20
+ under-grant, ARN wildcard matching, resource-based-policy omission).
21
+
22
+ ## [0.1.0] - 2026-07-21
23
+
24
+ ### Added
25
+ - Initial release.
26
+ - In-process Lambda runtime shim: faithful `context`, timeout enforcement,
27
+ cold/warm semantics, AWS-shape error envelopes, environment injection.
28
+ - Schema-accurate event builders: API Gateway v1 & v2, SQS, SNS, S3,
29
+ EventBridge, DynamoDB Streams.
30
+ - Local IAM linter: policy evaluator (explicit-deny / allow / implicit-deny,
31
+ permission-boundary intersection), common condition operators, `pull_role`
32
+ fan-out with disk cache, and a boto3 intercept hook. Strict mode raises
33
+ `CannotEvaluate` on constructs it cannot faithfully assess.
34
+ - pytest fixtures, auto-registered via entry point.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-preflight
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Catch AWS Lambda IAM permission errors and event-shape bugs before you deploy — a local pre-flight linter and AWS-faithful runtime. No Docker, no SAM, no invocation charges.
5
- Project-URL: Homepage, https://github.com/swaranshu-borgaonkar/lambda-preflight
6
- Project-URL: Issues, https://github.com/swaranshu-borgaonkar/lambda-preflight/issues
5
+ Project-URL: Homepage, https://github.com/yourname/lambda-preflight
6
+ Project-URL: Issues, https://github.com/yourname/lambda-preflight/issues
7
7
  Author: Swar Borgaonkar
8
8
  License: MIT
9
9
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lambda-preflight"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Catch AWS Lambda IAM permission errors and event-shape bugs before you deploy — a local pre-flight linter and AWS-faithful runtime. No Docker, no SAM, no invocation charges."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -42,8 +42,8 @@ aws = ["boto3>=1.26"]
42
42
  dev = ["pytest>=7.0", "boto3>=1.26", "build", "twine"]
43
43
 
44
44
  [project.urls]
45
- Homepage = "https://github.com/swaranshu-borgaonkar/lambda-preflight"
46
- Issues = "https://github.com/swaranshu-borgaonkar/lambda-preflight/issues"
45
+ Homepage = "https://github.com/yourname/lambda-preflight"
46
+ Issues = "https://github.com/yourname/lambda-preflight/issues"
47
47
 
48
48
  [project.entry-points.pytest11]
49
49
  lambda_preflight = "lambda_preflight.testing.fixtures"
@@ -53,3 +53,4 @@ packages = ["src/lambda_preflight"]
53
53
 
54
54
  [tool.pytest.ini_options]
55
55
  testpaths = ["tests"]
56
+ pythonpath = ["tests"]
@@ -37,7 +37,7 @@ from .runtime import (
37
37
  LambdaTimeout,
38
38
  )
39
39
 
40
- __version__ = "0.1.0"
40
+ __version__ = "0.1.1"
41
41
 
42
42
  __all__ = [
43
43
  "LambdaHarness",
@@ -120,10 +120,14 @@ class LambdaHarness:
120
120
  def invoke(
121
121
  self, event: Any, client_context: Any = None
122
122
  ) -> InvocationResult:
123
- """Invoke the handler with ``event`` and an AWS-faithful context."""
123
+ """Invoke the handler with ``event`` and an AWS-faithful context.
124
+
125
+ Handler-resolution failures (bad handler string, missing module or
126
+ function) are returned as an error ``InvocationResult`` — the same
127
+ channel as runtime errors — mirroring how AWS surfaces init failures,
128
+ so callers can rely on a single ``result.ok`` contract.
129
+ """
124
130
  is_cold = not self._has_run
125
- handler = self._ensure_handler()
126
- self._has_run = True
127
131
 
128
132
  context = LambdaContext(
129
133
  function_name=self.function_name,
@@ -149,6 +153,10 @@ class LambdaHarness:
149
153
 
150
154
  with lambda_environment(lambda_env):
151
155
  try:
156
+ # Resolve inside the try so handler-load failures are returned
157
+ # as an error result, not raised — one consistent contract.
158
+ handler = self._ensure_handler()
159
+ self._has_run = True
152
160
  payload = _run_with_timeout(
153
161
  handler, (event, context), self.timeout
154
162
  )
@@ -0,0 +1,6 @@
1
+ import os
2
+ import sys
3
+
4
+ # Make handler modules (e.g. handlers.py) importable by their bare name,
5
+ # matching how AWS resolves "module.function" handler strings.
6
+ sys.path.insert(0, os.path.dirname(__file__))
@@ -0,0 +1,93 @@
1
+ import os, time, threading
2
+
3
+ INIT_COUNTER = {"count": 0}
4
+ INIT_COUNTER["count"] += 1 # increments once per import (cold start)
5
+
6
+ _state = {"invocations": 0, "module_global": []}
7
+
8
+ def echo(event, context):
9
+ return {"event": event, "rid": context.aws_request_id}
10
+
11
+ def return_none(event, context):
12
+ return None
13
+
14
+ def return_scalar(event, context):
15
+ return 42
16
+
17
+ def return_list(event, context):
18
+ return [1, 2, {"nested": True}]
19
+
20
+ def raise_value(event, context):
21
+ raise ValueError("bad value")
22
+
23
+ def raise_custom(event, context):
24
+ class MyCustomError(Exception):
25
+ pass
26
+ raise MyCustomError("custom failure")
27
+
28
+ def raise_keyerror(event, context):
29
+ d = {}
30
+ return d["missing"]
31
+
32
+ def read_all_env(event, context):
33
+ return {k: os.environ.get(k) for k in [
34
+ "AWS_LAMBDA_FUNCTION_NAME", "AWS_LAMBDA_FUNCTION_VERSION",
35
+ "AWS_LAMBDA_FUNCTION_MEMORY_SIZE", "AWS_REGION", "AWS_DEFAULT_REGION",
36
+ "AWS_EXECUTION_ENV", "_HANDLER", "AWS_LAMBDA_LOG_GROUP_NAME",
37
+ "AWS_LAMBDA_LOG_STREAM_NAME", "TZ",
38
+ ]}
39
+
40
+ def custom_env_reader(event, context):
41
+ return os.environ.get("MY_CUSTOM_VAR", "NOT_SET")
42
+
43
+ def env_leak_check(event, context):
44
+ # returns whether a sentinel that shouldn't exist outside is present
45
+ return os.environ.get("AWS_LAMBDA_FUNCTION_NAME")
46
+
47
+ def sleep_long(event, context):
48
+ time.sleep(5)
49
+ return "finished"
50
+
51
+ def sleep_short(event, context):
52
+ time.sleep(0.05)
53
+ return "quick"
54
+
55
+ def remaining_time(event, context):
56
+ return {"remaining": context.get_remaining_time_in_millis()}
57
+
58
+ def remaining_time_after_sleep(event, context):
59
+ start = context.get_remaining_time_in_millis()
60
+ time.sleep(0.1)
61
+ end = context.get_remaining_time_in_millis()
62
+ return {"start": start, "end": end}
63
+
64
+ def module_global_accumulate(event, context):
65
+ _state["module_global"].append(event.get("n"))
66
+ return list(_state["module_global"])
67
+
68
+ def invocation_counter(event, context):
69
+ _state["invocations"] += 1
70
+ return _state["invocations"]
71
+
72
+ def uses_context_fields(event, context):
73
+ return {
74
+ "function_name": context.function_name,
75
+ "function_version": context.function_version,
76
+ "memory": context.memory_limit_in_mb,
77
+ "arn": context.invoked_function_arn,
78
+ "log_group": context.log_group_name,
79
+ "log_stream": context.log_stream_name,
80
+ }
81
+
82
+ def thread_check(event, context):
83
+ # Confirms handler runs and can spawn its own threads
84
+ results = []
85
+ def work():
86
+ results.append("worked")
87
+ t = threading.Thread(target=work)
88
+ t.start(); t.join()
89
+ return results
90
+
91
+ def mutate_event(event, context):
92
+ event["injected"] = True
93
+ return event
@@ -0,0 +1,137 @@
1
+ """Adversarial edge cases — probing the documented fidelity limits.
2
+
3
+ Several of these assert the CURRENT (imperfect) behavior deliberately, to make
4
+ the known gaps visible and regression-proof rather than silently wrong.
5
+ """
6
+ import pytest
7
+ from lambda_preflight import PolicySet, PolicyEvaluator
8
+ from lambda_preflight.iam import operation_to_action
9
+
10
+
11
+ def P(*stmts):
12
+ return PolicySet.from_policy_documents(documents=[{"Statement": list(stmts)}])
13
+
14
+ def allow(a, r="*", **k):
15
+ d = {"Effect": "Allow", "Action": a, "Resource": r}; d.update(k); return d
16
+
17
+
18
+ # ===== ARN matching fidelity (KNOWN GAP: fnmatch treats ':' as ordinary) =====
19
+
20
+ def test_arn_wildcard_crosses_colon_boundary():
21
+ """A '*' in a policy ARN can greedily cross ':' segments — NOT how IAM
22
+ matches. This documents the divergence: fnmatch '*' matches across colons."""
23
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::bucket*")))
24
+ # Intended: bucket-prefixed. fnmatch '*' also matches deeper — verify current:
25
+ r = ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-a/deep/key")
26
+ # Current behavior: matches (star is greedy). Real IAM: also matches here
27
+ # because '*' spans '/'. This one happens to agree.
28
+ assert r.allowed is True
29
+
30
+ def test_arn_question_mark_wildcard():
31
+ """IAM supports '?' single-char wildcard; fnmatch does too."""
32
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::bucket-?")))
33
+ assert ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-1").allowed
34
+ assert not ev.evaluate("s3:GetObject", "arn:aws:s3:::bucket-12").allowed
35
+
36
+ def test_arn_case_sensitivity_of_resource():
37
+ """Resource ARNs are case-sensitive in IAM; verify matcher respects case."""
38
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "arn:aws:s3:::MyBucket/*")))
39
+ assert ev.evaluate("s3:GetObject", "arn:aws:s3:::MyBucket/k").allowed
40
+ # different case bucket should NOT match
41
+ assert not ev.evaluate("s3:GetObject", "arn:aws:s3:::mybucket/k").allowed
42
+
43
+
44
+ # ===== NotAction / NotResource (KNOWN GAP: not implemented) =====
45
+
46
+ def test_notaction_not_supported_documents_behavior():
47
+ """Policies using NotAction aren't modeled. A statement with NotAction has
48
+ no 'Action' key -> our parser gives empty actions -> matches nothing.
49
+ This means NotAction-based Allows are effectively ignored (under-grant)."""
50
+ ps = PolicySet.from_policy_documents(documents=[{"Statement": [
51
+ {"Effect": "Allow", "NotAction": "s3:DeleteObject", "Resource": "*"}]}])
52
+ ev = PolicyEvaluator(ps)
53
+ # Real IAM: this ALLOWS everything except delete. Our tool: allows nothing.
54
+ # Document the divergence explicitly:
55
+ assert ev.evaluate("s3:GetObject").allowed is False # KNOWN under-grant
56
+
57
+
58
+ # ===== Principal / cross-account (out of scope) =====
59
+
60
+ def test_resource_policy_absent_documented():
61
+ """Resource-based policies aren't modeled; unscoped records this."""
62
+ ps = P(allow("s3:GetObject"))
63
+ assert "resource_policy" in ps.unscoped
64
+
65
+
66
+ # ===== Condition operator edge cases =====
67
+
68
+ def test_condition_ifexists_variant():
69
+ """StringEqualsIfExists passes when the key is absent."""
70
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
71
+ Condition={"StringEqualsIfExists": {"aws:username": "admin"}})))
72
+ # key absent -> IfExists means condition passes
73
+ d = ev.evaluate("s3:GetObject")
74
+ assert d.allowed is True
75
+
76
+ def test_condition_multiple_keys_all_must_pass():
77
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
78
+ Condition={"StringEquals": {"aws:RequestedRegion": "us-east-1",
79
+ "s3:prefix": "home/"}})))
80
+ # both match
81
+ assert ev.evaluate("s3:GetObject", context={
82
+ "aws:RequestedRegion": "us-east-1", "s3:prefix": "home/"}).allowed
83
+ # one fails -> deny
84
+ assert not ev.evaluate("s3:GetObject", context={
85
+ "aws:RequestedRegion": "us-east-1", "s3:prefix": "other/"}).allowed
86
+
87
+ def test_condition_multiple_values_or_semantics():
88
+ """A list of condition values is OR — any match passes (StringEquals)."""
89
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "*",
90
+ Condition={"StringEquals": {"aws:RequestedRegion": ["us-east-1", "eu-west-1"]}})))
91
+ assert ev.evaluate("s3:GetObject", context={"aws:RequestedRegion": "eu-west-1"}).allowed
92
+ assert not ev.evaluate("s3:GetObject", context={"aws:RequestedRegion": "ap-south-1"}).allowed
93
+
94
+
95
+ # ===== Unknown service mapping =====
96
+
97
+ def test_unknown_service_passthrough_mapping():
98
+ """A service not in the override map falls back to service:Operation."""
99
+ assert operation_to_action("kinesis", "PutRecord") == "kinesis:PutRecord"
100
+ assert operation_to_action("some-new-svc", "DoThing") == "some-new-svc:DoThing"
101
+
102
+
103
+ # ===== Empty / malformed inputs =====
104
+
105
+ def test_empty_action_list():
106
+ ps = PolicySet.from_policy_documents(documents=[{"Statement": [
107
+ {"Effect": "Allow", "Action": [], "Resource": "*"}]}])
108
+ ev = PolicyEvaluator(ps)
109
+ assert not ev.evaluate("s3:GetObject").allowed
110
+
111
+ def test_statement_without_resource_defaults_star():
112
+ ps = PolicySet.from_policy_documents(documents=[{"Statement": [
113
+ {"Effect": "Allow", "Action": "s3:GetObject"}]}]) # no Resource
114
+ ev = PolicyEvaluator(ps)
115
+ assert ev.evaluate("s3:GetObject", "arn:aws:s3:::anything").allowed
116
+
117
+ def test_missing_effect_defaults_deny():
118
+ ps = PolicySet.from_policy_documents(documents=[{"Statement": [
119
+ {"Action": "s3:GetObject", "Resource": "*"}]}]) # no Effect
120
+ ev = PolicyEvaluator(ps)
121
+ # defaults to Deny -> not an allow
122
+ assert not ev.evaluate("s3:GetObject").allowed
123
+
124
+
125
+ # ===== Sid tracking =====
126
+
127
+ def test_matched_sid_reported():
128
+ ev = PolicyEvaluator(P(allow("s3:GetObject", "*", Sid="AllowRead")))
129
+ d = ev.evaluate("s3:GetObject")
130
+ assert d.allowed and d.matched_sid == "AllowRead"
131
+
132
+ def test_deny_sid_reported():
133
+ ev = PolicyEvaluator(P(allow("s3:*"),
134
+ {"Effect": "Deny", "Action": "s3:DeleteObject", "Resource": "*",
135
+ "Sid": "NoDelete"}))
136
+ d = ev.evaluate("s3:DeleteObject")
137
+ assert not d.allowed and d.matched_sid == "NoDelete"
@@ -0,0 +1,165 @@
1
+ import json
2
+ import pytest
3
+ from lambda_preflight import events
4
+
5
+
6
+ # ---------- API Gateway v1 ----------
7
+ def test_v1_method_uppercased():
8
+ assert events.api_gateway_v1(method="post")["httpMethod"] == "POST"
9
+
10
+ def test_v1_body_serialized_to_string():
11
+ e = events.api_gateway_v1(body={"k": "v"})
12
+ assert isinstance(e["body"], str) and json.loads(e["body"]) == {"k": "v"}
13
+
14
+ def test_v1_string_body_passthrough():
15
+ e = events.api_gateway_v1(body="raw string")
16
+ assert e["body"] == "raw string"
17
+
18
+ def test_v1_none_body():
19
+ assert events.api_gateway_v1()["body"] is None
20
+
21
+ def test_v1_has_multivalue_headers():
22
+ e = events.api_gateway_v1(headers={"X-Test": "1"})
23
+ assert e["multiValueHeaders"]["X-Test"] == ["1"]
24
+
25
+ def test_v1_query_multivalue():
26
+ e = events.api_gateway_v1(query={"q": "search"})
27
+ assert e["multiValueQueryStringParameters"]["q"] == ["search"]
28
+
29
+ def test_v1_requestcontext_shape():
30
+ e = events.api_gateway_v1(method="DELETE", path="/x", stage="dev")
31
+ rc = e["requestContext"]
32
+ assert rc["httpMethod"] == "DELETE"
33
+ assert rc["stage"] == "dev"
34
+ assert rc["path"] == "/dev/x"
35
+ assert "identity" in rc and rc["identity"]["sourceIp"]
36
+
37
+ def test_v1_path_parameters():
38
+ e = events.api_gateway_v1(path_parameters={"id": "123"})
39
+ assert e["pathParameters"] == {"id": "123"}
40
+
41
+
42
+ # ---------- API Gateway v2 ----------
43
+ def test_v2_version_marker():
44
+ assert events.api_gateway_v2()["version"] == "2.0"
45
+
46
+ def test_v2_route_key():
47
+ assert events.api_gateway_v2(method="post", path="/o")["routeKey"] == "POST /o"
48
+
49
+ def test_v2_raw_path_and_query():
50
+ e = events.api_gateway_v2(path="/search", query={"q": "x", "n": "5"})
51
+ assert e["rawPath"] == "/search"
52
+ assert "q=x" in e["rawQueryString"] and "n=5" in e["rawQueryString"]
53
+
54
+ def test_v2_http_block():
55
+ e = events.api_gateway_v2(method="patch", path="/p")
56
+ assert e["requestContext"]["http"]["method"] == "PATCH"
57
+ assert e["requestContext"]["http"]["path"] == "/p"
58
+
59
+ def test_v2_has_cookies_field():
60
+ assert "cookies" in events.api_gateway_v2()
61
+
62
+ def test_v2_body_serialized():
63
+ e = events.api_gateway_v2(body={"id": 9})
64
+ assert json.loads(e["body"]) == {"id": 9}
65
+
66
+
67
+ # ---------- v1 vs v2 divergence ----------
68
+ def test_v1_v2_structural_difference():
69
+ v1, v2 = events.api_gateway_v1(), events.api_gateway_v2()
70
+ assert "httpMethod" in v1 and "httpMethod" not in v2
71
+ assert "version" in v2 and "version" not in v1
72
+ assert "rawPath" in v2 and "rawPath" not in v1
73
+
74
+
75
+ # ---------- SQS ----------
76
+ def test_sqs_single():
77
+ e = events.sqs(body={"o": 1})
78
+ assert len(e["Records"]) == 1
79
+ assert json.loads(e["Records"][0]["body"]) == {"o": 1}
80
+
81
+ def test_sqs_batch_count():
82
+ e = events.sqs(records=[{"a": 1}, {"b": 2}, {"c": 3}])
83
+ assert len(e["Records"]) == 3
84
+
85
+ def test_sqs_record_fields():
86
+ rec = events.sqs(body="x")["Records"][0]
87
+ for f in ["messageId", "receiptHandle", "body", "attributes",
88
+ "messageAttributes", "md5OfBody", "eventSource", "eventSourceARN"]:
89
+ assert f in rec
90
+ assert rec["eventSource"] == "aws:sqs"
91
+
92
+ def test_sqs_arn_format():
93
+ rec = events.sqs(body="x", queue_name="myq", region="eu-central-1")["Records"][0]
94
+ assert rec["eventSourceARN"] == "arn:aws:sqs:eu-central-1:000000000000:myq"
95
+
96
+ def test_sqs_unique_message_ids():
97
+ e = events.sqs(records=[{}, {}, {}])
98
+ ids = {r["messageId"] for r in e["Records"]}
99
+ assert len(ids) == 3
100
+
101
+ def test_sqs_message_attributes_passthrough():
102
+ attrs = {"attr1": {"DataType": "String", "StringValue": "v"}}
103
+ rec = events.sqs(body="x", message_attributes=attrs)["Records"][0]
104
+ assert rec["messageAttributes"] == attrs
105
+
106
+
107
+ # ---------- SNS ----------
108
+ def test_sns_shape():
109
+ rec = events.sns(message={"m": 1}, subject="subj")["Records"][0]
110
+ assert rec["EventSource"] == "aws:sns"
111
+ assert rec["Sns"]["Subject"] == "subj"
112
+ assert json.loads(rec["Sns"]["Message"]) == {"m": 1}
113
+
114
+ def test_sns_string_message():
115
+ rec = events.sns(message="plain")["Records"][0]
116
+ assert rec["Sns"]["Message"] == "plain"
117
+
118
+
119
+ # ---------- S3 ----------
120
+ def test_s3_shape():
121
+ rec = events.s3(bucket="b", key="path/to/k.txt", size=2048)["Records"][0]
122
+ assert rec["eventSource"] == "aws:s3"
123
+ assert rec["s3"]["bucket"]["name"] == "b"
124
+ assert rec["s3"]["bucket"]["arn"] == "arn:aws:s3:::b"
125
+ assert rec["s3"]["object"]["key"] == "path/to/k.txt"
126
+ assert rec["s3"]["object"]["size"] == 2048
127
+
128
+ def test_s3_event_name():
129
+ rec = events.s3(event_name="ObjectRemoved:Delete")["Records"][0]
130
+ assert rec["eventName"] == "ObjectRemoved:Delete"
131
+
132
+
133
+ # ---------- EventBridge ----------
134
+ def test_eventbridge_shape():
135
+ e = events.eventbridge(detail={"k": "v"}, detail_type="t", source="s")
136
+ assert e["detail-type"] == "t"
137
+ assert e["source"] == "s"
138
+ assert e["detail"] == {"k": "v"}
139
+ assert e["version"] == "0"
140
+
141
+ def test_eventbridge_empty_detail():
142
+ assert events.eventbridge()["detail"] == {}
143
+
144
+
145
+ # ---------- DynamoDB ----------
146
+ def test_dynamodb_insert():
147
+ rec = events.dynamodb_stream(keys={"id": {"S": "1"}},
148
+ new_image={"id": {"S": "1"}, "v": {"N": "5"}},
149
+ event_name="INSERT")["Records"][0]
150
+ assert rec["eventSource"] == "aws:dynamodb"
151
+ assert rec["eventName"] == "INSERT"
152
+ assert rec["dynamodb"]["Keys"] == {"id": {"S": "1"}}
153
+ assert rec["dynamodb"]["NewImage"]["v"] == {"N": "5"}
154
+
155
+ def test_dynamodb_no_new_image_when_absent():
156
+ rec = events.dynamodb_stream(keys={"id": {"S": "1"}}, event_name="REMOVE")["Records"][0]
157
+ assert "NewImage" not in rec["dynamodb"]
158
+
159
+ def test_dynamodb_modify_with_old_image():
160
+ rec = events.dynamodb_stream(keys={"id": {"S": "1"}},
161
+ new_image={"v": {"N": "2"}},
162
+ old_image={"v": {"N": "1"}},
163
+ event_name="MODIFY")["Records"][0]
164
+ assert rec["dynamodb"]["OldImage"] == {"v": {"N": "1"}}
165
+ assert rec["dynamodb"]["NewImage"] == {"v": {"N": "2"}}