@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,1017 @@
1
+ /**
2
+ * Tests for Admin Script Builder
3
+ *
4
+ * Tests admin script infrastructure generation including:
5
+ * - SQS queue for script execution
6
+ * - Lambda executor function
7
+ * - Lambda router function with HTTP routes
8
+ * - EventBridge Scheduler resources (optional)
9
+ */
10
+
11
+ const { AdminScriptBuilder } = require('./admin-script-builder');
12
+ const { ValidationResult } = require('../shared/base-builder');
13
+
14
+ describe('AdminScriptBuilder', () => {
15
+ let adminScriptBuilder;
16
+
17
+ beforeEach(() => {
18
+ adminScriptBuilder = new AdminScriptBuilder();
19
+ });
20
+
21
+ describe('shouldExecute()', () => {
22
+ it('should return false when no adminScripts', () => {
23
+ const appDefinition = {};
24
+
25
+ expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false);
26
+ });
27
+
28
+ it('should return false when adminScripts is empty array', () => {
29
+ const appDefinition = {
30
+ adminScripts: [],
31
+ };
32
+
33
+ expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false);
34
+ });
35
+
36
+ it('should return true when adminScripts has items', () => {
37
+ const appDefinition = {
38
+ adminScripts: [
39
+ { Definition: { name: 'test-script' } },
40
+ ],
41
+ };
42
+
43
+ expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(true);
44
+ });
45
+
46
+ it('should return false when adminScripts is not an array', () => {
47
+ const appDefinition = {
48
+ adminScripts: { name: 'test' },
49
+ };
50
+
51
+ expect(adminScriptBuilder.shouldExecute(appDefinition)).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('getDependencies()', () => {
56
+ it('should have no dependencies', () => {
57
+ const deps = adminScriptBuilder.getDependencies();
58
+
59
+ expect(deps).toEqual([]);
60
+ });
61
+ });
62
+
63
+ describe('validate()', () => {
64
+ it('should pass validation with valid adminScripts', () => {
65
+ const appDefinition = {
66
+ adminScripts: [
67
+ { Definition: { name: 'oauth-refresh' } },
68
+ { Definition: { name: 'health-check' } },
69
+ ],
70
+ };
71
+
72
+ const result = adminScriptBuilder.validate(appDefinition);
73
+
74
+ expect(result).toBeInstanceOf(ValidationResult);
75
+ expect(result.valid).toBe(true);
76
+ expect(result.errors).toEqual([]);
77
+ });
78
+
79
+ it('should pass when adminScripts is undefined', () => {
80
+ const appDefinition = {};
81
+
82
+ const result = adminScriptBuilder.validate(appDefinition);
83
+
84
+ expect(result.valid).toBe(true);
85
+ });
86
+
87
+ it('should fail when adminScripts is not an array', () => {
88
+ const appDefinition = {
89
+ adminScripts: 'invalid',
90
+ };
91
+
92
+ const result = adminScriptBuilder.validate(appDefinition);
93
+
94
+ expect(result.valid).toBe(false);
95
+ expect(result.errors).toContain('adminScripts must be an array');
96
+ });
97
+
98
+ it('should fail when script missing Definition.name', () => {
99
+ const appDefinition = {
100
+ adminScripts: [
101
+ { Definition: {} },
102
+ ],
103
+ };
104
+
105
+ const result = adminScriptBuilder.validate(appDefinition);
106
+
107
+ expect(result.valid).toBe(false);
108
+ expect(result.errors).toContain(
109
+ 'Admin script at index 0 is missing Definition or name'
110
+ );
111
+ });
112
+
113
+ it('should fail when script missing Definition', () => {
114
+ const appDefinition = {
115
+ adminScripts: [
116
+ { someOtherField: 'value' },
117
+ ],
118
+ };
119
+
120
+ const result = adminScriptBuilder.validate(appDefinition);
121
+
122
+ expect(result.valid).toBe(false);
123
+ expect(result.errors).toContain(
124
+ 'Admin script at index 0 is missing Definition or name'
125
+ );
126
+ });
127
+
128
+ it('should validate all scripts', () => {
129
+ const appDefinition = {
130
+ adminScripts: [
131
+ { Definition: { name: 'valid' } },
132
+ { Definition: {} }, // Invalid - no name
133
+ { someField: 'value' }, // Invalid - no Definition
134
+ ],
135
+ };
136
+
137
+ const result = adminScriptBuilder.validate(appDefinition);
138
+
139
+ expect(result.valid).toBe(false);
140
+ expect(result.errors).toHaveLength(2);
141
+ });
142
+ });
143
+
144
+ describe('build()', () => {
145
+ it('should create AdminScriptQueue resource', async () => {
146
+ const appDefinition = {
147
+ adminScripts: [
148
+ { Definition: { name: 'test-script' } },
149
+ ],
150
+ };
151
+
152
+ const result = await adminScriptBuilder.build(appDefinition, {});
153
+
154
+ expect(result.resources.AdminScriptQueue).toBeDefined();
155
+ expect(result.resources.AdminScriptQueue.Type).toBe('AWS::SQS::Queue');
156
+ });
157
+
158
+ it('should configure AdminScriptQueue with correct retention and timeout', async () => {
159
+ const appDefinition = {
160
+ adminScripts: [
161
+ { Definition: { name: 'test-script' } },
162
+ ],
163
+ };
164
+
165
+ const result = await adminScriptBuilder.build(appDefinition, {});
166
+
167
+ expect(result.resources.AdminScriptQueue.Properties.MessageRetentionPeriod).toBe(86400); // 1 day
168
+ expect(result.resources.AdminScriptQueue.Properties.VisibilityTimeout).toBe(900); // 15 minutes
169
+ });
170
+
171
+ it('should configure AdminScriptQueue redrive policy to InternalErrorQueue', async () => {
172
+ const appDefinition = {
173
+ adminScripts: [
174
+ { Definition: { name: 'test-script' } },
175
+ ],
176
+ };
177
+
178
+ const result = await adminScriptBuilder.build(appDefinition, {});
179
+
180
+ expect(result.resources.AdminScriptQueue.Properties.RedrivePolicy).toEqual({
181
+ maxReceiveCount: 3,
182
+ deadLetterTargetArn: {
183
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
184
+ },
185
+ });
186
+ });
187
+
188
+ it('should add ADMIN_SCRIPT_QUEUE_URL to environment variables', async () => {
189
+ const appDefinition = {
190
+ adminScripts: [
191
+ { Definition: { name: 'test-script' } },
192
+ ],
193
+ };
194
+
195
+ const result = await adminScriptBuilder.build(appDefinition, {});
196
+
197
+ expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toEqual({
198
+ Ref: 'AdminScriptQueue',
199
+ });
200
+ });
201
+
202
+ it('should create adminScriptExecutor function', async () => {
203
+ const appDefinition = {
204
+ adminScripts: [
205
+ { Definition: { name: 'test-script' } },
206
+ ],
207
+ };
208
+
209
+ const result = await adminScriptBuilder.build(appDefinition, {});
210
+
211
+ expect(result.functions.adminScriptExecutor).toBeDefined();
212
+ expect(result.functions.adminScriptExecutor.handler).toBe(
213
+ 'node_modules/@friggframework/admin-scripts/src/infrastructure/script-executor-handler.handler'
214
+ );
215
+ });
216
+
217
+ it('should configure adminScriptExecutor with SQS event', async () => {
218
+ const appDefinition = {
219
+ adminScripts: [
220
+ { Definition: { name: 'test-script' } },
221
+ ],
222
+ };
223
+
224
+ const result = await adminScriptBuilder.build(appDefinition, {});
225
+
226
+ expect(result.functions.adminScriptExecutor.events).toEqual([
227
+ {
228
+ sqs: {
229
+ arn: { 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'] },
230
+ batchSize: 1,
231
+ },
232
+ },
233
+ ]);
234
+ });
235
+
236
+ it('should set adminScriptExecutor timeout to 900 seconds', async () => {
237
+ const appDefinition = {
238
+ adminScripts: [
239
+ { Definition: { name: 'test-script' } },
240
+ ],
241
+ };
242
+
243
+ const result = await adminScriptBuilder.build(appDefinition, {});
244
+
245
+ expect(result.functions.adminScriptExecutor.timeout).toBe(900); // 15 minutes (Lambda max)
246
+ });
247
+
248
+ it('should set adminScriptExecutor memory size', async () => {
249
+ const appDefinition = {
250
+ adminScripts: [
251
+ { Definition: { name: 'test-script' } },
252
+ ],
253
+ };
254
+
255
+ const result = await adminScriptBuilder.build(appDefinition, {});
256
+
257
+ expect(result.functions.adminScriptExecutor.memorySize).toBe(1024);
258
+ });
259
+
260
+ it('should exclude aws-sdk/Prisma from the skipEsbuild function packages', async () => {
261
+ const appDefinition = {
262
+ adminScripts: [{ Definition: { name: 'test-script' } }],
263
+ };
264
+
265
+ const result = await adminScriptBuilder.build(appDefinition, {});
266
+
267
+ // Regression: without a package config the skipEsbuild functions
268
+ // package the whole node_modules and exceed Lambda's 250 MB limit.
269
+ for (const fn of [
270
+ result.functions.adminScriptExecutor,
271
+ result.functions.adminScriptRouter,
272
+ ]) {
273
+ expect(fn.package.exclude).toEqual(
274
+ expect.arrayContaining([
275
+ 'node_modules/aws-sdk/**',
276
+ 'node_modules/@aws-sdk/**',
277
+ 'node_modules/@prisma/**',
278
+ ])
279
+ );
280
+ }
281
+ });
282
+
283
+ it('should attach Prisma layer to adminScriptExecutor', async () => {
284
+ const appDefinition = {
285
+ adminScripts: [
286
+ { Definition: { name: 'test-script' } },
287
+ ],
288
+ };
289
+
290
+ const result = await adminScriptBuilder.build(appDefinition, {});
291
+
292
+ expect(result.functions.adminScriptExecutor.layers).toEqual([
293
+ { Ref: 'PrismaLambdaLayer' }
294
+ ]);
295
+ });
296
+
297
+ it('should create adminScriptRouter function', async () => {
298
+ const appDefinition = {
299
+ adminScripts: [
300
+ { Definition: { name: 'test-script' } },
301
+ ],
302
+ };
303
+
304
+ const result = await adminScriptBuilder.build(appDefinition, {});
305
+
306
+ expect(result.functions.adminScriptRouter).toBeDefined();
307
+ expect(result.functions.adminScriptRouter.handler).toBe(
308
+ 'node_modules/@friggframework/admin-scripts/src/infrastructure/admin-script-router.handler'
309
+ );
310
+ });
311
+
312
+ it('should configure adminScriptRouter with correct HTTP routes', async () => {
313
+ const appDefinition = {
314
+ adminScripts: [
315
+ { Definition: { name: 'test-script' } },
316
+ ],
317
+ };
318
+
319
+ const result = await adminScriptBuilder.build(appDefinition, {});
320
+
321
+ expect(result.functions.adminScriptRouter.events).toEqual([
322
+ // List scripts
323
+ { httpApi: { path: '/admin/scripts', method: 'GET' } },
324
+ // Get script details
325
+ { httpApi: { path: '/admin/scripts/{scriptName}', method: 'GET' } },
326
+ // Execute script (sync or async)
327
+ { httpApi: { path: '/admin/scripts/{scriptName}', method: 'POST' } },
328
+ // Validate script input (dry-run preview)
329
+ { httpApi: { path: '/admin/scripts/{scriptName}/validate', method: 'POST' } },
330
+ // List executions for a script
331
+ { httpApi: { path: '/admin/scripts/{scriptName}/executions', method: 'GET' } },
332
+ // Get a single execution
333
+ {
334
+ httpApi: {
335
+ path: '/admin/scripts/{scriptName}/executions/{executionId}',
336
+ method: 'GET',
337
+ },
338
+ },
339
+ // Schedule management (Phase 2)
340
+ { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'GET' } },
341
+ { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'PUT' } },
342
+ { httpApi: { path: '/admin/scripts/{scriptName}/schedule', method: 'DELETE' } },
343
+ ]);
344
+ });
345
+
346
+ it('should set adminScriptRouter timeout to 30 seconds', async () => {
347
+ const appDefinition = {
348
+ adminScripts: [
349
+ { Definition: { name: 'test-script' } },
350
+ ],
351
+ };
352
+
353
+ const result = await adminScriptBuilder.build(appDefinition, {});
354
+
355
+ expect(result.functions.adminScriptRouter.timeout).toBe(30);
356
+ });
357
+
358
+ it('should attach Prisma layer to adminScriptRouter', async () => {
359
+ const appDefinition = {
360
+ adminScripts: [
361
+ { Definition: { name: 'test-script' } },
362
+ ],
363
+ };
364
+
365
+ const result = await adminScriptBuilder.build(appDefinition, {});
366
+
367
+ expect(result.functions.adminScriptRouter.layers).toEqual([
368
+ { Ref: 'PrismaLambdaLayer' }
369
+ ]);
370
+ });
371
+
372
+ it('should create scheduler resources when admin.enableScheduling is true', async () => {
373
+ const appDefinition = {
374
+ adminScripts: [
375
+ { Definition: { name: 'test-script' } },
376
+ ],
377
+ admin: {
378
+ enableScheduling: true,
379
+ },
380
+ };
381
+
382
+ const result = await adminScriptBuilder.build(appDefinition, {});
383
+
384
+ // Check for scheduler IAM role
385
+ expect(result.resources.AdminScriptSchedulerRole).toBeDefined();
386
+ expect(result.resources.AdminScriptSchedulerRole.Type).toBe('AWS::IAM::Role');
387
+
388
+ // Check for schedule group
389
+ expect(result.resources.AdminScriptScheduleGroup).toBeDefined();
390
+ expect(result.resources.AdminScriptScheduleGroup.Type).toBe('AWS::Scheduler::ScheduleGroup');
391
+
392
+ // Scheduler env vars are scoped to the router function (not the
393
+ // shared provider env): resource refs would create CloudFormation
394
+ // circular deps, and SCHEDULER_PROVIDER='aws' would break core's
395
+ // scheduler factory (rejects 'aws') for every other Lambda.
396
+ const routerEnv = result.functions.adminScriptRouter.environment;
397
+ expect(routerEnv.SCHEDULER_PROVIDER).toBe('aws');
398
+ expect(result.environment.SCHEDULER_PROVIDER).toBeUndefined();
399
+ expect(routerEnv.SCHEDULER_ROLE_ARN).toEqual({
400
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
401
+ });
402
+ expect(routerEnv.ADMIN_SCRIPT_SCHEDULE_GROUP).toEqual({
403
+ Ref: 'AdminScriptScheduleGroup',
404
+ });
405
+ // Executor referenced by constructed ARN (Fn::Sub), not Fn::GetAtt,
406
+ // so it creates no dependency edge.
407
+ expect(routerEnv.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN).toEqual({
408
+ 'Fn::Sub':
409
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
410
+ });
411
+
412
+ // Regression guard: these must NOT leak onto the shared provider env
413
+ // (that is what produced the circular dependency, incl. the executor
414
+ // self-reference).
415
+ expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined();
416
+ expect(result.environment.ADMIN_SCRIPT_SCHEDULE_GROUP).toBeUndefined();
417
+ expect(
418
+ result.environment.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN
419
+ ).toBeUndefined();
420
+ });
421
+
422
+ it('should not create scheduler resources when enableScheduling is false', async () => {
423
+ const appDefinition = {
424
+ adminScripts: [
425
+ { Definition: { name: 'test-script' } },
426
+ ],
427
+ admin: {
428
+ enableScheduling: false,
429
+ },
430
+ };
431
+
432
+ const result = await adminScriptBuilder.build(appDefinition, {});
433
+
434
+ expect(result.resources.AdminScriptSchedulerRole).toBeUndefined();
435
+ expect(result.resources.AdminScriptScheduleGroup).toBeUndefined();
436
+ expect(result.environment.SCHEDULER_ROLE_ARN).toBeUndefined();
437
+ expect(result.environment.ADMIN_SCRIPT_SCHEDULE_GROUP).toBeUndefined();
438
+ });
439
+
440
+ it('should not create scheduler resources when admin config is not provided', async () => {
441
+ const appDefinition = {
442
+ adminScripts: [
443
+ { Definition: { name: 'test-script' } },
444
+ ],
445
+ };
446
+
447
+ const result = await adminScriptBuilder.build(appDefinition, {});
448
+
449
+ expect(result.resources.AdminScriptSchedulerRole).toBeUndefined();
450
+ expect(result.resources.AdminScriptScheduleGroup).toBeUndefined();
451
+ });
452
+
453
+ it('should use skipEsbuild for all functions', async () => {
454
+ const appDefinition = {
455
+ adminScripts: [
456
+ { Definition: { name: 'test-script' } },
457
+ ],
458
+ };
459
+
460
+ const result = await adminScriptBuilder.build(appDefinition, {});
461
+
462
+ expect(result.functions.adminScriptExecutor.skipEsbuild).toBe(true);
463
+ expect(result.functions.adminScriptRouter.skipEsbuild).toBe(true);
464
+ });
465
+
466
+ it('should not attach Prisma layer when usePrismaLambdaLayer=false', async () => {
467
+ const appDefinition = {
468
+ usePrismaLambdaLayer: false,
469
+ adminScripts: [
470
+ { Definition: { name: 'test-script' } },
471
+ ],
472
+ };
473
+
474
+ const result = await adminScriptBuilder.build(appDefinition, {});
475
+
476
+ expect(result.functions.adminScriptExecutor.layers).toBeUndefined();
477
+ expect(result.functions.adminScriptRouter.layers).toBeUndefined();
478
+ });
479
+
480
+ it('should handle multiple admin scripts', async () => {
481
+ const appDefinition = {
482
+ adminScripts: [
483
+ { Definition: { name: 'oauth-refresh' } },
484
+ { Definition: { name: 'health-check' } },
485
+ { Definition: { name: 'attio-healing' } },
486
+ ],
487
+ };
488
+
489
+ const result = await adminScriptBuilder.build(appDefinition, {});
490
+
491
+ // Should still only create one queue and two functions
492
+ expect(result.resources.AdminScriptQueue).toBeDefined();
493
+ expect(result.functions.adminScriptExecutor).toBeDefined();
494
+ expect(result.functions.adminScriptRouter).toBeDefined();
495
+
496
+ // Should not create separate resources per script
497
+ expect(Object.keys(result.resources)).toHaveLength(1); // Only AdminScriptQueue
498
+ expect(Object.keys(result.functions)).toHaveLength(2); // Only executor and router
499
+ });
500
+
501
+ it('should configure scheduler role with correct trust policy', async () => {
502
+ const appDefinition = {
503
+ adminScripts: [
504
+ { Definition: { name: 'test-script' } },
505
+ ],
506
+ admin: {
507
+ enableScheduling: true,
508
+ },
509
+ };
510
+
511
+ const result = await adminScriptBuilder.build(appDefinition, {});
512
+
513
+ const trustPolicy = result.resources.AdminScriptSchedulerRole.Properties.AssumeRolePolicyDocument;
514
+
515
+ expect(trustPolicy.Statement[0]).toEqual({
516
+ Effect: 'Allow',
517
+ Principal: { Service: 'scheduler.amazonaws.com' },
518
+ Action: 'sts:AssumeRole',
519
+ });
520
+ });
521
+
522
+ it('should configure scheduler role with Lambda invoke permission', async () => {
523
+ const appDefinition = {
524
+ adminScripts: [
525
+ { Definition: { name: 'test-script' } },
526
+ ],
527
+ admin: {
528
+ enableScheduling: true,
529
+ },
530
+ };
531
+
532
+ const result = await adminScriptBuilder.build(appDefinition, {});
533
+
534
+ const policies = result.resources.AdminScriptSchedulerRole.Properties.Policies;
535
+
536
+ expect(policies[0].PolicyName).toBe('InvokeLambda');
537
+ // Constructed ARN (Fn::Sub), not Fn::GetAtt — a GetAtt here closes a
538
+ // circular dependency through the shared Lambda execution role.
539
+ expect(policies[0].PolicyDocument.Statement[0]).toEqual({
540
+ Effect: 'Allow',
541
+ Action: 'lambda:InvokeFunction',
542
+ Resource: {
543
+ 'Fn::Sub':
544
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
545
+ },
546
+ });
547
+ });
548
+
549
+ it('should grant the router SendMessage on AdminScriptQueue', async () => {
550
+ const appDefinition = {
551
+ adminScripts: [{ Definition: { name: 'test-script' } }],
552
+ };
553
+
554
+ const result = await adminScriptBuilder.build(appDefinition, {});
555
+
556
+ const sqsGrant = result.iamStatements.find(
557
+ (s) =>
558
+ Array.isArray(s.Action) &&
559
+ s.Action.includes('sqs:SendMessage')
560
+ );
561
+ expect(sqsGrant).toBeDefined();
562
+ expect(sqsGrant.Action).toContain('sqs:SendMessageBatch');
563
+ expect(sqsGrant.Resource).toEqual({
564
+ 'Fn::GetAtt': ['AdminScriptQueue', 'Arn'],
565
+ });
566
+ });
567
+
568
+ it('should grant scheduler:* + iam:PassRole when scheduling is enabled', async () => {
569
+ const appDefinition = {
570
+ adminScripts: [{ Definition: { name: 'test-script' } }],
571
+ admin: { enableScheduling: true },
572
+ };
573
+
574
+ const result = await adminScriptBuilder.build(appDefinition, {});
575
+
576
+ const schedulerGrant = result.iamStatements.find(
577
+ (s) =>
578
+ Array.isArray(s.Action) &&
579
+ s.Action.includes('scheduler:CreateSchedule')
580
+ );
581
+ expect(schedulerGrant).toBeDefined();
582
+ expect(schedulerGrant.Action).toEqual(
583
+ expect.arrayContaining([
584
+ 'scheduler:CreateSchedule',
585
+ 'scheduler:UpdateSchedule',
586
+ 'scheduler:DeleteSchedule',
587
+ 'scheduler:GetSchedule',
588
+ ])
589
+ );
590
+ expect(schedulerGrant.Resource['Fn::Sub'][1]).toEqual({
591
+ GroupName: { Ref: 'AdminScriptScheduleGroup' },
592
+ });
593
+
594
+ const passRole = result.iamStatements.find(
595
+ (s) =>
596
+ Array.isArray(s.Action) && s.Action.includes('iam:PassRole')
597
+ );
598
+ expect(passRole).toBeDefined();
599
+ expect(passRole.Resource).toEqual({
600
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
601
+ });
602
+ expect(passRole.Condition.StringEquals['iam:PassedToService']).toBe(
603
+ 'scheduler.amazonaws.com'
604
+ );
605
+ });
606
+
607
+ it('should add no scheduler IAM statements when scheduling is disabled', async () => {
608
+ const appDefinition = {
609
+ adminScripts: [{ Definition: { name: 'test-script' } }],
610
+ };
611
+
612
+ const result = await adminScriptBuilder.build(appDefinition, {});
613
+
614
+ const schedulerGrant = result.iamStatements.find(
615
+ (s) =>
616
+ Array.isArray(s.Action) &&
617
+ s.Action.some((a) => a.startsWith('scheduler:'))
618
+ );
619
+ expect(schedulerGrant).toBeUndefined();
620
+ });
621
+ });
622
+
623
+ describe('reports (ReportQueue + reportExecutor)', () => {
624
+ it('creates ReportQueue + reportExecutor and wires REPORT_QUEUE_URL when reports are present', async () => {
625
+ const appDefinition = {
626
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
627
+ };
628
+
629
+ const result = await adminScriptBuilder.build(appDefinition, {});
630
+
631
+ // Queue
632
+ expect(result.resources.ReportQueue).toBeDefined();
633
+ expect(result.resources.ReportQueue.Type).toBe('AWS::SQS::Queue');
634
+ expect(
635
+ result.resources.ReportQueue.Properties.MessageRetentionPeriod
636
+ ).toBe(86400);
637
+ expect(
638
+ result.resources.ReportQueue.Properties.VisibilityTimeout
639
+ ).toBe(900);
640
+ expect(
641
+ result.resources.ReportQueue.Properties.RedrivePolicy
642
+ ).toEqual({
643
+ maxReceiveCount: 3,
644
+ deadLetterTargetArn: {
645
+ 'Fn::GetAtt': ['InternalErrorQueue', 'Arn'],
646
+ },
647
+ });
648
+
649
+ // Executor function
650
+ expect(result.functions.reportExecutor).toBeDefined();
651
+ expect(result.functions.reportExecutor.handler).toBe(
652
+ 'node_modules/@friggframework/admin-scripts/src/infrastructure/report-executor-handler.handler'
653
+ );
654
+ expect(result.functions.reportExecutor.timeout).toBe(900);
655
+ expect(result.functions.reportExecutor.memorySize).toBe(1024);
656
+ expect(result.functions.reportExecutor.events).toEqual([
657
+ {
658
+ sqs: {
659
+ arn: { 'Fn::GetAtt': ['ReportQueue', 'Arn'] },
660
+ batchSize: 1,
661
+ },
662
+ },
663
+ ]);
664
+ expect(result.functions.reportExecutor.skipEsbuild).toBe(true);
665
+ expect(result.functions.reportExecutor.layers).toEqual([
666
+ { Ref: 'PrismaLambdaLayer' },
667
+ ]);
668
+
669
+ // Env wired app-wide (scoped flag off)
670
+ expect(result.environment.REPORT_QUEUE_URL).toEqual({
671
+ Ref: 'ReportQueue',
672
+ });
673
+
674
+ // IAM SendMessage grant on the queue Arn
675
+ const grant = result.iamStatements.find(
676
+ (s) =>
677
+ Array.isArray(s.Action) &&
678
+ s.Action.includes('sqs:SendMessage') &&
679
+ s.Resource &&
680
+ s.Resource['Fn::GetAtt'] &&
681
+ s.Resource['Fn::GetAtt'][0] === 'ReportQueue'
682
+ );
683
+ expect(grant).toBeDefined();
684
+ expect(grant.Action).toContain('sqs:SendMessageBatch');
685
+ });
686
+
687
+ it('scopes REPORT_QUEUE_URL to reportRouter + reportExecutor when scopedEnvironment is on', async () => {
688
+ const appDefinition = {
689
+ lambda: { scopedEnvironment: true },
690
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
691
+ };
692
+
693
+ const result = await adminScriptBuilder.build(appDefinition, {});
694
+
695
+ expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
696
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
697
+ expect(
698
+ result.functionEnvironments[fnName].REPORT_QUEUE_URL
699
+ ).toEqual({ Ref: 'ReportQueue' });
700
+ }
701
+ });
702
+
703
+ it('creates ReportQueue + reportExecutor when only builtin reports are enabled', async () => {
704
+ const appDefinition = {
705
+ admin: { includeBuiltinReports: true },
706
+ };
707
+
708
+ const result = await adminScriptBuilder.build(appDefinition, {});
709
+
710
+ expect(result.resources.ReportQueue).toBeDefined();
711
+ expect(result.functions.reportExecutor).toBeDefined();
712
+ });
713
+
714
+ it('does NOT create ReportQueue or reportExecutor when only adminScripts are present', async () => {
715
+ const appDefinition = {
716
+ adminScripts: [{ Definition: { name: 'test-script' } }],
717
+ };
718
+
719
+ const result = await adminScriptBuilder.build(appDefinition, {});
720
+
721
+ expect(result.resources.ReportQueue).toBeUndefined();
722
+ expect(result.functions.reportExecutor).toBeUndefined();
723
+ expect(result.functions.reportRouter).toBeUndefined();
724
+ expect(result.environment.REPORT_QUEUE_URL).toBeUndefined();
725
+ });
726
+ });
727
+
728
+ describe('report artifacts (ReportArtifactBucket for non-JSON output)', () => {
729
+ it('provisions a private encrypted bucket + IAM + env when a report emits non-JSON', async () => {
730
+ const appDefinition = {
731
+ reports: [
732
+ {
733
+ Definition: {
734
+ name: 'sales-csv',
735
+ version: '1.0.0',
736
+ output: { format: 'csv' },
737
+ },
738
+ },
739
+ ],
740
+ };
741
+
742
+ const result = await adminScriptBuilder.build(appDefinition, {});
743
+
744
+ const bucket = result.resources.ReportArtifactBucket;
745
+ expect(bucket).toBeDefined();
746
+ expect(bucket.Type).toBe('AWS::S3::Bucket');
747
+ expect(
748
+ bucket.Properties.BucketEncryption
749
+ .ServerSideEncryptionConfiguration[0]
750
+ .ServerSideEncryptionByDefault.SSEAlgorithm
751
+ ).toBe('AES256');
752
+ expect(bucket.Properties.PublicAccessBlockConfiguration).toEqual({
753
+ BlockPublicAcls: true,
754
+ BlockPublicPolicy: true,
755
+ IgnorePublicAcls: true,
756
+ RestrictPublicBuckets: true,
757
+ });
758
+
759
+ // Env wired app-wide (scoped flag off).
760
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toEqual({
761
+ Ref: 'ReportArtifactBucket',
762
+ });
763
+
764
+ // IAM: object-level Put/Get scoped to the bucket keys.
765
+ const grant = result.iamStatements.find(
766
+ (s) =>
767
+ Array.isArray(s.Action) &&
768
+ s.Action.includes('s3:PutObject')
769
+ );
770
+ expect(grant).toBeDefined();
771
+ expect(grant.Action).toContain('s3:GetObject');
772
+ expect(grant.Resource['Fn::Sub'][0]).toBe('${BucketArn}/*');
773
+ expect(grant.Resource['Fn::Sub'][1]).toEqual({
774
+ BucketArn: { 'Fn::GetAtt': ['ReportArtifactBucket', 'Arn'] },
775
+ });
776
+ });
777
+
778
+ it('does NOT provision the bucket for JSON-only reports', async () => {
779
+ const appDefinition = {
780
+ reports: [
781
+ {
782
+ Definition: {
783
+ name: 'json-report',
784
+ version: '1.0.0',
785
+ output: { format: 'json' },
786
+ },
787
+ },
788
+ // No output field defaults to JSON.
789
+ { Definition: { name: 'plain', version: '1.0.0' } },
790
+ ],
791
+ };
792
+
793
+ const result = await adminScriptBuilder.build(appDefinition, {});
794
+
795
+ expect(result.resources.ReportArtifactBucket).toBeUndefined();
796
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
797
+ const grant = result.iamStatements.find(
798
+ (s) =>
799
+ Array.isArray(s.Action) &&
800
+ s.Action.includes('s3:PutObject')
801
+ );
802
+ expect(grant).toBeUndefined();
803
+ });
804
+
805
+ it('scopes REPORT_ARTIFACT_BUCKET to report functions when scopedEnvironment is on', async () => {
806
+ const appDefinition = {
807
+ lambda: { scopedEnvironment: true },
808
+ reports: [
809
+ {
810
+ Definition: {
811
+ name: 'sales-csv',
812
+ version: '1.0.0',
813
+ output: { format: 'csv' },
814
+ },
815
+ },
816
+ ],
817
+ };
818
+
819
+ const result = await adminScriptBuilder.build(appDefinition, {});
820
+
821
+ expect(result.environment.REPORT_ARTIFACT_BUCKET).toBeUndefined();
822
+ for (const fnName of ['reportRouter', 'reportExecutor']) {
823
+ expect(
824
+ result.functionEnvironments[fnName].REPORT_ARTIFACT_BUCKET
825
+ ).toEqual({ Ref: 'ReportArtifactBucket' });
826
+ }
827
+ });
828
+ });
829
+
830
+ describe('report scheduling (enableScheduling && reports)', () => {
831
+ it('wires the report scheduler env onto reportRouter targeting the report executor', async () => {
832
+ const appDefinition = {
833
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
834
+ admin: { enableScheduling: true },
835
+ };
836
+
837
+ const result = await adminScriptBuilder.build(appDefinition, {});
838
+
839
+ // Shared scheduler role + group are created even without scripts.
840
+ expect(result.resources.AdminScriptSchedulerRole).toBeDefined();
841
+ expect(result.resources.AdminScriptScheduleGroup).toBeDefined();
842
+
843
+ const routerEnv = result.functions.reportRouter.environment;
844
+ expect(routerEnv.SCHEDULER_PROVIDER).toBe('aws');
845
+ expect(routerEnv.SCHEDULER_ROLE_ARN).toEqual({
846
+ 'Fn::GetAtt': ['AdminScriptSchedulerRole', 'Arn'],
847
+ });
848
+ expect(routerEnv.REPORT_SCHEDULE_GROUP).toEqual({
849
+ Ref: 'AdminScriptScheduleGroup',
850
+ });
851
+ // Constructed ARN (Fn::Sub), not Fn::GetAtt — avoids a circular dep.
852
+ expect(routerEnv.REPORT_EXECUTOR_LAMBDA_ARN).toEqual({
853
+ 'Fn::Sub':
854
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
855
+ });
856
+
857
+ // Must NOT leak onto the shared provider env.
858
+ expect(result.environment.SCHEDULER_PROVIDER).toBeUndefined();
859
+ expect(result.environment.REPORT_EXECUTOR_LAMBDA_ARN).toBeUndefined();
860
+ });
861
+
862
+ it('grants the scheduler role invoke on the report executor (reports-only)', async () => {
863
+ const appDefinition = {
864
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
865
+ admin: { enableScheduling: true },
866
+ };
867
+
868
+ const result = await adminScriptBuilder.build(appDefinition, {});
869
+
870
+ const statement =
871
+ result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
872
+ .PolicyDocument.Statement[0];
873
+ // Only reports present -> single Resource, the report executor ARN.
874
+ expect(statement.Resource).toEqual({
875
+ 'Fn::Sub':
876
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
877
+ });
878
+
879
+ // scheduler:* + iam:PassRole grants present.
880
+ const schedulerGrant = result.iamStatements.find(
881
+ (s) =>
882
+ Array.isArray(s.Action) &&
883
+ s.Action.includes('scheduler:CreateSchedule')
884
+ );
885
+ expect(schedulerGrant).toBeDefined();
886
+ });
887
+
888
+ it('lets the scheduler role invoke BOTH executors when scripts and reports coexist', async () => {
889
+ const appDefinition = {
890
+ adminScripts: [{ Definition: { name: 'test-script' } }],
891
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
892
+ admin: { enableScheduling: true },
893
+ };
894
+
895
+ const result = await adminScriptBuilder.build(appDefinition, {});
896
+
897
+ const statement =
898
+ result.resources.AdminScriptSchedulerRole.Properties.Policies[0]
899
+ .PolicyDocument.Statement[0];
900
+ expect(statement.Resource).toEqual([
901
+ {
902
+ 'Fn::Sub':
903
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-adminScriptExecutor',
904
+ },
905
+ {
906
+ 'Fn::Sub':
907
+ 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${self:service}-${self:provider.stage}-reportExecutor',
908
+ },
909
+ ]);
910
+
911
+ // Both routers get their own scheduler env.
912
+ expect(
913
+ result.functions.adminScriptRouter.environment
914
+ .ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN
915
+ ).toBeDefined();
916
+ expect(
917
+ result.functions.reportRouter.environment
918
+ .REPORT_EXECUTOR_LAMBDA_ARN
919
+ ).toBeDefined();
920
+ });
921
+
922
+ it('does NOT wire report scheduler env when enableScheduling is off', async () => {
923
+ const appDefinition = {
924
+ reports: [{ Definition: { name: 'my-report', version: '1.0.0' } }],
925
+ };
926
+
927
+ const result = await adminScriptBuilder.build(appDefinition, {});
928
+
929
+ expect(result.resources.AdminScriptSchedulerRole).toBeUndefined();
930
+ expect(
931
+ result.functions.reportRouter.environment
932
+ ).toBeUndefined();
933
+ });
934
+ });
935
+
936
+ describe('getName()', () => {
937
+ it('should return AdminScriptBuilder', () => {
938
+ expect(adminScriptBuilder.getName()).toBe('AdminScriptBuilder');
939
+ });
940
+ });
941
+
942
+ describe('scoped environment (lambda.scopedEnvironment)', () => {
943
+ const originalSkipDiscovery = process.env.FRIGG_SKIP_AWS_DISCOVERY;
944
+
945
+ beforeEach(() => {
946
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
947
+ });
948
+
949
+ afterEach(() => {
950
+ if (originalSkipDiscovery === undefined) {
951
+ delete process.env.FRIGG_SKIP_AWS_DISCOVERY;
952
+ } else {
953
+ process.env.FRIGG_SKIP_AWS_DISCOVERY = originalSkipDiscovery;
954
+ }
955
+ });
956
+
957
+ it('scopes ADMIN_SCRIPT_QUEUE_URL to the admin functions only', async () => {
958
+ const result = await adminScriptBuilder.build(
959
+ {
960
+ lambda: { scopedEnvironment: true },
961
+ adminScripts: [{ Definition: { name: 'fix-things' } }],
962
+ },
963
+ {}
964
+ );
965
+
966
+ expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toBeUndefined();
967
+ for (const fnName of ['adminScriptRouter', 'adminScriptExecutor']) {
968
+ expect(
969
+ result.functionEnvironments[fnName].ADMIN_SCRIPT_QUEUE_URL
970
+ ).toEqual({ Ref: 'AdminScriptQueue' });
971
+ }
972
+ });
973
+
974
+ it('broadcasts app-wide when the flag is off', async () => {
975
+ const result = await adminScriptBuilder.build(
976
+ { adminScripts: [{ Definition: { name: 'fix-things' } }] },
977
+ {}
978
+ );
979
+
980
+ expect(result.environment.ADMIN_SCRIPT_QUEUE_URL).toEqual({
981
+ Ref: 'AdminScriptQueue',
982
+ });
983
+ expect(result.functionEnvironments).toBeUndefined();
984
+ });
985
+ });
986
+ });
987
+
988
+ describe('AdminScriptBuilder nested node_modules (lambda.keepNestedNodeModules)', () => {
989
+ const appDefinition = (lambda) => ({
990
+ adminScripts: [{ Definition: { name: 'test-script' } }],
991
+ ...(lambda && { lambda }),
992
+ });
993
+ const executorAndRouter = (result) => [
994
+ result.functions.adminScriptExecutor,
995
+ result.functions.adminScriptRouter,
996
+ ];
997
+
998
+ it('excludes every nested node_modules by default', async () => {
999
+ const result = await new AdminScriptBuilder().build(appDefinition(), {});
1000
+
1001
+ for (const fn of executorAndRouter(result)) {
1002
+ expect(fn.package.exclude).toContain('node_modules/**/node_modules/**');
1003
+ }
1004
+ });
1005
+
1006
+ it('keeps nested node_modules when the app opts in, still excluding nested Frigg copies', async () => {
1007
+ const result = await new AdminScriptBuilder().build(
1008
+ appDefinition({ keepNestedNodeModules: true }),
1009
+ {}
1010
+ );
1011
+
1012
+ for (const fn of executorAndRouter(result)) {
1013
+ expect(fn.package.exclude).not.toContain('node_modules/**/node_modules/**');
1014
+ expect(fn.package.exclude).toContain('node_modules/**/node_modules/@friggframework/**');
1015
+ }
1016
+ });
1017
+ });