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 CfnTestFunctionLive, {
11 CfnTestFunction,
12 FIXTURE_EXPORT_NAME,
13} from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "CloudFormationBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy under parallel-suite load.
21const readinessPolicy = Schedule.max([
22 Schedule.fixed("2 seconds"),
23 Schedule.recurs(120),
24]);
25
26let baseUrl: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33// Retry transient 5xx from the shared Lambda fixture (cold re-init, IAM
34// propagation on the freshly attached cloudformation policy surfaced as a
35// 500 by the handler's `Effect.orDie`). Genuine 4xx/assertion failures
36// return immediately.
37const send = (request: HttpClientRequest.HttpClientRequest) =>
38 HttpClient.execute(request).pipe(
39 Effect.flatMap((response) =>
40 response.status >= 500
41 ? response.text.pipe(
42 Effect.flatMap((body) =>
43 Effect.fail(
44 new TransientUpstream({ status: response.status, body }),
45 ),
46 ),
47 )
48 : Effect.succeed(response),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "TransientUpstream",
52 schedule: Schedule.max([
53 Schedule.exponential("500 millis"),
54 Schedule.recurs(6),
55 ]),
56 }),
57 );
58
59const getJson = (url: string) =>
60 send(HttpClientRequest.get(url)).pipe(Effect.flatMap((r) => r.json));
61
62describe.sequential(
63 "CloudFormation Bindings",
64 {
65 tags: [
66 "provider:aws",
67 "provider:aws:cloudformation",
68 "provider:aws:lambda",
69 "live",
70 ],
71 },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* Effect.logInfo("CFN test setup: destroying previous resources");
76 yield* sharedStack.destroy();
77
78 yield* Effect.logInfo("CFN test setup: deploying fixture");
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* CfnTestFunction;
82 }).pipe(Effect.provide(CfnTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87 const readinessUrl = `${baseUrl}/describe`;
88
89 yield* Effect.logInfo(
90 `CFN test setup: probing readiness at ${readinessUrl}`,
91 );
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: 420_000 },
104 );
105
106 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
107 timeout: 240_000,
108 });
109
110 describe("DescribeStacks", () => {
111 test.provider("reads the bound stack's status and outputs", (_stack) =>
112 Effect.gen(function* () {
113 const body = (yield* getJson(`${baseUrl}/describe`)) as any;
114
115 expect(body.stackId).toContain("arn:aws:cloudformation:");
116 expect(["CREATE_COMPLETE", "UPDATE_COMPLETE"]).toContain(body.status);
117 expect(body.outputs.ParamName).toBeDefined();
118 }),
119 );
120 });
121
122 describe("DescribeStackEvents", () => {
123 test.provider("reads the stack's event history", (_stack) =>
124 Effect.gen(function* () {
125 const body = (yield* getJson(`${baseUrl}/events`)) as any;
126
127 expect(body.count).toBeGreaterThan(0);
128 expect(Array.isArray(body.statuses)).toBe(true);
129 }),
130 );
131 });
132
133 describe("DescribeStackResources", () => {
134 test.provider("resolves a resource's physical id", (_stack) =>
135 Effect.gen(function* () {
136 const body = (yield* getJson(`${baseUrl}/resources`)) as any;
137
138 expect(body.logicalId).toBe("Param");
139 expect(body.type).toBe("AWS::SSM::Parameter");
140 expect(body.physicalId).toBeTruthy();
141 }),
142 );
143 });
144
145 describe("ListStackResources", () => {
146 test.provider("enumerates the stack's resources", (_stack) =>
147 Effect.gen(function* () {
148 const body = (yield* getJson(`${baseUrl}/list-resources`)) as any;
149
150 expect(body.types).toContain("AWS::SSM::Parameter");
151 }),
152 );
153 });
154
155 describe("GetTemplate", () => {
156 test.provider("reads the deployed template body", (_stack) =>
157 Effect.gen(function* () {
158 const body = (yield* getJson(`${baseUrl}/template`)) as any;
159
160 expect(body.template).toContain("AWS::SSM::Parameter");
161 }),
162 );
163 });
164
165 describe("DetectStackDrift + DescribeStackDriftDetectionStatus", () => {
166 test.provider(
167 "starts drift detection and polls it to a terminal status",
168 (_stack) =>
169 Effect.gen(function* () {
170 const started = (yield* send(
171 HttpClientRequest.post(`${baseUrl}/drift`),
172 ).pipe(Effect.flatMap((r) => r.json))) as any;
173
174 expect(started.detectionId).toBeTruthy();
175
176 // Detection runs asynchronously; poll until it leaves
177 // DETECTION_IN_PROGRESS. DETECTION_FAILED is acceptable — the
178 // fixture role has no ssm read access, and either terminal status
179 // proves the cloudformation:* IAM + binding wiring.
180 const status = yield* getJson(
181 `${baseUrl}/drift-status?id=${started.detectionId}`,
182 ).pipe(
183 Effect.flatMap((body: any) =>
184 body.detectionStatus === "DETECTION_IN_PROGRESS"
185 ? Effect.fail(new DriftInProgress())
186 : Effect.succeed(body),
187 ),
188 Effect.retry({
189 while: (e) => e._tag === "DriftInProgress",
190 schedule: Schedule.max([
191 Schedule.fixed("3 seconds"),
192 Schedule.recurs(20),
193 ]),
194 }),
195 );
196
197 expect(["DETECTION_COMPLETE", "DETECTION_FAILED"]).toContain(
198 (status as any).detectionStatus,
199 );
200 }),
201 { timeout: 120_000 },
202 );
203 });
204
205 describe("DescribeStackResourceDrifts", () => {
206 test.provider("reads per-resource drift results", (_stack) =>
207 Effect.gen(function* () {
208 const body = (yield* getJson(`${baseUrl}/resource-drifts`)) as any;
209
210 // Results only exist for resources CloudFormation managed to check;
211 // the call succeeding proves the binding + IAM wiring either way.
212 expect(Array.isArray(body.drifts)).toBe(true);
213 }),
214 );
215 });
216
217 describe("ListExports", () => {
218 test.provider("lists the fixture stack's export", (_stack) =>
219 Effect.gen(function* () {
220 // Exports are eventually consistent for fresh stacks.
221 const body = yield* fetchUntil(
222 getJson(`${baseUrl}/exports`),
223 (b) =>
224 Array.isArray(b?.names) && b.names.includes(FIXTURE_EXPORT_NAME),
225 );
226
227 expect((body as any).names).toContain(FIXTURE_EXPORT_NAME);
228 }),
229 );
230 });
231
232 describe("ListImports", () => {
233 test.provider(
234 "fails with a typed error for an un-imported export",
235 (_stack) =>
236 Effect.gen(function* () {
237 const body = (yield* getJson(
238 `${baseUrl}/imports?name=${FIXTURE_EXPORT_NAME}`,
239 )) as any;
240
241 // Nothing imports the fixture export — CloudFormation must reject
242 // with a TYPED tag (an untyped catch-all would crash into a 500).
243 expect(typeof body.errorTag).toBe("string");
244 expect(body.errorTag.length).toBeGreaterThan(0);
245 }),
246 );
247 });
248
249 describe("ValidateTemplate", () => {
250 test.provider("validates the fixture template", (_stack) =>
251 Effect.gen(function* () {
252 const body = (yield* send(
253 HttpClientRequest.bodyJsonUnsafe(
254 HttpClientRequest.post(`${baseUrl}/validate`),
255 {},
256 ),
257 ).pipe(Effect.flatMap((r) => r.json))) as any;
258
259 expect(Array.isArray(body.parameters)).toBe(true);
260 expect(body.errorTag).toBeUndefined();
261 }),
262 );
263
264 test.provider(
265 "rejects an invalid template with a typed error",
266 (_stack) =>
267 Effect.gen(function* () {
268 const body = (yield* send(
269 HttpClientRequest.bodyJsonUnsafe(
270 HttpClientRequest.post(`${baseUrl}/validate`),
271 { template: '{"Resources": {}}' },
272 ),
273 ).pipe(Effect.flatMap((r) => r.json))) as any;
274
275 expect(typeof body.errorTag).toBe("string");
276 expect(body.errorTag.length).toBeGreaterThan(0);
277 }),
278 );
279 });
280
281 describe("SignalResource", () => {
282 test.provider("delivers a signal for a stack resource", (_stack) =>
283 Effect.gen(function* () {
284 const body = (yield* send(
285 HttpClientRequest.post(`${baseUrl}/signal`),
286 ).pipe(Effect.flatMap((r) => r.json))) as any;
287
288 // CloudFormation accepts (and ignores) signals for resources that
289 // are not waiting on a CreationPolicy — a 200 proves the
290 // cloudformation:SignalResource IAM + binding wiring.
291 expect(body.ok).toBe(true);
292 }),
293 );
294 });
295 },
296);
297
298class DriftInProgress extends Data.TaggedError("DriftInProgress") {}
299
300// A request observed not-yet-consistent control-plane state (ListExports
301// lags fresh stacks by a few seconds). Retry, bounded.
302class BindingNotConsistent extends Data.TaggedError("BindingNotConsistent") {}
303
304const fetchUntil = <A>(
305 fetch: Effect.Effect<unknown, any, HttpClient.HttpClient>,
306 ready: (body: any) => boolean,
307) =>
308 fetch.pipe(
309 Effect.flatMap((body) =>
310 ready(body)
311 ? Effect.succeed(body as A)
312 : Effect.fail(new BindingNotConsistent()),
313 ),
314 Effect.retry({
315 while: (e) => e._tag === "BindingNotConsistent",
316 schedule: Schedule.max([
317 Schedule.fixed("2 seconds"),
318 Schedule.recurs(20),
319 ]),
320 }),
321 );