1import * as AWS from "@/AWS";
2import * as Lambda from "@/AWS/Lambda";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
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 LatticeTestFunctionLive, { LatticeTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "VpcLatticeBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let functionArn: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// Retry only 5xx (cold re-init, IAM propagation surfaced by the handler's
33// `Effect.orDie` as a 500); a genuine 4xx/assertion failure surfaces
34// immediately.
35const send = (request: HttpClientRequest.HttpClientRequest) =>
36 HttpClient.execute(request).pipe(
37 Effect.flatMap((response) =>
38 response.status >= 500
39 ? response.text.pipe(
40 Effect.flatMap((body) =>
41 Effect.fail(
42 new TransientUpstream({ status: response.status, body }),
43 ),
44 ),
45 )
46 : Effect.succeed(response),
47 ),
48 Effect.retry({
49 while: (e): boolean => e._tag === "TransientUpstream",
50 schedule: Schedule.max([
51 Schedule.exponential("500 millis"),
52 Schedule.recurs(6),
53 ]),
54 }),
55 );
56
57const getJson = (path: string) =>
58 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
59 Effect.flatMap((r) => r.json),
60 );
61
62const postJson = (path: string, body: object) =>
63 send(
64 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
65 HttpClientRequest.bodyJsonUnsafe(body),
66 ),
67 ).pipe(Effect.flatMap((r) => r.json));
68
69describe.sequential(
70 "VpcLattice Bindings",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:lambda",
75 "provider:aws:vpclattice",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* Effect.logInfo(
83 "VpcLattice bindings setup: destroying previous resources",
84 );
85 yield* sharedStack.destroy();
86
87 yield* Effect.logInfo("VpcLattice bindings setup: deploying fixture");
88 const attrs = yield* sharedStack.deploy(
89 Effect.gen(function* () {
90 const fn = yield* LatticeTestFunction;
91 // Registering a Lambda target requires the function to allow
92 // vpc-lattice.amazonaws.com to invoke it — RegisterTargets rejects
93 // the target as `InvalidTarget` otherwise.
94 yield* Lambda.Permission("LatticeInvokePermission", {
95 action: "lambda:InvokeFunction",
96 functionName: fn.functionName,
97 principal: "vpc-lattice.amazonaws.com",
98 });
99 return fn;
100 }).pipe(Effect.provide(LatticeTestFunctionLive)),
101 );
102
103 expect(attrs.functionUrl).toBeTruthy();
104 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
105 functionArn = attrs.functionArn;
106
107 const readinessUrl = `${baseUrl}/bindings`;
108 yield* Effect.logInfo(
109 `VpcLattice bindings setup: probing readiness at ${readinessUrl}`,
110 );
111 yield* HttpClient.get(readinessUrl).pipe(
112 Effect.flatMap((response) =>
113 response.status === 200
114 ? Effect.succeed(response)
115 : Effect.fail(
116 new Error(`Function not ready: ${response.status}`),
117 ),
118 ),
119 Effect.tapError((error) =>
120 Effect.logWarning(
121 `VpcLattice bindings setup: fixture not ready yet (${String(error)})`,
122 ),
123 ),
124 Effect.retry({ schedule: readinessPolicy }),
125 );
126 }),
127 { timeout: 240_000 },
128 );
129
130 afterAll(sharedStack.destroy(), { timeout: 240_000 });
131
132 describe("binding registration", () => {
133 test.provider("the capabilities initialize in the runtime", (_stack) =>
134 Effect.gen(function* () {
135 const response = (yield* getJson("/bindings")) as { bound: string[] };
136 expect(response.bound).toEqual([
137 "deregisterTargets",
138 "listTargets",
139 "registerTargets",
140 ]);
141 }),
142 );
143 });
144
145 describe("ListTargets", () => {
146 test.provider(
147 "lists an empty target group (injected target group id)",
148 (_stack) =>
149 Effect.gen(function* () {
150 const response = (yield* getJson("/targets")) as {
151 targets: { id: string }[];
152 };
153 expect(response.targets).toEqual([]);
154 }),
155 );
156 });
157
158 describe("RegisterTargets / DeregisterTargets", () => {
159 test.provider(
160 "the function registers and deregisters itself as a Lambda target",
161 (_stack) =>
162 Effect.gen(function* () {
163 // Register the fixture's own function ARN into the LAMBDA group.
164 const registered = (yield* postJson("/register", {
165 id: functionArn,
166 })) as { successful: string[]; unsuccessful: unknown[] };
167 expect(registered.unsuccessful).toEqual([]);
168 expect(registered.successful).toEqual([functionArn]);
169
170 // The registration is observable through the list binding.
171 const listed = (yield* getJson("/targets")) as {
172 targets: { id: string }[];
173 };
174 expect(listed.targets.map((t) => t.id)).toEqual([functionArn]);
175
176 // Deregister so the shared stack destroy is drain-free.
177 const deregistered = (yield* postJson("/deregister", {
178 id: functionArn,
179 })) as { successful: string[]; unsuccessful: unknown[] };
180 expect(deregistered.unsuccessful).toEqual([]);
181 expect(deregistered.successful).toEqual([functionArn]);
182 }),
183 { timeout: 120_000 },
184 );
185 });
186 },
187);