1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import FisTestFunctionLive, { FisTestFunction } from "./handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "FISBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25let functionArn: 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
58const getJson = (path: string) =>
59 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
60 Effect.flatMap((r) => r.json),
61 );
62
63const postJson = (path: string) =>
64 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
65 Effect.flatMap((r) => r.json),
66 );
67
68describe.sequential(
69 "FIS Bindings",
70 {
71 tags: [
72 "provider:aws",
73 "provider:aws:fis",
74 "provider:aws:iam",
75 "provider:aws:lambda",
76 "live",
77 ],
78 },
79 () => {
80 beforeAll(
81 Effect.gen(function* () {
82 yield* Effect.logInfo("FIS test setup: destroying previous resources");
83 yield* sharedStack.destroy();
84
85 yield* Effect.logInfo("FIS test setup: deploying fixture");
86 const attrs = yield* sharedStack.deploy(
87 Effect.gen(function* () {
88 return yield* FisTestFunction;
89 }).pipe(Effect.provide(FisTestFunctionLive)),
90 );
91
92 expect(attrs.functionUrl).toBeTruthy();
93 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
94 functionArn = attrs.functionArn;
95
96 const readinessUrl = `${baseUrl}/bindings`;
97 yield* Effect.logInfo(
98 `FIS 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 `FIS test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 120_000 });
120
121 describe("binding registration", () => {
122 test.provider("all capabilities initialize in the runtime", (_stack) =>
123 Effect.gen(function* () {
124 const response = (yield* getJson("/bindings")) as { bound: string[] };
125 expect(response.bound).toHaveLength(15);
126 }),
127 );
128 });
129
130 describe("GetExperimentTemplate + ListExperimentTemplates", () => {
131 test.provider(
132 "reads the bound template (injected id) and finds it in the listing",
133 (_stack) =>
134 Effect.gen(function* () {
135 const template = (yield* getJson("/template")) as {
136 id: string;
137 actions: string[];
138 };
139 expect(template.id).toMatch(/^EXT/);
140 expect(template.actions).toContain("Wait");
141
142 const listing = (yield* getJson("/templates")) as { ids: string[] };
143 expect(listing.ids).toContain(template.id);
144 }),
145 { timeout: 60_000 },
146 );
147 });
148
149 describe("StartExperiment + GetExperiment + ListExperiments + StopExperiment", () => {
150 test.provider(
151 "runs the harmless wait experiment end to end and stops it",
152 (_stack) =>
153 Effect.gen(function* () {
154 const template = (yield* getJson("/template")) as { id: string };
155
156 // start — the bound template's id is injected by the binding.
157 const started = (yield* postJson("/experiments")) as {
158 id: string;
159 status: string;
160 };
161 expect(started.id).toMatch(/^EXP/);
162 expect(["pending", "initiating", "running"]).toContain(
163 started.status,
164 );
165
166 // read it back and see it in the template-scoped listing.
167 const read = (yield* getJson(`/experiment?id=${started.id}`)) as {
168 id: string;
169 templateId: string;
170 status: string;
171 };
172 expect(read.id).toBe(started.id);
173 expect(read.templateId).toBe(template.id);
174
175 const listing = (yield* getJson(
176 `/experiments?templateId=${template.id}`,
177 )) as { ids: string[] };
178 expect(listing.ids).toContain(started.id);
179
180 // the wait-only experiment has no targets to resolve.
181 const resolved = (yield* getJson(
182 `/resolved-targets?id=${started.id}`,
183 )) as { count: number };
184 expect(resolved.count).toBe(0);
185
186 // stop it so nothing lingers past the test.
187 const stopped = (yield* postJson(`/stop?id=${started.id}`)) as {
188 status: string;
189 };
190 expect(["stopping", "stopped"]).toContain(stopped.status);
191 }),
192 { timeout: 120_000 },
193 );
194 });
195
196 describe("GetAction + ListActions", () => {
197 test.provider("reads the aws:fis:wait action catalog entry", (_stack) =>
198 Effect.gen(function* () {
199 const action = (yield* getJson("/action?id=aws:fis:wait")) as {
200 id: string;
201 };
202 expect(action.id).toBe("aws:fis:wait");
203
204 const listing = (yield* getJson("/actions")) as { ids: string[] };
205 expect(listing.ids).toContain("aws:fis:wait");
206 }),
207 );
208 });
209
210 describe("GetTargetResourceType + ListTargetResourceTypes", () => {
211 test.provider(
212 "reads the aws:ec2:instance target resource type",
213 (_stack) =>
214 Effect.gen(function* () {
215 const type = (yield* getJson(
216 "/target-resource-type?type=aws:ec2:instance",
217 )) as { resourceType: string };
218 expect(type.resourceType).toBe("aws:ec2:instance");
219
220 const listing = (yield* getJson("/target-resource-types")) as {
221 resourceTypes: string[];
222 };
223 expect(listing.resourceTypes).toContain("aws:ec2:instance");
224 }),
225 );
226 });
227
228 describe("GetSafetyLever", () => {
229 test.provider("reads the account's default safety lever", (_stack) =>
230 Effect.gen(function* () {
231 const lever = (yield* getJson("/safety-lever")) as {
232 id: string;
233 status: string;
234 };
235 expect(lever.id).toBe("default");
236 // Anything but disengaged would halt every experiment in the
237 // account, so the suite asserts the steady state.
238 expect(lever.status).toBe("disengaged");
239 }),
240 );
241 });
242
243 describe(
244 "consumeExperimentEvents",
245 { tags: ["provider:aws:eventbridge"] },
246 () => {
247 test.provider(
248 "the deploy created an EventBridge rule targeting the function",
249 (_stack) =>
250 Effect.gen(function* () {
251 // Out-of-band via distilled: the fixture's consumeExperimentEvents
252 // must have materialized as a rule on the default bus with the
253 // Lambda as target.
254 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
255 TargetArn: functionArn,
256 });
257 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
258 }),
259 );
260 },
261 );
262 },
263);