1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as s3 from "@distilled.cloud/aws/s3";
6import { describe, expect } from "alchemy-test";
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 ElbBindingsFunctionLive, {
12 bundleKey,
13 CA_BUNDLE_PEM,
14 ElbBindingsFunction,
15 testTargetIp,
16} from "./fixtures/bindings-handler.ts";
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(testOptions, "ELBv2Bindings");
21
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28
29interface RouteResult {
30 ok: boolean;
31 tag: string;
32 message?: string;
33 targets?: { id?: string; state?: string }[];
34 states?: string[];
35 location?: string;
36}
37
38// Call a fixture route, repeating (bounded) while the response still shows an
39// authorization failure — a freshly attached IAM policy is eventually
40// consistent and the first invocations after deploy can see AccessDenied.
41const callRoute = (method: "GET" | "POST", path: string) =>
42 Effect.gen(function* () {
43 const request =
44 method === "GET"
45 ? HttpClientRequest.get(`${baseUrl}${path}`)
46 : HttpClientRequest.post(`${baseUrl}${path}`);
47 return yield* HttpClient.execute(request).pipe(
48 Effect.flatMap((response) =>
49 response.status === 200
50 ? response.json
51 : Effect.fail(
52 new Error(`Route ${path} not ready: ${response.status}`),
53 ),
54 ),
55 Effect.map((json) => json as unknown as RouteResult),
56 Effect.repeat({
57 until: (body): boolean =>
58 body.tag !== "AccessDenied" && body.tag !== "AccessDeniedException",
59 schedule: Schedule.spaced("3 seconds"),
60 times: 10,
61 }),
62 );
63 });
64
65// Deploys a Lambda bound to an unattached ip target group (RegisterTargets /
66// DeregisterTargets / DescribeTargetHealth) and an internal ALB
67// (Describe/ModifyCapacityReservation), then drives each binding over the
68// function URL against live cloud state.
69describe(
70 "ELBv2 runtime bindings",
71 {
72 tags: [
73 "provider:aws",
74 "provider:aws:ec2",
75 "provider:aws:elbv2",
76 "provider:aws:lambda",
77 "provider:aws:s3",
78 "live",
79 ],
80 },
81 () => {
82 beforeAll(
83 Effect.gen(function* () {
84 yield* sharedStack.destroy();
85
86 // Phase 1 — the trust store's CA bundle must exist in S3 before the
87 // TrustStore resource can be created, and there is no declarative S3
88 // object resource yet: deploy the bucket alone, upload the bundle
89 // out-of-band, then deploy the full fixture (same logical id, same
90 // stack, so phase 2 reconciles the bucket as a no-op).
91 const { bucketName } = yield* sharedStack.deploy(
92 Effect.gen(function* () {
93 const bucket = yield* Bucket("ElbBindingsBundleBucket", {
94 forceDestroy: true,
95 });
96 return { bucketName: bucket.bucketName };
97 }),
98 );
99 // beforeAll runs outside the provider env, so the raw distilled call
100 // needs the AWS layers (credentials/region) provided explicitly.
101 yield* Core.withProviders(
102 s3.putObject({
103 Bucket: bucketName,
104 Key: bundleKey,
105 Body: CA_BUNDLE_PEM,
106 ContentType: "application/x-pem-file",
107 }),
108 testOptions,
109 sharedStack.name,
110 );
111
112 const { fn } = yield* sharedStack.deploy(
113 Effect.gen(function* () {
114 const fn = yield* ElbBindingsFunction;
115 return { fn };
116 }).pipe(Effect.provide(ElbBindingsFunctionLive)),
117 );
118
119 expect(fn.functionUrl).toBeTruthy();
120 baseUrl = fn.functionUrl!.replace(/\/+$/, "");
121
122 yield* HttpClient.get(`${baseUrl}/health`).pipe(
123 Effect.flatMap((response) =>
124 response.status === 200
125 ? Effect.succeed(response)
126 : Effect.fail(
127 new Error(`Function not ready: ${response.status}`),
128 ),
129 ),
130 Effect.retry({ schedule: readinessPolicy }),
131 );
132 }),
133 { timeout: 300_000 },
134 );
135
136 // The ALB delete blocks until the balancer is fully gone (~2-4 min of ENI
137 // release), so give teardown headroom.
138 afterAll(sharedStack.destroy(), { timeout: 400_000 });
139
140 test.provider(
141 "RegisterTargets -> DescribeTargetHealth -> DeregisterTargets round-trip",
142 (_stack) =>
143 Effect.gen(function* () {
144 const registered = yield* callRoute("POST", "/register");
145 // Compare tag AND message so a failure diff surfaces the AWS error.
146 expect({ tag: registered.tag, message: registered.message }).toEqual({
147 tag: "Success",
148 message: undefined,
149 });
150
151 const health = yield* callRoute("GET", "/target-health");
152 expect(health.ok).toBe(true);
153 expect(health.targets?.some((t) => t.id === testTargetIp)).toBe(true);
154
155 const deregistered = yield* callRoute("POST", "/deregister");
156 expect(deregistered.tag).toEqual("Success");
157 }),
158 { timeout: 120_000 },
159 );
160
161 test.provider(
162 "DescribeCapacityReservation reads the ALB's reservation state",
163 (_stack) =>
164 Effect.gen(function* () {
165 const body = yield* callRoute("GET", "/capacity");
166 expect(body.ok).toBe(true);
167 // A fresh ALB has no reservation: the per-AZ state list is empty (or
168 // absent) — the successful call is what proves the read grant.
169 expect(body.tag).toEqual("Success");
170 }),
171 { timeout: 60_000 },
172 );
173
174 test.provider(
175 "ModifyCapacityReservation reset is IAM-wired",
176 (_stack) =>
177 Effect.gen(function* () {
178 const body = yield* callRoute("POST", "/capacity-reset");
179 // Resetting a reservation that was never set is accepted (no-op) or
180 // rejected with a typed configuration error — never an
181 // authorization failure.
182 expect([
183 "Success",
184 "InvalidConfigurationRequestException",
185 "CapacityReservationPendingException",
186 ]).toContain(body.tag);
187 }),
188 { timeout: 60_000 },
189 );
190
191 test.provider(
192 "GetTrustStoreCaCertificatesBundle returns a presigned bundle location",
193 (_stack) =>
194 Effect.gen(function* () {
195 const body = yield* callRoute("GET", "/truststore-bundle");
196 expect(body.tag).toEqual("Success");
197 expect(body.location).toMatch(/^https:\/\//);
198 }),
199 { timeout: 60_000 },
200 );
201
202 test.provider(
203 "GetTrustStoreRevocationContent surfaces the typed not-found tag",
204 (_stack) =>
205 Effect.gen(function* () {
206 const body = yield* callRoute(
207 "GET",
208 "/truststore-revocation-missing",
209 );
210 // The fixture queries a revocation id that was never added — the miss
211 // must surface as the typed tag (never an authorization or catch-all
212 // failure), proving both the IAM grant and the typed error path.
213 expect(body.ok).toBe(false);
214 expect(body.tag).toEqual("RevocationIdNotFoundException");
215 }),
216 { timeout: 60_000 },
217 );
218 },
219);