1import * as Budgets from "@/AWS/Budgets";
2import * as IAM from "@/AWS/IAM";
3import * as Lambda from "@/AWS/Lambda";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export const fixtureBudgetName = "alchemy-test-budgets-bindings";
13
14export class BudgetsTestFunction extends Lambda.Function<Lambda.Function>()(
15 "BudgetsTestFunction",
16) {}
17
18export default BudgetsTestFunction.make(
19 {
20 main,
21 functionUrl: true,
22 },
23 Effect.gen(function* () {
24 const execRole = yield* IAM.Role("BindingsExecRole", {
25 roleName: "alchemy-test-budgets-bind-exec",
26 assumeRolePolicyDocument: {
27 Version: "2012-10-17",
28 Statement: [
29 {
30 Effect: "Allow",
31 Principal: { Service: "budgets.amazonaws.com" },
32 Action: ["sts:AssumeRole"],
33 },
34 ],
35 },
36 managedPolicyArns: [
37 "arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess",
38 ],
39 });
40
41 const targetRole = yield* IAM.Role("BindingsTargetRole", {
42 roleName: "alchemy-test-budgets-bind-target",
43 assumeRolePolicyDocument: {
44 Version: "2012-10-17",
45 Statement: [
46 {
47 Effect: "Allow",
48 Principal: { Service: "ec2.amazonaws.com" },
49 Action: ["sts:AssumeRole"],
50 },
51 ],
52 },
53 });
54
55 const budget = yield* Budgets.Budget("BindingsBudget", {
56 budgetName: fixtureBudgetName,
57 budgetType: "COST",
58 timeUnit: "MONTHLY",
59 // Large limit so the action's 100% threshold is never crossed by real
60 // account spend — the fixture action must stay in STANDBY.
61 budgetLimit: { amount: "1000000", unit: "USD" },
62 notifications: [
63 {
64 notificationType: "ACTUAL",
65 comparisonOperator: "GREATER_THAN",
66 threshold: 80,
67 thresholdType: "PERCENTAGE",
68 subscribers: [
69 { subscriptionType: "EMAIL", address: "budget-test@example.com" },
70 ],
71 },
72 ],
73 });
74
75 const action = yield* Budgets.BudgetAction("BindingsAction", {
76 budgetName: budget.budgetName,
77 notificationType: "ACTUAL",
78 actionType: "APPLY_IAM_POLICY",
79 actionThreshold: {
80 actionThresholdValue: 100,
81 actionThresholdType: "PERCENTAGE",
82 },
83 definition: {
84 iamActionDefinition: {
85 policyArn: "arn:aws:iam::aws:policy/AWSDenyAll",
86 roles: [targetRole.roleName],
87 },
88 },
89 executionRoleArn: execRole.roleArn,
90 approvalModel: "MANUAL",
91 subscribers: [
92 { subscriptionType: "EMAIL", address: "budget-test@example.com" },
93 ],
94 });
95
96 // --- budget-scoped bindings ---
97 const describeBudget = yield* Budgets.DescribeBudget(budget);
98 const performanceHistory =
99 yield* Budgets.DescribeBudgetPerformanceHistory(budget);
100 const notifications = yield* Budgets.DescribeNotificationsForBudget(budget);
101 const subscribers =
102 yield* Budgets.DescribeSubscribersForNotification(budget);
103 const listActions = yield* Budgets.DescribeBudgetActionsForBudget(budget);
104
105 // --- action-scoped bindings ---
106 const executeAction = yield* Budgets.ExecuteBudgetAction(action);
107 const actionHistories =
108 yield* Budgets.DescribeBudgetActionHistories(action);
109
110 const bound = {
111 describeBudget,
112 performanceHistory,
113 notifications,
114 subscribers,
115 listActions,
116 executeAction,
117 actionHistories,
118 };
119
120 return {
121 fetch: Effect.gen(function* () {
122 const request = yield* HttpServerRequest;
123 const url = new URL(request.originalUrl);
124 const pathname = url.pathname;
125
126 if (request.method === "GET" && pathname === "/bindings") {
127 return yield* HttpServerResponse.json({
128 bound: Object.keys(bound),
129 });
130 }
131
132 if (request.method === "GET" && pathname === "/budget") {
133 const result = yield* describeBudget();
134 return yield* HttpServerResponse.json({
135 name: result.Budget?.BudgetName ?? null,
136 limitAmount: result.Budget?.BudgetLimit?.Amount ?? null,
137 limitUnit: result.Budget?.BudgetLimit?.Unit ?? null,
138 timeUnit: result.Budget?.TimeUnit ?? null,
139 });
140 }
141
142 if (request.method === "GET" && pathname === "/history") {
143 const result = yield* performanceHistory();
144 return yield* HttpServerResponse.json({
145 budgetName: result.BudgetPerformanceHistory?.BudgetName ?? null,
146 periods: (
147 result.BudgetPerformanceHistory?.BudgetedAndActualAmountsList ??
148 []
149 ).length,
150 });
151 }
152
153 if (request.method === "GET" && pathname === "/notifications") {
154 const result = yield* notifications();
155 return yield* HttpServerResponse.json({
156 thresholds: (result.Notifications ?? []).map((n) => n.Threshold),
157 });
158 }
159
160 if (request.method === "GET" && pathname === "/subscribers") {
161 // Enumerate notifications, then list each one's subscribers — the
162 // round trip proves the ViewBudget grant and identifier injection.
163 const notifs = yield* notifications();
164 const first = (notifs.Notifications ?? [])[0];
165 if (!first) {
166 return yield* HttpServerResponse.json({
167 count: 0,
168 subscriptionTypes: [],
169 });
170 }
171 const result = yield* subscribers({ Notification: first });
172 return yield* HttpServerResponse.json({
173 count: (result.Subscribers ?? []).length,
174 subscriptionTypes: (result.Subscribers ?? []).map(
175 (s) => s.SubscriptionType,
176 ),
177 });
178 }
179
180 if (request.method === "GET" && pathname === "/actions") {
181 const result = yield* listActions();
182 return yield* HttpServerResponse.json({
183 actions: (result.Actions ?? []).map((a) => ({
184 actionId: a.ActionId,
185 status: a.Status,
186 actionType: a.ActionType,
187 })),
188 });
189 }
190
191 if (request.method === "GET" && pathname === "/action-histories") {
192 const result = yield* actionHistories({});
193 return yield* HttpServerResponse.json({
194 count: result.ActionHistories.length,
195 eventTypes: result.ActionHistories.map((h) => h.EventType),
196 });
197 }
198
199 if (request.method === "GET" && pathname === "/execute-reset") {
200 // A STANDBY action can't meaningfully be reset — the point is to
201 // prove the `budgets:ExecuteBudgetAction` grant and identifier
202 // injection: any typed budgets-service outcome (success or a
203 // business error) proves the call was authorized. An IAM gap would
204 // surface AccessDeniedException and fail the route with a 500.
205 const outcome = yield* executeAction({
206 ExecutionType: "RESET_BUDGET_ACTION",
207 }).pipe(
208 Effect.map((r) => ({
209 outcome: "ok",
210 executionType: r.ExecutionType,
211 })),
212 Effect.catchTag(
213 ["ResourceLockedException", "InvalidParameterException"],
214 (e) => Effect.succeed({ outcome: e._tag, executionType: null }),
215 ),
216 );
217 return yield* HttpServerResponse.json(outcome);
218 }
219
220 return yield* HttpServerResponse.json(
221 { error: "Not found", method: request.method, pathname },
222 { status: 404 },
223 );
224 }).pipe(Effect.orDie),
225 };
226 }).pipe(
227 Effect.provide(
228 Layer.mergeAll(
229 Budgets.DescribeBudgetHttp,
230 Budgets.DescribeBudgetPerformanceHistoryHttp,
231 Budgets.DescribeNotificationsForBudgetHttp,
232 Budgets.DescribeSubscribersForNotificationHttp,
233 Budgets.DescribeBudgetActionsForBudgetHttp,
234 Budgets.ExecuteBudgetActionHttp,
235 Budgets.DescribeBudgetActionHistoriesHttp,
236 ),
237 ),
238 ),
239);