1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as appintegrations from "@distilled.cloud/aws/appintegrations";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import AppIntegrationsTestFunctionLive, {
10 AppIntegrationsTestFunction,
11} from "./fixtures/handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "AppIntegrationsBindings");
16
17// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
18// over 60s on a fresh deploy under parallel-suite load.
19const readinessPolicy = Schedule.max([
20 Schedule.fixed("2 seconds"),
21 Schedule.recurs(75),
22]);
23
24let baseUrl: string;
25
26const getJson = <T>(pathname: string) =>
27 HttpClient.get(`${baseUrl}${pathname}`).pipe(
28 Effect.flatMap((response) =>
29 response.status === 200
30 ? Effect.succeed(response)
31 : Effect.fail(new Error(`unexpected status ${response.status}`)),
32 ),
33 Effect.flatMap((response) => response.json),
34 Effect.map((body) => body as T),
35 // First requests after deploy can hit IAM-propagation AccessDenied
36 // surfaced as a 500 by the fixture's orDie — bounded retry.
37 Effect.retry({
38 schedule: Schedule.max([
39 Schedule.exponential("1 second"),
40 Schedule.recurs(8),
41 ]),
42 }),
43 );
44
45const postJson = <T>(pathname: string) =>
46 HttpClient.post(`${baseUrl}${pathname}`).pipe(
47 Effect.flatMap((response) =>
48 response.status === 200
49 ? Effect.succeed(response)
50 : Effect.fail(new Error(`unexpected status ${response.status}`)),
51 ),
52 Effect.flatMap((response) => response.json),
53 Effect.map((body) => body as T),
54 Effect.retry({
55 schedule: Schedule.max([
56 Schedule.exponential("1 second"),
57 Schedule.recurs(8),
58 ]),
59 }),
60 );
61
62describe(
63 "AppIntegrations Bindings",
64 {
65 tags: [
66 "provider:aws",
67 "provider:aws:appintegrations",
68 "provider:aws:kms",
69 "provider:aws:lambda",
70 "provider:aws:s3",
71 "live",
72 ],
73 },
74 () => {
75 beforeAll(
76 Effect.gen(function* () {
77 yield* sharedStack.destroy();
78
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* AppIntegrationsTestFunction;
82 }).pipe(Effect.provide(AppIntegrationsTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87
88 yield* HttpClient.get(`${baseUrl}/applications`).pipe(
89 Effect.flatMap((response) =>
90 response.status === 200
91 ? Effect.succeed(response)
92 : Effect.fail(
93 new Error(`Function not ready: ${response.status}`),
94 ),
95 ),
96 Effect.retry({ schedule: readinessPolicy }),
97 );
98 }),
99 { timeout: 240_000 },
100 );
101
102 afterAll(sharedStack.destroy(), { timeout: 120_000 });
103
104 describe("ListApplications", () => {
105 test.provider("lists the fixture application", () =>
106 Effect.gen(function* () {
107 const body = yield* getJson<{
108 self: string;
109 applications: { Id?: string }[];
110 }>("/applications");
111 expect(Array.isArray(body.applications)).toBe(true);
112 expect(body.applications.map((a) => a.Id)).toContain(body.self);
113 }),
114 );
115 });
116
117 describe("ListApplicationAssociations", () => {
118 test.provider("lists the application's associations (empty)", () =>
119 Effect.gen(function* () {
120 const body = yield* getJson<{ associations: unknown[] }>(
121 "/application-associations",
122 );
123 // Nothing associates with the fixture application — the call
124 // succeeding with an array proves the binding + IAM grant.
125 expect(Array.isArray(body.associations)).toBe(true);
126 }),
127 );
128 });
129
130 describe("ListDataIntegrations", () => {
131 test.provider("lists the fixture data integration", () =>
132 Effect.gen(function* () {
133 const body = yield* getJson<{
134 self: string;
135 dataIntegrations: { Name?: string; Arn?: string }[];
136 }>("/data-integrations");
137 expect(Array.isArray(body.dataIntegrations)).toBe(true);
138 // The list API returns Arn/Name summaries; resolve self (an id) via
139 // distilled to compare by name.
140 const observed = yield* appintegrations.getDataIntegration({
141 Identifier: body.self,
142 });
143 expect(body.dataIntegrations.map((d) => d.Name)).toContain(
144 observed.Name,
145 );
146 }),
147 );
148 });
149
150 describe("ListDataIntegrationAssociations", () => {
151 test.provider("lists the data integration's associations (empty)", () =>
152 Effect.gen(function* () {
153 const body = yield* getJson<{ associations: unknown[] }>(
154 "/data-integration-associations",
155 );
156 expect(Array.isArray(body.associations)).toBe(true);
157 }),
158 );
159 });
160
161 describe("CreateDataIntegrationAssociation / UpdateDataIntegrationAssociation", () => {
162 // AppIntegrations attaches a service-managed resource-based policy to
163 // DataIntegrations that explicitly denies association creation to
164 // principals other than approved client services — Amazon Connect /
165 // Amazon Q create the associations when the integration is configured
166 // there. The ungated probe asserts the typed AccessDeniedException whose
167 // message names the exact action AND the resolved data-integration ARN,
168 // proving the binding wiring, the IAM grant path, and the identifier
169 // injection end-to-end. Run the full lifecycle on an entitled principal
170 // with AWS_TEST_APPINTEGRATIONS_ASSOCIATIONS=1.
171 test.provider.skipIf(!!process.env.AWS_TEST_APPINTEGRATIONS_ASSOCIATIONS)(
172 "probe: the service's resource policy denies association creation",
173 () =>
174 Effect.gen(function* () {
175 const body = yield* postJson<{
176 created: string;
177 message?: string;
178 associationId?: string;
179 updated: string;
180 }>("/data-integration-associations").pipe(
181 Effect.repeat({
182 // Transient IAM-propagation denials lack the "explicit deny"
183 // clause — retry (bounded) until the authoritative denial (or
184 // an unexpected success) is observed.
185 schedule: Schedule.spaced("5 seconds"),
186 until: (b): boolean =>
187 b.created === "ok" ||
188 (b.message ?? "").includes("explicit deny"),
189 times: 8,
190 }),
191 );
192 expect(body.created).toBe("AccessDeniedException");
193 expect(body.message).toContain(
194 "app-integrations:CreateDataIntegrationAssociation on resource: arn:aws:app-integrations",
195 );
196 expect(body.message).toContain(":data-integration/");
197 expect(body.message).toContain(
198 "explicit deny in a resource-based policy",
199 );
200 }),
201 );
202
203 test.provider.skipIf(!process.env.AWS_TEST_APPINTEGRATIONS_ASSOCIATIONS)(
204 "creates an association and reruns its on-demand job",
205 () =>
206 Effect.gen(function* () {
207 const body = yield* postJson<{
208 created: string;
209 message?: string;
210 associationId?: string;
211 updated: string;
212 }>("/data-integration-associations").pipe(
213 Effect.repeat({
214 schedule: Schedule.spaced("5 seconds"),
215 until: (b): boolean => b.created === "ok" && b.updated === "ok",
216 times: 10,
217 }),
218 );
219 expect(body, JSON.stringify(body)).toMatchObject({
220 created: "ok",
221 updated: "ok",
222 });
223 expect(body.associationId).toBeTruthy();
224
225 // The association is now visible through the list binding.
226 const listed = yield* getJson<{
227 associations: { DataIntegrationAssociationArn?: string }[];
228 }>("/data-integration-associations");
229 expect(
230 listed.associations.map((a) =>
231 a.DataIntegrationAssociationArn?.split("/").at(-1),
232 ),
233 ).toContain(body.associationId);
234 }),
235 );
236 });
237
238 describe("ListEventIntegrations", () => {
239 test.provider("lists the fixture event integration", () =>
240 Effect.gen(function* () {
241 const body = yield* getJson<{
242 self: string;
243 eventIntegrations: { Name?: string }[];
244 }>("/event-integrations");
245 expect(Array.isArray(body.eventIntegrations)).toBe(true);
246 expect(body.eventIntegrations.map((e) => e.Name)).toContain(
247 body.self,
248 );
249 }),
250 );
251 });
252
253 describe("ListEventIntegrationAssociations", () => {
254 test.provider("lists the event integration's associations (empty)", () =>
255 Effect.gen(function* () {
256 const body = yield* getJson<{ associations: unknown[] }>(
257 "/event-integration-associations",
258 );
259 expect(Array.isArray(body.associations)).toBe(true);
260 }),
261 );
262 });
263
264 describe("consumeIntegrationEvents", () => {
265 test.provider("creates the EventIntegration for the event source", () =>
266 Effect.gen(function* () {
267 // The fixture's event source created an EventIntegration on the
268 // default bus — verify it out-of-band via distilled.
269 const body = yield* getJson<{ self: string }>("/event-integrations");
270 const observed = yield* appintegrations.getEventIntegration({
271 Name: body.self,
272 });
273 expect(observed.EventBridgeBus).toBe("default");
274 expect(observed.EventFilter?.Source).toBe(
275 "aws.partner/examplepartner.com/alchemy-bindings",
276 );
277 }),
278 );
279 });
280 },
281);