1import * as AWS from "@/AWS";
2import {
3 AccessPolicy,
4 Collection,
5 SecurityPolicy,
6} from "@/AWS/OpenSearchServerless";
7import * as Test from "@/Test/Alchemy";
8import * as aoss from "@distilled.cloud/aws/opensearchserverless";
9import * as sts from "@distilled.cloud/aws/sts";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: AWS.providers() });
15
16// Deterministic, collision-free names (≤32 chars, lowercase) reused across the
17// collection and the policies that must reference its resource pattern.
18const COLLECTION_NAME = "alchemy-test-aoss";
19const ENC_POLICY = "alchemy-aoss-enc";
20const NET_POLICY = "alchemy-aoss-net";
21const ACC_POLICY = "alchemy-aoss-acc";
22
23const encryptionPolicy = {
24 Rules: [
25 { ResourceType: "collection", Resource: [`collection/${COLLECTION_NAME}`] },
26 ],
27 AWSOwnedKey: true,
28};
29
30const networkPolicy = [
31 {
32 Rules: [
33 {
34 ResourceType: "collection",
35 Resource: [`collection/${COLLECTION_NAME}`],
36 },
37 {
38 ResourceType: "dashboard",
39 Resource: [`collection/${COLLECTION_NAME}`],
40 },
41 ],
42 AllowFromPublic: true,
43 },
44];
45
46const accessPolicy = (principalArn: string) => [
47 {
48 Rules: [
49 {
50 ResourceType: "index",
51 Resource: [`index/${COLLECTION_NAME}/*`],
52 Permission: ["aoss:*"],
53 },
54 {
55 ResourceType: "collection",
56 Resource: [`collection/${COLLECTION_NAME}`],
57 Permission: ["aoss:*"],
58 },
59 ],
60 Principal: [principalArn],
61 },
62];
63
64// Ungated typed-error probe: proves the distilled error union carries the
65// not-found tag that the SecurityPolicy/AccessPolicy read + delete paths depend
66// on, and that batchGetCollection decodes the per-item error detail shape used
67// to observe a missing collection.
68test.provider(
69 "typed error semantics on nonexistent policies and collections",
70 () =>
71 Effect.gen(function* () {
72 const policyError = yield* Effect.flip(
73 aoss.getSecurityPolicy({
74 type: "encryption",
75 name: "alchemy-nonexistent-probe",
76 }),
77 );
78 expect(policyError._tag).toBe("ResourceNotFoundException");
79
80 // batchGetCollection does not throw for a missing collection — it reports
81 // it in collectionErrorDetails, which the Collection provider treats as
82 // "not present".
83 const batch = yield* aoss.batchGetCollection({
84 names: ["alchemy-nonexistent-collection-probe"],
85 });
86 expect(batch.collectionDetails ?? []).toHaveLength(0);
87 expect((batch.collectionErrorDetails ?? []).length).toBeGreaterThan(0);
88 }),
89 { tags: ["provider:aws", "provider:aws:opensearchserverless", "live"] },
90);
91
92// Security and access policies are FREE (no OCU cost) and provision instantly,
93// so their full lifecycle runs ungated. This exercises the bulk of the
94// SecurityPolicy and AccessPolicy providers (create, no-op, update-with-version
95// -bump, delete, verify-gone) without touching a billed collection.
96test.provider(
97 "security + access policy lifecycle: create, update, destroy, verify gone",
98 (stack) =>
99 Effect.gen(function* () {
100 // Clean slate in case a previous run died mid-flight.
101 yield* stack.destroy();
102
103 const { Account } = yield* sts.getCallerIdentity({});
104 const principal = `arn:aws:iam::${Account}:role/alchemy-test-aoss-role`;
105
106 // Create all three policies.
107 const created = yield* stack.deploy(
108 Effect.gen(function* () {
109 const enc = yield* SecurityPolicy("Enc", {
110 policyName: ENC_POLICY,
111 type: "encryption",
112 policy: encryptionPolicy,
113 description: "alchemy test encryption policy",
114 });
115 const net = yield* SecurityPolicy("Net", {
116 policyName: NET_POLICY,
117 type: "network",
118 policy: networkPolicy,
119 });
120 const acc = yield* AccessPolicy("Acc", {
121 policyName: ACC_POLICY,
122 policy: accessPolicy(principal),
123 });
124 return { enc, net, acc };
125 }),
126 );
127
128 expect(created.enc.policyName).toBe(ENC_POLICY);
129 expect(created.enc.type).toBe("encryption");
130 expect(created.net.type).toBe("network");
131 expect(created.acc.type).toBe("data");
132 const initialNetVersion = created.net.policyVersion;
133
134 // Out-of-band verification via distilled.
135 const encObserved = yield* aoss.getSecurityPolicy({
136 type: "encryption",
137 name: ENC_POLICY,
138 });
139 expect(encObserved.securityPolicyDetail?.name).toBe(ENC_POLICY);
140 const accObserved = yield* aoss.getAccessPolicy({
141 type: "data",
142 name: ACC_POLICY,
143 });
144 expect(accObserved.accessPolicyDetail?.name).toBe(ACC_POLICY);
145
146 // No-op redeploy: policy version must not change.
147 const noop = yield* stack.deploy(
148 Effect.gen(function* () {
149 const enc = yield* SecurityPolicy("Enc", {
150 policyName: ENC_POLICY,
151 type: "encryption",
152 policy: encryptionPolicy,
153 description: "alchemy test encryption policy",
154 });
155 const net = yield* SecurityPolicy("Net", {
156 policyName: NET_POLICY,
157 type: "network",
158 policy: networkPolicy,
159 });
160 const acc = yield* AccessPolicy("Acc", {
161 policyName: ACC_POLICY,
162 policy: accessPolicy(principal),
163 });
164 return { enc, net, acc };
165 }),
166 );
167 expect(noop.net.policyVersion).toBe(initialNetVersion);
168
169 // Update the network policy document → new policy version.
170 const updated = yield* stack.deploy(
171 Effect.gen(function* () {
172 const enc = yield* SecurityPolicy("Enc", {
173 policyName: ENC_POLICY,
174 type: "encryption",
175 policy: encryptionPolicy,
176 description: "alchemy test encryption policy",
177 });
178 const net = yield* SecurityPolicy("Net", {
179 policyName: NET_POLICY,
180 type: "network",
181 policy: [
182 {
183 Rules: [
184 {
185 ResourceType: "collection",
186 Resource: [`collection/${COLLECTION_NAME}`],
187 },
188 ],
189 AllowFromPublic: true,
190 },
191 ],
192 });
193 const acc = yield* AccessPolicy("Acc", {
194 policyName: ACC_POLICY,
195 policy: accessPolicy(principal),
196 });
197 return { enc, net, acc };
198 }),
199 );
200 expect(updated.net.policyVersion).not.toBe(initialNetVersion);
201
202 // Destroy and verify deletion out-of-band.
203 yield* stack.destroy();
204 yield* assertPolicyGone("encryption", ENC_POLICY);
205 yield* assertPolicyGone("network", NET_POLICY);
206 yield* assertAccessPolicyGone(ACC_POLICY);
207 }),
208 {
209 tags: ["provider:aws", "provider:aws:opensearchserverless", "live"],
210 timeout: 120_000,
211 },
212);
213
214// A VECTORSEARCH collection is the vector store Bedrock Knowledge Bases require.
215// Collections have a real-money OCU floor and take ~1-5 min to provision, so the
216// full lifecycle is gated behind AWS_TEST_SLOW=1, destroys IMMEDIATELY, and
217// verifies the collection is gone.
218test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
219 "create VECTORSEARCH collection, verify ACTIVE, destroy, verify gone (AWS_TEST_SLOW=1)",
220 (stack) =>
221 Effect.gen(function* () {
222 // Clean slate in case a previous run died mid-flight.
223 yield* stack.destroy();
224
225 const { Account } = yield* sts.getCallerIdentity({});
226 const principal = `arn:aws:iam::${Account}:role/alchemy-test-aoss-role`;
227
228 const { collection } = yield* stack.deploy(
229 Effect.gen(function* () {
230 // Encryption policy is a hard prerequisite for the collection.
231 const enc = yield* SecurityPolicy("Enc", {
232 policyName: ENC_POLICY,
233 type: "encryption",
234 policy: encryptionPolicy,
235 });
236 const net = yield* SecurityPolicy("Net", {
237 policyName: NET_POLICY,
238 type: "network",
239 policy: networkPolicy,
240 });
241 const acc = yield* AccessPolicy("Acc", {
242 policyName: ACC_POLICY,
243 policy: accessPolicy(principal),
244 });
245 const collection = yield* Collection("Collection", {
246 collectionName: COLLECTION_NAME,
247 type: "VECTORSEARCH",
248 standbyReplicas: "DISABLED",
249 // Ensure the policies exist first (bindings/refs are unnecessary —
250 // the encryption policy is matched by resource pattern — but keep
251 // them referenced so the engine deploys them alongside).
252 tags: { encPolicy: enc.policyName, netPolicy: net.policyName },
253 });
254 return { collection, acc };
255 }),
256 );
257
258 expect(collection.collectionName).toBe(COLLECTION_NAME);
259 expect(collection.type).toBe("VECTORSEARCH");
260 expect(collection.collectionArn).toContain(":collection/");
261 expect(collection.collectionId).toBeDefined();
262 // The data-plane endpoint that a Bedrock Knowledge Base points at.
263 expect(collection.collectionEndpoint).toContain("aoss.amazonaws.com");
264
265 // Out-of-band verification via distilled: the collection is ACTIVE.
266 const observed = yield* aoss.batchGetCollection({
267 ids: [collection.collectionId],
268 });
269 const detail = observed.collectionDetails?.[0];
270 expect(detail?.status).toBe("ACTIVE");
271 expect(detail?.type).toBe("VECTORSEARCH");
272
273 // Destroy immediately — collections meter OCUs while they exist — and
274 // verify deletion out-of-band.
275 yield* stack.destroy();
276 yield* assertCollectionGone(collection.collectionId);
277 }),
278 {
279 tags: ["provider:aws", "provider:aws:opensearchserverless", "live"],
280 timeout: 900_000,
281 },
282);
283
284const assertPolicyGone = (type: "encryption" | "network", name: string) =>
285 aoss.getSecurityPolicy({ type, name }).pipe(
286 Effect.flip,
287 Effect.map((e) => expect(e._tag).toBe("ResourceNotFoundException")),
288 Effect.retry({
289 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(5)]),
290 }),
291 );
292
293const assertAccessPolicyGone = (name: string) =>
294 aoss.getAccessPolicy({ type: "data", name }).pipe(
295 Effect.flip,
296 Effect.map((e) => expect(e._tag).toBe("ResourceNotFoundException")),
297 Effect.retry({
298 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(5)]),
299 }),
300 );
301
302// Deletion is verified as INITIATED (status `DELETING`) or fully gone — full
303// disappearance takes another ~1-2 min server-side.
304const assertCollectionGone = (id: string) =>
305 Effect.gen(function* () {
306 const response = yield* aoss.batchGetCollection({ ids: [id] });
307 const detail = response.collectionDetails?.[0];
308 const status = detail?.status ?? "gone";
309 if (status !== "gone" && status !== "DELETING") {
310 return yield* Effect.fail(
311 new Error(`Collection '${id}' still exists (status: ${status})`),
312 );
313 }
314 }).pipe(
315 Effect.retry({
316 schedule: Schedule.max([
317 Schedule.fixed("5 seconds"),
318 Schedule.recurs(24),
319 ]),
320 }),
321 );