1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as iam from "@distilled.cloud/aws/iam";
5import * as Lambda from "@distilled.cloud/aws/lambda";
6import { expect } from "alchemy-test";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import { fileURLToPath } from "node:url";
14import { TestFunction, TestFunctionLive } from "./handler.ts";
15
16const timeoutHandlerPath = fileURLToPath(
17 new URL("./timeout-handler.ts", import.meta.url),
18);
19const externalPackageHandlerPath = fileURLToPath(
20 new URL("./external-package-handler.ts", import.meta.url),
21);
22const lockfilePinnedHandlerPath = (format: "npm" | "bun" | "pnpm" | "yarn") =>
23 fileURLToPath(
24 new URL(
25 `./fixtures/lockfile-pinning/${format}/handler.ts`,
26 import.meta.url,
27 ),
28 );
29
30const { test } = Test.make({ providers: AWS.providers() });
31
32test.provider(
33 "create, update, delete function",
34 (stack) =>
35 Effect.gen(function* () {
36 yield* stack.destroy();
37
38 const deploy = (marker: string) =>
39 stack.deploy(
40 Effect.gen(function* () {
41 const fn = yield* TestFunction;
42 yield* fn.bind`ReadinessMarker`({
43 env: { READINESS_MARKER: marker },
44 });
45 return fn;
46 }).pipe(Effect.provide(TestFunctionLive)),
47 );
48
49 const { functionName, functionUrl, roleName } = yield* deploy("created");
50 yield* assertFunctionReady(functionName, "created");
51
52 const updated = yield* deploy("updated");
53 expect(updated.functionName).toBe(functionName);
54 yield* assertFunctionReady(updated.functionName, "updated");
55
56 expect(functionUrl).toBeTruthy();
57
58 const response = yield* HttpClient.get(functionUrl!).pipe(
59 Effect.flatMap((response) =>
60 response.status === 200
61 ? Effect.succeed(response)
62 : Effect.fail(
63 new Error(`Function URL returned ${response.status}`),
64 ),
65 ),
66 Effect.tapError((error) => Effect.logError(error)),
67 Effect.retry({
68 schedule: Schedule.max([
69 Schedule.exponential(500),
70 Schedule.recurs(10),
71 ]),
72 }),
73 );
74
75 expect(response.status).toBe(200);
76 expect(yield* response.text).toBe("Hello, world!");
77
78 const invokePolicy = yield* getPolicyStatement(
79 functionName,
80 "FunctionURLAllowPublicInvoke",
81 );
82 expect(invokePolicy.Condition).toEqual({
83 Bool: {
84 "lambda:InvokedViaFunctionUrl": "true",
85 },
86 });
87
88 yield* stack.destroy();
89 yield* assertFunctionDeleted(functionName);
90 yield* assertRoleDeleted(roleName);
91
92 const recreated = yield* deploy("recreated");
93 yield* assertFunctionReady(recreated.functionName, "recreated");
94 yield* stack.destroy();
95 yield* assertFunctionDeleted(recreated.functionName);
96 yield* assertRoleDeleted(recreated.roleName);
97 }).pipe(
98 Effect.tap(() => stack.destroy()),
99 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
100 ),
101 {
102 tags: ["provider:aws", "provider:aws:iam", "provider:aws:lambda", "live"],
103 timeout: 180_000,
104 },
105);
106
107test.provider(
108 "applies and updates the Lambda timeout",
109 (stack) =>
110 Effect.gen(function* () {
111 yield* stack.destroy();
112
113 const initial = yield* stack.deploy(
114 AWS.Lambda.Function("TimeoutFn", {
115 main: timeoutHandlerPath,
116 handler: "handler",
117 isExternal: true,
118 functionUrl: false,
119 timeout: Duration.seconds(15),
120 }),
121 );
122
123 const initialConfig = yield* Lambda.getFunction({
124 FunctionName: initial.functionName,
125 });
126 expect(initialConfig.Configuration?.Timeout).toBe(15);
127
128 yield* stack.deploy(
129 AWS.Lambda.Function("TimeoutFn", {
130 main: timeoutHandlerPath,
131 handler: "handler",
132 isExternal: true,
133 functionUrl: false,
134 timeout: Duration.seconds(45),
135 }),
136 );
137
138 const updatedConfig = yield* Lambda.getFunction({
139 FunctionName: initial.functionName,
140 }).pipe(
141 Effect.filterOrFail(
142 (c) => c.Configuration?.Timeout === 45,
143 () => new Error("Timeout update has not propagated yet"),
144 ),
145 Effect.retry({
146 schedule: Schedule.max([
147 Schedule.exponential(500),
148 Schedule.recurs(10),
149 ]),
150 }),
151 );
152 expect(updatedConfig.Configuration?.Timeout).toBe(45);
153
154 yield* stack.destroy();
155 yield* assertFunctionDeleted(initial.functionName);
156 }).pipe(
157 Effect.tap(() => stack.destroy()),
158 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
159 ),
160 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
161);
162
163test.provider(
164 "installs explicit external packages into the deployment artifact",
165 (stack) =>
166 Effect.gen(function* () {
167 yield* stack.destroy();
168
169 const { functionName, functionUrl } = yield* stack.deploy(
170 AWS.Lambda.Function("InstallFn", {
171 main: externalPackageHandlerPath,
172 handler: "handler",
173 isExternal: true,
174 functionUrl: true,
175 build: {
176 install: ["uuid"],
177 },
178 }),
179 );
180
181 const response = yield* HttpClient.get(functionUrl!).pipe(
182 Effect.flatMap((response) =>
183 response.status === 200
184 ? Effect.succeed(response)
185 : Effect.fail(
186 new Error(`Function URL returned ${response.status}`),
187 ),
188 ),
189 Effect.retry({
190 schedule: Schedule.max([
191 Schedule.exponential(500),
192 Schedule.recurs(10),
193 ]),
194 }),
195 );
196
197 const body = JSON.parse(yield* response.text) as { id: string };
198 expect(body.id).toMatch(
199 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
200 );
201
202 yield* stack.destroy();
203 yield* assertFunctionDeleted(functionName);
204 }).pipe(
205 Effect.tap(() => stack.destroy()),
206 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
207 ),
208 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
209);
210
211test.provider(
212 "installs external packages at the versions pinned by each lockfile format",
213 (stack) =>
214 Effect.gen(function* () {
215 yield* stack.destroy();
216
217 // Each fixture project declares make-dir@^3.0.0 but its lockfile pins
218 // make-dir@3.0.0 and its transitive semver@6.3.0 — both below the latest
219 // versions satisfying their ranges (3.1.0 / 6.3.1). Free npm resolution
220 // installs the newer versions, so the assertions below fail without
221 // lockfile pinning. Legacy binary bun.lockb has no live fixture because
222 // current Bun can only write the text format; its print-and-parse path
223 // is covered by the yarn-v1 unit fixtures.
224 const formats = ["npm", "bun", "pnpm", "yarn"] as const;
225 const pinnedFunction = (format: (typeof formats)[number]) =>
226 AWS.Lambda.Function(`LockfilePinnedFn-${format}`, {
227 main: lockfilePinnedHandlerPath(format),
228 handler: "handler",
229 isExternal: true,
230 functionUrl: true,
231 build: {
232 install: ["make-dir"],
233 },
234 });
235 const deployed = yield* stack.deploy(
236 Effect.all({
237 npm: pinnedFunction("npm"),
238 bun: pinnedFunction("bun"),
239 pnpm: pinnedFunction("pnpm"),
240 yarn: pinnedFunction("yarn"),
241 }),
242 );
243
244 for (const format of formats) {
245 const { functionUrl } = deployed[format];
246 const response = yield* HttpClient.get(functionUrl!).pipe(
247 Effect.flatMap((response) =>
248 response.status === 200
249 ? Effect.succeed(response)
250 : Effect.fail(
251 new Error(`Function URL returned ${response.status}`),
252 ),
253 ),
254 Effect.retry({
255 schedule: Schedule.max([
256 Schedule.exponential(500),
257 Schedule.recurs(10),
258 ]),
259 }),
260 );
261
262 const body = JSON.parse(yield* response.text) as {
263 makeDir: string;
264 semver: string;
265 };
266 expect(body.makeDir, format).toBe("3.0.0");
267 expect(body.semver, format).toBe("6.3.0");
268 }
269
270 yield* stack.destroy();
271 yield* assertFunctionDeleted(deployed.npm.functionName);
272 }).pipe(
273 Effect.tap(() => stack.destroy()),
274 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
275 ),
276 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
277);
278
279test.provider(
280 "applies and updates the Lambda architecture",
281 (stack) =>
282 Effect.gen(function* () {
283 yield* stack.destroy();
284
285 const initial = yield* stack.deploy(
286 AWS.Lambda.Function("ArchitectureFn", {
287 main: timeoutHandlerPath,
288 handler: "handler",
289 isExternal: true,
290 functionUrl: false,
291 architecture: "arm64",
292 }),
293 );
294
295 yield* waitForArchitecture(initial.functionName, "arm64");
296
297 const updated = yield* stack.deploy(
298 AWS.Lambda.Function("ArchitectureFn", {
299 main: timeoutHandlerPath,
300 handler: "handler",
301 isExternal: true,
302 functionUrl: false,
303 }),
304 );
305
306 expect(updated.functionName).toBe(initial.functionName);
307 yield* waitForArchitecture(updated.functionName, "x86_64");
308
309 yield* stack.destroy();
310 yield* assertFunctionDeleted(initial.functionName);
311 }).pipe(
312 Effect.tap(() => stack.destroy()),
313 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
314 ),
315 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
316);
317
318test.provider(
319 "applies, updates, and removes reserved concurrency",
320 (stack) =>
321 Effect.gen(function* () {
322 yield* stack.destroy();
323
324 const initial = yield* stack.deploy(
325 AWS.Lambda.Function("ConcurrencyFn", {
326 main: timeoutHandlerPath,
327 handler: "handler",
328 isExternal: true,
329 functionUrl: false,
330 }),
331 );
332
333 expect(initial.reservedConcurrentExecutions).toBeUndefined();
334 yield* waitForReservedConcurrency(initial.functionName, undefined);
335
336 const updated = yield* stack.deploy(
337 AWS.Lambda.Function("ConcurrencyFn", {
338 main: timeoutHandlerPath,
339 handler: "handler",
340 isExternal: true,
341 functionUrl: false,
342 reservedConcurrentExecutions: 0,
343 }),
344 );
345
346 expect(updated.functionName).toBe(initial.functionName);
347 expect(updated.reservedConcurrentExecutions).toBe(0);
348 yield* waitForReservedConcurrency(updated.functionName, 0);
349
350 const removed = yield* stack.deploy(
351 AWS.Lambda.Function("ConcurrencyFn", {
352 main: timeoutHandlerPath,
353 handler: "handler",
354 isExternal: true,
355 functionUrl: false,
356 }),
357 );
358
359 expect(removed.functionName).toBe(initial.functionName);
360 expect(removed.reservedConcurrentExecutions).toBeUndefined();
361 yield* waitForReservedConcurrency(removed.functionName, undefined);
362
363 yield* stack.destroy();
364 yield* assertFunctionDeleted(initial.functionName);
365 }).pipe(
366 Effect.tap(() => stack.destroy()),
367 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
368 ),
369 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
370);
371
372// Canonical `list()` test (AWS account/region-scoped collection): deploy a
373// real function, resolve the provider from context via the typed
374// `Provider.findProvider`, call `list()`, and assert the deployed function
375// appears in the exhaustively-paginated result.
376test.provider(
377 "list enumerates the deployed function",
378 (stack) =>
379 Effect.gen(function* () {
380 yield* stack.destroy();
381
382 const deployed = yield* stack.deploy(
383 AWS.Lambda.Function("ListFn", {
384 main: timeoutHandlerPath,
385 handler: "handler",
386 isExternal: true,
387 functionUrl: false,
388 }),
389 );
390
391 const provider = yield* Provider.findProvider(AWS.Lambda.Function);
392 const all = yield* provider.list();
393
394 expect(all.some((f) => f.functionName === deployed.functionName)).toBe(
395 true,
396 );
397
398 yield* stack.destroy();
399 yield* assertFunctionDeleted(deployed.functionName);
400 }).pipe(
401 Effect.tap(() => stack.destroy()),
402 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
403 ),
404 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 180_000 },
405);
406
407test.provider(
408 "updates function URL auth to AWS_IAM",
409 (stack) =>
410 Effect.gen(function* () {
411 yield* stack.destroy();
412
413 const initial = yield* stack.deploy(
414 AWS.Lambda.Function("IamUrlFn", {
415 main: timeoutHandlerPath,
416 handler: "handler",
417 isExternal: true,
418 functionUrl: true,
419 }),
420 );
421
422 const invokePolicy = yield* getPolicyStatement(
423 initial.functionName,
424 "FunctionURLAllowPublicInvoke",
425 );
426 expect(invokePolicy.Condition).toEqual({
427 Bool: {
428 "lambda:InvokedViaFunctionUrl": "true",
429 },
430 });
431
432 const updated = yield* stack.deploy(
433 AWS.Lambda.Function("IamUrlFn", {
434 main: timeoutHandlerPath,
435 handler: "handler",
436 isExternal: true,
437 functionUrl: {
438 authType: "AWS_IAM",
439 cors: {
440 AllowHeaders: ["authorization", "content-type"],
441 AllowMethods: ["GET", "POST"],
442 AllowOrigins: ["https://example.com"],
443 ExposeHeaders: ["x-request-id"],
444 MaxAge: 300,
445 },
446 invokeMode: "RESPONSE_STREAM",
447 },
448 }),
449 );
450
451 expect(updated.functionName).toBe(initial.functionName);
452 expect(updated.functionUrl).toBeTruthy();
453
454 const config = yield* getFunctionUrlConfigWithAuth(
455 updated.functionName,
456 "AWS_IAM",
457 );
458 expect(config.AuthType).toBe("AWS_IAM");
459 expect(config.InvokeMode).toBe("RESPONSE_STREAM");
460 expect(config.Cors).toEqual({
461 AllowHeaders: ["authorization", "content-type"],
462 AllowMethods: ["GET", "POST"],
463 AllowOrigins: ["https://example.com"],
464 ExposeHeaders: ["x-request-id"],
465 MaxAge: 300,
466 });
467
468 yield* waitForPolicyStatementAbsent(
469 updated.functionName,
470 "FunctionURLAllowPublicAccess",
471 );
472 yield* waitForPolicyStatementAbsent(
473 updated.functionName,
474 "FunctionURLAllowPublicInvoke",
475 );
476
477 const response = yield* HttpClient.get(updated.functionUrl!).pipe(
478 Effect.flatMap((response) =>
479 response.status === 403
480 ? Effect.succeed(response)
481 : Effect.fail(
482 new Error(`IAM Function URL returned ${response.status}`),
483 ),
484 ),
485 Effect.retry({
486 schedule: Schedule.max([
487 Schedule.exponential(500),
488 Schedule.recurs(10),
489 ]),
490 }),
491 );
492 expect(response.status).toBe(403);
493
494 yield* stack.destroy();
495 yield* assertFunctionDeleted(initial.functionName);
496 }).pipe(
497 Effect.tap(() => stack.destroy()),
498 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
499 ),
500 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 360_000 },
501);
502
503const assertFunctionReady = Effect.fn(function* (
504 functionName: string,
505 marker: string,
506) {
507 // Deploy must finish configuration propagation; these checks never retry.
508 const { Configuration } = yield* Lambda.getFunction({
509 FunctionName: functionName,
510 });
511 expect(Configuration?.State).toBe("Active");
512 expect(Configuration?.LastUpdateStatus).toBe("Successful");
513 const observed = Configuration?.Environment?.Variables?.READINESS_MARKER;
514 expect(
515 Redacted.isRedacted(observed) ? Redacted.value(observed) : observed,
516 ).toBe(marker);
517
518 const response = yield* Lambda.invoke({
519 FunctionName: functionName,
520 Payload: JSON.stringify({
521 version: "2.0",
522 rawPath: "/readiness",
523 rawQueryString: "",
524 headers: { host: "localhost" },
525 requestContext: {
526 http: {
527 method: "GET",
528 path: "/readiness",
529 protocol: "HTTP/1.1",
530 sourceIp: "127.0.0.1",
531 userAgent: "alchemy-test",
532 },
533 },
534 isBase64Encoded: false,
535 }),
536 });
537 expect(response.FunctionError).toBeUndefined();
538 const payload = response.Payload
539 ? yield* response.Payload.pipe(Stream.decodeText(), Stream.mkString)
540 : "";
541 const body = yield* Effect.try(() => JSON.parse(payload));
542 expect(body.statusCode).toBe(200);
543 expect(body.body).toBe(marker);
544});
545
546// Out-of-band proof that the trailing destroy actually removed the function
547// from the cloud (bounded retry to ride out delete propagation).
548const assertFunctionDeleted = Effect.fn(function* (functionName: string) {
549 yield* Lambda.getFunction({ FunctionName: functionName }).pipe(
550 Effect.flatMap(() =>
551 Effect.fail(new Error(`Function ${functionName} still exists`)),
552 ),
553 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
554 Effect.retry({
555 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
556 }),
557 );
558});
559
560// Function cleanup owns its generated execution role. Verify the role is not
561// stranded when deletion also waits for Lambda's asynchronous log flush.
562const assertRoleDeleted = Effect.fn(function* (roleName: string) {
563 yield* iam.getRole({ RoleName: roleName }).pipe(
564 Effect.flatMap(() =>
565 Effect.fail(new Error(`Role ${roleName} still exists`)),
566 ),
567 Effect.catchTag("NoSuchEntityException", () => Effect.void),
568 Effect.retry({
569 schedule: Schedule.spaced("1 second"),
570 times: 8,
571 }),
572 );
573});
574
575const getPolicyStatement = Effect.fn(function* (
576 functionName: string,
577 statementId: string,
578) {
579 return yield* findPolicyStatement(functionName, statementId).pipe(
580 Effect.flatMap((statement) =>
581 statement
582 ? Effect.succeed(statement)
583 : Effect.fail(new Error(`Policy statement ${statementId} not found`)),
584 ),
585 Effect.retry({
586 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
587 }),
588 );
589});
590
591const waitForPolicyStatementAbsent = Effect.fn(function* (
592 functionName: string,
593 statementId: string,
594) {
595 yield* findPolicyStatement(functionName, statementId).pipe(
596 Effect.flatMap((statement) =>
597 statement
598 ? Effect.fail(new Error(`Policy statement ${statementId} still exists`))
599 : Effect.void,
600 ),
601 Effect.retry({
602 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
603 }),
604 );
605});
606
607const findPolicyStatement = Effect.fn(function* (
608 functionName: string,
609 statementId: string,
610) {
611 return yield* Lambda.getPolicy({ FunctionName: functionName }).pipe(
612 Effect.flatMap(({ Policy }) =>
613 Effect.try({
614 try: () => {
615 const policy = JSON.parse(Policy ?? "{}") as {
616 Statement?: Array<{
617 Sid?: string;
618 Condition?: unknown;
619 }>;
620 };
621 return policy.Statement?.find(
622 (statement) => statement.Sid === statementId,
623 );
624 },
625 catch: (cause) =>
626 cause instanceof Error ? cause : new Error(String(cause)),
627 }),
628 ),
629 Effect.catchTag("ResourceNotFoundException", () =>
630 Effect.succeed(undefined),
631 ),
632 );
633});
634
635const getFunctionUrlConfigWithAuth = Effect.fn(function* (
636 functionName: string,
637 authType: Lambda.FunctionUrlAuthType,
638) {
639 return yield* Lambda.getFunctionUrlConfig({
640 FunctionName: functionName,
641 }).pipe(
642 Effect.filterOrFail(
643 (config) => config.AuthType === authType,
644 () => new Error("Function URL auth has not propagated yet"),
645 ),
646 Effect.retry({
647 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
648 }),
649 );
650});
651
652const waitForReservedConcurrency = Effect.fn(function* (
653 functionName: string,
654 expected: number | undefined,
655) {
656 return yield* Lambda.getFunctionConcurrency({
657 FunctionName: functionName,
658 }).pipe(
659 Effect.map((config) => config.ReservedConcurrentExecutions),
660 Effect.catchTag("ResourceNotFoundException", () =>
661 Effect.succeed(undefined),
662 ),
663 Effect.filterOrFail(
664 (actual) => actual === expected,
665 () => new Error("Reserved concurrency update has not propagated yet"),
666 ),
667 Effect.retry({
668 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
669 }),
670 );
671});
672
673const waitForArchitecture = Effect.fn(function* (
674 functionName: string,
675 expected: AWS.Lambda.FunctionArchitecture,
676) {
677 return yield* Lambda.getFunction({ FunctionName: functionName }).pipe(
678 Effect.map((result) => result.Configuration?.Architectures),
679 Effect.filterOrFail(
680 (architectures) => architectures?.[0] === expected,
681 () => new Error("Lambda architecture update has not propagated yet"),
682 ),
683 Effect.retry({
684 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
685 }),
686 );
687});