1import * as AWS from "@/AWS";
2import { Index, View } from "@/AWS/ResourceExplorer";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as re2 from "@distilled.cloud/aws/resource-explorer-2";
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 ResourceExplorerTestFunctionLive, {
11 ResourceExplorerTestFunction,
12} from "./handler.ts";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// `GetIndex` keeps returning the most recent index long after deletion
17// (State DELETED), so both NotFound and DELETING/DELETED mean "no index".
18const getLiveIndex = re2.getIndex({}).pipe(
19 Effect.map((r): re2.GetIndexOutput | undefined =>
20 r.State === "DELETED" || r.State === "DELETING" ? undefined : r,
21 ),
22 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
23);
24
25// The index is an account/region singleton (capture-and-restore): these
26// tests only run destructively when the region has no index, or when the
27// existing one carries Alchemy tags (i.e. a leftover from a crashed
28// previous run of this same suite). A user-owned index is never touched.
29const foreignIndexExists = Effect.gen(function* () {
30 const live = yield* getLiveIndex;
31 return (
32 live !== undefined &&
33 !Object.keys(live.Tags ?? {}).some((key) => key.startsWith("alchemy::"))
34 );
35});
36
37const waitForIndexGone = re2.getIndex({}).pipe(
38 Effect.map((r): string | undefined => r.State),
39 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
40 Effect.repeat({
41 schedule: Schedule.spaced("3 seconds"),
42 until: (state) =>
43 state === undefined || state === "DELETED" || state === "DELETING",
44 times: 20,
45 }),
46);
47
48const waitForIndexActive = re2.getIndex({}).pipe(
49 Effect.repeat({
50 schedule: Schedule.spaced("3 seconds"),
51 until: (r) => r.State === "ACTIVE",
52 times: 30,
53 }),
54);
55
56const getViewSafe = (viewArn: string) =>
57 re2.getView({ ViewArn: viewArn }).pipe(
58 Effect.map((r): re2.GetViewOutput | undefined => r),
59 // Resource Explorer's "view not found" is the typed UnauthorizedException
60 Effect.catchTag(
61 ["UnauthorizedException", "ResourceNotFoundException"],
62 () => Effect.succeed(undefined),
63 ),
64 );
65
66const waitForViewGone = (viewArn: string) =>
67 getViewSafe(viewArn).pipe(
68 Effect.repeat({
69 schedule: Schedule.spaced("2 seconds"),
70 until: (view) => view === undefined,
71 times: 10,
72 }),
73 );
74
75// The Resource Explorer index is an account/region singleton, so every
76// test in this file mutates the same underlying cloud state — run them
77// strictly one at a time (vitest runs tests in a file concurrently by
78// default).
79describe.sequential(
80 "ResourceExplorer",
81 { tags: ["provider:aws", "provider:aws:resourceexplorer", "live"] },
82 () => {
83 test.provider(
84 "index lifecycle: create LOCAL, sync tags, promote to AGGREGATOR, destroy",
85 (stack) =>
86 Effect.gen(function* () {
87 if (yield* foreignIndexExists) {
88 yield* Effect.logInfo(
89 "a foreign Resource Explorer index exists — skipping destructive index lifecycle test",
90 );
91 return;
92 }
93 yield* stack.destroy();
94
95 const make = (props: {
96 type?: "LOCAL" | "AGGREGATOR";
97 tags?: Record<string, string>;
98 }) =>
99 Effect.gen(function* () {
100 const index = yield* Index("Explorer", props);
101 return { index };
102 });
103
104 // Create — LOCAL index, engine waits for ACTIVE.
105 const { index } = yield* stack.deploy(
106 make({ tags: { purpose: "alchemy-re2-test" } }),
107 );
108 expect(index.indexArn).toContain(":index/");
109 expect(index.indexType).toBe("LOCAL");
110 expect(index.indexState).toBe("ACTIVE");
111
112 // Out-of-band verification: live state + internal & user tags.
113 const live = yield* re2.getIndex({});
114 expect(live.Arn).toBe(index.indexArn);
115 expect(live.State).toBe("ACTIVE");
116 expect(live.Tags?.purpose).toBe("alchemy-re2-test");
117 expect(live.Tags?.["alchemy::id"]).toBe("Explorer");
118
119 // Canonical list() coverage — the singleton is reported.
120 const provider = yield* Provider.findProvider(Index);
121 const all = yield* provider.list();
122 expect(all.map((i) => i.indexArn)).toContain(index.indexArn);
123
124 // Update — tag sync (add one, drop one) without replacement.
125 const { index: retagged } = yield* stack.deploy(
126 make({ tags: { team: "platform" } }),
127 );
128 expect(retagged.indexArn).toBe(index.indexArn);
129 const afterTags = yield* re2.getIndex({});
130 expect(afterTags.Tags?.team).toBe("platform");
131 expect(afterTags.Tags?.purpose).toBeUndefined();
132 expect(afterTags.Tags?.["alchemy::id"]).toBe("Explorer");
133
134 // Update — promote to AGGREGATOR in place (single-region account:
135 // replication settles quickly). GATED: promoting and then deleting
136 // the aggregator index starts an account-wide 24h cool-down on
137 // UpdateIndexType (`ServiceQuotaExceededException`, Name
138 // "COOL_DOWN_PERIOD"), so the promotion leg cannot run on every CI
139 // pass — set AWS_TEST_RE2_AGGREGATOR=1 on a cooled-down account.
140 if (process.env.AWS_TEST_RE2_AGGREGATOR) {
141 const { index: promoted } = yield* stack.deploy(
142 make({ type: "AGGREGATOR", tags: { team: "platform" } }),
143 );
144 expect(promoted.indexArn).toBe(index.indexArn);
145 expect(promoted.indexType).toBe("AGGREGATOR");
146
147 // Let the async promotion settle before deleting.
148 const settled = yield* waitForIndexActive;
149 expect(settled.Type).toBe("AGGREGATOR");
150 }
151
152 // Destroy — Resource Explorer is turned off for the region.
153 yield* stack.destroy();
154 const gone = yield* waitForIndexGone;
155 expect(["DELETED", "DELETING", undefined]).toContain(gone);
156 }),
157 { timeout: 240_000 },
158 );
159
160 test.provider(
161 "view lifecycle: single-shot deploy with index, update filters, replace on rename",
162 (stack) =>
163 Effect.gen(function* () {
164 if (yield* foreignIndexExists) {
165 yield* Effect.logInfo(
166 "a foreign Resource Explorer index exists — skipping destructive view lifecycle test",
167 );
168 return;
169 }
170 yield* stack.destroy();
171
172 const make = (props: {
173 viewName?: string;
174 filterString?: string;
175 includedProperties?: string[];
176 }) =>
177 Effect.gen(function* () {
178 // Index + View in ONE deploy: the view provider retries
179 // through the index's provisioning window.
180 const index = yield* Index("Explorer", {});
181 const view = yield* View("TestView", props);
182 return { index, view };
183 });
184
185 // Create — filtered view with tags included in results.
186 const { index, view } = yield* stack.deploy(
187 make({
188 filterString: "service:s3",
189 includedProperties: ["tags"],
190 }),
191 );
192 expect(index.indexState).toBe("ACTIVE");
193 expect(view.viewArn).toContain(":view/");
194 expect(view.scope).toBeDefined();
195
196 const observed = yield* re2.getView({ ViewArn: view.viewArn });
197 expect(observed.View?.Filters?.FilterString).toBe("service:s3");
198 expect(observed.View?.IncludedProperties).toEqual([{ Name: "tags" }]);
199 expect(observed.Tags?.["alchemy::id"]).toBe("TestView");
200
201 // Update — change the filter and clear included properties
202 // (UpdateView replaces both).
203 const { view: updated } = yield* stack.deploy(
204 make({ filterString: "service:sqs" }),
205 );
206 expect(updated.viewArn).toBe(view.viewArn);
207 const afterUpdate = yield* re2.getView({ ViewArn: view.viewArn });
208 expect(afterUpdate.View?.Filters?.FilterString).toBe("service:sqs");
209 expect(afterUpdate.View?.IncludedProperties ?? []).toEqual([]);
210
211 // Canonical list() coverage.
212 const provider = yield* Provider.findProvider(View);
213 const all = yield* provider.list();
214 expect(all.map((v) => v.viewArn)).toContain(view.viewArn);
215
216 // Replace — an explicit rename replaces the view (the index stays
217 // deployed across the replacement).
218 const { view: renamed } = yield* stack.deploy(
219 make({
220 viewName: "alchemy-re2-renamed-view",
221 filterString: "service:sqs",
222 }),
223 );
224 expect(renamed.viewArn).not.toBe(view.viewArn);
225 expect(renamed.viewName).toBe("alchemy-re2-renamed-view");
226 // Old view is gone after the replacement completes.
227 const oldView = yield* waitForViewGone(view.viewArn);
228 expect(oldView).toBeUndefined();
229
230 // Destroy — the view and the index are removed.
231 yield* stack.destroy();
232 const goneView = yield* waitForViewGone(renamed.viewArn);
233 expect(goneView).toBeUndefined();
234 const goneIndex = yield* waitForIndexGone;
235 expect(["DELETED", "DELETING", undefined]).toContain(goneIndex);
236 }),
237 { timeout: 240_000 },
238 );
239
240 describe("Bindings", { tags: ["provider:aws:lambda"] }, () => {
241 test.provider(
242 "deployed Lambda exercises Search, ListResources and ListSupportedResourceTypes",
243 (stack) =>
244 Effect.gen(function* () {
245 if (yield* foreignIndexExists) {
246 yield* Effect.logInfo(
247 "a foreign Resource Explorer index exists — skipping search binding test",
248 );
249 return;
250 }
251 yield* stack.destroy();
252
253 const { functionUrl } = yield* stack.deploy(
254 Effect.gen(function* () {
255 return yield* ResourceExplorerTestFunction;
256 }).pipe(Effect.provide(ResourceExplorerTestFunctionLive)),
257 );
258 expect(functionUrl).toBeTruthy();
259 const baseUrl = functionUrl!.replace(/\/+$/, "");
260
261 const client = yield* HttpClient.HttpClient;
262 // Fresh function URLs take a few seconds to start serving; 403/
263 // 5xx during that window are transient.
264 const response = yield* client
265 .get(`${baseUrl}/search?q=service:s3`)
266 .pipe(
267 Effect.flatMap((res) =>
268 res.status === 200
269 ? Effect.succeed(res)
270 : Effect.fail(new Error(`status ${res.status}`)),
271 ),
272 Effect.retry({
273 schedule: Schedule.max([
274 Schedule.fixed("3 seconds"),
275 Schedule.recurs(40),
276 ]),
277 }),
278 );
279 const body = (yield* response.json) as {
280 viewArn: string;
281 totalResources: number;
282 complete: boolean;
283 resources: string[];
284 };
285 // A brand-new index may not have indexed anything yet — assert
286 // the search round-trip itself, not its contents.
287 expect(body.viewArn).toContain(":view/");
288 expect(typeof body.totalResources).toBe("number");
289 expect(Array.isArray(body.resources)).toBe(true);
290
291 // ListResources — structured-filter enumeration through the same
292 // view (a brand-new index may be empty; assert the round-trip).
293 const listRes = yield* client.get(
294 `${baseUrl}/resources?filter=service:s3`,
295 );
296 expect(listRes.status).toBe(200);
297 const listBody = (yield* listRes.json) as {
298 viewArn: string;
299 resources: string[];
300 };
301 expect(listBody.viewArn).toContain(":view/");
302 expect(Array.isArray(listBody.resources)).toBe(true);
303
304 // ListSupportedResourceTypes — account-level discovery; always
305 // returns a non-empty catalog.
306 const typesRes = yield* client.get(`${baseUrl}/types`);
307 expect(typesRes.status).toBe(200);
308 const typesBody = (yield* typesRes.json) as {
309 resourceTypes: string[];
310 };
311 expect(typesBody.resourceTypes.length).toBeGreaterThan(0);
312
313 yield* stack.destroy();
314 const goneIndex = yield* waitForIndexGone;
315 expect(["DELETED", "DELETING", undefined]).toContain(goneIndex);
316 }),
317 { timeout: 300_000 },
318 );
319 });
320 },
321);