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 * as resourcegroups from "@distilled.cloud/aws/resource-groups";
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 RGTestFunctionLive, { RGTestFunction } from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(testOptions, "ResourceGroupsBindings");
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;
26let functionArn: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33// The shared Lambda fixture occasionally answers a transient 5xx under load
34// (cold re-init, IAM propagation on the freshly attached policy). Retry only
35// 5xx; a genuine 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, body: object) =>
64 send(
65 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
66 HttpClientRequest.bodyJsonUnsafe(body),
67 ),
68 ).pipe(Effect.flatMap((r) => r.json));
69
70describe.sequential(
71 "ResourceGroups Bindings",
72 {
73 tags: [
74 "provider:aws",
75 "provider:aws:iam",
76 "provider:aws:lambda",
77 "provider:aws:resourcegroups",
78 "live",
79 ],
80 },
81 () => {
82 beforeAll(
83 Effect.gen(function* () {
84 yield* Effect.logInfo("RG test setup: destroying previous resources");
85 yield* sharedStack.destroy();
86
87 yield* Effect.logInfo("RG test setup: deploying fixture");
88 const attrs = yield* sharedStack.deploy(
89 Effect.gen(function* () {
90 return yield* RGTestFunction;
91 }).pipe(Effect.provide(RGTestFunctionLive)),
92 );
93
94 expect(attrs.functionUrl).toBeTruthy();
95 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
96 functionArn = attrs.functionArn;
97
98 const readinessUrl = `${baseUrl}/bindings`;
99 yield* Effect.logInfo(
100 `RG test setup: probing readiness at ${readinessUrl}`,
101 );
102 yield* HttpClient.get(readinessUrl).pipe(
103 Effect.flatMap((response) =>
104 response.status === 200
105 ? Effect.succeed(response)
106 : Effect.fail(
107 new Error(`Function not ready: ${response.status}`),
108 ),
109 ),
110 Effect.tapError((error) =>
111 Effect.logWarning(
112 `RG test setup: fixture not ready yet (${String(error)})`,
113 ),
114 ),
115 Effect.retry({ schedule: readinessPolicy }),
116 );
117 }),
118 { timeout: 240_000 },
119 );
120
121 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
122 timeout: 120_000,
123 });
124
125 describe("binding registration", () => {
126 test.provider(
127 "all ten capabilities initialize in the runtime",
128 (_stack) =>
129 Effect.gen(function* () {
130 const response = (yield* getJson("/bindings")) as {
131 bound: string[];
132 };
133 expect(response.bound).toHaveLength(10);
134 }),
135 );
136 });
137
138 describe("ListGroupResources", () => {
139 test.provider(
140 "enumerates the bound group's members (injected group name)",
141 (_stack) =>
142 Effect.gen(function* () {
143 const response = (yield* getJson("/members")) as { arns: string[] };
144 // The pool group starts (and stays) empty; the call succeeding
145 // proves the injected group + the tagging read-through grants.
146 expect(response.arns).toEqual([]);
147 }),
148 );
149 });
150
151 describe("GroupResources / UngroupResources", () => {
152 test.provider(
153 "accepts a grouping request and reports the per-resource outcome",
154 (_stack) =>
155 Effect.gen(function* () {
156 // A Lambda ARN is not a capacity reservation, so the service
157 // accepts the request and reports the ARN as Failed with a typed
158 // error code — proving the full wire loop without cost.
159 const grouped = (yield* postJson("/group", {
160 arn: functionArn,
161 })) as { succeeded: string[]; failedCodes: string[] };
162 expect(grouped.succeeded).toEqual([]);
163 expect(grouped.failedCodes).toEqual([
164 "ResourceArnValidationException",
165 ]);
166
167 const ungrouped = (yield* postJson("/ungroup", {
168 arn: functionArn,
169 })) as { failedCodes: string[] };
170 expect(ungrouped.failedCodes).toEqual([
171 "ResourceArnValidationException",
172 ]);
173 }),
174 );
175 });
176
177 describe("ListGroupingStatuses", () => {
178 test.provider(
179 "rejects non-application groups with the typed BadRequestException",
180 (_stack) =>
181 Effect.gen(function* () {
182 // Grouping statuses only exist for application groups, which
183 // CreateGroup cannot make — the typed rejection proves the wire.
184 const response = (yield* getJson("/grouping-statuses")) as {
185 errorTag?: string;
186 message?: string;
187 };
188 expect(response.errorTag).toBe("BadRequestException");
189 expect(response.message).toContain("application group");
190 }),
191 );
192 });
193
194 describe("SearchResources", () => {
195 test.provider("runs an ad-hoc tag query", (_stack) =>
196 Effect.gen(function* () {
197 const response = (yield* getJson("/search")) as { arns: string[] };
198 // Tag indexing is eventually consistent, so assert only that the
199 // query executed (grants held) and returned an array.
200 expect(Array.isArray(response.arns)).toBe(true);
201 }),
202 );
203 });
204
205 describe("GetAccountSettings", () => {
206 test.provider("reads the group lifecycle events status", (_stack) =>
207 Effect.gen(function* () {
208 const response = (yield* getJson("/account-settings")) as {
209 status: string;
210 };
211 expect(["ACTIVE", "INACTIVE", "IN_PROGRESS", "ERROR"]).toContain(
212 response.status,
213 );
214 }),
215 );
216 });
217
218 describe("ListTagSyncTasks", () => {
219 test.provider("enumerates the account's tag-sync tasks", (_stack) =>
220 Effect.gen(function* () {
221 const response = (yield* getJson("/tag-sync-tasks")) as {
222 count: number;
223 };
224 expect(response.count).toBeGreaterThanOrEqual(0);
225 }),
226 );
227 });
228
229 describe("StartTagSyncTask / GetTagSyncTask / CancelTagSyncTask", () => {
230 test.provider(
231 "tag-sync operations reach the service and fail with typed tags",
232 (_stack) =>
233 Effect.gen(function* () {
234 // Tag-sync is part of Group Lifecycle Events (GLE), which entered
235 // maintenance mode on 2026-07-30 and is closed to new customers:
236 // non-entitled accounts get ForbiddenException with the GLE notice.
237 // Entitled accounts get the application-group-only
238 // BadRequestException (application groups are myApplications-only,
239 // not creatable via CreateGroup). Either typed rejection proves
240 // IAM + wiring, including the iam:PassRole grant path. Retry while
241 // the freshly attached role policy is still propagating — resource-
242 // groups surfaces IAM denials as ForbiddenException "not
243 // authorized" (the service has no AccessDeniedException).
244 const started = (yield* postJson("/start-tag-sync", {}).pipe(
245 Effect.repeat({
246 schedule: Schedule.spaced("3 seconds"),
247 until: (r: unknown) =>
248 !(r as { message?: string }).message?.includes(
249 "not authorized",
250 ),
251 times: 8,
252 }),
253 )) as { errorTag?: string; message?: string };
254 expect(
255 ["BadRequestException", "ForbiddenException"],
256 started.message,
257 ).toContain(started.errorTag);
258 if (started.errorTag === "ForbiddenException") {
259 expect(started.message).toContain("Group Lifecycle Events");
260 }
261
262 const got = (yield* getJson(
263 "/tag-sync-task?arn=arn:aws:resource-groups:us-west-2:000000000000:group/none/00000000-0000-0000-0000-000000000000",
264 )) as { errorTag?: string };
265 expect(got.errorTag).toBeTruthy();
266 expect(got.errorTag).not.toBe("UnknownAwsError");
267
268 const cancelled = (yield* postJson("/cancel-tag-sync", {
269 arn: "arn:aws:resource-groups:us-west-2:000000000000:group/none/00000000-0000-0000-0000-000000000000",
270 })) as { errorTag?: string };
271 expect(cancelled.errorTag).toBeTruthy();
272 expect(cancelled.errorTag).not.toBe("UnknownAwsError");
273 }),
274 );
275 });
276
277 describe(
278 "consumeGroupEvents",
279 { tags: ["provider:aws:eventbridge"] },
280 () => {
281 test.provider(
282 "the deploy created an EventBridge rule targeting the function",
283 (_stack) =>
284 Effect.gen(function* () {
285 // Out-of-band via distilled: the fixture's consumeGroupEvents must
286 // have materialized as a rule on the default bus with the Lambda
287 // as target.
288 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
289 TargetArn: functionArn,
290 });
291 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
292 }),
293 );
294 },
295 );
296
297 describe("typed error probes (distilled patch)", () => {
298 test.provider(
299 "listGroupingStatuses on a missing group is the typed NotFoundException",
300 (_stack) =>
301 Effect.gen(function* () {
302 // Proves the distilled patch adding NotFoundException to
303 // ListGroupingStatuses' error union (previously UnknownAwsError).
304 const error = yield* resourcegroups
305 .listGroupingStatuses({ Group: "alchemy-rg-does-not-exist" })
306 .pipe(Effect.flip);
307 expect(error._tag).toBe("NotFoundException");
308 }),
309 );
310 });
311 },
312);