1import * as AppRegistry from "@/AWS/AppRegistry";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import path from "pathe";
8
9const main = path.resolve(import.meta.dirname, "handler.ts");
10
11// Deterministic name of a stack that never exists — drives the typed
12// ResourceNotFoundException paths for GetAssociatedResource / SyncResource.
13const NONEXISTENT_STACK = "alchemy-appregistry-bindings-nonexistent-stack";
14
15export class AppRegistryTestFunction extends Lambda.Function<Lambda.Function>()(
16 "AppRegistryTestFunction",
17) {}
18
19export default AppRegistryTestFunction.make(
20 {
21 main,
22 functionUrl: true,
23 },
24 Effect.gen(function* () {
25 const app = yield* AppRegistry.Application("BindingsApp", {
26 description: "AppRegistry bindings fixture",
27 });
28 const group = yield* AppRegistry.AttributeGroup("BindingsGroup", {
29 attributes: { owner: "alchemy-test", tier: "bindings" },
30 });
31 yield* AppRegistry.AttributeGroupAssociation("BindingsGroupAssoc", {
32 application: app.applicationId,
33 attributeGroup: group.attributeGroupId,
34 });
35
36 // --- application-scoped bindings ---
37 const getApplication = yield* AppRegistry.GetApplication(app);
38 const getAssociatedResource = yield* AppRegistry.GetAssociatedResource(app);
39 const listAssociatedResources =
40 yield* AppRegistry.ListAssociatedResources(app);
41 const listAssociatedAttributeGroups =
42 yield* AppRegistry.ListAssociatedAttributeGroups(app);
43 const listAttributeGroupsForApplication =
44 yield* AppRegistry.ListAttributeGroupsForApplication(app);
45
46 // --- attribute-group-scoped bindings ---
47 const getAttributeGroup = yield* AppRegistry.GetAttributeGroup(group);
48
49 // --- account-level bindings ---
50 const syncResource = yield* AppRegistry.SyncResource();
51 const listApplications = yield* AppRegistry.ListApplications();
52 const listAttributeGroups = yield* AppRegistry.ListAttributeGroups();
53
54 const bound = {
55 getApplication,
56 getAssociatedResource,
57 listAssociatedResources,
58 listAssociatedAttributeGroups,
59 listAttributeGroupsForApplication,
60 getAttributeGroup,
61 syncResource,
62 listApplications,
63 listAttributeGroups,
64 };
65
66 return {
67 fetch: Effect.gen(function* () {
68 const request = yield* HttpServerRequest;
69 const url = new URL(request.originalUrl);
70 const pathname = url.pathname;
71
72 if (request.method === "GET" && pathname === "/bindings") {
73 return yield* HttpServerResponse.json({
74 bound: Object.keys(bound),
75 });
76 }
77
78 if (request.method === "GET" && pathname === "/application") {
79 const result = yield* getApplication();
80 return yield* HttpServerResponse.json({
81 name: result.name ?? null,
82 associatedResourceCount: result.associatedResourceCount ?? 0,
83 });
84 }
85
86 if (request.method === "GET" && pathname === "/attribute-group") {
87 const result = yield* getAttributeGroup();
88 return yield* HttpServerResponse.json({
89 name: result.name ?? null,
90 attributes: JSON.parse(result.attributes ?? "{}"),
91 });
92 }
93
94 if (
95 request.method === "GET" &&
96 pathname === "/associated-attribute-groups"
97 ) {
98 const result = yield* listAssociatedAttributeGroups({
99 maxResults: 20,
100 });
101 return yield* HttpServerResponse.json({
102 count: (result.attributeGroups ?? []).length,
103 attributeGroups: result.attributeGroups ?? [],
104 });
105 }
106
107 if (
108 request.method === "GET" &&
109 pathname === "/attribute-groups-details"
110 ) {
111 const result = yield* listAttributeGroupsForApplication({
112 maxResults: 20,
113 });
114 return yield* HttpServerResponse.json({
115 names: (result.attributeGroupsDetails ?? []).map(
116 (detail) => detail.name ?? null,
117 ),
118 });
119 }
120
121 if (request.method === "GET" && pathname === "/associated-resources") {
122 const result = yield* listAssociatedResources({ maxResults: 20 });
123 return yield* HttpServerResponse.json({
124 count: (result.resources ?? []).length,
125 });
126 }
127
128 if (
129 request.method === "GET" &&
130 pathname === "/associated-resource-not-found"
131 ) {
132 // Exercises `application` injection + the typed not-found path.
133 const result = yield* getAssociatedResource({
134 resourceType: "CFN_STACK",
135 resource: NONEXISTENT_STACK,
136 }).pipe(
137 Effect.map(() => ({ found: true })),
138 Effect.catchTag("ResourceNotFoundException", () =>
139 Effect.succeed({ found: false }),
140 ),
141 );
142 return yield* HttpServerResponse.json(result);
143 }
144
145 if (request.method === "GET" && pathname === "/applications") {
146 // The fixture's own application must appear in the account listing.
147 // Bounded pagination (5 pages) keeps the assertion robust as the
148 // account accumulates applications.
149 const names: (string | null)[] = [];
150 let nextToken: string | undefined;
151 for (let page = 0; page < 5; page++) {
152 const result = yield* listApplications({
153 maxResults: 25,
154 nextToken,
155 });
156 names.push(
157 ...(result.applications ?? []).map((a) => a.name ?? null),
158 );
159 nextToken = result.nextToken;
160 if (!nextToken) break;
161 }
162 return yield* HttpServerResponse.json({ names });
163 }
164
165 if (request.method === "GET" && pathname === "/attribute-groups") {
166 // The fixture's own attribute group must appear in the account
167 // listing. Bounded pagination (5 pages) as above.
168 const names: (string | null)[] = [];
169 let nextToken: string | undefined;
170 for (let page = 0; page < 5; page++) {
171 const result = yield* listAttributeGroups({
172 maxResults: 25,
173 nextToken,
174 });
175 names.push(
176 ...(result.attributeGroups ?? []).map((g) => g.name ?? null),
177 );
178 nextToken = result.nextToken;
179 if (!nextToken) break;
180 }
181 return yield* HttpServerResponse.json({ names });
182 }
183
184 if (request.method === "POST" && pathname === "/sync-resource") {
185 // A nonexistent stack proves the servicecatalog:SyncResource grant
186 // end-to-end via its typed error (an IAM gap would surface
187 // AccessDeniedException instead).
188 const tag = yield* syncResource({
189 resourceType: "CFN_STACK",
190 resource: NONEXISTENT_STACK,
191 }).pipe(
192 Effect.map(() => "Synced"),
193 Effect.catchTag(
194 [
195 "ResourceNotFoundException",
196 "ValidationException",
197 // Not expected — surfaced (and failed on) by the test with a
198 // readable tag instead of an opaque 500 if the grant is wrong.
199 "AccessDeniedException",
200 ],
201 (e) => Effect.succeed(e._tag),
202 ),
203 );
204 return yield* HttpServerResponse.json({ tag });
205 }
206
207 return yield* HttpServerResponse.json(
208 { error: "Not found", method: request.method, pathname },
209 { status: 404 },
210 );
211 }).pipe(Effect.orDie),
212 };
213 }).pipe(
214 Effect.provide(
215 Layer.mergeAll(
216 AppRegistry.GetApplicationHttp,
217 AppRegistry.GetAssociatedResourceHttp,
218 AppRegistry.ListAssociatedResourcesHttp,
219 AppRegistry.ListAssociatedAttributeGroupsHttp,
220 AppRegistry.ListAttributeGroupsForApplicationHttp,
221 AppRegistry.GetAttributeGroupHttp,
222 AppRegistry.SyncResourceHttp,
223 AppRegistry.ListApplicationsHttp,
224 AppRegistry.ListAttributeGroupsHttp,
225 ),
226 ),
227 ),
228);