@liflig/cdk 3.22.18 → 3.23.0

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.
@@ -0,0 +1,32 @@
1
+ **Lambda assets testing convention**
2
+
3
+ Each lambda under `assets/` can include a small test setup and development dependencies. To make tests reproducible and isolated we follow this convention:
4
+
5
+ Each lambda that has tests exposes a `pyproject.toml` declaring its test/dev dependencies.
6
+ - Tests are inside the lambda folder (e.g. `assets/slack-error-log-handler-lambda/test_handler.py`).
7
+ - To run all lambda tests locally or in CI, run the project Makefile target:
8
+
9
+ ```bash
10
+ make py-test
11
+ ```
12
+
13
+ This target will auto-discover all `assets/*` directories that contain tests (via a `pyproject.toml`), and run `pytest` in each folder using the interpreter(s) provided on your machine or CI runner.
14
+
15
+ ***Interpreter selection***
16
+
17
+ - The repository declares supported Python interpreters in the top-level `mise.toml` (used by CI).
18
+ - Each asset should include a `.python-version` containing the desired interpreter for tests (for example `3.14`).
19
+ - If present, the test runner will prefer that interpreter; if the interpreter isn't available on the runner the CI job will fail (this helps surface missing interpreter versions early).
20
+
21
+ Per-lambda `pyproject.toml` keeps each lambda's test/dev dependencies isolated and avoids clashes across assets. Tests run using the interpreter installed on the runner (see `mise.toml` and CI configuration).
22
+ - boto3 should match the version used in the lambda runtime - https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html#python-sdk-included
23
+ - If a lambda requires system packages or compiled native extensions, CI needs to provide those system dependencies (e.g., apt packages) or you should run tests inside a suitable container.
24
+
25
+ ***Adding a new lambda with tests***
26
+
27
+ 1. Add your lambda folder under `assets/`.
28
+ 2. Add `pyproject.toml` declaring your test/dev dependencies.
29
+ 3. Add test files inside the folder and ensure they import the lambda code relative to that folder.
30
+ 4. Run `make py-test` locally to verify.
31
+
32
+ This convention keeps test runs predictable and easy to run both locally and in CI.
@@ -0,0 +1,411 @@
1
+ """
2
+ Slack error log handler Lambda
3
+ =============================
4
+ Lambda used as a CloudWatch Logs subscription destination. It
5
+ decodes base64+gzip subscription payloads, extracts structured JSON log
6
+ messages and posts a compact Slack message using a webhook URL
7
+ stored in Secrets Manager.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from pprint import pprint
13
+ from typing import TypedDict, Optional
14
+ from urllib.request import Request, urlopen
15
+ import base64
16
+ import gzip
17
+ from urllib.error import URLError, HTTPError
18
+ import time
19
+ import boto3
20
+
21
+ # Module-level cached Secrets Manager client. Stored as a global variable and
22
+ # lazily created by _get_secrets_client when a client is not injected.
23
+ _secrets_client = None
24
+
25
+
26
+ # Lazily-created cached Secrets Manager client. Don't create a boto3 client at
27
+ # import time (it may raise NoRegionError during test import or create network
28
+ # noise). Use _get_secrets_client() to obtain a client (injectable for tests).
29
+ def _get_secrets_client(secrets_client=None):
30
+ """Return a Secrets Manager client.
31
+
32
+ Caches the client lazily, so it isn’t created during import,
33
+ but is reused across invocations within the same Lambda execution environment.
34
+
35
+ Behavior:
36
+ - If a `secrets_client` is provided, return it directly and do not mutate
37
+ any cache. This is the injection path used by tests.
38
+ - Otherwise, lazily create and cache a boto3 client and return it.
39
+ """
40
+ if secrets_client is not None:
41
+ return secrets_client
42
+
43
+ global _secrets_client
44
+ if _secrets_client is None:
45
+ _secrets_client = boto3.client("secretsmanager")
46
+
47
+ return _secrets_client
48
+
49
+
50
+ SLACK_URL_SECRET_NAME = os.getenv("SLACK_URL_SECRET_NAME", None)
51
+ PROJECT_NAME = os.getenv("PROJECT_NAME", "undefined")
52
+ ENVIRONMENT_NAME = os.getenv("ENVIRONMENT_NAME", "undefined")
53
+ REGION = os.getenv("AWS_REGION", "eu-west-1")
54
+
55
+
56
+ class CloudWatchLog(TypedDict, total=False):
57
+ """Single parsed log entry (message, stack_trace, service)."""
58
+
59
+ message: str
60
+ stack_trace: Optional[str]
61
+ service: Optional[str]
62
+
63
+
64
+ class CloudWatchEvent(TypedDict):
65
+ """Decoded CloudWatch Logs subscription payload."""
66
+
67
+ logEvents: list[CloudWatchLog]
68
+ logGroup: str
69
+ logStream: str
70
+
71
+
72
+ def handler(event, _context):
73
+ """Entrypoint that delegates to `process_event`."""
74
+ return process_event(event, _context)
75
+
76
+
77
+ def process_event(
78
+ event,
79
+ _context,
80
+ *,
81
+ secrets_client=None,
82
+ urlopen_func=None,
83
+ time_func=time.time,
84
+ slack_secret_name=SLACK_URL_SECRET_NAME,
85
+ project_name=PROJECT_NAME,
86
+ environment_name=ENVIRONMENT_NAME,
87
+ region=REGION,
88
+ ):
89
+ """Decode a CloudWatch Logs event, build a Slack payload and post it.
90
+
91
+ Network and secrets access are injectable for testing.
92
+ """
93
+ pprint("Dump: " + json.dumps(event))
94
+ print(f"boto3 version: {boto3.__version__}")
95
+
96
+ data = event["awslogs"]["data"]
97
+ decoded_message: CloudWatchEvent = json.loads(
98
+ gzip.decompress(base64.b64decode(data))
99
+ )
100
+
101
+ pprint("Data: " + json.dumps(decoded_message))
102
+
103
+ timestamp_in_seconds = _resolve_timestamp(
104
+ decoded_message.get("logEvents", []), time_func
105
+ )
106
+
107
+ log_group = decoded_message.get("logGroup", "undefined")
108
+
109
+ # Use the provided region (default is module-level REGION)
110
+ resolved_region = region
111
+
112
+ try:
113
+ if len(decoded_message["logEvents"]) > 0:
114
+ log_events: list[CloudWatchLog] = [
115
+ json.loads(log_event["message"])
116
+ for log_event in decoded_message["logEvents"]
117
+ ]
118
+ slack_message = create_slack_message_from_cloudwatch_log(
119
+ log_events,
120
+ log_group,
121
+ timestamp_in_seconds,
122
+ project_name=project_name,
123
+ environment_name=environment_name,
124
+ region=resolved_region,
125
+ )
126
+ else:
127
+ slack_message = create_slack_message(
128
+ "No log message received in slack error log handler",
129
+ project_name,
130
+ timestamp_in_seconds,
131
+ environment_name,
132
+ f"No log messages received when the lambda handling errors for log group {log_group} was called.",
133
+ None,
134
+ log_group,
135
+ [],
136
+ )
137
+ except json.JSONDecodeError:
138
+ slack_message = create_slack_message(
139
+ f"Error in {log_group}",
140
+ project_name,
141
+ timestamp_in_seconds,
142
+ environment_name,
143
+ decoded_message["logEvents"][0]["message"][:750] + "...\n...",
144
+ None,
145
+ log_group,
146
+ [f"{evt['message'][:100]}..." for evt in decoded_message["logEvents"][1:]],
147
+ )
148
+ send_slack_notification(
149
+ slack_message,
150
+ secrets_client=secrets_client,
151
+ urlopen_func=urlopen_func,
152
+ slack_secret_name=slack_secret_name,
153
+ )
154
+
155
+
156
+ def get_secret(secret, secrets_client=None):
157
+ """Return a secret string from Secrets Manager."""
158
+ client = _get_secrets_client(secrets_client)
159
+ try:
160
+ return client.get_secret_value(SecretId=secret)["SecretString"]
161
+ except Exception as e:
162
+ raise RuntimeError(f"Error retrieving secret '{secret}': {e}") from e
163
+
164
+
165
+ def send_slack_notification(
166
+ slack_message: dict,
167
+ *,
168
+ secrets_client=None,
169
+ urlopen_func=None,
170
+ slack_secret_name=None,
171
+ ):
172
+ """Post the Slack payload using a webhook from Secrets Manager."""
173
+ client = _get_secrets_client(secrets_client)
174
+ if urlopen_func is None:
175
+ urlopen_func = urlopen
176
+ if slack_secret_name is None:
177
+ slack_secret_name = SLACK_URL_SECRET_NAME
178
+
179
+ slack_url = get_secret(slack_secret_name, secrets_client=client)
180
+
181
+ # Use helper to post so error mapping is consistent
182
+ _post_to_slack(slack_url, slack_message, urlopen_func=urlopen_func)
183
+
184
+
185
+ def _post_to_slack(url: str, payload: dict, *, urlopen_func):
186
+ req = Request(
187
+ url,
188
+ data=json.dumps(payload).encode("utf-8"),
189
+ headers={"Content-Type": "application/json"},
190
+ )
191
+ try:
192
+ urlopen_func(req).read()
193
+ except HTTPError as e:
194
+ raise RuntimeError(f"Request to slack failed: {e.code} {e.reason}") from e
195
+ except URLError as e:
196
+ raise RuntimeError(f"Server connection to slack failed: {e.reason}") from e
197
+
198
+
199
+ def _resolve_timestamp(log_events, time_func):
200
+ """Return a unix timestamp in seconds derived from the first log event.
201
+
202
+ Falls back to the current time on malformed values.
203
+ """
204
+ current_timestamp = int(time_func())
205
+ if not log_events:
206
+ return current_timestamp
207
+ raw = log_events[0].get("timestamp", current_timestamp)
208
+ try:
209
+ ts = int(raw)
210
+ except Exception:
211
+ return current_timestamp
212
+ # CloudWatch may return milliseconds; normalise to seconds when value is large.
213
+ return ts if ts < 1_000_000_000_000 else int(ts / 1000)
214
+
215
+
216
+ def get_masked_slack_webhook_url(slack_webhook_url: str) -> str:
217
+ """Return a masked representation of the webhook URL suitable for logging.
218
+
219
+ Keeps the leading part of the URL intact and replaces the final path
220
+ segment with the same number of asterisks. Examples:
221
+
222
+ - "https://example.com/abcd" -> "https://example.com/****"
223
+ - "tokenonly" -> "*****"
224
+ """
225
+ if not slack_webhook_url:
226
+ return ""
227
+ trimmed = slack_webhook_url.rstrip("/")
228
+ head, sep, tail = trimmed.rpartition("/")
229
+ if sep == "":
230
+ # no slash present; mask the whole value
231
+ return "*" * len(tail)
232
+ return head + sep + ("*" * len(tail))
233
+
234
+
235
+ def create_slack_message_from_cloudwatch_log(
236
+ events: list[CloudWatchLog],
237
+ log_group,
238
+ timestamp,
239
+ *,
240
+ project_name: str = PROJECT_NAME,
241
+ environment_name: str = ENVIRONMENT_NAME,
242
+ region: Optional[str] = None,
243
+ ):
244
+ """Create a Slack message from parsed CloudWatch log events.
245
+
246
+ `project_name` and `environment_name` default to the module-level
247
+ env-vars but can be provided for testing or when the runtime should
248
+ override them.
249
+ """
250
+ event = events[0]
251
+
252
+ service = event.get("service", "Undefined")
253
+ stack_trace = event.get("stack_trace")
254
+ message = event.get("message", "No message.")
255
+
256
+ other_messages = [
257
+ other_event.get("message", "undefined") for other_event in events[1:]
258
+ ]
259
+
260
+ slack_message = create_slack_message(
261
+ f"{service} error",
262
+ project_name,
263
+ timestamp,
264
+ environment_name,
265
+ message,
266
+ stack_trace,
267
+ log_group,
268
+ other_messages,
269
+ region=region,
270
+ )
271
+ return slack_message
272
+
273
+
274
+ def create_slack_message(
275
+ title,
276
+ project,
277
+ timestamp,
278
+ environment,
279
+ message,
280
+ stack_trace,
281
+ log_group,
282
+ extra_messages: list[str],
283
+ *,
284
+ region: Optional[str] = None,
285
+ ):
286
+ """Construct the Slack blocks payload used by the notification."""
287
+ blocks = [
288
+ {
289
+ "type": "header",
290
+ "text": {
291
+ "type": "plain_text",
292
+ "text": f"⚠️{title}⚠️",
293
+ "emoji": True,
294
+ },
295
+ },
296
+ {"type": "section", "text": {"type": "mrkdwn", "text": "*Message*:"}},
297
+ {
298
+ "type": "rich_text",
299
+ "elements": [
300
+ {
301
+ "type": "rich_text_preformatted",
302
+ "elements": [{"type": "text", "text": f"{message}"}],
303
+ "border": 0,
304
+ }
305
+ ],
306
+ },
307
+ ]
308
+
309
+ if stack_trace:
310
+ blocks.extend(
311
+ [
312
+ {
313
+ "type": "section",
314
+ "text": {"type": "mrkdwn", "text": "*Stack trace*:"},
315
+ },
316
+ {
317
+ "type": "rich_text",
318
+ "elements": [
319
+ {
320
+ "type": "rich_text_preformatted",
321
+ "elements": [
322
+ {"type": "text", "text": f"{stack_trace[:750]}"}
323
+ ],
324
+ "border": 0,
325
+ }
326
+ ],
327
+ },
328
+ ]
329
+ )
330
+
331
+ use_region = region or REGION
332
+ blocks.extend(
333
+ [
334
+ {
335
+ "type": "section",
336
+ "text": {
337
+ "type": "mrkdwn",
338
+ "text": f"<https://{use_region}.console.aws.amazon.com/cloudwatch/home?region={use_region}#logsV2:logs-insights$3FqueryDetail$3D~(end~0~start~-1800~timeType~'RELATIVE~tz~'LOCAL~unit~'seconds~editorString~'fields*20timestamp*2c*20message*0a*7c*20filter*20level*20*3d*20*22ERROR*22*0a*7c*20sort*20timestamp*20desc*0a*7c*20limit*20100~source~(~'{log_group})~lang~'CWLI)|Logs insights>",
339
+ },
340
+ },
341
+ {"type": "divider"},
342
+ {
343
+ "type": "context",
344
+ "elements": [
345
+ {
346
+ "type": "mrkdwn",
347
+ "text": "*Logged:* "
348
+ + f"<!date^{timestamp}^"
349
+ + "{date_num} {time_secs}|Failed parsing timestamp>",
350
+ },
351
+ {"type": "mrkdwn", "text": f"*Project:* {project}"},
352
+ {"type": "mrkdwn", "text": f"*Environment:* {environment}"},
353
+ ],
354
+ },
355
+ ]
356
+ )
357
+
358
+ if len(extra_messages) > 0:
359
+ list_items = [
360
+ {
361
+ "type": "rich_text_section",
362
+ "elements": [
363
+ {
364
+ "type": "text",
365
+ "text": message,
366
+ "style": {"italic": True},
367
+ }
368
+ ],
369
+ }
370
+ for message in extra_messages[:3]
371
+ ]
372
+
373
+ if len(extra_messages) > 3:
374
+ list_items.append(
375
+ {
376
+ "type": "rich_text_section",
377
+ "elements": [
378
+ {
379
+ "type": "text",
380
+ "text": f"...and {len(extra_messages[3:])} other messages.",
381
+ "style": {"italic": True},
382
+ }
383
+ ],
384
+ }
385
+ )
386
+
387
+ blocks.extend(
388
+ [
389
+ {"type": "divider"},
390
+ {
391
+ "type": "section",
392
+ "text": {
393
+ "type": "mrkdwn",
394
+ "text": f"*And {len(extra_messages)} other logs with messages:*",
395
+ },
396
+ },
397
+ {
398
+ "type": "rich_text",
399
+ "elements": [
400
+ {
401
+ "type": "rich_text_list",
402
+ "style": "bullet",
403
+ "indent": 0,
404
+ "elements": list_items,
405
+ }
406
+ ],
407
+ },
408
+ ]
409
+ )
410
+ slack_template = {"blocks": blocks}
411
+ return slack_template
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "slack-error-log-handler-lambda"
3
+ version = "0.0.0"
4
+ requires-python = ">=3.14"
5
+
6
+ [dependency-groups]
7
+ dev = [
8
+ "pytest>=7.0",
9
+ "pytest-mock>=3.0",
10
+ # boto3 should match the version used in the lambda runtime
11
+ # https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html#python-sdk-included
12
+ "boto3>=1.26",
13
+ ]
@@ -0,0 +1,229 @@
1
+ import json
2
+ import gzip
3
+ import base64
4
+ import os
5
+ import pytest
6
+
7
+ os.environ.setdefault("AWS_REGION", "eu-west-1")
8
+
9
+ import index as handler_module
10
+
11
+ from index import (
12
+ create_slack_message_from_cloudwatch_log,
13
+ process_event,
14
+ get_secret,
15
+ send_slack_notification,
16
+ get_masked_slack_webhook_url,
17
+ )
18
+
19
+
20
+ def make_event(log_messages):
21
+ log_events = []
22
+ for i, msg in enumerate(log_messages, start=1):
23
+ log_events.append(
24
+ {
25
+ "id": str(i),
26
+ "timestamp": 1620000000000 + i,
27
+ "message": json.dumps(msg),
28
+ }
29
+ )
30
+ payload = {
31
+ "logGroup": "test-log-group",
32
+ "logStream": "test-log-stream",
33
+ "logEvents": log_events,
34
+ }
35
+ compressed = base64.b64encode(
36
+ gzip.compress(json.dumps(payload).encode("utf-8"))
37
+ ).decode("utf-8")
38
+ return {"awslogs": {"data": compressed}}
39
+
40
+
41
+ # Small helper used across tests to represent a secrets client that returns
42
+ # a fixed secret string.
43
+ class SimpleSecretsClient:
44
+ def __init__(self, secret_str):
45
+ self._secret = secret_str
46
+
47
+ def get_secret_value(self, SecretId):
48
+ return {"SecretString": self._secret}
49
+
50
+
51
+ @pytest.fixture
52
+ def dummy_resp():
53
+ class _D:
54
+ def read(self):
55
+ return b"ok"
56
+
57
+ return _D()
58
+
59
+
60
+ def test_create_slack_message_from_cloudwatch_log_defaults():
61
+ events = [{"service": "svc", "message": "err", "stack_trace": "trace"}]
62
+ slack = create_slack_message_from_cloudwatch_log(
63
+ events, "lg", 123456, project_name="proj", environment_name="env"
64
+ )
65
+ assert "blocks" in slack
66
+ # header contains the title with service
67
+ header_text = slack["blocks"][0]["text"]["text"]
68
+ assert "svc error" in header_text
69
+
70
+
71
+ def test_get_secret_success_and_failure():
72
+ assert (
73
+ get_secret("id", secrets_client=SimpleSecretsClient("super-secret"))
74
+ == "super-secret"
75
+ )
76
+
77
+ class BrokenSecretsClient:
78
+ def get_secret_value(self, SecretId):
79
+ raise Exception("boom")
80
+
81
+ with pytest.raises(Exception) as exc:
82
+ get_secret("id", secrets_client=BrokenSecretsClient())
83
+ assert "Error retrieving secret" in str(exc.value)
84
+
85
+
86
+ def test_send_slack_notification_uses_secret_and_urlopen(dummy_resp):
87
+ # send_slack_notification uses get_secret and urlopen; inject both
88
+ dummy_client = SimpleSecretsClient("https://hooks.slack.com/services/T/B/S")
89
+
90
+ called = {"c": 0}
91
+
92
+ def dummy_urlopen(req):
93
+ called["c"] += 1
94
+ # basic checks on request body (headers may not be accessible in this
95
+ # test environment in the same way across Python versions).
96
+ data = None
97
+ if hasattr(req, "data") and req.data is not None:
98
+ data = req.data
99
+ elif hasattr(req, "get_data"):
100
+ data = req.get_data()
101
+ assert data is not None
102
+ payload = json.loads(data.decode("utf-8"))
103
+ assert payload.get("foo") == "bar"
104
+ return dummy_resp
105
+
106
+ send_slack_notification(
107
+ {"foo": "bar"},
108
+ secrets_client=dummy_client,
109
+ urlopen_func=dummy_urlopen,
110
+ slack_secret_name="unused",
111
+ )
112
+ assert called["c"] == 1
113
+
114
+
115
+ def test_create_slack_message_more_than_three_events():
116
+ # 5 events -> summary should say "...and 2 other messages." or similar
117
+ events = [
118
+ {"service": "svc1", "message": "m1", "stack_trace": "t1"},
119
+ {"service": "svc2", "message": "m2", "stack_trace": "t2"},
120
+ {"service": "svc3", "message": "m3", "stack_trace": "t3"},
121
+ {"service": "svc4", "message": "m4", "stack_trace": "t4"},
122
+ {"service": "svc5", "message": "m5", "stack_trace": "t5"},
123
+ ]
124
+ slack = create_slack_message_from_cloudwatch_log(
125
+ events, "lg", 123456, project_name="proj", environment_name="env"
126
+ )
127
+ # Collect text from blocks and assert the summary mentions extra messages
128
+ text_join = " ".join(
129
+ b.get("text", {}).get("text", "")
130
+ for b in slack.get("blocks", [])
131
+ if "text" in b
132
+ )
133
+ # Be permissive about the exact wording; the handler may say "And 4 other logs"
134
+ # or similar. Ensure some "other" summary appears.
135
+ assert "other" in text_join.lower()
136
+
137
+
138
+ def test_create_slack_message_no_stack_trace():
139
+ # event without stack_trace should include a fallback such as "No stack trace."
140
+ events = [{"service": "svc", "message": "err"}] # no stack_trace key
141
+ slack = create_slack_message_from_cloudwatch_log(
142
+ events, "lg", 123456, project_name="proj", environment_name="env"
143
+ )
144
+ # Assert there is no section block that contains the explicit
145
+ # "*Stack trace*:" header and that no rich_text block for stack trace
146
+ # exists.
147
+ blocks = slack.get("blocks", [])
148
+ assert not any(
149
+ b.get("type") == "section"
150
+ and "stack trace" in b.get("text", {}).get("text", "").lower()
151
+ for b in blocks
152
+ )
153
+ # Ensure at least the message rich_text block exists (the handler
154
+ # always includes a rich_text block for the message itself).
155
+ assert any(b.get("type") == "rich_text" for b in blocks)
156
+
157
+
158
+ def test_injected_client_does_not_pollute_cache(monkeypatch):
159
+ # Start from a clean module-level cache
160
+ monkeypatch.setattr(handler_module, "_secrets_client", None, raising=False)
161
+
162
+ injected = object()
163
+ assert handler_module._get_secrets_client(secrets_client=injected) is injected
164
+ # cache should remain empty after injection
165
+ assert handler_module._secrets_client is None
166
+
167
+ # Lazy-create a real client via boto3.client (monkeypatch to a sentinel)
168
+ monkeypatch.setattr(handler_module.boto3, "client", lambda svc: "sentinel")
169
+ assert handler_module._get_secrets_client() == "sentinel"
170
+ assert handler_module._secrets_client == "sentinel"
171
+
172
+
173
+ def test_process_event_json_decode_error_fallback(dummy_resp):
174
+ # Build compressed payload with a raw non-JSON first message to trigger
175
+ # JSONDecodeError in the parser path.
176
+ payload = {
177
+ "logGroup": "test-log-group",
178
+ "logStream": "test-log-stream",
179
+ "logEvents": [
180
+ {"id": "1", "timestamp": 1620000000000, "message": "not-a-json"},
181
+ {
182
+ "id": "2",
183
+ "timestamp": 1620000000001,
184
+ "message": json.dumps({"service": "svc2", "message": "m2"}),
185
+ },
186
+ ],
187
+ }
188
+ compressed = base64.b64encode(
189
+ gzip.compress(json.dumps(payload).encode("utf-8"))
190
+ ).decode("utf-8")
191
+ ev = {"awslogs": {"data": compressed}}
192
+
193
+ dummy_client = SimpleSecretsClient("https://hooks.slack.com/services/T/B/S")
194
+
195
+ called = {"c": 0}
196
+
197
+ def dummy_urlopen(req):
198
+ called["c"] += 1
199
+ return dummy_resp
200
+
201
+ # Should not raise and should call urlopen once
202
+ process_event(ev, None, secrets_client=dummy_client, urlopen_func=dummy_urlopen)
203
+ assert called["c"] == 1
204
+
205
+
206
+ def test_process_event_with_broken_secrets_client_raises():
207
+ ev = make_event([{"service": "svc", "message": "oops", "stack_trace": "trace"}])
208
+
209
+ class BrokenSecretsClient:
210
+ def get_secret_value(self, SecretId):
211
+ raise Exception("boom-secret")
212
+
213
+ with pytest.raises(Exception):
214
+ process_event(
215
+ ev, None, secrets_client=BrokenSecretsClient(), urlopen_func=lambda r: None
216
+ )
217
+
218
+
219
+ @pytest.mark.parametrize(
220
+ "url, tail",
221
+ [
222
+ ("https://hooks.slack.com/services/T/B/S", "S"),
223
+ ("https://example.com/abcd/", "abcd"),
224
+ ("https://example.com/foo", "foo"),
225
+ ],
226
+ )
227
+ def test_get_masked_slack_webhook_url(url, tail):
228
+ masked = get_masked_slack_webhook_url(url)
229
+ assert masked.endswith("*" * len(tail))