@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.
@@ -1,298 +1,954 @@
1
- const AWS = require('aws-sdk');
1
+ const { mockClient } = require('aws-sdk-client-mock');
2
2
  const { AWSDiscovery } = require('./aws-discovery');
3
3
 
4
- // Mock AWS SDK
5
- jest.mock('@aws-sdk/client-ec2');
6
- jest.mock('@aws-sdk/client-kms');
7
- jest.mock('@aws-sdk/client-sts');
8
-
9
- const { EC2Client, DescribeVpcsCommand, DescribeSubnetsCommand, DescribeSecurityGroupsCommand, DescribeRouteTablesCommand } = require('@aws-sdk/client-ec2');
10
- const { KMSClient, ListKeysCommand, DescribeKeyCommand } = require('@aws-sdk/client-kms');
11
- const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts');
4
+ // Import AWS SDK commands
5
+ const {
6
+ EC2Client,
7
+ DescribeVpcsCommand,
8
+ DescribeSubnetsCommand,
9
+ DescribeSecurityGroupsCommand,
10
+ DescribeRouteTablesCommand,
11
+ DescribeNatGatewaysCommand,
12
+ DescribeAddressesCommand,
13
+ DescribeInternetGatewaysCommand,
14
+ } = require('@aws-sdk/client-ec2');
15
+ const {
16
+ KMSClient,
17
+ ListKeysCommand,
18
+ DescribeKeyCommand
19
+ } = require('@aws-sdk/client-kms');
20
+ const {
21
+ STSClient,
22
+ GetCallerIdentityCommand
23
+ } = require('@aws-sdk/client-sts');
24
+
25
+ // Create mock clients
26
+ const ec2Mock = mockClient(EC2Client);
27
+ const kmsMock = mockClient(KMSClient);
28
+ const stsMock = mockClient(STSClient);
12
29
 
13
30
  describe('AWSDiscovery', () => {
14
31
  let discovery;
15
- let mockEC2Send;
16
- let mockKMSSend;
17
- let mockSTSSend;
18
32
 
19
33
  beforeEach(() => {
34
+ // Reset all mocks before each test
35
+ ec2Mock.reset();
36
+ kmsMock.reset();
37
+ stsMock.reset();
38
+
20
39
  discovery = new AWSDiscovery('us-east-1');
21
-
22
- // Create mock send functions
23
- mockEC2Send = jest.fn();
24
- mockKMSSend = jest.fn();
25
- mockSTSSend = jest.fn();
26
-
27
- // Mock the client constructors and send methods
28
- EC2Client.mockImplementation(() => ({
29
- send: mockEC2Send
30
- }));
31
-
32
- KMSClient.mockImplementation(() => ({
33
- send: mockKMSSend
34
- }));
35
-
36
- STSClient.mockImplementation(() => ({
37
- send: mockSTSSend
38
- }));
39
-
40
- // Reset mocks
41
- jest.clearAllMocks();
42
40
  });
43
41
 
44
42
  describe('getAccountId', () => {
45
43
  it('should return AWS account ID', async () => {
46
44
  const mockAccountId = '123456789012';
47
- mockSTSSend.mockResolvedValue({
45
+ stsMock.on(GetCallerIdentityCommand).resolves({
48
46
  Account: mockAccountId
49
47
  });
50
48
 
51
- const result = await discovery.getAccountId();
52
-
53
- expect(result).toBe(mockAccountId);
54
- expect(mockSTSSend).toHaveBeenCalledWith(expect.any(GetCallerIdentityCommand));
49
+ const accountId = await discovery.getAccountId();
50
+ expect(accountId).toBe(mockAccountId);
55
51
  });
56
52
 
57
53
  it('should throw error when STS call fails', async () => {
58
- const error = new Error('STS Error');
59
- mockSTSSend.mockRejectedValue(error);
54
+ stsMock.on(GetCallerIdentityCommand).rejects(new Error('STS error'));
60
55
 
61
- await expect(discovery.getAccountId()).rejects.toThrow('STS Error');
56
+ await expect(discovery.getAccountId()).rejects.toThrow('STS error');
62
57
  });
63
58
  });
64
59
 
65
60
  describe('findDefaultVpc', () => {
66
- it('should return default VPC when found', async () => {
67
- const mockVpc = {
68
- VpcId: 'vpc-12345678',
69
- IsDefault: true,
70
- State: 'available'
71
- };
72
-
73
- mockEC2Send.mockResolvedValue({
61
+ it('should return default VPC when available', async () => {
62
+ const mockVpc = { VpcId: 'vpc-12345678', IsDefault: true };
63
+ ec2Mock.on(DescribeVpcsCommand).resolves({
74
64
  Vpcs: [mockVpc]
75
65
  });
76
66
 
77
- const result = await discovery.findDefaultVpc();
78
-
79
- expect(result).toEqual(mockVpc);
80
- expect(mockEC2Send).toHaveBeenCalledWith(expect.objectContaining({
81
- input: {
82
- Filters: [{
83
- Name: 'is-default',
84
- Values: ['true']
85
- }]
86
- }
87
- }));
67
+ const vpc = await discovery.findDefaultVpc();
68
+ expect(vpc).toEqual(mockVpc);
88
69
  });
89
70
 
90
- it('should return first available VPC when no default VPC exists', async () => {
91
- const mockVpc = {
92
- VpcId: 'vpc-87654321',
93
- IsDefault: false,
94
- State: 'available'
95
- };
71
+ it('should return first VPC when no default VPC exists', async () => {
72
+ const mockVpc = { VpcId: 'vpc-12345678', IsDefault: false };
73
+ ec2Mock.on(DescribeVpcsCommand).resolves({
74
+ Vpcs: [mockVpc]
75
+ });
96
76
 
97
- mockEC2Send
98
- .mockResolvedValueOnce({ Vpcs: [] }) // No default VPC
99
- .mockResolvedValueOnce({ Vpcs: [mockVpc] }); // All VPCs
77
+ const vpc = await discovery.findDefaultVpc();
78
+ expect(vpc).toEqual(mockVpc);
79
+ });
100
80
 
101
- const result = await discovery.findDefaultVpc();
81
+ it('should retry without default filter when default VPC query returns empty', async () => {
82
+ const mockVpc = { VpcId: 'vpc-23456789', IsDefault: false };
102
83
 
103
- expect(result).toEqual(mockVpc);
104
- expect(mockEC2Send).toHaveBeenCalledTimes(2);
84
+ ec2Mock
85
+ .on(DescribeVpcsCommand)
86
+ .resolvesOnce({ Vpcs: [] })
87
+ .resolves({ Vpcs: [mockVpc] });
88
+
89
+ const vpc = await discovery.findDefaultVpc();
90
+ expect(vpc).toEqual(mockVpc);
105
91
  });
106
92
 
107
93
  it('should throw error when no VPCs found', async () => {
108
- mockEC2Send
109
- .mockResolvedValueOnce({ Vpcs: [] }) // No default VPC
110
- .mockResolvedValueOnce({ Vpcs: [] }); // No VPCs at all
94
+ ec2Mock.on(DescribeVpcsCommand).resolves({
95
+ Vpcs: []
96
+ });
111
97
 
112
98
  await expect(discovery.findDefaultVpc()).rejects.toThrow('No VPC found in the account');
113
99
  });
114
100
  });
115
101
 
102
+ describe('isSubnetPrivate', () => {
103
+ const mockVpcId = 'vpc-12345678';
104
+ const mockSubnetId = 'subnet-12345678';
105
+
106
+ it('should return true for private subnet', async () => {
107
+ // Mock subnet lookup first
108
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
109
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
110
+ });
111
+
112
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
113
+ RouteTables: [{
114
+ Associations: [{ SubnetId: mockSubnetId }],
115
+ Routes: [
116
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
117
+ ]
118
+ }]
119
+ });
120
+
121
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
122
+ expect(isPrivate).toBe(true);
123
+ });
124
+
125
+ it('should return false for public subnet', async () => {
126
+ // Mock subnet lookup first
127
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
128
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
129
+ });
130
+
131
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
132
+ RouteTables: [{
133
+ Associations: [{ SubnetId: mockSubnetId }],
134
+ Routes: [
135
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
136
+ ]
137
+ }]
138
+ });
139
+
140
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
141
+ expect(isPrivate).toBe(false);
142
+ });
143
+
144
+ it('should default to private when no route table found', async () => {
145
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
146
+ Subnets: [{ SubnetId: mockSubnetId, VpcId: mockVpcId }]
147
+ });
148
+
149
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({ RouteTables: [] });
150
+
151
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
152
+ expect(isPrivate).toBe(true);
153
+ });
154
+
155
+ it('should default to private when AWS throws describing subnet', async () => {
156
+ ec2Mock.on(DescribeSubnetsCommand).rejects(new Error('boom'));
157
+
158
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
159
+ expect(isPrivate).toBe(true);
160
+ });
161
+
162
+ it('should log warning when subnet cannot be found', async () => {
163
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: [] });
164
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
165
+
166
+ const isPrivate = await discovery.isSubnetPrivate(mockSubnetId);
167
+
168
+ expect(isPrivate).toBe(true);
169
+ expect(warnSpy).toHaveBeenCalledWith(
170
+ `Could not determine if subnet ${mockSubnetId} is private:`,
171
+ expect.any(Error)
172
+ );
173
+
174
+ warnSpy.mockRestore();
175
+ });
176
+ });
177
+
116
178
  describe('findPrivateSubnets', () => {
117
179
  const mockVpcId = 'vpc-12345678';
118
180
 
119
- it('should return private subnets when found', async () => {
181
+ it('should return private subnets', async () => {
120
182
  const mockSubnets = [
121
- { SubnetId: 'subnet-private-1', VpcId: mockVpcId },
122
- { SubnetId: 'subnet-private-2', VpcId: mockVpcId }
183
+ { SubnetId: 'subnet-private-1', AvailabilityZone: 'us-east-1a' },
184
+ { SubnetId: 'subnet-private-2', AvailabilityZone: 'us-east-1b' }
123
185
  ];
124
186
 
125
- mockEC2Send
126
- .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
127
- .mockResolvedValue({ RouteTables: [] }); // DescribeRouteTables (private)
187
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
188
+ Subnets: mockSubnets
189
+ });
128
190
 
129
- const result = await discovery.findPrivateSubnets(mockVpcId);
191
+ // Mock route tables - no IGW routes (private)
192
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
193
+ RouteTables: [{
194
+ Associations: [
195
+ { SubnetId: 'subnet-private-1' },
196
+ { SubnetId: 'subnet-private-2' }
197
+ ],
198
+ Routes: [
199
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
200
+ ]
201
+ }]
202
+ });
130
203
 
131
- expect(result).toHaveLength(2);
132
- expect(result[0].SubnetId).toBe('subnet-private-1');
133
- expect(result[1].SubnetId).toBe('subnet-private-2');
204
+ const subnets = await discovery.findPrivateSubnets(mockVpcId);
205
+ expect(subnets).toEqual(mockSubnets);
134
206
  });
