create-aws-project 1.5.1 → 1.7.0
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.
- package/dist/__tests__/aws/cdk-bootstrap.spec.d.ts +1 -0
- package/dist/__tests__/aws/cdk-bootstrap.spec.js +266 -0
- package/dist/__tests__/aws/root-credentials.spec.d.ts +1 -0
- package/dist/__tests__/aws/root-credentials.spec.js +230 -0
- package/dist/__tests__/config/non-interactive-aws.spec.d.ts +1 -0
- package/dist/__tests__/config/non-interactive-aws.spec.js +100 -0
- package/dist/__tests__/config/non-interactive.spec.d.ts +1 -0
- package/dist/__tests__/config/non-interactive.spec.js +152 -0
- package/dist/aws/cdk-bootstrap.d.ts +77 -0
- package/dist/aws/cdk-bootstrap.js +139 -0
- package/dist/aws/iam.js +17 -0
- package/dist/aws/organizations.d.ts +7 -1
- package/dist/aws/organizations.js +19 -1
- package/dist/aws/root-credentials.d.ts +68 -0
- package/dist/aws/root-credentials.js +157 -0
- package/dist/cli.js +58 -23
- package/dist/commands/initialize-github.js +162 -83
- package/dist/commands/setup-aws-envs.d.ts +2 -2
- package/dist/commands/setup-aws-envs.js +590 -52
- package/dist/config/non-interactive-aws.d.ts +27 -0
- package/dist/config/non-interactive-aws.js +80 -0
- package/dist/config/non-interactive.d.ts +48 -0
- package/dist/config/non-interactive.js +92 -0
- package/dist/git/setup.d.ts +3 -3
- package/dist/git/setup.js +28 -24
- package/dist/utils/project-context.d.ts +14 -0
- package/package.json +4 -3
- package/templates/apps/web/src/App.tsx +4 -2
- package/templates/apps/web/src/__tests__/App.spec.tsx +27 -9
- package/templates/apps/web/vite.config.ts +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
// Mock execa
|
|
3
|
+
const mockExeca = jest.fn();
|
|
4
|
+
jest.unstable_mockModule('execa', () => ({
|
|
5
|
+
execa: mockExeca,
|
|
6
|
+
}));
|
|
7
|
+
// Mock AWS SDK STS client
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
const mockSTSSend = jest.fn();
|
|
10
|
+
const mockAssumeRoleCommand = jest.fn();
|
|
11
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
jest.unstable_mockModule('@aws-sdk/client-sts', () => {
|
|
14
|
+
class MockSTSClient {
|
|
15
|
+
send = mockSTSSend;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
STSClient: MockSTSClient,
|
|
19
|
+
AssumeRoleCommand: mockAssumeRoleCommand,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
23
|
+
// Mock ora (for spinner type)
|
|
24
|
+
const mockOra = {
|
|
25
|
+
text: '',
|
|
26
|
+
succeed: jest.fn(),
|
|
27
|
+
fail: jest.fn(),
|
|
28
|
+
};
|
|
29
|
+
const { bootstrapCDKEnvironment, bootstrapAllEnvironments } = await import('../../aws/cdk-bootstrap.js');
|
|
30
|
+
describe('cdk-bootstrap', () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.clearAllMocks();
|
|
33
|
+
mockOra.text = '';
|
|
34
|
+
// Mock AssumeRoleCommand to return the input
|
|
35
|
+
mockAssumeRoleCommand.mockImplementation((input) => input);
|
|
36
|
+
});
|
|
37
|
+
describe('bootstrapCDKEnvironment', () => {
|
|
38
|
+
it('calls execa with correct npx cdk bootstrap command', async () => {
|
|
39
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
40
|
+
await bootstrapCDKEnvironment({
|
|
41
|
+
accountId: '123456789012',
|
|
42
|
+
region: 'us-west-2',
|
|
43
|
+
credentials: {
|
|
44
|
+
accessKeyId: 'AKIATEST',
|
|
45
|
+
secretAccessKey: 'secret123',
|
|
46
|
+
sessionToken: 'token123',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', [
|
|
50
|
+
'cdk',
|
|
51
|
+
'bootstrap',
|
|
52
|
+
'aws://123456789012/us-west-2',
|
|
53
|
+
'--trust',
|
|
54
|
+
'123456789012',
|
|
55
|
+
'--cloudformation-execution-policies',
|
|
56
|
+
'arn:aws:iam::aws:policy/AdministratorAccess',
|
|
57
|
+
'--require-approval',
|
|
58
|
+
'never',
|
|
59
|
+
], expect.objectContaining({
|
|
60
|
+
all: true,
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
it('passes credentials as environment variables', async () => {
|
|
64
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
65
|
+
await bootstrapCDKEnvironment({
|
|
66
|
+
accountId: '123456789012',
|
|
67
|
+
region: 'us-west-2',
|
|
68
|
+
credentials: {
|
|
69
|
+
accessKeyId: 'AKIATEST',
|
|
70
|
+
secretAccessKey: 'secret123',
|
|
71
|
+
sessionToken: 'token123',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
const callArgs = mockExeca.mock.calls[0];
|
|
75
|
+
const options = callArgs[2];
|
|
76
|
+
expect(options.env).toBeDefined();
|
|
77
|
+
expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
|
|
78
|
+
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
|
|
79
|
+
expect(options.env?.AWS_SESSION_TOKEN).toBe('token123');
|
|
80
|
+
expect(options.env?.AWS_REGION).toBe('us-west-2');
|
|
81
|
+
});
|
|
82
|
+
it('omits session token from env if not provided', async () => {
|
|
83
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
84
|
+
await bootstrapCDKEnvironment({
|
|
85
|
+
accountId: '123456789012',
|
|
86
|
+
region: 'us-west-2',
|
|
87
|
+
credentials: {
|
|
88
|
+
accessKeyId: 'AKIATEST',
|
|
89
|
+
secretAccessKey: 'secret123',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const callArgs = mockExeca.mock.calls[0];
|
|
93
|
+
const options = callArgs[2];
|
|
94
|
+
expect(options.env).toBeDefined();
|
|
95
|
+
expect(options.env?.AWS_ACCESS_KEY_ID).toBe('AKIATEST');
|
|
96
|
+
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBe('secret123');
|
|
97
|
+
expect(options.env?.AWS_SESSION_TOKEN).toBeUndefined();
|
|
98
|
+
expect(options.env?.AWS_REGION).toBe('us-west-2');
|
|
99
|
+
});
|
|
100
|
+
it('returns success result on successful execution', async () => {
|
|
101
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
102
|
+
const result = await bootstrapCDKEnvironment({
|
|
103
|
+
accountId: '123456789012',
|
|
104
|
+
region: 'us-west-2',
|
|
105
|
+
credentials: {
|
|
106
|
+
accessKeyId: 'AKIATEST',
|
|
107
|
+
secretAccessKey: 'secret123',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
expect(result.success).toBe(true);
|
|
111
|
+
expect(result.output).toBe('Bootstrap successful');
|
|
112
|
+
});
|
|
113
|
+
it('returns failure result with output on error', async () => {
|
|
114
|
+
const error = new Error('Bootstrap failed');
|
|
115
|
+
error.all = 'Error: Stack already exists';
|
|
116
|
+
mockExeca.mockRejectedValue(error);
|
|
117
|
+
const result = await bootstrapCDKEnvironment({
|
|
118
|
+
accountId: '123456789012',
|
|
119
|
+
region: 'us-west-2',
|
|
120
|
+
credentials: {
|
|
121
|
+
accessKeyId: 'AKIATEST',
|
|
122
|
+
secretAccessKey: 'secret123',
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
expect(result.success).toBe(false);
|
|
126
|
+
expect(result.output).toBe('Error: Stack already exists');
|
|
127
|
+
});
|
|
128
|
+
it('uses error message if all property not available', async () => {
|
|
129
|
+
const error = new Error('Bootstrap failed');
|
|
130
|
+
mockExeca.mockRejectedValue(error);
|
|
131
|
+
const result = await bootstrapCDKEnvironment({
|
|
132
|
+
accountId: '123456789012',
|
|
133
|
+
region: 'us-west-2',
|
|
134
|
+
credentials: {
|
|
135
|
+
accessKeyId: 'AKIATEST',
|
|
136
|
+
secretAccessKey: 'secret123',
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
expect(result.success).toBe(false);
|
|
140
|
+
expect(result.output).toBe('Bootstrap failed');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe('bootstrapAllEnvironments', () => {
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
// Mock successful STS AssumeRole responses
|
|
146
|
+
mockSTSSend.mockResolvedValue({
|
|
147
|
+
Credentials: {
|
|
148
|
+
AccessKeyId: 'ASIATEMP123',
|
|
149
|
+
SecretAccessKey: 'tempsecret123',
|
|
150
|
+
SessionToken: 'temptoken123',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
// Mock successful bootstrap
|
|
154
|
+
mockExeca.mockResolvedValue({ all: 'Bootstrap successful' });
|
|
155
|
+
});
|
|
156
|
+
it('calls bootstrapCDKEnvironment for each environment', async () => {
|
|
157
|
+
await bootstrapAllEnvironments({
|
|
158
|
+
accounts: {
|
|
159
|
+
dev: '111111111111',
|
|
160
|
+
stage: '222222222222',
|
|
161
|
+
prod: '333333333333',
|
|
162
|
+
},
|
|
163
|
+
region: 'us-east-1',
|
|
164
|
+
adminCredentials: {
|
|
165
|
+
accessKeyId: 'AKIAADMIN',
|
|
166
|
+
secretAccessKey: 'adminsecret',
|
|
167
|
+
},
|
|
168
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
169
|
+
});
|
|
170
|
+
// Should call execa 3 times (once per environment)
|
|
171
|
+
expect(mockExeca).toHaveBeenCalledTimes(3);
|
|
172
|
+
// Verify each environment was bootstrapped
|
|
173
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://111111111111/us-east-1']), expect.any(Object));
|
|
174
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://222222222222/us-east-1']), expect.any(Object));
|
|
175
|
+
expect(mockExeca).toHaveBeenCalledWith('npx', expect.arrayContaining(['aws://333333333333/us-east-1']), expect.any(Object));
|
|
176
|
+
});
|
|
177
|
+
it('assumes OrganizationAccountAccessRole for each account', async () => {
|
|
178
|
+
await bootstrapAllEnvironments({
|
|
179
|
+
accounts: {
|
|
180
|
+
dev: '111111111111',
|
|
181
|
+
stage: '222222222222',
|
|
182
|
+
prod: '333333333333',
|
|
183
|
+
},
|
|
184
|
+
region: 'us-east-1',
|
|
185
|
+
adminCredentials: {
|
|
186
|
+
accessKeyId: 'AKIAADMIN',
|
|
187
|
+
secretAccessKey: 'adminsecret',
|
|
188
|
+
},
|
|
189
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
190
|
+
});
|
|
191
|
+
// Should call AssumeRoleCommand 3 times (once per environment)
|
|
192
|
+
expect(mockAssumeRoleCommand).toHaveBeenCalledTimes(3);
|
|
193
|
+
// Verify role ARNs for each environment
|
|
194
|
+
const calls = mockAssumeRoleCommand.mock.calls;
|
|
195
|
+
expect(calls[0][0].RoleArn).toBe('arn:aws:iam::111111111111:role/OrganizationAccountAccessRole');
|
|
196
|
+
expect(calls[1][0].RoleArn).toBe('arn:aws:iam::222222222222:role/OrganizationAccountAccessRole');
|
|
197
|
+
expect(calls[2][0].RoleArn).toBe('arn:aws:iam::333333333333:role/OrganizationAccountAccessRole');
|
|
198
|
+
});
|
|
199
|
+
it('updates spinner text for each environment', async () => {
|
|
200
|
+
await bootstrapAllEnvironments({
|
|
201
|
+
accounts: {
|
|
202
|
+
dev: '111111111111',
|
|
203
|
+
stage: '222222222222',
|
|
204
|
+
prod: '333333333333',
|
|
205
|
+
},
|
|
206
|
+
region: 'us-east-1',
|
|
207
|
+
adminCredentials: {
|
|
208
|
+
accessKeyId: 'AKIAADMIN',
|
|
209
|
+
secretAccessKey: 'adminsecret',
|
|
210
|
+
},
|
|
211
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
212
|
+
});
|
|
213
|
+
expect(mockOra.succeed).toHaveBeenCalledTimes(3);
|
|
214
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in dev account (111111111111)');
|
|
215
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in stage account (222222222222)');
|
|
216
|
+
expect(mockOra.succeed).toHaveBeenCalledWith('CDK bootstrapped in prod account (333333333333)');
|
|
217
|
+
});
|
|
218
|
+
it('throws error and fails spinner if bootstrap fails', async () => {
|
|
219
|
+
mockExeca.mockResolvedValueOnce({ all: 'Success' }); // dev succeeds
|
|
220
|
+
mockExeca.mockRejectedValueOnce({
|
|
221
|
+
all: 'Error: CDK bootstrap failed',
|
|
222
|
+
message: 'Error: CDK bootstrap failed',
|
|
223
|
+
}); // stage fails
|
|
224
|
+
await expect(bootstrapAllEnvironments({
|
|
225
|
+
accounts: {
|
|
226
|
+
dev: '111111111111',
|
|
227
|
+
stage: '222222222222',
|
|
228
|
+
prod: '333333333333',
|
|
229
|
+
},
|
|
230
|
+
region: 'us-east-1',
|
|
231
|
+
adminCredentials: null,
|
|
232
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
233
|
+
})).rejects.toThrow('CDK bootstrap failed in stage account');
|
|
234
|
+
expect(mockOra.fail).toHaveBeenCalledWith('CDK bootstrap failed in stage account');
|
|
235
|
+
});
|
|
236
|
+
it('works with null adminCredentials (uses default credentials)', async () => {
|
|
237
|
+
await bootstrapAllEnvironments({
|
|
238
|
+
accounts: {
|
|
239
|
+
dev: '111111111111',
|
|
240
|
+
},
|
|
241
|
+
region: 'us-east-1',
|
|
242
|
+
adminCredentials: null,
|
|
243
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
244
|
+
});
|
|
245
|
+
expect(mockSTSSend).toHaveBeenCalled();
|
|
246
|
+
expect(mockExeca).toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
it('skips environments with missing account IDs', async () => {
|
|
249
|
+
await bootstrapAllEnvironments({
|
|
250
|
+
accounts: {
|
|
251
|
+
dev: '111111111111',
|
|
252
|
+
// stage missing
|
|
253
|
+
prod: '333333333333',
|
|
254
|
+
},
|
|
255
|
+
region: 'us-east-1',
|
|
256
|
+
adminCredentials: {
|
|
257
|
+
accessKeyId: 'AKIAADMIN',
|
|
258
|
+
secretAccessKey: 'adminsecret',
|
|
259
|
+
},
|
|
260
|
+
spinner: mockOra, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
261
|
+
});
|
|
262
|
+
// Should only call execa twice (dev and prod, skip stage)
|
|
263
|
+
expect(mockExeca).toHaveBeenCalledTimes(2);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
// Mock getAccessKeyCount from iam.ts
|
|
3
|
+
const mockGetAccessKeyCount = jest.fn();
|
|
4
|
+
jest.unstable_mockModule('../../aws/iam.js', () => ({
|
|
5
|
+
getAccessKeyCount: mockGetAccessKeyCount,
|
|
6
|
+
}));
|
|
7
|
+
// Mock AWS SDK clients
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
const mockSTSSend = jest.fn();
|
|
10
|
+
const mockIAMSend = jest.fn();
|
|
11
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
12
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
13
|
+
jest.unstable_mockModule('@aws-sdk/client-sts', () => {
|
|
14
|
+
class MockSTSClient {
|
|
15
|
+
send = mockSTSSend;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
STSClient: MockSTSClient,
|
|
19
|
+
GetCallerIdentityCommand: jest.fn((input) => input),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
jest.unstable_mockModule('@aws-sdk/client-iam', () => {
|
|
23
|
+
class MockIAMClient {
|
|
24
|
+
send = mockIAMSend;
|
|
25
|
+
}
|
|
26
|
+
class NoSuchEntityException extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'NoSuchEntityException';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
IAMClient: MockIAMClient,
|
|
34
|
+
CreateUserCommand: jest.fn((input) => input),
|
|
35
|
+
GetUserCommand: jest.fn((input) => input),
|
|
36
|
+
AttachUserPolicyCommand: jest.fn((input) => input),
|
|
37
|
+
CreateAccessKeyCommand: jest.fn((input) => input),
|
|
38
|
+
ListUserTagsCommand: jest.fn((input) => input),
|
|
39
|
+
NoSuchEntityException,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
43
|
+
const { isRootUser, detectRootCredentials, retryWithBackoff, createOrAdoptAdminUser, } = await import('../../aws/root-credentials.js');
|
|
44
|
+
describe('root-credentials', () => {
|
|
45
|
+
describe('isRootUser', () => {
|
|
46
|
+
it('returns true for root ARN', () => {
|
|
47
|
+
expect(isRootUser('arn:aws:iam::123456789012:root')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
it('returns false for IAM user ARN', () => {
|
|
50
|
+
expect(isRootUser('arn:aws:iam::123456789012:user/admin')).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
it('returns false for different IAM user', () => {
|
|
53
|
+
expect(isRootUser('arn:aws:iam::123456789012:user/deploy')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
it('returns false for assumed role ARN', () => {
|
|
56
|
+
expect(isRootUser('arn:aws:sts::123456789012:assumed-role/role-name/session')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('returns false for empty string', () => {
|
|
59
|
+
expect(isRootUser('')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('detectRootCredentials', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
it('detects root credentials correctly', async () => {
|
|
67
|
+
mockSTSSend.mockResolvedValueOnce({
|
|
68
|
+
Arn: 'arn:aws:iam::123456789012:root',
|
|
69
|
+
Account: '123456789012',
|
|
70
|
+
UserId: '123456789012',
|
|
71
|
+
});
|
|
72
|
+
const result = await detectRootCredentials('us-east-1');
|
|
73
|
+
expect(result.arn).toBe('arn:aws:iam::123456789012:root');
|
|
74
|
+
expect(result.accountId).toBe('123456789012');
|
|
75
|
+
expect(result.userId).toBe('123456789012');
|
|
76
|
+
expect(result.isRoot).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it('detects IAM user credentials correctly', async () => {
|
|
79
|
+
mockSTSSend.mockResolvedValueOnce({
|
|
80
|
+
Arn: 'arn:aws:iam::123456789012:user/admin',
|
|
81
|
+
Account: '123456789012',
|
|
82
|
+
UserId: 'AIDACKCEVSQ6C2EXAMPLE',
|
|
83
|
+
});
|
|
84
|
+
const result = await detectRootCredentials('us-east-1');
|
|
85
|
+
expect(result.isRoot).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it('propagates errors from STS', async () => {
|
|
88
|
+
mockSTSSend.mockRejectedValueOnce(new Error('Access denied'));
|
|
89
|
+
await expect(detectRootCredentials('us-east-1')).rejects.toThrow('Access denied');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('retryWithBackoff', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
jest.useFakeTimers();
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
jest.useRealTimers();
|
|
98
|
+
});
|
|
99
|
+
it('returns result on first success', async () => {
|
|
100
|
+
const fn = jest.fn().mockResolvedValue('success');
|
|
101
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10 });
|
|
102
|
+
await jest.runAllTimersAsync();
|
|
103
|
+
const result = await promise;
|
|
104
|
+
expect(result).toBe('success');
|
|
105
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
it('retries on failure and eventually succeeds', async () => {
|
|
108
|
+
const fn = jest.fn()
|
|
109
|
+
.mockRejectedValueOnce(new Error('Fail 1'))
|
|
110
|
+
.mockRejectedValueOnce(new Error('Fail 2'))
|
|
111
|
+
.mockResolvedValue('success');
|
|
112
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 5 });
|
|
113
|
+
await jest.runAllTimersAsync();
|
|
114
|
+
const result = await promise;
|
|
115
|
+
expect(result).toBe('success');
|
|
116
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
117
|
+
});
|
|
118
|
+
it('throws last error after all retries fail', async () => {
|
|
119
|
+
const fn = jest.fn();
|
|
120
|
+
fn.mockRejectedValue(new Error('Permanent failure'));
|
|
121
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 2 });
|
|
122
|
+
jest.runAllTimersAsync();
|
|
123
|
+
try {
|
|
124
|
+
await promise;
|
|
125
|
+
throw new Error('Should have thrown');
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
expect(error).toBeInstanceOf(Error);
|
|
129
|
+
expect(error.message).toBe('Permanent failure');
|
|
130
|
+
}
|
|
131
|
+
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
|
132
|
+
});
|
|
133
|
+
it('respects maxRetries option', async () => {
|
|
134
|
+
const fn = jest.fn();
|
|
135
|
+
fn.mockRejectedValue(new Error('Always fails'));
|
|
136
|
+
const promise = retryWithBackoff(fn, { baseDelayMs: 10, maxRetries: 3 });
|
|
137
|
+
jest.runAllTimersAsync();
|
|
138
|
+
try {
|
|
139
|
+
await promise;
|
|
140
|
+
throw new Error('Should have thrown');
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
expect(error).toBeInstanceOf(Error);
|
|
144
|
+
expect(error.message).toBe('Always fails');
|
|
145
|
+
}
|
|
146
|
+
expect(fn).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
describe('createOrAdoptAdminUser', () => {
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
jest.clearAllMocks();
|
|
152
|
+
mockGetAccessKeyCount.mockReset();
|
|
153
|
+
});
|
|
154
|
+
it('creates new admin user when user does not exist', async () => {
|
|
155
|
+
// Import the mocked IAMClient and exception class
|
|
156
|
+
const { IAMClient, NoSuchEntityException } = await import('@aws-sdk/client-iam');
|
|
157
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
158
|
+
// Use the mocked NoSuchEntityException class
|
|
159
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
|
+
const noSuchEntityError = new NoSuchEntityException('User does not exist');
|
|
161
|
+
mockIAMSend
|
|
162
|
+
.mockRejectedValueOnce(noSuchEntityError) // GetUserCommand throws
|
|
163
|
+
.mockResolvedValueOnce({}) // CreateUserCommand succeeds
|
|
164
|
+
.mockResolvedValueOnce({}) // AttachUserPolicyCommand succeeds
|
|
165
|
+
.mockResolvedValueOnce({
|
|
166
|
+
// CreateAccessKeyCommand succeeds
|
|
167
|
+
AccessKey: {
|
|
168
|
+
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
169
|
+
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
mockGetAccessKeyCount.mockResolvedValue(0);
|
|
173
|
+
const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
|
|
174
|
+
expect(result.userName).toBe('test-project-admin');
|
|
175
|
+
expect(result.accessKeyId).toBe('AKIAIOSFODNN7EXAMPLE');
|
|
176
|
+
expect(result.secretAccessKey).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
|
177
|
+
expect(result.adopted).toBe(false);
|
|
178
|
+
// Verify commands were called in order
|
|
179
|
+
expect(mockIAMSend).toHaveBeenCalledTimes(4);
|
|
180
|
+
});
|
|
181
|
+
it('adopts existing managed user with no keys', async () => {
|
|
182
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
183
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
184
|
+
mockIAMSend
|
|
185
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
186
|
+
.mockResolvedValueOnce({
|
|
187
|
+
// ListUserTagsCommand returns ManagedBy tag
|
|
188
|
+
Tags: [
|
|
189
|
+
{ Key: 'ManagedBy', Value: 'create-aws-starter-kit' },
|
|
190
|
+
{ Key: 'Purpose', Value: 'CLI Admin' },
|
|
191
|
+
],
|
|
192
|
+
})
|
|
193
|
+
.mockResolvedValueOnce({
|
|
194
|
+
// CreateAccessKeyCommand succeeds
|
|
195
|
+
AccessKey: {
|
|
196
|
+
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
197
|
+
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
mockGetAccessKeyCount.mockResolvedValue(0);
|
|
201
|
+
const result = await createOrAdoptAdminUser(mockIAMClient, 'test-project');
|
|
202
|
+
expect(result.userName).toBe('test-project-admin');
|
|
203
|
+
expect(result.adopted).toBe(true);
|
|
204
|
+
expect(mockIAMSend).toHaveBeenCalledTimes(3);
|
|
205
|
+
});
|
|
206
|
+
it('throws error when existing user is not managed by us', async () => {
|
|
207
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
208
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
209
|
+
mockIAMSend
|
|
210
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
211
|
+
.mockResolvedValueOnce({
|
|
212
|
+
// ListUserTagsCommand returns different tag
|
|
213
|
+
Tags: [{ Key: 'Owner', Value: 'SomeoneElse' }],
|
|
214
|
+
});
|
|
215
|
+
await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" exists but was not created by this tool');
|
|
216
|
+
});
|
|
217
|
+
it('throws error when existing managed user has keys', async () => {
|
|
218
|
+
const { IAMClient } = await import('@aws-sdk/client-iam');
|
|
219
|
+
const mockIAMClient = new IAMClient({ region: 'us-east-1' });
|
|
220
|
+
mockIAMSend
|
|
221
|
+
.mockResolvedValueOnce({}) // GetUserCommand succeeds
|
|
222
|
+
.mockResolvedValueOnce({
|
|
223
|
+
// ListUserTagsCommand returns ManagedBy tag
|
|
224
|
+
Tags: [{ Key: 'ManagedBy', Value: 'create-aws-starter-kit' }],
|
|
225
|
+
});
|
|
226
|
+
mockGetAccessKeyCount.mockResolvedValue(1);
|
|
227
|
+
await expect(createOrAdoptAdminUser(mockIAMClient, 'test-project')).rejects.toThrow('IAM user "test-project-admin" already exists with 1 access key');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// Mock picocolors to avoid console output issues in tests
|
|
6
|
+
jest.unstable_mockModule('picocolors', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: {
|
|
9
|
+
red: (s) => s,
|
|
10
|
+
green: (s) => s,
|
|
11
|
+
blue: (s) => s,
|
|
12
|
+
yellow: (s) => s,
|
|
13
|
+
cyan: (s) => s,
|
|
14
|
+
magenta: (s) => s,
|
|
15
|
+
bold: (s) => s,
|
|
16
|
+
dim: (s) => s,
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
// Dynamic import after mocking
|
|
20
|
+
const { loadSetupAwsEnvsConfig, deriveEnvironmentEmails } = await import('../../config/non-interactive-aws.js');
|
|
21
|
+
// Helper to write a temp config file and return its path
|
|
22
|
+
function writeTempConfig(content) {
|
|
23
|
+
const tmpFile = join(tmpdir(), `non-interactive-aws-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
24
|
+
writeFileSync(tmpFile, typeof content === 'string' ? content : JSON.stringify(content));
|
|
25
|
+
return tmpFile;
|
|
26
|
+
}
|
|
27
|
+
describe('deriveEnvironmentEmails', () => {
|
|
28
|
+
it('derives standard dev/stage/prod emails from a simple address', () => {
|
|
29
|
+
const result = deriveEnvironmentEmails('owner@example.com', ['dev', 'stage', 'prod']);
|
|
30
|
+
expect(result.dev).toBe('owner-dev@example.com');
|
|
31
|
+
expect(result.stage).toBe('owner-stage@example.com');
|
|
32
|
+
expect(result.prod).toBe('owner-prod@example.com');
|
|
33
|
+
});
|
|
34
|
+
it('handles plus alias emails', () => {
|
|
35
|
+
const result = deriveEnvironmentEmails('user+tag@company.com', ['dev']);
|
|
36
|
+
expect(result.dev).toBe('user+tag-dev@company.com');
|
|
37
|
+
});
|
|
38
|
+
it('handles subdomain emails', () => {
|
|
39
|
+
const result = deriveEnvironmentEmails('admin@sub.example.com', ['dev']);
|
|
40
|
+
expect(result.dev).toBe('admin-dev@sub.example.com');
|
|
41
|
+
});
|
|
42
|
+
it('returns an empty object for an empty environments array', () => {
|
|
43
|
+
const result = deriveEnvironmentEmails('owner@example.com', []);
|
|
44
|
+
expect(result).toEqual({});
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('loadSetupAwsEnvsConfig', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Mock process.exit to throw so tests can catch exit calls
|
|
50
|
+
jest.spyOn(process, 'exit').mockImplementation((() => {
|
|
51
|
+
throw new Error('process.exit called');
|
|
52
|
+
}));
|
|
53
|
+
// Suppress console.error output during tests
|
|
54
|
+
jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
55
|
+
});
|
|
56
|
+
describe('schema validation (valid configs)', () => {
|
|
57
|
+
it('loads valid config with email field only', () => {
|
|
58
|
+
const tmpFile = writeTempConfig({ email: 'owner@example.com' });
|
|
59
|
+
const config = loadSetupAwsEnvsConfig(tmpFile);
|
|
60
|
+
expect(config.email).toBe('owner@example.com');
|
|
61
|
+
});
|
|
62
|
+
it('strips unknown keys silently', () => {
|
|
63
|
+
const tmpFile = writeTempConfig({ email: 'owner@example.com', unknown: 'value', extra: 42 });
|
|
64
|
+
const config = loadSetupAwsEnvsConfig(tmpFile);
|
|
65
|
+
expect(config.email).toBe('owner@example.com');
|
|
66
|
+
expect(config.unknown).toBeUndefined();
|
|
67
|
+
expect(config.extra).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('schema validation (invalid configs)', () => {
|
|
71
|
+
it('exits with error when email is missing', () => {
|
|
72
|
+
const tmpFile = writeTempConfig({});
|
|
73
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
74
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
75
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('email'));
|
|
76
|
+
});
|
|
77
|
+
it('exits with error when email is empty string', () => {
|
|
78
|
+
const tmpFile = writeTempConfig({ email: '' });
|
|
79
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
80
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
81
|
+
});
|
|
82
|
+
it('exits with error when email has no @ sign', () => {
|
|
83
|
+
const tmpFile = writeTempConfig({ email: 'notanemail' });
|
|
84
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
85
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
86
|
+
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('email'));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('file errors', () => {
|
|
90
|
+
it('exits with error for non-existent file', () => {
|
|
91
|
+
expect(() => loadSetupAwsEnvsConfig('/nonexistent/path/aws-config.json')).toThrow('process.exit called');
|
|
92
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
93
|
+
});
|
|
94
|
+
it('exits with error for invalid JSON content', () => {
|
|
95
|
+
const tmpFile = writeTempConfig('not json {{{');
|
|
96
|
+
expect(() => loadSetupAwsEnvsConfig(tmpFile)).toThrow('process.exit called');
|
|
97
|
+
expect(process.exit).toHaveBeenCalledWith(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|