1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import * as im from "@distilled.cloud/aws/internetmonitor";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import InternetMonitorBindingsFunctionLive, {
15 InternetMonitorBindingsFunction,
16} from "./handler";
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(testOptions, "InternetMonitorBindings");
21
22// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
23// over 60s on a fresh deploy.
24const readinessPolicy = Schedule.max([
25 Schedule.fixed("2 seconds"),
26 Schedule.recurs(75),
27]);
28
29let baseUrl: string;
30let functionArn: string;
31
32// beforeAll/afterAll hooks run outside `test.provider`'s layer, so raw
33// distilled calls need the provider layer (credentials, region) supplied
34// explicitly.
35const aws = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
36 Core.withProviders(effect, testOptions, sharedStack.name);
37
38// Names of all monitors owned by this suite's scratch stack (matched by the
39// internal `alchemy::stack` ownership tag), read fresh from the cloud.
40const listStackMonitors = aws(
41 im.listMonitors.items({}).pipe(
42 Stream.runCollect,
43 Effect.flatMap((monitors) =>
44 Effect.forEach(
45 Array.from(monitors),
46 (monitor) =>
47 im.getMonitor({ MonitorName: monitor.MonitorName }).pipe(
48 Effect.map((r) =>
49 r.Tags?.["alchemy::stack"] === sharedStack.name
50 ? [monitor.MonitorName]
51 : [],
52 ),
53 // Tolerate delete races between list and get.
54 Effect.catchTag("ResourceNotFoundException", () =>
55 Effect.succeed([] as string[]),
56 ),
57 ),
58 { concurrency: 4 },
59 ),
60 ),
61 Effect.map((names) => names.flat()),
62 ),
63);
64
65class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
66 readonly status: number;
67 readonly body: string;
68}> {}
69
70// The shared Lambda fixture occasionally answers a transient 5xx under load
71// (cold re-init, IAM propagation on the freshly attached policy). Retry only
72// 5xx; a genuine 4xx/assertion failure surfaces immediately.
73const send = (request: HttpClientRequest.HttpClientRequest) =>
74 HttpClient.execute(request).pipe(
75 Effect.flatMap((response) =>
76 response.status >= 500
77 ? response.text.pipe(
78 Effect.flatMap((body) =>
79 Effect.fail(
80 new TransientUpstream({ status: response.status, body }),
81 ),
82 ),
83 )
84 : Effect.succeed(response),
85 ),
86 Effect.retry({
87 while: (e) => e._tag === "TransientUpstream",
88 schedule: Schedule.max([
89 Schedule.exponential("500 millis"),
90 Schedule.recurs(6),
91 ]),
92 }),
93 );
94
95const getJson = (path: string) =>
96 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
97 Effect.flatMap((r) => r.json),
98 );
99
100describe.sequential(
101 "InternetMonitor Bindings",
102 {
103 tags: [
104 "provider:aws",
105 "provider:aws:internetmonitor",
106 "provider:aws:lambda",
107 "live",
108 ],
109 },
110 () => {
111 beforeAll(
112 Effect.gen(function* () {
113 yield* Effect.logInfo(
114 "InternetMonitor test setup: destroying previous resources",
115 );
116 yield* sharedStack.destroy();
117
118 yield* Effect.logInfo("InternetMonitor test setup: deploying fixture");
119 const attrs = yield* sharedStack.deploy(
120 Effect.gen(function* () {
121 return yield* InternetMonitorBindingsFunction;
122 }).pipe(Effect.provide(InternetMonitorBindingsFunctionLive)),
123 );
124
125 expect(attrs.functionUrl).toBeTruthy();
126 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
127 functionArn = attrs.functionArn;
128
129 const readinessUrl = `${baseUrl}/bindings`;
130 yield* Effect.logInfo(
131 `InternetMonitor test setup: probing readiness at ${readinessUrl}`,
132 );
133 yield* HttpClient.get(readinessUrl).pipe(
134 Effect.flatMap((response) =>
135 response.status === 200
136 ? Effect.succeed(response)
137 : Effect.fail(
138 new Error(`Function not ready: ${response.status}`),
139 ),
140 ),
141 Effect.tapError((error) =>
142 Effect.logWarning(
143 `InternetMonitor test setup: fixture not ready yet (${String(error)})`,
144 ),
145 ),
146 Effect.retry({ schedule: readinessPolicy }),
147 );
148 }),
149 { timeout: 300_000 },
150 );
151
152 afterAll(
153 Effect.gen(function* () {
154 if (process.env.NO_DESTROY) return;
155 // Capture our monitor names before destroy so the log-group reap can
156 // be verified per-monitor afterwards.
157 const owned = yield* listStackMonitors;
158 yield* sharedStack.destroy();
159 // Zero-orphan proof: no monitor tagged with this stack remains.
160 const remaining = yield* listStackMonitors;
161 expect(remaining).toEqual([]);
162 // And the auto-created /aws/internet-monitor/{name}/* log groups were
163 // reaped by the provider delete.
164 const remainingLogGroups = yield* aws(
165 Effect.forEach(owned, (name) =>
166 logs
167 .describeLogGroups({
168 logGroupNamePrefix: `/aws/internet-monitor/${name}`,
169 })
170 .pipe(
171 Effect.map((r) =>
172 (r.logGroups ?? []).map((g) => g.logGroupName),
173 ),
174 ),
175 ),
176 );
177 expect(remainingLogGroups.flat()).toEqual([]);
178 }),
179 { timeout: 300_000 },
180 );
181
182 describe("binding registration", () => {
183 test.provider("all 8 capabilities initialize in the runtime", (_stack) =>
184 Effect.gen(function* () {
185 const response = (yield* getJson("/bindings")) as { bound: string[] };
186 expect(response.bound).toHaveLength(8);
187 expect(response.bound).toContain("listHealthEvents");
188 expect(response.bound).toContain("startQuery");
189 expect(response.bound).toContain("listInternetEvents");
190 }),
191 );
192 });
193
194 describe("ListHealthEvents", () => {
195 test.provider(
196 "lists health events on the bound monitor (name injected)",
197 (_stack) =>
198 Effect.gen(function* () {
199 const response = (yield* getJson("/health-events")) as {
200 count: number;
201 };
202 // A fresh empty monitor has no health events; a zero count still
203 // proves the grant + monitor-name injection round-tripped.
204 expect(response.count).toBeGreaterThanOrEqual(0);
205 }),
206 );
207 });
208
209 describe("GetHealthEvent", () => {
210 test.provider(
211 "typed rejection for a nonexistent event id (grant proven)",
212 (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* getJson("/health-events/typed-probe")) as {
215 tag: string;
216 };
217 // Any typed tag except AccessDenied proves the IAM grant reached
218 // the monitor-scoped API.
219 expect(response.tag).not.toBe("AccessDeniedException");
220 }),
221 );
222 });
223
224 describe("ListInternetEvents", () => {
225 test.provider("lists global internet events (account-level)", (_stack) =>
226 Effect.gen(function* () {
227 const response = (yield* getJson("/internet-events")) as {
228 count: number;
229 };
230 expect(response.count).toBeGreaterThanOrEqual(0);
231 }),
232 );
233 });
234
235 describe("GetInternetEvent", () => {
236 test.provider(
237 "typed rejection for a nonexistent event id (grant proven)",
238 (_stack) =>
239 Effect.gen(function* () {
240 const response = (yield* getJson(
241 "/internet-event/typed-probe",
242 )) as {
243 tag: string;
244 };
245 expect(response.tag).not.toBe("AccessDeniedException");
246 }),
247 );
248 });
249
250 describe("StartQuery / GetQueryStatus / GetQueryResults / StopQuery", () => {
251 test.provider(
252 "runs a MEASUREMENTS query end-to-end on the bound monitor",
253 (_stack) =>
254 Effect.gen(function* () {
255 type QueryResponse = {
256 step: string;
257 tag?: string;
258 error?: string;
259 queryId?: string;
260 status?: string;
261 fields?: number;
262 rows?: number;
263 stopTag?: string;
264 };
265 // The role policy granting internetmonitor:* is attached moments
266 // before this test — IAM propagation can lag ~10-30s, surfacing as
267 // AccessDeniedException from a step. Retry (bounded) until the
268 // grant lands; any other failure surfaces immediately.
269 const response = yield* getJson("/query").pipe(
270 Effect.map((r) => r as QueryResponse),
271 Effect.repeat({
272 schedule: Schedule.spaced("5 seconds"),
273 until: (r): boolean => r.tag !== "AccessDeniedException",
274 times: 10,
275 }),
276 );
277 // On a step failure the route reports { step, tag, error } — log
278 // the full payload so the exact typed failure is never elided.
279 yield* Effect.logInfo(
280 `/query response: ${JSON.stringify(response)}`,
281 );
282 expect(response).toMatchObject({ step: "ok" });
283 expect(response.queryId).toBeTruthy();
284 // An empty monitor's query still runs to a terminal state.
285 expect(["SUCCEEDED", "FAILED", "CANCELED"]).toContain(
286 response.status,
287 );
288 expect(response.rows).toBeGreaterThanOrEqual(0);
289 expect(response.stopTag).not.toBe("AccessDeniedException");
290 }),
291 { timeout: 180_000 },
292 );
293 });
294
295 describe(
296 "consumeHealthEvents",
297 { tags: ["provider:aws:eventbridge"] },
298 () => {
299 test.provider(
300 "the deploy created an EventBridge rule targeting the function",
301 (_stack) =>
302 Effect.gen(function* () {
303 // Out-of-band via distilled: the fixture's consumeHealthEvents
304 // must have materialized as a rule on the default bus with the
305 // Lambda as target.
306 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
307 TargetArn: functionArn,
308 });
309 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
310 }),
311 );
312 },
313 );
314 },
315);