1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as budgets from "@distilled.cloud/aws/budgets";
4import { expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6
7const { test } = Test.make({ providers: AWS.providers() });
8
9const budgetName = "alchemy-test-budget-action";
10const execRoleName = "alchemy-test-budgets-action-exec";
11const targetRoleName = "alchemy-test-budgets-action-target";
12
13const getAction = (accountId: string, actionId: string) =>
14 budgets
15 .describeBudgetAction({
16 AccountId: accountId,
17 BudgetName: budgetName,
18 ActionId: actionId,
19 })
20 .pipe(
21 Effect.map((r) => r.Action),
22 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
23 );
24
25const deployAction = (thresholdValue: number) =>
26 Effect.gen(function* () {
27 const execRole = yield* AWS.IAM.Role("BudgetActionExecRole", {
28 roleName: execRoleName,
29 assumeRolePolicyDocument: {
30 Version: "2012-10-17",
31 Statement: [
32 {
33 Effect: "Allow",
34 Principal: { Service: "budgets.amazonaws.com" },
35 Action: ["sts:AssumeRole"],
36 },
37 ],
38 },
39 managedPolicyArns: [
40 "arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess",
41 ],
42 });
43
44 const targetRole = yield* AWS.IAM.Role("BudgetActionTargetRole", {
45 roleName: targetRoleName,
46 assumeRolePolicyDocument: {
47 Version: "2012-10-17",
48 Statement: [
49 {
50 Effect: "Allow",
51 Principal: { Service: "ec2.amazonaws.com" },
52 Action: ["sts:AssumeRole"],
53 },
54 ],
55 },
56 });
57
58 const budget = yield* AWS.Budgets.Budget("ActionBudget", {
59 budgetName,
60 budgetType: "COST",
61 timeUnit: "MONTHLY",
62 // Large limit so the 100% threshold is never crossed by real account
63 // spend — the action must stay in STANDBY.
64 budgetLimit: { amount: "1000000", unit: "USD" },
65 });
66
67 const action = yield* AWS.Budgets.BudgetAction("FreezeAction", {
68 budgetName: budget.budgetName,
69 notificationType: "ACTUAL",
70 actionType: "APPLY_IAM_POLICY",
71 actionThreshold: {
72 actionThresholdValue: thresholdValue,
73 actionThresholdType: "PERCENTAGE",
74 },
75 definition: {
76 iamActionDefinition: {
77 policyArn: "arn:aws:iam::aws:policy/AWSDenyAll",
78 roles: [targetRole.roleName],
79 },
80 },
81 executionRoleArn: execRole.roleArn,
82 approvalModel: "MANUAL",
83 subscribers: [
84 { subscriptionType: "EMAIL", address: "budget-test@example.com" },
85 ],
86 tags: { Team: "alchemy-test" },
87 });
88
89 return {
90 accountId: action.accountId,
91 actionId: action.actionId,
92 actionArn: action.actionArn,
93 execRoleArn: execRole.roleArn,
94 };
95 });
96
97test.provider(
98 "lifecycle: create IAM budget action, update threshold, destroy",
99 (stack) =>
100 Effect.gen(function* () {
101 yield* stack.destroy();
102
103 const deployed = yield* stack.deploy(deployAction(100));
104 const { accountId, actionId } = deployed;
105
106 expect(deployed.actionArn).toBe(
107 `arn:aws:budgets::${accountId}:budget/${budgetName}/action/${actionId}`,
108 );
109
110 // Out-of-band verification via distilled.
111 const created = yield* getAction(accountId, actionId);
112 expect(created?.ActionType).toBe("APPLY_IAM_POLICY");
113 expect(created?.Status).toBe("STANDBY");
114 expect(created?.ApprovalModel).toBe("MANUAL");
115 expect(created?.ExecutionRoleArn).toBe(deployed.execRoleArn);
116 expect(created?.ActionThreshold.ActionThresholdValue).toBe(100);
117 expect(created?.Definition.IamActionDefinition?.PolicyArn).toBe(
118 "arn:aws:iam::aws:policy/AWSDenyAll",
119 );
120 expect(created?.Definition.IamActionDefinition?.Roles).toEqual([
121 targetRoleName,
122 ]);
123
124 // Tags — user tag plus the internal alchemy brand.
125 const tags = yield* budgets
126 .listTagsForResource({ ResourceARN: deployed.actionArn })
127 .pipe(
128 Effect.map((r) =>
129 Object.fromEntries(
130 (r.ResourceTags ?? []).map((t) => [t.Key, t.Value]),
131 ),
132 ),
133 );
134 expect(tags.Team).toBe("alchemy-test");
135 expect(Object.keys(tags).some((k) => k.startsWith("alchemy:"))).toBe(
136 true,
137 );
138
139 // Update — lower the threshold; the action id must be stable.
140 const updated = yield* stack.deploy(deployAction(90));
141 expect(updated.actionId).toBe(actionId);
142
143 const afterUpdate = yield* getAction(accountId, actionId);
144 expect(afterUpdate?.ActionThreshold.ActionThresholdValue).toBe(90);
145
146 // Destroy — the action (and budget) are gone.
147 yield* stack.destroy();
148 const afterDestroy = yield* budgets
149 .describeBudgetAction({
150 AccountId: accountId,
151 BudgetName: budgetName,
152 ActionId: actionId,
153 })
154 .pipe(
155 Effect.map(() => "found"),
156 Effect.catchTag("NotFoundException", () =>
157 Effect.succeed("not-found"),
158 ),
159 );
160 expect(afterDestroy).toBe("not-found");
161 }),
162 {
163 tags: ["provider:aws", "provider:aws:budgets", "provider:aws:iam", "live"],
164 timeout: 240_000,
165 },
166);