@friggframework/admin-scripts 2.0.0--canary.517.41839c5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE.md +9 -0
  2. package/index.js +66 -0
  3. package/package.json +53 -0
  4. package/src/adapters/__tests__/aws-scheduler-adapter.test.js +322 -0
  5. package/src/adapters/__tests__/local-scheduler-adapter.test.js +325 -0
  6. package/src/adapters/__tests__/scheduler-adapter-factory.test.js +257 -0
  7. package/src/adapters/__tests__/scheduler-adapter.test.js +103 -0
  8. package/src/adapters/aws-scheduler-adapter.js +138 -0
  9. package/src/adapters/local-scheduler-adapter.js +103 -0
  10. package/src/adapters/scheduler-adapter-factory.js +69 -0
  11. package/src/adapters/scheduler-adapter.js +64 -0
  12. package/src/application/__tests__/admin-frigg-commands.test.js +643 -0
  13. package/src/application/__tests__/admin-script-base.test.js +273 -0
  14. package/src/application/__tests__/dry-run-http-interceptor.test.js +313 -0
  15. package/src/application/__tests__/dry-run-repository-wrapper.test.js +257 -0
  16. package/src/application/__tests__/schedule-management-use-case.test.js +276 -0
  17. package/src/application/__tests__/script-factory.test.js +381 -0
  18. package/src/application/__tests__/script-runner.test.js +202 -0
  19. package/src/application/admin-frigg-commands.js +242 -0
  20. package/src/application/admin-script-base.js +138 -0
  21. package/src/application/dry-run-http-interceptor.js +296 -0
  22. package/src/application/dry-run-repository-wrapper.js +261 -0
  23. package/src/application/schedule-management-use-case.js +230 -0
  24. package/src/application/script-factory.js +161 -0
  25. package/src/application/script-runner.js +254 -0
  26. package/src/builtins/__tests__/integration-health-check.test.js +598 -0
  27. package/src/builtins/__tests__/oauth-token-refresh.test.js +344 -0
  28. package/src/builtins/index.js +28 -0
  29. package/src/builtins/integration-health-check.js +279 -0
  30. package/src/builtins/oauth-token-refresh.js +221 -0
  31. package/src/infrastructure/__tests__/admin-auth-middleware.test.js +148 -0
  32. package/src/infrastructure/__tests__/admin-script-router.test.js +701 -0
  33. package/src/infrastructure/admin-auth-middleware.js +49 -0
  34. package/src/infrastructure/admin-script-router.js +311 -0
  35. package/src/infrastructure/script-executor-handler.js +75 -0
