1import * as Lambda from "@/AWS/Lambda";
2import * as ServiceCatalog from "@/AWS/ServiceCatalog";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12/**
13 * Well-formed identifiers that cannot exist — drive the typed not-found
14 * paths for the service-action and StackSet bindings (whose live setup
15 * needs an SSM-backed service action / StackSet product), proving the
16 * grants reach the API: an IAM gap would surface AccessDeniedException
17 * (an untyped defect) instead of the typed tag.
18 */
19const FAKE_PROVISIONED_PRODUCT_ID = "pp-aaaaaaaaaaaaa";
20const FAKE_SERVICE_ACTION_ID = "act-aaaaaaaaaaaaa";
21
22export class ServiceCatalogTestFunction extends Lambda.Function<Lambda.Function>()(
23 "ServiceCatalogTestFunction",
24) {}
25
26export default ServiceCatalogTestFunction.make(
27 {
28 main,
29 functionUrl: true,
30 timeout: Duration.seconds(30),
31 },
32 Effect.gen(function* () {
33 const searchProducts = yield* ServiceCatalog.SearchProducts();
34 const describeProduct = yield* ServiceCatalog.DescribeProduct();
35 const listLaunchPaths = yield* ServiceCatalog.ListLaunchPaths();
36 const describeProvisioningParameters =
37 yield* ServiceCatalog.DescribeProvisioningParameters();
38 const provisionProduct = yield* ServiceCatalog.ProvisionProduct();
39 const updateProvisionedProduct =
40 yield* ServiceCatalog.UpdateProvisionedProduct();
41 const terminateProvisionedProduct =
42 yield* ServiceCatalog.TerminateProvisionedProduct();
43 const describeProvisionedProduct =
44 yield* ServiceCatalog.DescribeProvisionedProduct();
45 const searchProvisionedProducts =
46 yield* ServiceCatalog.SearchProvisionedProducts();
47 const describeRecord = yield* ServiceCatalog.DescribeRecord();
48 const listRecordHistory = yield* ServiceCatalog.ListRecordHistory();
49 const getProvisionedProductOutputs =
50 yield* ServiceCatalog.GetProvisionedProductOutputs();
51 const listStackInstancesForProvisionedProduct =
52 yield* ServiceCatalog.ListStackInstancesForProvisionedProduct();
53 const executeProvisionedProductServiceAction =
54 yield* ServiceCatalog.ExecuteProvisionedProductServiceAction();
55 const describeServiceActionExecutionParameters =
56 yield* ServiceCatalog.DescribeServiceActionExecutionParameters();
57
58 const bound = {
59 searchProducts,
60 describeProduct,
61 listLaunchPaths,
62 describeProvisioningParameters,
63 provisionProduct,
64 updateProvisionedProduct,
65 terminateProvisionedProduct,
66 describeProvisionedProduct,
67 searchProvisionedProducts,
68 describeRecord,
69 listRecordHistory,
70 getProvisionedProductOutputs,
71 listStackInstancesForProvisionedProduct,
72 executeProvisionedProductServiceAction,
73 describeServiceActionExecutionParameters,
74 };
75
76 return {
77 fetch: Effect.gen(function* () {
78 const request = yield* HttpServerRequest;
79 const url = new URL(request.originalUrl);
80 const pathname = url.pathname;
81 const q = (name: string) => url.searchParams.get(name) ?? undefined;
82
83 if (pathname === "/bindings") {
84 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
85 }
86
87 // IAM policy propagation on the freshly attached binding grants can
88 // lag the deploy — surface the typed AccessDeniedException as a tag
89 // so the test's bounded polls ride it out instead of exhausting the
90 // 5xx retry budget.
91 if (pathname === "/search") {
92 const result = yield* searchProducts().pipe(
93 Effect.map((r) => ({
94 tag: "Ok" as string,
95 count: (r.ProductViewSummaries ?? []).length,
96 productIds: (r.ProductViewSummaries ?? []).map(
97 (p) => p.ProductId,
98 ),
99 })),
100 Effect.catchTag(
101 ["InvalidParametersException", "AccessDeniedException"],
102 (e) => Effect.succeed({ tag: e._tag as string, count: -1 }),
103 ),
104 );
105 return yield* HttpServerResponse.json(result);
106 }
107
108 if (pathname === "/product") {
109 const result = yield* describeProduct({ Id: q("id") }).pipe(
110 Effect.map((r) => ({
111 tag: "Ok" as string,
112 name: r.ProductViewSummary?.Name,
113 })),
114 Effect.catchTag(
115 [
116 "ResourceNotFoundException",
117 "InvalidParametersException",
118 "AccessDeniedException",
119 ],
120 (e) => Effect.succeed({ tag: e._tag as string, name: undefined }),
121 ),
122 );
123 return yield* HttpServerResponse.json(result);
124 }
125
126 if (pathname === "/launch-paths") {
127 const result = yield* listLaunchPaths({
128 ProductId: q("productId")!,
129 }).pipe(
130 Effect.map((r) => ({
131 tag: "Ok" as string,
132 count: (r.LaunchPathSummaries ?? []).length,
133 pathId: r.LaunchPathSummaries?.[0]?.Id,
134 })),
135 Effect.catchTag(
136 [
137 "ResourceNotFoundException",
138 "InvalidParametersException",
139 "AccessDeniedException",
140 ],
141 (e) =>
142 Effect.succeed({
143 tag: e._tag as string,
144 count: 0,
145 pathId: undefined,
146 }),
147 ),
148 );
149 return yield* HttpServerResponse.json(result);
150 }
151
152 if (pathname === "/params") {
153 const result = yield* describeProvisioningParameters({
154 ProductId: q("productId"),
155 ProvisioningArtifactId: q("artifactId"),
156 PathId: q("pathId"),
157 }).pipe(
158 Effect.map((r) => ({
159 tag: "Ok" as string,
160 count: (r.ProvisioningArtifactParameters ?? []).length,
161 })),
162 Effect.catchTag(
163 [
164 "ResourceNotFoundException",
165 "InvalidParametersException",
166 "AccessDeniedException",
167 ],
168 (e) => Effect.succeed({ tag: e._tag as string, count: 0 }),
169 ),
170 );
171 return yield* HttpServerResponse.json(result);
172 }
173
174 if (pathname === "/provision") {
175 const result = yield* provisionProduct({
176 ProductId: q("productId")!,
177 ProvisioningArtifactId: q("artifactId")!,
178 PathId: q("pathId"),
179 ProvisionedProductName: q("name")!,
180 ProvisionToken: q("token")!,
181 }).pipe(
182 Effect.map((r) => ({
183 tag: "Ok" as string,
184 recordId: r.RecordDetail?.RecordId,
185 provisionedProductId: r.RecordDetail?.ProvisionedProductId,
186 status: r.RecordDetail?.Status,
187 })),
188 Effect.catchTag(
189 [
190 "ResourceNotFoundException",
191 "InvalidParametersException",
192 "DuplicateResourceException",
193 "AccessDeniedException",
194 ],
195 (e) => Effect.succeed({ tag: e._tag as string }),
196 ),
197 );
198 return yield* HttpServerResponse.json(result);
199 }
200
201 if (pathname === "/record") {
202 const result = yield* describeRecord({ Id: q("id")! }).pipe(
203 Effect.map((r) => ({
204 tag: "Ok" as string,
205 status: r.RecordDetail?.Status,
206 errors: (r.RecordDetail?.RecordErrors ?? []).map(
207 (e) => e.Description,
208 ),
209 })),
210 Effect.catchTag(
211 ["ResourceNotFoundException", "AccessDeniedException"],
212 (e) => Effect.succeed({ tag: e._tag as string }),
213 ),
214 );
215 return yield* HttpServerResponse.json(result);
216 }
217
218 if (pathname === "/pp") {
219 const result = yield* describeProvisionedProduct({
220 Name: q("name"),
221 }).pipe(
222 Effect.map((r) => ({
223 tag: "Ok" as string,
224 status: r.ProvisionedProductDetail?.Status,
225 provisionedProductId: r.ProvisionedProductDetail?.Id,
226 lastRecordId: r.ProvisionedProductDetail?.LastRecordId,
227 })),
228 Effect.catchTag(
229 [
230 "ResourceNotFoundException",
231 "InvalidParametersException",
232 "AccessDeniedException",
233 ],
234 (e) => Effect.succeed({ tag: e._tag as string }),
235 ),
236 );
237 return yield* HttpServerResponse.json(result);
238 }
239
240 if (pathname === "/search-pp") {
241 const result = yield* searchProvisionedProducts().pipe(
242 Effect.map((r) => ({
243 tag: "Ok" as string,
244 count: (r.ProvisionedProducts ?? []).length,
245 })),
246 Effect.catchTag(
247 ["InvalidParametersException", "AccessDeniedException"],
248 (e) => Effect.succeed({ tag: e._tag as string, count: -1 }),
249 ),
250 );
251 return yield* HttpServerResponse.json(result);
252 }
253
254 if (pathname === "/history") {
255 const result = yield* listRecordHistory().pipe(
256 Effect.map((r) => ({
257 tag: "Ok" as string,
258 count: (r.RecordDetails ?? []).length,
259 })),
260 Effect.catchTag(
261 ["InvalidParametersException", "AccessDeniedException"],
262 (e) => Effect.succeed({ tag: e._tag as string, count: -1 }),
263 ),
264 );
265 return yield* HttpServerResponse.json(result);
266 }
267
268 if (pathname === "/outputs") {
269 const result = yield* getProvisionedProductOutputs({
270 ProvisionedProductName: q("name"),
271 }).pipe(
272 Effect.map((r) => ({
273 tag: "Ok" as string,
274 count: (r.Outputs ?? []).length,
275 })),
276 Effect.catchTag(
277 [
278 "ResourceNotFoundException",
279 "InvalidParametersException",
280 "AccessDeniedException",
281 ],
282 (e) => Effect.succeed({ tag: e._tag as string, count: 0 }),
283 ),
284 );
285 return yield* HttpServerResponse.json(result);
286 }
287
288 if (pathname === "/terminate") {
289 const result = yield* terminateProvisionedProduct({
290 ProvisionedProductName: q("name"),
291 TerminateToken: q("token")!,
292 }).pipe(
293 Effect.map((r) => ({
294 tag: "Ok" as string,
295 recordId: r.RecordDetail?.RecordId,
296 })),
297 Effect.catchTag(
298 ["ResourceNotFoundException", "AccessDeniedException"],
299 (e) =>
300 Effect.succeed({ tag: e._tag as string, recordId: undefined }),
301 ),
302 );
303 return yield* HttpServerResponse.json(result);
304 }
305
306 if (pathname === "/update-nonexistent") {
307 // Typed not-found — proves the UpdateProvisionedProduct grant
308 // reaches the API without mutating anything real.
309 const result = yield* updateProvisionedProduct({
310 ProvisionedProductName: "alchemy-sc-does-not-exist",
311 UpdateToken: "alchemyscupdateprobe",
312 }).pipe(
313 Effect.map(() => "Ok"),
314 Effect.catchTag(
315 ["ResourceNotFoundException", "InvalidParametersException"],
316 (e) => Effect.succeed(e._tag),
317 ),
318 );
319 return yield* HttpServerResponse.json({ tag: result });
320 }
321
322 if (pathname === "/stack-instances-nonexistent") {
323 const result = yield* listStackInstancesForProvisionedProduct({
324 ProvisionedProductId: FAKE_PROVISIONED_PRODUCT_ID,
325 }).pipe(
326 Effect.map(() => "Ok"),
327 Effect.catchTag(
328 ["ResourceNotFoundException", "InvalidParametersException"],
329 (e) => Effect.succeed(e._tag),
330 ),
331 );
332 return yield* HttpServerResponse.json({ tag: result });
333 }
334
335 if (pathname === "/service-action-params-nonexistent") {
336 const result = yield* describeServiceActionExecutionParameters({
337 ProvisionedProductId: FAKE_PROVISIONED_PRODUCT_ID,
338 ServiceActionId: FAKE_SERVICE_ACTION_ID,
339 }).pipe(
340 Effect.map(() => "Ok"),
341 Effect.catchTag(
342 ["ResourceNotFoundException", "InvalidParametersException"],
343 (e) => Effect.succeed(e._tag),
344 ),
345 );
346 return yield* HttpServerResponse.json({ tag: result });
347 }
348
349 if (pathname === "/execute-nonexistent") {
350 // Service Catalog misuses ValidationException for a missing
351 // provisioned product on this op — distilled remaps it to the
352 // synthetic ProvisionedProductNotFound tag
353 // (patches/service-catalog.json). ValidationException (a typed
354 // CommonErrors member) is also caught so the probe works against
355 // a distilled lib built before the patch.
356 const result = yield* executeProvisionedProductServiceAction({
357 ProvisionedProductId: FAKE_PROVISIONED_PRODUCT_ID,
358 ServiceActionId: FAKE_SERVICE_ACTION_ID,
359 ExecuteToken: "alchemyscexecuteprobe",
360 }).pipe(
361 Effect.map(() => "Ok"),
362 Effect.catchTag(
363 [
364 "ProvisionedProductNotFound",
365 "ValidationException",
366 "ResourceNotFoundException",
367 "InvalidParametersException",
368 "InvalidStateException",
369 ],
370 (e) => Effect.succeed(e._tag),
371 ),
372 );
373 return yield* HttpServerResponse.json({ tag: result });
374 }
375
376 return yield* HttpServerResponse.json(
377 { error: "Not found", method: request.method, pathname },
378 { status: 404 },
379 );
380 }).pipe(Effect.orDie),
381 };
382 }).pipe(
383 Effect.provide(
384 Layer.mergeAll(
385 Lambda.EventSource,
386 ServiceCatalog.SearchProductsHttp,
387 ServiceCatalog.DescribeProductHttp,
388 ServiceCatalog.ListLaunchPathsHttp,
389 ServiceCatalog.DescribeProvisioningParametersHttp,
390 ServiceCatalog.ProvisionProductHttp,
391 ServiceCatalog.UpdateProvisionedProductHttp,
392 ServiceCatalog.TerminateProvisionedProductHttp,
393 ServiceCatalog.DescribeProvisionedProductHttp,
394 ServiceCatalog.SearchProvisionedProductsHttp,
395 ServiceCatalog.DescribeRecordHttp,
396 ServiceCatalog.ListRecordHistoryHttp,
397 ServiceCatalog.GetProvisionedProductOutputsHttp,
398 ServiceCatalog.ListStackInstancesForProvisionedProductHttp,
399 ServiceCatalog.ExecuteProvisionedProductServiceActionHttp,
400 ServiceCatalog.DescribeServiceActionExecutionParametersHttp,
401 ),
402 ),
403 ),
404);