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.
@@ -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,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 {};