1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as config from "@distilled.cloud/aws/config-service";
5import * as iam from "@distilled.cloud/aws/iam";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import ConfigTestFunctionLive, { ConfigTestFunction } from "./handler";
13import { makeConfigTestLease } from "./TestLease.ts";
14
15// Runtime binding coverage for every Config capability that can be
16// exercised against fixture-deployable resources. One binding has no route
17// here (documented, not silently dropped):
18//
19// - DeliverConfigSnapshot — binds to the account's singleton
20// DeliveryChannel, whose lifecycle is gated behind
21// AWS_TEST_CONFIG_RECORDER (see ConfigurationRecorder.test.ts); an
22// ungated fixture must not clobber the account's Config delivery setup.
23
24const testOptions = { providers: AWS.providers() };
25const { test, beforeAll, afterAll } = Test.make(testOptions);
26const sharedStack = Core.scratchStack(testOptions, "ConfigBindings");
27const testLease = makeConfigTestLease();
28
29// PutConfigRule (the fixture's rule) requires a configuration recorder in
30// the account/region. Capture-and-restore: if the account has none, stand
31// up a minimal recorder on the Config service-linked role for the duration
32// of the suite and delete it afterwards; an existing foreign recorder is
33// left untouched. The deterministic name means a leftover from a
34// previously-killed run is RECLAIMED (and cleaned up at the end) instead of
35// orphaning forever.
36const TEST_RECORDER_NAME = "alchemy-test-config-bindings-recorder";
37
38const ensureRecorder = Effect.gen(function* () {
39 const existing = yield* config.describeConfigurationRecorders({});
40 const recorders = existing.ConfigurationRecorders ?? [];
41 if (recorders.some((r) => r.name === TEST_RECORDER_NAME)) {
42 return true;
43 }
44 if (recorders.length > 0) {
45 return false;
46 }
47 yield* iam
48 .createServiceLinkedRole({ AWSServiceName: "config.amazonaws.com" })
49 .pipe(
50 Effect.catchTag("InvalidInputException", () => Effect.succeed(undefined)),
51 );
52 const role = yield* iam.getRole({ RoleName: "AWSServiceRoleForConfig" });
53 yield* config
54 .putConfigurationRecorder({
55 ConfigurationRecorder: {
56 name: TEST_RECORDER_NAME,
57 roleARN: role.Role.Arn,
58 recordingGroup: { resourceTypes: ["AWS::S3::Bucket"] },
59 },
60 })
61 .pipe(
62 Effect.retry({
63 while: (e) => e._tag === "InvalidRoleException",
64 schedule: Schedule.max([
65 Schedule.fixed("2 seconds"),
66 Schedule.recurs(15),
67 ]),
68 }),
69 );
70 return true;
71});
72
73const removeRecorderIfCreated = (created: boolean) =>
74 created
75 ? config
76 .deleteConfigurationRecorder({
77 ConfigurationRecorderName: TEST_RECORDER_NAME,
78 })
79 .pipe(
80 Effect.catchTag(
81 "NoSuchConfigurationRecorderException",
82 () => Effect.void,
83 ),
84 Effect.orDie,
85 )
86 : Effect.void;
87
88let recorderCreated = false;
89let baseUrl: string;
90
91const readinessPolicy = Schedule.max([
92 Schedule.fixed("2 seconds"),
93 Schedule.recurs(75),
94]);
95
96class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
97 readonly status: number;
98 readonly body: string;
99}> {}
100
101// The shared Lambda fixture occasionally answers a transient 5xx under
102// cold re-init or an IAM-propagation race. Only 5xx is retried; a genuine
103// 4xx/assertion failure is returned immediately.
104const send = (request: HttpClientRequest.HttpClientRequest) =>
105 HttpClient.execute(request).pipe(
106 Effect.flatMap((response) =>
107 response.status >= 500
108 ? response.text.pipe(
109 Effect.flatMap((body) =>
110 Effect.fail(
111 new TransientUpstream({ status: response.status, body }),
112 ),
113 ),
114 )
115 : Effect.succeed(response),
116 ),
117 Effect.retry({
118 while: (e) => e._tag === "TransientUpstream",
119 schedule: Schedule.max([
120 Schedule.exponential("500 millis"),
121 Schedule.recurs(6),
122 ]),
123 }),
124 );
125
126const getJson = (path: string) =>
127 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
128 Effect.flatMap((r) => r.json),
129 );
130
131const postJson = (path: string) =>
132 send(HttpClientRequest.post(`${baseUrl}${path}`)).pipe(
133 Effect.flatMap((r) => r.json),
134 );
135
136describe(
137 "Config Bindings",
138 {
139 tags: [
140 "provider:aws",
141 "provider:aws:batch",
142 "provider:aws:config",
143 "provider:aws:iam",
144 "provider:aws:lambda",
145 "live",
146 ],
147 },
148 () => {
149 beforeAll(
150 Effect.gen(function* () {
151 yield* testLease.acquire;
152 yield* Effect.logInfo("Config test setup: ensuring recorder");
153 // Direct distilled calls need the AWS provider context (credentials,
154 // region) that `test.provider` bodies get implicitly.
155 recorderCreated = yield* Core.withProviders(
156 ensureRecorder,
157 testOptions,
158 "ConfigBindings",
159 );
160
161 yield* Effect.logInfo("Config test setup: destroying previous stack");
162 yield* sharedStack.destroy();
163
164 yield* Effect.logInfo("Config test setup: deploying fixture");
165 const { functionUrl } = yield* sharedStack.deploy(
166 Effect.gen(function* () {
167 return yield* ConfigTestFunction;
168 }).pipe(Effect.provide(ConfigTestFunctionLive)),
169 );
170
171 expect(functionUrl).toBeTruthy();
172 baseUrl = functionUrl!.replace(/\/+$/, "");
173
174 yield* Effect.logInfo(
175 `Config test setup: probing readiness at ${baseUrl}/health`,
176 );
177 yield* HttpClient.get(`${baseUrl}/health`).pipe(
178 Effect.flatMap((response) =>
179 response.status === 200
180 ? Effect.succeed(response)
181 : Effect.fail(
182 new Error(`Function not ready: ${response.status}`),
183 ),
184 ),
185 Effect.retry({ schedule: readinessPolicy }),
186 );
187 }),
188 { timeout: 240_000 },
189 );
190
191 afterAll(
192 sharedStack
193 .destroy()
194 .pipe(
195 Effect.ensuring(
196 Effect.suspend(() =>
197 Core.withProviders(
198 removeRecorderIfCreated(recorderCreated),
199 testOptions,
200 "ConfigBindings",
201 ),
202 ),
203 ),
204 Effect.ensuring(testLease.release),
205 ),
206 { timeout: 180_000 },
207 );
208
209 // ── Discovering resources / querying ────────────────────────────────────
210
211 describe("SelectResourceConfig", () => {
212 test.provider("runs a SQL query over recorded state", (_stack) =>
213 Effect.gen(function* () {
214 const response = (yield* getJson("/select-resource-config")) as {
215 results: unknown[];
216 };
217 expect(Array.isArray(response.results)).toBe(true);
218 }),
219 );
220 });
221
222 describe("ListDiscoveredResources", () => {
223 test.provider("lists discovered bucket identifiers", (_stack) =>
224 Effect.gen(function* () {
225 const response = (yield* getJson("/list-discovered-resources")) as {
226 identifiers: unknown[];
227 };
228 expect(Array.isArray(response.identifiers)).toBe(true);
229 }),
230 );
231 });
232
233 describe("GetDiscoveredResourceCounts", () => {
234 test.provider("counts discovered resources", (_stack) =>
235 Effect.gen(function* () {
236 const response = (yield* getJson(
237 "/get-discovered-resource-counts",
238 )) as { total: number; counts: unknown[] };
239 expect(typeof response.total).toBe("number");
240 expect(Array.isArray(response.counts)).toBe(true);
241 }),
242 );
243 });
244
245 describe("BatchGetResourceConfig", () => {
246 test.provider(
247 "returns the undiscovered probe key as unprocessed",
248 (_stack) =>
249 Effect.gen(function* () {
250 const response = (yield* postJson(
251 "/batch-get-resource-config",
252 )) as {
253 items: unknown[];
254 unprocessed: { resourceId?: string }[];
255 };
256 expect(Array.isArray(response.items)).toBe(true);
257 expect(Array.isArray(response.unprocessed)).toBe(true);
258 }),
259 );
260 });
261
262 describe("GetResourceConfigHistory", () => {
263 test.provider(
264 "answers ok or the typed ResourceNotDiscoveredException",
265 (_stack) =>
266 Effect.gen(function* () {
267 const response = (yield* getJson(
268 "/get-resource-config-history",
269 )) as {
270 items?: unknown[];
271 errorTag?: string;
272 };
273 if (response.errorTag !== undefined) {
274 expect(response.errorTag).toBe("ResourceNotDiscoveredException");
275 } else {
276 expect(Array.isArray(response.items)).toBe(true);
277 }
278 }),
279 );
280 });
281
282 // ── Compliance reads ─────────────────────────────────────────────────────
283
284 describe("DescribeComplianceByConfigRule", () => {
285 test.provider("reads the fixture rule's compliance", (_stack) =>
286 Effect.gen(function* () {
287 const response = (yield* getJson(
288 "/describe-compliance-by-config-rule",
289 )) as { ruleName: string; compliances: unknown[] };
290 expect(response.ruleName).toBeTruthy();
291 expect(Array.isArray(response.compliances)).toBe(true);
292 }),
293 );
294 });
295
296 describe("DescribeComplianceByResource", () => {
297 test.provider("reads compliance by resource type", (_stack) =>
298 Effect.gen(function* () {
299 const response = (yield* getJson(
300 "/describe-compliance-by-resource",
301 )) as { compliances: unknown[] };
302 expect(Array.isArray(response.compliances)).toBe(true);
303 }),
304 );
305 });
306
307 describe("GetComplianceDetailsByResource", () => {
308 test.provider("reads evaluation results for a resource", (_stack) =>
309 Effect.gen(function* () {
310 const response = (yield* getJson(
311 "/get-compliance-details-by-resource",
312 )) as { evaluations: unknown[] };
313 expect(Array.isArray(response.evaluations)).toBe(true);
314 }),
315 );
316 });
317
318 describe("GetComplianceSummaryByConfigRule", () => {
319 test.provider("reads the account rule-compliance summary", (_stack) =>
320 Effect.gen(function* () {
321 const response = (yield* getJson(
322 "/get-compliance-summary-by-config-rule",
323 )) as { summary: unknown };
324 expect(response).toHaveProperty("summary");
325 }),
326 );
327 });
328
329 describe("GetComplianceSummaryByResourceType", () => {
330 test.provider("reads per-type compliance summaries", (_stack) =>
331 Effect.gen(function* () {
332 const response = (yield* getJson(
333 "/get-compliance-summary-by-resource-type",
334 )) as { summaries: unknown[] };
335 expect(Array.isArray(response.summaries)).toBe(true);
336 }),
337 );
338 });
339
340 describe("GetComplianceDetailsByConfigRule", () => {
341 test.provider(
342 "reads the bound rule's evaluation results (injected name)",
343 (_stack) =>
344 Effect.gen(function* () {
345 const response = (yield* getJson(
346 "/get-compliance-details-by-config-rule",
347 )) as { ruleName: string; evaluations: unknown[] };
348 expect(response.ruleName).toBeTruthy();
349 expect(Array.isArray(response.evaluations)).toBe(true);
350 }),
351 );
352 });
353
354 // ── Rule evaluation ──────────────────────────────────────────────────────
355
356 describe("DescribeConfigRuleEvaluationStatus", () => {
357 test.provider("reads the fixture rule's evaluation status", (_stack) =>
358 Effect.gen(function* () {
359 const response = (yield* getJson(
360 "/describe-config-rule-evaluation-status",
361 )) as { ruleName: string; statuses: string[] };
362 expect(response.statuses).toContain(response.ruleName);
363 }),
364 );
365 });
366
367 describe("StartConfigRulesEvaluation", () => {
368 test.provider(
369 "starts an on-demand evaluation of the bound rule",
370 (_stack) =>
371 Effect.gen(function* () {
372 const response = (yield* postJson(
373 "/start-config-rules-evaluation",
374 )) as { ok?: boolean; errorTag?: string };
375 if (response.errorTag !== undefined) {
376 expect([
377 "ResourceInUseException",
378 "LimitExceededException",
379 ]).toContain(response.errorTag);
380 } else {
381 expect(response.ok).toBe(true);
382 }
383 }),
384 );
385 });
386
387 describe("PutEvaluations", () => {
388 test.provider("validates evaluations in test mode", (_stack) =>
389 Effect.gen(function* () {
390 const response = (yield* postJson("/put-evaluations")) as {
391 failed?: unknown[];
392 errorTag?: string;
393 };
394 if (response.errorTag !== undefined) {
395 expect(response.errorTag).toBe("InvalidResultTokenException");
396 } else {
397 expect(Array.isArray(response.failed)).toBe(true);
398 }
399 }),
400 );
401 });
402
403 describe("PutExternalEvaluation", () => {
404 test.provider(
405 "rejects with the typed InvalidParameterValueException on a managed rule",
406 (_stack) =>
407 Effect.gen(function* () {
408 const response = (yield* postJson("/put-external-evaluation")) as {
409 ok?: boolean;
410 errorTag?: string;
411 };
412 if (response.errorTag !== undefined) {
413 expect(response.errorTag).toBe("InvalidParameterValueException");
414 } else {
415 expect(response.ok).toBe(true);
416 }
417 }),
418 );
419 });
420
421 // ── Custom resource recording ────────────────────────────────────────────
422
423 describe("PutResourceConfig", () => {
424 test.provider(
425 "records a custom resource (or the typed no-running-recorder error)",
426 (_stack) =>
427 Effect.gen(function* () {
428 const response = (yield* postJson("/put-resource-config")) as {
429 ok?: boolean;
430 errorTag?: string;
431 };
432 if (response.errorTag !== undefined) {
433 expect([
434 "NoRunningConfigurationRecorderException",
435 "ValidationException",
436 "MaxActiveResourcesExceededException",
437 ]).toContain(response.errorTag);
438 } else {
439 expect(response.ok).toBe(true);
440 }
441 }),
442 );
443 });
444
445 describe("DeleteResourceConfig", () => {
446 test.provider(
447 "deletes the custom resource (or the typed no-running-recorder error)",
448 (_stack) =>
449 Effect.gen(function* () {
450 const response = (yield* postJson("/delete-resource-config")) as {
451 ok?: boolean;
452 errorTag?: string;
453 };
454 if (response.errorTag !== undefined) {
455 expect([
456 "NoRunningConfigurationRecorderException",
457 "ValidationException",
458 ]).toContain(response.errorTag);
459 } else {
460 expect(response.ok).toBe(true);
461 }
462 }),
463 );
464 });
465
466 // ── Proactive resource evaluation ────────────────────────────────────────
467
468 describe("StartResourceEvaluation / GetResourceEvaluationSummary", () => {
469 test.provider(
470 "starts a proactive evaluation and reads its summary",
471 (_stack) =>
472 Effect.gen(function* () {
473 const started = (yield* postJson("/start-resource-evaluation")) as {
474 id?: string;
475 errorTag?: string;
476 errorMessage?: string;
477 };
478 if (started.errorTag !== undefined) {
479 expect(started.errorTag, started.errorMessage).toBe(
480 "InvalidParameterValueException",
481 );
482 return;
483 }
484 expect(started.id).toBeTruthy();
485
486 const summary = (yield* getJson(
487 `/get-resource-evaluation-summary?id=${started.id}`,
488 )) as { id?: string; status?: string; errorTag?: string };
489 if (summary.errorTag !== undefined) {
490 expect(summary.errorTag).toBe("ResourceNotFoundException");
491 } else {
492 expect(summary.id).toBe(started.id);
493 expect(summary.status).toBeTruthy();
494 }
495 }),
496 );
497 });
498
499 describe("ListResourceEvaluations", () => {
500 test.provider("lists proactive evaluations", (_stack) =>
501 Effect.gen(function* () {
502 const response = (yield* getJson("/list-resource-evaluations")) as {
503 evaluations: unknown[];
504 };
505 expect(Array.isArray(response.evaluations)).toBe(true);
506 }),
507 );
508 });
509 },
510);