@friggframework/devtools 2.0.0-next.11 → 2.0.0-next.110

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 (247) 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/generate-iam.test.js +97 -0
  7. package/frigg-cli/__tests__/unit/commands/install.test.js +400 -0
  8. package/frigg-cli/__tests__/unit/commands/ui.test.js +346 -0
  9. package/frigg-cli/__tests__/unit/dependencies.test.js +74 -0
  10. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +397 -0
  11. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +345 -0
  12. package/frigg-cli/__tests__/unit/version-detection.test.js +171 -0
  13. package/frigg-cli/__tests__/utils/mock-factory.js +270 -0
  14. package/frigg-cli/__tests__/utils/prisma-mock.js +194 -0
  15. package/frigg-cli/__tests__/utils/test-fixtures.js +463 -0
  16. package/frigg-cli/__tests__/utils/test-setup.js +287 -0
  17. package/frigg-cli/auth-command/CLAUDE.md +293 -0
  18. package/frigg-cli/auth-command/README.md +450 -0
  19. package/frigg-cli/auth-command/api-key-flow.js +153 -0
  20. package/frigg-cli/auth-command/auth-tester.js +344 -0
  21. package/frigg-cli/auth-command/credential-storage.js +182 -0
  22. package/frigg-cli/auth-command/index.js +256 -0
  23. package/frigg-cli/auth-command/json-schema-form.js +67 -0
  24. package/frigg-cli/auth-command/module-loader.js +172 -0
  25. package/frigg-cli/auth-command/oauth-callback-server.js +431 -0
  26. package/frigg-cli/auth-command/oauth-flow.js +195 -0
  27. package/frigg-cli/auth-command/utils/browser.js +30 -0
  28. package/frigg-cli/build-command/index.js +45 -12
  29. package/frigg-cli/db-setup-command/index.js +246 -0
  30. package/frigg-cli/deploy-command/SPEC-DEPLOY-DRY-RUN.md +981 -0
  31. package/frigg-cli/deploy-command/index.js +339 -23
  32. package/frigg-cli/doctor-command/index.js +335 -0
  33. package/frigg-cli/generate-command/__tests__/generate-command.test.js +301 -0
  34. package/frigg-cli/generate-command/azure-generator.js +43 -0
  35. package/frigg-cli/generate-command/gcp-generator.js +47 -0
  36. package/frigg-cli/generate-command/index.js +333 -0
  37. package/frigg-cli/generate-command/terraform-generator.js +555 -0
  38. package/frigg-cli/generate-iam-command.js +119 -0
  39. package/frigg-cli/index.js +189 -1
  40. package/frigg-cli/index.test.js +1 -4
  41. package/frigg-cli/init-command/backend-first-handler.js +756 -0
  42. package/frigg-cli/init-command/index.js +93 -0
  43. package/frigg-cli/init-command/template-handler.js +143 -0
  44. package/frigg-cli/install-command/index.js +1 -4
  45. package/frigg-cli/jest.config.js +124 -0
  46. package/frigg-cli/package.json +63 -0
  47. package/frigg-cli/repair-command/index.js +564 -0
  48. package/frigg-cli/ssm-command/index.js +308 -0
  49. package/frigg-cli/ssm-command/index.test.js +318 -0
  50. package/frigg-cli/start-command/index.js +118 -5
  51. package/frigg-cli/start-command/start-command.test.js +297 -0
  52. package/frigg-cli/test/init-command.test.js +180 -0
  53. package/frigg-cli/test/npm-registry.test.js +319 -0
  54. package/frigg-cli/ui-command/index.js +154 -0
  55. package/frigg-cli/utils/app-resolver.js +319 -0
  56. package/frigg-cli/utils/backend-path.js +16 -17
  57. package/frigg-cli/utils/database-validator.js +167 -0
  58. package/frigg-cli/utils/error-messages.js +329 -0
  59. package/frigg-cli/utils/npm-registry.js +167 -0
  60. package/frigg-cli/utils/process-manager.js +199 -0
  61. package/frigg-cli/utils/repo-detection.js +405 -0
  62. package/infrastructure/ARCHITECTURE.md +487 -0
  63. package/infrastructure/CLAUDE.md +481 -0
  64. package/infrastructure/HEALTH.md +468 -0
  65. package/infrastructure/README.md +540 -0
  66. package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
  67. package/infrastructure/__tests__/helpers/test-utils.js +275 -0
  68. package/infrastructure/__tests__/postgres-config.test.js +914 -0
  69. package/infrastructure/__tests__/scoped-environment.test.js +126 -0
  70. package/infrastructure/__tests__/ssm-preload-node-options.test.js +79 -0
  71. package/infrastructure/__tests__/template-generation.test.js +687 -0
  72. package/infrastructure/create-frigg-infrastructure.js +129 -20
  73. package/infrastructure/docs/POSTGRES-CONFIGURATION.md +630 -0
  74. package/infrastructure/docs/PRE-DEPLOYMENT-HEALTH-CHECK-SPEC.md +1317 -0
  75. package/infrastructure/docs/WEBSOCKET-CONFIGURATION.md +105 -0
  76. package/infrastructure/docs/deployment-instructions.md +268 -0
  77. package/infrastructure/docs/generate-iam-command.md +278 -0
  78. package/infrastructure/docs/iam-policy-templates.md +193 -0
  79. package/infrastructure/domains/admin-scripts/admin-script-builder.js +567 -0
  80. package/infrastructure/domains/admin-scripts/admin-script-builder.test.js +1017 -0
  81. package/infrastructure/domains/admin-scripts/index.js +5 -0
  82. package/infrastructure/domains/database/aurora-builder.js +857 -0
  83. package/infrastructure/domains/database/aurora-builder.test.js +960 -0
  84. package/infrastructure/domains/database/aurora-discovery.js +87 -0
  85. package/infrastructure/domains/database/aurora-discovery.test.js +188 -0
  86. package/infrastructure/domains/database/aurora-resolver.js +210 -0
  87. package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
  88. package/infrastructure/domains/database/migration-builder.js +757 -0
  89. package/infrastructure/domains/database/migration-builder.test.js +454 -0
  90. package/infrastructure/domains/database/migration-resolver.js +163 -0
  91. package/infrastructure/domains/database/migration-resolver.test.js +337 -0
  92. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  93. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  94. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  95. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  96. package/infrastructure/domains/health/application/ports/index.js +26 -0
  97. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  98. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  99. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  100. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  101. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +152 -0
  102. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
  103. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +535 -0
  104. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
  105. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +213 -0
  106. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
  107. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  108. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  109. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  110. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  111. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  112. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  113. package/infrastructure/domains/health/domain/entities/issue.js +299 -0
  114. package/infrastructure/domains/health/domain/entities/issue.test.js +528 -0
  115. package/infrastructure/domains/health/domain/entities/property-mismatch.js +108 -0
  116. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +275 -0
  117. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  118. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  119. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  120. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  121. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  122. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  123. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  124. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  125. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  126. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  127. package/infrastructure/domains/health/domain/services/health-score-calculator.js +248 -0
  128. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +504 -0
  129. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  130. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  131. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  132. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  133. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  134. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  135. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  136. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  137. package/infrastructure/domains/health/domain/value-objects/health-score.js +138 -0
  138. package/infrastructure/domains/health/domain/value-objects/health-score.test.js +267 -0
  139. package/infrastructure/domains/health/domain/value-objects/property-mutability.js +161 -0
  140. package/infrastructure/domains/health/domain/value-objects/property-mutability.test.js +198 -0
  141. package/infrastructure/domains/health/domain/value-objects/resource-state.js +167 -0
  142. package/infrastructure/domains/health/domain/value-objects/resource-state.test.js +196 -0
  143. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +192 -0
  144. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +262 -0
  145. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  146. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  147. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  148. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +784 -0
  149. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +1133 -0
  150. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +565 -0
  151. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +554 -0
  152. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.js +318 -0
  153. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.test.js +398 -0
  154. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +777 -0
  155. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
  156. package/infrastructure/domains/integration/integration-builder.js +656 -0
  157. package/infrastructure/domains/integration/integration-builder.test.js +1064 -0
  158. package/infrastructure/domains/integration/integration-resolver.js +170 -0
  159. package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
  160. package/infrastructure/domains/integration/websocket-builder.js +69 -0
  161. package/infrastructure/domains/integration/websocket-builder.test.js +195 -0
  162. package/infrastructure/domains/networking/vpc-builder.js +2101 -0
  163. package/infrastructure/domains/networking/vpc-builder.test.js +2001 -0
  164. package/infrastructure/domains/networking/vpc-discovery.js +184 -0
  165. package/infrastructure/domains/networking/vpc-discovery.test.js +368 -0
  166. package/infrastructure/domains/networking/vpc-resolver.js +514 -0
  167. package/infrastructure/domains/networking/vpc-resolver.test.js +841 -0
  168. package/infrastructure/domains/parameters/offload-utils.js +190 -0
  169. package/infrastructure/domains/parameters/offload-utils.test.js +193 -0
  170. package/infrastructure/domains/parameters/ssm-builder.js +132 -0
  171. package/infrastructure/domains/parameters/ssm-builder.test.js +346 -0
  172. package/infrastructure/domains/parameters/ssm-discovery.js +84 -0
  173. package/infrastructure/domains/parameters/ssm-discovery.test.js +210 -0
  174. package/infrastructure/domains/scheduler/scheduler-builder.js +247 -0
  175. package/infrastructure/domains/scheduler/scheduler-builder.test.js +118 -0
  176. package/infrastructure/domains/security/iam-generator.js +851 -0
  177. package/infrastructure/domains/security/iam-generator.test.js +287 -0
  178. package/infrastructure/domains/security/kms-builder.js +415 -0
  179. package/infrastructure/domains/security/kms-builder.test.js +392 -0
  180. package/infrastructure/domains/security/kms-discovery.js +80 -0
  181. package/infrastructure/domains/security/kms-discovery.test.js +177 -0
  182. package/infrastructure/domains/security/kms-resolver.js +96 -0
  183. package/infrastructure/domains/security/kms-resolver.test.js +216 -0
  184. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +418 -0
  185. package/infrastructure/domains/security/templates/iam-policy-basic.json +218 -0
  186. package/infrastructure/domains/security/templates/iam-policy-full.json +293 -0
  187. package/infrastructure/domains/shared/base-builder.js +112 -0
  188. package/infrastructure/domains/shared/base-resolver.js +186 -0
  189. package/infrastructure/domains/shared/base-resolver.test.js +305 -0
  190. package/infrastructure/domains/shared/builder-orchestrator.js +226 -0
  191. package/infrastructure/domains/shared/builder-orchestrator.test.js +258 -0
  192. package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
  193. package/infrastructure/domains/shared/cloudformation-discovery.js +681 -0
  194. package/infrastructure/domains/shared/cloudformation-discovery.test.js +1320 -0
  195. package/infrastructure/domains/shared/environment-builder.js +192 -0
  196. package/infrastructure/domains/shared/environment-builder.test.js +529 -0
  197. package/infrastructure/domains/shared/function-environments.js +97 -0
  198. package/infrastructure/domains/shared/function-environments.test.js +146 -0
  199. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +579 -0
  200. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +416 -0
  201. package/infrastructure/domains/shared/providers/azure-provider-adapter.stub.js +93 -0
  202. package/infrastructure/domains/shared/providers/cloud-provider-adapter.js +136 -0
  203. package/infrastructure/domains/shared/providers/gcp-provider-adapter.stub.js +82 -0
  204. package/infrastructure/domains/shared/providers/provider-factory.js +108 -0
  205. package/infrastructure/domains/shared/providers/provider-factory.test.js +170 -0
  206. package/infrastructure/domains/shared/resource-discovery.enhanced.test.js +306 -0
  207. package/infrastructure/domains/shared/resource-discovery.js +256 -0
  208. package/infrastructure/domains/shared/resource-discovery.test.js +757 -0
  209. package/infrastructure/domains/shared/types/app-definition.js +225 -0
  210. package/infrastructure/domains/shared/types/discovery-result.js +106 -0
  211. package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
  212. package/infrastructure/domains/shared/types/index.js +46 -0
  213. package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
  214. package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
  215. package/infrastructure/domains/shared/utilities/base-definition-factory.js +409 -0
  216. package/infrastructure/domains/shared/utilities/base-definition-factory.js.bak +338 -0
  217. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +319 -0
  218. package/infrastructure/domains/shared/utilities/handler-path-resolver.js +134 -0
  219. package/infrastructure/domains/shared/utilities/handler-path-resolver.test.js +268 -0
  220. package/infrastructure/domains/shared/utilities/nested-node-modules.js +53 -0
  221. package/infrastructure/domains/shared/utilities/nested-node-modules.test.js +61 -0
  222. package/infrastructure/domains/shared/utilities/prisma-layer-manager.js +159 -0
  223. package/infrastructure/domains/shared/utilities/prisma-layer-manager.test.js +444 -0
  224. package/infrastructure/domains/shared/validation/env-validator.js +78 -0
  225. package/infrastructure/domains/shared/validation/env-validator.test.js +173 -0
  226. package/infrastructure/domains/shared/validation/plugin-validator.js +187 -0
  227. package/infrastructure/domains/shared/validation/plugin-validator.test.js +323 -0
  228. package/infrastructure/esbuild.config.js +53 -0
  229. package/infrastructure/infrastructure-composer.js +168 -0
  230. package/infrastructure/infrastructure-composer.test.js +2042 -0
  231. package/infrastructure/integration.test.js +381 -0
  232. package/infrastructure/scripts/build-prisma-layer.js +701 -0
  233. package/infrastructure/scripts/build-prisma-layer.test.js +170 -0
  234. package/infrastructure/scripts/build-time-discovery.js +238 -0
  235. package/infrastructure/scripts/build-time-discovery.test.js +379 -0
  236. package/infrastructure/scripts/run-discovery.js +110 -0
  237. package/infrastructure/scripts/verify-prisma-layer.js +72 -0
  238. package/management-ui/README.md +203 -0
  239. package/package.json +48 -16
  240. package/test/index.js +2 -4
  241. package/test/mock-api.js +1 -3
  242. package/test/mock-integration.js +4 -14
  243. package/.eslintrc.json +0 -3
  244. package/CHANGELOG.md +0 -132
  245. package/infrastructure/serverless-template.js +0 -283
  246. package/infrastructure/webpack.config.js +0 -20
  247. package/test/auther-definition-tester.js +0 -125