135
207
 
136
- it('should return at least 2 subnets even if mixed private/public', async () => {
208
+ it('should duplicate single private subnet when only one available', async () => {
137
209
  const mockSubnets = [
138
- { SubnetId: 'subnet-1', VpcId: mockVpcId },
139
- { SubnetId: 'subnet-2', VpcId: mockVpcId },
140
- { SubnetId: 'subnet-3', VpcId: mockVpcId }
210
+ { SubnetId: 'subnet-private-1', AvailabilityZone: 'us-east-1a' },
211
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1b' }
141
212
  ];
142
213
 
143
- mockEC2Send
144
- .mockResolvedValueOnce({ Subnets: mockSubnets }) // DescribeSubnets
145
- .mockResolvedValue({ // Only one private subnet
146
- RouteTables: [{
147
- Routes: [] // No IGW route = private
148
- }]
149
- });
214
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: mockSubnets });
150
215
 
151
- const result = await discovery.findPrivateSubnets(mockVpcId);
216
+ jest
217
+ .spyOn(discovery, 'isSubnetPrivate')
218
+ .mockResolvedValueOnce(true)
219
+ .mockResolvedValueOnce(false);
152
220
 
153
- expect(result).toHaveLength(2);
221
+ const subnets = await discovery.findPrivateSubnets(mockVpcId);
222
+ expect(subnets.map((s) => s.SubnetId)).toEqual([
223
+ 'subnet-private-1',
224
+ 'subnet-public-1'
225
+ ]);
154
226
  });
155
227
 
156
- it('should throw error when no subnets found', async () => {
157
- mockEC2Send.mockResolvedValue({ Subnets: [] });
228
+ it('should throw error when no private subnets found and autoConvert is false', async () => {
229
+ const mockSubnets = [
230
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1a' },
231
+ { SubnetId: 'subnet-public-2', AvailabilityZone: 'us-east-1b' },
232
+ { SubnetId: 'subnet-public-3', AvailabilityZone: 'us-east-1c' }
233
+ ];
158
234
 
159
- await expect(discovery.findPrivateSubnets(mockVpcId)).rejects.toThrow(`No subnets found in VPC ${mockVpcId}`);
235
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
236
+ Subnets: mockSubnets
237
+ });
238
+
239
+ // Mock route tables - has IGW routes (public)
240
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
241
+ RouteTables: [{
242
+ Associations: [
243
+ { SubnetId: 'subnet-public-1' },
244
+ { SubnetId: 'subnet-public-2' },
245
+ { SubnetId: 'subnet-public-3' }
246
+ ],
247
+ Routes: [
248
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
249
+ ]
250
+ }]
251
+ });
252
+
253
+ await expect(discovery.findPrivateSubnets(mockVpcId, false))
254
+ .rejects.toThrow('No private subnets found in VPC');
160
255
  });
