1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as Lambda from "@distilled.cloud/aws/lambda";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import { TestFunction, TestFunctionLive } from "./handler.ts";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12test.provider(
13 "creates permission scoped to function URL invocation",
14 (stack) =>
15 Effect.gen(function* () {
16 yield* stack.destroy();
17
18 const deployed = yield* stack.deploy(
19 Effect.gen(function* () {
20 const fn = yield* TestFunction;
21 const permission = yield* AWS.Lambda.Permission("UrlInvokeOnly", {
22 action: "lambda:InvokeFunction",
23 functionName: fn.functionName,
24 principal: "*",
25 invokedViaFunctionUrl: true,
26 });
27
28 return { fn, permission };
29 }).pipe(Effect.provide(TestFunctionLive)),
30 );
31
32 const policy = yield* getPolicyStatement(
33 deployed.fn.functionName,
34 deployed.permission.statementId,
35 );
36
37 expect(policy.Condition).toEqual({
38 Bool: {
39 "lambda:InvokedViaFunctionUrl": "true",
40 },
41 });
42
43 yield* stack.destroy();
44 yield* assertFunctionDeleted(deployed.fn.functionName);
45 }).pipe(
46 Effect.tap(() => stack.destroy()),
47 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
48 ),
49 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 180_000 },
50);
51
52test.provider(
53 "list enumerates the deployed permission",
54 (stack) =>
55 Effect.gen(function* () {
56 yield* stack.destroy();
57
58 const deployed = yield* stack.deploy(
59 Effect.gen(function* () {
60 const fn = yield* TestFunction;
61 const permission = yield* AWS.Lambda.Permission("ListPermission", {
62 action: "lambda:InvokeFunction",
63 functionName: fn.functionName,
64 principal: "*",
65 invokedViaFunctionUrl: true,
66 });
67
68 return { fn, permission };
69 }).pipe(Effect.provide(TestFunctionLive)),
70 );
71
72 const provider = yield* Provider.findProvider(AWS.Lambda.Permission);
73 const all = yield* provider.list();
74
75 expect(
76 all.some(
77 (p) =>
78 p.statementId === deployed.permission.statementId &&
79 p.functionName === deployed.fn.functionName,
80 ),
81 ).toBe(true);
82
83 yield* stack.destroy();
84 yield* assertFunctionDeleted(deployed.fn.functionName);
85 }).pipe(
86 Effect.tap(() => stack.destroy()),
87 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
88 ),
89 { tags: ["provider:aws", "provider:aws:lambda", "live"], timeout: 180_000 },
90);
91
92// Out-of-band proof that the trailing destroy removed the host function (and
93// with it the permission's policy) from the cloud.
94const assertFunctionDeleted = Effect.fn(function* (functionName: string) {
95 yield* Lambda.getFunction({ FunctionName: functionName }).pipe(
96 Effect.flatMap(() =>
97 Effect.fail(new Error(`Function ${functionName} still exists`)),
98 ),
99 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
100 Effect.retry({
101 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
102 }),
103 );
104});
105
106const getPolicyStatement = Effect.fn(function* (
107 functionName: string,
108 statementId: string,
109) {
110 return yield* Lambda.getPolicy({ FunctionName: functionName }).pipe(
111 Effect.flatMap(({ Policy }) =>
112 Effect.try({
113 try: () => {
114 const policy = JSON.parse(Policy ?? "{}") as {
115 Statement?: Array<{
116 Sid?: string;
117 Condition?: unknown;
118 }>;
119 };
120 const statement = policy.Statement?.find(
121 (statement) => statement.Sid === statementId,
122 );
123 if (!statement) {
124 throw new Error(`Policy statement ${statementId} not found`);
125 }
126 return statement;
127 },
128 catch: (cause) =>
129 cause instanceof Error ? cause : new Error(String(cause)),
130 }),
131 ),
132 Effect.retry({
133 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(10)]),
134 }),
135 );
136});