1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
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 GreengrassTestFunctionLive, {
12 COMPONENT_NAME,
13 COMPONENT_VERSION,
14 GreengrassTestFunction,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "GreengrassV2Bindings");
20
21// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
22// over 60s on a fresh deploy.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(75),
26]);
27
28let baseUrl: string;
29let functionArn: string;
30
31class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
32 readonly status: number;
33 readonly body: string;
34}> {}
35
36// The shared Lambda fixture occasionally answers a transient 5xx under load
37// (cold re-init, IAM propagation on the freshly attached policy that the
38// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
39// 4xx/assertion failure surfaces immediately.
40const send = (request: HttpClientRequest.HttpClientRequest) =>
41 HttpClient.execute(request).pipe(
42 Effect.flatMap((response) =>
43 response.status >= 500
44 ? response.text.pipe(
45 Effect.flatMap((body) =>
46 Effect.fail(
47 new TransientUpstream({ status: response.status, body }),
48 ),
49 ),
50 )
51 : Effect.succeed(response),
52 ),
53 Effect.retry({
54 while: (e) => e._tag === "TransientUpstream",
55 schedule: Schedule.max([
56 Schedule.exponential("500 millis"),
57 Schedule.recurs(6),
58 ]),
59 }),
60 );
61
62const getJson = (path: string) =>
63 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 );
66
67const postJson = (path: string) =>
68 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
69 Effect.flatMap((r) => r.json),
70 );
71
72// A type alias (not an interface) so it keeps the implicit index signature —
73// interfaces are not comparable to the JSON `JsonObject` type that
74// `response.json` returns, which breaks the `as Probe` casts below.
75type Probe = {
76 ok: boolean;
77 tag?: string;
78};
79
80describe.sequential(
81 "GreengrassV2 Bindings",
82 {
83 tags: [
84 "provider:aws",
85 "provider:aws:batch",
86 "provider:aws:greengrassv2",
87 "provider:aws:iot",
88 "provider:aws:lambda",
89 "live",
90 ],
91 },
92 () => {
93 beforeAll(
94 Effect.gen(function* () {
95 yield* Effect.logInfo(
96 "GreengrassV2 test setup: destroying previous resources",
97 );
98 yield* sharedStack.destroy();
99
100 yield* Effect.logInfo("GreengrassV2 test setup: deploying fixture");
101 const attrs = yield* sharedStack.deploy(
102 Effect.gen(function* () {
103 return yield* GreengrassTestFunction;
104 }).pipe(Effect.provide(GreengrassTestFunctionLive)),
105 );
106
107 expect(attrs.functionUrl).toBeTruthy();
108 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
109 functionArn = attrs.functionArn;
110
111 const readinessUrl = `${baseUrl}/bindings`;
112 yield* Effect.logInfo(
113 `GreengrassV2 test setup: probing readiness at ${readinessUrl}`,
114 );
115 yield* HttpClient.get(readinessUrl).pipe(
116 Effect.flatMap((response) =>
117 response.status === 200
118 ? Effect.succeed(response)
119 : Effect.fail(
120 new Error(`Function not ready: ${response.status}`),
121 ),
122 ),
123 Effect.tapError((error) =>
124 Effect.logWarning(
125 `GreengrassV2 test setup: fixture not ready yet (${String(error)})`,
126 ),
127 ),
128 Effect.retry({ schedule: readinessPolicy }),
129 );
130 }),
131 { timeout: 300_000 },
132 );
133
134 afterAll(sharedStack.destroy(), { timeout: 180_000 });
135
136 describe("binding registration", () => {
137 test.provider("all 19 capabilities initialize in the runtime", (_stack) =>
138 Effect.gen(function* () {
139 const response = (yield* getJson("/bindings")) as { bound: string[] };
140 expect(response.bound).toHaveLength(19);
141 }),
142 );
143 });
144
145 describe("DescribeComponent", () => {
146 test.provider(
147 "reads the bound component version's metadata (injected arn)",
148 (_stack) =>
149 Effect.gen(function* () {
150 const response = (yield* getJson("/component")) as {
151 arn: string;
152 componentName: string;
153 componentVersion: string;
154 state: string;
155 };
156 expect(response.componentName).toBe(COMPONENT_NAME);
157 expect(response.componentVersion).toBe(COMPONENT_VERSION);
158 expect(response.arn).toContain(
159 `:components:${COMPONENT_NAME}:versions:${COMPONENT_VERSION}`,
160 );
161 expect(response.state).toBe("DEPLOYABLE");
162 }),
163 );
164 });
165
166 describe("GetComponent", () => {
167 test.provider("fetches the recipe (round-trips the name)", (_stack) =>
168 Effect.gen(function* () {
169 const response = (yield* getJson("/recipe")) as {
170 recipeOutputFormat: string;
171 hasName: boolean;
172 };
173 expect(response.recipeOutputFormat).toBe("JSON");
174 expect(response.hasName).toBe(true);
175 }),
176 );
177 });
178
179 describe("GetComponentVersionArtifact", () => {
180 test.provider(
181 "a missing artifact surfaces a typed error (never AccessDenied)",
182 (_stack) =>
183 Effect.gen(function* () {
184 const response = (yield* getJson("/artifact")) as Probe;
185 expect(response.ok).toBe(false);
186 expect(response.tag).not.toBe("AccessDeniedException");
187 expect([
188 "ResourceNotFoundException",
189 "ValidationException",
190 ]).toContain(response.tag);
191 }),
192 );
193 });
194
195 describe("GetDeployment", () => {
196 test.provider(
197 "reads the bound deployment's detail (injected deployment id)",
198 (_stack) =>
199 Effect.gen(function* () {
200 const response = (yield* getJson("/deployment")) as {
201 deploymentId: string;
202 deploymentStatus: string;
203 targetArn: string;
204 isLatestForTarget: boolean;
205 };
206 expect(response.deploymentId).toBeTruthy();
207 expect(response.targetArn).toContain(":thing/");
208 expect(response.isLatestForTarget).toBe(true);
209 }),
210 );
211 });
212
213 describe("ListDeployments", () => {
214 test.provider("enumerates the account's latest deployments", (_stack) =>
215 Effect.gen(function* () {
216 const deployment = (yield* getJson("/deployment")) as {
217 deploymentId: string;
218 };
219 const response = (yield* getJson("/deployments")) as {
220 ids: string[];
221 };
222 expect(response.ids).toContain(deployment.deploymentId);
223 }),
224 );
225 });
226
227 describe("ListComponents", () => {
228 test.provider(
229 "enumerates private components incl. the fixture's",
230 (_stack) =>
231 Effect.gen(function* () {
232 const response = (yield* getJson("/components")) as {
233 names: string[];
234 };
235 expect(response.names).toContain(COMPONENT_NAME);
236 }),
237 );
238 });
239
240 describe("ListComponentVersions", () => {
241 test.provider("lists the fixture component's versions", (_stack) =>
242 Effect.gen(function* () {
243 const response = (yield* getJson("/component-versions")) as {
244 versions: string[];
245 };
246 expect(response.versions).toContain(COMPONENT_VERSION);
247 }),
248 );
249 });
250
251 describe("ListCoreDevices", () => {
252 test.provider("enumerates core devices (count is a number)", (_stack) =>
253 Effect.gen(function* () {
254 const response = (yield* getJson("/core-devices")) as {
255 count: number;
256 };
257 expect(response.count).toBeGreaterThanOrEqual(0);
258 }),
259 );
260 });
261
262 describe("core device bindings", () => {
263 test.provider(
264 "probes against a missing core device return typed errors (never AccessDenied)",
265 (_stack) =>
266 Effect.gen(function* () {
267 // Freshly attached IAM policy statements are eventually consistent —
268 // poll (bounded) until the grants have propagated before asserting
269 // the steady-state invariant below.
270 const response = (yield* getJson("/core-device-probes").pipe(
271 Effect.repeat({
272 schedule: Schedule.spaced("3 seconds"),
273 until: (r): boolean =>
274 Object.values(r as Record<string, Probe>).every(
275 (probe) => probe.tag !== "AccessDeniedException",
276 ),
277 times: 20,
278 }),
279 )) as Record<string, Probe>;
280 // Point reads/writes on a missing core device are typed not-found.
281 expect(response.getCoreDevice!.tag).toBe(
282 "ResourceNotFoundException",
283 );
284 expect(response.deleteCoreDevice!.tag).toBe(
285 "ResourceNotFoundException",
286 );
287 // Every probe proves IAM: none may be AccessDenied.
288 for (const probe of Object.values(response)) {
289 expect(probe.tag).not.toBe("AccessDeniedException");
290 }
291 }),
292 { timeout: 240_000 },
293 );
294 });
295
296 describe("connectivity info", () => {
297 test.provider(
298 "UpdateConnectivityInfo + GetConnectivityInfo round-trip on the fixture thing",
299 (_stack) =>
300 Effect.gen(function* () {
301 const response = (yield* postJson("/connectivity")) as {
302 hostAddresses: string[];
303 };
304 expect(response.hostAddresses).toContain("127.0.0.1");
305 }),
306 { timeout: 120_000 },
307 );
308 });
309
310 describe("ResolveComponentCandidates", () => {
311 // Per the AWS API reference, ResolveComponentCandidates must be called
312 // through the Greengrass data plane endpoint with an AWS IoT device
313 // certificate; IAM-signed calls are rejected with AccessDeniedException
314 // even with greengrass:ResolveComponentCandidates granted. This ungated
315 // probe proves the binding's request plumbing end-to-end: the call
316 // reaches the service and surfaces the documented typed rejection.
317 test.provider(
318 "IAM-signed resolve surfaces the documented typed rejection",
319 (_stack) =>
320 Effect.gen(function* () {
321 const response = (yield* getJson("/resolve")) as {
322 ok: boolean;
323 names: string[];
324 tag?: string;
325 };
326 expect(response.ok).toBe(false);
327 expect(response.tag).toBe("AccessDeniedException");
328 }),
329 );
330 });
331
332 describe(
333 "consumeGreengrassEvents",
334 { tags: ["provider:aws:eventbridge"] },
335 () => {
336 test.provider(
337 "the deploy created an EventBridge rule targeting the function",
338 (_stack) =>
339 Effect.gen(function* () {
340 // Out-of-band via distilled: the fixture's consumeGreengrassEvents
341 // must have materialized as a rule on the default bus with the
342 // Lambda as target.
343 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
344 TargetArn: functionArn,
345 });
346 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
347 }),
348 );
349 },
350 );
351
352 describe("CancelDeployment", () => {
353 // Runs last: cancels the fixture rollout (the deployment stays ACTIVE
354 // until canceled because no live core device ever picks it up).
355 test.provider(
356 "cancels the bound deployment",
357 (_stack) =>
358 Effect.gen(function* () {
359 // The cancel path exercises freshly attached iot:CancelJob/UpdateJob
360 // grants — retry the attempt while IAM propagation still rejects it.
361 // A prior (retried) attempt may already have canceled the rollout,
362 // so the deployment status below is the authoritative assertion.
363 const response = (yield* postJson("/cancel-deployment").pipe(
364 Effect.repeat({
365 schedule: Schedule.spaced("3 seconds"),
366 until: (r): boolean =>
367 (r as Probe).tag !== "AccessDeniedException",
368 times: 20,
369 }),
370 )) as Probe;
371 expect(response.tag).not.toBe("AccessDeniedException");
372 const detail = (yield* getJson("/deployment").pipe(
373 Effect.repeat({
374 schedule: Schedule.spaced("3 seconds"),
375 until: (d): boolean =>
376 (d as { deploymentStatus: string }).deploymentStatus ===
377 "CANCELED",
378 times: 10,
379 }),
380 )) as { deploymentStatus: string };
381 expect(detail.deploymentStatus).toBe("CANCELED");
382 }),
383 { timeout: 150_000 },
384 );
385 });
386 },
387);