1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as identitystore from "@distilled.cloud/aws/identitystore";
5import * as ssoAdmin from "@distilled.cloud/aws/sso-admin";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import IdentityCenterBindingsFunctionLive, {
11 IdentityCenterBindingsFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16
17// The testing account is an organization management account with no enabled
18// Identity Center instance, and `CreateInstance` there fails with a typed
19// ValidationException ("Organization management account is not allowed to
20// perform the operation."), so the Lambda E2E — which binds a live instance —
21// is gated behind ALCHEMY_TEST_IDENTITY_CENTER=1 for entitled accounts (same
22// gate as the resource lifecycle tests in this suite).
23const RUN_LIVE = !!process.env.ALCHEMY_TEST_IDENTITY_CENTER;
24
25// Ungated typed-error probes: prove the distilled error unions the bindings
26// depend on are typed on every account, entitled or not, at near-zero cost.
27test.provider(
28 "describeUser on a nonexistent identity store fails with a typed tag",
29 () =>
30 Effect.gen(function* () {
31 const error = yield* Effect.flip(
32 identitystore.describeUser({
33 IdentityStoreId: "d-9067000000",
34 UserId: "00000000-0000-0000-0000-000000000000",
35 }),
36 );
37 expect([
38 "ResourceNotFoundException",
39 "ValidationException",
40 "AccessDeniedException",
41 ]).toContain(error._tag);
42 }),
43 { tags: ["provider:aws", "provider:aws:identitycenter", "live"] },
44);
45
46test.provider(
47 "isMemberInGroups on a nonexistent identity store fails with a typed tag",
48 () =>
49 Effect.gen(function* () {
50 const error = yield* Effect.flip(
51 identitystore.isMemberInGroups({
52 IdentityStoreId: "d-9067000000",
53 MemberId: { UserId: "00000000-0000-0000-0000-000000000000" },
54 GroupIds: ["00000000-0000-0000-0000-000000000000"],
55 }),
56 );
57 expect([
58 "ResourceNotFoundException",
59 "ValidationException",
60 "AccessDeniedException",
61 ]).toContain(error._tag);
62 }),
63 { tags: ["provider:aws", "provider:aws:identitycenter", "live"] },
64);
65
66test.provider(
67 "listPermissionSets on a nonexistent instance fails with a typed tag",
68 () =>
69 Effect.gen(function* () {
70 const error = yield* Effect.flip(
71 ssoAdmin.listPermissionSets({
72 InstanceArn: "arn:aws:sso:::instance/ssoins-0000000000000000",
73 }),
74 );
75 expect([
76 "ResourceNotFoundException",
77 "ValidationException",
78 "AccessDeniedException",
79 ]).toContain(error._tag);
80 }),
81 { tags: ["provider:aws", "provider:aws:identitycenter", "live"] },
82);
83
84const sharedStack = Core.scratchStack(testOptions, "IdentityCenterBindings");
85
86// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
87// over 60s on a fresh deploy.
88const readinessPolicy = Schedule.max([
89 Schedule.fixed("2 seconds"),
90 Schedule.recurs(75),
91]);
92
93let baseUrl: string;
94
95const getJson = (path: string) =>
96 HttpClient.get(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
97const postJson = (path: string) =>
98 HttpClient.post(`${baseUrl}${path}`).pipe(Effect.flatMap((r) => r.json));
99
100describe.sequential(
101 "IdentityCenter Bindings (E2E)",
102 {
103 tags: [
104 "provider:aws",
105 "provider:aws:identitycenter",
106 "provider:aws:lambda",
107 "live",
108 ],
109 },
110 () => {
111 beforeAll(
112 Effect.gen(function* () {
113 if (!RUN_LIVE) return;
114 yield* Effect.logInfo(
115 "IdentityCenter E2E setup: destroying previous resources",
116 );
117 yield* sharedStack.destroy();
118
119 yield* Effect.logInfo("IdentityCenter E2E setup: deploying fixture");
120 const attrs = yield* sharedStack.deploy(
121 Effect.gen(function* () {
122 return yield* IdentityCenterBindingsFunction;
123 }).pipe(Effect.provide(IdentityCenterBindingsFunctionLive)),
124 );
125
126 expect(attrs.functionUrl).toBeTruthy();
127 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
128
129 yield* HttpClient.get(`${baseUrl}/bindings`).pipe(
130 Effect.flatMap((response) =>
131 response.status === 200
132 ? Effect.succeed(response)
133 : Effect.fail(
134 new Error(`Function not ready: ${response.status}`),
135 ),
136 ),
137 Effect.retry({ schedule: readinessPolicy }),
138 );
139 }),
140 { timeout: 240_000 },
141 );
142
143 afterAll(
144 Effect.gen(function* () {
145 if (!RUN_LIVE) return;
146 yield* sharedStack.destroy();
147 }),
148 { timeout: 180_000 },
149 );
150
151 describe("binding registration", () => {
152 test.provider.skipIf(!RUN_LIVE)(
153 "all twenty capabilities initialize in the runtime",
154 () =>
155 Effect.gen(function* () {
156 const response = (yield* getJson("/bindings")) as {
157 bound: string[];
158 };
159 expect(response.bound).toHaveLength(20);
160 }),
161 );
162 });
163
164 describe("group + user lookups", () => {
165 test.provider.skipIf(!RUN_LIVE)(
166 "listGroups sees the fixture group and getGroupId resolves it",
167 () =>
168 Effect.gen(function* () {
169 const groups = (yield* getJson("/groups")) as {
170 displayNames: string[];
171 };
172 expect(groups.displayNames).toContain("alchemy-idc-bindings-group");
173
174 const groupId = (yield* getJson("/group-id")) as {
175 groupId: string;
176 matchesBoundGroup: boolean;
177 };
178 expect(groupId.matchesBoundGroup).toBe(true);
179 }),
180 { timeout: 120_000 },
181 );
182
183 test.provider.skipIf(!RUN_LIVE)(
184 "listUsers enumerates the identity store",
185 () =>
186 Effect.gen(function* () {
187 const users = (yield* getJson("/users")) as { userNames: string[] };
188 expect(Array.isArray(users.userNames)).toBe(true);
189 }),
190 { timeout: 120_000 },
191 );
192 });
193
194 describe("user lifecycle", () => {
195 test.provider.skipIf(!RUN_LIVE)(
196 "create/getUserId/describe/update/delete roundtrip",
197 () =>
198 Effect.gen(function* () {
199 const result = (yield* postJson("/user-roundtrip")) as {
200 userId: string;
201 resolvedMatches: boolean;
202 userName: string;
203 displayNameBefore: string;
204 displayNameAfter: string;
205 };
206 expect(result.userId).toBeTruthy();
207 expect(result.resolvedMatches).toBe(true);
208 expect(result.userName).toBe("alchemy-idc-bindings-user");
209 expect(result.displayNameAfter).toBe(
210 "Alchemy Bindings Test User (updated)",
211 );
212 }),
213 { timeout: 120_000 },
214 );
215 });
216
217 describe("membership lifecycle", () => {
218 test.provider.skipIf(!RUN_LIVE)(
219 "create/resolve/describe/list/check/delete roundtrip",
220 () =>
221 Effect.gen(function* () {
222 const result = (yield* postJson("/membership-roundtrip")) as {
223 membershipId: string;
224 resolvedMatches: boolean;
225 describedGroupMatches: boolean;
226 memberCount: number;
227 memberOfCount: number;
228 isMember: boolean;
229 isMemberAfterDelete: boolean;
230 };
231 expect(result.membershipId).toBeTruthy();
232 expect(result.resolvedMatches).toBe(true);
233 expect(result.describedGroupMatches).toBe(true);
234 expect(result.memberCount).toBeGreaterThanOrEqual(1);
235 expect(result.memberOfCount).toBeGreaterThanOrEqual(1);
236 expect(result.isMember).toBe(true);
237 expect(result.isMemberAfterDelete).toBe(false);
238 }),
239 { timeout: 120_000 },
240 );
241 });
242
243 describe("permission set + assignment audit", () => {
244 test.provider.skipIf(!RUN_LIVE)(
245 "listPermissionSets/describePermissionSet see the fixture permission set",
246 () =>
247 Effect.gen(function* () {
248 const result = (yield* getJson("/permission-sets")) as {
249 containsFixturePermissionSet: boolean;
250 name: string;
251 sessionDuration: string;
252 provisionedAccounts: string[];
253 };
254 expect(result.containsFixturePermissionSet).toBe(true);
255 expect(result.name).toBe("AlchemyIdcBindings");
256 expect(result.sessionDuration).toBe("PT1H");
257 }),
258 { timeout: 120_000 },
259 );
260
261 test.provider.skipIf(!RUN_LIVE)(
262 "assignment audit reads answer for the fixture group",
263 () =>
264 Effect.gen(function* () {
265 const result = (yield* getJson("/assignments")) as {
266 forPrincipal:
267 | { ok: true; count: number }
268 | { ok: false; errorTag: string };
269 };
270 // Organization instances answer with a count; account instances
271 // reject ListAccountAssignmentsForPrincipal with a typed tag.
272 if (result.forPrincipal.ok) {
273 expect(result.forPrincipal.count).toBeGreaterThanOrEqual(0);
274 } else {
275 expect(result.forPrincipal.errorTag).toBeTruthy();
276 }
277 }),
278 { timeout: 120_000 },
279 );
280 });
281 },
282);