@friggframework/devtools 2.0.0--canary.428.2b9210c.1 → 2.0.0--canary.428.d54dca5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,8 @@ const {
9
9
  DescribeSecurityGroupsCommand,
10
10
  DescribeRouteTablesCommand,
11
11
  DescribeNatGatewaysCommand,
12
- DescribeAddressesCommand
12
+ DescribeAddressesCommand,
13
+ DescribeInternetGatewaysCommand,
13
14
  } = require('@aws-sdk/client-ec2');
14
15
  const {
15
16
  KMSClient,
@@ -77,6 +78,18 @@ describe('AWSDiscovery', () => {
77
78
  expect(vpc).toEqual(mockVpc);
78
79
  });
79
80
 
81
+ it('should retry without default filter when default VPC query returns empty', async () => {
82
+ const mockVpc = { VpcId: 'vpc-23456789', IsDefault: false };
83
+
84
+ ec2Mock
85
+ .on(DescribeVpcsCommand)
86
+ .resolvesOnce({ Vpcs: [] })
87
+ .resolves({ Vpcs: [mockVpc] });
88
+
89
+ const vpc = await discovery.findDefaultVpc();
90
+ expect(vpc).toEqual(mockVpc);
91
+ });
92
+
80
93
  it('should throw error when no VPCs found', async () => {
81
94
  ec2Mock.on(DescribeVpcsCommand).resolves({
82
95
  Vpcs: []
@@ -127,6 +140,39 @@ describe('AWSDiscovery', () => {
127
140
  const isPrivate = await discovery.isSubnetPrivate(mockSubnetId, mockVpcId);
128
141
  expect(isPrivate).toBe(false);
129
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
+ });
130
176
  });
131
177
 
132
178
  describe('findPrivateSubnets', () => {
@@ -159,6 +205,26 @@ describe('AWSDiscovery', () => {
159
205
  expect(subnets).toEqual(mockSubnets);
160
206
  });
161
207
 
208
+ it('should duplicate single private subnet when only one available', async () => {
209
+ const mockSubnets = [
210
+ { SubnetId: 'subnet-private-1', AvailabilityZone: 'us-east-1a' },
211
+ { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1b' }
212
+ ];
213
+
214
+ ec2Mock.on(DescribeSubnetsCommand).resolves({ Subnets: mockSubnets });
215
+
216
+ jest
217
+ .spyOn(discovery, 'isSubnetPrivate')
218
+ .mockResolvedValueOnce(true)
219
+ .mockResolvedValueOnce(false);
220
+
221
+ const subnets = await discovery.findPrivateSubnets(mockVpcId);
222
+ expect(subnets.map((s) => s.SubnetId)).toEqual([
223
+ 'subnet-private-1',
224
+ 'subnet-public-1'
225
+ ]);
226
+ });
227
+
162
228
  it('should throw error when no private subnets found and autoConvert is false', async () => {
163
229
  const mockSubnets = [
164
230
  { SubnetId: 'subnet-public-1', AvailabilityZone: 'us-east-1a' },
@@ -215,6 +281,31 @@ describe('AWSDiscovery', () => {
215
281
  expect(subnets).toHaveLength(2);
216
282
  expect(subnets[0].SubnetId).toBe('subnet-public-1');
217
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: [] });
304
+
305
+ await expect(discovery.findPrivateSubnets(mockVpcId)).rejects.toThrow(
306
+ `No subnets found in VPC ${mockVpcId}`
307
+ );
308
+ });
218
309
  });
219
310
 
220
311
  describe('findPublicSubnets', () => {
@@ -246,6 +337,21 @@ describe('AWSDiscovery', () => {
246
337
  await expect(discovery.findPublicSubnets(mockVpcId))
247
338
  .rejects.toThrow('No subnets found in VPC');
248
339
  });
340
+
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] });
344
+
345
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
346
+ RouteTables: [{
347
+ Associations: [{ SubnetId: 'subnet-private-1' }],
348
+ Routes: [{ GatewayId: 'local' }]
349
+ }]
350
+ });
351
+
352
+ const subnet = await discovery.findPublicSubnets(mockVpcId);
353
+ expect(subnet).toBeNull();
354
+ });
249
355
  });
250
356
 
251
357
  describe('findDefaultSecurityGroup', () => {
@@ -257,14 +363,29 @@ describe('AWSDiscovery', () => {
257
363
  GroupName: 'default'
258
364
  };
259
365
 
260
- ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
261
- SecurityGroups: [mockSecurityGroup]
262
- });
366
+ ec2Mock
367
+ .on(DescribeSecurityGroupsCommand)
368
+ .resolvesOnce({ SecurityGroups: [] })
369
+ .resolves({ SecurityGroups: [mockSecurityGroup] });
263
370
 
264
371
  const sg = await discovery.findDefaultSecurityGroup(mockVpcId);
265
372
  expect(sg).toEqual(mockSecurityGroup);
266
373
  });
