1import * as AWS from "@/AWS";
2import { AccessPolicy } from "@/AWS/OpenSearchServerless";
3import * as Output from "@/Output";
4import * as Test from "@/Test/Alchemy";
5import * as aoss from "@distilled.cloud/aws/opensearchserverless";
6import { 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";
11
12import AossIndexFunctionLive, {
13 AossIndexFunction,
14 COLLECTION_NAME,
15} from "./index-bindings-handler";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19const ACC_POLICY = "alchemy-aossb-acc";
20
21// A route answering 5xx is retried: the fixture's data access policy and IAM
22// grants propagate asynchronously after deploy (typically well under a
23// minute), and the handler's Effect.orDie surfaces AccessDenied as a 500.
24const drive = (request: HttpClientRequest.HttpClientRequest) =>
25 HttpClient.execute(request).pipe(
26 Effect.flatMap((response) =>
27 response.status === 200
28 ? Effect.succeed(response)
29 : Effect.fail(new Error(`route not ready: ${response.status}`)),
30 ),
31 Effect.retry({
32 schedule: Schedule.max([
33 Schedule.fixed("5 seconds"),
34 Schedule.recurs(24),
35 ]),
36 }),
37 );
38
39// Collections have a real-money OCU floor and take ~1-5 min to provision, so
40// the index data-plane bindings (aoss:APIAccessAll) are gated behind
41// AWS_TEST_SLOW=1, destroy IMMEDIATELY, and verify the collection is gone.
42test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
43 "index bindings roundtrip against a live collection (AWS_TEST_SLOW=1)",
44 (stack) =>
45 Effect.gen(function* () {
46 // Clean slate in case a previous run died mid-flight. Set
47 // AOSS_KEEP_COLLECTION=1 to skip it and converge onto the surviving
48 // resources instead (collections take minutes to provision, so reusing
49 // one keeps the re-run inside the runner's wall clock).
50 if (!process.env.AOSS_KEEP_COLLECTION) {
51 yield* stack.destroy();
52 }
53
54 const { fn } = yield* stack.deploy(
55 Effect.gen(function* () {
56 const fn = yield* AossIndexFunction;
57 // The data access policy granting the function's role index
58 // permissions — the control-plane index APIs enforce it in
59 // addition to the IAM aoss:APIAccessAll grant.
60 yield* AccessPolicy("Acc", {
61 policyName: ACC_POLICY,
62 policy: Output.interpolate`[{"Rules":[{"ResourceType":"index","Resource":["index/${COLLECTION_NAME}/*"],"Permission":["aoss:*"]},{"ResourceType":"collection","Resource":["collection/${COLLECTION_NAME}"],"Permission":["aoss:*"]}],"Principal":["${fn.roleArn}"]}]`,
63 });
64 return { fn };
65 }).pipe(Effect.provide(AossIndexFunctionLive)),
66 );
67
68 expect(fn.functionUrl).toBeTruthy();
69 const baseUrl = fn.functionUrl!.replace(/\/+$/, "");
70
71 // GetCollection — the bound collection is ACTIVE with an endpoint.
72 const collection = (yield* drive(
73 HttpClientRequest.get(`${baseUrl}/collection`),
74 ).pipe(Effect.flatMap((r) => r.json))) as any;
75 expect(collection.status).toBe("ACTIVE");
76 expect(collection.endpoint).toContain("aoss.amazonaws.com");
77
78 // Create → read → update → delete an index at runtime.
79 const roundtrip = (yield* drive(
80 HttpClientRequest.post(`${baseUrl}/index/roundtrip`),
81 ).pipe(Effect.flatMap((r) => r.json))) as any;
82 expect(roundtrip.created).toBe(true);
83 expect(roundtrip.hadSchema).toBe(true);
84 expect(roundtrip.deleted).toBe(true);
85
86 // Destroy immediately — collections meter OCUs while they exist — and
87 // verify deletion out-of-band.
88 const collectionId = collection.id as string;
89 yield* stack.destroy();
90 yield* assertCollectionGone(collectionId);
91 }),
92 {
93 tags: [
94 "provider:aws",
95 "provider:aws:lambda",
96 "provider:aws:opensearchserverless",
97 "live",
98 ],
99 timeout: 900_000,
100 },
101);
102
103// Deletion is verified as INITIATED (status `DELETING`) or fully gone — full
104// disappearance takes another ~1-2 min server-side.
105const assertCollectionGone = (id: string) =>
106 Effect.gen(function* () {
107 const response = yield* aoss.batchGetCollection({ ids: [id] });
108 const detail = response.collectionDetails?.[0];
109 const status = detail?.status ?? "gone";
110 if (status !== "gone" && status !== "DELETING") {
111 return yield* Effect.fail(
112 new Error(`Collection '${id}' still exists (status: ${status})`),
113 );
114 }
115 }).pipe(
116 Effect.retry({
117 schedule: Schedule.max([
118 Schedule.fixed("5 seconds"),
119 Schedule.recurs(24),
120 ]),
121 }),
122 );