161
- });
162
256
 
163
- describe('isSubnetPrivate', () => {
164
- const mockSubnetId = 'subnet-12345678';
257
+ it('should return public subnets with warning when autoConvert is true', async () => {
258
+ const mockSubnets = [
259
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1a' },
260
+ { SubnetId: 'subnet-public-2', AvailabilityZone: 'us-east-1b' }
261
+ ];
262
+
263
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
264
+ Subnets: mockSubnets
265
+ });
165
266
 
166
- it('should return false for public subnet (has IGW route)', async () => {
167
- mockEC2Send.mockResolvedValue({
267
+ // Mock route tables - has IGW routes (public)
268
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
168
269
  RouteTables: [{
169
- Routes: [{
170
- GatewayId: 'igw-12345678',
171
- DestinationCidrBlock: '0.0.0.0/0'
172
- }]
270
+ Associations: [
271
+ { SubnetId: 'subnet-public-1' },
272
+ { SubnetId: 'subnet-public-2' }
273
+ ],
274
+ Routes: [
275
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
276
+ ]
173
277
  }]
174
278
  });
175
279
 
176
- const result = await discovery.isSubnetPrivate(mockSubnetId);
280
+ const subnets = await discovery.findPrivateSubnets(mockVpcId, true);
281
+ expect(subnets).toHaveLength(2);
282
+ expect(subnets[0].SubnetId).toBe('subnet-public-1');
283
+ });
284
+
285
+ it('should select converted subnets when autoConvert handles three public subnets', async () => {
286
+ const mockSubnets = [
287
+ { SubnetId: 'subnet-public-1' },
288
+ { SubnetId: 'subnet-public-2' },
289
+ { SubnetId: 'subnet-public-3' }
290
+ ];
291
+
292
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: mockSubnets });
293
+ jest.spyOn(discovery, 'isSubnetPrivate').mockResolvedValue(false);
294
+
295
+ const subnets = await discovery.findPrivateSubnets(mockVpcId, true);
296
+ expect(subnets.map((s) => s.SubnetId)).toEqual([
297
+ 'subnet-public-2',
298
+ 'subnet-public-3'
299
+ ]);
300
+ });
301
+
302
+ it('should throw when AWS returns no subnets', async () => {
303
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: [] });
177
304
 
178
- expect(result).toBe(false);
305
+ await expect(discovery.findPrivateSubnets(mockVpcId)).rejects.toThrow(
306
+ `No subnets found in VPC ${mockVpcId}`
307
+ );
179
308
  });
309
+ });
180
310
 
181
- it('should return true for private subnet (no IGW route)', async () => {
182
- mockEC2Send.mockResolvedValue({
311
+ describe('findPublicSubnets', () => {
312
+ const mockVpcId = 'vpc-12345678';
313
+
314
+ it('should return public subnet', async () => {
315
+ const mockSubnet = { SubnetId: 'subnet-public-1' };
316
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
317
+ Subnets: [mockSubnet]
318
+ });
319
+
320
+ // Mock route table check to show it's public
321
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
183
322
  RouteTables: [{
184
- Routes: [{
185
- GatewayId: 'local',
186
- DestinationCidrBlock: '10.0.0.0/16'
187
- }]
323
+ Associations: [{ SubnetId: 'subnet-public-1' }],
324
+ Routes: [{ GatewayId: 'igw-12345' }] // Has IGW = public
188
325
  }]
189
326
  });
190
327
 
191
- const result = await discovery.isSubnetPrivate(mockSubnetId);
328
+ const subnet = await discovery.findPublicSubnets(mockVpcId);
329
+ expect(subnet).toEqual(mockSubnet);
330
+ });
331
+
332
+ it('should throw error when no subnets found', async () => {
333
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
334
+ Subnets: []
335
+ });
192
336
 
193
- expect(result).toBe(true);
337
+ await expect(discovery.findPublicSubnets(mockVpcId))
338
+ .rejects.toThrow('No subnets found in VPC');
194
339
  });
195
340
 
196
- it('should default to private on error', async () => {
197
- mockEC2Send.mockRejectedValue(new Error('Route table error'));
341
+ it('should return null when no public subnets identified', async () => {
342
+ const mockSubnet = { SubnetId: 'subnet-private-1' };
343
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: [mockSubnet] });
198
344
 
199
- const result = await discovery.isSubnetPrivate(mockSubnetId);
345
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
346
+ RouteTables: [{
347
+ Associations: [{ SubnetId: 'subnet-private-1' }],
348
+ Routes: [{ GatewayId: 'local' }]
349
+ }]
350
+ });
200
351
 
201
- expect(result).toBe(true);
352
+ const subnet = await discovery.findPublicSubnets(mockVpcId);
353
+ expect(subnet).toBeNull();
202
354
  });
203
355
  });
204
356
 
