1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import {
4 Portfolio,
5 PortfolioProductAssociation,
6 PrincipalPortfolioAssociation,
7 Product,
8} from "@/AWS/ServiceCatalog";
9import * as Core from "@/Test/Core";
10import * as Test from "@/Test/Alchemy";
11import * as s3 from "@distilled.cloud/aws/s3";
12import * as servicecatalog from "@distilled.cloud/aws/service-catalog";
13import { describe, expect } from "alchemy-test";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Schedule from "effect/Schedule";
17import * as HttpClient from "effect/http/HttpClient";
18import * as HttpClientRequest from "effect/http/HttpClientRequest";
19import ServiceCatalogTestFunctionLive, {
20 ServiceCatalogTestFunction,
21} from "./handler";
22
23const testOptions = { providers: AWS.providers() };
24const { test, beforeAll, afterAll } = Test.make(testOptions);
25const sharedStack = Core.scratchStack(testOptions, "ServiceCatalogBindings");
26
27// Deterministic provisioned-product name — the physical anchor for
28// idempotency and cross-run cleanup.
29const PP_NAME = "alchemy-sc-bindings-pp";
30
31// Minimal valid CloudFormation template (a no-op resource) so provisioning
32// needs no resource permissions beyond CloudFormation itself. Generated
33// once and inlined — never at deploy time.
34const TEMPLATE = JSON.stringify({
35 AWSTemplateFormatVersion: "2010-09-09",
36 Description: "alchemy Service Catalog bindings test template",
37 Resources: {
38 NoOp: { Type: "AWS::CloudFormation::WaitConditionHandle" },
39 },
40});
41
42class ProvisionedProductStillExists extends Data.TaggedError(
43 "ScBindingsProvisionedProductStillExists",
44)<{ name: string }> {}
45
46// beforeAll/afterAll hooks run without the providers layer (unlike
47// `test.provider` bodies) — out-of-band distilled calls need it provided
48// explicitly.
49const outOfBand = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
50 Core.withProviders(effect, testOptions, sharedStack.name);
51
52/**
53 * Out-of-band cleanup: terminate any provisioned product a previous run
54 * left behind (its terminate token derives from the instance's unique ID
55 * so a retried cleanup converges) and wait — bounded — until it is gone.
56 */
57const ensureProvisionedProductGone = Effect.gen(function* () {
58 const existing = yield* servicecatalog
59 .describeProvisionedProduct({ Name: PP_NAME })
60 .pipe(
61 Effect.catchTag("ResourceNotFoundException", () =>
62 Effect.succeed(undefined),
63 ),
64 );
65 const id = existing?.ProvisionedProductDetail?.Id;
66 if (id === undefined) return;
67 yield* servicecatalog
68 .terminateProvisionedProduct({
69 ProvisionedProductId: id,
70 TerminateToken: id.replaceAll(/[^a-zA-Z0-9_-]/g, ""),
71 IgnoreErrors: true,
72 })
73 .pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
74 yield* servicecatalog.describeProvisionedProduct({ Name: PP_NAME }).pipe(
75 Effect.flatMap(() =>
76 Effect.fail(new ProvisionedProductStillExists({ name: PP_NAME })),
77 ),
78 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
79 Effect.retry({
80 while: (e): boolean =>
81 e._tag === "ScBindingsProvisionedProductStillExists",
82 schedule: Schedule.max([
83 Schedule.fixed("4 seconds"),
84 Schedule.recurs(20),
85 ]),
86 }),
87 );
88});
89
90// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
91// over 60s on a fresh deploy.
92const readinessPolicy = Schedule.max([
93 Schedule.fixed("2 seconds"),
94 Schedule.recurs(75),
95]);
96
97let baseUrl: string;
98let productId: string;
99let provisioningArtifactId: string;
100
101class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
102 readonly status: number;
103 readonly body: string;
104}> {}
105
106// The shared Lambda fixture occasionally answers a transient 5xx under load
107// (cold re-init, IAM propagation on the freshly attached policy that the
108// handler's `Effect.orDie` surfaces as a 500). Retry only 5xx; a genuine
109// 4xx/assertion failure surfaces immediately.
110const send = (request: HttpClientRequest.HttpClientRequest) =>
111 HttpClient.execute(request).pipe(
112 Effect.flatMap((response) =>
113 response.status >= 500
114 ? response.text.pipe(
115 Effect.flatMap((body) =>
116 Effect.fail(
117 new TransientUpstream({ status: response.status, body }),
118 ),
119 ),
120 )
121 : Effect.succeed(response),
122 ),
123 Effect.retry({
124 while: (e) => e._tag === "TransientUpstream",
125 schedule: Schedule.max([
126 Schedule.exponential("500 millis"),
127 Schedule.recurs(6),
128 ]),
129 }),
130 );
131
132const getJson = (path: string) =>
133 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
134 Effect.flatMap((r) => r.json),
135 );
136
137describe.sequential(
138 "ServiceCatalog Bindings",
139 {
140 tags: [
141 "provider:aws",
142 "provider:aws:lambda",
143 "provider:aws:s3",
144 "provider:aws:servicecatalog",
145 "live",
146 ],
147 },
148 () => {
149 beforeAll(
150 Effect.gen(function* () {
151 yield* Effect.logInfo(
152 "ServiceCatalog test setup: destroying previous resources",
153 );
154 yield* outOfBand(ensureProvisionedProductGone);
155 yield* sharedStack.destroy();
156
157 // Phase 1: a bucket to host the CloudFormation template — it must
158 // exist in S3 before the product can be created.
159 const phase1 = yield* sharedStack.deploy(
160 Effect.gen(function* () {
161 const bucket = yield* Bucket("ScBindingsTemplateBucket", {
162 forceDestroy: true,
163 });
164 return { bucketName: bucket.bucketName, region: bucket.region };
165 }),
166 );
167 yield* outOfBand(
168 s3.putObject({
169 Bucket: phase1.bucketName,
170 Key: "bindings-template.json",
171 Body: new TextEncoder().encode(TEMPLATE),
172 ContentType: "application/json",
173 }),
174 );
175 const templateUrl = `https://${phase1.bucketName}.s3.${phase1.region}.amazonaws.com/bindings-template.json`;
176
177 // Phase 2: portfolio + product + the fixture Lambda, whose execution
178 // role becomes the portfolio principal so the function's runtime
179 // bindings can browse and provision.
180 yield* Effect.logInfo("ServiceCatalog test setup: deploying fixture");
181 const deployed = yield* sharedStack.deploy(
182 Effect.gen(function* () {
183 yield* Bucket("ScBindingsTemplateBucket", { forceDestroy: true });
184 const portfolio = yield* Portfolio("ScBindingsPortfolio", {
185 providerName: "alchemy-tests",
186 });
187 const product = yield* Product("ScBindingsProduct", {
188 owner: "alchemy-tests",
189 description: "bindings test product",
190 provisioningArtifact: { templateUrl },
191 });
192 yield* PortfolioProductAssociation("ScBindingsProductInPortfolio", {
193 portfolioId: portfolio.portfolioId,
194 productId: product.productId,
195 });
196 const fn = yield* ServiceCatalogTestFunction;
197 yield* PrincipalPortfolioAssociation("ScBindingsFunctionAccess", {
198 portfolioId: portfolio.portfolioId,
199 principalArn: fn.roleArn,
200 });
201 return {
202 functionUrl: fn.functionUrl,
203 productId: product.productId,
204 provisioningArtifactId: product.provisioningArtifactId,
205 };
206 }).pipe(Effect.provide(ServiceCatalogTestFunctionLive)),
207 );
208
209 expect(deployed.functionUrl).toBeTruthy();
210 baseUrl = deployed.functionUrl!.replace(/\/+$/, "");
211 productId = deployed.productId;
212 provisioningArtifactId = deployed.provisioningArtifactId;
213
214 const readinessUrl = `${baseUrl}/bindings`;
215 yield* Effect.logInfo(
216 `ServiceCatalog test setup: probing readiness at ${readinessUrl}`,
217 );
218 yield* HttpClient.get(readinessUrl).pipe(
219 Effect.flatMap((response) =>
220 response.status === 200
221 ? Effect.succeed(response)
222 : Effect.fail(
223 new Error(`Function not ready: ${response.status}`),
224 ),
225 ),
226 Effect.tapError((error) =>
227 Effect.logWarning(
228 `ServiceCatalog test setup: fixture not ready yet (${String(error)})`,
229 ),
230 ),
231 Effect.retry({ schedule: readinessPolicy }),
232 );
233 }),
234 { timeout: 300_000 },
235 );
236
237 afterAll(
238 Effect.gen(function* () {
239 // The provisioned product must be gone before the product/portfolio
240 // can be deleted.
241 yield* outOfBand(ensureProvisionedProductGone);
242 yield* sharedStack.destroy();
243 }),
244 { timeout: 180_000 },
245 );
246
247 describe("binding registration", () => {
248 test.provider("all 15 capabilities initialize in the runtime", (_stack) =>
249 Effect.gen(function* () {
250 const response = (yield* getJson("/bindings")) as any;
251 expect(response.bound).toHaveLength(15);
252 }),
253 );
254 });
255
256 describe("SearchProducts", () => {
257 test.provider(
258 "searches the caller's product view",
259 (_stack) =>
260 Effect.gen(function* () {
261 // Search indexing can lag well behind the principal association —
262 // assert the binding round-trips (poll past IAM propagation);
263 // catalog visibility is asserted via ListLaunchPaths below.
264 const response = (yield* getJson("/search").pipe(
265 Effect.repeat({
266 schedule: Schedule.spaced("3 seconds"),
267 until: (r): boolean => (r as any).tag === "Ok",
268 times: 20,
269 }),
270 )) as any;
271 expect(response.tag).toBe("Ok");
272 expect(response.count).toBeGreaterThanOrEqual(0);
273 }),
274 { timeout: 60_000 },
275 );
276 });
277
278 describe("DescribeProduct + ListLaunchPaths + DescribeProvisioningParameters", () => {
279 test.provider(
280 "browses the associated product through the portfolio",
281 (_stack) =>
282 Effect.gen(function* () {
283 // The principal association is eventually consistent — poll the
284 // launch paths (bounded) until the portfolio grants access.
285 const paths = (yield* getJson(
286 `/launch-paths?productId=${productId}`,
287 ).pipe(
288 Effect.repeat({
289 schedule: Schedule.spaced("3 seconds"),
290 until: (r): boolean =>
291 (r as any).tag === "Ok" && (r as any).count > 0,
292 times: 30,
293 }),
294 )) as any;
295 expect(paths.tag).toBe("Ok");
296 expect(paths.count).toBeGreaterThan(0);
297
298 const product = (yield* getJson(`/product?id=${productId}`)) as any;
299 expect(product.tag).toBe("Ok");
300 expect(typeof product.name).toBe("string");
301
302 const params = (yield* getJson(
303 `/params?productId=${productId}&artifactId=${provisioningArtifactId}&pathId=${paths.pathId}`,
304 )) as any;
305 expect(params.tag).toBe("Ok");
306 // the no-op template declares no parameters
307 expect(params.count).toBe(0);
308 }),
309 { timeout: 180_000 },
310 );
311 });
312
313 describe("ProvisionProduct + DescribeRecord + provisioned-product reads + TerminateProvisionedProduct", () => {
314 test.provider(
315 "provisions, tracks, reads outputs, and terminates a product",
316 (_stack) =>
317 Effect.gen(function* () {
318 // request tokens are per-attempt idempotency tokens, not physical
319 // names — the deterministic anchor is PP_NAME.
320 const runToken = crypto.randomUUID().replaceAll("-", "");
321
322 // Retry-safety: a previous (failed) attempt may have left the
323 // provisioned product behind — terminate it first so provisioning
324 // with the deterministic name converges.
325 const existing = (yield* getJson(`/pp?name=${PP_NAME}`)) as any;
326 if (existing.tag === "Ok") {
327 yield* getJson(`/terminate?name=${PP_NAME}&token=c${runToken}`);
328 yield* getJson(`/pp?name=${PP_NAME}`).pipe(
329 Effect.repeat({
330 schedule: Schedule.spaced("4 seconds"),
331 until: (r): boolean =>
332 (r as any).tag === "ResourceNotFoundException",
333 times: 30,
334 }),
335 );
336 }
337
338 // Provision — retry (bounded) while portfolio access propagates;
339 // the constant token makes retried calls converge on one record.
340 const provisioned = (yield* getJson(
341 `/provision?productId=${productId}&artifactId=${provisioningArtifactId}&name=${PP_NAME}&token=p${runToken}`,
342 ).pipe(
343 Effect.repeat({
344 schedule: Schedule.spaced("3 seconds"),
345 until: (r): boolean => (r as any).tag === "Ok",
346 times: 20,
347 }),
348 )) as any;
349 expect(provisioned.tag).toBe("Ok");
350 expect(provisioned.recordId).toMatch(/^rec-/);
351
352 // DescribeRecord — poll the record to completion.
353 const record = (yield* getJson(
354 `/record?id=${provisioned.recordId}`,
355 ).pipe(
356 Effect.repeat({
357 schedule: Schedule.spaced("4 seconds"),
358 until: (r): boolean =>
359 (r as any).status === "SUCCEEDED" ||
360 (r as any).status === "FAILED",
361 times: 30,
362 }),
363 )) as any;
364 expect(record.errors ?? []).toEqual([]);
365 expect(record.status).toBe("SUCCEEDED");
366
367 // DescribeProvisionedProduct — available by name.
368 const pp = (yield* getJson(`/pp?name=${PP_NAME}`)) as any;
369 expect(pp.tag).toBe("Ok");
370 expect(pp.status).toBe("AVAILABLE");
371 expect(pp.provisionedProductId).toMatch(/^pp-/);
372
373 // SearchProvisionedProducts sees it.
374 const searched = (yield* getJson("/search-pp")) as any;
375 expect(searched.tag).toBe("Ok");
376 expect(searched.count).toBeGreaterThan(0);
377
378 // GetProvisionedProductOutputs — the no-op template declares no
379 // outputs, but Service Catalog injects `CloudformationStackARN`.
380 const outputs = (yield* getJson(`/outputs?name=${PP_NAME}`)) as any;
381 expect(outputs.tag).toBe("Ok");
382 expect(outputs.count).toBeGreaterThanOrEqual(0);
383
384 // ListRecordHistory includes the provision record.
385 const history = (yield* getJson("/history")) as any;
386 expect(history.tag).toBe("Ok");
387 expect(history.count).toBeGreaterThan(0);
388
389 // Terminate and wait — bounded — until the provisioned product
390 // is gone.
391 const terminated = (yield* getJson(
392 `/terminate?name=${PP_NAME}&token=t${runToken}`,
393 )) as any;
394 expect(terminated.tag).toBe("Ok");
395 const gone = (yield* getJson(`/pp?name=${PP_NAME}`).pipe(
396 Effect.repeat({
397 schedule: Schedule.spaced("4 seconds"),
398 until: (r): boolean =>
399 (r as any).tag === "ResourceNotFoundException",
400 times: 30,
401 }),
402 )) as any;
403 expect(gone.tag).toBe("ResourceNotFoundException");
404 }),
405 { timeout: 300_000 },
406 );
407 });
408
409 describe("UpdateProvisionedProduct", () => {
410 test.provider(
411 "surfaces the typed not-found for a nonexistent provisioned product (proving the grant)",
412 (_stack) =>
413 Effect.gen(function* () {
414 const response = (yield* getJson("/update-nonexistent")) as any;
415 expect([
416 "ResourceNotFoundException",
417 "InvalidParametersException",
418 ]).toContain(response.tag);
419 }),
420 { timeout: 60_000 },
421 );
422 });
423
424 describe("ListStackInstancesForProvisionedProduct", () => {
425 test.provider(
426 "surfaces the typed not-found for a nonexistent provisioned product (proving the grant)",
427 (_stack) =>
428 Effect.gen(function* () {
429 const response = (yield* getJson(
430 "/stack-instances-nonexistent",
431 )) as any;
432 expect([
433 "ResourceNotFoundException",
434 "InvalidParametersException",
435 ]).toContain(response.tag);
436 }),
437 { timeout: 60_000 },
438 );
439 });
440
441 describe("DescribeServiceActionExecutionParameters", () => {
442 test.provider(
443 "surfaces the typed not-found for a nonexistent service action (proving the grant)",
444 (_stack) =>
445 Effect.gen(function* () {
446 const response = (yield* getJson(
447 "/service-action-params-nonexistent",
448 )) as any;
449 expect([
450 "ResourceNotFoundException",
451 "InvalidParametersException",
452 ]).toContain(response.tag);
453 }),
454 { timeout: 60_000 },
455 );
456 });
457
458 describe("ExecuteProvisionedProductServiceAction", () => {
459 test.provider(
460 "surfaces the typed not-found for a nonexistent service action (proving the grant)",
461 (_stack) =>
462 Effect.gen(function* () {
463 const response = (yield* getJson("/execute-nonexistent")) as any;
464 expect([
465 "ProvisionedProductNotFound",
466 // pre-patch distilled lib delivers the raw tag
467 "ValidationException",
468 "ResourceNotFoundException",
469 "InvalidParametersException",
470 "InvalidStateException",
471 ]).toContain(response.tag);
472 }),
473 { timeout: 60_000 },
474 );
475 });
476 },
477);