@friggframework/devtools 2.0.0-next.8 → 2.0.0-next.81

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 (240) hide show
  1. package/frigg-cli/README.md +1289 -0
  2. package/frigg-cli/__tests__/unit/commands/build.test.js +279 -0
  3. package/frigg-cli/__tests__/unit/commands/db-setup.test.js +649 -0
  4. package/frigg-cli/__tests__/unit/commands/deploy.test.js +320 -0
  5. package/frigg-cli/__tests__/unit/commands/doctor.test.js +309 -0
  6. package/frigg-cli/__tests__/unit/commands/install.test.js +400 -0
  7. package/frigg-cli/__tests__/unit/commands/ui.test.js +346 -0
  8. package/frigg-cli/__tests__/unit/dependencies.test.js +74 -0
  9. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +397 -0
  10. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +345 -0
  11. package/frigg-cli/__tests__/unit/version-detection.test.js +171 -0
  12. package/frigg-cli/__tests__/utils/mock-factory.js +270 -0
  13. package/frigg-cli/__tests__/utils/prisma-mock.js +194 -0
  14. package/frigg-cli/__tests__/utils/test-fixtures.js +463 -0
  15. package/frigg-cli/__tests__/utils/test-setup.js +287 -0
  16. package/frigg-cli/auth-command/CLAUDE.md +293 -0
  17. package/frigg-cli/auth-command/README.md +450 -0
  18. package/frigg-cli/auth-command/api-key-flow.js +153 -0
  19. package/frigg-cli/auth-command/auth-tester.js +344 -0
  20. package/frigg-cli/auth-command/credential-storage.js +182 -0
  21. package/frigg-cli/auth-command/index.js +256 -0
  22. package/frigg-cli/auth-command/json-schema-form.js +67 -0
  23. package/frigg-cli/auth-command/module-loader.js +172 -0
  24. package/frigg-cli/auth-command/oauth-callback-server.js +431 -0
  25. package/frigg-cli/auth-command/oauth-flow.js +195 -0
  26. package/frigg-cli/auth-command/utils/browser.js +30 -0
  27. package/frigg-cli/build-command/index.js +45 -12
  28. package/frigg-cli/db-setup-command/index.js +246 -0
  29. package/frigg-cli/deploy-command/SPEC-DEPLOY-DRY-RUN.md +981 -0
  30. package/frigg-cli/deploy-command/index.js +295 -23
  31. package/frigg-cli/doctor-command/index.js +335 -0
  32. package/frigg-cli/generate-command/__tests__/generate-command.test.js +301 -0
  33. package/frigg-cli/generate-command/azure-generator.js +43 -0
  34. package/frigg-cli/generate-command/gcp-generator.js +47 -0
  35. package/frigg-cli/generate-command/index.js +332 -0
  36. package/frigg-cli/generate-command/terraform-generator.js +555 -0
  37. package/frigg-cli/generate-iam-command.js +118 -0
  38. package/frigg-cli/index.js +174 -1
  39. package/frigg-cli/index.test.js +1 -4
  40. package/frigg-cli/init-command/backend-first-handler.js +756 -0
  41. package/frigg-cli/init-command/index.js +93 -0
  42. package/frigg-cli/init-command/template-handler.js +143 -0
  43. package/frigg-cli/install-command/index.js +1 -4
  44. package/frigg-cli/jest.config.js +124 -0
  45. package/frigg-cli/package.json +63 -0
  46. package/frigg-cli/repair-command/index.js +564 -0
  47. package/frigg-cli/start-command/index.js +118 -5
  48. package/frigg-cli/start-command/start-command.test.js +297 -0
  49. package/frigg-cli/test/init-command.test.js +180 -0
  50. package/frigg-cli/test/npm-registry.test.js +319 -0
  51. package/frigg-cli/ui-command/index.js +154 -0
  52. package/frigg-cli/utils/app-resolver.js +319 -0
  53. package/frigg-cli/utils/backend-path.js +16 -17
  54. package/frigg-cli/utils/database-validator.js +167 -0
  55. package/frigg-cli/utils/error-messages.js +329 -0
  56. package/frigg-cli/utils/npm-registry.js +167 -0
  57. package/frigg-cli/utils/process-manager.js +199 -0
  58. package/frigg-cli/utils/repo-detection.js +405 -0
  59. package/infrastructure/ARCHITECTURE.md +487 -0
  60. package/infrastructure/CLAUDE.md +481 -0
  61. package/infrastructure/HEALTH.md +468 -0
  62. package/infrastructure/README.md +522 -0
  63. package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
  64. package/infrastructure/__tests__/helpers/test-utils.js +277 -0
  65. package/infrastructure/__tests__/postgres-config.test.js +914 -0
  66. package/infrastructure/__tests__/template-generation.test.js +687 -0
  67. package/infrastructure/create-frigg-infrastructure.js +129 -20
  68. package/infrastructure/docs/POSTGRES-CONFIGURATION.md +630 -0
  69. package/infrastructure/docs/PRE-DEPLOYMENT-HEALTH-CHECK-SPEC.md +1317 -0
  70. package/infrastructure/docs/WEBSOCKET-CONFIGURATION.md +105 -0
  71. package/infrastructure/docs/deployment-instructions.md +268 -0
  72. package/infrastructure/docs/generate-iam-command.md +278 -0
  73. package/infrastructure/docs/iam-policy-templates.md +193 -0
  74. package/infrastructure/domains/database/aurora-builder.js +857 -0
  75. package/infrastructure/domains/database/aurora-builder.test.js +960 -0
  76. package/infrastructure/domains/database/aurora-discovery.js +87 -0
  77. package/infrastructure/domains/database/aurora-discovery.test.js +188 -0
  78. package/infrastructure/domains/database/aurora-resolver.js +210 -0
  79. package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
  80. package/infrastructure/domains/database/migration-builder.js +701 -0
  81. package/infrastructure/domains/database/migration-builder.test.js +321 -0
  82. package/infrastructure/domains/database/migration-resolver.js +163 -0
  83. package/infrastructure/domains/database/migration-resolver.test.js +337 -0
  84. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  85. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  86. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  87. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  88. package/infrastructure/domains/health/application/ports/index.js +26 -0
  89. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  90. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  91. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  92. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  93. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +152 -0
  94. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
  95. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +535 -0
  96. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
  97. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +213 -0
  98. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
  99. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  100. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  101. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  102. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  103. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  104. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  105. package/infrastructure/domains/health/domain/entities/issue.js +299 -0
  106. package/infrastructure/domains/health/domain/entities/issue.test.js +528 -0
  107. package/infrastructure/domains/health/domain/entities/property-mismatch.js +108 -0
  108. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +275 -0
  109. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  110. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  111. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  112. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  113. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  114. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  115. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  116. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  117. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  118. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  119. package/infrastructure/domains/health/domain/services/health-score-calculator.js +248 -0
  120. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +504 -0
  121. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  122. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  123. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  124. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  125. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  126. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  127. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  128. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  129. package/infrastructure/domains/health/domain/value-objects/health-score.js +138 -0
  130. package/infrastructure/domains/health/domain/value-objects/health-score.test.js +267 -0
  131. package/infrastructure/domains/health/domain/value-objects/property-mutability.js +161 -0
  132. package/infrastructure/domains/health/domain/value-objects/property-mutability.test.js +198 -0
  133. package/infrastructure/domains/health/domain/value-objects/resource-state.js +167 -0
  134. package/infrastructure/domains/health/domain/value-objects/resource-state.test.js +196 -0
  135. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +192 -0
  136. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +262 -0
  137. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  138. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  139. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  140. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +784 -0
  141. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +1133 -0
  142. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +565 -0
  143. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +554 -0
  144. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.js +318 -0
  145. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.test.js +398 -0
  146. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +777 -0
  147. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
  148. package/infrastructure/domains/integration/integration-builder.js +547 -0
  149. package/infrastructure/domains/integration/integration-builder.test.js +798 -0
  150. package/infrastructure/domains/integration/integration-resolver.js +170 -0
  151. package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
  152. package/infrastructure/domains/integration/websocket-builder.js +69 -0
  153. package/infrastructure/domains/integration/websocket-builder.test.js +195 -0
  154. package/infrastructure/domains/networking/vpc-builder.js +2051 -0
  155. package/infrastructure/domains/networking/vpc-builder.test.js +1960 -0
  156. package/infrastructure/domains/networking/vpc-discovery.js +177 -0
  157. package/infrastructure/domains/networking/vpc-discovery.test.js +350 -0
  158. package/infrastructure/domains/networking/vpc-resolver.js +505 -0
  159. package/infrastructure/domains/networking/vpc-resolver.test.js +801 -0
  160. package/infrastructure/domains/parameters/ssm-builder.js +79 -0
  161. package/infrastructure/domains/parameters/ssm-builder.test.js +189 -0
  162. package/infrastructure/domains/parameters/ssm-discovery.js +84 -0
  163. package/infrastructure/domains/parameters/ssm-discovery.test.js +210 -0
  164. package/infrastructure/domains/scheduler/scheduler-builder.js +211 -0
  165. package/infrastructure/domains/security/iam-generator.js +816 -0
  166. package/infrastructure/domains/security/iam-generator.test.js +204 -0
  167. package/infrastructure/domains/security/kms-builder.js +415 -0
  168. package/infrastructure/domains/security/kms-builder.test.js +392 -0
  169. package/infrastructure/domains/security/kms-discovery.js +80 -0
  170. package/infrastructure/domains/security/kms-discovery.test.js +177 -0
  171. package/infrastructure/domains/security/kms-resolver.js +96 -0
  172. package/infrastructure/domains/security/kms-resolver.test.js +216 -0
  173. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +401 -0
  174. package/infrastructure/domains/security/templates/iam-policy-basic.json +218 -0
  175. package/infrastructure/domains/security/templates/iam-policy-full.json +288 -0
  176. package/infrastructure/domains/shared/base-builder.js +112 -0
  177. package/infrastructure/domains/shared/base-resolver.js +186 -0
  178. package/infrastructure/domains/shared/base-resolver.test.js +305 -0
  179. package/infrastructure/domains/shared/builder-orchestrator.js +212 -0
  180. package/infrastructure/domains/shared/builder-orchestrator.test.js +213 -0
  181. package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
  182. package/infrastructure/domains/shared/cloudformation-discovery.js +681 -0
  183. package/infrastructure/domains/shared/cloudformation-discovery.test.js +1320 -0
  184. package/infrastructure/domains/shared/environment-builder.js +119 -0
  185. package/infrastructure/domains/shared/environment-builder.test.js +247 -0
  186. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +579 -0
  187. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +416 -0
  188. package/infrastructure/domains/shared/providers/azure-provider-adapter.stub.js +93 -0
  189. package/infrastructure/domains/shared/providers/cloud-provider-adapter.js +136 -0
  190. package/infrastructure/domains/shared/providers/gcp-provider-adapter.stub.js +82 -0
  191. package/infrastructure/domains/shared/providers/provider-factory.js +108 -0
  192. package/infrastructure/domains/shared/providers/provider-factory.test.js +170 -0
  193. package/infrastructure/domains/shared/resource-discovery.enhanced.test.js +306 -0
  194. package/infrastructure/domains/shared/resource-discovery.js +256 -0
  195. package/infrastructure/domains/shared/resource-discovery.test.js +757 -0
  196. package/infrastructure/domains/shared/types/app-definition.js +205 -0
  197. package/infrastructure/domains/shared/types/discovery-result.js +106 -0
  198. package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
  199. package/infrastructure/domains/shared/types/index.js +46 -0
  200. package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
  201. package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
  202. package/infrastructure/domains/shared/utilities/base-definition-factory.js +408 -0
  203. package/infrastructure/domains/shared/utilities/base-definition-factory.js.bak +338 -0
  204. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +291 -0
  205. package/infrastructure/domains/shared/utilities/handler-path-resolver.js +134 -0
  206. package/infrastructure/domains/shared/utilities/handler-path-resolver.test.js +268 -0
  207. package/infrastructure/domains/shared/utilities/prisma-layer-manager.js +159 -0
  208. package/infrastructure/domains/shared/utilities/prisma-layer-manager.test.js +444 -0
  209. package/infrastructure/domains/shared/validation/env-validator.js +78 -0
  210. package/infrastructure/domains/shared/validation/env-validator.test.js +173 -0
  211. package/infrastructure/domains/shared/validation/plugin-validator.js +187 -0
  212. package/infrastructure/domains/shared/validation/plugin-validator.test.js +323 -0
  213. package/infrastructure/esbuild.config.js +53 -0
  214. package/infrastructure/infrastructure-composer.js +119 -0
  215. package/infrastructure/infrastructure-composer.test.js +1896 -0
  216. package/infrastructure/integration.test.js +383 -0
  217. package/infrastructure/scripts/build-prisma-layer.js +701 -0
  218. package/infrastructure/scripts/build-prisma-layer.test.js +170 -0
  219. package/infrastructure/scripts/build-time-discovery.js +238 -0
  220. package/infrastructure/scripts/build-time-discovery.test.js +379 -0
  221. package/infrastructure/scripts/run-discovery.js +110 -0
  222. package/infrastructure/scripts/verify-prisma-layer.js +72 -0
  223. package/management-ui/README.md +203 -0
  224. package/package.json +44 -14
  225. package/test/index.js +2 -4
  226. package/test/mock-api.js +1 -3
  227. package/test/mock-integration.js +4 -14
  228. package/.eslintrc.json +0 -3
  229. package/CHANGELOG.md +0 -132
  230. package/infrastructure/app-handler-helpers.js +0 -57
  231. package/infrastructure/backend-utils.js +0 -87
  232. package/infrastructure/routers/auth.js +0 -26
  233. package/infrastructure/routers/integration-defined-routers.js +0 -42
  234. package/infrastructure/routers/middleware/loadUser.js +0 -15
  235. package/infrastructure/routers/middleware/requireLoggedInUser.js +0 -12
  236. package/infrastructure/routers/user.js +0 -41
  237. package/infrastructure/routers/websocket.js +0 -55
  238. package/infrastructure/serverless-template.js +0 -291
  239. package/infrastructure/workers/integration-defined-workers.js +0 -24
  240. package/test/auther-definition-tester.js +0 -125
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * Tests for CloudFormation-based Resource Discovery
3
+ *
4
+ * Tests discovering resources from existing CloudFormation stacks
5
+ * before falling back to direct AWS API discovery.
6
+ */
7
+
8
+ const { CloudFormationDiscovery } = require('./cloudformation-discovery');
9
+
10
+ describe('CloudFormationDiscovery', () => {
11
+ let cfDiscovery;
12
+ let mockProvider;
13
+
14
+ beforeEach(() => {
15
+ mockProvider = {
16
+ describeStack: jest.fn(),
17
+ listStackResources: jest.fn(),
18
+ };
19
+ cfDiscovery = new CloudFormationDiscovery(mockProvider);
20
+ });
21
+
22
+ describe('discoverFromStack()', () => {
23
+ it('should return null when stack does not exist', async () => {
24
+ mockProvider.describeStack.mockRejectedValue(
25
+ new Error('Stack with id test-stack does not exist')
26
+ );
27
+
28
+ const result = await cfDiscovery.discoverFromStack('test-stack');
29
+
30
+ expect(result).toBeNull();
31
+ expect(mockProvider.describeStack).toHaveBeenCalledWith(
32
+ 'test-stack'
33
+ );
34
+ });
35
+
36
+ it('should extract VPC resources from stack outputs', async () => {
37
+ const mockStack = {
38
+ StackName: 'test-stack',
39
+ Outputs: [
40
+ { OutputKey: 'VpcId', OutputValue: 'vpc-123' },
41
+ {
42
+ OutputKey: 'PrivateSubnetIds',
43
+ OutputValue: 'subnet-1,subnet-2',
44
+ },
45
+ { OutputKey: 'PublicSubnetId', OutputValue: 'subnet-3' },
46
+ { OutputKey: 'SecurityGroupId', OutputValue: 'sg-123' },
47
+ ],
48
+ };
49
+
50
+ mockProvider.describeStack.mockResolvedValue(mockStack);
51
+ mockProvider.listStackResources.mockResolvedValue([]);
52
+
53
+ const result = await cfDiscovery.discoverFromStack('test-stack');
54
+
55
+ expect(result).toEqual({
56
+ fromCloudFormationStack: true,
57
+ stackName: 'test-stack',
58
+ defaultVpcId: 'vpc-123', // VpcBuilder expects 'defaultVpcId', not 'vpcId'
59
+ privateSubnetIds: ['subnet-1', 'subnet-2'],
60
+ publicSubnetId: 'subnet-3',
61
+ securityGroupId: 'sg-123',
62
+ });
63
+ });
64
+
65
+ it('should extract KMS key from stack outputs', async () => {
66
+ const mockStack = {
67
+ StackName: 'test-stack',
68
+ Outputs: [
69
+ {
70
+ OutputKey: 'KMS_KEY_ARN',
71
+ OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
72
+ },
73
+ ],
74
+ };
75
+
76
+ mockProvider.describeStack.mockResolvedValue(mockStack);
77
+ mockProvider.listStackResources.mockResolvedValue([]);
78
+
79
+ const result = await cfDiscovery.discoverFromStack('test-stack');
80
+
81
+ expect(result).toEqual({
82
+ fromCloudFormationStack: true,
83
+ stackName: 'test-stack',
84
+ defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789:key/abc',
85
+ });
86
+ });
87
+
88
+ it('should extract VPC subnets from stack resources', async () => {
89
+ const mockStack = { StackName: 'test-stack', Outputs: [] };
90
+ const mockResources = [
91
+ {
92
+ LogicalResourceId: 'FriggPrivateSubnet1',
93
+ PhysicalResourceId: 'subnet-priv-1',
94
+ ResourceType: 'AWS::EC2::Subnet',
95
+ },
96
+ {
97
+ LogicalResourceId: 'FriggPrivateSubnet2',
98
+ PhysicalResourceId: 'subnet-priv-2',
99
+ ResourceType: 'AWS::EC2::Subnet',
100
+ },
101
+ {
102
+ LogicalResourceId: 'FriggPublicSubnet',
103
+ PhysicalResourceId: 'subnet-pub-1',
104
+ ResourceType: 'AWS::EC2::Subnet',
105
+ },
106
+ {
107
+ LogicalResourceId: 'FriggPublicSubnet2',
108
+ PhysicalResourceId: 'subnet-pub-2',
109
+ ResourceType: 'AWS::EC2::Subnet',
110
+ },
111
+ ];
112
+
113
+ mockProvider.describeStack.mockResolvedValue(mockStack);
114
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
115
+
116
+ const result = await cfDiscovery.discoverFromStack('test-stack');
117
+
118
+ expect(result.privateSubnetId1).toBe('subnet-priv-1');
119
+ expect(result.privateSubnetId2).toBe('subnet-priv-2');
120
+ expect(result.publicSubnetId1).toBe('subnet-pub-1');
121
+ expect(result.publicSubnetId2).toBe('subnet-pub-2');
122
+ });
123
+
124
+ it('should extract route tables and VPC endpoints from stack resources', async () => {
125
+ const mockStack = { StackName: 'test-stack', Outputs: [] };
126
+ const mockResources = [
127
+ {
128
+ LogicalResourceId: 'FriggLambdaRouteTable',
129
+ PhysicalResourceId: 'rtb-123',
130
+ ResourceType: 'AWS::EC2::RouteTable',
131
+ },
132
+ {
133
+ LogicalResourceId: 'FriggVPCEndpointSecurityGroup',
134
+ PhysicalResourceId: 'sg-vpce-123',
135
+ ResourceType: 'AWS::EC2::SecurityGroup',
136
+ },
137
+ {
138
+ LogicalResourceId: 'FriggS3VPCEndpoint',
139
+ PhysicalResourceId: 'vpce-s3-123',
140
+ ResourceType: 'AWS::EC2::VPCEndpoint',
141
+ },
142
+ {
143
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
144
+ PhysicalResourceId: 'vpce-ddb-123',
145
+ ResourceType: 'AWS::EC2::VPCEndpoint',
146
+ },
147
+ {
148
+ LogicalResourceId: 'FriggKMSVPCEndpoint',
149
+ PhysicalResourceId: 'vpce-kms-123',
150
+ ResourceType: 'AWS::EC2::VPCEndpoint',
151
+ },
152
+ {
153
+ LogicalResourceId: 'FriggSecretsManagerVPCEndpoint',
154
+ PhysicalResourceId: 'vpce-sm-123',
155
+ ResourceType: 'AWS::EC2::VPCEndpoint',
156
+ },
157
+ ];
158
+
159
+ mockProvider.describeStack.mockResolvedValue(mockStack);
160
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
161
+
162
+ const result = await cfDiscovery.discoverFromStack('test-stack');
163
+
164
+ expect(result.routeTableId).toBe('rtb-123');
165
+ expect(result.vpcEndpointSecurityGroupId).toBe('sg-vpce-123');
166
+ expect(result.s3VpcEndpointId).toBe('vpce-s3-123');
167
+ expect(result.dynamoDbVpcEndpointId).toBe('vpce-ddb-123');
168
+ expect(result.kmsVpcEndpointId).toBe('vpce-kms-123');
169
+ expect(result.secretsManagerVpcEndpointId).toBe('vpce-sm-123');
170
+ });
171
+
172
+ it('should extract Aurora cluster from stack resources', async () => {
173
+ const mockStack = {
174
+ StackName: 'test-stack',
175
+ Outputs: [],
176
+ };
177
+
178
+ const mockResources = [
179
+ {
180
+ LogicalResourceId: 'FriggAuroraCluster',
181
+ PhysicalResourceId: 'test-cluster',
182
+ ResourceType: 'AWS::RDS::DBCluster',
183
+ },
184
+ ];
185
+
186
+ mockProvider.describeStack.mockResolvedValue(mockStack);
187
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
188
+
189
+ const result = await cfDiscovery.discoverFromStack('test-stack');
190
+
191
+ expect(result).toEqual({
192
+ fromCloudFormationStack: true,
193
+ stackName: 'test-stack',
194
+ auroraClusterId: 'test-cluster',
195
+ existingLogicalIds: ['FriggAuroraCluster'],
196
+ });
197
+ });
198
+
199
+ it('should extract subnets from stack resources', async () => {
200
+ const mockStack = {
201
+ StackName: 'test-stack',
202
+ Outputs: [],
203
+ };
204
+
205
+ const mockResources = [
206
+ {
207
+ LogicalResourceId: 'FriggPrivateSubnet1',
208
+ PhysicalResourceId: 'subnet-private-1',
209
+ ResourceType: 'AWS::EC2::Subnet',
210
+ },
211
+ {
212
+ LogicalResourceId: 'FriggPrivateSubnet2',
213
+ PhysicalResourceId: 'subnet-private-2',
214
+ ResourceType: 'AWS::EC2::Subnet',
215
+ },
216
+ {
217
+ LogicalResourceId: 'FriggPublicSubnet',
218
+ PhysicalResourceId: 'subnet-public-1',
219
+ ResourceType: 'AWS::EC2::Subnet',
220
+ },
221
+ {
222
+ LogicalResourceId: 'FriggPublicSubnet2',
223
+ PhysicalResourceId: 'subnet-public-2',
224
+ ResourceType: 'AWS::EC2::Subnet',
225
+ },
226
+ ];
227
+
228
+ mockProvider.describeStack.mockResolvedValue(mockStack);
229
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
230
+
231
+ const result = await cfDiscovery.discoverFromStack('test-stack');
232
+
233
+ expect(result.privateSubnetId1).toBe('subnet-private-1');
234
+ expect(result.privateSubnetId2).toBe('subnet-private-2');
235
+ expect(result.publicSubnetId1).toBe('subnet-public-1');
236
+ expect(result.publicSubnetId2).toBe('subnet-public-2');
237
+ });
238
+
239
+ it('should extract S3 migration bucket from stack resources', async () => {
240
+ const mockStack = {
241
+ StackName: 'test-stack',
242
+ Outputs: [],
243
+ };
244
+
245
+ const mockResources = [
246
+ {
247
+ LogicalResourceId: 'FriggMigrationStatusBucket',
248
+ PhysicalResourceId: 'test-migration-bucket',
249
+ ResourceType: 'AWS::S3::Bucket',
250
+ },
251
+ ];
252
+
253
+ mockProvider.describeStack.mockResolvedValue(mockStack);
254
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
255
+
256
+ const result = await cfDiscovery.discoverFromStack('test-stack');
257
+
258
+ expect(result).toEqual({
259
+ fromCloudFormationStack: true,
260
+ stackName: 'test-stack',
261
+ migrationStatusBucket: 'test-migration-bucket',
262
+ existingLogicalIds: ['FriggMigrationStatusBucket'],
263
+ });
264
+ });
265
+
266
+ it('should extract SQS migration queue from stack resources', async () => {
267
+ const mockStack = {
268
+ StackName: 'test-stack',
269
+ Outputs: [],
270
+ };
271
+
272
+ const mockResources = [
273
+ {
274
+ LogicalResourceId: 'DbMigrationQueue',
275
+ PhysicalResourceId:
276
+ 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
277
+ ResourceType: 'AWS::SQS::Queue',
278
+ },
279
+ ];
280
+
281
+ mockProvider.describeStack.mockResolvedValue(mockStack);
282
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
283
+
284
+ const result = await cfDiscovery.discoverFromStack('test-stack');
285
+
286
+ expect(result).toEqual({
287
+ fromCloudFormationStack: true,
288
+ stackName: 'test-stack',
289
+ migrationQueueUrl:
290
+ 'https://sqs.us-east-1.amazonaws.com/123456789/test-queue',
291
+ existingLogicalIds: ['DbMigrationQueue'],
292
+ });
293
+ });
294
+
295
+ it('should extract NAT Gateway from stack resources', async () => {
296
+ const mockStack = {
297
+ StackName: 'test-stack',
298
+ Outputs: [],
299
+ };
300
+
301
+ const mockResources = [
302
+ {
303
+ LogicalResourceId: 'FriggNatGateway',
304
+ PhysicalResourceId: 'nat-0123456789',
305
+ ResourceType: 'AWS::EC2::NatGateway',
306
+ },
307
+ ];
308
+
309
+ mockProvider.describeStack.mockResolvedValue(mockStack);
310
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
311
+
312
+ const result = await cfDiscovery.discoverFromStack('test-stack');
313
+
314
+ expect(result).toEqual({
315
+ fromCloudFormationStack: true,
316
+ stackName: 'test-stack',
317
+ natGatewayId: 'nat-0123456789',
318
+ existingLogicalIds: ['FriggNatGateway'],
319
+ });
320
+ });
321
+
322
+ it('should extract VPC directly from stack resources', async () => {
323
+ const mockStack = {
324
+ StackName: 'test-stack',
325
+ Outputs: [],
326
+ };
327
+
328
+ const mockResources = [
329
+ {
330
+ LogicalResourceId: 'FriggVPC',
331
+ PhysicalResourceId: 'vpc-037ec55fe87aec1e7',
332
+ ResourceType: 'AWS::EC2::VPC',
333
+ },
334
+ ];
335
+
336
+ mockProvider.describeStack.mockResolvedValue(mockStack);
337
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
338
+
339
+ const result = await cfDiscovery.discoverFromStack('test-stack');
340
+
341
+ expect(result).toEqual({
342
+ fromCloudFormationStack: true,
343
+ stackName: 'test-stack',
344
+ defaultVpcId: 'vpc-037ec55fe87aec1e7',
345
+ existingLogicalIds: ['FriggVPC'],
346
+ });
347
+ });
348
+
349
+ it('should combine outputs and resources correctly', async () => {
350
+ const mockStack = {
351
+ StackName: 'test-stack',
352
+ Outputs: [
353
+ { OutputKey: 'VpcId', OutputValue: 'vpc-123' },
354
+ {
355
+ OutputKey: 'KMS_KEY_ARN',
356
+ OutputValue: 'arn:aws:kms:us-east-1:123456789:key/abc',
357
+ },
358
+ ],
359
+ };
360
+
361
+ const mockResources = [
362
+ {
363
+ LogicalResourceId: 'FriggAuroraCluster',
364
+ PhysicalResourceId: 'test-cluster',
365
+ ResourceType: 'AWS::RDS::DBCluster',
366
+ },
367
+ {
368
+ LogicalResourceId: 'FriggNatGateway',
369
+ PhysicalResourceId: 'nat-123',
370
+ ResourceType: 'AWS::EC2::NatGateway',
371
+ },
372
+ ];
373
+
374
+ mockProvider.describeStack.mockResolvedValue(mockStack);
375
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
376
+
377
+ const result = await cfDiscovery.discoverFromStack('test-stack');
378
+
379
+ expect(result).toEqual({
380
+ fromCloudFormationStack: true,
381
+ stackName: 'test-stack',
382
+ defaultVpcId: 'vpc-123', // VpcBuilder expects 'defaultVpcId'
383
+ defaultKmsKeyId: 'arn:aws:kms:us-east-1:123456789:key/abc',
384
+ auroraClusterId: 'test-cluster',
385
+ natGatewayId: 'nat-123',
386
+ existingLogicalIds: ['FriggAuroraCluster', 'FriggNatGateway'],
387
+ });
388
+ });
389
+
390
+ it('should handle stack with no relevant resources', async () => {
391
+ const mockStack = {
392
+ StackName: 'test-stack',
393
+ Outputs: [],
394
+ };
395
+
396
+ mockProvider.describeStack.mockResolvedValue(mockStack);
397
+ mockProvider.listStackResources.mockResolvedValue([
398
+ {
399
+ LogicalResourceId: 'SomeOtherResource',
400
+ PhysicalResourceId: 'some-id',
401
+ ResourceType: 'AWS::Lambda::Function',
402
+ },
403
+ ]);
404
+
405
+ const result = await cfDiscovery.discoverFromStack('test-stack');
406
+
407
+ expect(result).toEqual({
408
+ fromCloudFormationStack: true,
409
+ stackName: 'test-stack',
410
+ });
411
+ });
412
+
413
+ it('should query EC2 for subnets when VPC found but no subnet resources in stack', async () => {
414
+ const mockStack = {
415
+ StackName: 'test-stack',
416
+ Outputs: [],
417
+ };
418
+
419
+ const mockResources = [
420
+ {
421
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
422
+ PhysicalResourceId: 'sg-123',
423
+ ResourceType: 'AWS::EC2::SecurityGroup',
424
+ },
425
+ ];
426
+
427
+ const mockEC2Client = {
428
+ send: jest.fn(),
429
+ };
430
+
431
+ mockProvider.describeStack.mockResolvedValue(mockStack);
432
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
433
+ mockProvider.getEC2Client = jest
434
+ .fn()
435
+ .mockReturnValue(mockEC2Client);
436
+
437
+ // Mock security group query for VPC ID
438
+ mockEC2Client.send.mockResolvedValueOnce({
439
+ SecurityGroups: [{ VpcId: 'vpc-123' }],
440
+ });
441
+
442
+ // Mock subnet query
443
+ mockEC2Client.send.mockResolvedValueOnce({
444
+ Subnets: [
445
+ {
446
+ SubnetId: 'subnet-private-1',
447
+ MapPublicIpOnLaunch: false,
448
+ Tags: [
449
+ { Key: 'ManagedBy', Value: 'Frigg' },
450
+ {
451
+ Key: 'aws:cloudformation:logical-id',
452
+ Value: 'FriggPrivateSubnet1',
453
+ },
454
+ ],
455
+ },
456
+ {
457
+ SubnetId: 'subnet-private-2',
458
+ MapPublicIpOnLaunch: false,
459
+ Tags: [
460
+ { Key: 'ManagedBy', Value: 'Frigg' },
461
+ {
462
+ Key: 'aws:cloudformation:logical-id',
463
+ Value: 'FriggPrivateSubnet2',
464
+ },
465
+ ],
466
+ },
467
+ ],
468
+ });
469
+
470
+ const result = await cfDiscovery.discoverFromStack('test-stack');
471
+
472
+ expect(result.privateSubnetId1).toBe('subnet-private-1');
473
+ expect(result.privateSubnetId2).toBe('subnet-private-2');
474
+ expect(mockEC2Client.send).toHaveBeenCalledTimes(2);
475
+ });
476
+
477
+ it('should handle EC2 subnet query errors gracefully', async () => {
478
+ const mockStack = {
479
+ StackName: 'test-stack',
480
+ Outputs: [],
481
+ };
482
+
483
+ const mockResources = [
484
+ {
485
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
486
+ PhysicalResourceId: 'sg-123',
487
+ ResourceType: 'AWS::EC2::SecurityGroup',
488
+ },
489
+ ];
490
+
491
+ const mockEC2Client = {
492
+ send: jest.fn(),
493
+ };
494
+
495
+ mockProvider.describeStack.mockResolvedValue(mockStack);
496
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
497
+ mockProvider.getEC2Client = jest
498
+ .fn()
499
+ .mockReturnValue(mockEC2Client);
500
+
501
+ // Mock security group query for VPC ID
502
+ mockEC2Client.send.mockResolvedValueOnce({
503
+ SecurityGroups: [{ VpcId: 'vpc-123' }],
504
+ });
505
+
506
+ // Mock subnet query failure
507
+ mockEC2Client.send.mockRejectedValueOnce(
508
+ new Error('EC2 API Error')
509
+ );
510
+
511
+ const result = await cfDiscovery.discoverFromStack('test-stack');
512
+
513
+ expect(result.defaultVpcId).toBe('vpc-123');
514
+ expect(result.privateSubnetId1).toBeUndefined();
515
+ });
516
+
517
+ it('should extract KMS key alias from stack resources and query for key ARN', async () => {
518
+ const mockStack = {
519
+ StackName: 'test-stack',
520
+ Outputs: [],
521
+ };
522
+
523
+ const mockResources = [
524
+ {
525
+ LogicalResourceId: 'FriggKMSKeyAlias',
526
+ PhysicalResourceId: 'alias/test-service-dev-frigg-kms',
527
+ ResourceType: 'AWS::KMS::Alias',
528
+ },
529
+ ];
530
+
531
+ mockProvider.describeStack.mockResolvedValue(mockStack);
532
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
533
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
534
+ KeyId: 'abc-123',
535
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/abc-123',
536
+ });
537
+
538
+ const result = await cfDiscovery.discoverFromStack('test-stack');
539
+
540
+ expect(result.defaultKmsKeyId).toBe(
541
+ 'arn:aws:kms:us-east-1:123456789:key/abc-123'
542
+ );
543
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
544
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
545
+ 'alias/test-service-dev-frigg-kms'
546
+ );
547
+ });
548
+
549
+ it('should query AWS API for KMS alias when serviceName and stage are provided', async () => {
550
+ const mockStack = {
551
+ StackName: 'test-stack',
552
+ Outputs: [],
553
+ };
554
+
555
+ const mockResources = [];
556
+
557
+ mockProvider.describeStack.mockResolvedValue(mockStack);
558
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
559
+ mockProvider.region = 'us-east-1';
560
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
561
+ KeyId: 'abc-123',
562
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/abc-123',
563
+ });
564
+
565
+ // Pass serviceName and stage to discover alias
566
+ cfDiscovery.serviceName = 'test-service';
567
+ cfDiscovery.stage = 'dev';
568
+
569
+ const result = await cfDiscovery.discoverFromStack('test-stack');
570
+
571
+ expect(result.defaultKmsKeyId).toBe(
572
+ 'arn:aws:kms:us-east-1:123456789:key/abc-123'
573
+ );
574
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
575
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
576
+ 'alias/test-service-dev-frigg-kms'
577
+ );
578
+ });
579
+
580
+ it('should handle KMS alias not found gracefully', async () => {
581
+ const mockStack = {
582
+ StackName: 'test-stack',
583
+ Outputs: [],
584
+ };
585
+
586
+ const mockResources = [];
587
+
588
+ mockProvider.describeStack.mockResolvedValue(mockStack);
589
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
590
+ mockProvider.region = 'us-east-1';
591
+ mockProvider.describeKmsKey = jest
592
+ .fn()
593
+ .mockRejectedValue(
594
+ new Error('Alias/test-service-dev-frigg-kms is not found')
595
+ );
596
+
597
+ cfDiscovery.serviceName = 'test-service';
598
+ cfDiscovery.stage = 'dev';
599
+
600
+ const result = await cfDiscovery.discoverFromStack('test-stack');
601
+
602
+ expect(result.defaultKmsKeyId).toBeUndefined();
603
+ expect(result.kmsKeyAlias).toBeUndefined();
604
+ });
605
+
606
+ it('should prefer KMS key from stack resources over alias query', async () => {
607
+ const mockStack = {
608
+ StackName: 'test-stack',
609
+ Outputs: [],
610
+ };
611
+
612
+ const mockResources = [
613
+ {
614
+ LogicalResourceId: 'FriggKMSKey',
615
+ PhysicalResourceId:
616
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
617
+ ResourceType: 'AWS::KMS::Key',
618
+ },
619
+ ];
620
+
621
+ mockProvider.describeStack.mockResolvedValue(mockStack);
622
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
623
+ mockProvider.describeKmsKey = jest.fn();
624
+
625
+ const result = await cfDiscovery.discoverFromStack('test-stack');
626
+
627
+ // Should use the key from stack resources, not query for alias
628
+ expect(result.defaultKmsKeyId).toBe(
629
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789'
630
+ );
631
+ expect(mockProvider.describeKmsKey).not.toHaveBeenCalled();
632
+ });
633
+
634
+ it('should use KMS alias from stack resources even if key is also present', async () => {
635
+ const mockStack = {
636
+ StackName: 'test-stack',
637
+ Outputs: [],
638
+ };
639
+
640
+ const mockResources = [
641
+ {
642
+ LogicalResourceId: 'FriggKMSKey',
643
+ PhysicalResourceId:
644
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
645
+ ResourceType: 'AWS::KMS::Key',
646
+ },
647
+ {
648
+ LogicalResourceId: 'FriggKMSKeyAlias',
649
+ PhysicalResourceId: 'alias/test-service-dev-frigg-kms',
650
+ ResourceType: 'AWS::KMS::Alias',
651
+ },
652
+ ];
653
+
654
+ mockProvider.describeStack.mockResolvedValue(mockStack);
655
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
656
+ mockProvider.describeKmsKey = jest.fn().mockResolvedValue({
657
+ KeyId: 'xyz-789',
658
+ Arn: 'arn:aws:kms:us-east-1:123456789:key/xyz-789',
659
+ });
660
+
661
+ const result = await cfDiscovery.discoverFromStack('test-stack');
662
+
663
+ expect(result.defaultKmsKeyId).toBe(
664
+ 'arn:aws:kms:us-east-1:123456789:key/xyz-789'
665
+ );
666
+ expect(result.kmsKeyAlias).toBe('alias/test-service-dev-frigg-kms');
667
+ expect(mockProvider.describeKmsKey).toHaveBeenCalledWith(
668
+ 'alias/test-service-dev-frigg-kms'
669
+ );
670
+ });
671
+ });
672
+
673
+ describe('External VPC with routing infrastructure pattern', () => {
674
+ it('should discover routing resources when VPC is external', async () => {
675
+ // This tests the external VPC pattern: external VPC/subnets/KMS,
676
+ // but stack creates routing infrastructure (route table, NAT route, VPC endpoints)
677
+ const mockStack = {
678
+ StackName: 'create-frigg-app-production',
679
+ Outputs: [],
680
+ };
681
+
682
+ const mockResources = [
683
+ {
684
+ LogicalResourceId: 'FriggLambdaRouteTable',
685
+ PhysicalResourceId: 'rtb-0b83aca77ccde20a6',
686
+ ResourceType: 'AWS::EC2::RouteTable',
687
+ ResourceStatus: 'UPDATE_COMPLETE',
688
+ },
689
+ {
690
+ LogicalResourceId: 'FriggNATRoute',
691
+ PhysicalResourceId: 'rtb-0b83aca77ccde20a6|0.0.0.0/0',
692
+ ResourceType: 'AWS::EC2::Route',
693
+ ResourceStatus: 'UPDATE_COMPLETE',
694
+ },
695
+ {
696
+ LogicalResourceId: 'FriggSubnet1RouteAssociation',
697
+ PhysicalResourceId: 'rtbassoc-07245da0b447ca469',
698
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
699
+ ResourceStatus: 'CREATE_COMPLETE',
700
+ },
701
+ {
702
+ LogicalResourceId: 'FriggSubnet2RouteAssociation',
703
+ PhysicalResourceId: 'rtbassoc-0806f9783c4ea181f',
704
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
705
+ ResourceStatus: 'CREATE_COMPLETE',
706
+ },
707
+ {
708
+ LogicalResourceId: 'VPCEndpointS3',
709
+ PhysicalResourceId: 'vpce-0352ceac2124c14be',
710
+ ResourceType: 'AWS::EC2::VPCEndpoint',
711
+ ResourceStatus: 'CREATE_COMPLETE',
712
+ },
713
+ {
714
+ LogicalResourceId: 'VPCEndpointDynamoDB',
715
+ PhysicalResourceId: 'vpce-0b06c4f631199ea68',
716
+ ResourceType: 'AWS::EC2::VPCEndpoint',
717
+ ResourceStatus: 'CREATE_COMPLETE',
718
+ },
719
+ ];
720
+
721
+ mockProvider.describeStack.mockResolvedValue(mockStack);
722
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
723
+
724
+ const result = await cfDiscovery.discoverFromStack(
725
+ 'create-frigg-app-production'
726
+ );
727
+
728
+ // Verify routing infrastructure was discovered
729
+ expect(result.routeTableId).toBe('rtb-0b83aca77ccde20a6');
730
+ expect(result.privateRouteTableId).toBe('rtb-0b83aca77ccde20a6');
731
+ expect(result.natRoute).toBe('rtb-0b83aca77ccde20a6|0.0.0.0/0');
732
+ expect(result.routeTableAssociations).toEqual([
733
+ 'rtbassoc-07245da0b447ca469',
734
+ 'rtbassoc-0806f9783c4ea181f',
735
+ ]);
736
+
737
+ // Verify VPC endpoints were discovered (both naming conventions)
738
+ expect(result.vpcEndpoints).toBeDefined();
739
+ expect(result.vpcEndpoints.s3).toBe('vpce-0352ceac2124c14be');
740
+ expect(result.vpcEndpoints.dynamodb).toBe('vpce-0b06c4f631199ea68');
741
+ expect(result.s3VpcEndpointId).toBe('vpce-0352ceac2124c14be');
742
+ expect(result.dynamoDbVpcEndpointId).toBe('vpce-0b06c4f631199ea68');
743
+
744
+ // Verify NO VPC/KMS resources (they're external)
745
+ expect(result.defaultVpcId).toBeUndefined();
746
+ expect(result.defaultKmsKeyId).toBeUndefined();
747
+ });
748
+
749
+ it('should work with legacy VPC endpoint naming (FriggS3VPCEndpoint)', async () => {
750
+ const mockStack = {
751
+ StackName: 'test-stack',
752
+ Outputs: [],
753
+ };
754
+
755
+ const mockResources = [
756
+ {
757
+ LogicalResourceId: 'FriggS3VPCEndpoint',
758
+ PhysicalResourceId: 'vpce-legacy-s3',
759
+ ResourceType: 'AWS::EC2::VPCEndpoint',
760
+ ResourceStatus: 'CREATE_COMPLETE',
761
+ },
762
+ {
763
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
764
+ PhysicalResourceId: 'vpce-legacy-ddb',
765
+ ResourceType: 'AWS::EC2::VPCEndpoint',
766
+ ResourceStatus: 'CREATE_COMPLETE',
767
+ },
768
+ ];
769
+
770
+ mockProvider.describeStack.mockResolvedValue(mockStack);
771
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
772
+
773
+ const result = await cfDiscovery.discoverFromStack('test-stack');
774
+
775
+ // Both naming conventions should work
776
+ expect(result.vpcEndpoints.s3).toBe('vpce-legacy-s3');
777
+ expect(result.vpcEndpoints.dynamodb).toBe('vpce-legacy-ddb');
778
+ expect(result.s3VpcEndpointId).toBe('vpce-legacy-s3');
779
+ expect(result.dynamoDbVpcEndpointId).toBe('vpce-legacy-ddb');
780
+ });
781
+
782
+ it('should extract FriggLambdaSecurityGroup from stack', async () => {
783
+ const mockStack = {
784
+ StackName: 'test-stack',
785
+ Outputs: [],
786
+ };
787
+
788
+ const mockResources = [
789
+ {
790
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
791
+ PhysicalResourceId: 'sg-01002240c6a446202',
792
+ ResourceType: 'AWS::EC2::SecurityGroup',
793
+ ResourceStatus: 'UPDATE_COMPLETE',
794
+ },
795
+ {
796
+ LogicalResourceId: 'FriggLambdaRouteTable',
797
+ PhysicalResourceId: 'rtb-08af43bbf0775602d',
798
+ ResourceType: 'AWS::EC2::RouteTable',
799
+ ResourceStatus: 'UPDATE_COMPLETE',
800
+ },
801
+ ];
802
+
803
+ mockProvider.describeStack.mockResolvedValue(mockStack);
804
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
805
+
806
+ const result = await cfDiscovery.discoverFromStack('test-stack');
807
+
808
+ // Lambda security group should be extracted
809
+ expect(result.lambdaSecurityGroupId).toBe('sg-01002240c6a446202');
810
+ expect(result.defaultSecurityGroupId).toBe('sg-01002240c6a446202');
811
+ expect(result.existingLogicalIds).toContain(
812
+ 'FriggLambdaSecurityGroup'
813
+ );
814
+ });
815
+
816
+ it('should support FriggPrivateRoute naming for NAT routes', async () => {
817
+ const mockStack = {
818
+ StackName: 'test-stack',
819
+ Outputs: [],
820
+ };
821
+
822
+ const mockResources = [
823
+ {
824
+ LogicalResourceId: 'FriggLambdaRouteTable',
825
+ PhysicalResourceId: 'rtb-123',
826
+ ResourceType: 'AWS::EC2::RouteTable',
827
+ ResourceStatus: 'UPDATE_COMPLETE',
828
+ },
829
+ {
830
+ LogicalResourceId: 'FriggPrivateRoute',
831
+ PhysicalResourceId: 'rtb-123|0.0.0.0/0',
832
+ ResourceType: 'AWS::EC2::Route',
833
+ ResourceStatus: 'UPDATE_COMPLETE',
834
+ },
835
+ ];
836
+
837
+ mockProvider.describeStack.mockResolvedValue(mockStack);
838
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
839
+
840
+ const result = await cfDiscovery.discoverFromStack('test-stack');
841
+
842
+ // Both FriggNATRoute and FriggPrivateRoute should be recognized
843
+ expect(result.natRoute).toBe('rtb-123|0.0.0.0/0');
844
+ expect(result.routeTableId).toBe('rtb-123');
845
+ });
846
+
847
+ it('should extract external references from route table without stackName error', async () => {
848
+ const mockStack = {
849
+ StackName: 'test-stack',
850
+ Outputs: [],
851
+ };
852
+
853
+ const mockResources = [
854
+ {
855
+ LogicalResourceId: 'FriggLambdaRouteTable',
856
+ PhysicalResourceId: 'rtb-real-id',
857
+ ResourceType: 'AWS::EC2::RouteTable',
858
+ ResourceStatus: 'UPDATE_COMPLETE',
859
+ },
860
+ ];
861
+
862
+ mockProvider.describeStack.mockResolvedValue(mockStack);
863
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
864
+
865
+ // Mock EC2 DescribeRouteTables to return route table with VPC info
866
+ mockProvider.getEC2Client = jest.fn().mockReturnValue({
867
+ send: jest.fn().mockResolvedValue({
868
+ RouteTables: [
869
+ {
870
+ RouteTableId: 'rtb-real-id',
871
+ VpcId: 'vpc-extracted',
872
+ Routes: [
873
+ {
874
+ NatGatewayId: 'nat-extracted',
875
+ DestinationCidrBlock: '0.0.0.0/0',
876
+ },
877
+ ],
878
+ Associations: [
879
+ { SubnetId: 'subnet-1' },
880
+ { SubnetId: 'subnet-2' },
881
+ ],
882
+ },
883
+ ],
884
+ }),
885
+ });
886
+
887
+ const result = await cfDiscovery.discoverFromStack('test-stack');
888
+
889
+ // Should extract VPC, NAT, and subnets from route table
890
+ expect(result.defaultVpcId).toBe('vpc-extracted');
891
+ expect(result.existingNatGatewayId).toBe('nat-extracted');
892
+ expect(result.privateSubnetId1).toBe('subnet-1');
893
+ expect(result.privateSubnetId2).toBe('subnet-2');
894
+
895
+ // Should NOT throw 'stackName is not defined' error
896
+ expect(result).toBeDefined();
897
+ });
898
+ });
899
+
900
+ describe('existingLogicalIds tracking', () => {
901
+ it('should track OLD VPC endpoint logical IDs (VPCEndpointS3 pattern) for backwards compatibility', async () => {
902
+ // CRITICAL: Frontify production uses OLD naming convention
903
+ const mockStack = {
904
+ StackName: 'create-frigg-app-production',
905
+ Outputs: [],
906
+ };
907
+
908
+ const mockResources = [
909
+ {
910
+ LogicalResourceId: 'FriggLambdaRouteTable',
911
+ PhysicalResourceId: 'rtb-123',
912
+ ResourceType: 'AWS::EC2::RouteTable',
913
+ },
914
+ {
915
+ LogicalResourceId: 'FriggNATRoute',
916
+ PhysicalResourceId: 'rtb-123|0.0.0.0/0',
917
+ ResourceType: 'AWS::EC2::Route',
918
+ },
919
+ {
920
+ LogicalResourceId: 'FriggSubnet1RouteAssociation',
921
+ PhysicalResourceId: 'rtbassoc-1',
922
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
923
+ },
924
+ {
925
+ LogicalResourceId: 'FriggSubnet2RouteAssociation',
926
+ PhysicalResourceId: 'rtbassoc-2',
927
+ ResourceType: 'AWS::EC2::SubnetRouteTableAssociation',
928
+ },
929
+ {
930
+ LogicalResourceId: 'VPCEndpointS3',
931
+ PhysicalResourceId: 'vpce-s3-123',
932
+ ResourceType: 'AWS::EC2::VPCEndpoint',
933
+ },
934
+ {
935
+ LogicalResourceId: 'VPCEndpointDynamoDB',
936
+ PhysicalResourceId: 'vpce-ddb-123',
937
+ ResourceType: 'AWS::EC2::VPCEndpoint',
938
+ },
939
+ ];
940
+
941
+ mockProvider.describeStack.mockResolvedValue(mockStack);
942
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
943
+
944
+ const result = await cfDiscovery.discoverFromStack(
945
+ 'create-frigg-app-production'
946
+ );
947
+
948
+ // CRITICAL: existingLogicalIds MUST contain old VPC endpoint names
949
+ expect(result.existingLogicalIds).toBeDefined();
950
+ expect(result.existingLogicalIds).toContain('FriggNATRoute');
951
+ expect(result.existingLogicalIds).toContain(
952
+ 'FriggSubnet1RouteAssociation'
953
+ );
954
+ expect(result.existingLogicalIds).toContain(
955
+ 'FriggSubnet2RouteAssociation'
956
+ );
957
+ expect(result.existingLogicalIds).toContain('VPCEndpointS3'); // OLD naming
958
+ expect(result.existingLogicalIds).toContain('VPCEndpointDynamoDB'); // OLD naming
959
+
960
+ // Should also have the flat discovery properties
961
+ expect(result.routeTableId).toBe('rtb-123');
962
+ expect(result.natRoute).toBe('rtb-123|0.0.0.0/0');
963
+ expect(result.s3VpcEndpointId).toBe('vpce-s3-123');
964
+ expect(result.dynamodbVpcEndpointId).toBe('vpce-ddb-123');
965
+ });
966
+
967
+ it('should track NEW VPC endpoint logical IDs (FriggS3VPCEndpoint pattern) for newer stacks', async () => {
968
+ const mockStack = {
969
+ StackName: 'test-stack',
970
+ Outputs: [],
971
+ };
972
+
973
+ const mockResources = [
974
+ {
975
+ LogicalResourceId: 'FriggLambdaRouteTable',
976
+ PhysicalResourceId: 'rtb-456',
977
+ ResourceType: 'AWS::EC2::RouteTable',
978
+ },
979
+ {
980
+ LogicalResourceId: 'FriggPrivateRoute',
981
+ PhysicalResourceId: 'rtb-456|0.0.0.0/0',
982
+ ResourceType: 'AWS::EC2::Route',
983
+ },
984
+ {
985
+ LogicalResourceId: 'FriggS3VPCEndpoint',
986
+ PhysicalResourceId: 'vpce-s3-456',
987
+ ResourceType: 'AWS::EC2::VPCEndpoint',
988
+ },
989
+ {
990
+ LogicalResourceId: 'FriggDynamoDBVPCEndpoint',
991
+ PhysicalResourceId: 'vpce-ddb-456',
992
+ ResourceType: 'AWS::EC2::VPCEndpoint',
993
+ },
994
+ {
995
+ LogicalResourceId: 'FriggKMSVPCEndpoint',
996
+ PhysicalResourceId: 'vpce-kms-456',
997
+ ResourceType: 'AWS::EC2::VPCEndpoint',
998
+ },
999
+ ];
1000
+
1001
+ mockProvider.describeStack.mockResolvedValue(mockStack);
1002
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
1003
+
1004
+ const result = await cfDiscovery.discoverFromStack('test-stack');
1005
+
1006
+ // Should track NEW naming pattern in existingLogicalIds
1007
+ expect(result.existingLogicalIds).toContain('FriggPrivateRoute');
1008
+ expect(result.existingLogicalIds).toContain('FriggS3VPCEndpoint');
1009
+ expect(result.existingLogicalIds).toContain(
1010
+ 'FriggDynamoDBVPCEndpoint'
1011
+ );
1012
+ expect(result.existingLogicalIds).toContain('FriggKMSVPCEndpoint');
1013
+
1014
+ // Should NOT contain old naming patterns
1015
+ expect(result.existingLogicalIds).not.toContain('FriggNATRoute');
1016
+ expect(result.existingLogicalIds).not.toContain('VPCEndpointS3');
1017
+ });
1018
+ });
1019
+
1020
+ describe('Subnet extraction from VPC query (OLD reliable approach)', () => {
1021
+ it('should extract subnets by querying ALL subnets in VPC then filtering by route table', async () => {
1022
+ // Tests the proven method from aws-discovery.js
1023
+ // 1. Query ALL subnets in VPC using vpc-id filter (not association filter!)
1024
+ // 2. Query route table by ID (RouteTableIds parameter, not Filters!)
1025
+ // 3. Extract subnet IDs from route table's Associations array
1026
+
1027
+ const mockStack = {
1028
+ StackName: 'test-stack',
1029
+ Outputs: [],
1030
+ };
1031
+
1032
+ const mockResources = [
1033
+ {
1034
+ LogicalResourceId: 'FriggLambdaRouteTable',
1035
+ PhysicalResourceId: 'rtb-123',
1036
+ ResourceType: 'AWS::EC2::RouteTable',
1037
+ },
1038
+ {
1039
+ LogicalResourceId: 'FriggVPC',
1040
+ PhysicalResourceId: 'vpc-456',
1041
+ ResourceType: 'AWS::EC2::VPC',
1042
+ },
1043
+ ];
1044
+
1045
+ const sendMock = jest.fn();
1046
+ sendMock
1047
+ .mockResolvedValueOnce({
1048
+ RouteTables: [
1049
+ {
1050
+ RouteTableId: 'rtb-123',
1051
+ VpcId: 'vpc-456',
1052
+ Associations: [],
1053
+ Routes: [
1054
+ {
1055
+ NatGatewayId: 'nat-789',
1056
+ DestinationCidrBlock: '0.0.0.0/0',
1057
+ },
1058
+ ],
1059
+ },
1060
+ ],
1061
+ })
1062
+ .mockResolvedValueOnce({
1063
+ SecurityGroups: [{ GroupId: 'sg-default' }],
1064
+ })
1065
+ .mockResolvedValueOnce({
1066
+ Subnets: [
1067
+ {
1068
+ SubnetId: 'subnet-aaa',
1069
+ VpcId: 'vpc-456',
1070
+ AvailabilityZone: 'us-east-1a',
1071
+ },
1072
+ {
1073
+ SubnetId: 'subnet-bbb',
1074
+ VpcId: 'vpc-456',
1075
+ AvailabilityZone: 'us-east-1b',
1076
+ },
1077
+ {
1078
+ SubnetId: 'subnet-ccc',
1079
+ VpcId: 'vpc-456',
1080
+ AvailabilityZone: 'us-east-1c',
1081
+ },
1082
+ ],
1083
+ })
1084
+ .mockResolvedValueOnce({
1085
+ RouteTables: [
1086
+ {
1087
+ RouteTableId: 'rtb-123',
1088
+ Associations: [
1089
+ {
1090
+ RouteTableAssociationId: 'rtbassoc-111',
1091
+ SubnetId: 'subnet-aaa',
1092
+ },
1093
+ {
1094
+ RouteTableAssociationId: 'rtbassoc-222',
1095
+ SubnetId: 'subnet-bbb',
1096
+ },
1097
+ ],
1098
+ },
1099
+ ],
1100
+ });
1101
+
1102
+ const mockEC2Client = { send: sendMock };
1103
+
1104
+ mockProvider.describeStack.mockResolvedValue(mockStack);
1105
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
1106
+ mockProvider.getEC2Client = jest
1107
+ .fn()
1108
+ .mockReturnValue(mockEC2Client);
1109
+
1110
+ const result = await cfDiscovery.discoverFromStack('test-stack');
1111
+
1112
+ // Should have extracted subnets using VPC query approach
1113
+ expect(result.privateSubnetId1).toBe('subnet-aaa');
1114
+ expect(result.privateSubnetId2).toBe('subnet-bbb');
1115
+ });
1116
+
1117
+ it('should select only private subnets when route table has 0 associations and VPC has public + private subnets', async () => {
1118
+ // Real-world drift scenario:
1119
+ // - VPC has 3 subnets: 1 public (NAT/IGW) + 2 private (Lambda)
1120
+ // - IGW route table has all 3 associated (default route table)
1121
+ // - Frigg lambda route table has 0 associations (drift)
1122
+ // - Frigg-managed subnet query returns nothing useful
1123
+ // Expected: the fallback path selects only the 2 private subnets (MapPublicIpOnLaunch=false)
1124
+ const mockStack = {
1125
+ StackName: 'test-stack',
1126
+ Outputs: [],
1127
+ };
1128
+
1129
+ const mockResources = [
1130
+ {
1131
+ LogicalResourceId: 'FriggLambdaRouteTable',
1132
+ PhysicalResourceId: 'rtb-lambda',
1133
+ ResourceType: 'AWS::EC2::RouteTable',
1134
+ },
1135
+ {
1136
+ LogicalResourceId: 'FriggVPC',
1137
+ PhysicalResourceId: 'vpc-456',
1138
+ ResourceType: 'AWS::EC2::VPC',
1139
+ },
1140
+ ];
1141
+
1142
+ const sendMock = jest.fn();
1143
+ sendMock
1144
+ // External reference extraction: route table with 0 subnet associations
1145
+ .mockResolvedValueOnce({
1146
+ RouteTables: [
1147
+ {
1148
+ RouteTableId: 'rtb-lambda',
1149
+ VpcId: 'vpc-456',
1150
+ Associations: [
1151
+ {
1152
+ RouteTableAssociationId: 'rtbassoc-main',
1153
+ Main: true,
1154
+ },
1155
+ ],
1156
+ Routes: [
1157
+ {
1158
+ NatGatewayId: 'nat-789',
1159
+ DestinationCidrBlock: '0.0.0.0/0',
1160
+ },
1161
+ ],
1162
+ },
1163
+ ],
1164
+ })
1165
+ // DescribeSecurityGroupsCommand — default SG
1166
+ .mockResolvedValueOnce({
1167
+ SecurityGroups: [{ GroupId: 'sg-default' }],
1168
+ })
1169
+ // Frigg-managed subnet query returns nothing, forcing the fallback path to run
1170
+ .mockResolvedValueOnce({
1171
+ Subnets: [],
1172
+ })
1173
+ // Fallback VPC-wide subnet query — 3 subnets (1 public, 2 private)
1174
+ .mockResolvedValueOnce({
1175
+ Subnets: [
1176
+ {
1177
+ SubnetId: 'subnet-public',
1178
+ VpcId: 'vpc-456',
1179
+ MapPublicIpOnLaunch: true,
1180
+ AvailabilityZone: 'us-east-1a',
1181
+ },
1182
+ {
1183
+ SubnetId: 'subnet-priv-1',
1184
+ VpcId: 'vpc-456',
1185
+ MapPublicIpOnLaunch: false,
1186
+ AvailabilityZone: 'us-east-1b',
1187
+ },
1188
+ {
1189
+ SubnetId: 'subnet-priv-2',
1190
+ VpcId: 'vpc-456',
1191
+ MapPublicIpOnLaunch: false,
1192
+ AvailabilityZone: 'us-east-1c',
1193
+ },
1194
+ ],
1195
+ })
1196
+ // Fallback route table query — lambda route table still has 0 subnet associations
1197
+ .mockResolvedValueOnce({
1198
+ RouteTables: [
1199
+ {
1200
+ RouteTableId: 'rtb-lambda',
1201
+ Associations: [
1202
+ {
1203
+ RouteTableAssociationId: 'rtbassoc-main',
1204
+ Main: true,
1205
+ },
1206
+ ],
1207
+ },
1208
+ ],
1209
+ });
1210
+
1211
+ mockProvider.describeStack.mockResolvedValue(mockStack);
1212
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
1213
+ mockProvider.getEC2Client = jest
1214
+ .fn()
1215
+ .mockReturnValue({ send: sendMock });
1216
+
1217
+ const result = await cfDiscovery.discoverFromStack('test-stack');
1218
+
1219
+ // Should select ONLY the 2 private subnets
1220
+ expect(result.privateSubnetId1).toBe('subnet-priv-1');
1221
+ expect(result.privateSubnetId2).toBe('subnet-priv-2');
1222
+
1223
+ // Should NOT select the public subnet
1224
+ expect(result.privateSubnetId1).not.toBe('subnet-public');
1225
+ expect(result.privateSubnetId2).not.toBe('subnet-public');
1226
+
1227
+ // Should record 0 associations for self-heal to detect
1228
+ expect(result.routeTableAssociationCount).toBe(0);
1229
+
1230
+ // Verify the test actually exercised the fallback path:
1231
+ // 1) route table query for external refs
1232
+ // 2) default security group query
1233
+ // 3) Frigg-managed subnet query (empty)
1234
+ // 4) all-subnets-in-VPC fallback query
1235
+ // 5) route table query for association extraction
1236
+ expect(sendMock).toHaveBeenCalledTimes(5);
1237
+ expect(sendMock.mock.calls[2][0].input.Filters).toEqual([
1238
+ { Name: 'vpc-id', Values: ['vpc-456'] },
1239
+ { Name: 'tag:ManagedBy', Values: ['Frigg'] },
1240
+ ]);
1241
+ expect(sendMock.mock.calls[3][0].input.Filters).toEqual([
1242
+ { Name: 'vpc-id', Values: ['vpc-456'] },
1243
+ ]);
1244
+ });
1245
+
1246
+ it('should handle VPC with only 1 associated subnet (use second as fallback)', async () => {
1247
+ const mockStack = {
1248
+ StackName: 'test-stack',
1249
+ Outputs: [],
1250
+ };
1251
+
1252
+ const mockResources = [
1253
+ {
1254
+ LogicalResourceId: 'FriggLambdaRouteTable',
1255
+ PhysicalResourceId: 'rtb-123',
1256
+ ResourceType: 'AWS::EC2::RouteTable',
1257
+ },
1258
+ {
1259
+ LogicalResourceId: 'FriggVPC',
1260
+ PhysicalResourceId: 'vpc-456',
1261
+ ResourceType: 'AWS::EC2::VPC',
1262
+ },
1263
+ ];
1264
+
1265
+ const sendMock = jest.fn();
1266
+ sendMock
1267
+ .mockResolvedValueOnce({
1268
+ RouteTables: [
1269
+ {
1270
+ RouteTableId: 'rtb-123',
1271
+ VpcId: 'vpc-456',
1272
+ Associations: [],
1273
+ Routes: [
1274
+ {
1275
+ NatGatewayId: 'nat-789',
1276
+ DestinationCidrBlock: '0.0.0.0/0',
1277
+ },
1278
+ ],
1279
+ },
1280
+ ],
1281
+ })
1282
+ .mockResolvedValueOnce({
1283
+ SecurityGroups: [{ GroupId: 'sg-default' }],
1284
+ })
1285
+ .mockResolvedValueOnce({
1286
+ Subnets: [
1287
+ { SubnetId: 'subnet-aaa', VpcId: 'vpc-456' },
1288
+ { SubnetId: 'subnet-bbb', VpcId: 'vpc-456' },
1289
+ ],
1290
+ })
1291
+ .mockResolvedValueOnce({
1292
+ RouteTables: [
1293
+ {
1294
+ RouteTableId: 'rtb-123',
1295
+ Associations: [
1296
+ {
1297
+ RouteTableAssociationId: 'rtbassoc-111',
1298
+ SubnetId: 'subnet-aaa',
1299
+ },
1300
+ ],
1301
+ },
1302
+ ],
1303
+ });
1304
+
1305
+ const mockEC2Client = { send: sendMock };
1306
+
1307
+ mockProvider.describeStack.mockResolvedValue(mockStack);
1308
+ mockProvider.listStackResources.mockResolvedValue(mockResources);
1309
+ mockProvider.getEC2Client = jest
1310
+ .fn()
1311
+ .mockReturnValue(mockEC2Client);
1312
+
1313
+ const result = await cfDiscovery.discoverFromStack('test-stack');
1314
+
1315
+ // Should use first from route table, second from fallback
1316
+ expect(result.privateSubnetId1).toBe('subnet-aaa');
1317
+ expect(result.privateSubnetId2).toBe('subnet-bbb');
1318
+ });
1319
+ });
1320
+ });