puls-dev 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/aws/api.d.ts +4 -0
  4. package/dist/providers/aws/api.js +4 -0
  5. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  6. package/dist/providers/aws/cloudwatch.js +205 -0
  7. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  8. package/dist/providers/aws/cloudwatch.test.js +224 -0
  9. package/dist/providers/aws/fargate.d.ts +2 -0
  10. package/dist/providers/aws/fargate.js +6 -0
  11. package/dist/providers/aws/iam.d.ts +52 -0
  12. package/dist/providers/aws/iam.js +307 -0
  13. package/dist/providers/aws/iam.test.d.ts +1 -0
  14. package/dist/providers/aws/iam.test.js +367 -0
  15. package/dist/providers/aws/index.d.ts +7 -0
  16. package/dist/providers/aws/index.js +7 -0
  17. package/dist/providers/aws/lambda.d.ts +3 -1
  18. package/dist/providers/aws/lambda.js +11 -2
  19. package/dist/providers/aws/rds.d.ts +1 -0
  20. package/dist/providers/aws/rds.js +4 -1
  21. package/dist/providers/aws/sns.d.ts +22 -0
  22. package/dist/providers/aws/sns.js +146 -0
  23. package/dist/providers/aws/sns.test.d.ts +1 -0
  24. package/dist/providers/aws/sns.test.js +162 -0
  25. package/dist/providers/firebase/appcheck.d.ts +15 -0
  26. package/dist/providers/firebase/appcheck.js +109 -0
  27. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  28. package/dist/providers/firebase/appcheck.test.js +141 -0
  29. package/dist/providers/firebase/index.d.ts +2 -0
  30. package/dist/providers/firebase/index.js +2 -0
  31. package/dist/providers/gcp/api.d.ts +10 -0
  32. package/dist/providers/gcp/api.js +111 -0
  33. package/dist/providers/gcp/clouddns.d.ts +37 -0
  34. package/dist/providers/gcp/clouddns.js +284 -0
  35. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  36. package/dist/providers/gcp/clouddns.test.js +259 -0
  37. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  38. package/dist/providers/gcp/cloudrun.js +240 -0
  39. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  40. package/dist/providers/gcp/cloudrun.test.js +281 -0
  41. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  42. package/dist/providers/gcp/cloudsql.js +262 -0
  43. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  44. package/dist/providers/gcp/cloudsql.test.js +295 -0
  45. package/dist/providers/gcp/iam.d.ts +38 -0
  46. package/dist/providers/gcp/iam.js +309 -0
  47. package/dist/providers/gcp/iam.test.d.ts +1 -0
  48. package/dist/providers/gcp/iam.test.js +305 -0
  49. package/dist/providers/gcp/index.d.ts +19 -0
  50. package/dist/providers/gcp/index.js +19 -0
  51. package/dist/providers/gcp/pubsub.d.ts +31 -0
  52. package/dist/providers/gcp/pubsub.js +227 -0
  53. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  54. package/dist/providers/gcp/pubsub.test.js +244 -0
  55. package/dist/providers/gcp/secrets.d.ts +21 -0
  56. package/dist/providers/gcp/secrets.js +187 -0
  57. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  58. package/dist/providers/gcp/secrets.test.js +264 -0
  59. package/dist/providers/proxmox/vm.d.ts +2 -0
  60. package/dist/providers/proxmox/vm.js +35 -3
  61. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  62. package/dist/providers/proxmox/vm.test.js +155 -0
  63. package/dist/types/aws.d.ts +11 -0
  64. package/package.json +32 -2
