1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as autoscaling from "@distilled.cloud/aws/auto-scaling";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import { describe, expect } from "alchemy-test";
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 AsgBindingsFunctionLive, {
12 AsgBindingsFunction,
13 bindingsAsgName,
14} from "./fixtures/bindings-handler.ts";
15
16const testOptions = { providers: AWS.providers() };
17const { test, beforeAll, afterAll } = Test.make(testOptions);
18const sharedStack = Core.scratchStack(testOptions, "AutoScalingBindings");
19
20const readinessPolicy = Schedule.max([
21 Schedule.fixed("2 seconds"),
22 Schedule.recurs(75),
23]);
24
25let baseUrl: string;
26
27interface RouteResult {
28 ok: boolean;
29 tag: string;
30 name?: string;
31 maxSize?: number;
32 count?: number;
33}
34
35// Call a fixture route, repeating (bounded) while the response still shows an
36// authorization failure — a freshly attached IAM policy is eventually
37// consistent and the first invocations after deploy can see AccessDenied.
38// Routes whose EXPECTED outcome is a scoped denial pass
39// `retryDenied: false` (they run after routes that already proved the
40// policy propagated).
41const callRoute = (
42 method: "GET" | "POST",
43 path: string,
44 options: { retryDenied?: boolean } = {},
45) =>
46 Effect.gen(function* () {
47 const request =
48 method === "GET"
49 ? HttpClientRequest.get(`${baseUrl}${path}`)
50 : HttpClientRequest.post(`${baseUrl}${path}`);
51 return yield* HttpClient.execute(request).pipe(
52 Effect.flatMap((response) =>
53 response.status === 200
54 ? response.json
55 : Effect.fail(
56 new Error(`Route ${path} not ready: ${response.status}`),
57 ),
58 ),
59 // `json` is the untyped `Json` union; RouteResult's optional props
60 // (`string | undefined`) aren't Json-assignable, so the intentional
61 // two-step conversion is required (same pattern as the /refresh route).
62 Effect.map((json) => json as unknown as RouteResult),
63 Effect.repeat({
64 until: (body): boolean =>
65 options.retryDenied === false ||
66 (body.tag !== "AccessDenied" && body.tag !== "AccessDeniedException"),
67 schedule: Schedule.spaced("3 seconds"),
68 times: 10,
69 }),
70 );
71 });
72
73// Deploys a Lambda bound to a zero-sized ASG with every AutoScaling runtime
74// binding plus the instance-events EventBridge subscription. Read bindings are
75// asserted on live data; instance-scoped write bindings are probed with a
76// well-formed non-existent instance id — AWS answers with a typed
77// `ValidationError` (never AccessDenied), proving the binding's IAM grant.
78describe(
79 "AutoScaling runtime bindings",
80 {
81 tags: [
82 "provider:aws",
83 "provider:aws:autoscaling",
84 "provider:aws:ec2",
85 "provider:aws:lambda",
86 "live",
87 ],
88 },
89 () => {
90 beforeAll(
91 Effect.gen(function* () {
92 yield* sharedStack.destroy();
93
94 const { fn } = yield* sharedStack.deploy(
95 Effect.gen(function* () {
96 const fn = yield* AsgBindingsFunction;
97 return { fn };
98 }).pipe(Effect.provide(AsgBindingsFunctionLive)),
99 );
100
101 expect(fn.functionUrl).toBeTruthy();
102 baseUrl = fn.functionUrl!.replace(/\/+$/, "");
103
104 yield* HttpClient.get(`${baseUrl}/health`).pipe(
105 Effect.flatMap((response) =>
106 response.status === 200
107 ? Effect.succeed(response)
108 : Effect.fail(
109 new Error(`Function not ready: ${response.status}`),
110 ),
111 ),
112 Effect.retry({ schedule: readinessPolicy }),
113 );
114 }),
115 { timeout: 300_000 },
116 );
117
118 afterAll(
119 Effect.gen(function* () {
120 yield* sharedStack.destroy();
121 // Assert the fixture ASG is fully gone — the suite must leave nothing.
122 // The hook context has no AWS providers of its own, so provide them
123 // explicitly for the out-of-band distilled call.
124 const remaining = yield* Core.withProviders(
125 autoscaling
126 .describeAutoScalingGroups({
127 AutoScalingGroupNames: [bindingsAsgName],
128 } as any)
129 .pipe(
130 Effect.map((r) => (r.AutoScalingGroups ?? []).length),
131 Effect.repeat({
132 until: (count) => count === 0,
133 schedule: Schedule.spaced("3 seconds"),
134 times: 10,
135 }),
136 ),
137 testOptions,
138 sharedStack.name,
139 );
140 expect(remaining).toBe(0);
141 }),
142 { timeout: 180_000 },
143 );
144
145 test.provider(
146 "DescribeAutoScalingGroup returns the bound group's live state",
147 (_stack) =>
148 Effect.gen(function* () {
149 const body = yield* callRoute("GET", "/describe");
150 expect(body.ok).toBe(true);
151 expect(body.name).toEqual(bindingsAsgName);
152 expect(body.maxSize).toEqual(0);
153 }),
154 { timeout: 60_000 },
155 );
156
157 test.provider(
158 "DescribeScalingActivities lists the group's activities",
159 (_stack) =>
160 Effect.gen(function* () {
161 const body = yield* callRoute("GET", "/activities");
162 expect(body.ok).toBe(true);
163 expect(body.count).toBeGreaterThanOrEqual(0);
164 }),
165 { timeout: 60_000 },
166 );
167
168 test.provider(
169 "SetDesiredCapacity succeeds against the bound group",
170 (_stack) =>
171 Effect.gen(function* () {
172 const body = yield* callRoute("POST", "/set-desired");
173 expect(body.ok).toBe(true);
174 }),
175 { timeout: 60_000 },
176 );
177
178 test.provider(
179 "ExecutePolicy is IAM-wired (typed ValidationError for a bogus policy)",
180 (_stack) =>
181 Effect.gen(function* () {
182 const body = yield* callRoute("POST", "/execute-policy");
183 expect(body.tag).toEqual("ValidationError");
184 }),
185 { timeout: 60_000 },
186 );
187
188 // SetInstanceHealth / TerminateInstanceInAutoScalingGroup carry no group
189 // name in the request — EC2 Auto Scaling resolves the instance's owning
190 // group for resource-level authorization. The bogus instance resolves to
191 // nothing, so the (correct, least-privilege) group-scoped grant denies —
192 // a typed AccessDeniedException, proving the binding client signed and
193 // dispatched the operation with the attached policy (a missing policy
194 // would deny identically for REAL instances too; the other seven bindings
195 // prove grant attachment works end-to-end via the same scaffold). With a
196 // real in-group instance the same grant authorizes.
197 test.provider(
198 "SetInstanceHealth dispatches a signed request (scoped denial for a bogus instance)",
199 (_stack) =>
200 Effect.gen(function* () {
201 const body = yield* callRoute("POST", "/set-health", {
202 retryDenied: false,
203 });
204 expect(body.tag).toEqual("AccessDeniedException");
205 }),
206 { timeout: 60_000 },
207 );
208
209 test.provider(
210 "SetInstanceProtection is IAM-wired (typed ValidationError for a bogus instance)",
211 (_stack) =>
212 Effect.gen(function* () {
213 const body = yield* callRoute("POST", "/set-protection");
214 expect(body.tag).toEqual("ValidationError");
215 }),
216 { timeout: 60_000 },
217 );
218
219 test.provider(
220 "TerminateInstanceInAutoScalingGroup dispatches a signed request (scoped denial)",
221 (_stack) =>
222 Effect.gen(function* () {
223 const body = yield* callRoute("POST", "/terminate", {
224 retryDenied: false,
225 });
226 expect(body.tag).toEqual("AccessDeniedException");
227 }),
228 { timeout: 60_000 },
229 );
230
231 test.provider(
232 "Standby enter/exit are IAM-wired (typed ValidationError)",
233 (_stack) =>
234 Effect.gen(function* () {
235 const enter = yield* callRoute("POST", "/standby-enter");
236 expect(enter.tag).toEqual("ValidationError");
237 const exit = yield* callRoute("POST", "/standby-exit");
238 expect(exit.tag).toEqual("ValidationError");
239 }),
240 { timeout: 60_000 },
241 );
242
243 test.provider(
244 "InstanceRefresh start/describe/cancel round-trip on the zero-instance fleet",
245 (_stack) =>
246 Effect.gen(function* () {
247 const body = (yield* callRoute("POST", "/refresh")) as unknown as {
248 startTag: string;
249 describeOk: boolean;
250 describeCount?: number;
251 cancelTag: string;
252 };
253 // A refresh on the empty fleet completes (or is already in progress
254 // from a previous run) — never an authorization failure.
255 expect(["Success", "InstanceRefreshInProgressFault"]).toContain(
256 body.startTag,
257 );
258 expect(body.describeOk).toBe(true);
259 expect(body.describeCount).toBeGreaterThanOrEqual(1);
260 expect(body.cancelTag).toEqual("Success");
261 }),
262 { timeout: 60_000 },
263 );
264
265 test.provider(
266 "consumeInstanceEvents created the EventBridge rule for instance events",
267 (_stack) =>
268 Effect.gen(function* () {
269 // The rule's physical name is `{stack}-{logicalId}` truncated to fit
270 // the instance-id suffix (e.g. `AutoScalingBindings-BindingsGroup-
271 // Instan3unhktjkefuey4…`), so the full logical id may not survive.
272 // Filter server-side on the stable stack+logical prefix and pick the
273 // autoscaling-pattern rule.
274 let rule: eventbridge.Rule | undefined;
275 let nextToken: string | undefined;
276 for (let page = 0; page < 10 && !rule; page++) {
277 const result = yield* eventbridge.listRules({
278 NamePrefix: "AutoScalingBindings-BindingsGroup-",
279 NextToken: nextToken,
280 Limit: 100,
281 });
282 rule = (result.Rules ?? []).find((candidate) =>
283 candidate.EventPattern?.includes("aws.autoscaling"),
284 );
285 nextToken = result.NextToken;
286 if (!nextToken) break;
287 }
288 expect(rule).toBeDefined();
289 expect(rule?.EventPattern).toContain("aws.autoscaling");
290 expect(rule?.EventPattern).toContain(
291 "EC2 Instance Launch Successful",
292 );
293 }),
294 { tags: ["provider:aws:eventbridge"], timeout: 60_000 },
295 );
296 },
297);