267
374
 
375
+ it('should prefer Frigg-managed security group when present', async () => {
376
+ const friggSg = {
377
+ GroupId: 'sg-frigg',
378
+ GroupName: 'frigg-lambda-sg',
379
+ };
380
+
381
+ ec2Mock
382
+ .on(DescribeSecurityGroupsCommand)
383
+ .resolves({ SecurityGroups: [friggSg] });
384
+
385
+ const sg = await discovery.findDefaultSecurityGroup(mockVpcId);
386
+ expect(sg).toEqual(friggSg);
387
+ });
388
+
268
389
  it('should throw error when no default security group found', async () => {
269
390
  ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
270
391
  SecurityGroups: []
@@ -309,6 +430,14 @@ describe('AWSDiscovery', () => {
309
430
  const rt = await discovery.findPrivateRouteTable(mockVpcId);
310
431
  expect(rt).toEqual(mockRouteTable);
311
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
+ });
312
441
  });
313
442
 
314
443
  describe('findDefaultKmsKey', () => {
@@ -340,6 +469,69 @@ describe('AWSDiscovery', () => {
340
469
  const keyArn = await discovery.findDefaultKmsKey();
341
470
  expect(keyArn).toBeNull();
342
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'
483
+ }
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();
496
+ });
497
+
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
+ });
343
535
  });
344
536
 
345
537
  describe('findAvailableElasticIP', () => {
@@ -365,6 +557,32 @@ describe('AWSDiscovery', () => {
365
557
  const eip = await discovery.findAvailableElasticIP();
366
558
  expect(eip).toBeNull();
367
559
  });
560
+
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
+ };
568
+
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();
585
+ });
368
586
  });
369
587
 
370
588
  describe('findExistingNatGateway', () => {
@@ -541,6 +759,197 @@ describe('AWSDiscovery', () => {
541
759
  const result = await discovery.findExistingNatGateway(mockVpcId);
542
760
  expect(result).toBeNull();
543
761
  });
762
+
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([]);
952
+ });
544
953
  });
545
954
 
546
955
  describe('discoverResources', () => {
@@ -664,6 +1073,36 @@ describe('AWSDiscovery', () => {
664
1073
  });
665
1074
  });
666
1075
 
1076
+ it('should reuse available Elastic IP when no NAT Gateway exists and no public subnet is found', async () => {
1077
+ const mockVpc = { VpcId: 'vpc-12345678' };
1078
+ const mockSubnets = [
1079
+ { SubnetId: 'subnet-a' },
1080
+ { SubnetId: 'subnet-b' }
1081
+ ];
1082
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1083
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1084
+ const mockElasticIp = { AllocationId: 'eipalloc-available' };
1085
+
1086
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1087
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1088
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(null);
1089
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1090
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1091
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(null);
1092
+ jest.spyOn(discovery, 'findExistingNatGateway').mockResolvedValue(null);
1093
+ const findAvailableElasticIPSpy = jest
1094
+ .spyOn(discovery, 'findAvailableElasticIP')
1095
+ .mockResolvedValue(mockElasticIp);
1096
+ jest.spyOn(discovery, 'isSubnetPrivate').mockResolvedValue(true);
1097
+
1098
+ const result = await discovery.discoverResources();
1099
+
1100
+ expect(result.publicSubnetId).toBeNull();
1101
+ expect(result.existingNatGatewayId).toBeNull();
1102
+ expect(result.existingElasticIpAllocationId).toBe('eipalloc-available');
1103
+ expect(findAvailableElasticIPSpy).toHaveBeenCalled();
1104
+ });
1105
+
667
1106
  it('should detect NAT Gateway in private subnet in discoverResources', async () => {
668
1107
  const mockVpc = { VpcId: 'vpc-12345678' };
669
1108
  const mockSubnets = [
@@ -740,4 +1179,4 @@ describe('AWSDiscovery', () => {
740
1179
  expect(customDiscovery.region).toBe('us-west-2');
741
1180
  });
742
1181
  });
743
- });
1182
+ });
@@ -76,6 +76,18 @@ function generateIAMCloudFormation(appDefinition, options = {}) {
76
76
  Description:
77
77
  'Enable SSM Parameter Store permissions for Frigg applications',
78
78
  },
