@friggframework/devtools 2.0.0-next.4 → 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.
Files changed (197) hide show
  1. package/frigg-cli/.eslintrc.js +141 -0
  2. package/frigg-cli/__tests__/jest.config.js +102 -0
  3. package/frigg-cli/__tests__/unit/commands/build.test.js +483 -0
  4. package/frigg-cli/__tests__/unit/commands/install.test.js +418 -0
  5. package/frigg-cli/__tests__/unit/commands/ui.test.js +592 -0
  6. package/frigg-cli/__tests__/utils/command-tester.js +170 -0
  7. package/frigg-cli/__tests__/utils/mock-factory.js +270 -0
  8. package/frigg-cli/__tests__/utils/test-fixtures.js +463 -0
  9. package/frigg-cli/__tests__/utils/test-setup.js +286 -0
  10. package/frigg-cli/build-command/index.js +54 -0
  11. package/frigg-cli/deploy-command/index.js +175 -0
  12. package/frigg-cli/generate-command/__tests__/generate-command.test.js +312 -0
  13. package/frigg-cli/generate-command/azure-generator.js +43 -0
  14. package/frigg-cli/generate-command/gcp-generator.js +47 -0
  15. package/frigg-cli/generate-command/index.js +332 -0
  16. package/frigg-cli/generate-command/terraform-generator.js +555 -0
  17. package/frigg-cli/generate-iam-command.js +115 -0
  18. package/frigg-cli/index.js +47 -1
  19. package/frigg-cli/index.test.js +1 -4
  20. package/frigg-cli/init-command/backend-first-handler.js +756 -0
  21. package/frigg-cli/init-command/index.js +93 -0
  22. package/frigg-cli/init-command/template-handler.js +143 -0
  23. package/frigg-cli/install-command/index.js +1 -4
  24. package/frigg-cli/package.json +51 -0
  25. package/frigg-cli/start-command/index.js +24 -4
  26. package/frigg-cli/test/init-command.test.js +180 -0
  27. package/frigg-cli/test/npm-registry.test.js +319 -0
  28. package/frigg-cli/ui-command/index.js +154 -0
  29. package/frigg-cli/utils/app-resolver.js +319 -0
  30. package/frigg-cli/utils/backend-path.js +16 -17
  31. package/frigg-cli/utils/npm-registry.js +167 -0
  32. package/frigg-cli/utils/process-manager.js +199 -0
  33. package/frigg-cli/utils/repo-detection.js +405 -0
  34. package/infrastructure/DEPLOYMENT-INSTRUCTIONS.md +268 -0
  35. package/infrastructure/GENERATE-IAM-DOCS.md +278 -0
  36. package/infrastructure/IAM-POLICY-TEMPLATES.md +176 -0
  37. package/infrastructure/README.md +443 -0
  38. package/infrastructure/WEBSOCKET-CONFIGURATION.md +105 -0
  39. package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
  40. package/infrastructure/__tests__/helpers/test-utils.js +277 -0
  41. package/infrastructure/aws-discovery.js +1176 -0
  42. package/infrastructure/aws-discovery.test.js +1220 -0
  43. package/infrastructure/build-time-discovery.js +206 -0
  44. package/infrastructure/build-time-discovery.test.js +378 -0
  45. package/infrastructure/create-frigg-infrastructure.js +3 -5
  46. package/infrastructure/env-validator.js +77 -0
  47. package/infrastructure/frigg-deployment-iam-stack.yaml +401 -0
  48. package/infrastructure/iam-generator.js +836 -0
  49. package/infrastructure/iam-generator.test.js +172 -0
  50. package/infrastructure/iam-policy-basic.json +218 -0
  51. package/infrastructure/iam-policy-full.json +288 -0
  52. package/infrastructure/integration.test.js +383 -0
  53. package/infrastructure/run-discovery.js +110 -0
  54. package/infrastructure/serverless-template.js +1472 -138
  55. package/infrastructure/serverless-template.test.js +1759 -0
  56. package/management-ui/.eslintrc.js +22 -0
  57. package/management-ui/README.md +203 -0
  58. package/management-ui/components.json +21 -0
  59. package/management-ui/docs/phase2-integration-guide.md +320 -0
  60. package/management-ui/index.html +13 -0
  61. package/management-ui/package-lock.json +16517 -0
  62. package/management-ui/package.json +76 -0
  63. package/management-ui/packages/devtools/frigg-cli/ui-command/index.js +302 -0
  64. package/management-ui/postcss.config.js +6 -0
  65. package/management-ui/server/api/backend.js +256 -0
  66. package/management-ui/server/api/cli.js +315 -0
  67. package/management-ui/server/api/codegen.js +663 -0
  68. package/management-ui/server/api/connections.js +857 -0
  69. package/management-ui/server/api/discovery.js +185 -0
  70. package/management-ui/server/api/environment/index.js +1 -0
  71. package/management-ui/server/api/environment/router.js +378 -0
  72. package/management-ui/server/api/environment.js +328 -0
  73. package/management-ui/server/api/integrations.js +876 -0
  74. package/management-ui/server/api/logs.js +248 -0
  75. package/management-ui/server/api/monitoring.js +282 -0
  76. package/management-ui/server/api/open-ide.js +31 -0
  77. package/management-ui/server/api/project.js +1029 -0
  78. package/management-ui/server/api/users/sessions.js +371 -0
  79. package/management-ui/server/api/users/simulation.js +254 -0
  80. package/management-ui/server/api/users.js +362 -0
  81. package/management-ui/server/api-contract.md +275 -0
  82. package/management-ui/server/index.js +873 -0
  83. package/management-ui/server/middleware/errorHandler.js +93 -0
  84. package/management-ui/server/middleware/security.js +32 -0
  85. package/management-ui/server/processManager.js +296 -0
  86. package/management-ui/server/server.js +346 -0
  87. package/management-ui/server/services/aws-monitor.js +413 -0
  88. package/management-ui/server/services/npm-registry.js +347 -0
  89. package/management-ui/server/services/template-engine.js +538 -0
  90. package/management-ui/server/utils/cliIntegration.js +220 -0
  91. package/management-ui/server/utils/environment/auditLogger.js +471 -0
  92. package/management-ui/server/utils/environment/awsParameterStore.js +264 -0
  93. package/management-ui/server/utils/environment/encryption.js +278 -0
  94. package/management-ui/server/utils/environment/envFileManager.js +286 -0
  95. package/management-ui/server/utils/import-commonjs.js +28 -0
  96. package/management-ui/server/utils/response.js +83 -0
  97. package/management-ui/server/websocket/handler.js +325 -0
  98. package/management-ui/src/App.jsx +109 -0
  99. package/management-ui/src/assets/FriggLogo.svg +1 -0
  100. package/management-ui/src/components/AppRouter.jsx +65 -0
  101. package/management-ui/src/components/Button.jsx +70 -0
  102. package/management-ui/src/components/Card.jsx +97 -0
  103. package/management-ui/src/components/EnvironmentCompare.jsx +400 -0
  104. package/management-ui/src/components/EnvironmentEditor.jsx +372 -0
  105. package/management-ui/src/components/EnvironmentImportExport.jsx +469 -0
  106. package/management-ui/src/components/EnvironmentSchema.jsx +491 -0
  107. package/management-ui/src/components/EnvironmentSecurity.jsx +463 -0
  108. package/management-ui/src/components/ErrorBoundary.jsx +73 -0
  109. package/management-ui/src/components/IntegrationCard.jsx +481 -0
  110. package/management-ui/src/components/IntegrationCardEnhanced.jsx +770 -0
  111. package/management-ui/src/components/IntegrationExplorer.jsx +379 -0
  112. package/management-ui/src/components/IntegrationStatus.jsx +336 -0
  113. package/management-ui/src/components/Layout.jsx +716 -0
  114. package/management-ui/src/components/LoadingSpinner.jsx +113 -0
  115. package/management-ui/src/components/RepositoryPicker.jsx +248 -0
  116. package/management-ui/src/components/SessionMonitor.jsx +350 -0
  117. package/management-ui/src/components/StatusBadge.jsx +208 -0
  118. package/management-ui/src/components/UserContextSwitcher.jsx +212 -0
  119. package/management-ui/src/components/UserSimulation.jsx +327 -0
  120. package/management-ui/src/components/Welcome.jsx +434 -0
  121. package/management-ui/src/components/codegen/APIEndpointGenerator.jsx +637 -0
  122. package/management-ui/src/components/codegen/APIModuleSelector.jsx +227 -0
  123. package/management-ui/src/components/codegen/CodeGenerationWizard.jsx +247 -0
  124. package/management-ui/src/components/codegen/CodePreviewEditor.jsx +316 -0
  125. package/management-ui/src/components/codegen/DynamicModuleForm.jsx +271 -0
  126. package/management-ui/src/components/codegen/FormBuilder.jsx +737 -0
  127. package/management-ui/src/components/codegen/IntegrationGenerator.jsx +855 -0
  128. package/management-ui/src/components/codegen/ProjectScaffoldWizard.jsx +797 -0
  129. package/management-ui/src/components/codegen/SchemaBuilder.jsx +303 -0
  130. package/management-ui/src/components/codegen/TemplateSelector.jsx +586 -0
  131. package/management-ui/src/components/codegen/index.js +10 -0
  132. package/management-ui/src/components/connections/ConnectionConfigForm.jsx +362 -0
  133. package/management-ui/src/components/connections/ConnectionHealthMonitor.jsx +182 -0
  134. package/management-ui/src/components/connections/ConnectionTester.jsx +200 -0
  135. package/management-ui/src/components/connections/EntityRelationshipMapper.jsx +292 -0
  136. package/management-ui/src/components/connections/OAuthFlow.jsx +204 -0
  137. package/management-ui/src/components/connections/index.js +5 -0
  138. package/management-ui/src/components/index.js +21 -0
  139. package/management-ui/src/components/monitoring/APIGatewayMetrics.jsx +222 -0
  140. package/management-ui/src/components/monitoring/LambdaMetrics.jsx +169 -0
  141. package/management-ui/src/components/monitoring/MetricsChart.jsx +197 -0
  142. package/management-ui/src/components/monitoring/MonitoringDashboard.jsx +393 -0
  143. package/management-ui/src/components/monitoring/SQSMetrics.jsx +246 -0
  144. package/management-ui/src/components/monitoring/index.js +6 -0
  145. package/management-ui/src/components/monitoring/monitoring.css +218 -0
  146. package/management-ui/src/components/theme-provider.jsx +52 -0
  147. package/management-ui/src/components/theme-toggle.jsx +39 -0
  148. package/management-ui/src/components/ui/badge.tsx +36 -0
  149. package/management-ui/src/components/ui/button.test.jsx +56 -0
  150. package/management-ui/src/components/ui/button.tsx +57 -0
  151. package/management-ui/src/components/ui/card.tsx +76 -0
  152. package/management-ui/src/components/ui/dropdown-menu.tsx +199 -0
  153. package/management-ui/src/components/ui/select.tsx +157 -0
  154. package/management-ui/src/components/ui/skeleton.jsx +15 -0
  155. package/management-ui/src/hooks/useFrigg.jsx +601 -0
  156. package/management-ui/src/hooks/useSocket.jsx +58 -0
  157. package/management-ui/src/index.css +193 -0
  158. package/management-ui/src/lib/utils.ts +6 -0
  159. package/management-ui/src/main.jsx +10 -0
  160. package/management-ui/src/pages/CodeGeneration.jsx +14 -0
  161. package/management-ui/src/pages/Connections.jsx +252 -0
  162. package/management-ui/src/pages/ConnectionsEnhanced.jsx +633 -0
  163. package/management-ui/src/pages/Dashboard.jsx +311 -0
  164. package/management-ui/src/pages/Environment.jsx +314 -0
  165. package/management-ui/src/pages/IntegrationConfigure.jsx +669 -0
  166. package/management-ui/src/pages/IntegrationDiscovery.jsx +567 -0
  167. package/management-ui/src/pages/IntegrationTest.jsx +742 -0
  168. package/management-ui/src/pages/Integrations.jsx +253 -0
  169. package/management-ui/src/pages/Monitoring.jsx +17 -0
  170. package/management-ui/src/pages/Simulation.jsx +155 -0
  171. package/management-ui/src/pages/Users.jsx +492 -0
  172. package/management-ui/src/services/api.js +41 -0
  173. package/management-ui/src/services/apiModuleService.js +193 -0
  174. package/management-ui/src/services/websocket-handlers.js +120 -0
  175. package/management-ui/src/test/api/project.test.js +273 -0
  176. package/management-ui/src/test/components/Welcome.test.jsx +378 -0
  177. package/management-ui/src/test/mocks/server.js +178 -0
  178. package/management-ui/src/test/setup.js +61 -0
  179. package/management-ui/src/test/utils/test-utils.jsx +134 -0
  180. package/management-ui/src/utils/repository.js +98 -0
  181. package/management-ui/src/utils/repository.test.js +118 -0
  182. package/management-ui/src/workflows/phase2-integration-workflows.js +884 -0
  183. package/management-ui/tailwind.config.js +63 -0
  184. package/management-ui/tsconfig.json +37 -0
  185. package/management-ui/tsconfig.node.json +10 -0
  186. package/management-ui/vite.config.js +26 -0
  187. package/management-ui/vitest.config.js +38 -0
  188. package/package.json +20 -9
  189. package/infrastructure/app-handler-helpers.js +0 -57
  190. package/infrastructure/backend-utils.js +0 -90
  191. package/infrastructure/routers/auth.js +0 -26
  192. package/infrastructure/routers/integration-defined-routers.js +0 -37
  193. package/infrastructure/routers/middleware/loadUser.js +0 -15
  194. package/infrastructure/routers/middleware/requireLoggedInUser.js +0 -12
  195. package/infrastructure/routers/user.js +0 -41
  196. package/infrastructure/routers/websocket.js +0 -55
  197. package/infrastructure/workers/integration-defined-workers.js +0 -24
