@friggframework/devtools 2.0.0-next.39 → 2.0.0-next.40
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/infrastructure/README.md +19 -8
- package/infrastructure/aws-discovery.js +951 -345
- package/infrastructure/aws-discovery.test.js +1031 -184
- package/infrastructure/build-time-discovery.test.js +3 -0
- package/infrastructure/iam-generator.js +46 -0
- package/infrastructure/iam-generator.test.js +7 -4
- package/infrastructure/serverless-template.js +1096 -813
- package/infrastructure/serverless-template.test.js +1036 -21
- package/package.json +8 -6
- package/infrastructure/AWS-DISCOVERY-TROUBLESHOOTING.md +0 -245
- package/infrastructure/AWS-IAM-CREDENTIAL-NEEDS.md +0 -627
- package/infrastructure/README-TESTING.md +0 -332
|
@@ -1,28 +1,43 @@
|
|
|
1
1
|
const { composeServerlessDefinition } = require('./serverless-template');
|
|
2
2
|
|
|
3
|
+
// Helper to build discovery responses with overridable fields
|
|
4
|
+
const createDiscoveryResponse = (overrides = {}) => ({
|
|
5
|
+
defaultVpcId: 'vpc-123456',
|
|
6
|
+
vpcCidr: '172.31.0.0/16', // Provide VPC CIDR so security group fallbacks can be tested
|
|
7
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
8
|
+
privateSubnetId1: 'subnet-123456',
|
|
9
|
+
privateSubnetId2: 'subnet-789012',
|
|
10
|
+
publicSubnetId: 'subnet-public',
|
|
11
|
+
defaultRouteTableId: 'rtb-123456',
|
|
12
|
+
defaultKmsKeyId:
|
|
13
|
+
'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012',
|
|
14
|
+
existingNatGatewayId: 'nat-default123',
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
3
18
|
// Mock AWS Discovery to prevent actual AWS calls
|
|
4
19
|
jest.mock('./aws-discovery', () => {
|
|
5
20
|
return {
|
|
6
|
-
AWSDiscovery: jest.fn().mockImplementation(() => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
privateSubnetId1: 'subnet-123456',
|
|
12
|
-
privateSubnetId2: 'subnet-789012',
|
|
13
|
-
publicSubnetId: 'subnet-public',
|
|
14
|
-
defaultRouteTableId: 'rtb-123456',
|
|
15
|
-
defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
|
|
16
|
-
})
|
|
17
|
-
};
|
|
18
|
-
})
|
|
21
|
+
AWSDiscovery: jest.fn().mockImplementation(() => ({
|
|
22
|
+
discoverResources: jest
|
|
23
|
+
.fn()
|
|
24
|
+
.mockResolvedValue(createDiscoveryResponse()),
|
|
25
|
+
})),
|
|
19
26
|
};
|
|
20
27
|
});
|
|
21
28
|
|
|
29
|
+
const { AWSDiscovery } = require('./aws-discovery');
|
|
30
|
+
|
|
22
31
|
describe('composeServerlessDefinition', () => {
|
|
23
32
|
let mockIntegration;
|
|
24
33
|
|
|
25
34
|
beforeEach(() => {
|
|
35
|
+
AWSDiscovery.mockImplementation(() => ({
|
|
36
|
+
discoverResources: jest
|
|
37
|
+
.fn()
|
|
38
|
+
.mockResolvedValue(createDiscoveryResponse()),
|
|
39
|
+
}));
|
|
40
|
+
|
|
26
41
|
mockIntegration = {
|
|
27
42
|
Definition: {
|
|
28
43
|
name: 'testIntegration'
|
|
@@ -40,6 +55,37 @@ describe('composeServerlessDefinition', () => {
|
|
|
40
55
|
jest.restoreAllMocks();
|
|
41
56
|
// Restore env
|
|
42
57
|
delete process.env.AWS_REGION;
|
|
58
|
+
process.argv = ['node', 'test'];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('AWS discovery gating', () => {
|
|
62
|
+
it('should skip AWS discovery when no features require it', async () => {
|
|
63
|
+
AWSDiscovery.mockClear();
|
|
64
|
+
|
|
65
|
+
const appDefinition = {
|
|
66
|
+
integrations: [],
|
|
67
|
+
vpc: { enable: false },
|
|
68
|
+
encryption: { fieldLevelEncryptionMethod: 'aes' },
|
|
69
|
+
ssm: { enable: false },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
await composeServerlessDefinition(appDefinition);
|
|
73
|
+
|
|
74
|
+
expect(AWSDiscovery).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should run AWS discovery when VPC features are enabled', async () => {
|
|
78
|
+
AWSDiscovery.mockClear();
|
|
79
|
+
|
|
80
|
+
const appDefinition = {
|
|
81
|
+
integrations: [],
|
|
82
|
+
vpc: { enable: true },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
await composeServerlessDefinition(appDefinition);
|
|
86
|
+
|
|
87
|
+
expect(AWSDiscovery).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
43
89
|
});
|
|
44
90
|
|
|
45
91
|
describe('Basic Configuration', () => {
|
|
@@ -109,8 +155,286 @@ describe('composeServerlessDefinition', () => {
|
|
|
109
155
|
});
|
|
110
156
|
});
|
|
111
157
|
|
|
158
|
+
describe('Environment variables', () => {
|
|
159
|
+
it('should include only non-reserved environment flags', async () => {
|
|
160
|
+
const appDefinition = {
|
|
161
|
+
integrations: [],
|
|
162
|
+
environment: {
|
|
163
|
+
CUSTOM_FLAG: true,
|
|
164
|
+
AWS_REGION: true,
|
|
165
|
+
OPTIONAL_DISABLED: false,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
170
|
+
|
|
171
|
+
expect(result.provider.environment.CUSTOM_FLAG).toBe("${env:CUSTOM_FLAG, ''}");
|
|
172
|
+
expect(result.provider.environment).not.toHaveProperty('AWS_REGION');
|
|
173
|
+
expect(result.provider.environment).not.toHaveProperty('OPTIONAL_DISABLED');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should ignore string-valued environment entries', async () => {
|
|
177
|
+
const appDefinition = {
|
|
178
|
+
integrations: [],
|
|
179
|
+
environment: {
|
|
180
|
+
CUSTOM_FLAG: 'enabled',
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
185
|
+
|
|
186
|
+
expect(result.provider.environment).not.toHaveProperty('CUSTOM_FLAG');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
112
190
|
describe('VPC Configuration', () => {
|
|
113
|
-
it('should add VPC configuration when vpc.enable is true', async () => {
|
|
191
|
+
it('should add VPC configuration when vpc.enable is true with discover mode', async () => {
|
|
192
|
+
const appDefinition = {
|
|
193
|
+
vpc: {
|
|
194
|
+
enable: true,
|
|
195
|
+
management: 'discover'
|
|
196
|
+
},
|
|
197
|
+
integrations: []
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
201
|
+
|
|
202
|
+
expect(result.provider.vpc).toBeDefined();
|
|
203
|
+
expect(result.provider.vpc.securityGroupIds).toEqual(['sg-123456']);
|
|
204
|
+
expect(result.provider.vpc.subnetIds).toEqual(['subnet-123456', 'subnet-789012']);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should create new VPC infrastructure when management is create-new', async () => {
|
|
208
|
+
const appDefinition = {
|
|
209
|
+
vpc: {
|
|
210
|
+
enable: true,
|
|
211
|
+
management: 'create-new'
|
|
212
|
+
},
|
|
213
|
+
integrations: []
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
217
|
+
|
|
218
|
+
expect(result.provider.vpc).toBeDefined();
|
|
219
|
+
expect(result.provider.vpc.securityGroupIds).toEqual([{ Ref: 'FriggLambdaSecurityGroup' }]);
|
|
220
|
+
expect(result.provider.vpc.subnetIds).toEqual([
|
|
221
|
+
{ Ref: 'FriggPrivateSubnet1' },
|
|
222
|
+
{ Ref: 'FriggPrivateSubnet2' }
|
|
223
|
+
]);
|
|
224
|
+
expect(result.resources.Resources.FriggVPC).toBeDefined();
|
|
225
|
+
expect(result.resources.Resources.FriggLambdaSecurityGroup).toBeDefined();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should use provided VPC resources when management is use-existing', async () => {
|
|
229
|
+
const appDefinition = {
|
|
230
|
+
vpc: {
|
|
231
|
+
enable: true,
|
|
232
|
+
management: 'use-existing',
|
|
233
|
+
vpcId: 'vpc-custom123',
|
|
234
|
+
subnets: {
|
|
235
|
+
ids: ['subnet-custom1', 'subnet-custom2']
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
integrations: []
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
242
|
+
|
|
243
|
+
expect(result.provider.vpc).toBeDefined();
|
|
244
|
+
expect(result.provider.vpc.subnetIds).toEqual(['subnet-custom1', 'subnet-custom2']);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Test all 9 combinations of VPC and Subnet management modes
|
|
248
|
+
it('should handle create-new VPC with create subnets', async () => {
|
|
249
|
+
const appDefinition = {
|
|
250
|
+
vpc: {
|
|
251
|
+
enable: true,
|
|
252
|
+
management: 'create-new',
|
|
253
|
+
subnets: { management: 'create' }
|
|
254
|
+
},
|
|
255
|
+
integrations: []
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
259
|
+
|
|
260
|
+
expect(result.resources.Resources.FriggVPC).toBeDefined();
|
|
261
|
+
expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
|
|
262
|
+
expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
|
|
263
|
+
expect(result.provider.vpc.subnetIds).toEqual([
|
|
264
|
+
{ Ref: 'FriggPrivateSubnet1' },
|
|
265
|
+
{ Ref: 'FriggPrivateSubnet2' }
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should handle discover VPC with create subnets', async () => {
|
|
270
|
+
const appDefinition = {
|
|
271
|
+
vpc: {
|
|
272
|
+
enable: true,
|
|
273
|
+
management: 'discover',
|
|
274
|
+
subnets: { management: 'create' }
|
|
275
|
+
},
|
|
276
|
+
integrations: []
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
280
|
+
|
|
281
|
+
expect(result.resources.Resources.FriggVPC).toBeUndefined();
|
|
282
|
+
expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
|
|
283
|
+
expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
|
|
284
|
+
expect(result.resources.Resources.FriggPrivateSubnet1.Properties.VpcId).toBe('vpc-123456');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should handle use-existing VPC with create subnets', async () => {
|
|
288
|
+
const appDefinition = {
|
|
289
|
+
vpc: {
|
|
290
|
+
enable: true,
|
|
291
|
+
management: 'use-existing',
|
|
292
|
+
vpcId: 'vpc-existing123',
|
|
293
|
+
subnets: { management: 'create' }
|
|
294
|
+
},
|
|
295
|
+
integrations: []
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
299
|
+
|
|
300
|
+
expect(result.resources.Resources.FriggVPC).toBeUndefined();
|
|
301
|
+
expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
|
|
302
|
+
expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
|
|
303
|
+
expect(result.resources.Resources.FriggPrivateSubnet1.Properties.VpcId).toBe('vpc-existing123');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should handle use-existing VPC with use-existing subnets', async () => {
|
|
307
|
+
const appDefinition = {
|
|
308
|
+
vpc: {
|
|
309
|
+
enable: true,
|
|
310
|
+
management: 'use-existing',
|
|
311
|
+
vpcId: 'vpc-custom',
|
|
312
|
+
subnets: {
|
|
313
|
+
management: 'use-existing',
|
|
314
|
+
ids: ['subnet-explicit1', 'subnet-explicit2']
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
integrations: []
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
321
|
+
|
|
322
|
+
expect(result.resources.Resources.FriggPrivateSubnet1).toBeUndefined();
|
|
323
|
+
expect(result.resources.Resources.FriggPrivateSubnet2).toBeUndefined();
|
|
324
|
+
expect(result.provider.vpc.subnetIds).toEqual(['subnet-explicit1', 'subnet-explicit2']);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should respect provided security group IDs when supplied', async () => {
|
|
328
|
+
const appDefinition = {
|
|
329
|
+
vpc: {
|
|
330
|
+
enable: true,
|
|
331
|
+
management: 'discover',
|
|
332
|
+
securityGroupIds: ['sg-custom-1', 'sg-custom-2'],
|
|
333
|
+
},
|
|
334
|
+
integrations: [],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
338
|
+
|
|
339
|
+
expect(result.provider.vpc.securityGroupIds).toEqual([
|
|
340
|
+
'sg-custom-1',
|
|
341
|
+
'sg-custom-2',
|
|
342
|
+
]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should throw when discover mode finds no subnets and self-heal is disabled', async () => {
|
|
346
|
+
const discoveryInstance = {
|
|
347
|
+
discoverResources: jest.fn().mockResolvedValue(
|
|
348
|
+
createDiscoveryResponse({
|
|
349
|
+
privateSubnetId1: null,
|
|
350
|
+
privateSubnetId2: null,
|
|
351
|
+
})
|
|
352
|
+
),
|
|
353
|
+
};
|
|
354
|
+
AWSDiscovery.mockImplementation(() => discoveryInstance);
|
|
355
|
+
|
|
356
|
+
const appDefinition = {
|
|
357
|
+
vpc: {
|
|
358
|
+
enable: true,
|
|
359
|
+
management: 'discover',
|
|
360
|
+
subnets: { management: 'discover' },
|
|
361
|
+
},
|
|
362
|
+
integrations: [],
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
|
|
366
|
+
'No subnets discovered and subnets.management is "discover". Either enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.'
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should use Fn::Cidr for subnet CIDR blocks in new VPC to avoid conflicts', async () => {
|
|
371
|
+
const appDefinition = {
|
|
372
|
+
vpc: {
|
|
373
|
+
enable: true,
|
|
374
|
+
management: 'create-new',
|
|
375
|
+
subnets: { management: 'create' }
|
|
376
|
+
},
|
|
377
|
+
integrations: []
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
381
|
+
|
|
382
|
+
// Verify new VPC uses 10.0.0.0/16
|
|
383
|
+
expect(result.resources.Resources.FriggVPC.Properties.CidrBlock).toBe('10.0.0.0/16');
|
|
384
|
+
|
|
385
|
+
// Verify subnets use Fn::Cidr to generate non-conflicting CIDRs
|
|
386
|
+
const subnet1Cidr = result.resources.Resources.FriggPrivateSubnet1.Properties.CidrBlock;
|
|
387
|
+
const subnet2Cidr = result.resources.Resources.FriggPrivateSubnet2.Properties.CidrBlock;
|
|
388
|
+
const publicSubnetCidr = result.resources.Resources.FriggPublicSubnet.Properties.CidrBlock;
|
|
389
|
+
|
|
390
|
+
// Check that CIDRs are generated using Fn::Cidr and Fn::Select
|
|
391
|
+
expect(subnet1Cidr).toHaveProperty('Fn::Select');
|
|
392
|
+
expect(subnet1Cidr['Fn::Select'][0]).toBe(0);
|
|
393
|
+
expect(subnet2Cidr).toHaveProperty('Fn::Select');
|
|
394
|
+
expect(subnet2Cidr['Fn::Select'][0]).toBe(1);
|
|
395
|
+
expect(publicSubnetCidr).toHaveProperty('Fn::Select');
|
|
396
|
+
expect(publicSubnetCidr['Fn::Select'][0]).toBe(2);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should create route tables for subnets even without NAT Gateway management', async () => {
|
|
400
|
+
const appDefinition = {
|
|
401
|
+
vpc: {
|
|
402
|
+
enable: true,
|
|
403
|
+
management: 'create-new',
|
|
404
|
+
subnets: { management: 'create' },
|
|
405
|
+
natGateway: { management: 'discover' }
|
|
406
|
+
},
|
|
407
|
+
integrations: []
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
411
|
+
|
|
412
|
+
// Verify route tables are created
|
|
413
|
+
expect(result.resources.Resources.FriggPublicRouteTable).toBeDefined();
|
|
414
|
+
expect(result.resources.Resources.FriggPublicRoute).toBeDefined();
|
|
415
|
+
expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
|
|
416
|
+
|
|
417
|
+
// Verify subnet associations
|
|
418
|
+
expect(result.resources.Resources.FriggPublicSubnetRouteTableAssociation).toBeDefined();
|
|
419
|
+
expect(result.resources.Resources.FriggPrivateSubnet1RouteTableAssociation).toBeDefined();
|
|
420
|
+
expect(result.resources.Resources.FriggPrivateSubnet2RouteTableAssociation).toBeDefined();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('should throw error when use-existing mode without vpcId', async () => {
|
|
424
|
+
const appDefinition = {
|
|
425
|
+
vpc: {
|
|
426
|
+
enable: true,
|
|
427
|
+
management: 'use-existing'
|
|
428
|
+
},
|
|
429
|
+
integrations: []
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
|
|
433
|
+
'VPC management is set to "use-existing" but no vpcId was provided'
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should default to discover mode when management not specified', async () => {
|
|
114
438
|
const appDefinition = {
|
|
115
439
|
vpc: { enable: true },
|
|
116
440
|
integrations: []
|
|
@@ -125,7 +449,10 @@ describe('composeServerlessDefinition', () => {
|
|
|
125
449
|
|
|
126
450
|
it('should add VPC endpoint for S3 when VPC is enabled', async () => {
|
|
127
451
|
const appDefinition = {
|
|
128
|
-
vpc: {
|
|
452
|
+
vpc: {
|
|
453
|
+
enable: true,
|
|
454
|
+
management: 'discover'
|
|
455
|
+
},
|
|
129
456
|
integrations: []
|
|
130
457
|
};
|
|
131
458
|
|
|
@@ -136,6 +463,170 @@ describe('composeServerlessDefinition', () => {
|
|
|
136
463
|
expect(result.resources.Resources.VPCEndpointS3.Properties.VpcId).toBe('vpc-123456');
|
|
137
464
|
});
|
|
138
465
|
|
|
466
|
+
it('should skip creating VPC endpoints when disabled explicitly', async () => {
|
|
467
|
+
const appDefinition = {
|
|
468
|
+
vpc: {
|
|
469
|
+
enable: true,
|
|
470
|
+
management: 'discover',
|
|
471
|
+
enableVPCEndpoints: false,
|
|
472
|
+
},
|
|
473
|
+
integrations: [],
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
477
|
+
|
|
478
|
+
expect(result.resources.Resources.VPCEndpointS3).toBeUndefined();
|
|
479
|
+
expect(result.resources.Resources.VPCEndpointKMS).toBeUndefined();
|
|
480
|
+
expect(result.resources.Resources.VPCEndpointSecretsManager).toBeUndefined();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should add Secrets Manager endpoint only when enabled', async () => {
|
|
484
|
+
const appDefinition = {
|
|
485
|
+
vpc: {
|
|
486
|
+
enable: true,
|
|
487
|
+
management: 'discover',
|
|
488
|
+
},
|
|
489
|
+
secretsManager: { enable: true },
|
|
490
|
+
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
491
|
+
integrations: [],
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
495
|
+
|
|
496
|
+
expect(result.resources.Resources.VPCEndpointSecretsManager).toBeDefined();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('should allow Lambda security group access for VPC endpoints when security group is discovered', async () => {
|
|
500
|
+
const appDefinition = {
|
|
501
|
+
vpc: {
|
|
502
|
+
enable: true,
|
|
503
|
+
management: 'discover'
|
|
504
|
+
},
|
|
505
|
+
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
506
|
+
integrations: []
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
510
|
+
const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
|
|
511
|
+
|
|
512
|
+
expect(endpointSg).toBeDefined();
|
|
513
|
+
expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
|
|
514
|
+
{
|
|
515
|
+
IpProtocol: 'tcp',
|
|
516
|
+
FromPort: 443,
|
|
517
|
+
ToPort: 443,
|
|
518
|
+
SourceSecurityGroupId: 'sg-123456',
|
|
519
|
+
Description: 'HTTPS from Lambda security group'
|
|
520
|
+
}
|
|
521
|
+
]);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should fall back to VPC CIDR when Lambda security group identifier cannot be resolved', async () => {
|
|
525
|
+
AWSDiscovery.mockImplementation(() => ({
|
|
526
|
+
discoverResources: jest
|
|
527
|
+
.fn()
|
|
528
|
+
.mockResolvedValue(createDiscoveryResponse()),
|
|
529
|
+
}));
|
|
530
|
+
|
|
531
|
+
const appDefinition = {
|
|
532
|
+
vpc: {
|
|
533
|
+
enable: true,
|
|
534
|
+
management: 'discover',
|
|
535
|
+
securityGroupIds: [
|
|
536
|
+
{
|
|
537
|
+
'Fn::ImportValue': 'shared-lambda-security-group',
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
},
|
|
541
|
+
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
542
|
+
integrations: [],
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
546
|
+
const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
|
|
547
|
+
|
|
548
|
+
expect(endpointSg).toBeDefined();
|
|
549
|
+
expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
|
|
550
|
+
{
|
|
551
|
+
IpProtocol: 'tcp',
|
|
552
|
+
FromPort: 443,
|
|
553
|
+
ToPort: 443,
|
|
554
|
+
CidrIp: '172.31.0.0/16',
|
|
555
|
+
Description: 'HTTPS from VPC CIDR (fallback)',
|
|
556
|
+
},
|
|
557
|
+
]);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should fall back to default private ranges when neither Lambda security group nor VPC CIDR is available', async () => {
|
|
561
|
+
AWSDiscovery.mockImplementation(() => ({
|
|
562
|
+
discoverResources: jest
|
|
563
|
+
.fn()
|
|
564
|
+
.mockResolvedValue(
|
|
565
|
+
createDiscoveryResponse({ vpcCidr: null })
|
|
566
|
+
),
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
const appDefinition = {
|
|
570
|
+
vpc: {
|
|
571
|
+
enable: true,
|
|
572
|
+
management: 'discover',
|
|
573
|
+
securityGroupIds: [
|
|
574
|
+
{
|
|
575
|
+
'Fn::ImportValue': 'shared-lambda-security-group',
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
},
|
|
579
|
+
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
580
|
+
integrations: [],
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
584
|
+
const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
|
|
585
|
+
|
|
586
|
+
expect(endpointSg).toBeDefined();
|
|
587
|
+
expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
|
|
588
|
+
{
|
|
589
|
+
IpProtocol: 'tcp',
|
|
590
|
+
FromPort: 443,
|
|
591
|
+
ToPort: 443,
|
|
592
|
+
CidrIp: '172.31.0.0/16',
|
|
593
|
+
Description: 'HTTPS from default VPC range',
|
|
594
|
+
},
|
|
595
|
+
]);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should reference the Lambda security group when creating a new VPC', async () => {
|
|
599
|
+
const appDefinition = {
|
|
600
|
+
vpc: {
|
|
601
|
+
enable: true,
|
|
602
|
+
management: 'create-new'
|
|
603
|
+
},
|
|
604
|
+
encryption: { fieldLevelEncryptionMethod: 'kms' },
|
|
605
|
+
integrations: []
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
609
|
+
const endpointSg = result.resources.Resources.FriggVPCEndpointSecurityGroup;
|
|
610
|
+
|
|
611
|
+
expect(endpointSg).toBeDefined();
|
|
612
|
+
expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
|
|
613
|
+
{
|
|
614
|
+
IpProtocol: 'tcp',
|
|
615
|
+
FromPort: 443,
|
|
616
|
+
ToPort: 443,
|
|
617
|
+
SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
|
|
618
|
+
Description: 'HTTPS from Lambda security group'
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
IpProtocol: 'tcp',
|
|
622
|
+
FromPort: 443,
|
|
623
|
+
ToPort: 443,
|
|
624
|
+
CidrIp: '10.0.0.0/16',
|
|
625
|
+
Description: 'HTTPS from VPC CIDR (fallback)'
|
|
626
|
+
}
|
|
627
|
+
]);
|
|
628
|
+
});
|
|
629
|
+
|
|
139
630
|
it('should not add VPC configuration when vpc.enable is false', async () => {
|
|
140
631
|
const appDefinition = {
|
|
141
632
|
vpc: { enable: false },
|
|
@@ -159,6 +650,114 @@ describe('composeServerlessDefinition', () => {
|
|
|
159
650
|
});
|
|
160
651
|
});
|
|
161
652
|
|
|
653
|
+
describe('NAT Gateway behaviour', () => {
|
|
654
|
+
it('should reuse discovered NAT gateway in discover mode without creating new resources', async () => {
|
|
655
|
+
const discoveryInstance = {
|
|
656
|
+
discoverResources: jest.fn().mockResolvedValue(
|
|
657
|
+
createDiscoveryResponse({
|
|
658
|
+
existingNatGatewayId: 'nat-existing123',
|
|
659
|
+
natGatewayInPrivateSubnet: false,
|
|
660
|
+
})
|
|
661
|
+
),
|
|
662
|
+
};
|
|
663
|
+
AWSDiscovery.mockImplementation(() => discoveryInstance);
|
|
664
|
+
|
|
665
|
+
const appDefinition = {
|
|
666
|
+
vpc: {
|
|
667
|
+
enable: true,
|
|
668
|
+
management: 'discover',
|
|
669
|
+
natGateway: { management: 'discover' },
|
|
670
|
+
},
|
|
671
|
+
integrations: [],
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
675
|
+
|
|
676
|
+
expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
|
|
677
|
+
expect(result.resources.Resources.FriggNATRoute).toBeDefined();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('should reference provided NAT gateway when management set to useExisting', async () => {
|
|
681
|
+
const discoveryInstance = {
|
|
682
|
+
discoverResources: jest.fn().mockResolvedValue(
|
|
683
|
+
createDiscoveryResponse({ existingNatGatewayId: null })
|
|
684
|
+
),
|
|
685
|
+
};
|
|
686
|
+
AWSDiscovery.mockImplementation(() => discoveryInstance);
|
|
687
|
+
|
|
688
|
+
const appDefinition = {
|
|
689
|
+
vpc: {
|
|
690
|
+
enable: true,
|
|
691
|
+
management: 'discover',
|
|
692
|
+
natGateway: { management: 'useExisting', id: 'nat-custom-001' },
|
|
693
|
+
},
|
|
694
|
+
integrations: [],
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
698
|
+
|
|
699
|
+
expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
|
|
700
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom-001');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should reuse existing elastic IP allocation when creating managed NAT', async () => {
|
|
704
|
+
const discoveryInstance = {
|
|
705
|
+
discoverResources: jest.fn().mockResolvedValue(
|
|
706
|
+
createDiscoveryResponse({
|
|
707
|
+
existingNatGatewayId: null,
|
|
708
|
+
existingElasticIpAllocationId: 'eip-alloc-123',
|
|
709
|
+
})
|
|
710
|
+
),
|
|
711
|
+
};
|
|
712
|
+
AWSDiscovery.mockImplementation(() => discoveryInstance);
|
|
713
|
+
|
|
714
|
+
const appDefinition = {
|
|
715
|
+
vpc: {
|
|
716
|
+
enable: true,
|
|
717
|
+
management: 'discover',
|
|
718
|
+
natGateway: { management: 'createAndManage' },
|
|
719
|
+
selfHeal: true,
|
|
720
|
+
},
|
|
721
|
+
integrations: [],
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
725
|
+
|
|
726
|
+
expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
|
|
727
|
+
expect(result.resources.Resources.FriggNATGateway.Properties.AllocationId).toBe(
|
|
728
|
+
'eip-alloc-123'
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('should create a public subnet when discovery provides none', async () => {
|
|
733
|
+
const discoveryInstance = {
|
|
734
|
+
discoverResources: jest.fn().mockResolvedValue(
|
|
735
|
+
createDiscoveryResponse({
|
|
736
|
+
publicSubnetId: null,
|
|
737
|
+
internetGatewayId: null,
|
|
738
|
+
existingNatGatewayId: null,
|
|
739
|
+
})
|
|
740
|
+
),
|
|
741
|
+
};
|
|
742
|
+
AWSDiscovery.mockImplementation(() => discoveryInstance);
|
|
743
|
+
|
|
744
|
+
const appDefinition = {
|
|
745
|
+
vpc: {
|
|
746
|
+
enable: true,
|
|
747
|
+
management: 'discover',
|
|
748
|
+
natGateway: { management: 'createAndManage' },
|
|
749
|
+
selfHeal: true,
|
|
750
|
+
},
|
|
751
|
+
integrations: [],
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
755
|
+
|
|
756
|
+
expect(result.resources.Resources.FriggPublicSubnet).toBeDefined();
|
|
757
|
+
expect(result.resources.Resources.FriggPublicRouteTable).toBeDefined();
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
162
761
|
describe('KMS Configuration', () => {
|
|
163
762
|
it('should add KMS configuration when encryption is enabled and key is found', async () => {
|
|
164
763
|
const appDefinition = {
|
|
@@ -191,6 +790,17 @@ describe('composeServerlessDefinition', () => {
|
|
|
191
790
|
expect(result.custom.kmsGrants).toEqual({
|
|
192
791
|
kmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
|
|
193
792
|
});
|
|
793
|
+
|
|
794
|
+
// Check KMS Alias resource is created for discovered key
|
|
795
|
+
expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined();
|
|
796
|
+
expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({
|
|
797
|
+
Type: 'AWS::KMS::Alias',
|
|
798
|
+
DeletionPolicy: 'Retain',
|
|
799
|
+
Properties: {
|
|
800
|
+
AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms',
|
|
801
|
+
TargetKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
|
|
802
|
+
}
|
|
803
|
+
});
|
|
194
804
|
});
|
|
195
805
|
|
|
196
806
|
it('should create new KMS key when encryption is enabled, no key found, and createResourceIfNoneFound is true', async () => {
|
|
@@ -219,9 +829,11 @@ describe('composeServerlessDefinition', () => {
|
|
|
219
829
|
|
|
220
830
|
const result = await composeServerlessDefinition(appDefinition);
|
|
221
831
|
|
|
222
|
-
// Check that KMS key resource was created
|
|
832
|
+
// Check that KMS key resource was created with DeletionPolicy
|
|
223
833
|
expect(result.resources.Resources.FriggKMSKey).toEqual({
|
|
224
834
|
Type: 'AWS::KMS::Key',
|
|
835
|
+
DeletionPolicy: 'Retain',
|
|
836
|
+
UpdateReplacePolicy: 'Retain',
|
|
225
837
|
Properties: {
|
|
226
838
|
EnableKeyRotation: true,
|
|
227
839
|
Description: 'Frigg KMS key for field-level encryption',
|
|
@@ -264,6 +876,10 @@ describe('composeServerlessDefinition', () => {
|
|
|
264
876
|
Key: 'Name',
|
|
265
877
|
Value: '${self:service}-${self:provider.stage}-frigg-kms-key'
|
|
266
878
|
},
|
|
879
|
+
{
|
|
880
|
+
Key: 'ManagedBy',
|
|
881
|
+
Value: 'Frigg'
|
|
882
|
+
},
|
|
267
883
|
{
|
|
268
884
|
Key: 'Purpose',
|
|
269
885
|
Value: 'Field-level encryption for Frigg application'
|
|
@@ -272,6 +888,17 @@ describe('composeServerlessDefinition', () => {
|
|
|
272
888
|
}
|
|
273
889
|
});
|
|
274
890
|
|
|
891
|
+
// Check KMS Alias resource is created for the new key
|
|
892
|
+
expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined();
|
|
893
|
+
expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({
|
|
894
|
+
Type: 'AWS::KMS::Alias',
|
|
895
|
+
DeletionPolicy: 'Retain',
|
|
896
|
+
Properties: {
|
|
897
|
+
AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms',
|
|
898
|
+
TargetKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
275
902
|
// Check IAM permissions for the new key
|
|
276
903
|
const kmsPermission = result.provider.iamRoleStatements.find(
|
|
277
904
|
statement => statement.Action.includes('kms:GenerateDataKey')
|
|
@@ -533,11 +1160,181 @@ describe('composeServerlessDefinition', () => {
|
|
|
533
1160
|
});
|
|
534
1161
|
});
|
|
535
1162
|
|
|
1163
|
+
describe('Handler path adjustments', () => {
|
|
1164
|
+
const fs = require('fs');
|
|
1165
|
+
const path = require('path');
|
|
1166
|
+
|
|
1167
|
+
it('should rewrite handler paths in offline mode', async () => {
|
|
1168
|
+
process.argv = ['node', 'test', 'offline'];
|
|
1169
|
+
const existsSpy = jest.spyOn(fs, 'existsSync');
|
|
1170
|
+
const fallbackNodeModules = path.resolve(process.cwd(), '..', 'node_modules');
|
|
1171
|
+
existsSpy.mockImplementation((p) => p === fallbackNodeModules);
|
|
1172
|
+
|
|
1173
|
+
const appDefinition = { integrations: [] };
|
|
1174
|
+
|
|
1175
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1176
|
+
|
|
1177
|
+
expect(existsSpy).toHaveBeenCalled();
|
|
1178
|
+
expect(result.functions.auth.handler.startsWith('../node_modules/')).toBe(true);
|
|
1179
|
+
|
|
1180
|
+
existsSpy.mockRestore();
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
describe('NAT Gateway Management', () => {
|
|
1185
|
+
it('should handle NAT Gateway with createAndManage mode', async () => {
|
|
1186
|
+
const appDefinition = {
|
|
1187
|
+
vpc: {
|
|
1188
|
+
enable: true,
|
|
1189
|
+
management: 'discover',
|
|
1190
|
+
natGateway: {
|
|
1191
|
+
management: 'createAndManage'
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
integrations: []
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1198
|
+
|
|
1199
|
+
expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
|
|
1200
|
+
expect(result.resources.Resources.FriggNATRoute).toBeDefined();
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it('should mark managed NAT Gateway resources for retention', async () => {
|
|
1204
|
+
const appDefinition = {
|
|
1205
|
+
vpc: {
|
|
1206
|
+
enable: true,
|
|
1207
|
+
management: 'discover',
|
|
1208
|
+
natGateway: { management: 'createAndManage' },
|
|
1209
|
+
selfHeal: true,
|
|
1210
|
+
},
|
|
1211
|
+
integrations: [],
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1215
|
+
|
|
1216
|
+
expect(result.resources.Resources.FriggNATGateway).toBeDefined();
|
|
1217
|
+
expect(result.resources.Resources.FriggNATGateway.DeletionPolicy).toBe('Retain');
|
|
1218
|
+
expect(result.resources.Resources.FriggNATGateway.UpdateReplacePolicy).toBe('Retain');
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it('should handle NAT Gateway with discover mode', async () => {
|
|
1222
|
+
// Mock discovery to return existing NAT Gateway
|
|
1223
|
+
const { AWSDiscovery } = require('./aws-discovery');
|
|
1224
|
+
const mockDiscoverResources = jest.fn().mockResolvedValue({
|
|
1225
|
+
defaultVpcId: 'vpc-123456',
|
|
1226
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1227
|
+
privateSubnetId1: 'subnet-123456',
|
|
1228
|
+
privateSubnetId2: 'subnet-789012',
|
|
1229
|
+
publicSubnetId: 'subnet-public',
|
|
1230
|
+
defaultRouteTableId: 'rtb-123456',
|
|
1231
|
+
defaultKmsKeyId: null,
|
|
1232
|
+
existingNatGatewayId: 'nat-existing123'
|
|
1233
|
+
});
|
|
1234
|
+
AWSDiscovery.mockImplementation(() => ({
|
|
1235
|
+
discoverResources: mockDiscoverResources
|
|
1236
|
+
}));
|
|
1237
|
+
|
|
1238
|
+
const appDefinition = {
|
|
1239
|
+
vpc: {
|
|
1240
|
+
enable: true,
|
|
1241
|
+
management: 'discover',
|
|
1242
|
+
natGateway: {
|
|
1243
|
+
management: 'discover'
|
|
1244
|
+
}
|
|
1245
|
+
},
|
|
1246
|
+
integrations: []
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1250
|
+
|
|
1251
|
+
expect(result.resources.Resources.FriggNATRoute).toBeDefined();
|
|
1252
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-existing123');
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it('should handle NAT Gateway with useExisting mode and provided ID', async () => {
|
|
1256
|
+
const appDefinition = {
|
|
1257
|
+
vpc: {
|
|
1258
|
+
enable: true,
|
|
1259
|
+
management: 'discover',
|
|
1260
|
+
natGateway: {
|
|
1261
|
+
management: 'useExisting',
|
|
1262
|
+
id: 'nat-custom456'
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
integrations: []
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1269
|
+
|
|
1270
|
+
expect(result.resources.Resources.FriggNATRoute).toBeDefined();
|
|
1271
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom456');
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('should throw error when NAT Gateway not found in discover mode', async () => {
|
|
1275
|
+
// Mock discovery to return no NAT Gateway
|
|
1276
|
+
const { AWSDiscovery } = require('./aws-discovery');
|
|
1277
|
+
const mockDiscoverResources = jest.fn().mockResolvedValue({
|
|
1278
|
+
defaultVpcId: 'vpc-123456',
|
|
1279
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1280
|
+
privateSubnetId1: 'subnet-123456',
|
|
1281
|
+
privateSubnetId2: 'subnet-789012',
|
|
1282
|
+
publicSubnetId: 'subnet-public',
|
|
1283
|
+
defaultRouteTableId: 'rtb-123456',
|
|
1284
|
+
defaultKmsKeyId: null,
|
|
1285
|
+
existingNatGatewayId: null // No NAT Gateway
|
|
1286
|
+
});
|
|
1287
|
+
AWSDiscovery.mockImplementation(() => ({
|
|
1288
|
+
discoverResources: mockDiscoverResources
|
|
1289
|
+
}));
|
|
1290
|
+
|
|
1291
|
+
const appDefinition = {
|
|
1292
|
+
vpc: {
|
|
1293
|
+
enable: true,
|
|
1294
|
+
management: 'discover',
|
|
1295
|
+
natGateway: {
|
|
1296
|
+
management: 'discover'
|
|
1297
|
+
}
|
|
1298
|
+
},
|
|
1299
|
+
integrations: []
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
|
|
1303
|
+
'No existing NAT Gateway found in discovery mode'
|
|
1304
|
+
);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it('should enable self-healing when selfHeal is true', async () => {
|
|
1308
|
+
const appDefinition = {
|
|
1309
|
+
vpc: {
|
|
1310
|
+
enable: true,
|
|
1311
|
+
management: 'discover',
|
|
1312
|
+
natGateway: {
|
|
1313
|
+
management: 'discover'
|
|
1314
|
+
},
|
|
1315
|
+
selfHeal: true
|
|
1316
|
+
},
|
|
1317
|
+
integrations: []
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1321
|
+
|
|
1322
|
+
// With self-healing enabled, it should handle misconfigured NAT Gateways
|
|
1323
|
+
expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
536
1327
|
describe('Combined Configurations', () => {
|
|
537
1328
|
it('should combine VPC, KMS, and SSM configurations', async () => {
|
|
538
1329
|
const appDefinition = {
|
|
539
|
-
vpc: {
|
|
540
|
-
|
|
1330
|
+
vpc: {
|
|
1331
|
+
enable: true,
|
|
1332
|
+
natGateway: { management: 'createAndManage' } // Explicitly set NAT management mode
|
|
1333
|
+
},
|
|
1334
|
+
encryption: {
|
|
1335
|
+
fieldLevelEncryptionMethod: 'kms',
|
|
1336
|
+
createResourceIfNoneFound: true // Allow creating KMS key if not found
|
|
1337
|
+
},
|
|
541
1338
|
ssm: { enable: true },
|
|
542
1339
|
integrations: [mockIntegration]
|
|
543
1340
|
};
|
|
@@ -575,8 +1372,14 @@ describe('composeServerlessDefinition', () => {
|
|
|
575
1372
|
|
|
576
1373
|
it('should handle partial configuration combinations', async () => {
|
|
577
1374
|
const appDefinition = {
|
|
578
|
-
vpc: {
|
|
579
|
-
|
|
1375
|
+
vpc: {
|
|
1376
|
+
enable: true,
|
|
1377
|
+
natGateway: { management: 'createAndManage' } // Explicitly set NAT management mode
|
|
1378
|
+
},
|
|
1379
|
+
encryption: {
|
|
1380
|
+
fieldLevelEncryptionMethod: 'kms',
|
|
1381
|
+
createResourceIfNoneFound: true // Allow creating KMS key if not found
|
|
1382
|
+
},
|
|
580
1383
|
integrations: []
|
|
581
1384
|
};
|
|
582
1385
|
|
|
@@ -702,6 +1505,218 @@ describe('composeServerlessDefinition', () => {
|
|
|
702
1505
|
});
|
|
703
1506
|
});
|
|
704
1507
|
|
|
1508
|
+
describe('CRITICAL: NAT Gateway MUST be in PUBLIC Subnet', () => {
|
|
1509
|
+
it('should NEVER reuse NAT Gateway in private subnet - throw error when selfHeal disabled', async () => {
|
|
1510
|
+
// Mock NAT Gateway found in PRIVATE subnet (the original bug)
|
|
1511
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1512
|
+
const mockInstance = new mockDiscovery();
|
|
1513
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1514
|
+
defaultVpcId: 'vpc-123456',
|
|
1515
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1516
|
+
privateSubnetId1: 'subnet-private1',
|
|
1517
|
+
privateSubnetId2: 'subnet-private2',
|
|
1518
|
+
publicSubnetId: 'subnet-public',
|
|
1519
|
+
existingNatGatewayId: 'nat-in-private',
|
|
1520
|
+
natGatewayInPrivateSubnet: true, // CRITICAL: NAT is in WRONG subnet
|
|
1521
|
+
existingElasticIpAllocationId: 'eipalloc-123'
|
|
1522
|
+
});
|
|
1523
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1524
|
+
|
|
1525
|
+
const appDefinition = {
|
|
1526
|
+
vpc: {
|
|
1527
|
+
enable: true,
|
|
1528
|
+
natGateway: { management: 'createAndManage' },
|
|
1529
|
+
selfHeal: false
|
|
1530
|
+
},
|
|
1531
|
+
integrations: []
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
// Should throw error because NAT is in private subnet
|
|
1535
|
+
await expect(composeServerlessDefinition(appDefinition))
|
|
1536
|
+
.rejects
|
|
1537
|
+
.toThrow('CRITICAL: NAT Gateway is in PRIVATE subnet');
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
it('should create NEW NAT in PUBLIC subnet when existing NAT is in private subnet with selfHeal', async () => {
|
|
1541
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1542
|
+
const mockInstance = new mockDiscovery();
|
|
1543
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1544
|
+
defaultVpcId: 'vpc-123456',
|
|
1545
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1546
|
+
privateSubnetId1: 'subnet-private1',
|
|
1547
|
+
privateSubnetId2: 'subnet-private2',
|
|
1548
|
+
publicSubnetId: 'subnet-public',
|
|
1549
|
+
existingNatGatewayId: 'nat-in-private',
|
|
1550
|
+
natGatewayInPrivateSubnet: true, // NAT is in WRONG subnet
|
|
1551
|
+
existingElasticIpAllocationId: 'eipalloc-123'
|
|
1552
|
+
});
|
|
1553
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1554
|
+
|
|
1555
|
+
const appDefinition = {
|
|
1556
|
+
vpc: {
|
|
1557
|
+
enable: true,
|
|
1558
|
+
natGateway: { management: 'createAndManage' },
|
|
1559
|
+
selfHeal: true
|
|
1560
|
+
},
|
|
1561
|
+
integrations: []
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1565
|
+
|
|
1566
|
+
// MUST create new NAT Gateway (not reuse the one in private subnet)
|
|
1567
|
+
expect(result.resources.Resources.FriggNATGateway).toBeDefined();
|
|
1568
|
+
|
|
1569
|
+
// MUST be placed in PUBLIC subnet
|
|
1570
|
+
const natSubnet = result.resources.Resources.FriggNATGateway.Properties.SubnetId;
|
|
1571
|
+
expect(natSubnet).toEqual('subnet-public');
|
|
1572
|
+
|
|
1573
|
+
// MUST create new EIP (cannot reuse the one associated with wrong NAT)
|
|
1574
|
+
expect(result.resources.Resources.FriggNATGatewayEIP).toBeDefined();
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it('should create public subnet for NAT when none exists', async () => {
|
|
1578
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1579
|
+
const mockInstance = new mockDiscovery();
|
|
1580
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1581
|
+
defaultVpcId: 'vpc-123456',
|
|
1582
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1583
|
+
privateSubnetId1: 'subnet-private1',
|
|
1584
|
+
privateSubnetId2: 'subnet-private2',
|
|
1585
|
+
publicSubnetId: null, // NO PUBLIC SUBNET EXISTS
|
|
1586
|
+
existingNatGatewayId: null
|
|
1587
|
+
});
|
|
1588
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1589
|
+
|
|
1590
|
+
const appDefinition = {
|
|
1591
|
+
vpc: {
|
|
1592
|
+
enable: true,
|
|
1593
|
+
natGateway: { management: 'createAndManage' },
|
|
1594
|
+
selfHeal: true
|
|
1595
|
+
},
|
|
1596
|
+
integrations: []
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1600
|
+
|
|
1601
|
+
// MUST create public subnet
|
|
1602
|
+
expect(result.resources.Resources.FriggPublicSubnet).toBeDefined();
|
|
1603
|
+
expect(result.resources.Resources.FriggPublicSubnet.Properties.MapPublicIpOnLaunch).toBe(true);
|
|
1604
|
+
|
|
1605
|
+
// NAT Gateway MUST be in the newly created public subnet
|
|
1606
|
+
expect(result.resources.Resources.FriggNATGateway.Properties.SubnetId)
|
|
1607
|
+
.toEqual({ Ref: 'FriggPublicSubnet' });
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1610
|
+
it('should reuse CORRECTLY placed NAT Gateway in public subnet', async () => {
|
|
1611
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1612
|
+
const mockInstance = new mockDiscovery();
|
|
1613
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1614
|
+
defaultVpcId: 'vpc-123456',
|
|
1615
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1616
|
+
privateSubnetId1: 'subnet-private1',
|
|
1617
|
+
privateSubnetId2: 'subnet-private2',
|
|
1618
|
+
publicSubnetId: 'subnet-public',
|
|
1619
|
+
existingNatGatewayId: 'nat-good',
|
|
1620
|
+
natGatewayInPrivateSubnet: false, // NAT is CORRECTLY in public subnet
|
|
1621
|
+
existingElasticIpAllocationId: 'eipalloc-123'
|
|
1622
|
+
});
|
|
1623
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1624
|
+
|
|
1625
|
+
const appDefinition = {
|
|
1626
|
+
vpc: {
|
|
1627
|
+
enable: true,
|
|
1628
|
+
natGateway: { management: 'createAndManage' },
|
|
1629
|
+
selfHeal: true
|
|
1630
|
+
},
|
|
1631
|
+
integrations: []
|
|
1632
|
+
};
|
|
1633
|
+
|
|
1634
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1635
|
+
|
|
1636
|
+
// Should NOT create new NAT Gateway (reuse the good one)
|
|
1637
|
+
expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
|
|
1638
|
+
expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
|
|
1639
|
+
|
|
1640
|
+
// Should use existing NAT in routes
|
|
1641
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
|
|
1642
|
+
.toEqual('nat-good');
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
it('should fix route table associations to prevent NAT misconfiguration', async () => {
|
|
1646
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1647
|
+
const mockInstance = new mockDiscovery();
|
|
1648
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1649
|
+
defaultVpcId: 'vpc-123456',
|
|
1650
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1651
|
+
privateSubnetId1: 'subnet-private1',
|
|
1652
|
+
privateSubnetId2: 'subnet-private2',
|
|
1653
|
+
publicSubnetId: 'subnet-public',
|
|
1654
|
+
existingNatGatewayId: 'nat-good',
|
|
1655
|
+
natGatewayInPrivateSubnet: false
|
|
1656
|
+
});
|
|
1657
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1658
|
+
|
|
1659
|
+
const appDefinition = {
|
|
1660
|
+
vpc: {
|
|
1661
|
+
enable: true,
|
|
1662
|
+
natGateway: { management: 'discover' },
|
|
1663
|
+
selfHeal: true
|
|
1664
|
+
},
|
|
1665
|
+
integrations: []
|
|
1666
|
+
};
|
|
1667
|
+
|
|
1668
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1669
|
+
|
|
1670
|
+
// Should create route table to fix associations
|
|
1671
|
+
expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
|
|
1672
|
+
|
|
1673
|
+
// Should create NAT route pointing to good NAT
|
|
1674
|
+
expect(result.resources.Resources.FriggNATRoute).toBeDefined();
|
|
1675
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
|
|
1676
|
+
.toEqual('nat-good');
|
|
1677
|
+
|
|
1678
|
+
// Should associate private subnets with correct route table
|
|
1679
|
+
expect(result.resources.Resources.FriggSubnet1RouteAssociation).toBeDefined();
|
|
1680
|
+
expect(result.resources.Resources.FriggSubnet2RouteAssociation).toBeDefined();
|
|
1681
|
+
});
|
|
1682
|
+
|
|
1683
|
+
it('should handle EIP already associated error by reusing existing NAT', async () => {
|
|
1684
|
+
const mockDiscovery = require('./aws-discovery').AWSDiscovery;
|
|
1685
|
+
const mockInstance = new mockDiscovery();
|
|
1686
|
+
mockInstance.discoverResources = jest.fn().mockResolvedValue({
|
|
1687
|
+
defaultVpcId: 'vpc-123456',
|
|
1688
|
+
defaultSecurityGroupId: 'sg-123456',
|
|
1689
|
+
privateSubnetId1: 'subnet-private1',
|
|
1690
|
+
privateSubnetId2: 'subnet-private2',
|
|
1691
|
+
publicSubnetId: 'subnet-public',
|
|
1692
|
+
existingNatGatewayId: 'nat-existing',
|
|
1693
|
+
natGatewayInPrivateSubnet: false, // NAT is correctly placed
|
|
1694
|
+
existingElasticIpAllocationId: 'eipalloc-inuse',
|
|
1695
|
+
elasticIpAlreadyAssociated: true // EIP is already in use
|
|
1696
|
+
});
|
|
1697
|
+
mockDiscovery.mockImplementation(() => mockInstance);
|
|
1698
|
+
|
|
1699
|
+
const appDefinition = {
|
|
1700
|
+
vpc: {
|
|
1701
|
+
enable: true,
|
|
1702
|
+
natGateway: { management: 'createAndManage' },
|
|
1703
|
+
selfHeal: true
|
|
1704
|
+
},
|
|
1705
|
+
integrations: []
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
const result = await composeServerlessDefinition(appDefinition);
|
|
1709
|
+
|
|
1710
|
+
// Should NOT create new NAT or EIP (reuse existing)
|
|
1711
|
+
expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
|
|
1712
|
+
expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
|
|
1713
|
+
|
|
1714
|
+
// Should use existing NAT
|
|
1715
|
+
expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
|
|
1716
|
+
.toEqual('nat-existing');
|
|
1717
|
+
});
|
|
1718
|
+
});
|
|
1719
|
+
|
|
705
1720
|
describe('Edge Cases', () => {
|
|
706
1721
|
it('should handle empty app definition', async () => {
|
|
707
1722
|
const appDefinition = {};
|
|
@@ -741,4 +1756,4 @@ describe('composeServerlessDefinition', () => {
|
|
|
741
1756
|
await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow('Invalid integration: missing Definition or name');
|
|
742
1757
|
});
|
|
743
1758
|
});
|
|
744
|
-
});
|
|
1759
|
+
});
|