79
+ DeploymentKmsAliasName: {
80
+ Type: 'String',
81
+ Default: 'alias/frigg-deployment',
82
+ Description:
83
+ 'Alias name to create or manage for the deployment KMS key',
84
+ },
85
+ DeploymentKmsTargetKeyArn: {
86
+ Type: 'String',
87
+ Default: '',
88
+ Description:
89
+ 'Optional existing KMS key ARN that the deployment alias should reference',
90
+ },
79
91
  },
80
92
 
81
93
  Conditions: {
@@ -88,6 +100,23 @@ function generateIAMCloudFormation(appDefinition, options = {}) {
88
100
  CreateSSMPermissions: {
89
101
  'Fn::Equals': [{ Ref: 'EnableSSMSupport' }, 'true'],
90
102
  },
103
+ CreateKMSAlias: {
104
+ 'Fn::And': [
105
+ {
106
+ 'Fn::Equals': [{ Ref: 'EnableKMSSupport' }, 'true'],
107
+ },
108
+ {
109
+ 'Fn::Not': [
110
+ {
111
+ 'Fn::Equals': [
112
+ { Ref: 'DeploymentKmsTargetKeyArn' },
113
+ '',
114
+ ],
115
+ },
116
+ ],
117
+ },
118
+ ],
119
+ },
91
120
  },
92
121
 
93
122
  Resources: {},
@@ -556,6 +585,7 @@ function generateIAMCloudFormation(appDefinition, options = {}) {
556
585
  'ec2:DescribeRouteTables',
557
586
  'ec2:CreateRoute',
558
587
  'ec2:DeleteRoute',
588
+ 'ec2:ReplaceRoute',
559
589
  'ec2:AssociateRouteTable',
560
590
  'ec2:DisassociateRouteTable',
561
591
  'ec2:CreateSecurityGroup',
@@ -615,6 +645,11 @@ function generateIAMCloudFormation(appDefinition, options = {}) {
615
645
  'kms:TagResource',
616
646
  'kms:UntagResource',
617
647
  'kms:ListResourceTags',
648
+ 'kms:CreateAlias',
649
+ 'kms:UpdateAlias',
650
+ 'kms:DeleteAlias',
651
+ 'kms:ListAliases',
652
+ 'kms:DescribeKey',
618
653
  ],
619
654
  Resource: '*',
620
655
  },
@@ -624,6 +659,17 @@ function generateIAMCloudFormation(appDefinition, options = {}) {
624
659
  };
625
660
  }
626
661
 
662
+ template.Resources.FriggKMSKeyAlias = {
663
+ Type: 'AWS::KMS::Alias',
664
+ Condition: 'CreateKMSAlias',
665
+ DeletionPolicy: 'Retain',
666
+ UpdateReplacePolicy: 'Retain',
667
+ Properties: {
668
+ AliasName: { Ref: 'DeploymentKmsAliasName' },
669
+ TargetKeyId: { Ref: 'DeploymentKmsTargetKeyArn' },
670
+ },
671
+ };
672
+
627
673
  if (features.ssm) {
628
674
  template.Resources.FriggSSMPolicy = {
629
675
  Type: 'AWS::IAM::ManagedPolicy',
@@ -71,6 +71,7 @@ describe('IAM Generator', () => {
71
71
  expect(yaml).toContain('FriggVPCPolicy');
72
72
  expect(yaml).toContain('CreateVPCPermissions');
73
73
  expect(yaml).toContain('EnableVPCSupport');
74
+ expect(yaml).toContain('ec2:ReplaceRoute');
74
75
  });
75
76
 
76
77
  it('should include KMS policy when encryption is enabled', () => {
@@ -85,6 +86,8 @@ describe('IAM Generator', () => {
85
86
  expect(yaml).toContain('FriggKMSPolicy');
86
87
  expect(yaml).toContain('CreateKMSPermissions');
87
88
  expect(yaml).toContain('EnableKMSSupport');
89
+ expect(yaml).toContain('FriggKMSKeyAlias');
90
+ expect(yaml).toContain('kms:CreateAlias');
88
91
  });
89
92
 
90
93
  it('should include SSM policy when SSM is enabled', () => {
@@ -113,9 +116,9 @@ describe('IAM Generator', () => {
113
116
  const yaml = generateIAMCloudFormation(appDefinition);
114
117
 
115
118
  // Check parameter defaults match the enabled features
116
- expect(yaml).toContain('Default: true'); // VPC enabled
117
- expect(yaml).toContain('Default: false'); // KMS disabled
118
- // SSM should be true
119
+ expect(yaml).toContain("Default: 'true'"); // VPC enabled
120
+ expect(yaml).toContain("Default: 'false'"); // KMS disabled
121
+ expect(yaml).toContain('alias/frigg-deployment');
119
122
  });
120
123
 
121
124
  it('should include all core permissions', () => {
@@ -166,4 +169,4 @@ describe('IAM Generator', () => {
166
169
  expect(yaml).toContain('CredentialsSecretArn:');
167
170
  });
168
171
  });
169
- });
172
+ });
@@ -55,6 +55,37 @@ describe('composeServerlessDefinition', () => {
55
55
  jest.restoreAllMocks();
56
56
  // Restore env
57
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
+ });
58
89
  });
