1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as EC2 from "@distilled.cloud/aws/ec2";
5import { Region } from "@distilled.cloud/aws/Region";
6import * as sts from "@distilled.cloud/aws/sts";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import { getDefaultVpc } from "../DefaultVpc.ts";
12
13import DocDBTestFunctionLive, { DocDBTestFunction } from "./handler";
14import DocDBSlowTestFunctionLive, {
15 DocDBSlowTestFunction,
16} from "./slow-handler";
17
18const testOptions = { providers: AWS.providers() };
19const { test, beforeAll, afterAll } = Test.make(testOptions);
20const sharedStack = Core.scratchStack(testOptions, "DocDBBindings");
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;
30
31const getJson = (path: string) =>
32 HttpClient.get(`${baseUrl}${path}`).pipe(
33 Effect.flatMap((response) =>
34 response.status >= 500
35 ? Effect.fail(new Error(`transient upstream ${response.status}`))
36 : Effect.succeed(response),
37 ),
38 Effect.retry({
39 schedule: Schedule.max([
40 Schedule.exponential("500 millis"),
41 Schedule.recurs(6),
42 ]),
43 }),
44 Effect.flatMap((r) => r.json),
45 );
46
47describe.sequential(
48 "DocDB Bindings",
49 {
50 tags: ["provider:aws", "provider:aws:docdb", "provider:aws:lambda", "live"],
51 },
52 () => {
53 beforeAll(
54 Effect.gen(function* () {
55 yield* Effect.logInfo(
56 "DocDB test setup: destroying previous resources",
57 );
58 yield* sharedStack.destroy();
59
60 yield* Effect.logInfo("DocDB test setup: deploying fixture");
61 const { functionUrl } = yield* sharedStack.deploy(
62 Effect.gen(function* () {
63 return yield* DocDBTestFunction;
64 }).pipe(Effect.provide(DocDBTestFunctionLive)),
65 );
66
67 expect(functionUrl).toBeTruthy();
68 baseUrl = functionUrl!.replace(/\/+$/, "");
69
70 const readinessUrl = `${baseUrl}/bindings`;
71 yield* HttpClient.get(readinessUrl).pipe(
72 Effect.flatMap((response) =>
73 response.status === 200
74 ? Effect.succeed(response)
75 : Effect.fail(
76 new Error(`Function not ready: ${response.status}`),
77 ),
78 ),
79 Effect.retry({ schedule: readinessPolicy }),
80 );
81 }),
82 { timeout: 240_000 },
83 );
84
85 afterAll(sharedStack.destroy(), { timeout: 120_000 });
86
87 describe("binding registration", () => {
88 test.provider("all capabilities initialize in the runtime", (_stack) =>
89 Effect.gen(function* () {
90 const response = yield* getJson("/bindings");
91 expect((response as any).bound).toHaveLength(8);
92 }),
93 );
94 });
95
96 describe("DescribeDBClusters", () => {
97 test.provider(
98 "surfaces the typed not-found tag for a nonexistent cluster",
99 (_stack) =>
100 Effect.gen(function* () {
101 const response = yield* getJson("/clusters");
102 expect((response as any).tag).toBe("DBClusterNotFoundFault");
103 }),
104 );
105 });
106
107 describe("DescribeEvents", () => {
108 test.provider("lists the account's recent DocumentDB events", (_stack) =>
109 Effect.gen(function* () {
110 const response = yield* getJson("/events");
111 expect((response as any).count).toBeGreaterThanOrEqual(0);
112 }),
113 );
114 });
115
116 describe("DescribeDBInstances", () => {
117 test.provider(
118 "surfaces the typed not-found tag for a nonexistent instance",
119 (_stack) =>
120 Effect.gen(function* () {
121 const response = yield* getJson("/instances");
122 expect((response as any).tag).toBe("DBInstanceNotFoundFault");
123 }),
124 );
125 });
126
127 describe("DescribeDBClusterSnapshots", () => {
128 test.provider("lists the account's cluster snapshots", (_stack) =>
129 Effect.gen(function* () {
130 const response = yield* getJson("/snapshots");
131 expect((response as any).count).toBeGreaterThanOrEqual(0);
132 }),
133 );
134 });
135
136 describe("DeleteDBClusterSnapshot", () => {
137 test.provider(
138 "surfaces the typed not-found tag for a nonexistent snapshot",
139 (_stack) =>
140 Effect.gen(function* () {
141 const response = yield* getJson("/delete-snapshot-probe");
142 expect((response as any).tag).toBe(
143 "DBClusterSnapshotNotFoundFault",
144 );
145 }),
146 );
147 });
148
149 describe("CopyDBClusterSnapshot", () => {
150 test.provider(
151 "surfaces the typed not-found tag for a nonexistent source snapshot",
152 (_stack) =>
153 Effect.gen(function* () {
154 const response = yield* getJson("/copy-snapshot-probe");
155 expect((response as any).tag).toBe(
156 "DBClusterSnapshotNotFoundFault",
157 );
158 }),
159 );
160 });
161
162 describe("DescribePendingMaintenanceActions", () => {
163 test.provider(
164 "lists the account's pending maintenance actions",
165 (_stack) =>
166 Effect.gen(function* () {
167 const response = yield* getJson("/pending-maintenance");
168 expect((response as any).count).toBeGreaterThanOrEqual(0);
169 }),
170 );
171 });
172
173 describe("ApplyPendingMaintenanceAction", () => {
174 test.provider(
175 "surfaces the typed not-found tag for a nonexistent resource ARN",
176 (_stack) =>
177 Effect.gen(function* () {
178 // Build a well-formed cluster ARN in this account/region that
179 // cannot exist — the apply call must decode ResourceNotFoundFault.
180 // (`Region`'s service value is itself an Effect — resolve twice.)
181 const region = yield* yield* Region;
182 const identity = yield* sts.getCallerIdentity({});
183 const arn = `arn:aws:rds:${region}:${identity.Account}:cluster:alchemy-nonexistent-docdb-probe`;
184 const response = yield* getJson(
185 `/apply-probe?arn=${encodeURIComponent(arn)}`,
186 );
187 expect((response as any).tag).toBe("ResourceNotFoundFault");
188 }),
189 );
190 });
191 },
192);
193
194// ---------------------------------------------------------------------------
195// Data-plane binding (Connect + mongo) needs a real DocumentDB cluster +
196// instance (~15 min to provision, billed per instance-hour) and a Secrets
197// Manager VPC interface endpoint (the VPC-attached Lambda has no internet
198// path) — gated behind AWS_TEST_SLOW=1 like the DBCluster lifecycle test.
199// ---------------------------------------------------------------------------
200
201const slowStack = Core.scratchStack(testOptions, "DocDBSlowBindings");
202
203// Resolve two default-for-AZ subnets and the default security group. The
204// default security group allows all traffic from itself, so the Lambda, the
205// cluster, and the endpoint can all share it.
206const defaultNetwork = Effect.gen(function* () {
207 const vpc = yield* getDefaultVpc;
208 const subnets = yield* EC2.describeSubnets({
209 Filters: [
210 { Name: "vpc-id", Values: [vpc.vpcId] },
211 { Name: "default-for-az", Values: ["true"] },
212 ],
213 });
214 const subnetIds = (subnets.Subnets ?? [])
215 .map((s) => s.SubnetId)
216 .filter((id): id is `subnet-${string}` => id !== undefined)
217 .sort()
218 .slice(0, 2);
219 const groups = yield* EC2.describeSecurityGroups({
220 Filters: [
221 { Name: "vpc-id", Values: [vpc.vpcId] },
222 { Name: "group-name", Values: ["default"] },
223 ],
224 });
225 const securityGroupId = groups.SecurityGroups?.[0]?.GroupId;
226 if (subnetIds.length < 2 || securityGroupId === undefined) {
227 return yield* Effect.die(
228 new Error("default VPC is missing subnets or its default security group"),
229 );
230 }
231 return { vpcId: vpc.vpcId, subnetIds, securityGroupIds: [securityGroupId] };
232});
233
234// The VPC-attached Lambda resolves the master secret through a Secrets
235// Manager interface endpoint. Managed out-of-band (create-if-missing, keep
236// across runs — recreating interface endpoints costs ~2 min per run).
237const ensureSecretsManagerEndpoint = (network: {
238 vpcId: string;
239 subnetIds: string[];
240 securityGroupIds: string[];
241}) =>
242 Effect.gen(function* () {
243 // `Region`'s service value is itself an Effect — resolve twice.
244 const region = yield* yield* Region;
245 const serviceName = `com.amazonaws.${region}.secretsmanager`;
246 const existing = yield* EC2.describeVpcEndpoints({
247 Filters: [
248 { Name: "vpc-id", Values: [network.vpcId] },
249 { Name: "service-name", Values: [serviceName] },
250 ],
251 });
252 if ((existing.VpcEndpoints ?? []).length > 0) return;
253 yield* EC2.createVpcEndpoint({
254 VpcId: network.vpcId as `vpc-${string}`,
255 ServiceName: serviceName,
256 VpcEndpointType: "Interface",
257 SubnetIds: network.subnetIds as `subnet-${string}`[],
258 SecurityGroupIds: network.securityGroupIds as `sg-${string}`[],
259 PrivateDnsEnabled: true,
260 TagSpecifications: [
261 {
262 ResourceType: "vpc-endpoint",
263 Tags: [{ Key: "alchemy-test", Value: "docdb-bindings" }],
264 },
265 ],
266 });
267 // Bounded readiness wait for the endpoint's private DNS to serve.
268 yield* EC2.describeVpcEndpoints({
269 Filters: [
270 { Name: "vpc-id", Values: [network.vpcId] },
271 { Name: "service-name", Values: [serviceName] },
272 ],
273 }).pipe(
274 Effect.flatMap((r) =>
275 r.VpcEndpoints?.[0]?.State === "available"
276 ? Effect.void
277 : Effect.fail(new Error("secretsmanager endpoint not available")),
278 ),
279 Effect.retry({
280 schedule: Schedule.max([
281 Schedule.fixed("10 seconds"),
282 Schedule.recurs(24),
283 ]),
284 }),
285 );
286 });
287
288test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
289 "connect + mongo data plane against a live cluster",
290 () =>
291 Effect.gen(function* () {
292 yield* slowStack.destroy();
293 const network = yield* defaultNetwork;
294 yield* ensureSecretsManagerEndpoint(network);
295
296 // The fixture's init Effect runs in this process — pass the resolved
297 // network through env (the module cannot look it up itself).
298 yield* Effect.sync(() => {
299 process.env.DOCDB_TEST_SUBNET_IDS = network.subnetIds.join(",");
300 process.env.DOCDB_TEST_SG_IDS = network.securityGroupIds.join(",");
301 });
302
303 yield* Effect.gen(function* () {
304 const { functionUrl } = yield* slowStack.deploy(
305 Effect.gen(function* () {
306 return yield* DocDBSlowTestFunction;
307 }).pipe(Effect.provide(DocDBSlowTestFunctionLive)),
308 );
309 expect(functionUrl).toBeTruthy();
310 const slowBaseUrl = functionUrl!.replace(/\/+$/, "");
311
312 const get = (path: string) =>
313 HttpClient.get(`${slowBaseUrl}${path}`).pipe(
314 Effect.retry({
315 schedule: Schedule.max([
316 Schedule.exponential("1 second"),
317 Schedule.recurs(10),
318 ]),
319 }),
320 Effect.flatMap((r) => r.json),
321 );
322
323 // Connection info resolves from the runtime attributes, the secret,
324 // and the published environment variables.
325 const info = (yield* get("/info")) as {
326 host: string;
327 port: number;
328 database?: string;
329 username?: string;
330 tls: boolean;
331 hasPassword: boolean;
332 env: { host?: string; port?: string };
333 };
334 expect(info.host).toContain("docdb");
335 expect(info.port).toBe(27017);
336 expect(info.database).toBe("alchemy_test");
337 expect(info.username).toBe("alchemy");
338 expect(info.tls).toBe(true);
339 expect(info.hasPassword).toBe(true);
340 expect(info.env.host).toBe(info.host);
341 expect(info.env.port).toBe(String(info.port));
342
343 // The mongo Effect client opens a TLS socket, authenticates via the
344 // managed master secret, and round-trips a document.
345 const ping = (yield* get("/ping")) as { ok: number };
346 expect(ping.ok).toBe(1);
347
348 const crud = (yield* get("/crud?value=alchemy-docdb")) as {
349 marker?: string;
350 };
351 expect(crud.marker).toBe("alchemy-docdb");
352 }).pipe(Effect.ensuring(slowStack.destroy().pipe(Effect.orDie)));
353 }),
354 // cluster + instance create (~15 min) + probes + delete initiation.
355 {
356 tags: [
357 "provider:aws",
358 "provider:aws:docdb",
359 "provider:aws:ec2",
360 "provider:aws:lambda",
361 "live",
362 ],
363 timeout: 2_400_000,
364 },
365);