1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import Route53ResolverBindingsFunctionLive, {
11 Route53ResolverBindingsFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "Route53ResolverBindings");
17
18// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
19// over 60s on a fresh deploy.
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
28 readonly status: number;
29 readonly body: string;
30}> {}
31
32// The shared Lambda fixture occasionally answers a transient 5xx under load
33// (cold re-init, IAM propagation on the freshly attached policy). Retry only
34// 5xx; a genuine 4xx/assertion failure surfaces 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) => 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) =>
63 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
64 Effect.flatMap((r) => r.json),
65 );
66
67describe.sequential(
68 "Route53Resolver Bindings",
69 {
70 tags: [
71 "provider:aws",
72 "provider:aws:ec2",
73 "provider:aws:lambda",
74 "provider:aws:route53resolver",
75 "live",
76 ],
77 },
78 () => {
79 beforeAll(
80 Effect.gen(function* () {
81 yield* Effect.logInfo(
82 "Route53Resolver test setup: destroying previous resources",
83 );
84 yield* sharedStack.destroy();
85
86 yield* Effect.logInfo("Route53Resolver test setup: deploying fixture");
87 const attrs = yield* sharedStack.deploy(
88 Effect.gen(function* () {
89 return yield* Route53ResolverBindingsFunction;
90 }).pipe(Effect.provide(Route53ResolverBindingsFunctionLive)),
91 );
92
93 expect(attrs.functionUrl).toBeTruthy();
94 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `Route53Resolver test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `Route53Resolver test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 420_000 },
117 );
118
119 afterAll(process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(), {
120 timeout: 420_000,
121 });
122
123 describe("binding registration", () => {
124 test.provider("all 5 capabilities initialize in the runtime", (_stack) =>
125 Effect.gen(function* () {
126 const response = (yield* getJson("/bindings")) as { bound: string[] };
127 expect(response.bound).toHaveLength(5);
128 expect(response.bound).toContain("getResolverEndpoint");
129 expect(response.bound).toContain("listResolverEndpointIpAddresses");
130 expect(response.bound).toContain("getResolverRule");
131 expect(response.bound).toContain("updateResolverRule");
132 expect(response.bound).toContain("listResolverRuleAssociations");
133 }),
134 );
135 });
136
137 describe("GetResolverEndpoint", () => {
138 test.provider(
139 "reads the bound endpoint's live state (ID injected)",
140 (_stack) =>
141 Effect.gen(function* () {
142 const response = (yield* getJson("/endpoint")) as {
143 status: string;
144 direction: string;
145 ipAddressCount: number;
146 };
147 expect(response.direction).toBe("OUTBOUND");
148 expect(response.status).toBe("OPERATIONAL");
149 expect(response.ipAddressCount).toBe(2);
150 }),
151 );
152 });
153
154 describe("ListResolverEndpointIpAddresses", () => {
155 test.provider("discovers the endpoint's attached IPs", (_stack) =>
156 Effect.gen(function* () {
157 const response = (yield* getJson("/endpoint/ips")) as {
158 ips: { ip: string; status: string }[];
159 };
160 expect(response.ips).toHaveLength(2);
161 for (const entry of response.ips) {
162 expect(entry.ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
163 expect(entry.status).toBe("ATTACHED");
164 }
165 }),
166 );
167 });
168
169 describe("GetResolverRule", () => {
170 test.provider("reads the bound rule's targets (ID injected)", (_stack) =>
171 Effect.gen(function* () {
172 const response = (yield* getJson("/rule")) as {
173 domainName: string;
174 ruleType: string;
175 targetIps: string[];
176 };
177 expect(response.domainName.replace(/\.$/, "")).toBe(
178 "bindings.alchemy-r53r-test.internal",
179 );
180 expect(response.ruleType).toBe("FORWARD");
181 expect(response.targetIps).toEqual(["10.100.0.10"]);
182 }),
183 );
184 });
185
186 describe("UpdateResolverRule", () => {
187 test.provider(
188 "fails the rule over to a new target IP at runtime",
189 (_stack) =>
190 Effect.gen(function* () {
191 const response = (yield* postJson(
192 "/rule/target?ip=10.100.0.11",
193 )) as {
194 status: string;
195 targetIps: string[];
196 };
197 expect(response.targetIps).toEqual(["10.100.0.11"]);
198
199 // The update settles asynchronously — poll the rule-scoped read
200 // (bounded) until the new target is observed.
201 const updated = yield* getJson("/rule").pipe(
202 Effect.map((r) => r as { targetIps: string[] }),
203 Effect.repeat({
204 schedule: Schedule.fixed("3 seconds"),
205 until: (r): boolean => r.targetIps.includes("10.100.0.11"),
206 times: 10,
207 }),
208 );
209 expect(updated.targetIps).toEqual(["10.100.0.11"]);
210 }),
211 { timeout: 120_000 },
212 );
213 });
214
215 describe("ListResolverRuleAssociations", () => {
216 test.provider(
217 "lists the bound rule's VPC associations (filter injected)",
218 (_stack) =>
219 Effect.gen(function* () {
220 const response = (yield* getJson("/rule/associations")) as {
221 count: number;
222 };
223 // The fixture never associates the rule with a VPC — an empty,
224 // successfully-authorized listing proves the wiring and the
225 // rule-scoped IAM grant.
226 expect(response.count).toBe(0);
227 }),
228 );
229 });
230 },
231);