205
357
  describe('findDefaultSecurityGroup', () => {
206
358
  const mockVpcId = 'vpc-12345678';
207
359
 
208
- it('should return Frigg security group when found', async () => {
209
- const mockFriggSg = {
210
- GroupId: 'sg-frigg-123',
360
+ it('should return default security group', async () => {
361
+ const mockSecurityGroup = {
362
+ GroupId: 'sg-12345678',
363
+ GroupName: 'default'
364
+ };
365
+
366
+ ec2Mock
367
+ .on(DescribeSecurityGroupsCommand)
368
+ .resolvesOnce({ SecurityGroups: [] })
369
+ .resolves({ SecurityGroups: [mockSecurityGroup] });
370
+
371
+ const sg = await discovery.findDefaultSecurityGroup(mockVpcId);
372
+ expect(sg).toEqual(mockSecurityGroup);
373
+ });
374
+
375
+ it('should prefer Frigg-managed security group when present', async () => {
376
+ const friggSg = {
377
+ GroupId: 'sg-frigg',
211
378
  GroupName: 'frigg-lambda-sg',
212
- VpcId: mockVpcId
213
379
  };
214
380
 
215
- mockEC2Send.mockResolvedValue({
216
- SecurityGroups: [mockFriggSg]
381
+ ec2Mock
382
+ .on(DescribeSecurityGroupsCommand)
383
+ .resolves({ SecurityGroups: [friggSg] });
384
+
385
+ const sg = await discovery.findDefaultSecurityGroup(mockVpcId);
386
+ expect(sg).toEqual(friggSg);
387
+ });
388
+
389
+ it('should throw error when no default security group found', async () => {
390
+ ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
391
+ SecurityGroups: []
217
392
  });
218
393
 
219
- const result = await discovery.findDefaultSecurityGroup(mockVpcId);
394
+ await expect(discovery.findDefaultSecurityGroup(mockVpcId))
395
+ .rejects.toThrow('No security group found for VPC');
396
+ });
397
+ });
220
398
 
221
- expect(result).toEqual(mockFriggSg);
222
- expect(mockEC2Send).toHaveBeenCalledWith(expect.objectContaining({
223
- input: {
224
- Filters: [
225
- { Name: 'vpc-id', Values: [mockVpcId] },
226
- { Name: 'group-name', Values: ['frigg-lambda-sg'] }
227
- ]
399
+ describe('findPrivateRouteTable', () => {
400
+ const mockVpcId = 'vpc-12345678';
401
+
402
+ it('should return private route table', async () => {
403
+ const mockRouteTable = {
404
+ RouteTableId: 'rtb-12345678',
405
+ Routes: [
406
+ { GatewayId: 'local', DestinationCidrBlock: '10.0.0.0/16' }
407
+ ]
408
+ };
409
+
410
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
411
+ RouteTables: [mockRouteTable]
412
+ });
413
+
414
+ const rt = await discovery.findPrivateRouteTable(mockVpcId);
415
+ expect(rt).toEqual(mockRouteTable);
416
+ });
417
+
418
+ it('should return first route table when no private route table found', async () => {
419
+ const mockRouteTable = {
420
+ RouteTableId: 'rtb-12345678',
421
+ Routes: [
422
+ { GatewayId: 'igw-12345', DestinationCidrBlock: '0.0.0.0/0' }
423
+ ]
424
+ };
425
+
426
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
427
+ RouteTables: [mockRouteTable]
428
+ });
429
+
430
+ const rt = await discovery.findPrivateRouteTable(mockVpcId);
431
+ expect(rt).toEqual(mockRouteTable);
432
+ });
433
+
434
+ it('should throw error when no route tables found', async () => {
435
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({ RouteTables: [] });
436
+
437
+ await expect(
438
+ discovery.findPrivateRouteTable(mockVpcId)
439
+ ).rejects.toThrow(`No route tables found for VPC ${mockVpcId}`);
440
+ });
441
+ });
442
+
443
+ describe('findDefaultKmsKey', () => {
444
+ it('should return default KMS key ARN', async () => {
445
+ const mockKeyId = '12345678-1234-1234-1234-123456789012';
446
+ const mockKeyArn = `arn:aws:kms:us-east-1:123456789012:key/${mockKeyId}`;
447
+
448
+ kmsMock.on(ListKeysCommand).resolves({
449
+ Keys: [{ KeyId: mockKeyId }]
450
+ });
451
+
452
+ kmsMock.on(DescribeKeyCommand).resolves({
453
+ KeyMetadata: {
454
+ Arn: mockKeyArn,
455
+ KeyManager: 'CUSTOMER',
456
+ KeyState: 'Enabled'
457
+ }
458
+ });
459
+
460
+ const keyArn = await discovery.findDefaultKmsKey();
461
+ expect(keyArn).toBe(mockKeyArn);
462
+ });
463
+
464
+ it('should return null when no AWS-managed keys found', async () => {
465
+ kmsMock.on(ListKeysCommand).resolves({
466
+ Keys: []
467
+ });
468
+
469
+ const keyArn = await discovery.findDefaultKmsKey();
470
+ expect(keyArn).toBeNull();
471
+ });
472
+
473
+ it('should return null when only AWS-managed keys exist', async () => {
474
+ kmsMock.on(ListKeysCommand).resolves({
475
+ Keys: [{ KeyId: 'aws-key' }]
476
+ });
477
+
478
+ kmsMock.on(DescribeKeyCommand).resolves({
479
+ KeyMetadata: {
480
+ Arn: 'arn:aws:kms:us-east-1:123456789012:key/aws-key',
481
+ KeyManager: 'AWS',
482
+ KeyState: 'Enabled'
228
483
  }
229
- }));
484
+ });
485
+
486
+ const keyArn = await discovery.findDefaultKmsKey();
487
+ expect(keyArn).toBeNull();
488
+ });
489
+
490
+ it('should skip keys that fail to describe', async () => {
491
+ kmsMock.on(ListKeysCommand).resolves({ Keys: [{ KeyId: 'bad-key' }] });
492
+ kmsMock.on(DescribeKeyCommand).rejects(new Error('describe failure'));
493
+
494
+ const keyArn = await discovery.findDefaultKmsKey();
495
+ expect(keyArn).toBeNull();
230
496
  });
231
497
 
232
- it('should fallback to default security group', async () => {
233
- const mockDefaultSg = {
234
- GroupId: 'sg-default-123',
235
- GroupName: 'default',
236
- VpcId: mockVpcId
498
+ it('should return null when ListKeys fails', async () => {
499
+ kmsMock.on(ListKeysCommand).rejects(new Error('list failure'));
500
+
501
+ const keyArn = await discovery.findDefaultKmsKey();
502
+ expect(keyArn).toBeNull();
503
+ });
504
+
505
+ it('should skip customer keys pending deletion', async () => {
506
+ const mockKeyId = 'pending-key';
507
+ kmsMock.on(ListKeysCommand).resolves({ Keys: [{ KeyId: mockKeyId }] });
508
+ kmsMock.on(DescribeKeyCommand).resolves({
509
+ KeyMetadata: {
510
+ Arn: `arn:aws:kms:us-east-1:123456789012:key/${mockKeyId}`,
511
+ KeyManager: 'CUSTOMER',
512
+ KeyState: 'PendingDeletion',
513
+ DeletionDate: '2024-01-01T00:00:00Z',
514
+ },
515
+ });
516
+
517
+ const keyArn = await discovery.findDefaultKmsKey();
518
+ expect(keyArn).toBeNull();
519
+ });
520
+
521
+ it('should return null when all customer keys are disabled', async () => {
522
+ const mockKeyId = 'disabled-key';
523
+ kmsMock.on(ListKeysCommand).resolves({ Keys: [{ KeyId: mockKeyId }] });
524
+ kmsMock.on(DescribeKeyCommand).resolves({
525
+ KeyMetadata: {
526
+ Arn: `arn:aws:kms:us-east-1:123456789012:key/${mockKeyId}`,
527
+ KeyManager: 'CUSTOMER',
528
+ KeyState: 'Disabled',
529
+ },
530
+ });
531
+
532
+ const keyArn = await discovery.findDefaultKmsKey();
533
+ expect(keyArn).toBeNull();
534
+ });
535
+ });
536
+
537
+ describe('findAvailableElasticIP', () => {
538
+ it('should return available Elastic IP', async () => {
539
+ const mockElasticIP = {
540
+ AllocationId: 'eipalloc-12345',
541
+ PublicIp: '52.1.2.3'
237
542
  };
238
543
 
239
- mockEC2Send
240
- .mockResolvedValueOnce({ SecurityGroups: [] }) // No Frigg SG
241
- .mockResolvedValueOnce({ SecurityGroups: [mockDefaultSg] }); // Default SG
544
+ ec2Mock.on(DescribeAddressesCommand).resolves({
545
+ Addresses: [mockElasticIP]
546
+ });
547
+
548
+ const eip = await discovery.findAvailableElasticIP();
549
+ expect(eip).toEqual(mockElasticIP);
550
+ });
242
551
 
243
- const result = await discovery.findDefaultSecurityGroup(mockVpcId);
552
+ it('should return null when no available Elastic IPs', async () => {
553
+ ec2Mock.on(DescribeAddressesCommand).resolves({
554
+ Addresses: []
555
+ });
244
556
 
245
- expect(result).toEqual(mockDefaultSg);
246
- expect(mockEC2Send).toHaveBeenCalledTimes(2);
557
+ const eip = await discovery.findAvailableElasticIP();
558
+ expect(eip).toBeNull();
247
559
  });
248
560
 
249
- it('should throw error when no security groups found', async () => {
250
- mockEC2Send.mockResolvedValue({ SecurityGroups: [] });
561
+ it('should return Frigg-tagged Elastic IP when present', async () => {
562
+ const friggAddress = {
563
+ AllocationId: 'eipalloc-frigg',
564
+ PublicIp: '52.0.0.1',
565
+ NetworkInterfaceId: 'eni-12345',
566
+ Tags: [{ Key: 'Name', Value: 'frigg-shared-ip' }]
567
+ };
251
568
 
252
- await expect(discovery.findDefaultSecurityGroup(mockVpcId)).rejects.toThrow(`No security group found for VPC ${mockVpcId}`);
569
+ ec2Mock.on(DescribeAddressesCommand).resolves({
570
+ Addresses: [
571
+ { AllocationId: 'eipalloc-associated', AssociationId: 'assoc-1' },
572
+ friggAddress,
573
+ ]
574
+ });
575
+
576
+ const eip = await discovery.findAvailableElasticIP();
577
+ expect(eip).toEqual(friggAddress);
578
+ });
579
+
580
+ it('should return null when DescribeAddresses fails', async () => {
581
+ ec2Mock.on(DescribeAddressesCommand).rejects(new Error('addr failure'));
582
+
583
+ const eip = await discovery.findAvailableElasticIP();
584
+ expect(eip).toBeNull();
253
585
  });
254
586
  });
255
587
 
256
- describe('findDefaultKmsKey', () => {
257
- it('should return customer managed key when found', async () => {
258
- const mockKeyId = 'key-12345678';
259
- const mockKeyArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012';
588
+ describe('findExistingNatGateway', () => {
589
+ const mockVpcId = 'vpc-12345678';
590
+
591
+ beforeEach(() => {
592
+ // Create a fresh discovery instance for each test
593
+ discovery = new AWSDiscovery('us-east-1');
594
+ });
595
+
596
+ it('should return NAT Gateway in public subnet', async () => {
597
+ const mockNatGateway = {
598
+ NatGatewayId: 'nat-12345678',
599
+ SubnetId: 'subnet-public-1',
600
+ State: 'available',
601
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
602
+ Tags: []
603
+ };
604
+
605
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
606
+ NatGateways: [mockNatGateway]
607
+ });
608
+
609
+ // Mock subnet lookup
610
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
611
+ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }]
612
+ });
613
+
614
+ // Mock route table - has IGW (public)
615
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
616
+ RouteTables: [{
617
+ Associations: [{ SubnetId: 'subnet-public-1' }],
618
+ Routes: [{ GatewayId: 'igw-12345' }]
619
+ }]
620
+ });
621
+
622
+ const result = await discovery.findExistingNatGateway(mockVpcId);
623
+
624
+ expect(result).toBeDefined();
625
+ expect(result.NatGatewayId).toBe('nat-12345678');
626
+ expect(result._isInPrivateSubnet).toBe(false);
627
+ });
628
+
629
+ it('should detect NAT Gateway in private subnet', async () => {
630
+ const mockNatGateway = {
631
+ NatGatewayId: 'nat-12345678',
632
+ SubnetId: 'subnet-private-1',
633
+ State: 'available',
634
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
635
+ Tags: [
636
+ { Key: 'ManagedBy', Value: 'Frigg' }
637
+ ]
638
+ };
639
+
640
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
641
+ NatGateways: [mockNatGateway]
642
+ });
643
+
644
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
645
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
646
+ });
647
+
648
+ // Mock route table - no IGW (private)
649
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
650
+ RouteTables: [{
651
+ Associations: [{ SubnetId: 'subnet-private-1' }],
652
+ Routes: [{ GatewayId: 'local' }]
653
+ }]
654
+ });
655
+
656
+ const result = await discovery.findExistingNatGateway(mockVpcId);
657
+
658
+ expect(result).toBeDefined();
659
+ expect(result.NatGatewayId).toBe('nat-12345678');
660
+ expect(result._isInPrivateSubnet).toBe(true);
661
+ });
662
+
663
+ it('should skip non-Frigg NAT Gateway in private subnet', async () => {
664
+ const mockNatGateways = [
665
+ {
666
+ NatGatewayId: 'nat-other-12345',
667
+ SubnetId: 'subnet-private-1',
668
+ State: 'available',
669
+ Tags: [] // No Frigg tags
670
+ },
671
+ {
672
+ NatGatewayId: 'nat-good-12345',
673
+ SubnetId: 'subnet-public-1',
674
+ State: 'available',
675
+ Tags: []
676
+ }
677
+ ];
678
+
679
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
680
+ NatGateways: mockNatGateways
681
+ });
260
682
 
