1import * as AWS from "@/AWS";
2import {
3 Application,
4 DataSource,
5 Index,
6 Retriever,
7 WebExperience,
8} from "@/AWS/QBusiness";
9import * as Test from "@/Test/Alchemy";
10import * as qbusiness from "@distilled.cloud/aws/qbusiness";
11import { describe, expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17// Amazon Q Business applications require an IAM Identity Center (or
18// QuickSight/IAM IdP) identity source, which the shared testing account does
19// not provision. The ungated probes assert the distilled wiring surfaces the
20// typed errors the provider's read/delete and create paths depend on; the
21// full lifecycle is gated behind AWS_TEST_QBUSINESS=1 (an account with an
22// Identity Center instance, passed as QBUSINESS_IDC_INSTANCE_ARN).
23describe(
24 "AWS.QBusiness.Application",
25 { tags: ["provider:aws", "provider:aws:qbusiness", "live"] },
26 () => {
27 test.provider(
28 "getApplication on a nonexistent id yields a typed ResourceNotFoundException",
29 (_stack) =>
30 Effect.gen(function* () {
31 const error = yield* qbusiness
32 .getApplication({
33 applicationId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
34 })
35 .pipe(Effect.flip);
36 expect(error._tag).toBe("ResourceNotFoundException");
37 }),
38 { timeout: 60_000 },
39 );
40
41 test.provider(
42 "getIndex on a nonexistent application yields a typed ResourceNotFoundException",
43 (_stack) =>
44 Effect.gen(function* () {
45 const error = yield* qbusiness
46 .getIndex({
47 applicationId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
48 indexId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
49 })
50 .pipe(Effect.flip);
51 expect(error._tag).toBe("ResourceNotFoundException");
52 }),
53 { timeout: 60_000 },
54 );
55
56 // createApplication does NOT validate the Identity Center instance ARN
57 // synchronously — the application is created and converges asynchronously
58 // to the terminal FAILED status with an explanatory error detail. This
59 // probe proves the exact create → observe-FAILED → delete flow the
60 // provider's reconcile/delete paths depend on, without needing a real
61 // Identity Center instance. The application never becomes ACTIVE, so
62 // nothing bills.
63 test.provider(
64 "createApplication with a nonexistent Identity Center instance converges to FAILED and deletes cleanly",
65 (_stack) =>
66 Effect.gen(function* () {
67 const displayName = "alchemy-qbusiness-probe";
68 // Tolerate a leaked probe app from an interrupted prior run: a
69 // duplicate create conflicts, so fall back to the existing one.
70 const created = yield* qbusiness
71 .createApplication({
72 displayName,
73 identityCenterInstanceArn:
74 "arn:aws:sso:::instance/ssoins-0000000000000000",
75 })
76 .pipe(
77 Effect.map((r) => r.applicationId),
78 Effect.catchTag("ConflictException", () =>
79 qbusiness
80 .listApplications({})
81 .pipe(
82 Effect.map(
83 (r) =>
84 r.applications?.find(
85 (a) =>
86 a.displayName === displayName &&
87 a.status !== "DELETING",
88 )?.applicationId,
89 ),
90 ),
91 ),
92 );
93 if (created === undefined) {
94 return yield* Effect.die(
95 new Error("no probe applicationId available"),
96 );
97 }
98 const applicationId = created;
99
100 yield* Effect.gen(function* () {
101 // Converges to FAILED with a typed error detail.
102 const failed = yield* qbusiness
103 .getApplication({ applicationId })
104 .pipe(
105 Effect.repeat({
106 schedule: Schedule.spaced("5 seconds"),
107 until: (r) => r.status === "FAILED",
108 times: 24,
109 }),
110 );
111 expect(failed.status).toBe("FAILED");
112 expect(failed.error?.errorMessage).toContain("Identity Center");
113 }).pipe(
114 // Always delete the probe application, even if assertions fail.
115 Effect.ensuring(
116 qbusiness.deleteApplication({ applicationId }).pipe(
117 Effect.catchTag("ResourceNotFoundException", () =>
118 Effect.succeed({}),
119 ),
120 Effect.ignore,
121 ),
122 ),
123 );
124
125 // Deletion initiated — DELETING or gone.
126 const after = yield* qbusiness.getApplication({ applicationId }).pipe(
127 Effect.map((r) => r.status ?? "gone"),
128 Effect.catchTag("ResourceNotFoundException", () =>
129 Effect.succeed("gone" as const),
130 ),
131 );
132 expect(["DELETING", "gone"]).toContain(after);
133 }),
134 { timeout: 180_000 },
135 );
136
137 test.provider.skipIf(!process.env.AWS_TEST_QBUSINESS)(
138 "create application + index + retriever + web experience, update, destroy, verify gone",
139 (stack) =>
140 Effect.gen(function* () {
141 yield* stack.destroy();
142
143 const identityCenterInstanceArn =
144 process.env.QBUSINESS_IDC_INSTANCE_ARN;
145 if (!identityCenterInstanceArn) {
146 return yield* Effect.die(
147 new Error(
148 "AWS_TEST_QBUSINESS runs require QBUSINESS_IDC_INSTANCE_ARN",
149 ),
150 );
151 }
152
153 const deploy = (props: { description?: string }) =>
154 stack.deploy(
155 Effect.gen(function* () {
156 const app = yield* Application("Assistant", {
157 identityCenterInstanceArn,
158 description: props.description,
159 tags: { Environment: "test" },
160 });
161
162 const index = yield* Index("Docs", {
163 applicationId: app.applicationId,
164 });
165
166 const retriever = yield* Retriever("Docs", {
167 applicationId: app.applicationId,
168 type: "NATIVE_INDEX",
169 configuration: {
170 nativeIndexConfiguration: { indexId: index.indexId },
171 },
172 });
173
174 // Role the data source connector assumes; CUSTOM sources need
175 // index write access only.
176 const dataRole = yield* AWS.IAM.Role("QBusinessDataRole", {
177 assumeRolePolicyDocument: {
178 Version: "2012-10-17",
179 Statement: [
180 {
181 Effect: "Allow",
182 Principal: { Service: ["qbusiness.amazonaws.com"] },
183 Action: ["sts:AssumeRole"],
184 },
185 ],
186 },
187 inlinePolicies: {
188 ingest: {
189 Version: "2012-10-17",
190 Statement: [
191 {
192 Effect: "Allow",
193 Action: [
194 "qbusiness:BatchPutDocument",
195 "qbusiness:BatchDeleteDocument",
196 ],
197 Resource: ["*"],
198 },
199 ],
200 },
201 },
202 });
203
204 const source = yield* DataSource("Custom", {
205 applicationId: app.applicationId,
206 indexId: index.indexId,
207 roleArn: dataRole.roleArn,
208 configuration: { type: "CUSTOM", version: "1.0.0" },
209 });
210
211 const web = yield* WebExperience("Chat", {
212 applicationId: app.applicationId,
213 title: "Alchemy Test Assistant",
214 });
215
216 return { app, index, retriever, source, web };
217 }),
218 );
219
220 // Create — reconcile waits (bounded) for ACTIVE.
221 const { app, index, retriever, source, web } = yield* deploy({});
222 expect(app.applicationId).toBeDefined();
223 expect(app.status).toBe("ACTIVE");
224 expect(index.indexId).toBeDefined();
225 expect(index.status).toBe("ACTIVE");
226 expect(retriever.retrieverId).toBeDefined();
227 expect(retriever.status).toBe("ACTIVE");
228 expect(source.dataSourceId).toBeDefined();
229 expect(source.status).toBe("ACTIVE");
230 expect(web.webExperienceId).toBeDefined();
231 expect(web.defaultEndpoint).toBeDefined();
232
233 // Out-of-band verification via distilled.
234 const described = yield* qbusiness.getApplication({
235 applicationId: app.applicationId,
236 });
237 expect(described.status).toBe("ACTIVE");
238
239 // Update in place — description flows through UpdateApplication.
240 const updated = yield* deploy({ description: "updated by test" });
241 expect(updated.app.applicationId).toBe(app.applicationId);
242 const redescribed = yield* qbusiness.getApplication({
243 applicationId: app.applicationId,
244 });
245 expect(redescribed.description).toBe("updated by test");
246
247 yield* stack.destroy();
248
249 // Typed wait-until-gone.
250 yield* Effect.gen(function* () {
251 const gone = yield* qbusiness
252 .getApplication({ applicationId: app.applicationId })
253 .pipe(
254 Effect.map((d) => d.status === "DELETING"),
255 Effect.catchTag("ResourceNotFoundException", () =>
256 Effect.succeed(true),
257 ),
258 );
259 if (!gone) {
260 return yield* Effect.fail({ _tag: "StillExists" as const });
261 }
262 }).pipe(
263 Effect.retry({
264 while: (e: { _tag: string }) => e._tag === "StillExists",
265 schedule: Schedule.max([
266 Schedule.spaced("15 seconds"),
267 Schedule.recurs(40),
268 ]),
269 }),
270 );
271 }),
272 { tags: ["provider:aws:iam"], timeout: 3_600_000 },
273 );
274 },
275);