@@ -0,0 +1,2042 @@
1
+ const {
2
+ composeServerlessDefinition,
3
+ applySsmPreloadNodeOptions,
4
+ } = require('./infrastructure-composer');
5
+ const {
6
+ SSM_PRELOAD_NODE_OPTIONS,
7
+ } = require('./domains/parameters/offload-utils');
8
+
9
+ // Helper to build discovery responses with overridable fields
10
+ const createDiscoveryResponse = (overrides = {}) => ({
11
+ defaultVpcId: 'vpc-123456',
12
+ vpcCidr: '172.31.0.0/16', // Provide VPC CIDR so security group fallbacks can be tested
13
+ defaultSecurityGroupId: 'sg-123456',
14
+ privateSubnetId1: 'subnet-123456',
15
+ privateSubnetId2: 'subnet-789012',
16
+ publicSubnetId: 'subnet-public', // Keep for backward compat
17
+ publicSubnetId1: 'subnet-public-1',
18
+ publicSubnetId2: 'subnet-public-2',
19
+ defaultRouteTableId: 'rtb-123456',
20
+ defaultKmsKeyId:
21
+ 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012',
22
+ existingNatGatewayId: 'nat-default123',
23
+ auroraClusterEndpoint: 'test-cluster.cluster-abc123.us-east-1.rds.amazonaws.com',
24
+ auroraPort: 5432,
25
+ auroraEngine: 'aurora-postgresql',
26
+ ...overrides,
27
+ });
28
+
29
+ // Mock resource discovery to prevent actual AWS calls
30
+ jest.mock('./domains/shared/resource-discovery', () => {
31
+ const originalModule = jest.requireActual('./domains/shared/resource-discovery');
32
+ return {
33
+ ...originalModule,
34
+ gatherDiscoveredResources: jest.fn().mockResolvedValue(createDiscoveryResponse()),
35
+ };
36
+ });
37
+
38
+ const { gatherDiscoveredResources } = require('./domains/shared/resource-discovery');
39
+
40
+ describe('composeServerlessDefinition', () => {
41
+ let mockIntegration;
42
+
43
+ beforeEach(() => {
44
+ // Reset the mock to default behavior
45
+ gatherDiscoveredResources.mockResolvedValue(createDiscoveryResponse());
46
+
47
+ mockIntegration = {
48
+ Definition: {
49
+ name: 'testIntegration'
50
+ }
51
+ };
52
+
53
+ // Mock process.argv to avoid offline mode during tests
54
+ process.argv = ['node', 'test'];
55
+
56
+ // Clear AWS_REGION for tests
57
+ delete process.env.AWS_REGION;
58
+ });
59
+
60
+ afterEach(() => {
61
+ jest.clearAllMocks();
62
+ // Restore env
63
+ delete process.env.AWS_REGION;
64
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
65
+ process.argv = ['node', 'test'];
66
+ });
67
+
68
+ describe('AWS discovery gating', () => {
69
+ it('should skip AWS discovery when no features require it', async () => {
70
+ gatherDiscoveredResources.mockClear();
71
+
72
+ const appDefinition = {
73
+ integrations: [],
74
+ vpc: { enable: false },
75
+ encryption: { fieldLevelEncryptionMethod: 'aes' },
76
+ ssm: { enable: false },
77
+ };
78
+
79
+ await composeServerlessDefinition(appDefinition);
80
+
81
+ expect(gatherDiscoveredResources).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it('should run AWS discovery when VPC features are enabled', async () => {
85
+ gatherDiscoveredResources.mockClear();
86
+
87
+ const appDefinition = {
88
+ integrations: [],
89
+ vpc: { enable: true },
90
+ };
91
+
92
+ await composeServerlessDefinition(appDefinition);
93
+
94
+ expect(gatherDiscoveredResources).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('should skip AWS discovery when FRIGG_SKIP_AWS_DISCOVERY is set to true', async () => {
98
+ gatherDiscoveredResources.mockClear();
99
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
100
+
101
+ const appDefinition = {
102
+ integrations: [],
103
+ vpc: { enable: true },
104
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
105
+ ssm: { enable: true },
106
+ };
107
+
108
+ await composeServerlessDefinition(appDefinition);
109
+
110
+ expect(gatherDiscoveredResources).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should run AWS discovery when FRIGG_SKIP_AWS_DISCOVERY is not set', async () => {
114
+ gatherDiscoveredResources.mockClear();
115
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
116
+
117
+ const appDefinition = {
118
+ integrations: [],
119
+ vpc: { enable: true },
120
+ };
121
+
122
+ await composeServerlessDefinition(appDefinition);
123
+
124
+ expect(gatherDiscoveredResources).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it('should skip VPC configuration when FRIGG_SKIP_AWS_DISCOVERY is true', async () => {
128
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = 'true';
129
+
130
+ const appDefinition = {
131
+ integrations: [],
132
+ vpc: { enable: true, management: 'discover' },
133
+ };
134
+
135
+ const result = await composeServerlessDefinition(appDefinition);
136
+
137
+ // VPC configuration should not be present when skipped
138
+ expect(result.provider.vpc).toBeUndefined();
139
+ });
140
+ });
141
+
142
+ describe('Basic Configuration', () => {
143
+ it('should create basic serverless definition with minimal app definition', async () => {
144
+ const appDefinition = {
145
+ name: 'test-app',
146
+ integrations: []
147
+ };
148
+
149
+ const result = await composeServerlessDefinition(appDefinition);
150
+
151
+ expect(result.service).toBe('test-app');
152
+ expect(result.provider.name).toBe('aws');
153
+ expect(result.provider.runtime).toBe('nodejs20.x');
154
+ expect(result.provider.region).toBe('us-east-1');
155
+ expect(result.provider.stage).toBe('${opt:stage}');
156
+ expect(result.frameworkVersion).toBe('>=3.17.0');
157
+ });
158
+
159
+ it('should use default service name when name not provided', async () => {
160
+ const appDefinition = {
161
+ integrations: []
162
+ };
163
+
164
+ const result = await composeServerlessDefinition(appDefinition);
165
+
166
+ expect(result.service).toBe('create-frigg-app');
167
+ });
168
+
169
+ it('should use custom provider when specified', async () => {
170
+ const appDefinition = {
171
+ provider: 'custom-provider',
172
+ integrations: []
173
+ };
174
+
175
+ const result = await composeServerlessDefinition(appDefinition);
176
+
177
+ expect(result.provider.name).toBe('custom-provider');
178
+ });
179
+
180
+ it('should use AWS_REGION environment variable when set', async () => {
181
+ process.env.AWS_REGION = 'eu-west-1';
182
+
183
+ const appDefinition = {
184
+ name: 'test-app',
185
+ integrations: []
186
+ };
187
+
188
+ const result = await composeServerlessDefinition(appDefinition);
189
+
190
+ expect(result.provider.region).toBe('eu-west-1');
191
+ expect(result.custom['serverless-offline-sqs'].region).toBe('eu-west-1');
192
+ });
193
+
194
+ it('should default to us-east-1 when AWS_REGION is not set', async () => {
195
+ delete process.env.AWS_REGION;
196
+
197
+ const appDefinition = {
198
+ name: 'test-app',
199
+ integrations: []
200
+ };
201
+
202
+ const result = await composeServerlessDefinition(appDefinition);
203
+
204
+ expect(result.provider.region).toBe('us-east-1');
205
+ expect(result.custom['serverless-offline-sqs'].region).toBe('us-east-1');
206
+ });
207
+ });
208
+
209
+ describe('Environment variables', () => {
210
+ it('should include only non-reserved environment flags', async () => {
211
+ const appDefinition = {
212
+ integrations: [],
213
+ environment: {
214
+ CUSTOM_FLAG: true,
215
+ AWS_REGION: true,
216
+ OPTIONAL_DISABLED: false,
217
+ },
218
+ };
219
+
220
+ const result = await composeServerlessDefinition(appDefinition);
221
+
222
+ expect(result.provider.environment.CUSTOM_FLAG).toBe("${env:CUSTOM_FLAG, ''}");
223
+ expect(result.provider.environment).not.toHaveProperty('AWS_REGION');
224
+ expect(result.provider.environment).not.toHaveProperty('OPTIONAL_DISABLED');
225
+ });
226
+
227
+ it('should ignore string-valued environment entries', async () => {
228
+ const appDefinition = {
229
+ integrations: [],
230
+ environment: {
231
+ CUSTOM_FLAG: 'enabled',
232
+ },
233
+ };
234
+
235
+ const result = await composeServerlessDefinition(appDefinition);
236
+
237
+ expect(result.provider.environment).not.toHaveProperty('CUSTOM_FLAG');
238
+ });
239
+ });
240
+
241
+ describe('VPC Configuration', () => {
242
+ it('should add VPC configuration when vpc.enable is true with discover mode', async () => {
243
+ const appDefinition = {
244
+ vpc: {
245
+ enable: true,
246
+ management: 'discover'
247
+ },
248
+ integrations: []
249
+ };
250
+
251
+ const result = await composeServerlessDefinition(appDefinition);
252
+
253
+ expect(result.provider.vpc).toBeDefined();
254
+ expect(result.provider.vpc.securityGroupIds).toEqual(['sg-123456']);
255
+ expect(result.provider.vpc.subnetIds).toEqual(['subnet-123456', 'subnet-789012']);
256
+ });
257
+
258
+ it('should create new VPC infrastructure when management is create-new', async () => {
259
+ const appDefinition = {
260
+ vpc: {
261
+ enable: true,
262
+ management: 'create-new'
263
+ },
264
+ integrations: []
265
+ };
266
+
267
+ const result = await composeServerlessDefinition(appDefinition);
268
+
269
+ expect(result.provider.vpc).toBeDefined();
270
+ expect(result.provider.vpc.securityGroupIds).toEqual([{ Ref: 'FriggLambdaSecurityGroup' }]);
271
+ expect(result.provider.vpc.subnetIds).toEqual([
272
+ { Ref: 'FriggPrivateSubnet1' },
273
+ { Ref: 'FriggPrivateSubnet2' }
274
+ ]);
275
+ expect(result.resources.Resources.FriggVPC).toBeDefined();
276
+ expect(result.resources.Resources.FriggLambdaSecurityGroup).toBeDefined();
277
+ });
278
+
279
+ it('should use provided VPC resources when management is use-existing', async () => {
280
+ const appDefinition = {
281
+ vpc: {
282
+ enable: true,
283
+ management: 'use-existing',
284
+ vpcId: 'vpc-custom123',
285
+ subnets: {
286
+ ids: ['subnet-custom1', 'subnet-custom2']
287
+ }
288
+ },
289
+ integrations: []
290
+ };
291
+
292
+ const result = await composeServerlessDefinition(appDefinition);
293
+
294
+ expect(result.provider.vpc).toBeDefined();
295
+ expect(result.provider.vpc.subnetIds).toEqual(['subnet-custom1', 'subnet-custom2']);
296
+ });
297
+
298
+ // Test all 9 combinations of VPC and Subnet management modes
299
+ it('should handle create-new VPC with create subnets', async () => {
300
+ const appDefinition = {
301
+ vpc: {
302
+ enable: true,
303
+ management: 'create-new',
304
+ subnets: { management: 'create' }
305
+ },
306
+ integrations: []
307
+ };
308
+
309
+ const result = await composeServerlessDefinition(appDefinition);
310
+
311
+ expect(result.resources.Resources.FriggVPC).toBeDefined();
312
+ expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
313
+ expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
314
+ expect(result.provider.vpc.subnetIds).toEqual([
315
+ { Ref: 'FriggPrivateSubnet1' },
316
+ { Ref: 'FriggPrivateSubnet2' }
317
+ ]);
318
+ });
319
+
320
+ it('should handle discover VPC with create subnets', async () => {
321
+ const appDefinition = {
322
+ vpc: {
323
+ enable: true,
324
+ management: 'discover',
325
+ subnets: { management: 'create' }
326
+ },
327
+ integrations: []
328
+ };
329
+
330
+ const result = await composeServerlessDefinition(appDefinition);
331
+
332
+ expect(result.resources.Resources.FriggVPC).toBeUndefined();
333
+ expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
334
+ expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
335
+ expect(result.resources.Resources.FriggPrivateSubnet1.Properties.VpcId).toBe('vpc-123456');
336
+ });
337
+
338
+ it('should handle use-existing VPC with create subnets', async () => {
339
+ const appDefinition = {
340
+ vpc: {
341
+ enable: true,
342
+ management: 'use-existing',
343
+ vpcId: 'vpc-existing123',
344
+ subnets: { management: 'create' }
345
+ },
346
+ integrations: []
347
+ };
348
+
349
+ const result = await composeServerlessDefinition(appDefinition);
350
+
351
+ expect(result.resources.Resources.FriggVPC).toBeUndefined();
352
+ expect(result.resources.Resources.FriggPrivateSubnet1).toBeDefined();
353
+ expect(result.resources.Resources.FriggPrivateSubnet2).toBeDefined();
354
+ expect(result.resources.Resources.FriggPrivateSubnet1.Properties.VpcId).toBe('vpc-existing123');
355
+ });
356
+
357
+ it('should handle use-existing VPC with use-existing subnets', async () => {
358
+ const appDefinition = {
359
+ vpc: {
360
+ enable: true,
361
+ management: 'use-existing',
362
+ vpcId: 'vpc-custom',
363
+ subnets: {
364
+ management: 'use-existing',
365
+ ids: ['subnet-explicit1', 'subnet-explicit2']
366
+ }
367
+ },
368
+ integrations: []
369
+ };
370
+
371
+ const result = await composeServerlessDefinition(appDefinition);
372
+
373
+ expect(result.resources.Resources.FriggPrivateSubnet1).toBeUndefined();
374
+ expect(result.resources.Resources.FriggPrivateSubnet2).toBeUndefined();
375
+ expect(result.provider.vpc.subnetIds).toEqual(['subnet-explicit1', 'subnet-explicit2']);
376
+ });
377
+
378
+ it('should respect provided security group IDs when supplied', async () => {
379
+ const appDefinition = {
380
+ vpc: {
381
+ enable: true,
382
+ management: 'discover',
383
+ securityGroupIds: ['sg-custom-1', 'sg-custom-2'],
384
+ },
385
+ integrations: [],
386
+ };
387
+
388
+ const result = await composeServerlessDefinition(appDefinition);
389
+
390
+ expect(result.provider.vpc.securityGroupIds).toEqual([
391
+ 'sg-custom-1',
392
+ 'sg-custom-2',
393
+ ]);
394
+ });
395
+
396
+ it('should throw when discover mode finds no subnets and self-heal is disabled', async () => {
397
+ const discoveryInstance = {
398
+ discoverResources: jest.fn().mockResolvedValue(
399
+ createDiscoveryResponse({
400
+ privateSubnetId1: null,
401
+ privateSubnetId2: null,
402
+ })
403
+ ),
404
+ };
405
+ AWSDiscovery.mockImplementation(() => discoveryInstance);
406
+
407
+ const appDefinition = {
408
+ vpc: {
409
+ enable: true,
410
+ management: 'discover',
411
+ subnets: { management: 'discover' },
412
+ },
413
+ integrations: [],
414
+ };
415
+
416
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
417
+ 'No subnets discovered and subnets.management is "discover". Either enable vpc.selfHeal, set subnets.management to "create", or provide subnet IDs.'
418
+ );
419
+ });
420
+
421
+ it('should use Fn::Cidr for subnet CIDR blocks in new VPC to avoid conflicts', async () => {
422
+ const appDefinition = {
423
+ vpc: {
424
+ enable: true,
425
+ management: 'create-new',
426
+ subnets: { management: 'create' }
427
+ },
428
+ integrations: []
429
+ };
430
+
431
+ const result = await composeServerlessDefinition(appDefinition);
432
+
433
+ // Verify new VPC uses 10.0.0.0/16
434
+ expect(result.resources.Resources.FriggVPC.Properties.CidrBlock).toBe('10.0.0.0/16');
435
+
436
+ // Verify subnets use Fn::Cidr to generate non-conflicting CIDRs
437
+ const subnet1Cidr = result.resources.Resources.FriggPrivateSubnet1.Properties.CidrBlock;
438
+ const subnet2Cidr = result.resources.Resources.FriggPrivateSubnet2.Properties.CidrBlock;
439
+ const publicSubnetCidr = result.resources.Resources.FriggPublicSubnet.Properties.CidrBlock;
440
+
441
+ // Check that CIDRs are generated using Fn::Cidr and Fn::Select
442
+ expect(subnet1Cidr).toHaveProperty('Fn::Select');
443
+ expect(subnet1Cidr['Fn::Select'][0]).toBe(0);
444
+ expect(subnet2Cidr).toHaveProperty('Fn::Select');
445
+ expect(subnet2Cidr['Fn::Select'][0]).toBe(1);
446
+ expect(publicSubnetCidr).toHaveProperty('Fn::Select');
447
+ expect(publicSubnetCidr['Fn::Select'][0]).toBe(2);
448
+ });
449
+
450
+ it('should create route tables for subnets even without NAT Gateway management', async () => {
451
+ const appDefinition = {
452
+ vpc: {
453
+ enable: true,
454
+ management: 'create-new',
455
+ subnets: { management: 'create' },
456
+ natGateway: { management: 'discover' }
457
+ },
458
+ integrations: []
459
+ };
460
+
461
+ const result = await composeServerlessDefinition(appDefinition);
462
+
463
+ // Verify route tables are created
464
+ expect(result.resources.Resources.FriggPublicRouteTable).toBeDefined();
465
+ expect(result.resources.Resources.FriggPublicRoute).toBeDefined();
466
+ expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
467
+
468
+ // Verify subnet associations
469
+ expect(result.resources.Resources.FriggPublicSubnetRouteTableAssociation).toBeDefined();
470
+ expect(result.resources.Resources.FriggPrivateSubnet1RouteTableAssociation).toBeDefined();
471
+ expect(result.resources.Resources.FriggPrivateSubnet2RouteTableAssociation).toBeDefined();
472
+ });
473
+
474
+ it('should throw error when use-existing mode without vpcId', async () => {
475
+ const appDefinition = {
476
+ vpc: {
477
+ enable: true,
478
+ management: 'use-existing'
479
+ },
480
+ integrations: []
481
+ };
482
+
483
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
484
+ 'VPC management is set to "use-existing" but no vpcId was provided'
485
+ );
486
+ });
487
+
488
+ it('should default to discover mode when management not specified', async () => {
489
+ const appDefinition = {
490
+ vpc: { enable: true },
491
+ integrations: []
492
+ };
493
+
494
+ const result = await composeServerlessDefinition(appDefinition);
495
+
496
+ expect(result.provider.vpc).toBeDefined();
497
+ expect(result.provider.vpc.securityGroupIds).toEqual(['sg-123456']);
498
+ expect(result.provider.vpc.subnetIds).toEqual(['subnet-123456', 'subnet-789012']);
499
+ });
500
+
501
+ it('should add VPC endpoint for S3 when VPC is enabled', async () => {
502
+ const appDefinition = {
503
+ vpc: {
504
+ enable: true,
505
+ management: 'discover'
506
+ },
507
+ integrations: []
508
+ };
509
+
510
+ const result = await composeServerlessDefinition(appDefinition);
511
+
512
+ expect(result.resources.Resources.VPCEndpointS3).toBeDefined();
513
+ expect(result.resources.Resources.VPCEndpointS3.Type).toBe('AWS::EC2::VPCEndpoint');
514
+ expect(result.resources.Resources.VPCEndpointS3.Properties.VpcId).toBe('vpc-123456');
515
+ });
516
+
517
+ it('should skip creating VPC endpoints when disabled explicitly', async () => {
518
+ const appDefinition = {
519
+ vpc: {
520
+ enable: true,
521
+ management: 'discover',
522
+ enableVPCEndpoints: false,
523
+ },
524
+ integrations: [],
525
+ };
526
+
527
+ const result = await composeServerlessDefinition(appDefinition);
528
+
529
+ expect(result.resources.Resources.VPCEndpointS3).toBeUndefined();
530
+ expect(result.resources.Resources.VPCEndpointKMS).toBeUndefined();
531
+ expect(result.resources.Resources.VPCEndpointSecretsManager).toBeUndefined();
532
+ });
533
+
534
+ it('should add Secrets Manager endpoint only when enabled', async () => {
535
+ const appDefinition = {
536
+ vpc: {
537
+ enable: true,
538
+ management: 'discover',
539
+ },
540
+ secretsManager: { enable: true },
541
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
542
+ integrations: [],
543
+ };
544
+
545
+ const result = await composeServerlessDefinition(appDefinition);
546
+
547
+ expect(result.resources.Resources.VPCEndpointSecretsManager).toBeDefined();
548
+ });
549
+
550
+ it('should allow Lambda security group access for VPC endpoints when security group is discovered', async () => {
551
+ const appDefinition = {
552
+ vpc: {
553
+ enable: true,
554
+ management: 'discover'
555
+ },
556
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
557
+ integrations: []
558
+ };
559
+
560
+ const result = await composeServerlessDefinition(appDefinition);
561
+ const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
562
+
563
+ expect(endpointSg).toBeDefined();
564
+ expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
565
+ {
566
+ IpProtocol: 'tcp',
567
+ FromPort: 443,
568
+ ToPort: 443,
569
+ SourceSecurityGroupId: 'sg-123456',
570
+ Description: 'HTTPS from Lambda security group'
571
+ }
572
+ ]);
573
+ });
574
+
575
+ it('should fall back to VPC CIDR when Lambda security group identifier cannot be resolved', async () => {
576
+ AWSDiscovery.mockImplementation(() => ({
577
+ discoverResources: jest
578
+ .fn()
579
+ .mockResolvedValue(createDiscoveryResponse()),
580
+ }));
581
+
582
+ const appDefinition = {
583
+ vpc: {
584
+ enable: true,
585
+ management: 'discover',
586
+ securityGroupIds: [
587
+ {
588
+ 'Fn::ImportValue': 'shared-lambda-security-group',
589
+ },
590
+ ],
591
+ },
592
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
593
+ integrations: [],
594
+ };
595
+
596
+ const result = await composeServerlessDefinition(appDefinition);
597
+ const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
598
+
599
+ expect(endpointSg).toBeDefined();
600
+ expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
601
+ {
602
+ IpProtocol: 'tcp',
603
+ FromPort: 443,
604
+ ToPort: 443,
605
+ CidrIp: '172.31.0.0/16',
606
+ Description: 'HTTPS from VPC CIDR (fallback)',
607
+ },
608
+ ]);
609
+ });
610
+
611
+ it('should fall back to default private ranges when neither Lambda security group nor VPC CIDR is available', async () => {
612
+ AWSDiscovery.mockImplementation(() => ({
613
+ discoverResources: jest
614
+ .fn()
615
+ .mockResolvedValue(
616
+ createDiscoveryResponse({ vpcCidr: null })
617
+ ),
618
+ }));
619
+
620
+ const appDefinition = {
621
+ vpc: {
622
+ enable: true,
623
+ management: 'discover',
624
+ securityGroupIds: [
625
+ {
626
+ 'Fn::ImportValue': 'shared-lambda-security-group',
627
+ },
628
+ ],
629
+ },
630
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
631
+ integrations: [],
632
+ };
633
+
634
+ const result = await composeServerlessDefinition(appDefinition);
635
+ const endpointSg = result.resources.Resources.VPCEndpointSecurityGroup;
636
+
637
+ expect(endpointSg).toBeDefined();
638
+ expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
639
+ {
640
+ IpProtocol: 'tcp',
641
+ FromPort: 443,
642
+ ToPort: 443,
643
+ CidrIp: '172.31.0.0/16',
644
+ Description: 'HTTPS from default VPC range',
645
+ },
646
+ ]);
647
+ });
648
+
649
+ it('should reference the Lambda security group when creating a new VPC', async () => {
650
+ const appDefinition = {
651
+ vpc: {
652
+ enable: true,
653
+ management: 'create-new'
654
+ },
655
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
656
+ integrations: []
657
+ };
658
+
659
+ const result = await composeServerlessDefinition(appDefinition);
660
+ const endpointSg = result.resources.Resources.FriggVPCEndpointSecurityGroup;
661
+
662
+ expect(endpointSg).toBeDefined();
663
+ expect(endpointSg.Properties.SecurityGroupIngress).toEqual([
664
+ {
665
+ IpProtocol: 'tcp',
666
+ FromPort: 443,
667
+ ToPort: 443,
668
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
669
+ Description: 'HTTPS from Lambda security group'
670
+ },
671
+ {
672
+ IpProtocol: 'tcp',
673
+ FromPort: 443,
674
+ ToPort: 443,
675
+ CidrIp: '10.0.0.0/16',
676
+ Description: 'HTTPS from VPC CIDR (fallback)'
677
+ }
678
+ ]);
679
+ });
680
+
681
+ it('should not add VPC configuration when vpc.enable is false', async () => {
682
+ const appDefinition = {
683
+ vpc: { enable: false },
684
+ integrations: []
685
+ };
686
+
687
+ const result = await composeServerlessDefinition(appDefinition);
688
+
689
+ expect(result.provider.vpc).toBeUndefined();
690
+ expect(result.resources.Resources.VPCEndpointS3).toBeUndefined();
691
+ });
692
+
693
+ it('should not add VPC configuration when vpc is not defined', async () => {
694
+ const appDefinition = {
695
+ integrations: []
696
+ };
697
+
698
+ const result = await composeServerlessDefinition(appDefinition);
699
+
700
+ expect(result.provider.vpc).toBeUndefined();
701
+ });
702
+ });
703
+
704
+ describe('NAT Gateway behaviour', () => {
705
+ it('should reuse discovered NAT gateway in discover mode without creating new resources', async () => {
706
+ const discoveryInstance = {
707
+ discoverResources: jest.fn().mockResolvedValue(
708
+ createDiscoveryResponse({
709
+ existingNatGatewayId: 'nat-existing123',
710
+ natGatewayInPrivateSubnet: false,
711
+ })
712
+ ),
713
+ };
714
+ AWSDiscovery.mockImplementation(() => discoveryInstance);
715
+
716
+ const appDefinition = {
717
+ vpc: {
718
+ enable: true,
719
+ management: 'discover',
720
+ natGateway: { management: 'discover' },
721
+ },
722
+ integrations: [],
723
+ };
724
+
725
+ const result = await composeServerlessDefinition(appDefinition);
726
+
727
+ expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
728
+ expect(result.resources.Resources.FriggNATRoute).toBeDefined();
729
+ });
730
+
731
+ it('should reference provided NAT gateway when management set to useExisting', async () => {
732
+ const discoveryInstance = {
733
+ discoverResources: jest.fn().mockResolvedValue(
734
+ createDiscoveryResponse({ existingNatGatewayId: null })
735
+ ),
736
+ };
737
+ AWSDiscovery.mockImplementation(() => discoveryInstance);
738
+
739
+ const appDefinition = {
740
+ vpc: {
741
+ enable: true,
742
+ management: 'discover',
743
+ natGateway: { management: 'useExisting', id: 'nat-custom-001' },
744
+ },
745
+ integrations: [],
746
+ };
747
+
748
+ const result = await composeServerlessDefinition(appDefinition);
749
+
750
+ expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
751
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom-001');
752
+ });
753
+
754
+ it('should reuse existing elastic IP allocation when creating managed NAT', async () => {
755
+ const discoveryInstance = {
756
+ discoverResources: jest.fn().mockResolvedValue(
757
+ createDiscoveryResponse({
758
+ existingNatGatewayId: null,
759
+ existingElasticIpAllocationId: 'eip-alloc-123',
760
+ })
761
+ ),
762
+ };
763
+ AWSDiscovery.mockImplementation(() => discoveryInstance);
764
+
765
+ const appDefinition = {
766
+ vpc: {
767
+ enable: true,
768
+ management: 'discover',
769
+ natGateway: { management: 'createAndManage' },
770
+ selfHeal: true,
771
+ },
772
+ integrations: [],
773
+ };
774
+
775
+ const result = await composeServerlessDefinition(appDefinition);
776
+
777
+ expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
778
+ expect(result.resources.Resources.FriggNATGateway.Properties.AllocationId).toBe(
779
+ 'eip-alloc-123'
780
+ );
781
+ });
782
+
783
+ it('should create a public subnet when discovery provides none', async () => {
784
+ const discoveryInstance = {
785
+ discoverResources: jest.fn().mockResolvedValue(
786
+ createDiscoveryResponse({
787
+ publicSubnetId: null,
788
+ internetGatewayId: null,
789
+ existingNatGatewayId: null,
790
+ })
791
+ ),
792
+ };
793
+ AWSDiscovery.mockImplementation(() => discoveryInstance);
794
+
795
+ const appDefinition = {
796
+ vpc: {
797
+ enable: true,
798
+ management: 'discover',
799
+ natGateway: { management: 'createAndManage' },
800
+ selfHeal: true,
801
+ },
802
+ integrations: [],
803
+ };
804
+
805
+ const result = await composeServerlessDefinition(appDefinition);
806
+
807
+ expect(result.resources.Resources.FriggPublicSubnet).toBeDefined();
808
+ expect(result.resources.Resources.FriggPublicRouteTable).toBeDefined();
809
+ });
810
+ });
811
+
812
+ describe('KMS Configuration', () => {
813
+ it('should add KMS configuration when encryption is enabled and key is found', async () => {
814
+ const appDefinition = {
815
+ encryption: { fieldLevelEncryptionMethod: 'kms' },
816
+ integrations: []
817
+ };
818
+
819
+ const result = await composeServerlessDefinition(appDefinition);
820
+
821
+ // Check IAM permissions
822
+ const kmsPermission = result.provider.iamRoleStatements.find(
823
+ statement => statement.Action.includes('kms:GenerateDataKey')
824
+ );
825
+ expect(kmsPermission).toEqual({
826
+ Effect: 'Allow',
827
+ Action: [
828
+ 'kms:GenerateDataKey',
829
+ 'kms:Decrypt'
830
+ ],
831
+ Resource: ['arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012']
832
+ });
833
+
834
+ // Check environment variable
835
+ expect(result.provider.environment.KMS_KEY_ARN).toBe('arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012');
836
+
837
+ // Check plugin
838
+ expect(result.plugins).toContain('serverless-kms-grants');
839
+
840
+ // Check custom configuration
841
+ expect(result.custom.kmsGrants).toEqual({
842
+ kmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
843
+ });
844
+
845
+ // Check KMS Alias resource is created for discovered key
846
+ expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined();
847
+ expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({
848
+ Type: 'AWS::KMS::Alias',
849
+ DeletionPolicy: 'Retain',
850
+ Properties: {
851
+ AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms',
852
+ TargetKeyId: 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'
853
+ }
854
+ });
855
+ });
856
+
857
+ it('should create new KMS key when encryption is enabled, no key found, and createResourceIfNoneFound is true', async () => {
858
+ // Mock AWS discovery to return no KMS key
859
+ const { AWSDiscovery } = require('./aws-discovery');
860
+ const mockDiscoverResources = jest.fn().mockResolvedValue({
861
+ defaultVpcId: 'vpc-123456',
862
+ defaultSecurityGroupId: 'sg-123456',
863
+ privateSubnetId1: 'subnet-123456',
864
+ privateSubnetId2: 'subnet-789012',
865
+ publicSubnetId: 'subnet-public',
866
+ defaultRouteTableId: 'rtb-123456',
867
+ defaultKmsKeyId: null // No KMS key found
868
+ });
869
+ AWSDiscovery.mockImplementation(() => ({
870
+ discoverResources: mockDiscoverResources
871
+ }));
872
+
873
+ const appDefinition = {
874
+ encryption: {
875
+ fieldLevelEncryptionMethod: 'kms',
876
+ createResourceIfNoneFound: true
877
+ },
878
+ integrations: []
879
+ };
880
+
881
+ const result = await composeServerlessDefinition(appDefinition);
882
+
883
+ // Check that KMS key resource was created with DeletionPolicy
884
+ expect(result.resources.Resources.FriggKMSKey).toEqual({
885
+ Type: 'AWS::KMS::Key',
886
+ DeletionPolicy: 'Retain',
887
+ UpdateReplacePolicy: 'Retain',
888
+ Properties: {
889
+ EnableKeyRotation: true,
890
+ Description: 'Frigg KMS key for field-level encryption',
891
+ KeyPolicy: {
892
+ Version: '2012-10-17',
893
+ Statement: [
894
+ {
895
+ Sid: 'AllowRootAccountAdmin',
896
+ Effect: 'Allow',
897
+ Principal: {
898
+ AWS: {
899
+ 'Fn::Sub': 'arn:aws:iam::${AWS::AccountId}:root'
900
+ }
901
+ },
902
+ Action: 'kms:*',
903
+ Resource: '*'
904
+ },
905
+ {
906
+ Sid: 'AllowLambdaService',
907
+ Effect: 'Allow',
908
+ Principal: {
909
+ Service: 'lambda.amazonaws.com'
910
+ },
911
+ Action: [
912
+ 'kms:GenerateDataKey',
913
+ 'kms:Decrypt',
914
+ 'kms:DescribeKey'
915
+ ],
916
+ Resource: '*',
917
+ Condition: {
918
+ StringEquals: {
919
+ 'kms:ViaService': 'lambda.us-east-1.amazonaws.com'
920
+ }
921
+ }
922
+ }
923
+ ]
924
+ },
925
+ Tags: [
926
+ {
927
+ Key: 'Name',
928
+ Value: '${self:service}-${self:provider.stage}-frigg-kms-key'
929
+ },
930
+ {
931
+ Key: 'ManagedBy',
932
+ Value: 'Frigg'
933
+ },
934
+ {
935
+ Key: 'Purpose',
936
+ Value: 'Field-level encryption for Frigg application'
937
+ }
938
+ ]
939
+ }
940
+ });
941
+
942
+ // Check KMS Alias resource is created for the new key
943
+ expect(result.resources.Resources.FriggKMSKeyAlias).toBeDefined();
944
+ expect(result.resources.Resources.FriggKMSKeyAlias).toEqual({
945
+ Type: 'AWS::KMS::Alias',
946
+ DeletionPolicy: 'Retain',
947
+ Properties: {
948
+ AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms',
949
+ TargetKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }
950
+ }
951
+ });
952
+
953
+ // Check IAM permissions for the new key
954
+ const kmsPermission = result.provider.iamRoleStatements.find(
955
+ statement => statement.Action.includes('kms:GenerateDataKey')
956
+ );
957
+ expect(kmsPermission).toEqual({
958
+ Effect: 'Allow',
959
+ Action: ['kms:GenerateDataKey', 'kms:Decrypt'],
960
+ Resource: [{ 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }]
961
+ });
962
+
963
+ // Check environment variable
964
+ expect(result.provider.environment.KMS_KEY_ARN).toEqual({
965
+ 'Fn::GetAtt': ['FriggKMSKey', 'Arn']
966
+ });
967
+
968
+ // Check plugin
969
+ expect(result.plugins).toContain('serverless-kms-grants');
970
+
971
+ // Check custom configuration
972
+ // When creating a new key, it should reference the CloudFormation resource
973
+ expect(result.custom.kmsGrants).toEqual({
974
+ kmsKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] }
975
+ });
976
+ });
977
+
978
+ it('should throw error when encryption is enabled, no key found, and createResourceIfNoneFound is false', async () => {
979
+ // Mock AWS discovery to return no KMS key
980
+ const { AWSDiscovery } = require('./aws-discovery');
981
+ const mockDiscoverResources = jest.fn().mockResolvedValue({
982
+ defaultVpcId: 'vpc-123456',
983
+ defaultSecurityGroupId: 'sg-123456',
984
+ privateSubnetId1: 'subnet-123456',
985
+ privateSubnetId2: 'subnet-789012',
986
+ publicSubnetId: 'subnet-public',
987
+ defaultRouteTableId: 'rtb-123456',
988
+ defaultKmsKeyId: null // No KMS key found
989
+ });
990
+ AWSDiscovery.mockImplementation(() => ({
991
+ discoverResources: mockDiscoverResources
992
+ }));
993
+
994
+ const appDefinition = {
995
+ encryption: {
996
+ fieldLevelEncryptionMethod: 'kms',
997
+ createResourceIfNoneFound: false
998
+ },
999
+ integrations: []
1000
+ };
1001
+
1002
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
1003
+ 'KMS field-level encryption is enabled but no KMS key was found. ' +
1004
+ 'Either provide an existing KMS key or set encryption.createResourceIfNoneFound to true to create a new key.'
1005
+ );
1006
+ });
1007
+
1008
+ it('should throw error when encryption is enabled, no key found, and createResourceIfNoneFound is not specified', async () => {
1009
+ // Mock AWS discovery to return no KMS key
1010
+ const { AWSDiscovery } = require('./aws-discovery');
1011
+ const mockDiscoverResources = jest.fn().mockResolvedValue({
1012
+ defaultVpcId: 'vpc-123456',
1013
+ defaultSecurityGroupId: 'sg-123456',
1014
+ privateSubnetId1: 'subnet-123456',
1015
+ privateSubnetId2: 'subnet-789012',
1016
+ publicSubnetId: 'subnet-public',
1017
+ defaultRouteTableId: 'rtb-123456',
1018
+ defaultKmsKeyId: null // No KMS key found
1019
+ });
1020
+ AWSDiscovery.mockImplementation(() => ({
1021
+ discoverResources: mockDiscoverResources
1022
+ }));
1023
+
1024
+ const appDefinition = {
1025
+ encryption: {
1026
+ fieldLevelEncryptionMethod: 'kms'
1027
+ // createResourceIfNoneFound not specified, defaults to false
1028
+ },
1029
+ integrations: []
1030
+ };
1031
+
1032
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
1033
+ 'KMS field-level encryption is enabled but no KMS key was found. ' +
1034
+ 'Either provide an existing KMS key or set encryption.createResourceIfNoneFound to true to create a new key.'
1035
+ );
1036
+ });
1037
+
1038
+ it('should not add KMS configuration when encryption is disabled', async () => {
1039
+ const appDefinition = {
1040
+ encryption: { fieldLevelEncryptionMethod: 'aes' },
1041
+ integrations: []
1042
+ };
1043
+
1044
+ const result = await composeServerlessDefinition(appDefinition);
1045
+
1046
+ const kmsPermission = result.provider.iamRoleStatements.find(
1047
+ statement => statement.Action && statement.Action.includes('kms:GenerateDataKey')
1048
+ );
1049
+ expect(kmsPermission).toBeUndefined();
1050
+ expect(result.provider.environment.KMS_KEY_ARN).toBeUndefined();
1051
+ expect(result.plugins).not.toContain('serverless-kms-grants');
1052
+ expect(result.custom.kmsGrants).toBeUndefined();
1053
+ });
1054
+
1055
+ it('should not add KMS configuration when encryption is not defined', async () => {
1056
+ const appDefinition = {
1057
+ integrations: []
1058
+ };
1059
+
1060
+ const result = await composeServerlessDefinition(appDefinition);
1061
+
1062
+ const kmsPermission = result.provider.iamRoleStatements.find(
1063
+ statement => statement.Action && statement.Action.includes('kms:GenerateDataKey')
1064
+ );
1065
+ expect(kmsPermission).toBeUndefined();
1066
+ expect(result.custom.kmsGrants).toBeUndefined();
1067
+ });
1068
+ });
1069
+
1070
+ describe('SSM Configuration', () => {
1071
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
1072
+
1073
+ afterEach(() => {
1074
+ if (originalSkipDiscovery === undefined) {
1075
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1076
+ } else {
1077
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
1078
+ }
1079
+ });
1080
+
1081
+ it('should add the broad SSM read grant when ssm.enable is true without offload', async () => {
1082
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1083
+ const appDefinition = {
1084
+ ssm: { enable: true },
1085
+ integrations: []
1086
+ };
1087
+
1088
+ const result = await composeServerlessDefinition(appDefinition);
1089
+
1090
+ // We deliberately do NOT use the AWS Parameters-and-Secrets extension layer
1091
+ expect(result.provider.layers).toBeUndefined();
1092
+
1093
+ // Broad read grant present
1094
+ const ssmPermission = result.provider.iamRoleStatements.find(
1095
+ statement => statement.Action.includes('ssm:GetParameter')
1096
+ );
1097
+ expect(ssmPermission).toEqual({
1098
+ Effect: 'Allow',
1099
+ Action: [
1100
+ 'ssm:GetParameter',
1101
+ 'ssm:GetParameters',
1102
+ 'ssm:GetParametersByPath'
1103
+ ],
1104
+ Resource: {
1105
+ 'Fn::Sub': 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*'
1106
+ }
1107
+ });
1108
+
1109
+ // No offload markers -> no offload env vars
1110
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1111
+ expect(result.provider.environment.FRIGG_SSM_OFFLOADED_KEYS).toBeUndefined();
1112
+ });
1113
+
1114
+ it('should offload marked keys and expose prefix + offloaded keys env vars', async () => {
1115
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1116
+ const appDefinition = {
1117
+ ssm: { enable: true },
1118
+ environment: { FOO: 'ssm', BAR: true },
1119
+ integrations: []
1120
+ };
1121
+
1122
+ const result = await composeServerlessDefinition(appDefinition);
1123
+
1124
+ // BAR stays in the Lambda env, FOO is offloaded out of it
1125
+ expect(result.provider.environment.BAR).toBe("${env:BAR, ''}");
1126
+ expect(result.provider.environment.FOO).toBeUndefined();
1127
+
1128
+ // Offload env contract
1129
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBe(
1130
+ '/frigg/${self:service}/${self:provider.stage}'
1131
+ );
1132
+ expect(result.provider.environment.FRIGG_SSM_OFFLOADED_KEYS).toBe('FOO');
1133
+
1134
+ // Prefix-scoped read grant present alongside the broad grant
1135
+ const scoped = result.provider.iamRoleStatements.find(
1136
+ statement => statement.Resource === 'arn:aws:ssm:${self:provider.region}:${aws:accountId}:parameter/frigg/${self:service}/${self:provider.stage}/*'
1137
+ );
1138
+ expect(scoped).toBeDefined();
1139
+ });
1140
+
1141
+ it('should not add offload env vars when ssm.enable is true but nothing is marked', async () => {
1142
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1143
+ const appDefinition = {
1144
+ ssm: { enable: true },
1145
+ environment: { BAR: true },
1146
+ integrations: []
1147
+ };
1148
+
1149
+ const result = await composeServerlessDefinition(appDefinition);
1150
+
1151
+ expect(result.provider.environment.BAR).toBe("${env:BAR, ''}");
1152
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1153
+ expect(result.provider.environment.FRIGG_SSM_OFFLOADED_KEYS).toBeUndefined();
1154
+ });
1155
+
1156
+ it('should not add SSM configuration when ssm.enable is false', async () => {
1157
+ const appDefinition = {
1158
+ ssm: { enable: false },
1159
+ integrations: []
1160
+ };
1161
+
1162
+ const result = await composeServerlessDefinition(appDefinition);
1163
+
1164
+ expect(result.provider.layers).toBeUndefined();
1165
+
1166
+ const ssmPermission = result.provider.iamRoleStatements.find(
1167
+ statement => statement.Action && statement.Action.includes('ssm:GetParameter')
1168
+ );
1169
+ expect(ssmPermission).toBeUndefined();
1170
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1171
+ });
1172
+
1173
+ it('should not add SSM configuration when ssm is not defined', async () => {
1174
+ const appDefinition = {
1175
+ integrations: []
1176
+ };
1177
+
1178
+ const result = await composeServerlessDefinition(appDefinition);
1179
+
1180
+ expect(result.provider.layers).toBeUndefined();
1181
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1182
+ });
1183
+ });
1184
+
1185
+ describe('Integration Configuration', () => {
1186
+ it('should add integration-specific resources and functions', async () => {
1187
+ const appDefinition = {
1188
+ integrations: [mockIntegration]
1189
+ };
1190
+
1191
+ const result = await composeServerlessDefinition(appDefinition);
1192
+
1193
+ // Check integration function
1194
+ expect(result.functions.testIntegration).toEqual({
1195
+ handler: 'node_modules/@friggframework/core/handlers/routers/integration-defined-routers.handlers.testIntegration.handler',
1196
+ events: [{
1197
+ httpApi: {
1198
+ path: '/api/testIntegration-integration/{proxy+}',
1199
+ method: 'ANY'
1200
+ }
1201
+ }]
1202
+ });
1203
+
1204
+ // Check SQS Queue
1205
+ expect(result.resources.Resources.TestIntegrationQueue).toEqual({
1206
+ Type: 'AWS::SQS::Queue',
1207
+ Properties: {
1208
+ QueueName: '${self:custom.TestIntegrationQueue}',
1209
+ MessageRetentionPeriod: 345600,
1210
+ VisibilityTimeout: 1800,
1211
+ RedrivePolicy: {
1212
+ maxReceiveCount: 3,
1213
+ deadLetterTargetArn: {
1214
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn']
1215
+ }
1216
+ }
1217
+ }
1218
+ });
1219
+
1220
+ // Check Queue Worker
1221
+ expect(result.functions.testIntegrationQueueWorker).toEqual({
1222
+ handler: 'node_modules/@friggframework/core/handlers/workers/integration-defined-workers.handlers.testIntegration.queueWorker',
1223
+ reservedConcurrency: 20,
1224
+ events: [{
1225
+ sqs: {
1226
+ arn: {
1227
+ 'Fn::GetAtt': ['TestIntegrationQueue', 'Arn']
1228
+ },
1229
+ batchSize: 1,
1230
+ functionResponseType: 'ReportBatchItemFailures'
1231
+ }
1232
+ }],
1233
+ timeout: 600
1234
+ });
1235
+
1236
+ // Check environment variable
1237
+ expect(result.provider.environment.TESTINTEGRATION_QUEUE_URL).toEqual({
1238
+ Ref: 'TestIntegrationQueue'
1239
+ });
1240
+
1241
+ // Check custom queue name
1242
+ expect(result.custom.TestIntegrationQueue).toBe('${self:service}--${self:provider.stage}-TestIntegrationQueue');
1243
+ });
1244
+
1245
+ it('should handle multiple integrations', async () => {
1246
+ const secondIntegration = {
1247
+ Definition: {
1248
+ name: 'secondIntegration'
1249
+ }
1250
+ };
1251
+
1252
+ const appDefinition = {
1253
+ integrations: [mockIntegration, secondIntegration]
1254
+ };
1255
+
1256
+ const result = await composeServerlessDefinition(appDefinition);
1257
+
1258
+ expect(result.functions.testIntegration).toBeDefined();
1259
+ expect(result.functions.secondIntegration).toBeDefined();
1260
+ expect(result.functions.testIntegrationQueueWorker).toBeDefined();
1261
+ expect(result.functions.secondIntegrationQueueWorker).toBeDefined();
1262
+ expect(result.resources.Resources.TestIntegrationQueue).toBeDefined();
1263
+ expect(result.resources.Resources.SecondIntegrationQueue).toBeDefined();
1264
+ });
1265
+ });
1266
+
1267
+ describe('Handler path adjustments', () => {
1268
+ const fs = require('fs');
1269
+ const path = require('path');
1270
+
1271
+ it('should rewrite handler paths in offline mode', async () => {
1272
+ process.argv = ['node', 'test', 'offline'];
1273
+ const existsSpy = jest.spyOn(fs, 'existsSync');
1274
+ const fallbackNodeModules = path.resolve(process.cwd(), '..', 'node_modules');
1275
+ existsSpy.mockImplementation((p) => p === fallbackNodeModules);
1276
+
1277
+ const appDefinition = { integrations: [] };
1278
+
1279
+ const result = await composeServerlessDefinition(appDefinition);
1280
+
1281
+ expect(existsSpy).toHaveBeenCalled();
1282
+ expect(result.functions.auth.handler.startsWith('../node_modules/')).toBe(true);
1283
+
1284
+ existsSpy.mockRestore();
1285
+ });
1286
+ });
1287
+
1288
+ describe('NAT Gateway Management', () => {
1289
+ it('should handle NAT Gateway with createAndManage mode', async () => {
1290
+ const appDefinition = {
1291
+ vpc: {
1292
+ enable: true,
1293
+ management: 'discover',
1294
+ natGateway: {
1295
+ management: 'createAndManage'
1296
+ }
1297
+ },
1298
+ integrations: []
1299
+ };
1300
+
1301
+ const result = await composeServerlessDefinition(appDefinition);
1302
+
1303
+ expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
1304
+ expect(result.resources.Resources.FriggNATRoute).toBeDefined();
1305
+ });
1306
+
1307
+ it('should mark managed NAT Gateway resources for retention', async () => {
1308
+ const appDefinition = {
1309
+ vpc: {
1310
+ enable: true,
1311
+ management: 'discover',
1312
+ natGateway: { management: 'createAndManage' },
1313
+ selfHeal: true,
1314
+ },
1315
+ integrations: [],
1316
+ };
1317
+
1318
+ const result = await composeServerlessDefinition(appDefinition);
1319
+
1320
+ expect(result.resources.Resources.FriggNATGateway).toBeDefined();
1321
+ expect(result.resources.Resources.FriggNATGateway.DeletionPolicy).toBe('Retain');
1322
+ expect(result.resources.Resources.FriggNATGateway.UpdateReplacePolicy).toBe('Retain');
1323
+ });
1324
+
1325
+ it('should handle NAT Gateway with discover mode', async () => {
1326
+ // Mock discovery to return existing NAT Gateway
1327
+ const { AWSDiscovery } = require('./aws-discovery');
1328
+ const mockDiscoverResources = jest.fn().mockResolvedValue({
1329
+ defaultVpcId: 'vpc-123456',
1330
+ defaultSecurityGroupId: 'sg-123456',
1331
+ privateSubnetId1: 'subnet-123456',
1332
+ privateSubnetId2: 'subnet-789012',
1333
+ publicSubnetId: 'subnet-public',
1334
+ defaultRouteTableId: 'rtb-123456',
1335
+ defaultKmsKeyId: null,
1336
+ existingNatGatewayId: 'nat-existing123'
1337
+ });
1338
+ AWSDiscovery.mockImplementation(() => ({
1339
+ discoverResources: mockDiscoverResources
1340
+ }));
1341
+
1342
+ const appDefinition = {
1343
+ vpc: {
1344
+ enable: true,
1345
+ management: 'discover',
1346
+ natGateway: {
1347
+ management: 'discover'
1348
+ }
1349
+ },
1350
+ integrations: []
1351
+ };
1352
+
1353
+ const result = await composeServerlessDefinition(appDefinition);
1354
+
1355
+ expect(result.resources.Resources.FriggNATRoute).toBeDefined();
1356
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-existing123');
1357
+ });
1358
+
1359
+ it('should handle NAT Gateway with useExisting mode and provided ID', async () => {
1360
+ const appDefinition = {
1361
+ vpc: {
1362
+ enable: true,
1363
+ management: 'discover',
1364
+ natGateway: {
1365
+ management: 'useExisting',
1366
+ id: 'nat-custom456'
1367
+ }
1368
+ },
1369
+ integrations: []
1370
+ };
1371
+
1372
+ const result = await composeServerlessDefinition(appDefinition);
1373
+
1374
+ expect(result.resources.Resources.FriggNATRoute).toBeDefined();
1375
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId).toBe('nat-custom456');
1376
+ });
1377
+
1378
+ it('should throw error when NAT Gateway not found in discover mode', async () => {
1379
+ // Mock discovery to return no NAT Gateway
1380
+ const { AWSDiscovery } = require('./aws-discovery');
1381
+ const mockDiscoverResources = jest.fn().mockResolvedValue({
1382
+ defaultVpcId: 'vpc-123456',
1383
+ defaultSecurityGroupId: 'sg-123456',
1384
+ privateSubnetId1: 'subnet-123456',
1385
+ privateSubnetId2: 'subnet-789012',
1386
+ publicSubnetId: 'subnet-public',
1387
+ defaultRouteTableId: 'rtb-123456',
1388
+ defaultKmsKeyId: null,
1389
+ existingNatGatewayId: null // No NAT Gateway
1390
+ });
1391
+ AWSDiscovery.mockImplementation(() => ({
1392
+ discoverResources: mockDiscoverResources
1393
+ }));
1394
+
1395
+ const appDefinition = {
1396
+ vpc: {
1397
+ enable: true,
1398
+ management: 'discover',
1399
+ natGateway: {
1400
+ management: 'discover'
1401
+ }
1402
+ },
1403
+ integrations: []
1404
+ };
1405
+
1406
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow(
1407
+ 'No existing NAT Gateway found in discovery mode'
1408
+ );
1409
+ });
1410
+
1411
+ it('should enable self-healing when selfHeal is true', async () => {
1412
+ const appDefinition = {
1413
+ vpc: {
1414
+ enable: true,
1415
+ management: 'discover',
1416
+ natGateway: {
1417
+ management: 'discover'
1418
+ },
1419
+ selfHeal: true
1420
+ },
1421
+ integrations: []
1422
+ };
1423
+
1424
+ const result = await composeServerlessDefinition(appDefinition);
1425
+
1426
+ // With self-healing enabled, it should handle misconfigured NAT Gateways
1427
+ expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
1428
+ });
1429
+ });
1430
+
1431
+ describe('Combined Configurations', () => {
1432
+ it('should combine VPC, KMS, and SSM configurations', async () => {
1433
+ const appDefinition = {
1434
+ vpc: {
1435
+ enable: true,
1436
+ natGateway: { management: 'createAndManage' } // Explicitly set NAT management mode
1437
+ },
1438
+ encryption: {
1439
+ fieldLevelEncryptionMethod: 'kms',
1440
+ createResourceIfNoneFound: true // Allow creating KMS key if not found
1441
+ },
1442
+ ssm: { enable: true },
1443
+ integrations: [mockIntegration]
1444
+ };
1445
+
1446
+ const result = await composeServerlessDefinition(appDefinition);
1447
+
1448
+ // VPC
1449
+ expect(result.provider.vpc).toBeDefined();
1450
+ // custom.vpc doesn't exist in the serverless template
1451
+ expect(result.resources.Resources.VPCEndpointS3).toBeDefined();
1452
+
1453
+ // KMS
1454
+ expect(result.plugins).toContain('serverless-kms-grants');
1455
+ expect(result.provider.environment.KMS_KEY_ARN).toBeDefined();
1456
+ expect(result.custom.kmsGrants).toBeDefined();
1457
+
1458
+ // SSM (enabled without offload markers -> broad grant only, no offload env vars)
1459
+ const ssmPermission = result.provider.iamRoleStatements.find(
1460
+ statement => statement.Action && statement.Action.includes('ssm:GetParameter')
1461
+ );
1462
+ expect(ssmPermission).toBeDefined();
1463
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1464
+
1465
+ // Integration
1466
+ expect(result.functions.testIntegration).toBeDefined();
1467
+ expect(result.resources.Resources.TestIntegrationQueue).toBeDefined();
1468
+
1469
+ // All plugins should be present
1470
+ expect(result.plugins).toEqual([
1471
+ 'serverless-jetpack',
1472
+ 'serverless-dotenv-plugin',
1473
+ 'serverless-offline-sqs',
1474
+ 'serverless-offline',
1475
+ '@friggframework/serverless-plugin',
1476
+ 'serverless-kms-grants'
1477
+ ]);
1478
+ });
1479
+
1480
+ it('should handle partial configuration combinations', async () => {
1481
+ const appDefinition = {
1482
+ vpc: {
1483
+ enable: true,
1484
+ natGateway: { management: 'createAndManage' } // Explicitly set NAT management mode
1485
+ },
1486
+ encryption: {
1487
+ fieldLevelEncryptionMethod: 'kms',
1488
+ createResourceIfNoneFound: true // Allow creating KMS key if not found
1489
+ },
1490
+ integrations: []
1491
+ };
1492
+
1493
+ const result = await composeServerlessDefinition(appDefinition);
1494
+
1495
+ // VPC and KMS should be present
1496
+ expect(result.provider.vpc).toBeDefined();
1497
+ expect(result.custom.kmsGrants).toBeDefined();
1498
+
1499
+ // SSM should not be present
1500
+ expect(result.provider.layers).toBeUndefined();
1501
+ expect(result.provider.environment.SSM_PARAMETER_PREFIX).toBeUndefined();
1502
+ });
1503
+ });
1504
+
1505
+ describe('Default Resources', () => {
1506
+ it('should always include default resources', async () => {
1507
+ const appDefinition = {
1508
+ integrations: []
1509
+ };
1510
+
1511
+ const result = await composeServerlessDefinition(appDefinition);
1512
+
1513
+ // Check default resources are always present
1514
+ expect(result.resources.Resources.InternalErrorQueue).toBeDefined();
1515
+ expect(result.resources.Resources.InternalErrorBridgeTopic).toBeDefined();
1516
+ expect(result.resources.Resources.InternalErrorBridgePolicy).toBeDefined();
1517
+ expect(result.resources.Resources.ApiGatewayAlarm5xx).toBeDefined();
1518
+
1519
+ // Check default functions
1520
+ expect(result.functions.auth).toBeDefined();
1521
+ expect(result.functions.user).toBeDefined();
1522
+ expect(result.functions.health).toBeDefined();
1523
+
1524
+ // Check default plugins
1525
+ expect(result.plugins).toContain('serverless-jetpack');
1526
+ expect(result.plugins).toContain('serverless-dotenv-plugin');
1527
+ expect(result.plugins).toContain('@friggframework/serverless-plugin');
1528
+ });
1529
+
1530
+ it('should always include default IAM permissions', async () => {
1531
+ const appDefinition = {
1532
+ integrations: []
1533
+ };
1534
+
1535
+ const result = await composeServerlessDefinition(appDefinition);
1536
+
1537
+ // Check SNS publish permission
1538
+ const snsPermission = result.provider.iamRoleStatements.find(
1539
+ statement => statement.Action.includes('sns:Publish')
1540
+ );
1541
+ expect(snsPermission).toBeDefined();
1542
+
1543
+ // Check SQS permissions
1544
+ const sqsPermission = result.provider.iamRoleStatements.find(
1545
+ statement => statement.Action.includes('sqs:SendMessage')
1546
+ );
1547
+ expect(sqsPermission).toBeDefined();
1548
+ });
1549
+
1550
+ it('should include default environment variables', async () => {
1551
+ const appDefinition = {
1552
+ integrations: []
1553
+ };
1554
+
1555
+ const result = await composeServerlessDefinition(appDefinition);
1556
+
1557
+ expect(result.provider.environment.STAGE).toBe('${opt:stage, "dev"}');
1558
+ expect(result.provider.environment.AWS_NODEJS_CONNECTION_REUSE_ENABLED).toBe(1);
1559
+ });
1560
+ });
1561
+
1562
+ describe('WebSocket Configuration', () => {
1563
+ it('should add websocket function when websockets.enable is true', async () => {
1564
+ const appDefinition = {
1565
+ websockets: { enable: true },
1566
+ integrations: []
1567
+ };
1568
+
1569
+ const result = await composeServerlessDefinition(appDefinition);
1570
+
1571
+ expect(result.functions.defaultWebsocket).toEqual({
1572
+ handler: 'node_modules/@friggframework/core/handlers/routers/websocket.handler',
1573
+ events: [
1574
+ {
1575
+ websocket: {
1576
+ route: '$connect',
1577
+ },
1578
+ },
1579
+ {
1580
+ websocket: {
1581
+ route: '$default',
1582
+ },
1583
+ },
1584
+ {
1585
+ websocket: {
1586
+ route: '$disconnect',
1587
+ },
1588
+ },
1589
+ ],
1590
+ });
1591
+ });
1592
+
1593
+ it('should not add websocket function when websockets.enable is false', async () => {
1594
+ const appDefinition = {
1595
+ websockets: { enable: false },
1596
+ integrations: []
1597
+ };
1598
+
1599
+ const result = await composeServerlessDefinition(appDefinition);
1600
+
1601
+ expect(result.functions.defaultWebsocket).toBeUndefined();
1602
+ });
1603
+
1604
+ it('should not add websocket function when websockets is not defined', async () => {
1605
+ const appDefinition = {
1606
+ integrations: []
1607
+ };
1608
+
1609
+ const result = await composeServerlessDefinition(appDefinition);
1610
+
1611
+ expect(result.functions.defaultWebsocket).toBeUndefined();
1612
+ });
1613
+ });
1614
+
1615
+ describe('CRITICAL: NAT Gateway MUST be in PUBLIC Subnet', () => {
1616
+ it('should NEVER reuse NAT Gateway in private subnet - throw error when selfHeal disabled', async () => {
1617
+ // Mock NAT Gateway found in PRIVATE subnet (the original bug)
1618
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1619
+ const mockInstance = new mockDiscovery();
1620
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1621
+ defaultVpcId: 'vpc-123456',
1622
+ defaultSecurityGroupId: 'sg-123456',
1623
+ privateSubnetId1: 'subnet-private1',
1624
+ privateSubnetId2: 'subnet-private2',
1625
+ publicSubnetId: 'subnet-public',
1626
+ existingNatGatewayId: 'nat-in-private',
1627
+ natGatewayInPrivateSubnet: true, // CRITICAL: NAT is in WRONG subnet
1628
+ existingElasticIpAllocationId: 'eipalloc-123'
1629
+ });
1630
+ mockDiscovery.mockImplementation(() => mockInstance);
1631
+
1632
+ const appDefinition = {
1633
+ vpc: {
1634
+ enable: true,
1635
+ natGateway: { management: 'createAndManage' },
1636
+ selfHeal: false
1637
+ },
1638
+ integrations: []
1639
+ };
1640
+
1641
+ // Should throw error because NAT is in private subnet
1642
+ await expect(composeServerlessDefinition(appDefinition))
1643
+ .rejects
1644
+ .toThrow('CRITICAL: NAT Gateway is in PRIVATE subnet');
1645
+ });
1646
+
1647
+ it('should create NEW NAT in PUBLIC subnet when existing NAT is in private subnet with selfHeal', async () => {
1648
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1649
+ const mockInstance = new mockDiscovery();
1650
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1651
+ defaultVpcId: 'vpc-123456',
1652
+ defaultSecurityGroupId: 'sg-123456',
1653
+ privateSubnetId1: 'subnet-private1',
1654
+ privateSubnetId2: 'subnet-private2',
1655
+ publicSubnetId: 'subnet-public',
1656
+ existingNatGatewayId: 'nat-in-private',
1657
+ natGatewayInPrivateSubnet: true, // NAT is in WRONG subnet
1658
+ existingElasticIpAllocationId: 'eipalloc-123'
1659
+ });
1660
+ mockDiscovery.mockImplementation(() => mockInstance);
1661
+
1662
+ const appDefinition = {
1663
+ vpc: {
1664
+ enable: true,
1665
+ natGateway: { management: 'createAndManage' },
1666
+ selfHeal: true
1667
+ },
1668
+ integrations: []
1669
+ };
1670
+
1671
+ const result = await composeServerlessDefinition(appDefinition);
1672
+
1673
+ // MUST create new NAT Gateway (not reuse the one in private subnet)
1674
+ expect(result.resources.Resources.FriggNATGateway).toBeDefined();
1675
+
1676
+ // MUST be placed in PUBLIC subnet
1677
+ const natSubnet = result.resources.Resources.FriggNATGateway.Properties.SubnetId;
1678
+ expect(natSubnet).toEqual('subnet-public');
1679
+
1680
+ // MUST create new EIP (cannot reuse the one associated with wrong NAT)
1681
+ expect(result.resources.Resources.FriggNATGatewayEIP).toBeDefined();
1682
+ });
1683
+
1684
+ it('should create public subnet for NAT when none exists', async () => {
1685
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1686
+ const mockInstance = new mockDiscovery();
1687
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1688
+ defaultVpcId: 'vpc-123456',
1689
+ defaultSecurityGroupId: 'sg-123456',
1690
+ privateSubnetId1: 'subnet-private1',
1691
+ privateSubnetId2: 'subnet-private2',
1692
+ publicSubnetId: null, // NO PUBLIC SUBNET EXISTS
1693
+ existingNatGatewayId: null
1694
+ });
1695
+ mockDiscovery.mockImplementation(() => mockInstance);
1696
+
1697
+ const appDefinition = {
1698
+ vpc: {
1699
+ enable: true,
1700
+ natGateway: { management: 'createAndManage' },
1701
+ selfHeal: true
1702
+ },
1703
+ integrations: []
1704
+ };
1705
+
1706
+ const result = await composeServerlessDefinition(appDefinition);
1707
+
1708
+ // MUST create public subnet
1709
+ expect(result.resources.Resources.FriggPublicSubnet).toBeDefined();
1710
+ expect(result.resources.Resources.FriggPublicSubnet.Properties.MapPublicIpOnLaunch).toBe(true);
1711
+
1712
+ // NAT Gateway MUST be in the newly created public subnet
1713
+ expect(result.resources.Resources.FriggNATGateway.Properties.SubnetId)
1714
+ .toEqual({ Ref: 'FriggPublicSubnet' });
1715
+ });
1716
+
1717
+ it('should reuse CORRECTLY placed NAT Gateway in public subnet', async () => {
1718
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1719
+ const mockInstance = new mockDiscovery();
1720
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1721
+ defaultVpcId: 'vpc-123456',
1722
+ defaultSecurityGroupId: 'sg-123456',
1723
+ privateSubnetId1: 'subnet-private1',
1724
+ privateSubnetId2: 'subnet-private2',
1725
+ publicSubnetId: 'subnet-public',
1726
+ existingNatGatewayId: 'nat-good',
1727
+ natGatewayInPrivateSubnet: false, // NAT is CORRECTLY in public subnet
1728
+ existingElasticIpAllocationId: 'eipalloc-123'
1729
+ });
1730
+ mockDiscovery.mockImplementation(() => mockInstance);
1731
+
1732
+ const appDefinition = {
1733
+ vpc: {
1734
+ enable: true,
1735
+ natGateway: { management: 'createAndManage' },
1736
+ selfHeal: true
1737
+ },
1738
+ integrations: []
1739
+ };
1740
+
1741
+ const result = await composeServerlessDefinition(appDefinition);
1742
+
1743
+ // Should NOT create new NAT Gateway (reuse the good one)
1744
+ expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
1745
+ expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
1746
+
1747
+ // Should use existing NAT in routes
1748
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
1749
+ .toEqual('nat-good');
1750
+ });
1751
+
1752
+ it('should fix route table associations to prevent NAT misconfiguration', async () => {
1753
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1754
+ const mockInstance = new mockDiscovery();
1755
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1756
+ defaultVpcId: 'vpc-123456',
1757
+ defaultSecurityGroupId: 'sg-123456',
1758
+ privateSubnetId1: 'subnet-private1',
1759
+ privateSubnetId2: 'subnet-private2',
1760
+ publicSubnetId: 'subnet-public',
1761
+ existingNatGatewayId: 'nat-good',
1762
+ natGatewayInPrivateSubnet: false
1763
+ });
1764
+ mockDiscovery.mockImplementation(() => mockInstance);
1765
+
1766
+ const appDefinition = {
1767
+ vpc: {
1768
+ enable: true,
1769
+ natGateway: { management: 'discover' },
1770
+ selfHeal: true
1771
+ },
1772
+ integrations: []
1773
+ };
1774
+
1775
+ const result = await composeServerlessDefinition(appDefinition);
1776
+
1777
+ // Should create route table to fix associations
1778
+ expect(result.resources.Resources.FriggLambdaRouteTable).toBeDefined();
1779
+
1780
+ // Should create NAT route pointing to good NAT
1781
+ expect(result.resources.Resources.FriggNATRoute).toBeDefined();
1782
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
1783
+ .toEqual('nat-good');
1784
+
1785
+ // Should associate private subnets with correct route table
1786
+ expect(result.resources.Resources.FriggSubnet1RouteAssociation).toBeDefined();
1787
+ expect(result.resources.Resources.FriggSubnet2RouteAssociation).toBeDefined();
1788
+ });
1789
+
1790
+ it('should handle EIP already associated error by reusing existing NAT', async () => {
1791
+ const mockDiscovery = require('./aws-discovery').AWSDiscovery;
1792
+ const mockInstance = new mockDiscovery();
1793
+ mockInstance.discoverResources = jest.fn().mockResolvedValue({
1794
+ defaultVpcId: 'vpc-123456',
1795
+ defaultSecurityGroupId: 'sg-123456',
1796
+ privateSubnetId1: 'subnet-private1',
1797
+ privateSubnetId2: 'subnet-private2',
1798
+ publicSubnetId: 'subnet-public',
1799
+ existingNatGatewayId: 'nat-existing',
1800
+ natGatewayInPrivateSubnet: false, // NAT is correctly placed
1801
+ existingElasticIpAllocationId: 'eipalloc-inuse',
1802
+ elasticIpAlreadyAssociated: true // EIP is already in use
1803
+ });
1804
+ mockDiscovery.mockImplementation(() => mockInstance);
1805
+
1806
+ const appDefinition = {
1807
+ vpc: {
1808
+ enable: true,
1809
+ natGateway: { management: 'createAndManage' },
1810
+ selfHeal: true
1811
+ },
1812
+ integrations: []
1813
+ };
1814
+
1815
+ const result = await composeServerlessDefinition(appDefinition);
1816
+
1817
+ // Should NOT create new NAT or EIP (reuse existing)
1818
+ expect(result.resources.Resources.FriggNATGateway).toBeUndefined();
1819
+ expect(result.resources.Resources.FriggNATGatewayEIP).toBeUndefined();
1820
+
1821
+ // Should use existing NAT
1822
+ expect(result.resources.Resources.FriggNATRoute.Properties.NatGatewayId)
1823
+ .toEqual('nat-existing');
1824
+ });
1825
+ });
1826
+
1827
+ describe('Database Migration Lambda', () => {
1828
+ it('should include dbMigrate function in all deployments', async () => {
1829
+ const appDefinition = {
1830
+ name: 'test-app',
1831
+ integrations: []
1832
+ };
1833
+
1834
+ const result = await composeServerlessDefinition(appDefinition);
1835
+
1836
+ // Check dbMigrate function exists
1837
+ expect(result.functions.dbMigrate).toBeDefined();
1838
+ expect(result.functions.dbMigrate.handler).toBe(
1839
+ 'node_modules/@friggframework/core/handlers/workers/db-migration.handler'
1840
+ );
1841
+ });
1842
+
1843
+ it('should configure dbMigrate with correct settings', async () => {
1844
+ const appDefinition = {
1845
+ integrations: []
1846
+ };
1847
+
1848
+ const result = await composeServerlessDefinition(appDefinition);
1849
+
1850
+ const dbMigrate = result.functions.dbMigrate;
1851
+
1852
+ // Check timeout (5 minutes for long migrations)
1853
+ expect(dbMigrate.timeout).toBe(300);
1854
+
1855
+ // Check memory allocation (extra for Prisma CLI)
1856
+ expect(dbMigrate.memorySize).toBe(512);
1857
+
1858
+ // Check description
1859
+ expect(dbMigrate.description).toContain('database migrations');
1860
+ expect(dbMigrate.description).toContain('Prisma');
1861
+ });
1862
+
1863
+ it('should not have HTTP events (manual invocation only)', async () => {
1864
+ const appDefinition = {
1865
+ integrations: []
1866
+ };
1867
+
1868
+ const result = await composeServerlessDefinition(appDefinition);
1869
+
1870
+ const dbMigrate = result.functions.dbMigrate;
1871
+
1872
+ // Should have no events (manually invoked via AWS CLI)
1873
+ expect(dbMigrate.events).toBeUndefined();
1874
+ });
1875
+
1876
+ it('should include dbMigrate even with VPC enabled', async () => {
1877
+ const appDefinition = {
1878
+ vpc: {
1879
+ enable: true,
1880
+ management: 'discover'
1881
+ },
1882
+ integrations: []
1883
+ };
1884
+
1885
+ const result = await composeServerlessDefinition(appDefinition);
1886
+
1887
+ // dbMigrate should exist with VPC configuration
1888
+ expect(result.functions.dbMigrate).toBeDefined();
1889
+
1890
+ // Should have same VPC access as other functions
1891
+ expect(result.provider.vpc).toBeDefined();
1892
+ });
1893
+
1894
+ it('should include dbMigrate with database configuration', async () => {
1895
+ const appDefinition = {
1896
+ vpc: {
1897
+ enable: true,
1898
+ management: 'discover'
1899
+ },
1900
+ database: {
1901
+ postgres: {
1902
+ enable: true,
1903
+ management: 'discover'
1904
+ }
1905
+ },
1906
+ integrations: []
1907
+ };
1908
+
1909
+ const result = await composeServerlessDefinition(appDefinition);
1910
+
1911
+ // dbMigrate should exist alongside database configuration
1912
+ expect(result.functions.dbMigrate).toBeDefined();
1913
+ expect(result.provider.environment.DB_TYPE).toBe('postgresql');
1914
+ expect(result.provider.environment.DATABASE_URL).toBeDefined();
1915
+ });
1916
+ });
1917
+
1918
+ describe('Edge Cases', () => {
1919
+ it('should handle empty app definition', async () => {
1920
+ const appDefinition = {};
1921
+
1922
+ await expect(composeServerlessDefinition(appDefinition)).resolves.not.toThrow();
1923
+ const result = await composeServerlessDefinition(appDefinition);
1924
+ expect(result.service).toBe('create-frigg-app');
1925
+ });
1926
+
1927
+ it('should handle null/undefined integrations', async () => {
1928
+ const appDefinition = {
1929
+ integrations: null
1930
+ };
1931
+
1932
+ // Should not throw, just ignore invalid integrations
1933
+ const result = await composeServerlessDefinition(appDefinition);
1934
+ expect(result).toBeDefined();
1935
+ });
1936
+
1937
+ it('should handle integration with missing Definition', async () => {
1938
+ const invalidIntegration = {};
1939
+ const appDefinition = {
1940
+ integrations: [invalidIntegration]
1941
+ };
1942
+
1943
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow('Invalid integration: missing Definition or name');
1944
+ });
1945
+
1946
+ it('should handle integration with missing name', async () => {
1947
+ const invalidIntegration = {
1948
+ Definition: {}
1949
+ };
1950
+ const appDefinition = {
1951
+ integrations: [invalidIntegration]
1952
+ };
1953
+
1954
+ await expect(composeServerlessDefinition(appDefinition)).rejects.toThrow('Invalid integration: missing Definition or name');
1955
+ });
1956
+ });
1957
+ });
1958
+
1959
+ describe('applySsmPreloadNodeOptions', () => {
1960
+ const appDefinition = {
1961
+ ssm: { enable: true },
1962
+ environment: { FOO: 'ssm' },
1963
+ };
1964
+ const savedSkip = process.env.FRIGG_SKIP_AWS_DISCOVERY;
1965
+
1966
+ beforeEach(() => {
1967
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1968
+ });
1969
+
1970
+ afterEach(() => {
1971
+ if (savedSkip === undefined) {
1972
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
1973
+ } else {
1974
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = savedSkip;
1975
+ }
1976
+ });
1977
+
1978
+ it('sets the preload NODE_OPTIONS on a skipEsbuild function with no existing value', () => {
1979
+ const functions = { auth: { skipEsbuild: true } };
1980
+ applySsmPreloadNodeOptions(appDefinition, functions);
1981
+ expect(functions.auth.environment.NODE_OPTIONS).toBe(
1982
+ SSM_PRELOAD_NODE_OPTIONS
1983
+ );
1984
+ });
1985
+
1986
+ it('appends to an existing function-level NODE_OPTIONS instead of clobbering it', () => {
1987
+ const functions = {
1988
+ auth: {
1989
+ skipEsbuild: true,
1990
+ environment: { NODE_OPTIONS: '--enable-source-maps' },
1991
+ },
1992
+ };
1993
+ applySsmPreloadNodeOptions(appDefinition, functions);
1994
+ expect(functions.auth.environment.NODE_OPTIONS).toBe(
1995
+ `--enable-source-maps ${SSM_PRELOAD_NODE_OPTIONS}`
1996
+ );
1997
+ });
1998
+
1999
+ it('folds in a provider-level NODE_OPTIONS (which the function env would otherwise shadow)', () => {
2000
+ const functions = { auth: { skipEsbuild: true } };
2001
+ applySsmPreloadNodeOptions(appDefinition, functions, {
2002
+ NODE_OPTIONS: '--require ./otel.js',
2003
+ });
2004
+ expect(functions.auth.environment.NODE_OPTIONS).toBe(
2005
+ `--require ./otel.js ${SSM_PRELOAD_NODE_OPTIONS}`
2006
+ );
2007
+ });
2008
+
2009
+ it('prefers a function-level value over the provider-level one', () => {
2010
+ const functions = {
2011
+ auth: {
2012
+ skipEsbuild: true,
2013
+ environment: { NODE_OPTIONS: '--fn-flag' },
2014
+ },
2015
+ };
2016
+ applySsmPreloadNodeOptions(appDefinition, functions, {
2017
+ NODE_OPTIONS: '--provider-flag',
2018
+ });
2019
+ expect(functions.auth.environment.NODE_OPTIONS).toBe(
2020
+ `--fn-flag ${SSM_PRELOAD_NODE_OPTIONS}`
2021
+ );
2022
+ });
2023
+
2024
+ it('never touches esbuild-bundled functions', () => {
2025
+ const functions = {
2026
+ websocket: { environment: { NODE_OPTIONS: '--keep-me' } },
2027
+ };
2028
+ applySsmPreloadNodeOptions(appDefinition, functions, {
2029
+ NODE_OPTIONS: '--provider',
2030
+ });
2031
+ expect(functions.websocket.environment.NODE_OPTIONS).toBe('--keep-me');
2032
+ });
2033
+
2034
+ it('is a no-op when offload is inactive', () => {
2035
+ const functions = { auth: { skipEsbuild: true } };
2036
+ applySsmPreloadNodeOptions(
2037
+ { ssm: { enable: true } }, // no offloaded keys → inactive
2038
+ functions
2039
+ );
2040
+ expect(functions.auth.environment).toBeUndefined();
2041
+ });
2042
+ });