261
- mockKMSSend
262
- .mockResolvedValueOnce({ // ListKeys
263
- Keys: [{ KeyId: mockKeyId }]
683
+ // First call for subnet-private-1
684
+ ec2Mock.on(DescribeSubnetsCommand)
685
+ .resolvesOnce({
686
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
264
687
  })
265
- .mockResolvedValueOnce({ // DescribeKey
266
- KeyMetadata: {
267
- KeyId: mockKeyId,
268
- Arn: mockKeyArn,
269
- KeyManager: 'CUSTOMER',
270
- KeyState: 'Enabled'
271
- }
688
+ // Second call for subnet-public-1
689
+ .resolvesOnce({
690
+ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }]
272
691
  });
273
692
 
274
- mockSTSSend.mockResolvedValue({ Account: '123456789012' });
693
+ // First call for private subnet route table
694
+ ec2Mock.on(DescribeRouteTablesCommand)
695
+ .resolvesOnce({
696
+ RouteTables: [{
697
+ Associations: [{ SubnetId: 'subnet-private-1' }],
698
+ Routes: [{ GatewayId: 'local' }] // Private
699
+ }]
700
+ })
701
+ // Second call for public subnet route table
702
+ .resolvesOnce({
703
+ RouteTables: [{
704
+ Associations: [{ SubnetId: 'subnet-public-1' }],
705
+ Routes: [{ GatewayId: 'igw-12345' }] // Public
706
+ }]
707
+ });
275
708
 
276
- const result = await discovery.findDefaultKmsKey();
709
+ const result = await discovery.findExistingNatGateway(mockVpcId);
277
710
 
278
- expect(result).toBe(mockKeyArn);
711
+ expect(result).toBeDefined();
712
+ expect(result.NatGatewayId).toBe('nat-good-12345');
713
+ expect(result._isInPrivateSubnet).toBe(false);
279
714
  });
