1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment";
3import * as Core from "@/Test/Core";
4import * as Test from "@/Test/Alchemy";
5import * as docdbelastic from "@distilled.cloud/aws/docdb-elastic";
6import * as EC2 from "@distilled.cloud/aws/ec2";
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 DocDBElasticTestFunctionLive, {
14 DocDBElasticTestFunction,
15} from "./handler";
16import DocDBElasticSlowTestFunctionLive, {
17 DocDBElasticSlowTestFunction,
18 SECURITY_GROUPS_ENV,
19 SUBNETS_ENV,
20} from "./slow-handler";
21
22const testOptions = { providers: AWS.providers() };
23const { test, beforeAll, afterAll } = Test.make(testOptions);
24const sharedStack = Core.scratchStack(testOptions, "DocDBElasticBindings");
25
26const NONEXISTENT_UUID = "00000000-0000-0000-0000-000000000000";
27
28// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
29// over 60s on a fresh deploy.
30const readinessPolicy = Schedule.max([
31 Schedule.fixed("2 seconds"),
32 Schedule.recurs(75),
33]);
34
35let baseUrl: string;
36
37// Well-formed-but-nonexistent ARNs the probe routes are driven against.
38// Computed inside test bodies (test.provider provides AWSEnvironment).
39const probeArns = Effect.gen(function* () {
40 const { accountId, region } = yield* AWSEnvironment.current;
41 return {
42 snapshot: `arn:aws:docdb-elastic:${region}:${accountId}:cluster-snapshot/${NONEXISTENT_UUID}`,
43 cluster: `arn:aws:docdb-elastic:${region}:${accountId}:cluster/${NONEXISTENT_UUID}`,
44 };
45});
46
47const getJson = (path: string) =>
48 HttpClient.get(`${baseUrl}${path}`).pipe(
49 Effect.flatMap((response) =>
50 response.status >= 500
51 ? Effect.fail(new Error(`transient upstream ${response.status}`))
52 : Effect.succeed(response),
53 ),
54 Effect.retry({
55 schedule: Schedule.max([
56 Schedule.exponential("500 millis"),
57 Schedule.recurs(6),
58 ]),
59 }),
60 Effect.flatMap((r) => r.json),
61 );
62
63describe.sequential(
64 "DocDBElastic Bindings",
65 {
66 tags: [
67 "provider:aws",
68 "provider:aws:docdbelastic",
69 "provider:aws:lambda",
70 "live",
71 ],
72 },
73 () => {
74 beforeAll(
75 Effect.gen(function* () {
76 yield* Effect.logInfo(
77 "DocDBElastic test setup: destroying previous resources",
78 );
79 yield* sharedStack.destroy();
80
81 yield* Effect.logInfo("DocDBElastic test setup: deploying fixture");
82 const { functionUrl } = yield* sharedStack.deploy(
83 Effect.gen(function* () {
84 return yield* DocDBElasticTestFunction;
85 }).pipe(Effect.provide(DocDBElasticTestFunctionLive)),
86 );
87
88 expect(functionUrl).toBeTruthy();
89 baseUrl = functionUrl!.replace(/\/+$/, "");
90
91 const readinessUrl = `${baseUrl}/bindings`;
92 yield* HttpClient.get(readinessUrl).pipe(
93 Effect.flatMap((response) =>
94 response.status === 200
95 ? Effect.succeed(response)
96 : Effect.fail(
97 new Error(`Function not ready: ${response.status}`),
98 ),
99 ),
100 Effect.retry({ schedule: readinessPolicy }),
101 );
102 }),
103 { timeout: 240_000 },
104 );
105
106 afterAll(sharedStack.destroy(), { timeout: 120_000 });
107
108 describe("binding registration", () => {
109 test.provider("all eight capabilities initialize in the runtime", () =>
110 Effect.gen(function* () {
111 const response = yield* getJson("/bindings");
112 expect((response as any).bound).toHaveLength(8);
113 }),
114 );
115 });
116
117 describe("ListClusterSnapshots", () => {
118 test.provider("lists the account's snapshots", () =>
119 Effect.gen(function* () {
120 const response = yield* getJson("/snapshots");
121 expect((response as any).count).toBeGreaterThanOrEqual(0);
122 }),
123 );
124 });
125
126 describe("ListPendingMaintenanceActions", () => {
127 test.provider("lists the account's pending maintenance", () =>
128 Effect.gen(function* () {
129 const response = yield* getJson("/maintenance");
130 expect((response as any).count).toBeGreaterThanOrEqual(0);
131 }),
132 );
133 });
134
135 describe("GetClusterSnapshot", () => {
136 test.provider("surfaces the typed not-found tag", () =>
137 Effect.gen(function* () {
138 const arns = yield* probeArns;
139 const response = yield* getJson(
140 `/snapshot-probe?arn=${encodeURIComponent(arns.snapshot)}`,
141 );
142 expect((response as any).tag).toBe("ResourceNotFoundException");
143 }),
144 );
145 });
146
147 describe("DeleteClusterSnapshot", () => {
148 test.provider("surfaces the typed not-found tag", () =>
149 Effect.gen(function* () {
150 const arns = yield* probeArns;
151 const response = yield* getJson(
152 `/delete-probe?arn=${encodeURIComponent(arns.snapshot)}`,
153 );
154 expect((response as any).tag).toBe("ResourceNotFoundException");
155 }),
156 );
157 });
158
159 describe("CopyClusterSnapshot", () => {
160 test.provider(
161 "rejects a nonexistent source snapshot with a typed tag",
162 () =>
163 Effect.gen(function* () {
164 const arns = yield* probeArns;
165 const response = yield* getJson(
166 `/copy-probe?arn=${encodeURIComponent(arns.snapshot)}`,
167 );
168 // Copy authorizes against the source-snapshot resource; a
169 // nonexistent snapshot is reported as AccessDeniedException
170 // (existence non-disclosure) rather than not-found.
171 expect([
172 "ResourceNotFoundException",
173 "ValidationException",
174 "AccessDeniedException",
175 ]).toContain((response as any).tag);
176 }),
177 );
178 });
179
180 describe("RestoreClusterFromSnapshot", () => {
181 test.provider(
182 "rejects a nonexistent source snapshot with a typed tag",
183 () =>
184 Effect.gen(function* () {
185 const arns = yield* probeArns;
186 const response = yield* getJson(
187 `/restore-probe?arn=${encodeURIComponent(arns.snapshot)}`,
188 );
189 // Restore authorizes against the source-snapshot resource; a
190 // nonexistent snapshot is reported as AccessDeniedException
191 // (existence non-disclosure) rather than not-found.
192 expect([
193 "ResourceNotFoundException",
194 "ValidationException",
195 "AccessDeniedException",
196 ]).toContain((response as any).tag);
197 }),
198 );
199 });
200
201 describe("GetPendingMaintenanceAction", () => {
202 test.provider("surfaces a typed tag for a nonexistent cluster", () =>
203 Effect.gen(function* () {
204 const arns = yield* probeArns;
205 const response = yield* getJson(
206 `/pending-probe?arn=${encodeURIComponent(arns.cluster)}`,
207 );
208 expect([
209 "ResourceNotFoundException",
210 "ValidationException",
211 ]).toContain((response as any).tag);
212 }),
213 );
214 });
215
216 describe("ApplyPendingMaintenanceAction", () => {
217 test.provider("surfaces a typed tag for a nonexistent cluster", () =>
218 Effect.gen(function* () {
219 const arns = yield* probeArns;
220 const response = yield* getJson(
221 `/apply-probe?arn=${encodeURIComponent(arns.cluster)}`,
222 );
223 expect([
224 "ResourceNotFoundException",
225 "ValidationException",
226 ]).toContain((response as any).tag);
227 }),
228 );
229 });
230 },
231);
232
233// ---------------------------------------------------------------------------
234// Cluster-scoped bindings (CreateClusterSnapshot, StopCluster, StartCluster)
235// need a real elastic cluster (~10 min to provision, billed per
236// shard-vCPU-hour) — gated behind AWS_TEST_SLOW=1 like the Cluster lifecycle
237// test.
238// ---------------------------------------------------------------------------
239
240const slowStack = Core.scratchStack(testOptions, "DocDBElasticSlowBindings");
241
242const SLOW_SNAPSHOT_NAME = "alchemy-docdb-elastic-bindings-snap";
243
244// Resolve two default-for-AZ subnets from the region's first three AZs
245// (elastic clusters are not offered in every AZ, e.g. us-west-2d) and the
246// default security group — same selection as Cluster.test.ts.
247const defaultNetwork = Effect.gen(function* () {
248 const vpc = yield* getDefaultVpc;
249 const subnets = yield* EC2.describeSubnets({
250 Filters: [
251 { Name: "vpc-id", Values: [vpc.vpcId] },
252 { Name: "default-for-az", Values: ["true"] },
253 ],
254 });
255 const subnetIds = (subnets.Subnets ?? [])
256 .filter((s) => /[abc]$/.test(s.AvailabilityZone ?? ""))
257 .sort((l, r) =>
258 (l.AvailabilityZone ?? "").localeCompare(r.AvailabilityZone ?? ""),
259 )
260 .map((s) => s.SubnetId)
261 .filter((id): id is `subnet-${string}` => id !== undefined)
262 .slice(0, 2);
263 const groups = yield* EC2.describeSecurityGroups({
264 Filters: [
265 { Name: "vpc-id", Values: [vpc.vpcId] },
266 { Name: "group-name", Values: ["default"] },
267 ],
268 });
269 const securityGroupId = groups.SecurityGroups?.[0]?.GroupId;
270 if (subnetIds.length < 2 || securityGroupId === undefined) {
271 return yield* Effect.die(
272 new Error("default VPC is missing subnets or its default security group"),
273 );
274 }
275 return { subnetIds, securityGroupIds: [securityGroupId] };
276});
277
278// The snapshot the gated test creates is cleaned up out-of-band; deletion of
279// a snapshot still CREATING conflicts, so retry (bounded) while it settles.
280const deleteSnapshot = (snapshotArn: string) =>
281 docdbelastic.deleteClusterSnapshot({ snapshotArn }).pipe(
282 Effect.asVoid,
283 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
284 Effect.retry({
285 while: (e): boolean => e._tag === "ConflictException",
286 schedule: Schedule.max([
287 Schedule.fixed("15 seconds"),
288 Schedule.recurs(40),
289 ]),
290 }),
291 );
292
293test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
294 "snapshot + stop + start bindings against a live elastic cluster",
295 () =>
296 Effect.gen(function* () {
297 yield* slowStack.destroy();
298 const network = yield* defaultNetwork;
299 yield* Effect.sync(() => {
300 process.env[SUBNETS_ENV] = JSON.stringify(network.subnetIds);
301 process.env[SECURITY_GROUPS_ENV] = JSON.stringify(
302 network.securityGroupIds,
303 );
304 });
305
306 let snapshotArn: string | undefined;
307
308 yield* Effect.gen(function* () {
309 const { functionUrl } = yield* slowStack.deploy(
310 Effect.gen(function* () {
311 return yield* DocDBElasticSlowTestFunction;
312 }).pipe(Effect.provide(DocDBElasticSlowTestFunctionLive)),
313 );
314 expect(functionUrl).toBeTruthy();
315 const slowBaseUrl = functionUrl!.replace(/\/+$/, "");
316
317 const get = (path: string) =>
318 HttpClient.get(`${slowBaseUrl}${path}`).pipe(
319 Effect.retry({
320 schedule: Schedule.max([
321 Schedule.exponential("500 millis"),
322 Schedule.recurs(10),
323 ]),
324 }),
325 Effect.flatMap((r) => r.json),
326 );
327
328 // Take an on-demand snapshot of the live cluster.
329 const snapshot = (yield* get(
330 `/snapshot?name=${SLOW_SNAPSHOT_NAME}`,
331 )) as { snapshotArn: string; status: string };
332 expect(snapshot.snapshotArn).toContain(":cluster-snapshot/");
333 snapshotArn = snapshot.snapshotArn;
334
335 // Wait (bounded) for the snapshot to become AVAILABLE so the stop
336 // call below does not conflict with the in-flight snapshot.
337 yield* docdbelastic
338 .getClusterSnapshot({ snapshotArn: snapshot.snapshotArn })
339 .pipe(
340 Effect.map((r) => r.snapshot.status),
341 Effect.repeat({
342 schedule: Schedule.spaced("10 seconds"),
343 until: (status): boolean => status === "AVAILABLE",
344 times: 60,
345 }),
346 );
347
348 // Stop the cluster (compute billing pauses).
349 const stop = (yield* get("/stop")) as { status: string };
350 expect(["STOPPING", "STOPPED"]).toContain(stop.status);
351
352 // Starting while STOPPING surfaces a typed rejection; a fully
353 // stopped cluster reports STARTING. Both prove the grant.
354 const start = (yield* get("/start")) as { status: string };
355 expect(["STARTING", "ValidationException"]).toContain(start.status);
356 }).pipe(
357 Effect.ensuring(
358 Effect.gen(function* () {
359 yield* slowStack.destroy();
360 if (snapshotArn !== undefined) {
361 yield* deleteSnapshot(snapshotArn);
362 }
363 }).pipe(Effect.orDie),
364 ),
365 );
366 }),
367 // cluster create (~10 min) + snapshot + stop + destroy in one test.
368 {
369 tags: [
370 "provider:aws",
371 "provider:aws:docdbelastic",
372 "provider:aws:ec2",
373 "provider:aws:lambda",
374 "live",
375 ],
376 timeout: 2_400_000,
377 },
378);