@@ -0,0 +1,307 @@
1
+ import { GetRoleCommand, CreateRoleCommand, UpdateRoleCommand, DeleteRoleCommand, UpdateAssumeRolePolicyCommand, AttachRolePolicyCommand, DetachRolePolicyCommand, ListAttachedRolePoliciesCommand, PutRolePolicyCommand, DeleteRolePolicyCommand, ListRolePoliciesCommand, CreatePolicyCommand, DeletePolicyCommand, ListPoliciesCommand, CreatePolicyVersionCommand, DeletePolicyVersionCommand, ListPolicyVersionsCommand, } from "@aws-sdk/client-iam";
2
+ import { BaseBuilder } from "../../core/resource.js";
3
+ import { Output } from "../../core/output.js";
4
+ import { getIAMClient } from "./api.js";
5
+ const DEFAULT_ASSUME_ROLE_POLICY = {
6
+ Version: "2012-10-17",
7
+ Statement: [
8
+ {
9
+ Effect: "Allow",
10
+ Principal: { Service: "lambda.amazonaws.com" },
11
+ Action: "sts:AssumeRole",
12
+ },
13
+ ],
14
+ };
15
+ export class IAMPolicyBuilder extends BaseBuilder {
16
+ out = {
17
+ arn: new Output(),
18
+ };
19
+ _document;
20
+ _description;
21
+ _path = "/";
22
+ resolvedArn = null;
23
+ constructor(name) {
24
+ super(name);
25
+ this.discoveryPromise = this.discoverPolicy(name);
26
+ }
27
+ async discoverPolicy(name) {
28
+ try {
29
+ const iam = getIAMClient();
30
+ const result = await iam.send(new ListPoliciesCommand({ Scope: "Local", OnlyAttached: false, MaxItems: 100 }));
31
+ const match = (result.Policies ?? []).find((p) => p.PolicyName === name);
32
+ if (match) {
33
+ this.resolvedArn = match.Arn ?? null;
34
+ if (this.resolvedArn) {
35
+ this.out.arn.resolve(this.resolvedArn);
36
+ }
37
+ return match;
38
+ }
39
+ return null;
40
+ }
41
+ catch (e) {
42
+ if (e.name === "CredentialsProviderError")
43
+ return null;
44
+ throw e;
45
+ }
46
+ }
47
+ document(doc) {
48
+ this._document = doc;
49
+ return this;
50
+ }
51
+ description(desc) {
52
+ this._description = desc;
53
+ return this;
54
+ }
55
+ path(p) {
56
+ this._path = p;
57
+ return this;
58
+ }
59
+ async deploy() {
60
+ const dryRun = this.isDryRunActive();
61
+ const existing = await this.discoveryPromise;
62
+ const iam = getIAMClient();
63
+ console.log(`\n🔐 Finalizing IAM Policy "${this.name}"...`);
64
+ if (!this._document) {
65
+ throw new Error(`[IAMPolicy:${this.name}] .document() is required`);
66
+ }
67
+ if (dryRun) {
68
+ console.log(` 📝 [PLAN] ${existing ? "Update" : "Create"} IAM policy "${this.name}"`);
69
+ this.resolvedArn = existing?.Arn ?? `arn:aws:iam::000000000000:policy/DRYRUN-${this.name}`;
70
+ this.out.arn.resolve(this.resolvedArn);
71
+ return { name: this.name, arn: this.resolvedArn };
72
+ }
73
+ if (!existing) {
74
+ const result = await iam.send(new CreatePolicyCommand({
75
+ PolicyName: this.name,
76
+ PolicyDocument: JSON.stringify(this._document),
77
+ Description: this._description,
78
+ Path: this._path,
79
+ }));
80
+ this.resolvedArn = result.Policy.Arn;
81
+ this.out.arn.resolve(this.resolvedArn);
82
+ console.log(`🚀 Created IAM Policy "${this.name}" (arn=${this.resolvedArn})`);
83
+ }
84
+ else {
85
+ this.resolvedArn = existing.Arn;
86
+ this.out.arn.resolve(this.resolvedArn);
87
+ // Handle AWS 5-version limit
88
+ const versions = await iam.send(new ListPolicyVersionsCommand({ PolicyArn: this.resolvedArn }));
89
+ if (versions.Versions && versions.Versions.length >= 5) {
90
+ const nonDefault = versions.Versions.filter((v) => !v.IsDefaultVersion);
91
+ if (nonDefault.length > 0) {
92
+ nonDefault.sort((a, b) => new Date(a.CreateDate).getTime() - new Date(b.CreateDate).getTime());
93
+ const oldest = nonDefault[0];
94
+ await iam.send(new DeletePolicyVersionCommand({
95
+ PolicyArn: this.resolvedArn,
96
+ VersionId: oldest.VersionId,
97
+ }));
98
+ console.log(` 🧹 Pruned oldest IAM policy version: ${oldest.VersionId}`);
99
+ }
100
+ }
101
+ await iam.send(new CreatePolicyVersionCommand({
102
+ PolicyArn: this.resolvedArn,
103
+ PolicyDocument: JSON.stringify(this._document),
104
+ SetAsDefault: true,
105
+ }));
106
+ console.log(` ✅ Updated IAM Policy "${this.name}" (created new default version)`);
107
+ }
108
+ return { name: this.name, arn: this.resolvedArn };
109
+ }
110
+ async destroy() {
111
+ const dryRun = this.isDryRunActive();
112
+ const existing = await this.discoveryPromise;
113
+ const iam = getIAMClient();
114
+ console.log(`\n🗑️ Destroying IAM Policy "${this.name}"...`);
115
+ if (!existing || !existing.Arn) {
116
+ console.log(` ✅ IAM Policy "${this.name}" does not exist - nothing to do`);
117
+ return { destroyed: this.name };
118
+ }
119
+ const policyArn = existing.Arn;
120
+ if (dryRun) {
121
+ console.log(` 📝 [PLAN] Delete IAM policy "${this.name}"`);
122
+ return { destroyed: this.name };
123
+ }
124
+ // Must delete all non-default versions before deleting policy
125
+ const versions = await iam.send(new ListPolicyVersionsCommand({ PolicyArn: policyArn }));
126
+ for (const v of versions.Versions ?? []) {
127
+ if (!v.IsDefaultVersion) {
128
+ await iam.send(new DeletePolicyVersionCommand({
129
+ PolicyArn: policyArn,
130
+ VersionId: v.VersionId,
131
+ }));
132
+ }
133
+ }
134
+ await iam.send(new DeletePolicyCommand({ PolicyArn: policyArn }));
135
+ console.log(` ✅ Deleted IAM Policy "${this.name}"`);
136
+ return { destroyed: this.name };
137
+ }
138
+ }
139
+ export class IAMRoleBuilder extends BaseBuilder {
140
+ out = {
141
+ arn: new Output(),
142
+ name: new Output(),
143
+ };
144
+ _assumeRolePolicy = DEFAULT_ASSUME_ROLE_POLICY;
145
+ _managedPolicies = [];
146
+ _inlinePolicies = {};
147
+ _description;
148
+ _path = "/";
149
+ _maxSessionDuration;
150
+ resolvedArn = null;
151
+ constructor(name) {
152
+ super(name);
153
+ this.discoveryPromise = this.discoverRole(name);
154
+ }
155
+ async discoverRole(name) {
156
+ try {
157
+ const iam = getIAMClient();
158
+ const result = await iam.send(new GetRoleCommand({ RoleName: name }));
159
+ this.resolvedArn = result.Role.Arn;
160
+ this.out.arn.resolve(this.resolvedArn);
161
+ this.out.name.resolve(name);
162
+ return result.Role;
163
+ }
164
+ catch (e) {
165
+ if (e.name === "NoSuchEntityException")
166
+ return null;
167
+ if (e.name === "CredentialsProviderError")
168
+ return null;
169
+ throw e;
170
+ }
171
+ }
172
+ assumeRolePolicy(doc) {
173
+ this._assumeRolePolicy = doc;
174
+ return this;
175
+ }
176
+ attach(policyArnOrBuilder) {
177
+ this._managedPolicies.push(policyArnOrBuilder);
178
+ return this;
179
+ }
180
+ inlinePolicy(name, doc) {
181
+ this._inlinePolicies[name] = doc;
182
+ return this;
183
+ }
184
+ description(desc) {
185
+ this._description = desc;
186
+ return this;
187
+ }
188
+ path(p) {
189
+ this._path = p;
190
+ return this;
191
+ }
192
+ maxSessionDuration(seconds) {
193
+ this._maxSessionDuration = seconds;
194
+ return this;
195
+ }
196
+ async deploy() {
197
+ const dryRun = this.isDryRunActive();
198
+ const existing = await this.discoveryPromise;
199
+ const iam = getIAMClient();
200
+ console.log(`\n⚡ Finalizing IAM Role "${this.name}"...`);
201
+ if (dryRun) {
202
+ console.log(` 📝 [PLAN] ${existing ? "Update" : "Create"} IAM role "${this.name}"`);
203
+ this.resolvedArn = existing?.Arn ?? `arn:aws:iam::000000000000:role/DRYRUN-${this.name}`;
204
+ this.out.arn.resolve(this.resolvedArn);
205
+ this.out.name.resolve(this.name);
206
+ return { name: this.name, arn: this.resolvedArn };
207
+ }
208
+ if (!existing) {
209
+ const result = await iam.send(new CreateRoleCommand({
210
+ RoleName: this.name,
211
+ AssumeRolePolicyDocument: JSON.stringify(this._assumeRolePolicy),
212
+ Description: this._description,
213
+ Path: this._path,
214
+ MaxSessionDuration: this._maxSessionDuration,
215
+ }));
216
+ this.resolvedArn = result.Role.Arn;
217
+ this.out.arn.resolve(this.resolvedArn);
218
+ this.out.name.resolve(this.name);
219
+ console.log(`🚀 Created IAM Role "${this.name}" (arn=${this.resolvedArn})`);
220
+ }
221
+ else {
222
+ this.resolvedArn = existing.Arn;
223
+ this.out.arn.resolve(this.resolvedArn);
224
+ this.out.name.resolve(this.name);
225
+ await iam.send(new UpdateAssumeRolePolicyCommand({
226
+ RoleName: this.name,
227
+ PolicyDocument: JSON.stringify(this._assumeRolePolicy),
228
+ }));
229
+ await iam.send(new UpdateRoleCommand({
230
+ RoleName: this.name,
231
+ Description: this._description || existing.Description,
232
+ MaxSessionDuration: this._maxSessionDuration || existing.MaxSessionDuration,
233
+ }));
234
+ console.log(` ✅ Updated IAM Role "${this.name}" configuration`);
235
+ }
236
+ // Resolve requested managed policies
237
+ const requestedArns = [];
238
+ for (const policy of this._managedPolicies) {
239
+ if (policy instanceof IAMPolicyBuilder) {
240
+ requestedArns.push(await policy.out.arn.get());
241
+ }
242
+ else {
243
+ requestedArns.push(policy);
244
+ }
245
+ }
246
+ // Sync managed policies
247
+ const attached = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName: this.name }));
248
+ const attachedArns = (attached.AttachedPolicies ?? []).map((p) => p.PolicyArn);
249
+ for (const arn of attachedArns) {
250
+ if (!requestedArns.includes(arn)) {
251
+ await iam.send(new DetachRolePolicyCommand({ RoleName: this.name, PolicyArn: arn }));
252
+ console.log(` ➖ Detached policy ${arn} from role ${this.name}`);
253
+ }
254
+ }
255
+ for (const arn of requestedArns) {
256
+ if (!attachedArns.includes(arn)) {
257
+ await iam.send(new AttachRolePolicyCommand({ RoleName: this.name, PolicyArn: arn }));
258
+ console.log(` ➕ Attached policy ${arn} to role ${this.name}`);
259
+ }
260
+ }
261
+ // Sync inline policies
262
+ const inline = await iam.send(new ListRolePoliciesCommand({ RoleName: this.name }));
263
+ const inlineNames = inline.PolicyNames ?? [];
264
+ for (const pName of inlineNames) {
265
+ if (!this._inlinePolicies[pName]) {
266
+ await iam.send(new DeleteRolePolicyCommand({ RoleName: this.name, PolicyName: pName }));
267
+ console.log(` ➖ Deleted inline policy ${pName} from role ${this.name}`);
268
+ }
269
+ }
270
+ for (const [pName, doc] of Object.entries(this._inlinePolicies)) {
271
+ await iam.send(new PutRolePolicyCommand({
272
+ RoleName: this.name,
273
+ PolicyName: pName,
274
+ PolicyDocument: JSON.stringify(doc),
275
+ }));
276
+ console.log(` ➕ Applied inline policy ${pName} to role ${this.name}`);
277
+ }
278
+ return { name: this.name, arn: this.resolvedArn };
279
+ }
280
+ async destroy() {
281
+ const dryRun = this.isDryRunActive();
282
+ const existing = await this.discoveryPromise;
283
+ const iam = getIAMClient();
284
+ console.log(`\n🗑️ Destroying IAM Role "${this.name}"...`);
285
+ if (!existing) {
286
+ console.log(` ✅ IAM Role "${this.name}" does not exist - nothing to do`);
287
+ return { destroyed: this.name };
288
+ }
289
+ if (dryRun) {
290
+ console.log(` 📝 [PLAN] Delete IAM role "${this.name}"`);
291
+ return { destroyed: this.name };
292
+ }
293
+ // Must delete all inline policies first
294
+ const inline = await iam.send(new ListRolePoliciesCommand({ RoleName: this.name }));
295
+ for (const pName of inline.PolicyNames ?? []) {
296
+ await iam.send(new DeleteRolePolicyCommand({ RoleName: this.name, PolicyName: pName }));
297
+ }
298
+ // Must detach all attached managed policies
299
+ const attached = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName: this.name }));
300
+ for (const p of attached.AttachedPolicies ?? []) {
301
+ await iam.send(new DetachRolePolicyCommand({ RoleName: this.name, PolicyArn: p.PolicyArn }));
302
+ }
303
+ await iam.send(new DeleteRoleCommand({ RoleName: this.name }));
304
+ console.log(` ✅ Deleted IAM Role "${this.name}"`);
305
+ return { destroyed: this.name };
306
+ }
307
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,367 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'assert';
3
+ import fs from 'node:fs';
4
+ import { IAMClient } from '@aws-sdk/client-iam';
5
+ import { LambdaClient } from '@aws-sdk/client-lambda';
6
+ import { Config } from '../../core/config.js';
7
+ import { IAMRoleBuilder, IAMPolicyBuilder } from './iam.js';
8
+ import { LambdaBuilder } from './lambda.js';
9
+ describe('AWS IAM Builders Unit Tests', () => {
10
+ let originalSend;
11
+ let iamCalls = [];
12
+ let mockIamResponses = {};
13
+ beforeEach(() => {
14
+ Config.set({
15
+ dryRun: false,
16
+ providers: {
17
+ aws: { region: 'us-east-1' }
18
+ }
19
+ });
20
+ iamCalls = [];
21
+ mockIamResponses = {};
22
+ originalSend = IAMClient.prototype.send;
23
+ // FS Mocking to return a fake zip buffer when reading the code package
24
+ mock.method(fs, 'readFileSync', () => {
25
+ return Buffer.from('mock-zip-binary-payload');
26
+ });
27
+ // Intercept all IAM command sends
28
+ IAMClient.prototype.send = async function (command) {
29
+ const commandName = command.constructor.name;
30
+ const input = command.input;
31
+ iamCalls.push({ commandName, input });
32
+ if (mockIamResponses[commandName]) {
33
+ const handler = mockIamResponses[commandName];
34
+ if (typeof handler === 'function')
35
+ return handler(input);
36
+ if (handler instanceof Error)
37
+ throw handler;
38
+ return handler;
39
+ }
40
+ return {};
41
+ };
42
+ });
43
+ afterEach(() => {
44
+ IAMClient.prototype.send = originalSend;
45
+ mock.restoreAll();
46
+ });
47
+ describe('IAMPolicyBuilder Tests', () => {
48
+ test('gracefully handles discovery when policy does not exist', async () => {
49
+ mockIamResponses['ListPoliciesCommand'] = { Policies: [] };
50
+ const builder = new IAMPolicyBuilder('my-policy');
51
+ const discoveryResult = await builder.discoveryPromise;
52
+ assert.strictEqual(discoveryResult, null);
53
+ assert.strictEqual(iamCalls.length, 1);
54
+ assert.strictEqual(iamCalls[0].commandName, 'ListPoliciesCommand');
55
+ });
56
+ test('discovers policy successfully when it exists', async () => {
57
+ const expectedArn = 'arn:aws:iam::123456789012:policy/my-policy';
58
+ mockIamResponses['ListPoliciesCommand'] = {
59
+ Policies: [{ PolicyName: 'my-policy', Arn: expectedArn }]
60
+ };
61
+ const builder = new IAMPolicyBuilder('my-policy');
62
+ const discoveryResult = await builder.discoveryPromise;
63
+ assert.ok(discoveryResult);
64
+ assert.strictEqual(builder.resolvedArn, expectedArn);
65
+ const resolvedArn = await builder.out.arn.get();
66
+ assert.strictEqual(resolvedArn, expectedArn);
67
+ });
68
+ test('performs dry-run planning without making writes', async () => {
69
+ Config.set({
70
+ dryRun: true,
71
+ providers: { aws: { region: 'us-east-1' } }
72
+ });
73
+ mockIamResponses['ListPoliciesCommand'] = { Policies: [] };
74
+ const builder = new IAMPolicyBuilder('my-policy')
75
+ .document({
76
+ Version: '2012-10-17',
77
+ Statement: [{ Effect: 'Allow', Action: 's3:*', Resource: '*' }]
78
+ })
79
+ .description('Friendly desc');
80
+ const result = await builder.deploy();
81
+ assert.ok(result);
82
+ assert.strictEqual(result.arn, 'arn:aws:iam::000000000000:policy/DRYRUN-my-policy');
83
+ // Assert only discovery ListPolicies was sent, no writes
84
+ const writeCalls = iamCalls.filter(c => c.commandName !== 'ListPoliciesCommand');
85
+ assert.strictEqual(writeCalls.length, 0);
86
+ });
87
+ test('deploys new policy when missing', async () => {
88
+ mockIamResponses['ListPoliciesCommand'] = { Policies: [] };
89
+ mockIamResponses['CreatePolicyCommand'] = {
90
+ Policy: { Arn: 'arn:aws:iam::123456789012:policy/my-policy' }
91
+ };
92
+ const builder = new IAMPolicyBuilder('my-policy')
93
+ .document({
94
+ Version: '2012-10-17',
95
+ Statement: [{ Effect: 'Allow', Action: 's3:*', Resource: '*' }]
96
+ })
97
+ .description('My S3 Access Policy');
98
+ const result = await builder.deploy();
99
+ assert.ok(result);
100
+ assert.strictEqual(result.arn, 'arn:aws:iam::123456789012:policy/my-policy');
101
+ const createCall = iamCalls.find(c => c.commandName === 'CreatePolicyCommand');
102
+ assert.ok(createCall);
103
+ assert.strictEqual(createCall.input.PolicyName, 'my-policy');
104
+ assert.strictEqual(createCall.input.Description, 'My S3 Access Policy');
105
+ assert.deepStrictEqual(JSON.parse(createCall.input.PolicyDocument), {
106
+ Version: '2012-10-17',
107
+ Statement: [{ Effect: 'Allow', Action: 's3:*', Resource: '*' }]
108
+ });
109
+ });
110
+ test('updates policy creating new version and pruning oldest if versions >= 5', async () => {
111
+ const policyArn = 'arn:aws:iam::123456789012:policy/my-policy';
112
+ mockIamResponses['ListPoliciesCommand'] = {
113
+ Policies: [{ PolicyName: 'my-policy', Arn: policyArn }]
114
+ };
115
+ // Simulate 5 existing versions
116
+ mockIamResponses['ListPolicyVersionsCommand'] = {
117
+ Versions: [
118
+ { VersionId: 'v1', IsDefaultVersion: false, CreateDate: new Date('2026-01-01') },
119
+ { VersionId: 'v2', IsDefaultVersion: false, CreateDate: new Date('2026-01-02') },
120
+ { VersionId: 'v3', IsDefaultVersion: false, CreateDate: new Date('2026-01-03') },
121
+ { VersionId: 'v4', IsDefaultVersion: false, CreateDate: new Date('2026-01-04') },
122
+ { VersionId: 'v5', IsDefaultVersion: true, CreateDate: new Date('2026-01-05') },
123
+ ]
124
+ };
125
+ mockIamResponses['DeletePolicyVersionCommand'] = {};
126
+ mockIamResponses['CreatePolicyVersionCommand'] = {};
127
+ const builder = new IAMPolicyBuilder('my-policy')
128
+ .document({
129
+ Version: '2012-10-17',
130
+ Statement: [{ Effect: 'Allow', Action: 's3:GetObject', Resource: '*' }]
131
+ });
132
+ await builder.deploy();
133
+ // Assert oldest version v1 was deleted
134
+ const deleteCall = iamCalls.find(c => c.commandName === 'DeletePolicyVersionCommand');
135
+ assert.ok(deleteCall);
136
+ assert.strictEqual(deleteCall.input.PolicyArn, policyArn);
137
+ assert.strictEqual(deleteCall.input.VersionId, 'v1');
138
+ // Assert new version was created
139
+ const versionCall = iamCalls.find(c => c.commandName === 'CreatePolicyVersionCommand');
140
+ assert.ok(versionCall);
141
+ assert.strictEqual(versionCall.input.PolicyArn, policyArn);
142
+ assert.strictEqual(versionCall.input.SetAsDefault, true);
143
+ });
144
+ test('destroys custom managed policy and cleans up versions successfully', async () => {
145
+ const policyArn = 'arn:aws:iam::123456789012:policy/my-policy';
146
+ mockIamResponses['ListPoliciesCommand'] = {
147
+ Policies: [{ PolicyName: 'my-policy', Arn: policyArn }]
148
+ };
149
+ mockIamResponses['ListPolicyVersionsCommand'] = {
150
+ Versions: [
151
+ { VersionId: 'v1', IsDefaultVersion: false },
152
+ { VersionId: 'v2', IsDefaultVersion: true }
153
+ ]
154
+ };
155
+ mockIamResponses['DeletePolicyVersionCommand'] = {};
156
+ mockIamResponses['DeletePolicyCommand'] = {};
157
+ const builder = new IAMPolicyBuilder('my-policy');
158
+ await builder.discoveryPromise;
159
+ const result = await builder.destroy();
160
+ assert.deepStrictEqual(result, { destroyed: 'my-policy' });
161
+ // Non-default version v1 must be deleted
162
+ const deleteVersionCall = iamCalls.find(c => c.commandName === 'DeletePolicyVersionCommand');
163
+ assert.ok(deleteVersionCall);
164
+ assert.strictEqual(deleteVersionCall.input.VersionId, 'v1');
165
+ // Main policy deleted
166
+ const deletePolicyCall = iamCalls.find(c => c.commandName === 'DeletePolicyCommand');
167
+ assert.ok(deletePolicyCall);
168
+ assert.strictEqual(deletePolicyCall.input.PolicyArn, policyArn);
169
+ });
170
+ });
171
+ describe('IAMRoleBuilder Tests', () => {
172
+ test('gracefully handles discovery when role does not exist', async () => {
173
+ const noRoleError = new Error('NoSuchEntityException');
174
+ noRoleError.name = 'NoSuchEntityException';
175
+ mockIamResponses['GetRoleCommand'] = noRoleError;
176
+ const builder = new IAMRoleBuilder('my-role');
177
+ const discoveryResult = await builder.discoveryPromise;
178
+ assert.strictEqual(discoveryResult, null);
179
+ assert.strictEqual(iamCalls.length, 1);
180
+ assert.strictEqual(iamCalls[0].commandName, 'GetRoleCommand');
181
+ });
182
+ test('discovers role successfully when it exists', async () => {
183
+ const expectedArn = 'arn:aws:iam::123456789012:role/my-role';
184
+ mockIamResponses['GetRoleCommand'] = {
185
+ Role: { RoleName: 'my-role', Arn: expectedArn }
186
+ };
187
+ const builder = new IAMRoleBuilder('my-role');
188
+ const discoveryResult = await builder.discoveryPromise;
189
+ assert.ok(discoveryResult);
190
+ assert.strictEqual(builder.resolvedArn, expectedArn);
191
+ const resolvedArn = await builder.out.arn.get();
192
+ assert.strictEqual(resolvedArn, expectedArn);
193
+ const resolvedName = await builder.out.name.get();
194
+ assert.strictEqual(resolvedName, 'my-role');
195
+ });
196
+ test('performs dry-run planning without making writes', async () => {
197
+ Config.set({
198
+ dryRun: true,
199
+ providers: { aws: { region: 'us-east-1' } }
200
+ });
201
+ const noRoleError = new Error('NoSuchEntityException');
202
+ noRoleError.name = 'NoSuchEntityException';
203
+ mockIamResponses['GetRoleCommand'] = noRoleError;
204
+ const builder = new IAMRoleBuilder('my-role')
205
+ .assumeRolePolicy({
206
+ Statement: [{ Effect: 'Allow', Principal: { Service: 'ec2.amazonaws.com' }, Action: 'sts:AssumeRole' }]
207
+ });
208
+ const result = await builder.deploy();
209
+ assert.ok(result);
210
+ assert.strictEqual(result.arn, 'arn:aws:iam::000000000000:role/DRYRUN-my-role');
211
+ // Only discovery GetRole was sent
212
+ const writeCalls = iamCalls.filter(c => c.commandName !== 'GetRoleCommand');
213
+ assert.strictEqual(writeCalls.length, 0);
214
+ });
215
+ test('deploys new role with assume-role trust policy, managed attachments, and inline statements', async () => {
216
+ const noRoleError = new Error('NoSuchEntityException');
217
+ noRoleError.name = 'NoSuchEntityException';
218
+ mockIamResponses['GetRoleCommand'] = noRoleError;
219
+ const expectedArn = 'arn:aws:iam::123456789012:role/my-role';
220
+ mockIamResponses['CreateRoleCommand'] = { Role: { Arn: expectedArn } };
221
+ mockIamResponses['ListAttachedRolePoliciesCommand'] = { AttachedPolicies: [] };
222
+ mockIamResponses['AttachRolePolicyCommand'] = {};
223
+ mockIamResponses['ListRolePoliciesCommand'] = { PolicyNames: [] };
224
+ mockIamResponses['PutRolePolicyCommand'] = {};
225
+ const builder = new IAMRoleBuilder('my-role')
226
+ .assumeRolePolicy({
227
+ Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }]
228
+ })
229
+ .attach('arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess')
230
+ .inlinePolicy('my-inline', {
231
+ Statement: [{ Effect: 'Allow', Action: 'sqs:*', Resource: '*' }]
232
+ });
233
+ const result = await builder.deploy();
234
+ assert.ok(result);
235
+ assert.strictEqual(result.arn, expectedArn);
236
+ // Verify CreateRole arguments
237
+ const createCall = iamCalls.find(c => c.commandName === 'CreateRoleCommand');
238
+ assert.ok(createCall);
239
+ assert.strictEqual(createCall.input.RoleName, 'my-role');
240
+ assert.deepStrictEqual(JSON.parse(createCall.input.AssumeRolePolicyDocument), {
241
+ Statement: [{ Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' }, Action: 'sts:AssumeRole' }]
242
+ });
243
+ // Verify Managed Policy attachment
244
+ const attachCall = iamCalls.find(c => c.commandName === 'AttachRolePolicyCommand');
245
+ assert.ok(attachCall);
246
+ assert.strictEqual(attachCall.input.RoleName, 'my-role');
247
+ assert.strictEqual(attachCall.input.PolicyArn, 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess');
248
+ // Verify Inline Policy creation
249
+ const inlineCall = iamCalls.find(c => c.commandName === 'PutRolePolicyCommand');
250
+ assert.ok(inlineCall);
251
+ assert.strictEqual(inlineCall.input.RoleName, 'my-role');
252
+ assert.strictEqual(inlineCall.input.PolicyName, 'my-inline');
253
+ assert.deepStrictEqual(JSON.parse(inlineCall.input.PolicyDocument), {
254
+ Statement: [{ Effect: 'Allow', Action: 'sqs:*', Resource: '*' }]
255
+ });
256
+ });
257
+ test('updates existing role, syncing managed policy attachments and inline policies', async () => {
258
+ mockIamResponses['GetRoleCommand'] = {
259
+ Role: { RoleName: 'my-role', Arn: 'arn:aws:iam::123456789012:role/my-role' }
260
+ };
261
+ mockIamResponses['UpdateAssumeRolePolicyCommand'] = {};
262
+ mockIamResponses['UpdateRoleCommand'] = {};
263
+ // Current managed policies: AmazonS3ReadOnlyAccess, AmazonDynamoDBFullAccess (stale)
264
+ mockIamResponses['ListAttachedRolePoliciesCommand'] = {
265
+ AttachedPolicies: [
266
+ { PolicyName: 'AmazonS3ReadOnlyAccess', PolicyArn: 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' },
267
+ { PolicyName: 'AmazonDynamoDBFullAccess', PolicyArn: 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess' }
268
+ ]
269
+ };
270
+ mockIamResponses['AttachRolePolicyCommand'] = {};
271
+ mockIamResponses['DetachRolePolicyCommand'] = {};
272
+ // Current inline policies: stale-inline, matching-inline
273
+ mockIamResponses['ListRolePoliciesCommand'] = {
274
+ PolicyNames: ['stale-inline', 'matching-inline']
275
+ };
276
+ mockIamResponses['PutRolePolicyCommand'] = {};
277
+ mockIamResponses['DeleteRolePolicyCommand'] = {};
278
+ // Configured builder wants: AmazonS3ReadOnlyAccess, AmazonSQSFullAccess (new), and 'matching-inline'
279
+ const builder = new IAMRoleBuilder('my-role')
280
+ .attach('arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess')
281
+ .attach('arn:aws:iam::aws:policy/AmazonSQSFullAccess')
282
+ .inlinePolicy('matching-inline', {
283
+ Statement: [{ Effect: 'Allow', Action: 'sqs:*', Resource: '*' }]
284
+ });
285
+ await builder.deploy();
286
+ // Assert Detach is called on stale AmazonDynamoDBFullAccess
287
+ const detachCall = iamCalls.find(c => c.commandName === 'DetachRolePolicyCommand');
288
+ assert.ok(detachCall);
289
+ assert.strictEqual(detachCall.input.PolicyArn, 'arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess');
290
+ // Assert Attach is called on new AmazonSQSFullAccess
291
+ const attachCall = iamCalls.find(c => c.commandName === 'AttachRolePolicyCommand');
292
+ assert.ok(attachCall);
293
+ assert.strictEqual(attachCall.input.PolicyArn, 'arn:aws:iam::aws:policy/AmazonSQSFullAccess');
294
+ // Assert Delete is called on stale-inline policy
295
+ const deleteInlineCall = iamCalls.find(c => c.commandName === 'DeleteRolePolicyCommand');
296
+ assert.ok(deleteInlineCall);
297
+ assert.strictEqual(deleteInlineCall.input.PolicyName, 'stale-inline');
298
+ // Assert Put is called on matching-inline policy
299
+ const putInlineCall = iamCalls.find(c => c.commandName === 'PutRolePolicyCommand');
300
+ assert.ok(putInlineCall);
301
+ assert.strictEqual(putInlineCall.input.PolicyName, 'matching-inline');
302
+ });
303
+ test('destroys existing role cleaning up inline policies and managed attachments', async () => {
304
+ mockIamResponses['GetRoleCommand'] = {
305
+ Role: { RoleName: 'my-role', Arn: 'arn:aws:iam::123456789012:role/my-role' }
306
+ };
307
+ mockIamResponses['ListRolePoliciesCommand'] = { PolicyNames: ['inline-one'] };
308
+ mockIamResponses['DeleteRolePolicyCommand'] = {};
309
+ mockIamResponses['ListAttachedRolePoliciesCommand'] = {
310
+ AttachedPolicies: [{ PolicyName: 'S3Access', PolicyArn: 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess' }]
311
+ };
312
+ mockIamResponses['DetachRolePolicyCommand'] = {};
313
+ mockIamResponses['DeleteRoleCommand'] = {};
314
+ const builder = new IAMRoleBuilder('my-role');
315
+ await builder.discoveryPromise;
316
+ const result = await builder.destroy();
317
+ assert.deepStrictEqual(result, { destroyed: 'my-role' });
318
+ // Inline policy deleted
319
+ const deleteInline = iamCalls.find(c => c.commandName === 'DeleteRolePolicyCommand');
320
+ assert.ok(deleteInline);
321
+ assert.strictEqual(deleteInline.input.PolicyName, 'inline-one');
322
+ // Managed policy detached
323
+ const detachCall = iamCalls.find(c => c.commandName === 'DetachRolePolicyCommand');
324
+ assert.ok(detachCall);
325
+ assert.strictEqual(detachCall.input.PolicyArn, 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess');
326
+ // Role deleted
327
+ const deleteRole = iamCalls.find(c => c.commandName === 'DeleteRoleCommand');
328
+ assert.ok(deleteRole);
329
+ assert.strictEqual(deleteRole.input.RoleName, 'my-role');
330
+ });
331
+ });
332
+ describe('Lambda Integration', () => {
333
+ test('LambdaBuilder accepts IAMRoleBuilder and resolves its eager output ARN successfully', async () => {
334
+ // 1. Mock Role discovery to succeed
335
+ const expectedRoleArn = 'arn:aws:iam::123456789012:role/my-custom-role';
336
+ mockIamResponses['GetRoleCommand'] = {
337
+ Role: { RoleName: 'my-custom-role', Arn: expectedRoleArn }
338
+ };
339
+ const roleBuilder = new IAMRoleBuilder('my-custom-role');
340
+ // 2. Mock Lambda discovery to report not found (forces deploy ensureRole)
341
+ let lambdaCalls = [];
342
+ const originalLambdaSend = LambdaClient.prototype.send;
343
+ LambdaClient.prototype.send = async function (command) {
344
+ const commandName = command.constructor.name;
345
+ lambdaCalls.push({ commandName, input: command.input });
346
+ if (commandName === 'GetFunctionCommand') {
347
+ const notFoundError = new Error('Function not found');
348
+ notFoundError.name = 'ResourceNotFoundException';
349
+ throw notFoundError;
350
+ }
351
+ return { FunctionArn: 'arn:aws:lambda:us-east-1:12345:function:my-fn' };
352
+ };
353
+ // Mock fast-forward setTimeout
354
+ mock.method(global, 'setTimeout', (fn) => fn());
355
+ const lambdaBuilder = new LambdaBuilder('my-fn');
356
+ lambdaBuilder
357
+ .code('my-code.zip')
358
+ .role(roleBuilder); // custom role builder integration!
359
+ await lambdaBuilder.deploy();
360
+ // Assert Lambda was created using the custom role builder's ARN eagerly
361
+ const createFnCall = lambdaCalls.find(c => c.commandName === 'CreateFunctionCommand');
362
+ assert.ok(createFnCall);
363
+ assert.strictEqual(createFnCall.input.Role, expectedRoleArn);
364
+ LambdaClient.prototype.send = originalLambdaSend;
365
+ });
366
+ });
367
+ });