280
715
 
281
- it('should return wildcard pattern when no customer keys found', async () => {
282
- mockKMSSend.mockResolvedValue({ Keys: [] });
283
- mockSTSSend.mockResolvedValue({ Account: '123456789012' });
716
+ it('should prioritize Frigg-managed NAT Gateways', async () => {
717
+ const mockNatGateways = [
718
+ {
719
+ NatGatewayId: 'nat-other-12345',
720
+ SubnetId: 'subnet-public-1',
721
+ State: 'available',
722
+ Tags: []
723
+ },
724
+ {
725
+ NatGatewayId: 'nat-frigg-12345',
726
+ SubnetId: 'subnet-public-2',
727
+ State: 'available',
728
+ Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }]
729
+ }
730
+ ];
731
+
732
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
733
+ NatGateways: mockNatGateways
734
+ });
284
735
 
285
- const result = await discovery.findDefaultKmsKey();
736
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
737
+ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }]
738
+ });
286
739
 
287
- expect(result).toBe('arn:aws:kms:us-east-1:123456789012:key/*');
740
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
741
+ RouteTables: [{
742
+ Associations: [{ SubnetId: 'subnet-public-2' }],
743
+ Routes: [{ GatewayId: 'igw-12345' }] // Public
744
+ }]
745
+ });
746
+
747
+ const result = await discovery.findExistingNatGateway(mockVpcId);
748
+
749
+ expect(result).toBeDefined();
750
+ expect(result.NatGatewayId).toBe('nat-frigg-12345');
751
+ expect(result._isInPrivateSubnet).toBe(false);
288
752
  });
289
753
 
290
- it('should return fallback on error', async () => {
291
- mockKMSSend.mockRejectedValue(new Error('KMS Error'));
754
+ it('should return null when no NAT Gateways found', async () => {
755
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
756
+ NatGateways: []
757
+ });
292
758
 
293
- const result = await discovery.findDefaultKmsKey();
759
+ const result = await discovery.findExistingNatGateway(mockVpcId);
760
+ expect(result).toBeNull();
761
+ });
294
762
 
295
- expect(result).toBe('*');
763
+ it('should skip NAT Gateways that are not available', async () => {
764
+ const mockNatGateways = [
765
+ {
766
+ NatGatewayId: 'nat-pending',
767
+ SubnetId: 'subnet-public-1',
768
+ State: 'pending',
769
+ Tags: [],
770
+ },
771
+ ];
772
+
773
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
774
+ NatGateways: mockNatGateways,
775
+ });
776
+
777
+ const result = await discovery.findExistingNatGateway(mockVpcId);
778
+ expect(result).toBeNull();
779
+ });
780
+
781
+ it('should return null when only non-Frigg NAT Gateways are in private subnets', async () => {
782
+ const mockNatGateways = [
783
+ {
784
+ NatGatewayId: 'nat-private-only',
785
+ SubnetId: 'subnet-private-1',
786
+ State: 'available',
787
+ Tags: [],
788
+ },
789
+ ];
790
+
791
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
792
+ NatGateways: mockNatGateways,
793
+ });
794
+
795
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
796
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }],
797
+ });
798
+
799
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
800
+ RouteTables: [
801
+ {
802
+ Associations: [{ SubnetId: 'subnet-private-1' }],
803
+ Routes: [{ GatewayId: 'local' }],
804
+ },
805
+ ],
806
+ });
807
+
808
+ const result = await discovery.findExistingNatGateway(mockVpcId);
809
+ expect(result).toBeNull();
810
+ });
811
+
812
+ it('should return null when DescribeNatGateways fails', async () => {
813
+ ec2Mock.on(DescribeNatGatewaysCommand).rejects(new Error('nat failure'));
814
+
815
+ const result = await discovery.findExistingNatGateway(mockVpcId);
816
+ expect(result).toBeNull();
817
+ });
818
+ });
819
+
820
+ describe('findInternetGateway', () => {
821
+ const mockVpcId = 'vpc-12345678';
822
+
823
+ it('should return existing Internet Gateway', async () => {
824
+ const mockIgw = { InternetGatewayId: 'igw-12345' };
825
+
826
+ ec2Mock.on(DescribeInternetGatewaysCommand).resolves({
827
+ InternetGateways: [mockIgw]
828
+ });
829
+
830
+ const igw = await discovery.findInternetGateway(mockVpcId);
831
+ expect(igw).toEqual(mockIgw);
832
+ });
833
+
834
+ it('should return null when no Internet Gateway found', async () => {
835
+ ec2Mock.on(DescribeInternetGatewaysCommand).resolves({ InternetGateways: [] });
836
+
837
+ const igw = await discovery.findInternetGateway(mockVpcId);
838
+ expect(igw).toBeNull();
839
+ });
840
+
841
+ it('should return null when DescribeInternetGateways fails', async () => {
842
+ ec2Mock.on(DescribeInternetGatewaysCommand).rejects(new Error('igw failure'));
843
+
844
+ const igw = await discovery.findInternetGateway(mockVpcId);
845
+ expect(igw).toBeNull();
846
+ });
847
+ });
848
+
849
+ describe('findFriggManagedResources', () => {
850
+ it('should return tagged resources', async () => {
851
+ const mockNat = { NatGatewayId: 'nat-frigg', Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] };
852
+ const mockEip = { AllocationId: 'eip-frigg', Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] };
853
+ const mockRouteTable = { RouteTableId: 'rtb-frigg', Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] };
854
+ const mockSubnet = { SubnetId: 'subnet-frigg', Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] };
855
+ const mockSg = { GroupId: 'sg-frigg', Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }] };
856
+
857
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({ NatGateways: [mockNat] });
858
+ ec2Mock.on(DescribeAddressesCommand).resolves({ Addresses: [mockEip] });
859
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({ RouteTables: [mockRouteTable] });
860
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: [mockSubnet] });
861
+ ec2Mock.on(DescribeSecurityGroupsCommand).resolves({ SecurityGroups: [mockSg] });
862
+
863
+ const result = await discovery.findFriggManagedResources('service', 'stage');
864
+ expect(result).toMatchObject({
865
+ natGateways: [mockNat],
866
+ elasticIps: [mockEip],
867
+ routeTables: [mockRouteTable],
868
+ subnets: [mockSubnet],
869
+ securityGroups: [mockSg],
870
+ });
871
+ });
872
+
873
+ it('should return empty arrays when calls fail', async () => {
874
+ ec2Mock.on(DescribeNatGatewaysCommand).rejects(new Error('error'));
875
+ ec2Mock.on(DescribeAddressesCommand).rejects(new Error('error'));
876
+ ec2Mock.on(DescribeRouteTablesCommand).rejects(new Error('error'));
877
+ ec2Mock.on(DescribeSubnetsCommand).rejects(new Error('error'));
878
+ ec2Mock.on(DescribeSecurityGroupsCommand).rejects(new Error('error'));
879
+
880
+ const result = await discovery.findFriggManagedResources('service', 'stage');
881
+ expect(result).toEqual({
882
+ natGateways: [],
883
+ elasticIps: [],
884
+ routeTables: [],
885
+ subnets: [],
886
+ securityGroups: [],
887
+ });
888
+ });
889
+ });
890
+
891
+ describe('detectMisconfiguredResources', () => {
892
+ const mockVpcId = 'vpc-12345678';
893
+
894
+ it('should capture misconfigured resources', async () => {
895
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
896
+ NatGateways: [
897
+ { NatGatewayId: 'nat-private', SubnetId: 'subnet-private', State: 'available' },
898
+ ],
899
+ });
900
+
901
+ ec2Mock.on(DescribeAddressesCommand).resolves({
902
+ Addresses: [
903
+ {
904
+ AllocationId: 'eip-123',
905
+ PublicIp: '52.0.0.1',
906
+ Tags: [{ Key: 'ManagedBy', Value: 'Frigg' }],
907
+ },
908
+ ],
909
+ });
910
+
911
+ discovery.findPrivateSubnets = jest
912
+ .fn()
913
+ .mockResolvedValue([{ SubnetId: 'subnet-private', AvailabilityZone: 'us-east-1a' }]);
914
+ discovery.findRouteTables = jest.fn().mockResolvedValue([
915
+ {
916
+ Associations: [],
917
+ Routes: [],
918
+ },
919
+ ]);
920
+
921
+ jest.spyOn(discovery, 'isSubnetPrivate').mockResolvedValue(true);
922
+
923
+ const misconfigs = await discovery.detectMisconfiguredResources(mockVpcId);
924
+ expect(misconfigs.natGatewaysInPrivateSubnets).toHaveLength(1);
925
+ expect(misconfigs.orphanedElasticIps).toHaveLength(1);
926
+ expect(misconfigs.privateSubnetsWithoutNatRoute).toHaveLength(1);
927
+ });
928
+ });
929
+
930
+ describe('getHealingRecommendations', () => {
931
+ it('should produce ordered recommendations', () => {
932
+ const recs = discovery.getHealingRecommendations({
933
+ natGatewaysInPrivateSubnets: [{ natGatewayId: 'nat-1' }],
934
+ orphanedElasticIps: [{ allocationId: 'eip-1' }],
935
+ privateSubnetsWithoutNatRoute: [{ subnetId: 'subnet-1' }],
936
+ });
937
+
938
+ expect(recs[0].severity).toBe('critical');
939
+ expect(recs[0].issue).toContain('NAT Gateway');
940
+ expect(recs[1].severity).toBe('critical');
941
+ expect(recs[2].severity).toBe('warning');
942
+ });
943
+
944
+ it('should return empty array for no issues', () => {
945
+ const recs = discovery.getHealingRecommendations({
946
+ natGatewaysInPrivateSubnets: [],
947
+ orphanedElasticIps: [],
948
+ privateSubnetsWithoutNatRoute: [],
949
+ });
950
+
951
+ expect(recs).toEqual([]);
296
952
  });
297
953
  });