@@ -0,0 +1,257 @@
1
+ const { createDryRunWrapper, wrapAdminFriggCommandsForDryRun, sanitizeArgs } = require('../dry-run-repository-wrapper');
2
+
3
+ describe('Dry-Run Repository Wrapper', () => {
4
+ describe('createDryRunWrapper', () => {
5
+ let mockRepository;
6
+ let operationLog;
7
+
8
+ beforeEach(() => {
9
+ operationLog = [];
10
+ mockRepository = {
11
+ // Read operations
12
+ findById: jest.fn(async (id) => ({ id, name: 'Test Entity' })),
13
+ findAll: jest.fn(async () => [{ id: '1' }, { id: '2' }]),
14
+ getStatus: jest.fn(() => 'active'),
15
+
16
+ // Write operations
17
+ create: jest.fn(async (data) => ({ id: 'new-id', ...data })),
18
+ update: jest.fn(async (id, data) => ({ id, ...data })),
19
+ delete: jest.fn(async (id) => ({ deletedCount: 1 })),
20
+ updateStatus: jest.fn(async (id, status) => ({ id, status })),
21
+ };
22
+ });
23
+
24
+ test('should pass through read operations unchanged', async () => {
25
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
26
+
27
+ // Call read operations
28
+ const byId = await wrapped.findById('123');
29
+ const all = await wrapped.findAll();
30
+ const status = wrapped.getStatus();
31
+
32
+ // Verify original methods were called
33
+ expect(mockRepository.findById).toHaveBeenCalledWith('123');
34
+ expect(mockRepository.findAll).toHaveBeenCalled();
35
+ expect(mockRepository.getStatus).toHaveBeenCalled();
36
+
37
+ // Verify results match
38
+ expect(byId).toEqual({ id: '123', name: 'Test Entity' });
39
+ expect(all).toHaveLength(2);
40
+ expect(status).toBe('active');
41
+
42
+ // No operations should be logged
43
+ expect(operationLog).toHaveLength(0);
44
+ });
45
+
46
+ test('should intercept and log write operations', async () => {
47
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
48
+
49
+ // Call write operations
50
+ await wrapped.create({ name: 'New Entity' });
51
+ await wrapped.update('123', { name: 'Updated' });
52
+ await wrapped.delete('456');
53
+
54
+ // Original write methods should NOT be called
55
+ expect(mockRepository.create).not.toHaveBeenCalled();
56
+ expect(mockRepository.update).not.toHaveBeenCalled();
57
+ expect(mockRepository.delete).not.toHaveBeenCalled();
58
+
59
+ // All operations should be logged
60
+ expect(operationLog).toHaveLength(3);
61
+
62
+ expect(operationLog[0]).toMatchObject({
63
+ operation: 'CREATE',
64
+ model: 'TestModel',
65
+ method: 'create',
66
+ });
67
+
68
+ expect(operationLog[1]).toMatchObject({
69
+ operation: 'UPDATE',
70
+ model: 'TestModel',
71
+ method: 'update',
72
+ });
73
+
74
+ expect(operationLog[2]).toMatchObject({
75
+ operation: 'DELETE',
76
+ model: 'TestModel',
77
+ method: 'delete',
78
+ });
79
+ });
80
+
81
+ test('should return mock data for create operations', async () => {
82
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
83
+
84
+ const result = await wrapped.create({ name: 'Test', value: 42 });
85
+
86
+ expect(result).toMatchObject({
87
+ name: 'Test',
88
+ value: 42,
89
+ _dryRun: true,
90
+ });
91
+
92
+ expect(result.id).toMatch(/^dry-run-/);
93
+ expect(result.createdAt).toBeDefined();
94
+ });
95
+
96
+ test('should return mock data for update operations', async () => {
97
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
98
+
99
+ const result = await wrapped.update('123', { status: 'inactive' });
100
+
101
+ expect(result).toMatchObject({
102
+ id: '123',
103
+ status: 'inactive',
104
+ _dryRun: true,
105
+ });
106
+ });
107
+
108
+ test('should return mock data for delete operations', async () => {
109
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
110
+
111
+ const result = await wrapped.delete('123');
112
+
113
+ expect(result).toEqual({
114
+ deletedCount: 1,
115
+ _dryRun: true,
116
+ });
117
+ });
118
+
119
+ test('should try to return existing data for updates when possible', async () => {
120
+ const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel');
121
+
122
+ const result = await wrapped.updateStatus('123', 'inactive');
123
+
124
+ // Should attempt to read existing data
125
+ expect(mockRepository.findById).toHaveBeenCalledWith('123');
126
+
127
+ // If found, should return existing merged with updates
128
+ expect(result.id).toBe('123');
129
+ });
130
+ });
131
+
132
+ describe('sanitizeArgs', () => {
133
+ test('should redact sensitive fields in objects', () => {
134
+ const args = [
135
+ {
136
+ id: '123',
137
+ password: 'secret123',
138
+ token: 'abc-def-ghi',
139
+ apiKey: 'sk_live_123',
140
+ name: 'Test User',
141
+ },
142
+ ];
143
+
144
+ const sanitized = sanitizeArgs(args);
145
+
146
+ expect(sanitized[0]).toEqual({
147
+ id: '123',
148
+ password: '[REDACTED]',
149
+ token: '[REDACTED]',
150
+ apiKey: '[REDACTED]',
151
+ name: 'Test User',
152
+ });
153
+ });
154
+
155
+ test('should handle nested objects', () => {
156
+ const args = [
157
+ {
158
+ user: {
159
+ name: 'Test',
160
+ credentials: {
161
+ password: 'secret',
162
+ apiToken: 'token123',
163
+ },
164
+ },
165
+ },
166
+ ];
167
+
168
+ const sanitized = sanitizeArgs(args);
169
+
170
+ expect(sanitized[0].user.name).toBe('Test');
171
+ expect(sanitized[0].user.credentials.password).toBe('[REDACTED]');
172
+ expect(sanitized[0].user.credentials.apiToken).toBe('[REDACTED]');
173
+ });
174
+
175
+ test('should handle arrays', () => {
176
+ const args = [
177
+ [
178
+ { id: '1', token: 'abc' },
179
+ { id: '2', secret: 'xyz' },
180
+ ],
181
+ ];
182
+
183
+ const sanitized = sanitizeArgs(args);
184
+
185
+ expect(sanitized[0][0].token).toBe('[REDACTED]');
186
+ expect(sanitized[0][1].secret).toBe('[REDACTED]');
187
+ });
188
+
189
+ test('should preserve primitives', () => {
190
+ const args = ['string', 123, true, null, undefined];
191
+ const sanitized = sanitizeArgs(args);
192
+
193
+ expect(sanitized).toEqual(['string', 123, true, null, undefined]);
194
+ });
195
+ });
196
+
197
+ describe('wrapAdminFriggCommandsForDryRun', () => {
198
+ let mockCommands;
199
+ let operationLog;
200
+
201
+ beforeEach(() => {
202
+ operationLog = [];
203
+ mockCommands = {
204
+ // Read operations
205
+ findIntegrationById: jest.fn(async (id) => ({ id, status: 'active' })),
206
+ listIntegrations: jest.fn(async () => []),
207
+
208
+ // Write operations
209
+ updateIntegrationConfig: jest.fn(async (id, config) => ({ id, config })),
210
+ updateIntegrationStatus: jest.fn(async (id, status) => ({ id, status })),
211
+ updateCredential: jest.fn(async (id, updates) => ({ id, ...updates })),
212
+
213
+ // Other methods
214
+ log: jest.fn(),
215
+ };
216
+ });
217
+
218
+ test('should pass through read operations', async () => {
219
+ const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
220
+
221
+ const integration = await wrapped.findIntegrationById('123');
222
+ const list = await wrapped.listIntegrations();
223
+
224
+ expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123');
225
+ expect(mockCommands.listIntegrations).toHaveBeenCalled();
226
+
227
+ expect(integration.id).toBe('123');
228
+ expect(operationLog).toHaveLength(0);
229
+ });
230
+
231
+ test('should intercept write operations', async () => {
232
+ const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
233
+
234
+ await wrapped.updateIntegrationConfig('123', { setting: 'value' });
235
+ await wrapped.updateIntegrationStatus('456', 'inactive');
236
+
237
+ expect(mockCommands.updateIntegrationConfig).not.toHaveBeenCalled();
238
+ expect(mockCommands.updateIntegrationStatus).not.toHaveBeenCalled();
239
+
240
+ expect(operationLog).toHaveLength(2);
241
+ expect(operationLog[0].operation).toBe('UPDATEINTEGRATIONCONFIG');
242
+ expect(operationLog[1].operation).toBe('UPDATEINTEGRATIONSTATUS');
243
+ });
244
+
245
+ test('should return existing data for known update methods', async () => {
246
+ const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog);
247
+
248
+ const result = await wrapped.updateIntegrationConfig('123', { new: 'config' });
249
+
250
+ // Should have tried to fetch existing
251
+ expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123');
252
+
253
+ // Should return existing data
254
+ expect(result.id).toBe('123');
255
+ });
256
+ });
257
+ });
@@ -0,0 +1,276 @@
1
+ const { ScheduleManagementUseCase } = require('../schedule-management-use-case');
2
+
3
+ describe('ScheduleManagementUseCase', () => {
4
+ let useCase;
5
+ let mockCommands;
6
+ let mockSchedulerAdapter;
7
+ let mockScriptFactory;
8
+
9
+ beforeEach(() => {
10
+ mockCommands = {
11
+ getScheduleByScriptName: jest.fn(),
12
+ upsertSchedule: jest.fn(),
13
+ updateScheduleAwsInfo: jest.fn(),
14
+ deleteSchedule: jest.fn(),
15
+ };
16
+
17
+ mockSchedulerAdapter = {
18
+ createSchedule: jest.fn(),
19
+ deleteSchedule: jest.fn(),
20
+ };
21
+
22
+ mockScriptFactory = {
23
+ has: jest.fn(),
24
+ get: jest.fn(),
25
+ };
26
+
27
+ useCase = new ScheduleManagementUseCase({
28
+ commands: mockCommands,
29
+ schedulerAdapter: mockSchedulerAdapter,
30
+ scriptFactory: mockScriptFactory,
31
+ });
32
+ });
33
+
34
+ describe('getEffectiveSchedule', () => {
35
+ it('should return database schedule when override exists', async () => {
36
+ const dbSchedule = {
37
+ scriptName: 'test-script',
38
+ enabled: true,
39
+ cronExpression: '0 9 * * *',
40
+ timezone: 'UTC',
41
+ };
42
+
43
+ mockScriptFactory.has.mockReturnValue(true);
44
+ mockScriptFactory.get.mockReturnValue({ Definition: {} });
45
+ mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule);
46
+
47
+ const result = await useCase.getEffectiveSchedule('test-script');
48
+
49
+ expect(result.source).toBe('database');
50
+ expect(result.schedule).toEqual(dbSchedule);
51
+ });
52
+
53
+ it('should return definition schedule when no database override', async () => {
54
+ const definitionSchedule = {
55
+ enabled: true,
56
+ cronExpression: '0 12 * * *',
57
+ timezone: 'America/New_York',
58
+ };
59
+
60
+ mockScriptFactory.has.mockReturnValue(true);
61
+ mockScriptFactory.get.mockReturnValue({
62
+ Definition: { schedule: definitionSchedule },
63
+ });
64
+ mockCommands.getScheduleByScriptName.mockResolvedValue(null);
65
+
66
+ const result = await useCase.getEffectiveSchedule('test-script');
67
+
68
+ expect(result.source).toBe('definition');
69
+ expect(result.schedule.enabled).toBe(true);
70
+ expect(result.schedule.cronExpression).toBe('0 12 * * *');
71
+ });
72
+
73
+ it('should return none when no schedule configured', async () => {
74
+ mockScriptFactory.has.mockReturnValue(true);
75
+ mockScriptFactory.get.mockReturnValue({ Definition: {} });
76
+ mockCommands.getScheduleByScriptName.mockResolvedValue(null);
77
+
78
+ const result = await useCase.getEffectiveSchedule('test-script');
79
+
80
+ expect(result.source).toBe('none');
81
+ expect(result.schedule.enabled).toBe(false);
82
+ });
83
+
84
+ it('should throw error when script not found', async () => {
85
+ mockScriptFactory.has.mockReturnValue(false);
86
+
87
+ await expect(useCase.getEffectiveSchedule('non-existent'))
88
+ .rejects.toThrow('Script "non-existent" not found');
89
+ });
90
+ });
91
+
92
+ describe('upsertSchedule', () => {
93
+ it('should create schedule and provision EventBridge when enabled', async () => {
94
+ const savedSchedule = {
95
+ scriptName: 'test-script',
96
+ enabled: true,
97
+ cronExpression: '0 12 * * *',
98
+ timezone: 'UTC',
99
+ };
100
+
101
+ mockScriptFactory.has.mockReturnValue(true);
102
+ mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
103
+ mockSchedulerAdapter.createSchedule.mockResolvedValue({
104
+ scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
105
+ scheduleName: 'frigg-script-test-script',
106
+ });
107
+ mockCommands.updateScheduleAwsInfo.mockResolvedValue({
108
+ ...savedSchedule,
109
+ awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
110
+ });
111
+
112
+ const result = await useCase.upsertSchedule('test-script', {
113
+ enabled: true,
114
+ cronExpression: '0 12 * * *',
115
+ timezone: 'UTC',
116
+ });
117
+
118
+ expect(result.success).toBe(true);
119
+ expect(result.schedule.scriptName).toBe('test-script');
120
+ expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({
121
+ scriptName: 'test-script',
122
+ cronExpression: '0 12 * * *',
123
+ timezone: 'UTC',
124
+ });
125
+ expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalled();
126
+ });
127
+
128
+ it('should delete EventBridge schedule when disabling', async () => {
129
+ const existingSchedule = {
130
+ scriptName: 'test-script',
131
+ enabled: false,
132
+ cronExpression: null,
133
+ timezone: 'UTC',
134
+ awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
135
+ };
136
+
137
+ mockScriptFactory.has.mockReturnValue(true);
138
+ mockCommands.upsertSchedule.mockResolvedValue(existingSchedule);
139
+ mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
140
+ mockCommands.updateScheduleAwsInfo.mockResolvedValue({
141
+ ...existingSchedule,
142
+ awsScheduleArn: null,
143
+ });
144
+
145
+ const result = await useCase.upsertSchedule('test-script', {
146
+ enabled: false,
147
+ });
148
+
149
+ expect(result.success).toBe(true);
150
+ expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
151
+ });
152
+
153
+ it('should handle scheduler errors gracefully', async () => {
154
+ const savedSchedule = {
155
+ scriptName: 'test-script',
156
+ enabled: true,
157
+ cronExpression: '0 12 * * *',
158
+ timezone: 'UTC',
159
+ };
160
+
161
+ mockScriptFactory.has.mockReturnValue(true);
162
+ mockCommands.upsertSchedule.mockResolvedValue(savedSchedule);
163
+ mockSchedulerAdapter.createSchedule.mockRejectedValue(
164
+ new Error('AWS Scheduler API error')
165
+ );
166
+
167
+ const result = await useCase.upsertSchedule('test-script', {
168
+ enabled: true,
169
+ cronExpression: '0 12 * * *',
170
+ });
171
+
172
+ // Should succeed with warning, not fail
173
+ expect(result.success).toBe(true);
174
+ expect(result.schedulerWarning).toBe('AWS Scheduler API error');
175
+ });
176
+
177
+ it('should throw error when script not found', async () => {
178
+ mockScriptFactory.has.mockReturnValue(false);
179
+
180
+ await expect(useCase.upsertSchedule('non-existent', { enabled: true }))
181
+ .rejects.toThrow('Script "non-existent" not found');
182
+ });
183
+
184
+ it('should throw error when enabled without cronExpression', async () => {
185
+ mockScriptFactory.has.mockReturnValue(true);
186
+
187
+ await expect(useCase.upsertSchedule('test-script', { enabled: true }))
188
+ .rejects.toThrow('cronExpression is required when enabled is true');
189
+ });
190
+ });
191
+
192
+ describe('deleteSchedule', () => {
193
+ it('should delete schedule and EventBridge rule', async () => {
194
+ const deletedSchedule = {
195
+ scriptName: 'test-script',
196
+ awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
197
+ };
198
+
199
+ mockScriptFactory.has.mockReturnValue(true);
200
+ mockScriptFactory.get.mockReturnValue({ Definition: {} });
201
+ mockCommands.deleteSchedule.mockResolvedValue({
202
+ deletedCount: 1,
203
+ deleted: deletedSchedule,
204
+ });
205
+ mockSchedulerAdapter.deleteSchedule.mockResolvedValue();
206
+
207
+ const result = await useCase.deleteSchedule('test-script');
208
+
209
+ expect(result.success).toBe(true);
210
+ expect(result.deletedCount).toBe(1);
211
+ expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script');
212
+ });
213
+
214
+ it('should not call scheduler when no AWS rule exists', async () => {
215
+ mockScriptFactory.has.mockReturnValue(true);
216
+ mockScriptFactory.get.mockReturnValue({ Definition: {} });
217
+ mockCommands.deleteSchedule.mockResolvedValue({
218
+ deletedCount: 1,
219
+ deleted: { scriptName: 'test-script' }, // No awsScheduleArn
220
+ });
221
+
222
+ const result = await useCase.deleteSchedule('test-script');
223
+
224
+ expect(result.success).toBe(true);
225
+ expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('should handle scheduler delete errors gracefully', async () => {
229
+ mockScriptFactory.has.mockReturnValue(true);
230
+ mockScriptFactory.get.mockReturnValue({ Definition: {} });
231
+ mockCommands.deleteSchedule.mockResolvedValue({
232
+ deletedCount: 1,
233
+ deleted: {
234
+ scriptName: 'test-script',
235
+ awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test',
236
+ },
237
+ });
238
+ mockSchedulerAdapter.deleteSchedule.mockRejectedValue(
239
+ new Error('AWS delete failed')
240
+ );
241
+
242
+ const result = await useCase.deleteSchedule('test-script');
243
+
244
+ expect(result.success).toBe(true);
245
+ expect(result.schedulerWarning).toBe('AWS delete failed');
246
+ });
247
+
248
+ it('should return effective schedule after deletion', async () => {
249
+ const definitionSchedule = {
250
+ enabled: true,
251
+ cronExpression: '0 6 * * *',
252
+ };
253
+
254
+ mockScriptFactory.has.mockReturnValue(true);
255
+ mockScriptFactory.get.mockReturnValue({
256
+ Definition: { schedule: definitionSchedule },
257
+ });
258
+ mockCommands.deleteSchedule.mockResolvedValue({
259
+ deletedCount: 1,
260
+ deleted: { scriptName: 'test-script' },
261
+ });
262
+
263
+ const result = await useCase.deleteSchedule('test-script');
264
+
265
+ expect(result.effectiveSchedule.source).toBe('definition');
266
+ expect(result.effectiveSchedule.enabled).toBe(true);
267
+ });
268
+
269
+ it('should throw error when script not found', async () => {
270
+ mockScriptFactory.has.mockReturnValue(false);
271
+
272
+ await expect(useCase.deleteSchedule('non-existent'))
273
+ .rejects.toThrow('Script "non-existent" not found');
274
+ });
275
+ });
276
+ });