1import * as AWS from "@/AWS";
2import { UserPool } from "@/AWS/Cognito";
3import * as Test from "@/Test/Alchemy";
4import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
5import { expect } from "alchemy-test";
6import * as Cause from "effect/Cause";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Exit from "effect/Exit";
10import * as Schedule from "effect/Schedule";
11import { fileURLToPath } from "node:url";
12
13const customEmailSenderPath = fileURLToPath(
14 new URL("./custom-email-sender-handler.ts", import.meta.url),
15);
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19class UserPoolStillExists extends Data.TaggedError("UserPoolStillExists")<{
20 readonly userPoolId: string;
21}> {}
22
23const assertPoolDeleted = (userPoolId: string) =>
24 cip.describeUserPool({ UserPoolId: userPoolId }).pipe(
25 Effect.flatMap(() => Effect.fail(new UserPoolStillExists({ userPoolId }))),
26 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
27 Effect.retry({
28 while: (e) => e._tag === "UserPoolStillExists",
29 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
30 }),
31 );
32
33test.provider(
34 "create, update policy and tags, no-op, delete user pool",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 const pool = yield* stack.deploy(
40 Effect.gen(function* () {
41 return yield* UserPool("LifecyclePool", {
42 passwordPolicy: { minimumLength: 10 },
43 adminCreateUserOnly: true,
44 tags: { Environment: "test" },
45 });
46 }),
47 );
48
49 expect(pool.userPoolId).toMatch(/^[a-z0-9-]+_[A-Za-z0-9]+$/);
50 expect(pool.userPoolArn).toContain(":userpool/");
51 expect(pool.userPoolName).toBeDefined();
52
53 // out-of-band verification via distilled
54 const created = yield* cip.describeUserPool({
55 UserPoolId: pool.userPoolId,
56 });
57 expect(created.UserPool?.Name).toBe(pool.userPoolName);
58 expect(created.UserPool?.Policies?.PasswordPolicy?.MinimumLength).toBe(
59 10,
60 );
61 expect(
62 created.UserPool?.AdminCreateUserConfig?.AllowAdminCreateUserOnly,
63 ).toBe(true);
64 expect(created.UserPool?.MfaConfiguration).toBe("OFF");
65 expect(created.UserPool?.DeletionProtection).toBe("INACTIVE");
66
67 const tags = yield* cip.listTagsForResource({
68 ResourceArn: pool.userPoolArn,
69 });
70 expect(tags.Tags?.Environment).toBe("test");
71 expect(tags.Tags?.["alchemy::id"]).toBe("LifecyclePool");
72
73 // update the password policy and tag set in place
74 const updated = yield* stack.deploy(
75 Effect.gen(function* () {
76 return yield* UserPool("LifecyclePool", {
77 passwordPolicy: { minimumLength: 14, requireSymbols: false },
78 adminCreateUserOnly: true,
79 tags: { Environment: "test", Extra: "1" },
80 });
81 }),
82 );
83 expect(updated.userPoolId).toBe(pool.userPoolId);
84
85 const afterUpdate = yield* cip.describeUserPool({
86 UserPoolId: pool.userPoolId,
87 });
88 expect(
89 afterUpdate.UserPool?.Policies?.PasswordPolicy?.MinimumLength,
90 ).toBe(14);
91 expect(
92 afterUpdate.UserPool?.Policies?.PasswordPolicy?.RequireSymbols,
93 ).toBe(false);
94 const updatedTags = yield* cip.listTagsForResource({
95 ResourceArn: pool.userPoolArn,
96 });
97 expect(updatedTags.Tags?.Extra).toBe("1");
98
99 // removing a tag converges
100 yield* stack.deploy(
101 Effect.gen(function* () {
102 return yield* UserPool("LifecyclePool", {
103 passwordPolicy: { minimumLength: 14, requireSymbols: false },
104 adminCreateUserOnly: true,
105 tags: { Environment: "test" },
106 });
107 }),
108 );
109 const afterRemoval = yield* cip.listTagsForResource({
110 ResourceArn: pool.userPoolArn,
111 });
112 expect(afterRemoval.Tags?.Extra).toBeUndefined();
113 expect(afterRemoval.Tags?.Environment).toBe("test");
114
115 yield* stack.destroy();
116 yield* assertPoolDeleted(pool.userPoolId);
117 }),
118 { tags: ["provider:aws", "provider:aws:cognito", "live"], timeout: 120_000 },
119);
120
121test.provider(
122 "custom schema attributes are added in place; sign-in change replaces",
123 (stack) =>
124 Effect.gen(function* () {
125 yield* stack.destroy();
126
127 const pool = yield* stack.deploy(
128 Effect.gen(function* () {
129 return yield* UserPool("SchemaPool", {
130 schema: [{ name: "tenantId", mutable: true }],
131 });
132 }),
133 );
134
135 const created = yield* cip.describeUserPool({
136 UserPoolId: pool.userPoolId,
137 });
138 const names = (created.UserPool?.SchemaAttributes ?? []).map(
139 (a) => a.Name,
140 );
141 expect(names).toContain("custom:tenantId");
142
143 // adding an attribute updates in place
144 const withMore = yield* stack.deploy(
145 Effect.gen(function* () {
146 return yield* UserPool("SchemaPool", {
147 schema: [
148 { name: "tenantId", mutable: true },
149 { name: "plan", mutable: true },
150 ],
151 });
152 }),
153 );
154 expect(withMore.userPoolId).toBe(pool.userPoolId);
155 const afterAdd = yield* cip.describeUserPool({
156 UserPoolId: pool.userPoolId,
157 });
158 const afterAddNames = (afterAdd.UserPool?.SchemaAttributes ?? []).map(
159 (a) => a.Name,
160 );
161 expect(afterAddNames).toContain("custom:plan");
162
163 // switching to email sign-in is immutable ⇒ replacement
164 const replaced = yield* stack.deploy(
165 Effect.gen(function* () {
166 return yield* UserPool("SchemaPool", {
167 schema: [
168 { name: "tenantId", mutable: true },
169 { name: "plan", mutable: true },
170 ],
171 usernameAttributes: ["email"],
172 });
173 }),
174 );
175 expect(replaced.userPoolId).not.toBe(pool.userPoolId);
176 const replacedPool = yield* cip.describeUserPool({
177 UserPoolId: replaced.userPoolId,
178 });
179 expect(replacedPool.UserPool?.UsernameAttributes).toEqual(["email"]);
180 yield* assertPoolDeleted(pool.userPoolId);
181
182 yield* stack.destroy();
183 yield* assertPoolDeleted(replaced.userPoolId);
184 }),
185 { tags: ["provider:aws", "provider:aws:cognito", "live"], timeout: 120_000 },
186);
187
188// Regression: https://github.com/alchemy-run/alchemy/issues/1311 — email OTP
189// through a CustomEmailSender needs `Policies.SignInPolicy`,
190// `LambdaConfig.CustomEmailSender` and `LambdaConfig.KMSKeyID`, which the
191// provider used to strip (it only ever sent the string trigger slots), and
192// an unrelated update must never clear an already-configured sender.
193test.provider(
194 "email OTP sign-in policy with a custom email sender and KMS key",
195 (stack) =>
196 Effect.gen(function* () {
197 yield* stack.destroy();
198
199 const sender = (passwordMinimumLength: number, withSender: boolean) =>
200 Effect.gen(function* () {
201 const key = yield* AWS.KMS.Key("CodeKey", {
202 description: "alchemy cognito custom sender codes",
203 deletionWindow: "7 days",
204 });
205 const fn = yield* AWS.Lambda.Function("EmailSender", {
206 main: customEmailSenderPath,
207 handler: "handler",
208 isExternal: true,
209 functionUrl: false,
210 });
211 const pool = yield* UserPool("OtpPool", {
212 tier: "ESSENTIALS",
213 usernameAttributes: ["email"],
214 passwordPolicy: { minimumLength: passwordMinimumLength },
215 signInPolicy: {
216 allowedFirstAuthFactors: ["PASSWORD", "EMAIL_OTP"],
217 },
218 ...(withSender
219 ? {
220 customEmailSender: { lambdaArn: fn.functionArn },
221 kmsKeyId: key.keyArn,
222 }
223 : {}),
224 });
225 return { pool, fn, key };
226 });
227
228 const created = yield* stack.deploy(sender(8, true));
229
230 const describe = () =>
231 cip
232 .describeUserPool({ UserPoolId: created.pool.userPoolId })
233 .pipe(Effect.map((r) => r.UserPool!));
234
235 const initial = yield* describe();
236 expect(initial.UserPoolTier).toBe("ESSENTIALS");
237 expect(
238 [
239 ...(initial.Policies?.SignInPolicy?.AllowedFirstAuthFactors ?? []),
240 ].sort(),
241 ).toEqual(["EMAIL_OTP", "PASSWORD"]);
242 expect(initial.LambdaConfig?.CustomEmailSender).toEqual({
243 LambdaArn: created.fn.functionArn,
244 LambdaVersion: "V1_0",
245 });
246 expect(initial.LambdaConfig?.KMSKeyID).toBe(created.key.keyArn);
247
248 // An unrelated update (password policy) must keep the custom sender,
249 // KMS key and sign-in policy intact — updateUserPool resets anything
250 // omitted from its body.
251 const updated = yield* stack.deploy(sender(12, true));
252 expect(updated.pool.userPoolId).toBe(created.pool.userPoolId);
253 const afterUnrelated = yield* describe();
254 expect(afterUnrelated.Policies?.PasswordPolicy?.MinimumLength).toBe(12);
255 expect(
256 [
257 ...(afterUnrelated.Policies?.SignInPolicy?.AllowedFirstAuthFactors ??
258 []),
259 ].sort(),
260 ).toEqual(["EMAIL_OTP", "PASSWORD"]);
261 expect(afterUnrelated.LambdaConfig?.CustomEmailSender).toEqual({
262 LambdaArn: created.fn.functionArn,
263 LambdaVersion: "V1_0",
264 });
265 expect(afterUnrelated.LambdaConfig?.KMSKeyID).toBe(created.key.keyArn);
266
267 // Explicitly dropping the sender clears it (and the key) on the pool.
268 const removed = yield* stack.deploy(sender(12, false));
269 expect(removed.pool.userPoolId).toBe(created.pool.userPoolId);
270 const afterRemoval = yield* describe();
271 expect(afterRemoval.LambdaConfig?.CustomEmailSender).toBeUndefined();
272 expect(afterRemoval.LambdaConfig?.KMSKeyID).toBeUndefined();
273 expect(afterRemoval.Policies?.PasswordPolicy?.MinimumLength).toBe(12);
274
275 yield* stack.destroy();
276 yield* assertPoolDeleted(created.pool.userPoolId);
277 }),
278 {
279 tags: [
280 "provider:aws",
281 "provider:aws:cognito",
282 "provider:aws:kms",
283 "provider:aws:lambda",
284 "live",
285 ],
286 timeout: 180_000,
287 },
288);
289
290// UpdateUserPool resets any field omitted from its body to the service
291// default — an unrelated update must echo the observed EmailConfiguration
292// back rather than clear it.
293test.provider(
294 "email configuration is set, survives unrelated updates, and converges",
295 (stack) =>
296 Effect.gen(function* () {
297 yield* stack.destroy();
298
299 const pool = yield* stack.deploy(
300 Effect.gen(function* () {
301 return yield* UserPool("EmailPool", {
302 autoVerifiedAttributes: ["email"],
303 emailConfiguration: {
304 replyToEmailAddress: "support@example.com",
305 },
306 });
307 }),
308 );
309
310 const describe = () =>
311 cip
312 .describeUserPool({ UserPoolId: pool.userPoolId })
313 .pipe(Effect.map((r) => r.UserPool!));
314
315 const created = yield* describe();
316 expect(created.EmailConfiguration?.EmailSendingAccount).toBe(
317 "COGNITO_DEFAULT",
318 );
319 expect(created.EmailConfiguration?.ReplyToEmailAddress).toBe(
320 "support@example.com",
321 );
322
323 // an unrelated update that OMITS emailConfiguration must preserve the
324 // observed configuration (updateUserPool would otherwise reset it)
325 const updated = yield* stack.deploy(
326 Effect.gen(function* () {
327 return yield* UserPool("EmailPool", {
328 autoVerifiedAttributes: ["email"],
329 passwordPolicy: { minimumLength: 12 },
330 });
331 }),
332 );
333 expect(updated.userPoolId).toBe(pool.userPoolId);
334 const afterUnrelated = yield* describe();
335 expect(afterUnrelated.Policies?.PasswordPolicy?.MinimumLength).toBe(12);
336 expect(afterUnrelated.EmailConfiguration?.ReplyToEmailAddress).toBe(
337 "support@example.com",
338 );
339
340 // changing the declared configuration converges
341 yield* stack.deploy(
342 Effect.gen(function* () {
343 return yield* UserPool("EmailPool", {
344 autoVerifiedAttributes: ["email"],
345 passwordPolicy: { minimumLength: 12 },
346 emailConfiguration: {
347 replyToEmailAddress: "help@example.com",
348 },
349 });
350 }),
351 );
352 const afterChange = yield* describe();
353 expect(afterChange.EmailConfiguration?.ReplyToEmailAddress).toBe(
354 "help@example.com",
355 );
356 expect(afterChange.EmailConfiguration?.EmailSendingAccount).toBe(
357 "COGNITO_DEFAULT",
358 );
359
360 yield* stack.destroy();
361 yield* assertPoolDeleted(pool.userPoolId);
362 }),
363 { tags: ["provider:aws", "provider:aws:cognito", "live"], timeout: 120_000 },
364);
365
366test.provider(
367 "customEmailSender without kmsKeyId and passwordless factors on LITE fail early",
368 (stack) =>
369 Effect.gen(function* () {
370 yield* stack.destroy();
371
372 const failureTag = (exit: Exit.Exit<unknown, unknown>) => {
373 expect(Exit.isFailure(exit)).toBe(true);
374 if (!Exit.isFailure(exit)) return undefined;
375 const reason = exit.cause.reasons.find(Cause.isFailReason);
376 return (reason?.error as { _tag?: string } | undefined)?._tag;
377 };
378
379 const missingKey = yield* stack
380 .deploy(
381 UserPool("InvalidPool", {
382 customEmailSender: {
383 lambdaArn:
384 "arn:aws:lambda:us-east-1:123456789012:function:sender",
385 },
386 }),
387 )
388 .pipe(Effect.exit);
389 expect(failureTag(missingKey)).toBe("InvalidUserPoolConfiguration");
390
391 const liteOtp = yield* stack
392 .deploy(
393 UserPool("InvalidPool", {
394 tier: "LITE",
395 signInPolicy: {
396 allowedFirstAuthFactors: ["PASSWORD", "EMAIL_OTP"],
397 },
398 }),
399 )
400 .pipe(Effect.exit);
401 expect(failureTag(liteOtp)).toBe("InvalidUserPoolConfiguration");
402
403 const developerWithoutSource = yield* stack
404 .deploy(
405 UserPool("InvalidPool", {
406 emailConfiguration: { emailSendingAccount: "DEVELOPER" },
407 }),
408 )
409 .pipe(Effect.exit);
410 expect(failureTag(developerWithoutSource)).toBe(
411 "InvalidUserPoolConfiguration",
412 );
413
414 // nothing was created — the validation runs before any API call
415 yield* stack.destroy();
416 }),
417 { tags: ["provider:aws", "provider:aws:cognito", "live"], timeout: 60_000 },
418);