298
954
 
@@ -300,48 +956,239 @@ describe('AWSDiscovery', () => {
300
956
  it('should discover all AWS resources successfully', async () => {
301
957
  const mockVpc = { VpcId: 'vpc-12345678' };
302
958
  const mockSubnets = [
303
- { SubnetId: 'subnet-1' },
959
+ { SubnetId: 'subnet-1' },
304
960
  { SubnetId: 'subnet-2' }
305
961
  ];
962
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
306
963
  const mockSecurityGroup = { GroupId: 'sg-12345678' };
307
964
  const mockRouteTable = { RouteTableId: 'rtb-12345678' };
308
965
  const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
966
+ const mockNatGateway = {
967
+ NatGatewayId: 'nat-12345678',
968
+ SubnetId: 'subnet-public-1',
969
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
970
+ _isInPrivateSubnet: false
971
+ };
309
972
 
310
973
  // Mock all the discovery methods
311
974
  jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
312
975
  jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
976
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
313
977
  jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
314
978
  jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
315
979
  jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
980
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(mockNatGateway);
981
+ jest.spyOn(discovery, 'isSubnetPrivate')
982
+ .mockResolvedValueOnce(true) // subnet-1 is private
983
+ .mockResolvedValueOnce(true); // subnet-2 is private
316
984
 
317
985
  const result = await discovery.discoverResources();
318
986
 
319
- expect(result).toEqual({
987
+ expect(result).toMatchObject({
320
988
  defaultVpcId: 'vpc-12345678',
321
989
  defaultSecurityGroupId: 'sg-12345678',
322
990
  privateSubnetId1: 'subnet-1',
323
991
  privateSubnetId2: 'subnet-2',
992
+ publicSubnetId: 'subnet-public-1',
324
993
  privateRouteTableId: 'rtb-12345678',
325
- defaultKmsKeyId: mockKmsArn
994
+ defaultKmsKeyId: mockKmsArn,
995
+ existingNatGatewayId: 'nat-12345678',
996
+ existingElasticIpAllocationId: 'eipalloc-12345',
997
+ natGatewayInPrivateSubnet: false,
998
+ subnetConversionRequired: false,
999
+ privateSubnetsWithWrongRoutes: []
326
1000
  });
327
1001
 
328
1002
  // Verify all methods were called
329
1003
  expect(discovery.findDefaultVpc).toHaveBeenCalled();
330
- expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678');
1004
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', false);
1005
+ expect(discovery.findPublicSubnets).toHaveBeenCalledWith('vpc-12345678');
331
1006
  expect(discovery.findDefaultSecurityGroup).toHaveBeenCalledWith('vpc-12345678');
332
1007
  expect(discovery.findPrivateRouteTable).toHaveBeenCalledWith('vpc-12345678');
333
1008
  expect(discovery.findDefaultKmsKey).toHaveBeenCalled();
1009
+ expect(discovery.findExistingNatGateway).toHaveBeenCalledWith('vpc-12345678');
1010
+ });
1011
+
1012
+ it('should detect subnet conversion requirements', async () => {
1013
+ const mockVpc = { VpcId: 'vpc-12345678' };
1014
+ const mockSubnets = [
1015
+ { SubnetId: 'subnet-1' },
1016
+ { SubnetId: 'subnet-2' }
1017
+ ];
1018
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
1019
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1020
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1021
+
1022
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1023
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1024
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
1025
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1026
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1027
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1028
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
1029
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
1030
+ jest.spyOn(discovery, 'isSubnetPrivate')
1031
+ .mockImplementation((subnetId) => {
1032
+ // subnet-1 is public, subnet-2 is private
1033
+ return Promise.resolve(subnetId === 'subnet-2');
1034
+ });
1035
+
1036
+ const result = await discovery.discoverResources({ selfHeal: true });
1037
+
1038
+ expect(result).toMatchObject({
1039
+ defaultVpcId: 'vpc-12345678',
1040
+ privateSubnetId1: 'subnet-1',
1041
+ privateSubnetId2: 'subnet-2',
1042
+ publicSubnetId: 'subnet-public-1',
1043
+ subnetConversionRequired: true,
1044
+ privateSubnetsWithWrongRoutes: ['subnet-1']
1045
+ });
1046
+ });
1047
+
1048
+ it('should surface subnet analysis summary for diagnostic tooling', async () => {
1049
+ const mockVpc = { VpcId: 'vpc-987654321', CidrBlock: '10.0.0.0/16' };
1050
+ const mockSubnets = [
1051
+ { SubnetId: 'subnet-public-a', AvailabilityZone: 'us-east-1a' },
1052
+ { SubnetId: 'subnet-private-b', AvailabilityZone: 'us-east-1b' }
1053
+ ];
1054
+ const mockSecurityGroup = { GroupId: 'sg-22222222' };
1055
+ const mockRouteTable = { RouteTableId: 'rtb-22222222' };
1056
+
1057
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1058
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1059
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue({ SubnetId: 'subnet-nat-home' });
1060
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1061
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1062
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1063
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue({
1064
+ NatGatewayId: 'nat-2222',
1065
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-2222' }],
1066
+ _isInPrivateSubnet: false
1067
+ });
1068
+ jest.spyOn(discovery, 'isSubnetPrivate')
1069
+ .mockImplementation((subnetId) => subnetId === 'subnet-private-b');
1070
+
1071
+ const result = await discovery.discoverResources({ selfHeal: true });
1072
+
1073
+ expect(result.defaultVpcId).toBe('vpc-987654321');
1074
+ expect(result.subnetConversionRequired).toBe(true);
1075
+ expect(result.privateSubnetsWithWrongRoutes).toEqual(['subnet-public-a']);
1076
+ expect(result.privateSubnetId1).toBe('subnet-public-a');
1077
+ expect(result.privateSubnetId2).toBe('subnet-private-b');
1078
+ expect(result.existingNatGatewayId).toBe('nat-2222');
1079
+ expect(result.existingElasticIpAllocationId).toBe('eipalloc-2222');
1080
+ });
1081
+
1082
+ it('should handle selfHeal option', async () => {
1083
+ const mockVpc = { VpcId: 'vpc-12345678' };
1084
+ const mockSubnets = [
1085
+ { SubnetId: 'subnet-public-1' },
1086
+ { SubnetId: 'subnet-public-2' }
1087
+ ];
1088
+ const mockPublicSubnet = { SubnetId: 'subnet-public-3' };
1089
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1090
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1091
+
1092
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1093
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1094
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
1095
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1096
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1097
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1098
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
1099
+ jest.spyOn(discovery, 'findAvailableElasticIP').mockResolvedValue(null);
1100
+ jest.spyOn(discovery, 'isSubnetPrivate')
1101
+ .mockResolvedValue(false); // All subnets are public
1102
+
1103
+ const result = await discovery.discoverResources({ selfHeal: true });
1104
+
1105
+ // Verify that findPrivateSubnets was called with autoConvert=true
1106
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', true);
1107
+
1108
+ expect(result).toMatchObject({
1109
+ subnetConversionRequired: true,
1110
+ privateSubnetsWithWrongRoutes: ['subnet-public-1', 'subnet-public-2']
1111
+ });
1112
+ });
1113
+
1114
+ it('should reuse available Elastic IP when no NAT Gateway exists and no public subnet is found', async () => {
1115
+ const mockVpc = { VpcId: 'vpc-12345678' };
1116
+ const mockSubnets = [
1117
+ { SubnetId: 'subnet-a' },
1118
+ { SubnetId: 'subnet-b' }
1119
+ ];
1120
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1121
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1122
+ const mockElasticIp = { AllocationId: 'eipalloc-available' };
1123
+
1124
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1125
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1126
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(null);
1127
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1128
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1129
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1130
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
1131
+ const findAvailableElasticIPSpy = jest
1132
+ .spyOn(discovery, 'findAvailableElasticIP')
1133
+ .mockResolvedValue(mockElasticIp);
1134
+ jest.spyOn(discovery, 'isSubnetPrivate').mockResolvedValue(true);
1135
+
1136
+ const result = await discovery.discoverResources();
1137
+
1138
+ expect(result.publicSubnetId).toBeNull();
1139
+ expect(result.existingNatGatewayId).toBeNull();
1140
+ expect(result.existingElasticIpAllocationId).toBe('eipalloc-available');
1141
+ expect(findAvailableElasticIPSpy).toHaveBeenCalled();
1142
+ });
1143
+
1144
+ it('should detect NAT Gateway in private subnet in discoverResources', async () => {
1145
+ const mockVpc = { VpcId: 'vpc-12345678' };
1146
+ const mockSubnets = [
1147
+ { SubnetId: 'subnet-1' },
1148
+ { SubnetId: 'subnet-2' }
1149
+ ];
1150
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
1151
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1152
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1153
+ const mockNatGateway = {
1154
+ NatGatewayId: 'nat-12345678',
1155
+ NatGatewayAddresses: [{ AllocationId: 'eipalloc-12345' }],
1156
+ _isInPrivateSubnet: true // NAT is in private subnet
1157
+ };
1158
+
1159
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1160
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1161
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
1162
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1163
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1164
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1165
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(mockNatGateway);
1166
+ jest.spyOn(discovery, 'isSubnetPrivate')
1167
+ .mockResolvedValueOnce(true)
1168
+ .mockResolvedValueOnce(true);
1169
+
1170
+ const result = await discovery.discoverResources();
1171
+
1172
+ expect(result).toMatchObject({
1173
+ defaultVpcId: 'vpc-12345678',
1174
+ existingNatGatewayId: 'nat-12345678',
1175
+ natGatewayInPrivateSubnet: true, // Should be true
1176
+ subnetConversionRequired: false,
1177
+ privateSubnetsWithWrongRoutes: []
1178
+ });
334
1179
  });
335
1180
 
336
1181
  it('should handle single subnet scenario', async () => {
337
1182
  const mockVpc = { VpcId: 'vpc-12345678' };
338
1183
  const mockSubnets = [{ SubnetId: 'subnet-1' }]; // Only one subnet
1184
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
339
1185
  const mockSecurityGroup = { GroupId: 'sg-12345678' };
340
1186
  const mockRouteTable = { RouteTableId: 'rtb-12345678' };
341
1187
  const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
342
1188
 
343
1189
  jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
344
1190
  jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1191
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
345
1192
  jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
346
1193
  jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
347
1194
  jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
@@ -370,4 +1217,4 @@ describe('AWSDiscovery', () => {
370
1217
  expect(customDiscovery.region).toBe('us-west-2');
371
1218
  });
372
1219
  });
373
- });
1220
+ });