1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as lambda from "@distilled.cloud/aws/lambda";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import CloudWatchTestFunctionLive, {
12 CloudWatchTestFunction,
13 METRIC_NAME,
14} from "./handler";
15
16// Runtime binding coverage for every CloudWatch capability that can be
17// exercised against fixture-deployable resources. Two bindings have no
18// route here (documented, not silently dropped):
19//
20// - GetAlarmMuteRule — binding requires a deployed AlarmMuteRule, but
21// `putAlarmMuteRule` currently rejects every input with an empty-message
22// ValidationException (see AlarmMuteRule.test.ts). ListAlarmMuteRules is
23// covered below without a resource.
24// - GetMetricStream / StartMetricStreams / StopMetricStreams — bindings
25// require a deployed MetricStream, which needs a live Firehose delivery
26// stream + IAM roles and is gated behind AWS_TEST_METRICSTREAM at the
27// resource level (see MetricStream.test.ts). ListMetricStreams is covered
28// below without a resource.
29
30const testOptions = { providers: AWS.providers() };
31const { test, beforeAll, afterAll } = Test.make(testOptions);
32const sharedStack = Core.scratchStack(testOptions, "CloudWatchBindings");
33
34// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
35// over 60s on a fresh deploy under parallel-suite load. Budget ~150s of
36// readiness polling so we don't fail the whole suite on a slow init.
37const readinessPolicy = Schedule.max([
38 Schedule.fixed("2 seconds"),
39 Schedule.recurs(75),
40]);
41
42let baseUrl: string;
43let fixtureFunctionName: string | undefined;
44
45class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
46 readonly status: number;
47 readonly body: string;
48}> {}
49
50// The shared Lambda fixture occasionally answers a transient 5xx under
51// full-suite parallel load — a cold re-init or an IAM-propagation race the
52// handler's `Effect.orDie` surfaces as a 500. Only 5xx is retried; a genuine
53// 4xx/assertion failure is returned immediately.
54const send = (request: HttpClientRequest.HttpClientRequest) =>
55 HttpClient.execute(request).pipe(
56 Effect.flatMap((response) =>
57 response.status >= 500
58 ? response.text.pipe(
59 Effect.flatMap((body) =>
60 Effect.fail(
61 new TransientUpstream({ status: response.status, body }),
62 ),
63 ),
64 )
65 : Effect.succeed(response),
66 ),
67 Effect.retry({
68 while: (e) => e._tag === "TransientUpstream",
69 schedule: Schedule.max([
70 Schedule.exponential("500 millis"),
71 Schedule.recurs(6),
72 ]),
73 }),
74 );
75
76const getJson = (path: string) =>
77 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
78 Effect.flatMap((r) => r.json),
79 );
80
81const postJson = (path: string) =>
82 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
83 Effect.flatMap((r) => r.json),
84 );
85
86describe(
87 "CloudWatch Bindings",
88 {
89 tags: [
90 "provider:aws",
91 "provider:aws:cloudwatch",
92 "provider:aws:lambda",
93 "live",
94 ],
95 },
96 () => {
97 beforeAll(
98 Effect.gen(function* () {
99 yield* Effect.logInfo(
100 "CloudWatch test setup: destroying previous resources",
101 );
102 yield* sharedStack.destroy();
103
104 yield* Effect.logInfo("CloudWatch test setup: deploying fixture");
105 const { functionUrl, functionName } = yield* sharedStack.deploy(
106 Effect.gen(function* () {
107 return yield* CloudWatchTestFunction;
108 }).pipe(Effect.provide(CloudWatchTestFunctionLive)),
109 );
110
111 expect(functionUrl).toBeTruthy();
112 baseUrl = functionUrl!.replace(/\/+$/, "");
113 fixtureFunctionName = functionName;
114
115 yield* Effect.logInfo(
116 `CloudWatch test setup: probing readiness at ${baseUrl}/health`,
117 );
118 yield* HttpClient.get(`${baseUrl}/health`).pipe(
119 Effect.flatMap((response) =>
120 response.status === 200
121 ? Effect.succeed(response)
122 : Effect.fail(
123 new Error(`Function not ready: ${response.status}`),
124 ),
125 ),
126 Effect.retry({ schedule: readinessPolicy }),
127 );
128 }),
129 { timeout: 240_000 },
130 );
131
132 afterAll(
133 Effect.gen(function* () {
134 yield* sharedStack.destroy();
135 // Out-of-band assert-gone: the fixture Lambda (root of the deployed
136 // stack) no longer exists after the trailing destroy. `afterAll`
137 // doesn't run inside `test.provider`'s environment, so provide the
138 // AWS providers (Credentials/Region) explicitly for the distilled call.
139 if (fixtureFunctionName !== undefined) {
140 const gone = yield* Core.withProviders(
141 lambda.getFunction({ FunctionName: fixtureFunctionName }).pipe(
142 Effect.map(() => false),
143 Effect.catchTag("ResourceNotFoundException", () =>
144 Effect.succeed(true),
145 ),
146 ),
147 testOptions,
148 "CloudWatchBindings",
149 );
150 expect(gone).toBe(true);
151 }
152 }),
153 { timeout: 120_000 },
154 );
155
156 // ── Metrics ──────────────────────────────────────────────────────────────
157
158 describe("PutMetricData", () => {
159 test.provider("puts a custom metric datum", (_stack) =>
160 Effect.gen(function* () {
161 const response = yield* postJson("/put-metric-data");
162 expect(response).toHaveProperty("ok", true);
163 }),
164 );
165 });
166
167 describe("ListMetrics", () => {
168 test.provider("lists metrics in the fixture namespace", (_stack) =>
169 Effect.gen(function* () {
170 // Freshly-put metric data can take minutes to appear in ListMetrics —
171 // assert the authorized call returns a well-formed collection.
172 const response = (yield* getJson("/list-metrics")) as {
173 metrics: unknown[];
174 };
175 expect(Array.isArray(response.metrics)).toBe(true);
176 }),
177 );
178 });
179
180 describe("GetMetricData", () => {
181 test.provider("queries the fixture metric", (_stack) =>
182 Effect.gen(function* () {
183 const response = (yield* getJson("/get-metric-data")) as {
184 results: { Id?: string }[];
185 };
186 expect(response.results.length).toBe(1);
187 expect(response.results[0].Id).toBe("m1");
188 }),
189 );
190 });
191
192 describe("GetMetricStatistics", () => {
193 test.provider("returns statistics for the fixture metric", (_stack) =>
194 Effect.gen(function* () {
195 const response = (yield* getJson("/get-metric-statistics")) as {
196 label?: string;
197 datapoints: unknown[];
198 };
199 expect(response.label).toBe(METRIC_NAME);
200 expect(Array.isArray(response.datapoints)).toBe(true);
201 }),
202 );
203 });
204
205 describe("GetMetricWidgetImage", () => {
206 test.provider("renders a metric widget image", (_stack) =>
207 Effect.gen(function* () {
208 const response = (yield* getJson("/get-metric-widget-image")) as {
209 bytes: number;
210 };
211 expect(response.bytes).toBeGreaterThan(0);
212 }),
213 );
214 });
215
216 // ── Alarms ───────────────────────────────────────────────────────────────
217
218 describe("DescribeAlarms", () => {
219 test.provider("describes the bound alarm", (_stack) =>
220 Effect.gen(function* () {
221 const response = (yield* getJson("/describe-alarms")) as {
222 alarmName: string;
223 alarms: string[];
224 };
225 expect(response.alarms).toContain(response.alarmName);
226 }),
227 );
228 });
229
230 describe("DescribeAlarmsForMetric", () => {
231 test.provider("finds the alarm by its metric", (_stack) =>
232 Effect.gen(function* () {
233 const response = (yield* getJson("/describe-alarms-for-metric")) as {
234 alarmName: string;
235 alarms: string[];
236 };
237 expect(response.alarms).toContain(response.alarmName);
238 }),
239 );
240 });
241
242 describe("SetAlarmState", () => {
243 test.provider("sets the alarm state to OK", (_stack) =>
244 Effect.gen(function* () {
245 const response = yield* postJson("/set-alarm-state");
246 expect(response).toHaveProperty("ok", true);
247 }),
248 );
249 });
250
251 describe("DescribeAlarmHistory", () => {
252 test.provider("returns history for the bound alarm", (_stack) =>
253 Effect.gen(function* () {
254 const response = (yield* getJson("/describe-alarm-history")) as {
255 items: unknown[];
256 };
257 expect(Array.isArray(response.items)).toBe(true);
258 }),
259 );
260 });
261
262 describe("DescribeAlarmContributors", () => {
263 test.provider("is authorized against the bound alarm", (_stack) =>
264 Effect.gen(function* () {
265 // A plain metric alarm has no contributor data; the typed
266 // ValidationException (observed live) or ResourceNotFoundException is
267 // the expected outcome, and a successful contributor list is equally
268 // valid. Any of these proves binding + IAM (auth failures die as 500).
269 const response = (yield* getJson("/describe-alarm-contributors")) as
270 | { ok: true; contributors: unknown[] }
271 | { ok: false; error: string };
272 if (response.ok) {
273 expect(Array.isArray(response.contributors)).toBe(true);
274 } else {
275 expect([
276 "ResourceNotFoundException",
277 "ValidationException",
278 ]).toContain(response.error);
279 }
280 }),
281 );
282 });
283
284 describe("DisableAlarmActions", () => {
285 test.provider("disables actions on the bound alarm", (_stack) =>
286 Effect.gen(function* () {
287 const response = yield* postJson("/disable-alarm-actions");
288 expect(response).toHaveProperty("ok", true);
289 }),
290 );
291 });
292
293 describe("EnableAlarmActions", () => {
294 test.provider("re-enables actions on the bound alarm", (_stack) =>
295 Effect.gen(function* () {
296 const response = yield* postJson("/enable-alarm-actions");
297 expect(response).toHaveProperty("ok", true);
298 }),
299 );
300 });
301
302 describe("ListTagsForResource", () => {
303 test.provider("lists the alarm's internal tags", (_stack) =>
304 Effect.gen(function* () {
305 const response = (yield* getJson("/list-tags-for-resource")) as {
306 tags: { Key: string; Value: string }[];
307 };
308 // Every alchemy-managed alarm carries internal branding tags.
309 expect(response.tags.length).toBeGreaterThan(0);
310 }),
311 );
312 });
313
314 // ── Dashboards ───────────────────────────────────────────────────────────
315
316 describe("GetDashboard", () => {
317 test.provider("gets the bound dashboard", (_stack) =>
318 Effect.gen(function* () {
319 const response = (yield* getJson("/get-dashboard")) as {
320 dashboardName: string;
321 name?: string;
322 body?: string;
323 };
324 expect(response.name).toBe(response.dashboardName);
325 expect(response.body).toContain("widgets");
326 }),
327 );
328 });
329
330 describe("ListDashboards", () => {
331 test.provider("lists the deployed dashboard", (_stack) =>
332 Effect.gen(function* () {
333 const response = (yield* getJson("/list-dashboards")) as {
334 dashboardName: string;
335 entries: string[];
336 };
337 expect(response.entries).toContain(response.dashboardName);
338 }),
339 );
340 });
341
342 // ── Contributor Insights ─────────────────────────────────────────────────
343
344 describe("DescribeInsightRules", () => {
345 test.provider("lists the deployed insight rule", (_stack) =>
346 Effect.gen(function* () {
347 const response = (yield* getJson("/describe-insight-rules")) as {
348 ruleName: string;
349 rules: string[];
350 };
351 expect(response.rules).toContain(response.ruleName);
352 }),
353 );
354 });
355
356 describe("GetInsightRuleReport", () => {
357 test.provider("returns a report for the deployed rule", (_stack) =>
358 Effect.gen(function* () {
359 const response = (yield* getJson("/get-insight-rule-report")) as {
360 ok: boolean;
361 contributors: unknown[];
362 };
363 expect(response.ok).toBe(true);
364 expect(Array.isArray(response.contributors)).toBe(true);
365 }),
366 );
367 });
368
369 describe("DisableInsightRules", () => {
370 test.provider("disables the deployed rule without failures", (_stack) =>
371 Effect.gen(function* () {
372 const response = (yield* postJson("/disable-insight-rules")) as {
373 failures: unknown[];
374 };
375 expect(response.failures).toEqual([]);
376 }),
377 );
378 });
379
380 describe("EnableInsightRules", () => {
381 test.provider("re-enables the deployed rule without failures", (_stack) =>
382 Effect.gen(function* () {
383 const response = (yield* postJson("/enable-insight-rules")) as {
384 failures: unknown[];
385 };
386 expect(response.failures).toEqual([]);
387 }),
388 );
389 });
390
391 describe("ListManagedInsightRules", () => {
392 test.provider("is authorized for the probe resource", (_stack) =>
393 Effect.gen(function* () {
394 // Managed Contributor Insights rules only exist for specific AWS
395 // resource types; probing with the alarm ARN either returns rules or
396 // the typed InvalidParameterValueException. Either proves the
397 // binding is wired and authorized (auth failures surface as 500s).
398 const response = (yield* getJson("/list-managed-insight-rules")) as
399 | { ok: true; rules: unknown[] }
400 | { ok: false; error: string };
401 if (response.ok) {
402 expect(Array.isArray(response.rules)).toBe(true);
403 } else {
404 expect(response.error).toBe("InvalidParameterValueException");
405 }
406 }),
407 );
408 });
409
410 // ── Anomaly detectors ────────────────────────────────────────────────────
411
412 describe("DescribeAnomalyDetectors", () => {
413 test.provider("finds the deployed anomaly detector", (_stack) =>
414 Effect.gen(function* () {
415 const response = (yield* getJson("/describe-anomaly-detectors")) as {
416 detectors: {
417 SingleMetricAnomalyDetector?: { MetricName?: string };
418 MetricName?: string;
419 }[];
420 };
421 expect(
422 response.detectors.some(
423 (d) =>
424 d.MetricName === METRIC_NAME ||
425 d.SingleMetricAnomalyDetector?.MetricName === METRIC_NAME,
426 ),
427 ).toBe(true);
428 }),
429 );
430 });
431
432 // ── Alarm mute rules / metric streams ────────────────────────────────────
433
434 describe("ListAlarmMuteRules", () => {
435 test.provider("lists alarm mute rules in the region", (_stack) =>
436 Effect.gen(function* () {
437 const response = (yield* getJson("/list-alarm-mute-rules")) as {
438 summaries: unknown[];
439 };
440 expect(Array.isArray(response.summaries)).toBe(true);
441 }),
442 );
443 });
444
445 describe("ListMetricStreams", () => {
446 test.provider("lists metric streams in the region", (_stack) =>
447 Effect.gen(function* () {
448 const response = (yield* getJson("/list-metric-streams")) as {
449 entries: unknown[];
450 };
451 expect(Array.isArray(response.entries)).toBe(true);
452 }),
453 );
454 });
455 },
456);