@@ -0,0 +1,1220 @@
1
+ const { mockClient } = require('aws-sdk-client-mock');
2
+ const { AWSDiscovery } = require('./aws-discovery');
3
+
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);
29
+
30
+ describe('AWSDiscovery', () => {
31
+ let discovery;
32
+
33
+ beforeEach(() => {
34
+ // Reset all mocks before each test
35
+ ec2Mock.reset();
36
+ kmsMock.reset();
37
+ stsMock.reset();
38
+
39
+ discovery = new AWSDiscovery('us-east-1');
40
+ });
41
+
42
+ describe('getAccountId', () => {
43
+ it('should return AWS account ID', async () => {
44
+ const mockAccountId = '123456789012';
45
+ stsMock.on(GetCallerIdentityCommand).resolves({
46
+ Account: mockAccountId
47
+ });
48
+
49
+ const accountId = await discovery.getAccountId();
50
+ expect(accountId).toBe(mockAccountId);
51
+ });
52
+
53
+ it('should throw error when STS call fails', async () => {
54
+ stsMock.on(GetCallerIdentityCommand).rejects(new Error('STS error'));
55
+
56
+ await expect(discovery.getAccountId()).rejects.toThrow('STS error');
57
+ });
58
+ });
59
+
60
+ describe('findDefaultVpc', () => {
61
+ it('should return default VPC when available', async () => {
62
+ const mockVpc = { VpcId: 'vpc-12345678', IsDefault: true };
63
+ ec2Mock.on(DescribeVpcsCommand).resolves({
64
+ Vpcs: [mockVpc]
65
+ });
66
+
67
+ const vpc = await discovery.findDefaultVpc();
68
+ expect(vpc).toEqual(mockVpc);
69
+ });
70
+
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
+ });
76
+
77
+ const vpc = await discovery.findDefaultVpc();
78
+ expect(vpc).toEqual(mockVpc);
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
+
93
+ it('should throw error when no VPCs found', async () => {
94
+ ec2Mock.on(DescribeVpcsCommand).resolves({
95
+ Vpcs: []
96
+ });
97
+
98
+ await expect(discovery.findDefaultVpc()).rejects.toThrow('No VPC found in the account');
99
+ });
100
+ });
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
+
178
+ describe('findPrivateSubnets', () => {
179
+ const mockVpcId = 'vpc-12345678';
180
+
181
+ it('should return private subnets', async () => {
182
+ const mockSubnets = [
183
+ { SubnetId: 'subnet-private-1', AvailabilityZone: 'us-east-1a' },
184
+ { SubnetId: 'subnet-private-2', AvailabilityZone: 'us-east-1b' }
185
+ ];
186
+
187
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
188
+ Subnets: mockSubnets
189
+ });
190
+
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
+ });
203
+
204
+ const subnets = await discovery.findPrivateSubnets(mockVpcId);
205
+ expect(subnets).toEqual(mockSubnets);
206
+ });
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
+
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
+ ];
234
+
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');
255
+ });
256
+
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
+ });
266
+
267
+ // Mock route tables - has IGW routes (public)
268
+ ec2Mock.on(DescribeRouteTablesCommand).resolves({
269
+ RouteTables: [{
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
+ ]
277
+ }]
278
+ });
279
+
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: [] });
304
+
305
+ await expect(discovery.findPrivateSubnets(mockVpcId)).rejects.toThrow(
306
+ `No subnets found in VPC ${mockVpcId}`
307
+ );
308
+ });
309
+ });
310
+
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({
322
+ RouteTables: [{
323
+ Associations: [{ SubnetId: 'subnet-public-1' }],
324
+ Routes: [{ GatewayId: 'igw-12345' }] // Has IGW = public
325
+ }]
326
+ });
327
+
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
+ });
336
+
337
+ await expect(discovery.findPublicSubnets(mockVpcId))
338
+ .rejects.toThrow('No subnets found in VPC');
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
+ });
355
+ });
356
+
357
+ describe('findDefaultSecurityGroup', () => {
358
+ const mockVpcId = 'vpc-12345678';
359
+
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',
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
+
389
+ it('should throw error when no default security group found', async () => {
390
+ ec2Mock.on(DescribeSecurityGroupsCommand).resolves({
391
+ SecurityGroups: []
392
+ });
393
+
394
+ await expect(discovery.findDefaultSecurityGroup(mockVpcId))
395
+ .rejects.toThrow('No security group found for VPC');
396
+ });
397
+ });
398
+
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'
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
+ });
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'
542
+ };
543
+
544
+ ec2Mock.on(DescribeAddressesCommand).resolves({
545
+ Addresses: [mockElasticIP]
546
+ });
547
+
548
+ const eip = await discovery.findAvailableElasticIP();
549
+ expect(eip).toEqual(mockElasticIP);
550
+ });
551
+
552
+ it('should return null when no available Elastic IPs', async () => {
553
+ ec2Mock.on(DescribeAddressesCommand).resolves({
554
+ Addresses: []
555
+ });
556
+
557
+ const eip = await discovery.findAvailableElasticIP();
558
+ expect(eip).toBeNull();
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
+ });
586
+ });
587
+
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
+ });
682
+
683
+ // First call for subnet-private-1
684
+ ec2Mock.on(DescribeSubnetsCommand)
685
+ .resolvesOnce({
686
+ Subnets: [{ SubnetId: 'subnet-private-1', VpcId: mockVpcId }]
687
+ })
688
+ // Second call for subnet-public-1
689
+ .resolvesOnce({
690
+ Subnets: [{ SubnetId: 'subnet-public-1', VpcId: mockVpcId }]
691
+ });
692
+
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
+ });
708
+
709
+ const result = await discovery.findExistingNatGateway(mockVpcId);
710
+
711
+ expect(result).toBeDefined();
712
+ expect(result.NatGatewayId).toBe('nat-good-12345');
713
+ expect(result._isInPrivateSubnet).toBe(false);
714
+ });
715
+
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
+ });
735
+
736
+ ec2Mock.on(DescribeSubnetsCommand).resolves({
737
+ Subnets: [{ SubnetId: 'subnet-public-2', VpcId: mockVpcId }]
738
+ });
739
+
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);
752
+ });
753
+
754
+ it('should return null when no NAT Gateways found', async () => {
755
+ ec2Mock.on(DescribeNatGatewaysCommand).resolves({
756
+ NatGateways: []
757
+ });
758
+
759
+ const result = await discovery.findExistingNatGateway(mockVpcId);
760
+ expect(result).toBeNull();
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
+ });
953
+ });
954
+
955
+ describe('discoverResources', () => {
956
+ it('should discover all AWS resources successfully', async () => {
957
+ const mockVpc = { VpcId: 'vpc-12345678' };
958
+ const mockSubnets = [
959
+ { SubnetId: 'subnet-1' },
960
+ { SubnetId: 'subnet-2' }
961
+ ];
962
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
963
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
964
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
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
+ };
972
+
973
+ // Mock all the discovery methods
974
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
975
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
976
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
977
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
978
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
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
984
+
985
+ const result = await discovery.discoverResources();
986
+
987
+ expect(result).toMatchObject({
988
+ defaultVpcId: 'vpc-12345678',
989
+ defaultSecurityGroupId: 'sg-12345678',
990
+ privateSubnetId1: 'subnet-1',
991
+ privateSubnetId2: 'subnet-2',
992
+ publicSubnetId: 'subnet-public-1',
993
+ privateRouteTableId: 'rtb-12345678',
994
+ defaultKmsKeyId: mockKmsArn,
995
+ existingNatGatewayId: 'nat-12345678',
996
+ existingElasticIpAllocationId: 'eipalloc-12345',
997
+ natGatewayInPrivateSubnet: false,
998
+ subnetConversionRequired: false,
999
+ privateSubnetsWithWrongRoutes: []
1000
+ });
1001
+
1002
+ // Verify all methods were called
1003
+ expect(discovery.findDefaultVpc).toHaveBeenCalled();
1004
+ expect(discovery.findPrivateSubnets).toHaveBeenCalledWith('vpc-12345678', false);
1005
+ expect(discovery.findPublicSubnets).toHaveBeenCalledWith('vpc-12345678');
1006
+ expect(discovery.findDefaultSecurityGroup).toHaveBeenCalledWith('vpc-12345678');
1007
+ expect(discovery.findPrivateRouteTable).toHaveBeenCalledWith('vpc-12345678');
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
+ });
1179
+ });
1180
+
1181
+ it('should handle single subnet scenario', async () => {
1182
+ const mockVpc = { VpcId: 'vpc-12345678' };
1183
+ const mockSubnets = [{ SubnetId: 'subnet-1' }]; // Only one subnet
1184
+ const mockPublicSubnet = { SubnetId: 'subnet-public-1' };
1185
+ const mockSecurityGroup = { GroupId: 'sg-12345678' };
1186
+ const mockRouteTable = { RouteTableId: 'rtb-12345678' };
1187
+ const mockKmsArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678';
1188
+
1189
+ jest.spyOn(discovery, 'findDefaultVpc').mockResolvedValue(mockVpc);
1190
+ jest.spyOn(discovery, 'findPrivateSubnets').mockResolvedValue(mockSubnets);
1191
+ jest.spyOn(discovery, 'findPublicSubnets').mockResolvedValue(mockPublicSubnet);
1192
+ jest.spyOn(discovery, 'findDefaultSecurityGroup').mockResolvedValue(mockSecurityGroup);
1193
+ jest.spyOn(discovery, 'findPrivateRouteTable').mockResolvedValue(mockRouteTable);
1194
+ jest.spyOn(discovery, 'findDefaultKmsKey').mockResolvedValue(mockKmsArn);
1195
+
1196
+ const result = await discovery.discoverResources();
1197
+
1198
+ expect(result.privateSubnetId1).toBe('subnet-1');
1199
+ expect(result.privateSubnetId2).toBe('subnet-1'); // Should duplicate single subnet
1200
+ });
1201
+
1202
+ it('should throw error when discovery fails', async () => {
1203
+ jest.spyOn(discovery, 'findDefaultVpc').mockRejectedValue(new Error('VPC discovery failed'));
1204
+
1205
+ await expect(discovery.discoverResources()).rejects.toThrow('VPC discovery failed');
1206
+ });
1207
+ });
1208
+
1209
+ describe('constructor', () => {
1210
+ it('should initialize with default region', () => {
1211
+ const defaultDiscovery = new AWSDiscovery();
1212
+ expect(defaultDiscovery.region).toBe('us-east-1');
1213
+ });
1214
+
1215
+ it('should initialize with custom region', () => {
1216
+ const customDiscovery = new AWSDiscovery('us-west-2');
1217
+ expect(customDiscovery.region).toBe('us-west-2');
1218
+ });
1219
+ });
1220
+ });