59
90
 
60
91
  describe('Basic Configuration', () => {
@@ -124,6 +155,38 @@ describe('composeServerlessDefinition', () => {
124
155
  });
125
156
  });
126
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
+
127
190
  describe('VPC Configuration', () => {
128
191
  it('should add VPC configuration when vpc.enable is true with discover mode', async () => {
129
192
  const appDefinition = {
@@ -261,6 +324,49 @@ describe('composeServerlessDefinition', () => {
261
324
  expect(result.provider.vpc.subnetIds).toEqual(['subnet-explicit1', 'subnet-explicit2']);
262
325
  });
263
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
+
264
370
  it('should use Fn::Cidr for subnet CIDR blocks in new VPC to avoid conflicts', async () => {
265
371
  const appDefinition = {
266
372
  vpc: {
@@ -357,6 +463,39 @@ describe('composeServerlessDefinition', () => {
357
463
  expect(result.resources.Resources.VPCEndpointS3.Properties.VpcId).toBe('vpc-123456');
358
464
  });
359
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
+
360
499
  it('should allow Lambda security group access for VPC endpoints when security group is discovered', async () => {
361
500
  const appDefinition = {
362
501
  vpc: {
@@ -511,6 +650,114 @@ describe('composeServerlessDefinition', () => {
511
650
  });
512
651
  });
513
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
+
514
761
  describe('KMS Configuration', () => {
515
762
  it('should add KMS configuration when encryption is enabled and key is found', async () => {
516
763
  const appDefinition = {
@@ -913,6 +1160,27 @@ describe('composeServerlessDefinition', () => {
913
1160
  });
914
1161
  });
915
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
+
916
1184
  describe('NAT Gateway Management', () => {
917
1185
  it('should handle NAT Gateway with createAndManage mode', async () => {
918
1186
  const appDefinition = {
@@ -932,6 +1200,24 @@ describe('composeServerlessDefinition', () => {
932
1200
  expect(result.resources.Resources.FriggNATRoute).toBeDefined();
933
1201
  });
934
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
+
935
1221
  it('should handle NAT Gateway with discover mode', async () => {
936
1222
  // Mock discovery to return existing NAT Gateway
937
1223
  const { AWSDiscovery } = require('./aws-discovery');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.428.2b9210c.1",
4
+ "version": "2.0.0--canary.428.d54dca5.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -9,8 +9,8 @@
9
9
  "@babel/eslint-parser": "^7.18.9",
10
10
  "@babel/parser": "^7.25.3",
11
11
  "@babel/traverse": "^7.25.3",
12
- "@friggframework/schemas": "2.0.0--canary.428.2b9210c.1",
13
- "@friggframework/test": "2.0.0--canary.428.2b9210c.1",
12
+ "@friggframework/schemas": "2.0.0--canary.428.d54dca5.0",
13
+ "@friggframework/test": "2.0.0--canary.428.d54dca5.0",
14
14
  "@hapi/boom": "^10.0.1",
15
15
  "@inquirer/prompts": "^5.3.8",
16
16
  "axios": "^1.7.2",
@@ -32,8 +32,8 @@
32
32
  "serverless-http": "^2.7.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@friggframework/eslint-config": "2.0.0--canary.428.2b9210c.1",
36
- "@friggframework/prettier-config": "2.0.0--canary.428.2b9210c.1",
35
+ "@friggframework/eslint-config": "2.0.0--canary.428.d54dca5.0",
36
+ "@friggframework/prettier-config": "2.0.0--canary.428.d54dca5.0",
37
37
  "aws-sdk-client-mock": "^4.1.0",
38
38
  "aws-sdk-client-mock-jest": "^4.1.0",
39
39
  "jest": "^30.1.3",
@@ -68,5 +68,5 @@
68
68
  "publishConfig": {
69
69
  "access": "public"
70
70
  },
71
- "gitHead": "2b9210c7401ddd6baacac247aff11e3f73fcb315"
71
+ "gitHead": "d54dca5ba151e72370d6f82b4f841e2a8e5934c1"
72
72
  }