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 CloudControlTestFunctionLive, {
11 CloudControlTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "CloudControlBindings");
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 that the
34// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
35// 4xx/assertion failure surfaces immediately.
36const send = (request: HttpClientRequest.HttpClientRequest) =>
37 HttpClient.execute(request).pipe(
38 Effect.flatMap((response) =>
39 response.status >= 500
40 ? response.text.pipe(
41 Effect.flatMap((body) =>
42 Effect.fail(
43 new TransientUpstream({ status: response.status, body }),
44 ),
45 ),
46 )
47 : Effect.succeed(response),
48 ),
49 Effect.retry({
50 while: (e) => e._tag === "TransientUpstream",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(6),
54 ]),
55 }),
56 );
57
58describe.sequential(
59 "CloudControl Bindings",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:cloudcontrol",
64 "provider:aws:lambda",
65 "live",
66 ],
67 },
68 () => {
69 beforeAll(
70 Effect.gen(function* () {
71 yield* Effect.logInfo(
72 "CloudControl test setup: destroying previous resources",
73 );
74 yield* sharedStack.destroy();
75
76 yield* Effect.logInfo("CloudControl test setup: deploying fixture");
77 const { functionUrl } = yield* sharedStack.deploy(
78 Effect.gen(function* () {
79 return yield* CloudControlTestFunction;
80 }).pipe(Effect.provide(CloudControlTestFunctionLive)),
81 );
82
83 expect(functionUrl).toBeTruthy();
84 baseUrl = functionUrl!.replace(/\/+$/, "");
85
86 const readinessUrl = `${baseUrl}/bindings`;
87 yield* Effect.logInfo(
88 `CloudControl test setup: probing readiness at ${readinessUrl}`,
89 );
90 yield* HttpClient.get(readinessUrl).pipe(
91 Effect.flatMap((response) =>
92 response.status === 200
93 ? Effect.succeed(response)
94 : Effect.fail(
95 new Error(`Function not ready: ${response.status}`),
96 ),
97 ),
98 Effect.tapError((error) =>
99 Effect.logWarning(
100 `CloudControl test setup: fixture not ready yet (${String(error)})`,
101 ),
102 ),
103 Effect.retry({ schedule: readinessPolicy }),
104 );
105 }),
106 { timeout: 240_000 },
107 );
108
109 afterAll(sharedStack.destroy(), { timeout: 120_000 });
110
111 describe("binding registration", () => {
112 test.provider("all 8 capabilities initialize in the runtime", (_stack) =>
113 Effect.gen(function* () {
114 const response = yield* send(
115 HttpClientRequest.get(`${baseUrl}/bindings`),
116 ).pipe(Effect.flatMap((r) => r.json));
117 expect((response as any).bound).toHaveLength(8);
118 }),
119 );
120 });
121
122 describe("GetResource", () => {
123 test.provider(
124 "reads the stack-provisioned parameter's live state",
125 (_stack) =>
126 Effect.gen(function* () {
127 const response = yield* send(
128 HttpClientRequest.get(`${baseUrl}/fixture`),
129 ).pipe(Effect.flatMap((r) => r.json));
130 expect((response as any).identifier).toBe(
131 "/alchemy-test/cloudcontrol/bindings/fixture",
132 );
133 expect((response as any).value).toBe("fixture");
134 }),
135 { timeout: 60_000 },
136 );
137 });
138
139 describe("ListResources", () => {
140 test.provider(
141 "discovers the stack-provisioned parameter",
142 (_stack) =>
143 Effect.gen(function* () {
144 const response = yield* send(
145 HttpClientRequest.get(`${baseUrl}/list`),
146 ).pipe(Effect.flatMap((r) => r.json));
147 expect((response as any).count).toBeGreaterThanOrEqual(1);
148 expect((response as any).found).toBe(true);
149 }),
150 { timeout: 90_000 },
151 );
152 });
153
154 describe("CreateResource + GetResourceRequestStatus", () => {
155 test.provider(
156 "creates an SSM parameter at runtime and polls it to SUCCESS",
157 (_stack) =>
158 Effect.gen(function* () {
159 const response = yield* send(
160 HttpClientRequest.post(`${baseUrl}/runtime-create`).pipe(
161 HttpClientRequest.bodyJsonUnsafe({ value: "one" }),
162 ),
163 ).pipe(Effect.flatMap((r) => r.json));
164 expect((response as any).status).toBe("SUCCESS");
165 expect((response as any).value).toBe("one");
166 }),
167 { timeout: 120_000 },
168 );
169 });
170
171 describe("UpdateResource", () => {
172 test.provider(
173 "patches the runtime parameter's value",
174 (_stack) =>
175 Effect.gen(function* () {
176 const response = yield* send(
177 HttpClientRequest.post(`${baseUrl}/runtime-update`).pipe(
178 HttpClientRequest.bodyJsonUnsafe({ value: "two" }),
179 ),
180 ).pipe(Effect.flatMap((r) => r.json));
181 expect((response as any).status).toBe("SUCCESS");
182 expect((response as any).value).toBe("two");
183 }),
184 { timeout: 120_000 },
185 );
186 });
187
188 describe("DeleteResource", () => {
189 test.provider(
190 "deletes the runtime parameter and observes it gone",
191 (_stack) =>
192 Effect.gen(function* () {
193 const response = yield* send(
194 HttpClientRequest.post(`${baseUrl}/runtime-delete`),
195 ).pipe(Effect.flatMap((r) => r.json));
196 expect((response as any).status).toBe("SUCCESS");
197 expect((response as any).gone).toBe(true);
198 }),
199 { timeout: 120_000 },
200 );
201 });
202
203 describe("ListResourceRequests", () => {
204 test.provider(
205 "lists recent resource operation requests",
206 (_stack) =>
207 Effect.gen(function* () {
208 const response = yield* send(
209 HttpClientRequest.get(`${baseUrl}/requests`),
210 ).pipe(Effect.flatMap((r) => r.json));
211 // The runtime lifecycle above just issued create/update/delete
212 // requests, so at least one summary is visible.
213 expect((response as any).count).toBeGreaterThanOrEqual(1);
214 }),
215 { timeout: 60_000 },
216 );
217 });
218
219 describe("GetResourceRequestStatus", () => {
220 test.provider(
221 "returns the typed not-found error for an unknown token",
222 (_stack) =>
223 Effect.gen(function* () {
224 const response = yield* send(
225 HttpClientRequest.get(`${baseUrl}/status-not-found`),
226 ).pipe(Effect.flatMap((r) => r.json));
227 expect((response as any).found).toBe(false);
228 }),
229 { timeout: 60_000 },
230 );
231 });
232
233 describe("CancelResourceRequest", () => {
234 test.provider(
235 "surfaces the typed not-found error for an unknown token (proving the grant)",
236 (_stack) =>
237 Effect.gen(function* () {
238 const response = yield* send(
239 HttpClientRequest.post(`${baseUrl}/cancel-not-found`),
240 ).pipe(Effect.flatMap((r) => r.json));
241 expect((response as any).tag).toBe("RequestTokenNotFoundException");
242 }),
243 { timeout: 60_000 },
244 );
245 });
246 },
247);