diff --git a/packages/admin-scripts/index.js b/packages/admin-scripts/index.js new file mode 100644 index 000000000..b5e379910 --- /dev/null +++ b/packages/admin-scripts/index.js @@ -0,0 +1,73 @@ +/** + * @friggframework/admin-scripts + * + * Admin Script Runner for Frigg - Execute maintenance and operational scripts + * in hosted environments with VPC/KMS secured database connections. + */ + +// Application Services +const { ScriptFactory, getScriptFactory, createScriptFactory } = require('./src/application/script-factory'); +const { AdminScriptBase } = require('./src/application/admin-script-base'); +const { + AdminScriptContext, + createAdminScriptContext, + // Legacy aliases (deprecated) + AdminFriggCommands, + createAdminFriggCommands, +} = require('./src/application/admin-frigg-commands'); +const { ScriptRunner, createScriptRunner } = require('./src/application/script-runner'); + +// Infrastructure +const { validateAdminApiKey } = require('./src/infrastructure/admin-auth-middleware'); +const { router, app, handler: routerHandler } = require('./src/infrastructure/admin-script-router'); +const { handler: executorHandler } = require('./src/infrastructure/script-executor-handler'); + +// Built-in Scripts +const { + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, +} = require('./src/builtins'); + +// Adapters +const { SchedulerAdapter } = require('./src/adapters/scheduler-adapter'); +const { AWSSchedulerAdapter } = require('./src/adapters/aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('./src/adapters/local-scheduler-adapter'); +const { + createSchedulerAdapter, +} = require('./src/adapters/scheduler-adapter-factory'); + +module.exports = { + // Application layer + AdminScriptBase, + ScriptFactory, + getScriptFactory, + createScriptFactory, + AdminScriptContext, + createAdminScriptContext, + // Legacy aliases (deprecated) + AdminFriggCommands, + createAdminFriggCommands, + ScriptRunner, + createScriptRunner, + + // Infrastructure layer + validateAdminApiKey, + router, + app, + routerHandler, + executorHandler, + + // Built-in scripts + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, + + // Adapters + SchedulerAdapter, + AWSSchedulerAdapter, + LocalSchedulerAdapter, + createSchedulerAdapter, +}; diff --git a/packages/admin-scripts/package.json b/packages/admin-scripts/package.json new file mode 100644 index 000000000..254797d22 --- /dev/null +++ b/packages/admin-scripts/package.json @@ -0,0 +1,46 @@ +{ + "name": "@friggframework/admin-scripts", + "prettier": "@friggframework/prettier-config", + "version": "2.0.0-next.0", + "description": "Admin Script Runner for Frigg - Execute maintenance and operational scripts in hosted environments", + "dependencies": { + "@aws-sdk/client-scheduler": "^3.588.0", + "@friggframework/core": "^2.0.0-next.0", + "express": "^4.18.2", + "serverless-http": "^3.2.0" + }, + "devDependencies": { + "@friggframework/eslint-config": "^2.0.0-next.0", + "@friggframework/prettier-config": "^2.0.0-next.0", + "@friggframework/test": "^2.0.0-next.0", + "eslint": "^8.22.0", + "jest": "^29.7.0", + "prettier": "^2.7.1", + "supertest": "^7.1.4" + }, + "scripts": { + "lint:fix": "prettier --write --loglevel error . && eslint . --fix", + "test": "jest --passWithNoTests" + }, + "author": "", + "license": "MIT", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/friggframework/frigg.git" + }, + "bugs": { + "url": "https://github.com/friggframework/frigg/issues" + }, + "homepage": "https://github.com/friggframework/frigg#readme", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "frigg", + "admin", + "scripts", + "maintenance", + "operations" + ] +} diff --git a/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js new file mode 100644 index 000000000..c46271eb7 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/aws-scheduler-adapter.test.js @@ -0,0 +1,382 @@ +const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter'); +const { SchedulerAdapter } = require('../scheduler-adapter'); + +// Mock AWS SDK +jest.mock('@aws-sdk/client-scheduler', () => { + const mockSend = jest.fn(); + + return { + SchedulerClient: jest.fn(() => ({ + send: mockSend, + })), + CreateScheduleCommand: jest.fn((params) => ({ _type: 'CreateScheduleCommand', params })), + DeleteScheduleCommand: jest.fn((params) => ({ _type: 'DeleteScheduleCommand', params })), + GetScheduleCommand: jest.fn((params) => ({ _type: 'GetScheduleCommand', params })), + UpdateScheduleCommand: jest.fn((params) => ({ _type: 'UpdateScheduleCommand', params })), + ListSchedulesCommand: jest.fn((params) => ({ _type: 'ListSchedulesCommand', params })), + _mockSend: mockSend, + }; +}); + +const defaultParams = { + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', + scheduleGroupName: 'frigg-admin-scripts', + roleArn: 'arn:aws:iam::123456789012:role/test-role', +}; + +describe('AWSSchedulerAdapter', () => { + let adapter; + let mockSend; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv, AWS_REGION: 'us-east-1' }; + + const sdk = require('@aws-sdk/client-scheduler'); + mockSend = sdk._mockSend; + + adapter = new AWSSchedulerAdapter({ ...defaultParams }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('Inheritance', () => { + it('should extend SchedulerAdapter', () => { + expect(adapter).toBeInstanceOf(SchedulerAdapter); + }); + + it('should have correct adapter name', () => { + expect(adapter.getName()).toBe('aws-eventbridge-scheduler'); + }); + }); + + describe('Constructor', () => { + it('should use provided configuration and AWS_REGION from env', () => { + process.env.AWS_REGION = 'eu-west-1'; + const customAdapter = new AWSSchedulerAdapter({ + targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:custom', + scheduleGroupName: 'custom-group', + roleArn: 'arn:aws:iam::123456789012:role/custom-role', + }); + + expect(customAdapter.region).toBe('eu-west-1'); + expect(customAdapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:custom'); + expect(customAdapter.scheduleGroupName).toBe('custom-group'); + expect(customAdapter.roleArn).toBe('arn:aws:iam::123456789012:role/custom-role'); + }); + + it('should throw if AWS_REGION is not set', () => { + delete process.env.AWS_REGION; + expect(() => new AWSSchedulerAdapter({ + ...defaultParams, + })).toThrow('AWSSchedulerAdapter requires AWS_REGION environment variable'); + }); + + it('should throw if targetLambdaArn is missing', () => { + expect(() => new AWSSchedulerAdapter({ + scheduleGroupName: defaultParams.scheduleGroupName, + roleArn: defaultParams.roleArn, + })).toThrow('AWSSchedulerAdapter requires targetLambdaArn'); + }); + + it('should throw if scheduleGroupName is missing', () => { + expect(() => new AWSSchedulerAdapter({ + targetLambdaArn: defaultParams.targetLambdaArn, + roleArn: defaultParams.roleArn, + })).toThrow('AWSSchedulerAdapter requires scheduleGroupName'); + }); + + it('should throw if roleArn is missing', () => { + expect(() => new AWSSchedulerAdapter({ + targetLambdaArn: defaultParams.targetLambdaArn, + scheduleGroupName: defaultParams.scheduleGroupName, + })).toThrow('AWSSchedulerAdapter requires roleArn'); + }); + }); + + describe('createSchedule()', () => { + it('should create a schedule with required fields', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + const result = await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + expect(result).toEqual({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('CreateScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.ScheduleExpression).toBe('cron(0 0 * * ? *)'); + expect(command.params.ScheduleExpressionTimezone).toBe('UTC'); + }); + + it('should create a schedule with all optional fields', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 12 * * ? *)', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.ScheduleExpressionTimezone).toBe('America/New_York'); + + const targetInput = JSON.parse(command.params.Target.Input); + expect(targetInput).toEqual({ + scriptName: 'test-script', + trigger: 'SCHEDULED', + params: { key: 'value' }, + }); + }); + + it('should configure target with Lambda ARN and constructor roleArn', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.Target.Arn).toBe('arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor'); + expect(command.params.Target.RoleArn).toBe('arn:aws:iam::123456789012:role/test-role'); + }); + + it('should use roleArn from constructor, not process.env', async () => { + const customRoleArn = 'arn:aws:iam::999999999999:role/custom-scheduler-role'; + const customAdapter = new AWSSchedulerAdapter({ + ...defaultParams, + roleArn: customRoleArn, + }); + + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await customAdapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.Target.RoleArn).toBe(customRoleArn); + }); + + it('should enable schedule by default', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.State).toBe('ENABLED'); + }); + + it('should set flexible time window to OFF', async () => { + mockSend.mockResolvedValue({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + const command = mockSend.mock.calls[0][0]; + expect(command.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); + }); + + it('should fall back to UpdateScheduleCommand on ConflictException', async () => { + const conflictError = new Error('Schedule already exists'); + conflictError.name = 'ConflictException'; + + mockSend + .mockRejectedValueOnce(conflictError) + .mockResolvedValueOnce({ + ScheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }); + + const result = await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + }); + + expect(result).toEqual({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', + }); + + expect(mockSend).toHaveBeenCalledTimes(2); + expect(mockSend.mock.calls[0][0]._type).toBe('CreateScheduleCommand'); + expect(mockSend.mock.calls[1][0]._type).toBe('UpdateScheduleCommand'); + }); + + it('should rethrow non-conflict errors', async () => { + const otherError = new Error('Access denied'); + otherError.name = 'AccessDeniedException'; + + mockSend.mockRejectedValue(otherError); + + await expect(adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: 'cron(0 0 * * ? *)', + })).rejects.toThrow('Access denied'); + }); + }); + + describe('deleteSchedule()', () => { + it('should delete a schedule', async () => { + mockSend.mockResolvedValue({}); + + await adapter.deleteSchedule('test-script'); + + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('DeleteScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + }); + + describe('setScheduleEnabled()', () => { + beforeEach(() => { + // Mock GetScheduleCommand response + mockSend.mockImplementation((command) => { + if (command._type === 'GetScheduleCommand') { + return Promise.resolve({ + Name: 'frigg-script-test-script', + GroupName: 'frigg-admin-scripts', + ScheduleExpression: 'cron(0 0 * * ? *)', + ScheduleExpressionTimezone: 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + Target: { + Arn: 'arn:aws:lambda:us-east-1:123456789012:function:admin-script-executor', + RoleArn: 'arn:aws:iam::123456789012:role/test-role', + Input: '{"scriptName":"test-script","trigger":"SCHEDULED","params":{}}', + }, + State: 'ENABLED', + }); + } + return Promise.resolve({}); + }); + }); + + it('should disable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + + expect(mockSend).toHaveBeenCalledTimes(2); // GET then UPDATE + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand._type).toBe('UpdateScheduleCommand'); + expect(updateCommand.params.State).toBe('DISABLED'); + }); + + it('should enable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', true); + + expect(mockSend).toHaveBeenCalledTimes(2); // GET then UPDATE + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand._type).toBe('UpdateScheduleCommand'); + expect(updateCommand.params.State).toBe('ENABLED'); + }); + + it('should preserve schedule configuration when updating state', async () => { + await adapter.setScheduleEnabled('test-script', false); + + const updateCommand = mockSend.mock.calls[1][0]; + expect(updateCommand.params.ScheduleExpression).toBe('cron(0 0 * * ? *)'); + expect(updateCommand.params.ScheduleExpressionTimezone).toBe('UTC'); + expect(updateCommand.params.FlexibleTimeWindow).toEqual({ Mode: 'OFF' }); + expect(updateCommand.params.Target).toBeDefined(); + }); + }); + + describe('listSchedules()', () => { + it('should list all schedules', async () => { + const mockSchedules = [ + { Name: 'frigg-script-script-1', State: 'ENABLED' }, + { Name: 'frigg-script-script-2', State: 'DISABLED' }, + ]; + + mockSend.mockResolvedValue({ Schedules: mockSchedules }); + + const result = await adapter.listSchedules(); + + expect(result).toEqual(mockSchedules); + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('ListSchedulesCommand'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + + it('should return empty array when no schedules exist', async () => { + mockSend.mockResolvedValue({ Schedules: undefined }); + + const result = await adapter.listSchedules(); + + expect(result).toEqual([]); + }); + }); + + describe('getSchedule()', () => { + it('should get schedule details', async () => { + const mockSchedule = { + Name: 'frigg-script-test-script', + GroupName: 'frigg-admin-scripts', + ScheduleExpression: 'cron(0 0 * * ? *)', + ScheduleExpressionTimezone: 'UTC', + State: 'ENABLED', + }; + + mockSend.mockResolvedValue(mockSchedule); + + const result = await adapter.getSchedule('test-script'); + + expect(result).toEqual(mockSchedule); + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command._type).toBe('GetScheduleCommand'); + expect(command.params.Name).toBe('frigg-script-test-script'); + expect(command.params.GroupName).toBe('frigg-admin-scripts'); + }); + }); + + describe('Lazy SDK loading', () => { + it('should load AWS SDK on first client access', () => { + const newAdapter = new AWSSchedulerAdapter({ ...defaultParams }); + + expect(newAdapter.scheduler).toBeNull(); + + newAdapter.getSchedulerClient(); + + expect(newAdapter.scheduler).toBeDefined(); + }); + + it('should reuse client after first creation', () => { + const client1 = adapter.getSchedulerClient(); + const client2 = adapter.getSchedulerClient(); + + expect(client1).toBe(client2); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js new file mode 100644 index 000000000..a93b20171 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/local-scheduler-adapter.test.js @@ -0,0 +1,322 @@ +const { LocalSchedulerAdapter } = require('../local-scheduler-adapter'); +const { SchedulerAdapter } = require('../scheduler-adapter'); + +describe('LocalSchedulerAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new LocalSchedulerAdapter(); + }); + + afterEach(() => { + adapter.clear(); + }); + + describe('Inheritance', () => { + it('should extend SchedulerAdapter', () => { + expect(adapter).toBeInstanceOf(SchedulerAdapter); + }); + + it('should have correct adapter name', () => { + expect(adapter.getName()).toBe('local-cron'); + }); + }); + + describe('createSchedule()', () => { + it('should create a schedule with required fields', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + const result = await adapter.createSchedule(config); + + expect(result).toEqual({ + scheduleName: 'test-script', + scheduleArn: 'local:schedule:test-script', + }); + expect(adapter.size).toBe(1); + }); + + it('should create a schedule with all optional fields', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }; + + const result = await adapter.createSchedule(config); + + expect(result).toEqual({ + scheduleName: 'test-script', + scheduleArn: 'local:schedule:test-script', + }); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.ScheduleExpressionTimezone).toBe('America/New_York'); + expect(JSON.parse(schedule.Target.Input).params).toEqual({ key: 'value' }); + }); + + it('should default timezone to UTC', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + await adapter.createSchedule(config); + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.ScheduleExpressionTimezone).toBe('UTC'); + }); + + it('should enable schedule by default', async () => { + const config = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + await adapter.createSchedule(config); + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.State).toBe('ENABLED'); + }); + + it('should update existing schedule if created again', async () => { + const config1 = { + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }; + + const config2 = { + scriptName: 'test-script', + cronExpression: '0 12 * * *', + }; + + await adapter.createSchedule(config1); + expect(adapter.size).toBe(1); + + await adapter.createSchedule(config2); + expect(adapter.size).toBe(1); // Still only 1 schedule + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.ScheduleExpression).toBe('0 12 * * *'); + }); + }); + + describe('deleteSchedule()', () => { + it('should delete an existing schedule', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + + expect(adapter.size).toBe(1); + + await adapter.deleteSchedule('test-script'); + + expect(adapter.size).toBe(0); + }); + + it('should not throw error when deleting non-existent schedule', async () => { + await expect(adapter.deleteSchedule('non-existent')).resolves.toBeUndefined(); + }); + + it('should clear intervals if they exist', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + + // Simulate an interval + const intervalId = setInterval(() => {}, 1000); + adapter.intervals.set('test-script', intervalId); + + await adapter.deleteSchedule('test-script'); + + expect(adapter.intervals.has('test-script')).toBe(false); + expect(adapter.size).toBe(0); + }); + }); + + describe('setScheduleEnabled()', () => { + beforeEach(async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + }); + }); + + it('should disable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.State).toBe('DISABLED'); + }); + + it('should enable a schedule', async () => { + await adapter.setScheduleEnabled('test-script', false); + await adapter.setScheduleEnabled('test-script', true); + + const schedule = await adapter.getSchedule('test-script'); + expect(schedule.State).toBe('ENABLED'); + }); + + it('should throw error if schedule not found', async () => { + await expect( + adapter.setScheduleEnabled('non-existent', true) + ).rejects.toThrow('Schedule for script "non-existent" not found'); + }); + + it('should update the updatedAt timestamp', async () => { + const schedule1 = await adapter.getSchedule('test-script'); + const originalUpdatedAt = schedule1.LastModificationDate; + + // Wait a bit to ensure timestamp changes + await new Promise((resolve) => setTimeout(resolve, 10)); + + await adapter.setScheduleEnabled('test-script', false); + + const schedule2 = await adapter.getSchedule('test-script'); + expect(schedule2.LastModificationDate.getTime()).toBeGreaterThan( + originalUpdatedAt.getTime() + ); + }); + }); + + describe('listSchedules()', () => { + it('should return empty array when no schedules exist', async () => { + const schedules = await adapter.listSchedules(); + + expect(schedules).toEqual([]); + }); + + it('should return all schedules', async () => { + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-3', + cronExpression: '0 18 * * *', + }); + + const schedules = await adapter.listSchedules(); + + expect(schedules).toHaveLength(3); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-1'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-2'); + expect(schedules.map((s) => s.Name)).toContain('frigg-script-script-3'); + }); + + it('should include all schedule properties in normalized format', async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + + const schedules = await adapter.listSchedules(); + + expect(schedules[0]).toMatchObject({ + Name: 'frigg-script-test-script', + State: 'ENABLED', + ScheduleExpression: '0 0 * * *', + ScheduleExpressionTimezone: 'America/New_York', + }); + }); + }); + + describe('getSchedule()', () => { + beforeEach(async () => { + await adapter.createSchedule({ + scriptName: 'test-script', + cronExpression: '0 0 * * *', + timezone: 'America/New_York', + input: { key: 'value' }, + }); + }); + + it('should return schedule details', async () => { + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.Name).toBe('test-script'); + expect(schedule.State).toBe('ENABLED'); + expect(schedule.ScheduleExpression).toBe('0 0 * * *'); + expect(schedule.ScheduleExpressionTimezone).toBe('America/New_York'); + }); + + it('should include target configuration', async () => { + const schedule = await adapter.getSchedule('test-script'); + + const targetInput = JSON.parse(schedule.Target.Input); + expect(targetInput).toEqual({ + scriptName: 'test-script', + trigger: 'SCHEDULED', + params: { key: 'value' }, + }); + }); + + it('should include creation and modification dates', async () => { + const schedule = await adapter.getSchedule('test-script'); + + expect(schedule.CreationDate).toBeInstanceOf(Date); + expect(schedule.LastModificationDate).toBeInstanceOf(Date); + }); + + it('should throw error if schedule not found', async () => { + await expect(adapter.getSchedule('non-existent')).rejects.toThrow( + 'Schedule for script "non-existent" not found' + ); + }); + }); + + describe('Utility methods', () => { + it('clear() should remove all schedules', async () => { + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + expect(adapter.size).toBe(2); + + adapter.clear(); + + expect(adapter.size).toBe(0); + }); + + it('size should return number of schedules', async () => { + expect(adapter.size).toBe(0); + + await adapter.createSchedule({ + scriptName: 'script-1', + cronExpression: '0 0 * * *', + }); + + expect(adapter.size).toBe(1); + + await adapter.createSchedule({ + scriptName: 'script-2', + cronExpression: '0 12 * * *', + }); + + expect(adapter.size).toBe(2); + + await adapter.deleteSchedule('script-1'); + + expect(adapter.size).toBe(1); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js new file mode 100644 index 000000000..c59be6529 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter-factory.test.js @@ -0,0 +1,117 @@ +const { createSchedulerAdapter } = require('../scheduler-adapter-factory'); +const { AWSSchedulerAdapter } = require('../aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('../local-scheduler-adapter'); + +// Mock AWS SDK to prevent actual AWS calls +jest.mock('@aws-sdk/client-scheduler', () => ({ + SchedulerClient: jest.fn(() => ({ + send: jest.fn(), + })), + CreateScheduleCommand: jest.fn(), + DeleteScheduleCommand: jest.fn(), + GetScheduleCommand: jest.fn(), + UpdateScheduleCommand: jest.fn(), + ListSchedulesCommand: jest.fn(), +})); + +const awsAdapterParams = { + targetLambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:test', + scheduleGroupName: 'test-group', + roleArn: 'arn:aws:iam::123456789012:role/test-role', +}; + +describe('Scheduler Adapter Factory', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv, AWS_REGION: 'us-east-1' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('createSchedulerAdapter()', () => { + it('should throw if type is not provided', () => { + expect(() => createSchedulerAdapter()).toThrow(); + }); + + it('should throw if type is not provided in options object', () => { + expect(() => createSchedulerAdapter({})).toThrow(); + }); + + it('should create local adapter when type is "local"', () => { + const adapter = createSchedulerAdapter({ type: 'local' }); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter.getName()).toBe('local-cron'); + }); + + it('should create AWS adapter when type is "aws"', () => { + const adapter = createSchedulerAdapter({ type: 'aws', ...awsAdapterParams }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.getName()).toBe('aws-eventbridge-scheduler'); + }); + + it('should create AWS adapter when type is "eventbridge"', () => { + const adapter = createSchedulerAdapter({ type: 'eventbridge', ...awsAdapterParams }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should handle case-insensitive type values', () => { + const adapter1 = createSchedulerAdapter({ type: 'AWS', ...awsAdapterParams }); + const adapter2 = createSchedulerAdapter({ type: 'LOCAL' }); + const adapter3 = createSchedulerAdapter({ type: 'EventBridge', ...awsAdapterParams }); + + expect(adapter1).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter2).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter3).toBeInstanceOf(AWSSchedulerAdapter); + }); + + it('should pass AWS configuration to AWS adapter', () => { + const config = { + type: 'aws', + targetLambdaArn: 'arn:aws:lambda:eu-west-1:123456789012:function:test', + scheduleGroupName: 'custom-group', + roleArn: 'arn:aws:iam::123456789012:role/custom-role', + }; + + const adapter = createSchedulerAdapter(config); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.region).toBe('us-east-1'); // From process.env.AWS_REGION + expect(adapter.targetLambdaArn).toBe('arn:aws:lambda:eu-west-1:123456789012:function:test'); + expect(adapter.scheduleGroupName).toBe('custom-group'); + expect(adapter.roleArn).toBe('arn:aws:iam::123456789012:role/custom-role'); + }); + + it('should pass roleArn through to AWS adapter', () => { + const adapter = createSchedulerAdapter({ + type: 'aws', + ...awsAdapterParams, + roleArn: 'arn:aws:iam::999999999999:role/scheduler-role', + }); + + expect(adapter).toBeInstanceOf(AWSSchedulerAdapter); + expect(adapter.roleArn).toBe('arn:aws:iam::999999999999:role/scheduler-role'); + }); + + it('should ignore AWS config for local adapter', () => { + const config = { + type: 'local', + region: 'eu-west-1', // This should be ignored + }; + + const adapter = createSchedulerAdapter(config); + + expect(adapter).toBeInstanceOf(LocalSchedulerAdapter); + expect(adapter.region).toBeUndefined(); + }); + + it('should throw for unknown adapter type', () => { + expect(() => createSchedulerAdapter({ type: 'unknown-type' })).toThrow(); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js new file mode 100644 index 000000000..a93c56669 --- /dev/null +++ b/packages/admin-scripts/src/adapters/__tests__/scheduler-adapter.test.js @@ -0,0 +1,103 @@ +const { SchedulerAdapter } = require('../scheduler-adapter'); + +describe('SchedulerAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new SchedulerAdapter(); + }); + + describe('Abstract base class', () => { + it('should throw error for getName()', () => { + expect(() => adapter.getName()).toThrow( + 'SchedulerAdapter.getName() must be implemented' + ); + }); + + it('should throw error for createSchedule()', async () => { + await expect(adapter.createSchedule({})).rejects.toThrow( + 'SchedulerAdapter.createSchedule() must be implemented' + ); + }); + + it('should throw error for deleteSchedule()', async () => { + await expect(adapter.deleteSchedule('test')).rejects.toThrow( + 'SchedulerAdapter.deleteSchedule() must be implemented' + ); + }); + + it('should throw error for setScheduleEnabled()', async () => { + await expect(adapter.setScheduleEnabled('test', true)).rejects.toThrow( + 'SchedulerAdapter.setScheduleEnabled() must be implemented' + ); + }); + + it('should throw error for listSchedules()', async () => { + await expect(adapter.listSchedules()).rejects.toThrow( + 'SchedulerAdapter.listSchedules() must be implemented' + ); + }); + + it('should throw error for getSchedule()', async () => { + await expect(adapter.getSchedule('test')).rejects.toThrow( + 'SchedulerAdapter.getSchedule() must be implemented' + ); + }); + }); + + describe('Inheritance', () => { + it('should be extendable by concrete implementations', () => { + class TestSchedulerAdapter extends SchedulerAdapter { + getName() { + return 'test-adapter'; + } + + async createSchedule(config) { + return { scheduleName: config.scriptName }; + } + + async deleteSchedule(scriptName) { + return; + } + + async setScheduleEnabled(scriptName, enabled) { + return; + } + + async listSchedules() { + return []; + } + + async getSchedule(scriptName) { + return { scriptName }; + } + } + + const testAdapter = new TestSchedulerAdapter(); + + expect(testAdapter).toBeInstanceOf(SchedulerAdapter); + expect(testAdapter.getName()).toBe('test-adapter'); + }); + + it('should require all abstract methods to be implemented', async () => { + class IncompleteAdapter extends SchedulerAdapter { + getName() { + return 'incomplete'; + } + // Missing other methods + } + + const incomplete = new IncompleteAdapter(); + + // Should work for implemented method + expect(incomplete.getName()).toBe('incomplete'); + + // Should throw for missing methods + await expect(incomplete.createSchedule({})).rejects.toThrow(); + await expect(incomplete.deleteSchedule('test')).rejects.toThrow(); + await expect(incomplete.setScheduleEnabled('test', true)).rejects.toThrow(); + await expect(incomplete.listSchedules()).rejects.toThrow(); + await expect(incomplete.getSchedule('test')).rejects.toThrow(); + }); + }); +}); diff --git a/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js new file mode 100644 index 000000000..dfe64e275 --- /dev/null +++ b/packages/admin-scripts/src/adapters/aws-scheduler-adapter.js @@ -0,0 +1,156 @@ +const { SchedulerAdapter } = require('./scheduler-adapter'); + +// Lazy-loaded AWS SDK clients (following AWSProviderAdapter pattern) +let SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand, + GetScheduleCommand, UpdateScheduleCommand, ListSchedulesCommand; + +function loadSchedulerSDK() { + if (!SchedulerClient) { + const schedulerModule = require('@aws-sdk/client-scheduler'); + SchedulerClient = schedulerModule.SchedulerClient; + CreateScheduleCommand = schedulerModule.CreateScheduleCommand; + DeleteScheduleCommand = schedulerModule.DeleteScheduleCommand; + GetScheduleCommand = schedulerModule.GetScheduleCommand; + UpdateScheduleCommand = schedulerModule.UpdateScheduleCommand; + ListSchedulesCommand = schedulerModule.ListSchedulesCommand; + } +} + +/** + * AWS EventBridge Scheduler Adapter + * + * Infrastructure Adapter - Hexagonal Architecture + * + * Implements scheduling using AWS EventBridge Scheduler. + * Supports cron expressions, timezone configuration, and Lambda invocation. + */ +class AWSSchedulerAdapter extends SchedulerAdapter { + constructor({ credentials, targetLambdaArn, scheduleGroupName, roleArn } = {}) { + super(); + if (!targetLambdaArn) throw new Error('AWSSchedulerAdapter requires targetLambdaArn'); + if (!scheduleGroupName) throw new Error('AWSSchedulerAdapter requires scheduleGroupName'); + if (!roleArn) throw new Error('AWSSchedulerAdapter requires roleArn'); + // Region inherits from the service (set by Lambda runtime, same for all AWS resources) + const region = process.env.AWS_REGION; + if (!region) throw new Error('AWSSchedulerAdapter requires AWS_REGION environment variable'); + this.region = region; + this.credentials = credentials; + this.targetLambdaArn = targetLambdaArn; + this.scheduleGroupName = scheduleGroupName; + this.roleArn = roleArn; + this.scheduler = null; + } + + getSchedulerClient() { + if (!this.scheduler) { + loadSchedulerSDK(); + this.scheduler = new SchedulerClient({ + region: this.region, + credentials: this.credentials, + }); + } + return this.scheduler; + } + + getName() { + return 'aws-eventbridge-scheduler'; + } + + async createSchedule({ scriptName, cronExpression, timezone, input }) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + const scheduleParams = { + Name: scheduleName, + GroupName: this.scheduleGroupName, + ScheduleExpression: cronExpression, + ScheduleExpressionTimezone: timezone || 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + Target: { + Arn: this.targetLambdaArn, + RoleArn: this.roleArn, + Input: JSON.stringify({ + scriptName, + trigger: 'SCHEDULED', + params: input || {}, + }), + }, + State: 'ENABLED', + }; + + try { + const response = await client.send(new CreateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } catch (error) { + if (error.name === 'ConflictException') { + const response = await client.send(new UpdateScheduleCommand(scheduleParams)); + return { + scheduleArn: response.ScheduleArn, + scheduleName: scheduleName, + }; + } + throw error; + } + } + + async deleteSchedule(scriptName) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + await client.send(new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + })); + } + + async setScheduleEnabled(scriptName, enabled) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + // Get the current schedule first to preserve all settings + const getCommand = new GetScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + }); + + const currentSchedule = await client.send(getCommand); + + // Update with the new state + await client.send(new UpdateScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + ScheduleExpression: currentSchedule.ScheduleExpression, + ScheduleExpressionTimezone: currentSchedule.ScheduleExpressionTimezone, + FlexibleTimeWindow: currentSchedule.FlexibleTimeWindow, + Target: currentSchedule.Target, + State: enabled ? 'ENABLED' : 'DISABLED', + })); + } + + async listSchedules() { + const client = this.getSchedulerClient(); + + const response = await client.send(new ListSchedulesCommand({ + GroupName: this.scheduleGroupName, + })); + + return response.Schedules || []; + } + + async getSchedule(scriptName) { + const client = this.getSchedulerClient(); + const scheduleName = `frigg-script-${scriptName}`; + + const response = await client.send(new GetScheduleCommand({ + Name: scheduleName, + GroupName: this.scheduleGroupName, + })); + + return response; + } +} + +module.exports = { AWSSchedulerAdapter }; diff --git a/packages/admin-scripts/src/adapters/local-scheduler-adapter.js b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js new file mode 100644 index 000000000..7cca0a971 --- /dev/null +++ b/packages/admin-scripts/src/adapters/local-scheduler-adapter.js @@ -0,0 +1,108 @@ +const { SchedulerAdapter } = require('./scheduler-adapter'); + +/** + * Local Scheduler Adapter + * + * Infrastructure Adapter - Hexagonal Architecture + * + * In-memory implementation for local development and testing. + * Stores schedule configurations but does not execute them. + * For actual cron execution, use a library like node-cron. + */ +class LocalSchedulerAdapter extends SchedulerAdapter { + constructor() { + super(); + this.schedules = new Map(); + this.intervals = new Map(); + } + + getName() { + return 'local-cron'; + } + + async createSchedule({ scriptName, cronExpression, timezone, input }) { + // Store schedule (actual cron execution would use node-cron) + this.schedules.set(scriptName, { + scriptName, + cronExpression, + timezone: timezone || 'UTC', + input, + enabled: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + return { + scheduleName: scriptName, + scheduleArn: `local:schedule:${scriptName}`, + }; + } + + async deleteSchedule(scriptName) { + this.schedules.delete(scriptName); + if (this.intervals.has(scriptName)) { + clearInterval(this.intervals.get(scriptName)); + this.intervals.delete(scriptName); + } + } + + async setScheduleEnabled(scriptName, enabled) { + const schedule = this.schedules.get(scriptName); + if (!schedule) { + throw new Error(`Schedule for script "${scriptName}" not found`); + } + + schedule.enabled = enabled; + schedule.updatedAt = new Date().toISOString(); + } + + async listSchedules() { + return Array.from(this.schedules.values()).map((schedule) => ({ + Name: `frigg-script-${schedule.scriptName}`, + State: schedule.enabled ? 'ENABLED' : 'DISABLED', + ScheduleExpression: schedule.cronExpression, + ScheduleExpressionTimezone: schedule.timezone, + })); + } + + async getSchedule(scriptName) { + const schedule = this.schedules.get(scriptName); + if (!schedule) { + throw new Error(`Schedule for script "${scriptName}" not found`); + } + + return { + Name: scriptName, + State: schedule.enabled ? 'ENABLED' : 'DISABLED', + ScheduleExpression: schedule.cronExpression, + ScheduleExpressionTimezone: schedule.timezone, + Target: { + Input: JSON.stringify({ + scriptName, + trigger: 'SCHEDULED', + params: schedule.input || {}, + }), + }, + CreationDate: new Date(schedule.createdAt), + LastModificationDate: new Date(schedule.updatedAt), + }; + } + + /** + * Clear all schedules (useful for testing) + */ + clear() { + this.schedules.clear(); + this.intervals.forEach((interval) => clearInterval(interval)); + this.intervals.clear(); + } + + /** + * Get number of schedules (useful for testing) + */ + get size() { + return this.schedules.size; + } +} + +module.exports = { LocalSchedulerAdapter }; diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js new file mode 100644 index 000000000..522920b1b --- /dev/null +++ b/packages/admin-scripts/src/adapters/scheduler-adapter-factory.js @@ -0,0 +1,49 @@ +const { AWSSchedulerAdapter } = require('./aws-scheduler-adapter'); +const { LocalSchedulerAdapter } = require('./local-scheduler-adapter'); + +/** + * Scheduler Adapter Factory + * + * Application Layer - Hexagonal Architecture + * + * Creates the appropriate scheduler adapter based on explicit configuration + * from appDefinition. Does not auto-detect or read environment variables. + */ + +/** + * Create a scheduler adapter instance + * + * @param {Object} options - Configuration options (from appDefinition.adminScripts.scheduler) + * @param {string} options.type - Adapter type ('aws', 'eventbridge', 'local') - required + * @param {Object} [options.credentials] - AWS credentials (for AWS adapter) + * @param {string} [options.targetLambdaArn] - Lambda ARN to invoke (required for AWS adapter) + * @param {string} [options.scheduleGroupName] - EventBridge schedule group name (required for AWS adapter) + * @param {string} [options.roleArn] - IAM role ARN for scheduler (required for AWS adapter) + * @returns {SchedulerAdapter} Configured scheduler adapter + */ +function createSchedulerAdapter(options = {}) { + if (!options.type) { + throw new Error('Scheduler adapter type is required. Configure in appDefinition.adminScripts.scheduler.type'); + } + + switch (options.type.toLowerCase()) { + case 'aws': + case 'eventbridge': + return new AWSSchedulerAdapter({ + credentials: options.credentials, + targetLambdaArn: options.targetLambdaArn, + scheduleGroupName: options.scheduleGroupName, + roleArn: options.roleArn, + }); + + case 'local': + return new LocalSchedulerAdapter(); + + default: + throw new Error(`Unknown scheduler adapter type: ${options.type}`); + } +} + +module.exports = { + createSchedulerAdapter, +}; diff --git a/packages/admin-scripts/src/adapters/scheduler-adapter.js b/packages/admin-scripts/src/adapters/scheduler-adapter.js new file mode 100644 index 000000000..4a2ad3ae6 --- /dev/null +++ b/packages/admin-scripts/src/adapters/scheduler-adapter.js @@ -0,0 +1,64 @@ +/** + * Scheduler Adapter (Abstract Base Class) + * + * Port - Hexagonal Architecture + * + * Defines the contract for scheduler implementations. + * Supports AWS EventBridge, local cron, or other providers. + */ +class SchedulerAdapter { + getName() { + throw new Error('SchedulerAdapter.getName() must be implemented'); + } + + /** + * Create or update a schedule for a script + * @param {Object} config + * @param {string} config.scriptName - Script identifier + * @param {string} config.cronExpression - Cron expression + * @param {string} [config.timezone] - Timezone (default UTC) + * @param {Object} [config.input] - Optional input params + * @returns {Promise} Created schedule { scheduleArn, scheduleName } + */ + async createSchedule(config) { + throw new Error('SchedulerAdapter.createSchedule() must be implemented'); + } + + /** + * Delete a schedule + * @param {string} scriptName - Script identifier + * @returns {Promise} + */ + async deleteSchedule(scriptName) { + throw new Error('SchedulerAdapter.deleteSchedule() must be implemented'); + } + + /** + * Enable or disable a schedule + * @param {string} scriptName - Script identifier + * @param {boolean} enabled - Whether to enable + * @returns {Promise} + */ + async setScheduleEnabled(scriptName, enabled) { + throw new Error('SchedulerAdapter.setScheduleEnabled() must be implemented'); + } + + /** + * List all schedules + * @returns {Promise} List of schedules + */ + async listSchedules() { + throw new Error('SchedulerAdapter.listSchedules() must be implemented'); + } + + /** + * Get a specific schedule + * @param {string} scriptName - Script identifier + * @returns {Promise} Schedule details + */ + async getSchedule(scriptName) { + throw new Error('SchedulerAdapter.getSchedule() must be implemented'); + } +} + +module.exports = { SchedulerAdapter }; diff --git a/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js new file mode 100644 index 000000000..c8fdbb149 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/admin-frigg-commands.test.js @@ -0,0 +1,429 @@ +const { AdminFriggCommands, createAdminFriggCommands } = require('../admin-frigg-commands'); + +// Mock all repository factories +jest.mock('@friggframework/core/integrations/repositories/integration-repository-factory'); +jest.mock('@friggframework/core/user/repositories/user-repository-factory'); +jest.mock('@friggframework/core/modules/repositories/module-repository-factory'); +jest.mock('@friggframework/core/credential/repositories/credential-repository-factory'); +jest.mock('@friggframework/core/queues'); + +describe('AdminScriptContext', () => { + let mockIntegrationRepo; + let mockUserRepo; + let mockModuleRepo; + let mockCredentialRepo; + let mockQueuerUtil; + + beforeEach(() => { + jest.clearAllMocks(); + + mockIntegrationRepo = { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + findIntegrationsByUserId: jest.fn(), + updateIntegrationConfig: jest.fn(), + updateIntegrationStatus: jest.fn(), + }; + + mockUserRepo = { + findIndividualUserById: jest.fn(), + findIndividualUserByAppUserId: jest.fn(), + findIndividualUserByUsername: jest.fn(), + }; + + mockModuleRepo = { + findEntity: jest.fn(), + findEntityById: jest.fn(), + findEntitiesByUserId: jest.fn(), + }; + + mockCredentialRepo = { + findCredential: jest.fn(), + updateCredential: jest.fn(), + }; + + mockQueuerUtil = { + send: jest.fn().mockResolvedValue(undefined), + batchSend: jest.fn().mockResolvedValue(undefined), + }; + + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + const { QueuerUtil } = require('@friggframework/core/queues'); + + createIntegrationRepository.mockReturnValue(mockIntegrationRepo); + createUserRepository.mockReturnValue(mockUserRepo); + createModuleRepository.mockReturnValue(mockModuleRepo); + createCredentialRepository.mockReturnValue(mockCredentialRepo); + + QueuerUtil.send = mockQueuerUtil.send; + QueuerUtil.batchSend = mockQueuerUtil.batchSend; + }); + + describe('Constructor', () => { + it('creates with executionId', () => { + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); + + expect(ctx.executionId).toBe('exec_123'); + expect(ctx.logs).toEqual([]); + expect(ctx.integrationFactory).toBeNull(); + }); + + it('creates with integrationFactory', () => { + const mockFactory = { getInstanceFromIntegrationId: jest.fn() }; + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); + + expect(ctx.integrationFactory).toBe(mockFactory); + }); + + it('creates without params (defaults)', () => { + const ctx = new AdminFriggCommands(); + + expect(ctx.executionId).toBeNull(); + expect(ctx.logs).toEqual([]); + expect(ctx.integrationFactory).toBeNull(); + }); + }); + + describe('Lazy Repository Loading', () => { + it('creates integrationRepository on first access', () => { + const ctx = new AdminFriggCommands(); + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + + expect(createIntegrationRepository).not.toHaveBeenCalled(); + + const repo = ctx.integrationRepository; + + expect(createIntegrationRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockIntegrationRepo); + }); + + it('returns same instance on subsequent access', () => { + const ctx = new AdminFriggCommands(); + + const repo1 = ctx.integrationRepository; + const repo2 = ctx.integrationRepository; + + expect(repo1).toBe(repo2); + expect(repo1).toBe(mockIntegrationRepo); + }); + + it('creates userRepository on first access', () => { + const ctx = new AdminFriggCommands(); + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + + expect(createUserRepository).not.toHaveBeenCalled(); + + const repo = ctx.userRepository; + + expect(createUserRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockUserRepo); + }); + + it('creates moduleRepository on first access', () => { + const ctx = new AdminFriggCommands(); + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + + expect(createModuleRepository).not.toHaveBeenCalled(); + + const repo = ctx.moduleRepository; + + expect(createModuleRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockModuleRepo); + }); + + it('creates credentialRepository on first access', () => { + const ctx = new AdminFriggCommands(); + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + + expect(createCredentialRepository).not.toHaveBeenCalled(); + + const repo = ctx.credentialRepository; + + expect(createCredentialRepository).toHaveBeenCalledTimes(1); + expect(repo).toBe(mockCredentialRepo); + }); + }); + + describe('instantiate()', () => { + it('throws if no integrationFactory', async () => { + const ctx = new AdminFriggCommands(); + + await expect(ctx.instantiate('int_123')).rejects.toThrow( + 'instantiate() requires integrationFactory. ' + + 'Set Definition.config.requireIntegrationInstance = true' + ); + }); + + it('calls integrationFactory.getInstanceFromIntegrationId', async () => { + const mockInstance = { primary: { api: {} } }; + const mockFactory = { + getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), + }; + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); + + const result = await ctx.instantiate('int_123'); + + expect(result).toEqual(mockInstance); + expect(mockFactory.getInstanceFromIntegrationId).toHaveBeenCalledWith({ + integrationId: 'int_123', + _isAdminContext: true, + }); + }); + + it('passes _isAdminContext: true', async () => { + const mockInstance = { primary: { api: {} } }; + const mockFactory = { + getInstanceFromIntegrationId: jest.fn().mockResolvedValue(mockInstance), + }; + const ctx = new AdminFriggCommands({ integrationFactory: mockFactory }); + + await ctx.instantiate('int_123'); + + const callArgs = mockFactory.getInstanceFromIntegrationId.mock.calls[0][0]; + expect(callArgs._isAdminContext).toBe(true); + }); + }); + + describe('queueScript()', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + const ctx = new AdminFriggCommands(); + + await expect(ctx.queueScript('test-script', {})).rejects.toThrow( + 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' + ); + }); + + it('calls QueuerUtil.send with correct params', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/admin-scripts'; + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); + const params = { integrationId: 'int_456' }; + + await ctx.queueScript('test-script', params); + + expect(mockQueuerUtil.send).toHaveBeenCalledWith( + { + scriptName: 'test-script', + trigger: 'QUEUE', + params: { integrationId: 'int_456' }, + parentExecutionId: 'exec_123', + }, + 'https://sqs.us-east-1.amazonaws.com/123456789012/admin-scripts' + ); + }); + + it('includes parentExecutionId from constructor', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands({ executionId: 'exec_parent' }); + + await ctx.queueScript('my-script', {}); + + const callArgs = mockQueuerUtil.send.mock.calls[0][0]; + expect(callArgs.parentExecutionId).toBe('exec_parent'); + }); + + it('logs queuing operation', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands(); + const params = { batchId: 'batch_1' }; + + await ctx.queueScript('test-script', params); + + const logs = ctx.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('info'); + expect(logs[0].message).toBe('Queued continuation for test-script'); + expect(logs[0].data).toEqual({ params }); + }); + }); + + describe('queueScriptBatch()', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('throws if ADMIN_SCRIPT_QUEUE_URL not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + const ctx = new AdminFriggCommands(); + + await expect(ctx.queueScriptBatch([])).rejects.toThrow( + 'ADMIN_SCRIPT_QUEUE_URL environment variable not set' + ); + }); + + it('calls QueuerUtil.batchSend', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); + const entries = [ + { scriptName: 'script-1', params: { id: '1' } }, + { scriptName: 'script-2', params: { id: '2' } }, + ]; + + await ctx.queueScriptBatch(entries); + + expect(mockQueuerUtil.batchSend).toHaveBeenCalledWith( + [ + { + scriptName: 'script-1', + trigger: 'QUEUE', + params: { id: '1' }, + parentExecutionId: 'exec_123', + }, + { + scriptName: 'script-2', + trigger: 'QUEUE', + params: { id: '2' }, + parentExecutionId: 'exec_123', + }, + ], + 'https://sqs.example.com/queue' + ); + }); + + it('maps entries correctly', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands(); + const entries = [ + { scriptName: 'test-script', params: { value: 'abc' } }, + ]; + + await ctx.queueScriptBatch(entries); + + const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; + expect(callArgs).toHaveLength(1); + expect(callArgs[0].scriptName).toBe('test-script'); + expect(callArgs[0].params).toEqual({ value: 'abc' }); + expect(callArgs[0].trigger).toBe('QUEUE'); + }); + + it('handles entries without params', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands(); + const entries = [ + { scriptName: 'no-params-script' }, + ]; + + await ctx.queueScriptBatch(entries); + + const callArgs = mockQueuerUtil.batchSend.mock.calls[0][0]; + expect(callArgs[0].params).toEqual({}); + }); + + it('logs batch queuing operation', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.example.com/queue'; + const ctx = new AdminFriggCommands(); + const entries = [ + { scriptName: 'script-1', params: {} }, + { scriptName: 'script-2', params: {} }, + { scriptName: 'script-3', params: {} }, + ]; + + await ctx.queueScriptBatch(entries); + + const logs = ctx.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('info'); + expect(logs[0].message).toBe('Queued 3 script continuations'); + }); + }); + + describe('Logging', () => { + it('log() adds entry to logs array', () => { + const ctx = new AdminFriggCommands(); + + const entry = ctx.log('info', 'Test message', { key: 'value' }); + + expect(entry.level).toBe('info'); + expect(entry.message).toBe('Test message'); + expect(entry.data).toEqual({ key: 'value' }); + expect(entry.timestamp).toBeDefined(); + expect(ctx.logs).toHaveLength(1); + expect(ctx.logs[0]).toBe(entry); + }); + + it('log() is in-memory only (no DB persistence)', () => { + const ctx = new AdminFriggCommands({ executionId: 'exec_123' }); + + ctx.log('warn', 'Warning message', { detail: 'xyz' }); + + // Verify entry was added to in-memory logs + expect(ctx.logs).toHaveLength(1); + expect(ctx.logs[0].level).toBe('warn'); + expect(ctx.logs[0].message).toBe('Warning message'); + }); + + it('getLogs() returns all logs', () => { + const ctx = new AdminFriggCommands(); + + ctx.log('info', 'First'); + ctx.log('warn', 'Second'); + ctx.log('error', 'Third'); + + const logs = ctx.getLogs(); + + expect(logs).toHaveLength(3); + expect(logs[0].message).toBe('First'); + expect(logs[1].message).toBe('Second'); + expect(logs[2].message).toBe('Third'); + }); + + it('clearLogs() clears logs array', () => { + const ctx = new AdminFriggCommands(); + + ctx.log('info', 'First'); + ctx.log('info', 'Second'); + expect(ctx.logs).toHaveLength(2); + + ctx.clearLogs(); + + expect(ctx.logs).toHaveLength(0); + }); + + it('getExecutionId() returns executionId', () => { + const ctx = new AdminFriggCommands({ executionId: 'exec_789' }); + + expect(ctx.getExecutionId()).toBe('exec_789'); + }); + + it('getExecutionId() returns null if not set', () => { + const ctx = new AdminFriggCommands(); + + expect(ctx.getExecutionId()).toBeNull(); + }); + }); + + describe('createAdminFriggCommands factory', () => { + it('creates AdminScriptContext instance', () => { + const ctx = createAdminFriggCommands({ executionId: 'exec_123' }); + + expect(ctx).toBeInstanceOf(AdminFriggCommands); + expect(ctx.executionId).toBe('exec_123'); + }); + + it('creates with default params', () => { + const ctx = createAdminFriggCommands(); + + expect(ctx).toBeInstanceOf(AdminFriggCommands); + expect(ctx.executionId).toBeNull(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js new file mode 100644 index 000000000..b07bb61d2 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/admin-script-base.test.js @@ -0,0 +1,198 @@ +const { AdminScriptBase } = require('../admin-script-base'); + +describe('AdminScriptBase', () => { + describe('Static Definition pattern', () => { + it('should have a default Definition', () => { + expect(AdminScriptBase.Definition).toBeDefined(); + expect(AdminScriptBase.Definition.name).toBe('Script Name'); + expect(AdminScriptBase.Definition.version).toBe('0.0.0'); + expect(AdminScriptBase.Definition.description).toBe( + 'What this script does' + ); + expect(AdminScriptBase.Definition.source).toBe('USER_DEFINED'); + }); + + it('should allow child classes to override Definition', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'A test script', + source: 'BUILTIN', + inputSchema: { type: 'object' }, + outputSchema: { type: 'object' }, + schedule: { + enabled: true, + cronExpression: 'cron(0 12 * * ? *)', + }, + config: { + timeout: 600000, + maxRetries: 3, + requireIntegrationInstance: true, + }, + display: { + category: 'testing', + icon: 'test-icon', + }, + }; + } + + expect(TestScript.Definition.name).toBe('test-script'); + expect(TestScript.Definition.version).toBe('1.0.0'); + expect(TestScript.Definition.description).toBe('A test script'); + expect(TestScript.Definition.source).toBe('BUILTIN'); + expect(TestScript.Definition.schedule.enabled).toBe(true); + expect(TestScript.Definition.config.timeout).toBe(600000); + }); + + it('should have clean display object without redundant fields', () => { + expect(AdminScriptBase.Definition.display).toBeDefined(); + expect(AdminScriptBase.Definition.display.category).toBe('maintenance'); + expect(AdminScriptBase.Definition.display.label).toBeUndefined(); + expect(AdminScriptBase.Definition.display.description).toBeUndefined(); + }); + }); + + describe('Constructor', () => { + it('should initialize with default values', () => { + const script = new AdminScriptBase(); + + expect(script.context).toBeNull(); + expect(script.executionId).toBeNull(); + expect(script.integrationFactory).toBeNull(); + }); + + it('should accept context parameter', () => { + const mockContext = { log: jest.fn() }; + const script = new AdminScriptBase({ context: mockContext }); + + expect(script.context).toBe(mockContext); + }); + + it('should accept executionId parameter', () => { + const script = new AdminScriptBase({ executionId: 'exec_123' }); + + expect(script.executionId).toBe('exec_123'); + }); + + it('should accept integrationFactory parameter', () => { + const mockFactory = { mock: true }; + const script = new AdminScriptBase({ + integrationFactory: mockFactory, + }); + + expect(script.integrationFactory).toBe(mockFactory); + }); + + it('should accept all parameters together', () => { + const mockContext = { log: jest.fn() }; + const mockFactory = { mock: true }; + const script = new AdminScriptBase({ + context: mockContext, + executionId: 'exec_456', + integrationFactory: mockFactory, + }); + + expect(script.context).toBe(mockContext); + expect(script.executionId).toBe('exec_456'); + expect(script.integrationFactory).toBe(mockFactory); + }); + }); + + describe('execute()', () => { + it('should throw error when not implemented by subclass', async () => { + const script = new AdminScriptBase(); + + await expect(script.execute({})).rejects.toThrow( + 'AdminScriptBase.execute() must be implemented by subclass' + ); + }); + + it('should allow child classes to implement execute() with params only', async () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'test', + }; + + async execute(params) { + return { result: 'success', params }; + } + } + + const script = new TestScript(); + const params = { foo: 'bar' }; + + const result = await script.execute(params); + + expect(result.result).toBe('success'); + expect(result.params).toEqual({ foo: 'bar' }); + }); + + it('should access context via this.context', async () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'test', + }; + + async execute(params) { + this.context.log('info', 'Starting'); + return { success: true }; + } + } + + const mockContext = { log: jest.fn() }; + const script = new TestScript({ context: mockContext }); + + await script.execute({}); + + expect(mockContext.log).toHaveBeenCalledWith('info', 'Starting'); + }); + }); + + describe('Integration with child classes', () => { + it('should support full lifecycle with context injection', async () => { + class MyScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'My test script', + config: { + requireIntegrationInstance: true, + }, + }; + + async execute(params) { + this.context.log('info', 'Starting execution'); + this.context.log('debug', 'Processing', params); + + if (this.integrationFactory) { + this.context.log('info', 'Integration factory available'); + } + + return { processed: true }; + } + } + + const mockContext = { log: jest.fn() }; + const mockFactory = { getInstanceById: jest.fn() }; + const script = new MyScript({ + context: mockContext, + executionId: 'exec_789', + integrationFactory: mockFactory, + }); + + const result = await script.execute({ test: 'data' }); + + expect(result).toEqual({ processed: true }); + + expect(mockContext.log).toHaveBeenCalledTimes(3); + expect(mockContext.log).toHaveBeenCalledWith('info', 'Starting execution'); + expect(mockContext.log).toHaveBeenCalledWith('debug', 'Processing', { test: 'data' }); + expect(mockContext.log).toHaveBeenCalledWith('info', 'Integration factory available'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js new file mode 100644 index 000000000..498031649 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/dry-run-http-interceptor.test.js @@ -0,0 +1,313 @@ +const { + createDryRunHttpClient, + injectDryRunHttpClient, + sanitizeHeaders, + sanitizeData, + detectService, +} = require('../dry-run-http-interceptor'); + +describe('Dry-Run HTTP Interceptor', () => { + describe('sanitizeHeaders', () => { + test('should redact authorization headers', () => { + const headers = { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret-token', + 'X-API-Key': 'api-key-123', + 'User-Agent': 'frigg/1.0', + }; + + const sanitized = sanitizeHeaders(headers); + + expect(sanitized['Content-Type']).toBe('application/json'); + expect(sanitized['User-Agent']).toBe('frigg/1.0'); + expect(sanitized.Authorization).toBe('[REDACTED]'); + expect(sanitized['X-API-Key']).toBe('[REDACTED]'); + }); + + test('should handle case variations', () => { + const headers = { + authorization: 'Bearer token', + Authorization: 'Bearer token', + 'x-api-key': 'key1', + 'X-API-Key': 'key2', + }; + + const sanitized = sanitizeHeaders(headers); + + expect(sanitized.authorization).toBe('[REDACTED]'); + expect(sanitized.Authorization).toBe('[REDACTED]'); + expect(sanitized['x-api-key']).toBe('[REDACTED]'); + expect(sanitized['X-API-Key']).toBe('[REDACTED]'); + }); + + test('should handle null/undefined', () => { + expect(sanitizeHeaders(null)).toEqual({}); + expect(sanitizeHeaders(undefined)).toEqual({}); + expect(sanitizeHeaders({})).toEqual({}); + }); + }); + + describe('detectService', () => { + test('should detect CRM services', () => { + expect(detectService('https://api.hubapi.com')).toBe('HubSpot'); + expect(detectService('https://login.salesforce.com')).toBe('Salesforce'); + expect(detectService('https://api.pipedrive.com')).toBe('Pipedrive'); + expect(detectService('https://api.attio.com')).toBe('Attio'); + }); + + test('should detect communication services', () => { + expect(detectService('https://slack.com/api')).toBe('Slack'); + expect(detectService('https://discord.com/api')).toBe('Discord'); + expect(detectService('https://graph.teams.microsoft.com')).toBe('Microsoft Teams'); + }); + + test('should detect project management tools', () => { + expect(detectService('https://app.asana.com/api')).toBe('Asana'); + expect(detectService('https://api.monday.com')).toBe('Monday.com'); + expect(detectService('https://api.trello.com')).toBe('Trello'); + }); + + test('should return unknown for unrecognized services', () => { + expect(detectService('https://example.com/api')).toBe('unknown'); + expect(detectService(null)).toBe('unknown'); + expect(detectService(undefined)).toBe('unknown'); + }); + + test('should be case insensitive', () => { + expect(detectService('HTTPS://API.HUBSPOT.COM')).toBe('HubSpot'); + expect(detectService('https://API.SLACK.COM')).toBe('Slack'); + }); + }); + + describe('sanitizeData', () => { + test('should redact sensitive fields', () => { + const data = { + name: 'Test User', + email: 'test@example.com', + password: 'secret123', + apiToken: 'token-abc', + authKey: 'key-xyz', + }; + + const sanitized = sanitizeData(data); + + expect(sanitized.name).toBe('Test User'); + expect(sanitized.email).toBe('test@example.com'); + expect(sanitized.password).toBe('[REDACTED]'); + expect(sanitized.apiToken).toBe('[REDACTED]'); + expect(sanitized.authKey).toBe('[REDACTED]'); + }); + + test('should handle nested objects', () => { + const data = { + user: { + name: 'Test', + credentials: { + password: 'secret', + token: 'abc123', + }, + }, + }; + + const sanitized = sanitizeData(data); + + expect(sanitized.user.name).toBe('Test'); + expect(sanitized.user.credentials.password).toBe('[REDACTED]'); + expect(sanitized.user.credentials.token).toBe('[REDACTED]'); + }); + + test('should handle arrays', () => { + const data = [ + { id: '1', password: 'secret1' }, + { id: '2', apiKey: 'key2' }, + ]; + + const sanitized = sanitizeData(data); + + expect(sanitized[0].id).toBe('1'); + expect(sanitized[0].password).toBe('[REDACTED]'); + expect(sanitized[1].apiKey).toBe('[REDACTED]'); + }); + + test('should preserve primitives', () => { + expect(sanitizeData('string')).toBe('string'); + expect(sanitizeData(123)).toBe(123); + expect(sanitizeData(true)).toBe(true); + expect(sanitizeData(null)).toBe(null); + expect(sanitizeData(undefined)).toBe(undefined); + }); + }); + + describe('createDryRunHttpClient', () => { + let operationLog; + + beforeEach(() => { + operationLog = []; + }); + + test('should log GET requests', async () => { + const client = createDryRunHttpClient(operationLog); + + const response = await client.get('/contacts', { + baseURL: 'https://api.hubapi.com', + headers: { Authorization: 'Bearer token' }, + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0]).toMatchObject({ + operation: 'HTTP_REQUEST', + method: 'GET', + url: 'https://api.hubapi.com/contacts', + service: 'HubSpot', + }); + + expect(operationLog[0].headers.Authorization).toBe('[REDACTED]'); + expect(response.data._dryRun).toBe(true); + }); + + test('should log POST requests with data', async () => { + const client = createDryRunHttpClient(operationLog); + + const postData = { + name: 'John Doe', + email: 'john@example.com', + password: 'secret123', + }; + + await client.post('/users', postData, { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('POST'); + expect(operationLog[0].data.name).toBe('John Doe'); + expect(operationLog[0].data.email).toBe('john@example.com'); + expect(operationLog[0].data.password).toBe('[REDACTED]'); + }); + + test('should log PUT requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.put('/users/123', { status: 'active' }, { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('PUT'); + expect(operationLog[0].data.status).toBe('active'); + }); + + test('should log PATCH requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.patch('/users/123', { name: 'Updated' }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('PATCH'); + }); + + test('should log DELETE requests', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.delete('/users/123', { + baseURL: 'https://api.example.com', + }); + + expect(operationLog).toHaveLength(1); + expect(operationLog[0].method).toBe('DELETE'); + }); + + test('should return mock response', async () => { + const client = createDryRunHttpClient(operationLog); + + const response = await client.get('/test'); + + expect(response.status).toBe(200); + expect(response.statusText).toContain('Dry-Run'); + expect(response.data._dryRun).toBe(true); + expect(response.headers['x-dry-run']).toBe('true'); + }); + + test('should include query params in log', async () => { + const client = createDryRunHttpClient(operationLog); + + await client.get('/search', { + baseURL: 'https://api.example.com', + params: { q: 'test', limit: 10 }, + }); + + expect(operationLog[0].params).toEqual({ q: 'test', limit: 10 }); + }); + }); + + describe('injectDryRunHttpClient', () => { + let operationLog; + let dryRunClient; + + beforeEach(() => { + operationLog = []; + dryRunClient = createDryRunHttpClient(operationLog); + }); + + test('should inject into primary API module', () => { + const integrationInstance = { + primary: { + api: { + _httpClient: { get: jest.fn() }, + }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); + }); + + test('should inject into target API module', () => { + const integrationInstance = { + target: { + api: { + _httpClient: { get: jest.fn() }, + }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); + }); + + test('should inject into both primary and target', () => { + const integrationInstance = { + primary: { + api: { _httpClient: { get: jest.fn() } }, + }, + target: { + api: { _httpClient: { get: jest.fn() } }, + }, + }; + + injectDryRunHttpClient(integrationInstance, dryRunClient); + + expect(integrationInstance.primary.api._httpClient).toBe(dryRunClient); + expect(integrationInstance.target.api._httpClient).toBe(dryRunClient); + }); + + test('should handle missing api modules gracefully', () => { + const integrationInstance = { + primary: {}, + target: null, + }; + + expect(() => { + injectDryRunHttpClient(integrationInstance, dryRunClient); + }).not.toThrow(); + }); + + test('should handle null integration instance', () => { + expect(() => { + injectDryRunHttpClient(null, dryRunClient); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js b/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js new file mode 100644 index 000000000..4d3f9eb5d --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/dry-run-repository-wrapper.test.js @@ -0,0 +1,257 @@ +const { createDryRunWrapper, wrapAdminFriggCommandsForDryRun, sanitizeArgs } = require('../dry-run-repository-wrapper'); + +describe('Dry-Run Repository Wrapper', () => { + describe('createDryRunWrapper', () => { + let mockRepository; + let operationLog; + + beforeEach(() => { + operationLog = []; + mockRepository = { + // Read operations + findById: jest.fn(async (id) => ({ id, name: 'Test Entity' })), + findAll: jest.fn(async () => [{ id: '1' }, { id: '2' }]), + getStatus: jest.fn(() => 'active'), + + // Write operations + create: jest.fn(async (data) => ({ id: 'new-id', ...data })), + update: jest.fn(async (id, data) => ({ id, ...data })), + delete: jest.fn(async (id) => ({ deletedCount: 1 })), + updateStatus: jest.fn(async (id, status) => ({ id, status })), + }; + }); + + test('should pass through read operations unchanged', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + // Call read operations + const byId = await wrapped.findById('123'); + const all = await wrapped.findAll(); + const status = wrapped.getStatus(); + + // Verify original methods were called + expect(mockRepository.findById).toHaveBeenCalledWith('123'); + expect(mockRepository.findAll).toHaveBeenCalled(); + expect(mockRepository.getStatus).toHaveBeenCalled(); + + // Verify results match + expect(byId).toEqual({ id: '123', name: 'Test Entity' }); + expect(all).toHaveLength(2); + expect(status).toBe('active'); + + // No operations should be logged + expect(operationLog).toHaveLength(0); + }); + + test('should intercept and log write operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + // Call write operations + await wrapped.create({ name: 'New Entity' }); + await wrapped.update('123', { name: 'Updated' }); + await wrapped.delete('456'); + + // Original write methods should NOT be called + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.update).not.toHaveBeenCalled(); + expect(mockRepository.delete).not.toHaveBeenCalled(); + + // All operations should be logged + expect(operationLog).toHaveLength(3); + + expect(operationLog[0]).toMatchObject({ + operation: 'CREATE', + model: 'TestModel', + method: 'create', + }); + + expect(operationLog[1]).toMatchObject({ + operation: 'UPDATE', + model: 'TestModel', + method: 'update', + }); + + expect(operationLog[2]).toMatchObject({ + operation: 'DELETE', + model: 'TestModel', + method: 'delete', + }); + }); + + test('should return mock data for create operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.create({ name: 'Test', value: 42 }); + + expect(result).toMatchObject({ + name: 'Test', + value: 42, + _dryRun: true, + }); + + expect(result.id).toMatch(/^dry-run-/); + expect(result.createdAt).toBeDefined(); + }); + + test('should return mock data for update operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.update('123', { status: 'inactive' }); + + expect(result).toMatchObject({ + id: '123', + status: 'inactive', + _dryRun: true, + }); + }); + + test('should return mock data for delete operations', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.delete('123'); + + expect(result).toEqual({ + deletedCount: 1, + _dryRun: true, + }); + }); + + test('should try to return existing data for updates when possible', async () => { + const wrapped = createDryRunWrapper(mockRepository, operationLog, 'TestModel'); + + const result = await wrapped.updateStatus('123', 'inactive'); + + // Should attempt to read existing data + expect(mockRepository.findById).toHaveBeenCalledWith('123'); + + // If found, should return existing merged with updates + expect(result.id).toBe('123'); + }); + }); + + describe('sanitizeArgs', () => { + test('should redact sensitive fields in objects', () => { + const args = [ + { + id: '123', + password: 'secret123', + token: 'abc-def-ghi', + apiKey: 'sk_live_123', + name: 'Test User', + }, + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0]).toEqual({ + id: '123', + password: '[REDACTED]', + token: '[REDACTED]', + apiKey: '[REDACTED]', + name: 'Test User', + }); + }); + + test('should handle nested objects', () => { + const args = [ + { + user: { + name: 'Test', + credentials: { + password: 'secret', + apiToken: 'token123', + }, + }, + }, + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0].user.name).toBe('Test'); + expect(sanitized[0].user.credentials.password).toBe('[REDACTED]'); + expect(sanitized[0].user.credentials.apiToken).toBe('[REDACTED]'); + }); + + test('should handle arrays', () => { + const args = [ + [ + { id: '1', token: 'abc' }, + { id: '2', secret: 'xyz' }, + ], + ]; + + const sanitized = sanitizeArgs(args); + + expect(sanitized[0][0].token).toBe('[REDACTED]'); + expect(sanitized[0][1].secret).toBe('[REDACTED]'); + }); + + test('should preserve primitives', () => { + const args = ['string', 123, true, null, undefined]; + const sanitized = sanitizeArgs(args); + + expect(sanitized).toEqual(['string', 123, true, null, undefined]); + }); + }); + + describe('wrapAdminFriggCommandsForDryRun', () => { + let mockCommands; + let operationLog; + + beforeEach(() => { + operationLog = []; + mockCommands = { + // Read operations + findIntegrationById: jest.fn(async (id) => ({ id, status: 'active' })), + listIntegrations: jest.fn(async () => []), + + // Write operations + updateIntegrationConfig: jest.fn(async (id, config) => ({ id, config })), + updateIntegrationStatus: jest.fn(async (id, status) => ({ id, status })), + updateCredential: jest.fn(async (id, updates) => ({ id, ...updates })), + + // Other methods + log: jest.fn(), + }; + }); + + test('should pass through read operations', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + const integration = await wrapped.findIntegrationById('123'); + const list = await wrapped.listIntegrations(); + + expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); + expect(mockCommands.listIntegrations).toHaveBeenCalled(); + + expect(integration.id).toBe('123'); + expect(operationLog).toHaveLength(0); + }); + + test('should intercept write operations', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + await wrapped.updateIntegrationConfig('123', { setting: 'value' }); + await wrapped.updateIntegrationStatus('456', 'inactive'); + + expect(mockCommands.updateIntegrationConfig).not.toHaveBeenCalled(); + expect(mockCommands.updateIntegrationStatus).not.toHaveBeenCalled(); + + expect(operationLog).toHaveLength(2); + expect(operationLog[0].operation).toBe('UPDATEINTEGRATIONCONFIG'); + expect(operationLog[1].operation).toBe('UPDATEINTEGRATIONSTATUS'); + }); + + test('should return existing data for known update methods', async () => { + const wrapped = wrapAdminFriggCommandsForDryRun(mockCommands, operationLog); + + const result = await wrapped.updateIntegrationConfig('123', { new: 'config' }); + + // Should have tried to fetch existing + expect(mockCommands.findIntegrationById).toHaveBeenCalledWith('123'); + + // Should return existing data + expect(result.id).toBe('123'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js b/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js new file mode 100644 index 000000000..0dc88cc33 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/schedule-management-use-case.test.js @@ -0,0 +1,276 @@ +const { ScheduleManagementUseCase } = require('../schedule-management-use-case'); + +describe('ScheduleManagementUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + getScheduleByScriptName: jest.fn(), + upsertSchedule: jest.fn(), + updateScheduleAwsInfo: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new ScheduleManagementUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('getEffectiveSchedule', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('database'); + expect(result.schedule).toEqual(dbSchedule); + }); + + it('should return definition schedule when no database override', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/New_York', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('definition'); + expect(result.schedule.enabled).toBe(true); + expect(result.schedule.cronExpression).toBe('0 12 * * *'); + }); + + it('should return none when no schedule configured', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.getEffectiveSchedule('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.getEffectiveSchedule('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + }); + }); + + describe('upsertSchedule', () => { + it('should create schedule and provision EventBridge when enabled', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + scheduleName: 'frigg-script-test-script', + }); + mockCommands.updateScheduleAwsInfo.mockResolvedValue({ + ...savedSchedule, + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }); + + const result = await useCase.upsertSchedule('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + + expect(result.success).toBe(true); + expect(result.schedule.scriptName).toBe('test-script'); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + expect(mockCommands.updateScheduleAwsInfo).toHaveBeenCalled(); + }); + + it('should delete EventBridge schedule when disabling', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + mockCommands.updateScheduleAwsInfo.mockResolvedValue({ + ...existingSchedule, + awsScheduleArn: null, + }); + + const result = await useCase.upsertSchedule('test-script', { + enabled: false, + }); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should handle scheduler errors gracefully', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue( + new Error('AWS Scheduler API error') + ); + + const result = await useCase.upsertSchedule('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Should succeed with warning, not fail + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('AWS Scheduler API error'); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.upsertSchedule('non-existent', { enabled: true })) + .rejects.toThrow('Script "non-existent" not found'); + }); + + it('should throw error when enabled without cronExpression', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.upsertSchedule('test-script', { enabled: true })) + .rejects.toThrow('cronExpression is required when enabled is true'); + }); + }); + + describe('deleteSchedule', () => { + it('should delete schedule and EventBridge rule', async () => { + const deletedSchedule = { + scriptName: 'test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: deletedSchedule, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no AWS rule exists', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, // No awsScheduleArn + }); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { + scriptName: 'test-script', + awsScheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue( + new Error('AWS delete failed') + ); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('AWS delete failed'); + }); + + it('should return effective schedule after deletion', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 6 * * *', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.deleteSchedule('test-script'); + + expect(result.effectiveSchedule.source).toBe('definition'); + expect(result.effectiveSchedule.enabled).toBe(true); + }); + + it('should throw error when script not found', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.deleteSchedule('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/script-factory.test.js b/packages/admin-scripts/src/application/__tests__/script-factory.test.js new file mode 100644 index 000000000..e7e60c483 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/script-factory.test.js @@ -0,0 +1,381 @@ +const { + ScriptFactory, + createScriptFactory, + getScriptFactory, +} = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +describe('ScriptFactory', () => { + let factory; + + beforeEach(() => { + factory = new ScriptFactory(); + }); + + describe('register()', () => { + it('should register a script class', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'A test script', + }; + } + + factory.register(TestScript); + + expect(factory.has('test-script')).toBe(true); + expect(factory.size).toBe(1); + }); + + it('should throw error if script class has no Definition', () => { + class InvalidScript {} + + expect(() => factory.register(InvalidScript)).toThrow( + 'Script class must have a static Definition property' + ); + }); + + it('should throw error if Definition has no name', () => { + class InvalidScript extends AdminScriptBase { + static Definition = { + version: '1.0.0', + description: 'No name', + }; + } + + expect(() => factory.register(InvalidScript)).toThrow( + 'Script Definition must have a name' + ); + }); + + it('should throw error if script name is already registered', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'duplicate', + version: '1.0.0', + description: 'First', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'duplicate', + version: '2.0.0', + description: 'Second', + }; + } + + factory.register(Script1); + + expect(() => factory.register(Script2)).toThrow( + 'Script "duplicate" is already registered' + ); + }); + }); + + describe('registerAll()', () => { + it('should register multiple scripts', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'script-1', + version: '1.0.0', + description: 'First', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'script-2', + version: '1.0.0', + description: 'Second', + }; + } + + class Script3 extends AdminScriptBase { + static Definition = { + name: 'script-3', + version: '1.0.0', + description: 'Third', + }; + } + + factory.registerAll([Script1, Script2, Script3]); + + expect(factory.size).toBe(3); + expect(factory.has('script-1')).toBe(true); + expect(factory.has('script-2')).toBe(true); + expect(factory.has('script-3')).toBe(true); + }); + + it('should handle empty array', () => { + factory.registerAll([]); + + expect(factory.size).toBe(0); + }); + }); + + describe('get()', () => { + it('should return registered script class', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const retrieved = factory.get('test'); + + expect(retrieved).toBe(TestScript); + }); + + it('should throw error if script not found', () => { + expect(() => factory.get('non-existent')).toThrow( + 'Script "non-existent" not found' + ); + }); + }); + + describe('has()', () => { + it('should return true for registered script', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + expect(factory.has('test')).toBe(true); + }); + + it('should return false for non-registered script', () => { + expect(factory.has('non-existent')).toBe(false); + }); + }); + + describe('getNames()', () => { + it('should return array of all registered script names', () => { + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.registerAll([Script1, Script2]); + + const names = factory.getNames(); + + expect(names).toHaveLength(2); + expect(names).toContain('script-1'); + expect(names).toContain('script-2'); + }); + + it('should return empty array when no scripts registered', () => { + const names = factory.getNames(); + + expect(names).toEqual([]); + }); + }); + + describe('getAll()', () => { + it('should return all scripts with their definitions', () => { + class Script1 extends AdminScriptBase { + static Definition = { + name: 'script-1', + version: '1.0.0', + description: 'First script', + }; + } + + class Script2 extends AdminScriptBase { + static Definition = { + name: 'script-2', + version: '2.0.0', + description: 'Second script', + }; + } + + factory.registerAll([Script1, Script2]); + + const all = factory.getAll(); + + expect(all).toHaveLength(2); + + const script1Entry = all.find((s) => s.name === 'script-1'); + const script2Entry = all.find((s) => s.name === 'script-2'); + + expect(script1Entry.definition).toEqual(Script1.Definition); + expect(script2Entry.definition).toEqual(Script2.Definition); + }); + + it('should return empty array when no scripts registered', () => { + const all = factory.getAll(); + + expect(all).toEqual([]); + }); + }); + + describe('createInstance()', () => { + it('should create an instance of registered script', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const instance = factory.createInstance('test'); + + expect(instance).toBeInstanceOf(TestScript); + expect(instance).toBeInstanceOf(AdminScriptBase); + }); + + it('should pass params to constructor', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + factory.register(TestScript); + + const mockFactory = { mock: true }; + const instance = factory.createInstance('test', { + executionId: 'exec_123', + integrationFactory: mockFactory, + }); + + expect(instance.executionId).toBe('exec_123'); + expect(instance.integrationFactory).toBe(mockFactory); + }); + + it('should throw error if script not found', () => { + expect(() => factory.createInstance('non-existent')).toThrow( + 'Script "non-existent" not found' + ); + }); + }); + + describe('clear()', () => { + it('should remove all registered scripts', () => { + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.registerAll([Script1, Script2]); + expect(factory.size).toBe(2); + + factory.clear(); + + expect(factory.size).toBe(0); + expect(factory.has('script-1')).toBe(false); + expect(factory.has('script-2')).toBe(false); + }); + }); + + describe('size property', () => { + it('should return count of registered scripts', () => { + expect(factory.size).toBe(0); + + class Script1 extends AdminScriptBase { + static Definition = { name: 'script-1', version: '1.0.0', description: 'One' }; + } + + factory.register(Script1); + expect(factory.size).toBe(1); + + class Script2 extends AdminScriptBase { + static Definition = { name: 'script-2', version: '1.0.0', description: 'Two' }; + } + + factory.register(Script2); + expect(factory.size).toBe(2); + + factory.clear(); + expect(factory.size).toBe(0); + }); + }); + + describe('Global factory functions', () => { + it('getScriptFactory() should return singleton instance', () => { + const factory1 = getScriptFactory(); + const factory2 = getScriptFactory(); + + expect(factory1).toBe(factory2); + expect(factory1).toBeInstanceOf(ScriptFactory); + }); + + it('createScriptFactory() should create new instance', () => { + const factory1 = createScriptFactory(); + const factory2 = createScriptFactory(); + + expect(factory1).not.toBe(factory2); + expect(factory1).toBeInstanceOf(ScriptFactory); + expect(factory2).toBeInstanceOf(ScriptFactory); + }); + + it('global factory should be independent from created instances', () => { + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test', + version: '1.0.0', + description: 'Test', + }; + } + + const customFactory = createScriptFactory(); + customFactory.register(TestScript); + + const globalFactory = getScriptFactory(); + + // Custom factory has the script + expect(customFactory.has('test')).toBe(true); + + // Global factory doesn't (assuming it's empty or has different scripts) + // We can't make assumptions about global factory state in tests + // so we just verify they're different instances + expect(customFactory).not.toBe(globalFactory); + }); + }); + + describe('Exported AdminScriptBase', () => { + it('should export AdminScriptBase class', () => { + expect(AdminScriptBase).toBeDefined(); + expect(typeof AdminScriptBase).toBe('function'); + }); + + it('should be usable to create scripts', () => { + class MyScript extends AdminScriptBase { + static Definition = { + name: 'my-script', + version: '1.0.0', + description: 'My script', + }; + + async execute(frigg, params) { + return { success: true }; + } + } + + const script = new MyScript(); + expect(script).toBeInstanceOf(AdminScriptBase); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/script-runner.test.js b/packages/admin-scripts/src/application/__tests__/script-runner.test.js new file mode 100644 index 000000000..f89f411b1 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/script-runner.test.js @@ -0,0 +1,218 @@ +const { ScriptRunner, createScriptRunner } = require('../script-runner'); +const { ScriptFactory } = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +// Mock dependencies +jest.mock('../admin-frigg-commands'); +jest.mock('@friggframework/core/application/commands/admin-script-commands'); + +const { createAdminFriggCommands } = require('../admin-frigg-commands'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +describe('ScriptRunner', () => { + let scriptFactory; + let mockCommands; + let mockFrigg; + let testScript; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { + timeout: 300000, + maxRetries: 0, + requireIntegrationInstance: false, + }, + }; + + async execute(params) { + return { success: true, params }; + } + } + + beforeEach(() => { + scriptFactory = new ScriptFactory([TestScript]); + + mockCommands = { + createAdminProcess: jest.fn(), + updateAdminProcessState: jest.fn(), + completeAdminProcess: jest.fn(), + }; + + mockFrigg = { + log: jest.fn(), + getExecutionId: jest.fn(), + }; + + createAdminScriptCommands.mockReturnValue(mockCommands); + createAdminFriggCommands.mockReturnValue(mockFrigg); + + mockCommands.createAdminProcess.mockResolvedValue({ + id: 'exec-123', + }); + mockCommands.updateAdminProcessState.mockResolvedValue({}); + mockCommands.completeAdminProcess.mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('execute()', () => { + it('should execute script successfully', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('test-script', { foo: 'bar' }, { + trigger: 'MANUAL', + mode: 'async', + audit: { apiKeyName: 'test-key' }, + }); + + expect(result.status).toBe('COMPLETED'); + expect(result.scriptName).toBe('test-script'); + expect(result.output).toEqual({ success: true, params: { foo: 'bar' } }); + expect(result.executionId).toBe('exec-123'); + expect(result.metrics.durationMs).toBeGreaterThanOrEqual(0); + + expect(mockCommands.createAdminProcess).toHaveBeenCalledWith({ + scriptName: 'test-script', + scriptVersion: '1.0.0', + trigger: 'MANUAL', + mode: 'async', + input: { foo: 'bar' }, + audit: { apiKeyName: 'test-key' }, + }); + + expect(mockCommands.updateAdminProcessState).toHaveBeenCalledWith( + 'exec-123', + 'RUNNING' + ); + + expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( + 'exec-123', + expect.objectContaining({ + state: 'COMPLETED', + output: { success: true, params: { foo: 'bar' } }, + metrics: expect.objectContaining({ + durationMs: expect.any(Number), + }), + }) + ); + }); + + it('should throw error if trigger is not provided', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + await expect( + runner.execute('test-script', { foo: 'bar' }, {}) + ).rejects.toThrow('options.trigger is required'); + }); + + it('should throw error if options are omitted entirely', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + await expect( + runner.execute('test-script', { foo: 'bar' }) + ).rejects.toThrow('options.trigger is required'); + }); + + it('should handle script execution failure', async () => { + class FailingScript extends AdminScriptBase { + static Definition = { + name: 'failing-script', + version: '1.0.0', + description: 'Failing script', + config: { timeout: 300000, maxRetries: 0 }, + }; + + async execute() { + throw new Error('Script failed'); + } + } + + scriptFactory.register(FailingScript); + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('failing-script', {}, { + trigger: 'MANUAL', + mode: 'sync', + }); + + expect(result.status).toBe('FAILED'); + expect(result.scriptName).toBe('failing-script'); + expect(result.error.message).toBe('Script failed'); + + expect(mockCommands.completeAdminProcess).toHaveBeenCalledWith( + 'exec-123', + expect.objectContaining({ + state: 'FAILED', + error: expect.objectContaining({ + message: 'Script failed', + }), + }) + ); + }); + + it('should throw error if integrationFactory required but not provided', async () => { + class IntegrationScript extends AdminScriptBase { + static Definition = { + name: 'integration-script', + version: '1.0.0', + description: 'Integration script', + config: { + requireIntegrationInstance: true, + }, + }; + + async execute() { + return {}; + } + } + + scriptFactory.register(IntegrationScript); + const runner = new ScriptRunner({ + scriptFactory, + commands: mockCommands, + integrationFactory: null, + }); + + await expect( + runner.execute('integration-script', {}, { trigger: 'MANUAL' }) + ).rejects.toThrow( + 'Script "integration-script" requires integrationFactory but none was provided' + ); + }); + + it('should reuse existing execution ID when provided', async () => { + const runner = new ScriptRunner({ scriptFactory, commands: mockCommands }); + + const result = await runner.execute('test-script', { foo: 'bar' }, { + trigger: 'QUEUE', + executionId: 'existing-exec-456', + }); + + expect(result.executionId).toBe('existing-exec-456'); + expect(mockCommands.createAdminProcess).not.toHaveBeenCalled(); + expect(mockCommands.updateAdminProcessState).toHaveBeenCalledWith( + 'existing-exec-456', + 'RUNNING' + ); + }); + }); + + describe('createScriptRunner()', () => { + it('should create runner with default factory', () => { + const runner = createScriptRunner(); + expect(runner).toBeInstanceOf(ScriptRunner); + }); + + it('should create runner with custom params', () => { + const customFactory = new ScriptFactory(); + const runner = createScriptRunner({ scriptFactory: customFactory }); + expect(runner).toBeInstanceOf(ScriptRunner); + expect(runner.scriptFactory).toBe(customFactory); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js b/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js new file mode 100644 index 000000000..66bc16914 --- /dev/null +++ b/packages/admin-scripts/src/application/__tests__/validate-script-input.test.js @@ -0,0 +1,196 @@ +const { validateScriptInput, validateParams, validateType } = require('../validate-script-input'); +const { ScriptFactory } = require('../script-factory'); +const { AdminScriptBase } = require('../admin-script-base'); + +describe('validateScriptInput', () => { + let scriptFactory; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { + requireIntegrationInstance: false, + }, + }; + + async execute(params) { + return { success: true, params }; + } + } + + class SchemaScript extends AdminScriptBase { + static Definition = { + name: 'schema-script', + version: '1.0.0', + description: 'Script with schema', + inputSchema: { + type: 'object', + required: ['requiredParam'], + properties: { + requiredParam: { type: 'string' }, + optionalParam: { type: 'number' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + class TypedScript extends AdminScriptBase { + static Definition = { + name: 'typed-script', + version: '1.0.0', + description: 'Script with typed params', + inputSchema: { + type: 'object', + properties: { + count: { type: 'integer' }, + name: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + }, + }; + + async execute() { + return {}; + } + } + + beforeEach(() => { + scriptFactory = new ScriptFactory([TestScript, SchemaScript, TypedScript]); + }); + + describe('validateScriptInput()', () => { + it('should return VALID for script without schema', () => { + const result = validateScriptInput(scriptFactory, 'test-script', { foo: 'bar' }); + + expect(result.status).toBe('VALID'); + expect(result.scriptName).toBe('test-script'); + expect(result.preview.script.name).toBe('test-script'); + expect(result.preview.script.version).toBe('1.0.0'); + expect(result.preview.input).toEqual({ foo: 'bar' }); + expect(result.message).toContain('Validation passed'); + }); + + it('should return INVALID when required parameters are missing', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', {}); + + expect(result.status).toBe('INVALID'); + expect(result.preview.validation.valid).toBe(false); + expect(result.preview.validation.errors).toContain('Missing required parameter: requiredParam'); + }); + + it('should return INVALID for wrong parameter types', () => { + const result = validateScriptInput(scriptFactory, 'typed-script', { + count: 'not-a-number', + name: 123, + enabled: 'true', + }); + + expect(result.status).toBe('INVALID'); + expect(result.preview.validation.errors).toHaveLength(3); + }); + + it('should return VALID with correct parameters', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', { + requiredParam: 'hello', + optionalParam: 42, + }); + + expect(result.status).toBe('VALID'); + expect(result.preview.validation.valid).toBe(true); + expect(result.preview.validation.errors).toHaveLength(0); + }); + + it('should include inputSchema in preview', () => { + const result = validateScriptInput(scriptFactory, 'schema-script', { + requiredParam: 'test', + }); + + expect(result.preview.inputSchema).toEqual({ + type: 'object', + required: ['requiredParam'], + properties: { + requiredParam: { type: 'string' }, + optionalParam: { type: 'number' }, + }, + }); + }); + + it('should return null inputSchema when script has no schema', () => { + const result = validateScriptInput(scriptFactory, 'test-script', {}); + + expect(result.preview.inputSchema).toBeNull(); + }); + }); + + describe('validateParams()', () => { + it('should return valid when no schema defined', () => { + const result = validateParams({ name: 'test' }, { anything: 'goes' }); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should check required fields', () => { + const definition = { + inputSchema: { + type: 'object', + required: ['a', 'b'], + properties: { + a: { type: 'string' }, + b: { type: 'string' }, + }, + }, + }; + + const result = validateParams(definition, { a: 'yes' }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Missing required parameter: b'); + }); + }); + + describe('validateType()', () => { + it('should validate integer type', () => { + expect(validateType('x', 42, { type: 'integer' })).toBeNull(); + expect(validateType('x', 3.14, { type: 'integer' })).toContain('must be an integer'); + expect(validateType('x', 'foo', { type: 'integer' })).toContain('must be an integer'); + }); + + it('should validate number type', () => { + expect(validateType('x', 3.14, { type: 'number' })).toBeNull(); + expect(validateType('x', 42, { type: 'number' })).toBeNull(); + expect(validateType('x', 'foo', { type: 'number' })).toContain('must be a number'); + }); + + it('should validate string type', () => { + expect(validateType('x', 'hello', { type: 'string' })).toBeNull(); + expect(validateType('x', 123, { type: 'string' })).toContain('must be a string'); + }); + + it('should validate boolean type', () => { + expect(validateType('x', true, { type: 'boolean' })).toBeNull(); + expect(validateType('x', 'true', { type: 'boolean' })).toContain('must be a boolean'); + }); + + it('should validate array type', () => { + expect(validateType('x', [1, 2], { type: 'array' })).toBeNull(); + expect(validateType('x', 'not-array', { type: 'array' })).toContain('must be an array'); + }); + + it('should validate object type', () => { + expect(validateType('x', { a: 1 }, { type: 'object' })).toBeNull(); + expect(validateType('x', [1, 2], { type: 'object' })).toContain('must be an object'); + expect(validateType('x', 'string', { type: 'object' })).toContain('must be an object'); + }); + + it('should return null when no type specified', () => { + expect(validateType('x', 'anything', {})).toBeNull(); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/admin-frigg-commands.js b/packages/admin-scripts/src/application/admin-frigg-commands.js new file mode 100644 index 000000000..c3a51dc85 --- /dev/null +++ b/packages/admin-scripts/src/application/admin-frigg-commands.js @@ -0,0 +1,167 @@ +const { QueuerUtil } = require('@friggframework/core/queues'); + +/** + * AdminScriptContext - Execution environment for admin scripts + * + * Provides a controlled surface area for scripts to interact with + * the Frigg platform. Unique capabilities vs direct repo access: + * + * - **Admin bypass**: `instantiate()` passes `_isAdminContext: true` to + * skip user-ownership checks when loading integration instances + * - **Script chaining**: `queueScript()` / `queueScriptBatch()` let scripts + * enqueue follow-up work with parent execution tracking + * - **Execution-scoped logging**: `log()` collects structured entries tied + * to the current execution for post-run inspection + * - **Lazy-loaded repositories**: Repos are exposed directly as getters + * so scripts can query any data they need without wrapper indirection + */ +class AdminScriptContext { + constructor(params = {}) { + this.executionId = params.executionId || null; + this.logs = []; + + // OPTIONAL: Integration factory for scripts that need external API access + this.integrationFactory = params.integrationFactory || null; + + // Lazy-load repositories to avoid circular deps + this._integrationRepository = null; + this._userRepository = null; + this._moduleRepository = null; + this._credentialRepository = null; + } + + // ==================== LAZY-LOADED REPOSITORIES ==================== + + get integrationRepository() { + if (!this._integrationRepository) { + const { createIntegrationRepository } = require('@friggframework/core/integrations/repositories/integration-repository-factory'); + this._integrationRepository = createIntegrationRepository(); + } + return this._integrationRepository; + } + + get userRepository() { + if (!this._userRepository) { + const { createUserRepository } = require('@friggframework/core/user/repositories/user-repository-factory'); + this._userRepository = createUserRepository(); + } + return this._userRepository; + } + + get moduleRepository() { + if (!this._moduleRepository) { + const { createModuleRepository } = require('@friggframework/core/modules/repositories/module-repository-factory'); + this._moduleRepository = createModuleRepository(); + } + return this._moduleRepository; + } + + get credentialRepository() { + if (!this._credentialRepository) { + const { createCredentialRepository } = require('@friggframework/core/credential/repositories/credential-repository-factory'); + this._credentialRepository = createCredentialRepository(); + } + return this._credentialRepository; + } + + // ==================== INTEGRATION INSTANTIATION ==================== + + /** + * Instantiate an integration instance (for calling external APIs) + * REQUIRES: integrationFactory in constructor + */ + async instantiate(integrationId) { + if (!this.integrationFactory) { + throw new Error( + 'instantiate() requires integrationFactory. ' + + 'Set Definition.config.requireIntegrationInstance = true' + ); + } + return this.integrationFactory.getInstanceFromIntegrationId({ + integrationId, + _isAdminContext: true, // Bypass user ownership check + }); + } + + // ==================== QUEUE OPERATIONS ==================== + + async queueScript(scriptName, params = {}) { + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + throw new Error('ADMIN_SCRIPT_QUEUE_URL environment variable not set'); + } + + await QueuerUtil.send( + { + scriptName, + trigger: 'QUEUE', + params, + parentExecutionId: this.executionId, + }, + queueUrl + ); + + this.log('info', `Queued continuation for ${scriptName}`, { params }); + } + + async queueScriptBatch(entries) { + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + throw new Error('ADMIN_SCRIPT_QUEUE_URL environment variable not set'); + } + + const messages = entries.map(entry => ({ + scriptName: entry.scriptName, + trigger: 'QUEUE', + params: entry.params || {}, + parentExecutionId: this.executionId, + })); + + await QueuerUtil.batchSend(messages, queueUrl); + this.log('info', `Queued ${entries.length} script continuations`); + } + + // ==================== LOGGING ==================== + + log(level, message, data = {}) { + const entry = { + level, + message, + data, + timestamp: new Date().toISOString(), + }; + this.logs.push(entry); + return entry; + } + + getExecutionId() { + return this.executionId; + } + + getLogs() { + return this.logs; + } + + clearLogs() { + this.logs = []; + } +} + +/** + * Create AdminScriptContext instance + */ +function createAdminScriptContext(params = {}) { + return new AdminScriptContext(params); +} + +// Legacy aliases for backwards compatibility +const AdminFriggCommands = AdminScriptContext; +const createAdminFriggCommands = createAdminScriptContext; + +module.exports = { + AdminScriptContext, + createAdminScriptContext, + // Legacy exports (deprecated) + AdminFriggCommands, + createAdminFriggCommands, +}; diff --git a/packages/admin-scripts/src/application/admin-script-base.js b/packages/admin-scripts/src/application/admin-script-base.js new file mode 100644 index 000000000..2aaa50f50 --- /dev/null +++ b/packages/admin-scripts/src/application/admin-script-base.js @@ -0,0 +1,39 @@ +class AdminScriptBase { + static Definition = { + name: 'Script Name', + version: '0.0.0', + description: 'What this script does', + source: 'USER_DEFINED', // 'BUILTIN' | 'USER_DEFINED' + + inputSchema: null, + outputSchema: null, + + schedule: { + enabled: false, + cronExpression: null, + }, + + config: { + timeout: 300000, + maxRetries: 0, + requireIntegrationInstance: false, + }, + + display: { + category: 'maintenance', + icon: null, + }, + }; + + constructor(params = {}) { + this.context = params.context || null; + this.executionId = params.executionId || null; + this.integrationFactory = params.integrationFactory || null; + } + + async execute(params) { + throw new Error('AdminScriptBase.execute() must be implemented by subclass'); + } +} + +module.exports = { AdminScriptBase }; diff --git a/packages/admin-scripts/src/application/dry-run-http-interceptor.js b/packages/admin-scripts/src/application/dry-run-http-interceptor.js new file mode 100644 index 000000000..9b9aba65c --- /dev/null +++ b/packages/admin-scripts/src/application/dry-run-http-interceptor.js @@ -0,0 +1,296 @@ +/** + * Dry-Run HTTP Interceptor + * + * Creates a mock HTTP client that logs requests instead of executing them. + * Used to intercept API module calls during dry-run. + */ + +/** + * Sanitize headers to remove authentication tokens + * @param {Object} headers - HTTP headers + * @returns {Object} Sanitized headers + */ +function sanitizeHeaders(headers) { + if (!headers || typeof headers !== 'object') { + return {}; + } + + const safe = { ...headers }; + + // Remove common auth headers + const sensitiveHeaders = [ + 'authorization', + 'Authorization', + 'x-api-key', + 'X-API-Key', + 'x-auth-token', + 'X-Auth-Token', + 'api-key', + 'API-Key', + 'apikey', + 'ApiKey', + 'token', + 'Token', + ]; + + for (const header of sensitiveHeaders) { + if (safe[header]) { + safe[header] = '[REDACTED]'; + } + } + + return safe; +} + +/** + * Detect service name from base URL + * @param {string} baseURL - Base URL of the API + * @returns {string} Service name + */ +function detectService(baseURL) { + if (!baseURL) return 'unknown'; + + const url = baseURL.toLowerCase(); + + // CRM Systems + if (url.includes('hubspot') || url.includes('hubapi')) return 'HubSpot'; + if (url.includes('salesforce')) return 'Salesforce'; + if (url.includes('pipedrive')) return 'Pipedrive'; + if (url.includes('zoho')) return 'Zoho CRM'; + if (url.includes('attio')) return 'Attio'; + + // Communication + if (url.includes('slack')) return 'Slack'; + if (url.includes('discord')) return 'Discord'; + if (url.includes('teams.microsoft')) return 'Microsoft Teams'; + + // Project Management + if (url.includes('asana')) return 'Asana'; + if (url.includes('monday')) return 'Monday.com'; + if (url.includes('trello')) return 'Trello'; + if (url.includes('clickup')) return 'ClickUp'; + + // Storage + if (url.includes('googleapis.com/drive')) return 'Google Drive'; + if (url.includes('dropbox')) return 'Dropbox'; + if (url.includes('box.com')) return 'Box'; + + // Email & Marketing + if (url.includes('sendgrid')) return 'SendGrid'; + if (url.includes('mailchimp')) return 'Mailchimp'; + if (url.includes('gmail')) return 'Gmail'; + + // Accounting + if (url.includes('quickbooks')) return 'QuickBooks'; + if (url.includes('xero')) return 'Xero'; + + // Other + if (url.includes('stripe')) return 'Stripe'; + if (url.includes('shopify')) return 'Shopify'; + if (url.includes('github')) return 'GitHub'; + if (url.includes('gitlab')) return 'GitLab'; + + return 'unknown'; +} + +/** + * Sanitize request data to remove sensitive information + * @param {*} data - Request data + * @returns {*} Sanitized data + */ +function sanitizeData(data) { + if (data === null || data === undefined) { + return data; + } + + if (typeof data !== 'object') { + return data; + } + + if (Array.isArray(data)) { + return data.map(sanitizeData); + } + + const sanitized = {}; + for (const [key, value] of Object.entries(data)) { + const lowerKey = key.toLowerCase(); + + // Check if this is a leaf node that should be redacted + const isSensitiveField = + lowerKey === 'password' || + lowerKey === 'token' || + lowerKey === 'secret' || + lowerKey === 'apikey' || + lowerKey.endsWith('password') || + lowerKey.endsWith('token') || + lowerKey.endsWith('secret') || + lowerKey.endsWith('key') && !lowerKey.endsWith('publickey'); + + // Only redact if it's a primitive value (not an object/array) + if (isSensitiveField && typeof value !== 'object') { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeData(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; +} + +/** + * Create a dry-run HTTP client + * + * @param {Array} operationLog - Array to append logged HTTP requests + * @returns {Object} Mock HTTP client compatible with axios interface + */ +function createDryRunHttpClient(operationLog) { + /** + * Mock HTTP request handler + * @param {Object} config - Request configuration + * @returns {Promise} Mock response + */ + const mockRequest = async (config) => { + // Build full URL + let fullUrl = config.url; + if (config.baseURL && !config.url.startsWith('http')) { + fullUrl = `${config.baseURL}${config.url.startsWith('/') ? '' : '/'}${config.url}`; + } + + // Log the request that WOULD have been made + const logEntry = { + operation: 'HTTP_REQUEST', + method: (config.method || 'GET').toUpperCase(), + url: fullUrl, + baseURL: config.baseURL, + path: config.url, + service: detectService(config.baseURL || fullUrl), + headers: sanitizeHeaders(config.headers), + timestamp: new Date().toISOString(), + }; + + // Include request data for write operations + if (config.data && ['POST', 'PUT', 'PATCH'].includes(logEntry.method)) { + logEntry.data = sanitizeData(config.data); + } + + // Include query params + if (config.params) { + logEntry.params = sanitizeData(config.params); + } + + operationLog.push(logEntry); + + // Return mock response + return { + status: 200, + statusText: 'OK (Dry-Run)', + data: { + _dryRun: true, + _message: 'This is a dry-run mock response', + _wouldHaveExecuted: `${logEntry.method} ${fullUrl}`, + _service: logEntry.service, + }, + headers: { + 'content-type': 'application/json', + 'x-dry-run': 'true', + }, + config, + }; + }; + + // Return axios-compatible interface + return { + request: mockRequest, + get: (url, config = {}) => mockRequest({ ...config, method: 'GET', url }), + post: (url, data, config = {}) => mockRequest({ ...config, method: 'POST', url, data }), + put: (url, data, config = {}) => mockRequest({ ...config, method: 'PUT', url, data }), + patch: (url, data, config = {}) => + mockRequest({ ...config, method: 'PATCH', url, data }), + delete: (url, config = {}) => mockRequest({ ...config, method: 'DELETE', url }), + head: (url, config = {}) => mockRequest({ ...config, method: 'HEAD', url }), + options: (url, config = {}) => mockRequest({ ...config, method: 'OPTIONS', url }), + + // Axios-specific properties + defaults: { + headers: { + common: {}, + get: {}, + post: {}, + put: {}, + patch: {}, + delete: {}, + }, + }, + + // Interceptors (no-op in dry-run) + interceptors: { + request: { use: () => {}, eject: () => {} }, + response: { use: () => {}, eject: () => {} }, + }, + }; +} + +/** + * Inject dry-run HTTP client into an integration instance + * + * @param {Object} integrationInstance - Integration instance from integrationFactory + * @param {Object} dryRunHttpClient - Dry-run HTTP client + */ +function injectDryRunHttpClient(integrationInstance, dryRunHttpClient) { + if (!integrationInstance) { + return; + } + + // Inject into primary API module + if (integrationInstance.primary?.api) { + injectIntoApiModule(integrationInstance.primary.api, dryRunHttpClient); + } + + // Inject into target API module + if (integrationInstance.target?.api) { + injectIntoApiModule(integrationInstance.target.api, dryRunHttpClient); + } +} + +/** + * Inject dry-run HTTP client into an API module + * @param {Object} apiModule - API module instance + * @param {Object} dryRunHttpClient - Dry-run HTTP client + */ +function injectIntoApiModule(apiModule, dryRunHttpClient) { + // Common property names for HTTP clients in API modules + const httpClientProps = [ + '_httpClient', + 'httpClient', + 'client', + 'axios', + 'request', + 'api', + 'http', + ]; + + for (const prop of httpClientProps) { + if (apiModule[prop] && typeof apiModule[prop] === 'object') { + apiModule[prop] = dryRunHttpClient; + } + } + + // Also check if the API module itself has request methods + if (typeof apiModule.request === 'function') { + Object.assign(apiModule, dryRunHttpClient); + } +} + +module.exports = { + createDryRunHttpClient, + injectDryRunHttpClient, + sanitizeHeaders, + sanitizeData, + detectService, +}; diff --git a/packages/admin-scripts/src/application/dry-run-repository-wrapper.js b/packages/admin-scripts/src/application/dry-run-repository-wrapper.js new file mode 100644 index 000000000..b94a35803 --- /dev/null +++ b/packages/admin-scripts/src/application/dry-run-repository-wrapper.js @@ -0,0 +1,261 @@ +/** + * Dry-Run Repository Wrapper + * + * Wraps any repository to intercept write operations. + * - READ operations pass through unchanged + * - WRITE operations are logged but not executed + * + * Uses Proxy pattern for dynamic method interception + */ + +/** + * Create a dry-run wrapper for any repository + * + * @param {Object} repository - The real repository to wrap + * @param {Array} operationLog - Array to append logged operations + * @param {string} modelName - Name of the model (for logging) + * @returns {Proxy} Wrapped repository that logs write operations + */ +function createDryRunWrapper(repository, operationLog, modelName) { + return new Proxy(repository, { + get(target, prop) { + const value = target[prop]; + + // Return non-function properties as-is + if (typeof value !== 'function') { + return value; + } + + // Identify write operations by name pattern + const writePatterns = /^(create|update|delete|upsert|append|remove|insert|save)/i; + const isWrite = writePatterns.test(prop); + + // Pass through read operations + if (!isWrite) { + return value.bind(target); + } + + // Wrap write operation + return async (...args) => { + // Log the operation that WOULD have been performed + operationLog.push({ + operation: prop.toUpperCase(), + model: modelName, + method: prop, + args: sanitizeArgs(args), + timestamp: new Date().toISOString(), + wouldExecute: `${modelName}.${prop}()`, + }); + + // For write operations, try to return existing data or mock data + // This helps scripts continue executing without errors + + // For updates, try to return existing data + if (prop.includes('update') || prop.includes('upsert')) { + // Try to extract ID from first argument + const possibleId = args[0]; + let existing = null; + + if (possibleId && typeof possibleId === 'string') { + // Try to find existing record + const findMethod = getFindMethod(target, prop); + if (findMethod) { + try { + existing = await findMethod.call(target, possibleId); + } catch (err) { + // Ignore errors, continue to mock + } + } + } + + // Return merged data + if (existing) { + // Merge update data with existing + return { ...existing, ...args[1], _dryRun: true }; + } + + // No existing data, return mock + if (args[1]) { + return { id: possibleId, ...args[1], _dryRun: true }; + } + + return { id: possibleId, _dryRun: true }; + } + + // For creates, return mock object with the data + if (prop.includes('create') || prop.includes('insert')) { + const data = args[0] || {}; + return { + id: `dry-run-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + ...data, + _dryRun: true, + createdAt: new Date().toISOString(), + }; + } + + // For deletes, return success indication + if (prop.includes('delete') || prop.includes('remove')) { + return { deletedCount: 1, _dryRun: true }; + } + + // Default: return mock success + return { success: true, _dryRun: true }; + }; + }, + }); +} + +/** + * Try to find a corresponding find method for an update operation + * @param {Object} target - Repository target + * @param {string} updateMethod - Update method name + * @returns {Function|null} Find method or null + */ +function getFindMethod(target, updateMethod) { + // Common patterns: updateIntegration -> findIntegrationById + const patterns = [ + () => { + const match = updateMethod.match(/update(\w+)/i); + return match ? `find${match[1]}ById` : null; + }, + () => { + const match = updateMethod.match(/update(\w+)/i); + return match ? `get${match[1]}ById` : null; + }, + () => 'findById', + () => 'getById', + ]; + + for (const pattern of patterns) { + const methodName = pattern(); + if (methodName && typeof target[methodName] === 'function') { + return target[methodName]; + } + } + + return null; +} + +/** + * Sanitize arguments for logging (remove sensitive data) + * @param {Array} args - Function arguments + * @returns {Array} Sanitized arguments + */ +function sanitizeArgs(args) { + return args.map((arg) => { + if (arg === null || arg === undefined) { + return arg; + } + + if (typeof arg !== 'object') { + return arg; + } + + if (Array.isArray(arg)) { + return arg.map((item) => sanitizeArgs([item])[0]); + } + + // Sanitize object - remove sensitive fields + const sanitized = {}; + for (const [key, value] of Object.entries(arg)) { + const lowerKey = key.toLowerCase(); + + // Skip sensitive fields + if ( + lowerKey.includes('password') || + lowerKey.includes('token') || + lowerKey.includes('secret') || + lowerKey.includes('key') || + lowerKey.includes('auth') + ) { + sanitized[key] = '[REDACTED]'; + continue; + } + + // Recursively sanitize nested objects + if (typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeArgs([value])[0]; + } else { + sanitized[key] = value; + } + } + + return sanitized; + }); +} + +/** + * Wrap AdminFriggCommands for dry-run mode + * + * @param {Object} realCommands - Real AdminFriggCommands instance + * @param {Array} operationLog - Array to append logged operations + * @returns {Object} Wrapped commands with dry-run repository wrappers + */ +function wrapAdminFriggCommandsForDryRun(realCommands, operationLog) { + return new Proxy(realCommands, { + get(target, prop) { + const value = target[prop]; + + // Pass through non-functions + if (typeof value !== 'function') { + // For lazy-loaded repositories, wrap them + if (prop.endsWith('Repository') && value && typeof value === 'object') { + const modelName = prop.replace('Repository', ''); + return createDryRunWrapper( + value, + operationLog, + modelName.charAt(0).toUpperCase() + modelName.slice(1) + ); + } + return value; + } + + // Identify write operations on the commands themselves + const writePatterns = /^(update|create|delete|append)/i; + const isWrite = writePatterns.test(prop); + + if (!isWrite) { + // Read operations pass through + return value.bind(target); + } + + // Wrap write operations + return async (...args) => { + operationLog.push({ + operation: prop.toUpperCase(), + source: 'AdminFriggCommands', + method: prop, + args: sanitizeArgs(args), + timestamp: new Date().toISOString(), + }); + + // For specific known methods, try to return sensible mocks + if (prop === 'updateIntegrationConfig') { + const [integrationId] = args; + const existing = await target.findIntegrationById(integrationId); + return existing; + } + + if (prop === 'updateIntegrationStatus') { + const [integrationId] = args; + const existing = await target.findIntegrationById(integrationId); + return existing; + } + + if (prop === 'updateCredential') { + const [credentialId, updates] = args; + return { id: credentialId, ...updates, _dryRun: true }; + } + + // Default mock + return { success: true, _dryRun: true }; + }; + }, + }); +} + +module.exports = { + createDryRunWrapper, + wrapAdminFriggCommandsForDryRun, + sanitizeArgs, +}; diff --git a/packages/admin-scripts/src/application/schedule-management-use-case.js b/packages/admin-scripts/src/application/schedule-management-use-case.js new file mode 100644 index 000000000..e3fba9442 --- /dev/null +++ b/packages/admin-scripts/src/application/schedule-management-use-case.js @@ -0,0 +1,230 @@ +/** + * Schedule Management Use Case + * + * Application Layer - Hexagonal Architecture + * + * Orchestrates schedule management operations: + * - Get effective schedule (DB override > Definition > none) + * - Upsert schedule with EventBridge provisioning + * - Delete schedule with EventBridge cleanup + * + * This use case encapsulates the business logic that was previously + * embedded in the router, reducing cognitive complexity and improving testability. + */ +class ScheduleManagementUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Validate that a script exists + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * Get the definition schedule from a script class + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } + + /** + * Get effective schedule (DB override > Definition default > none) + */ + async getEffectiveSchedule(scriptName) { + this._validateScriptExists(scriptName); + + // Check database override first + const dbSchedule = await this.commands.getScheduleByScriptName(scriptName); + if (dbSchedule) { + return { + source: 'database', + schedule: dbSchedule, + }; + } + + // Check definition default + const definitionSchedule = this._getDefinitionSchedule(scriptName); + if (definitionSchedule?.enabled) { + return { + source: 'definition', + schedule: { + scriptName, + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }, + }; + } + + // No schedule configured + return { + source: 'none', + schedule: { + scriptName, + enabled: false, + }, + }; + } + + /** + * Create or update schedule with EventBridge provisioning + */ + async upsertSchedule(scriptName, { enabled, cronExpression, timezone }) { + this._validateScriptExists(scriptName); + this._validateScheduleInput(enabled, cronExpression); + + // Save to database + const schedule = await this.commands.upsertSchedule({ + scriptName, + enabled, + cronExpression: cronExpression || null, + timezone: timezone || 'UTC', + }); + + // Provision/deprovision EventBridge + const schedulerResult = await this._syncEventBridgeSchedule( + scriptName, + enabled, + cronExpression, + timezone, + schedule.awsScheduleArn + ); + + return { + success: true, + schedule: { + ...schedule, + awsScheduleArn: schedulerResult.awsScheduleArn || schedule.awsScheduleArn, + awsScheduleName: schedulerResult.awsScheduleName || schedule.awsScheduleName, + }, + ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }), + }; + } + + /** + * Validate schedule input + * @private + */ + _validateScheduleInput(enabled, cronExpression) { + if (typeof enabled !== 'boolean') { + const error = new Error('enabled must be a boolean'); + error.code = 'INVALID_INPUT'; + throw error; + } + + if (enabled && !cronExpression) { + const error = new Error('cronExpression is required when enabled is true'); + error.code = 'INVALID_INPUT'; + throw error; + } + } + + /** + * Sync EventBridge schedule based on enabled state + * @private + */ + async _syncEventBridgeSchedule(scriptName, enabled, cronExpression, timezone, existingArn) { + const result = { awsScheduleArn: null, awsScheduleName: null, warning: null }; + + try { + if (enabled && cronExpression) { + // Create/update EventBridge schedule + const awsInfo = await this.schedulerAdapter.createSchedule({ + scriptName, + cronExpression, + timezone: timezone || 'UTC', + }); + + if (awsInfo?.scheduleArn) { + await this.commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: awsInfo.scheduleArn, + awsScheduleName: awsInfo.scheduleName, + }); + result.awsScheduleArn = awsInfo.scheduleArn; + result.awsScheduleName = awsInfo.scheduleName; + } + } else if (!enabled && existingArn) { + // Delete EventBridge schedule + await this.schedulerAdapter.deleteSchedule(scriptName); + await this.commands.updateScheduleAwsInfo(scriptName, { + awsScheduleArn: null, + awsScheduleName: null, + }); + } + } catch (error) { + // Non-fatal: DB schedule is saved, AWS can be retried + result.warning = error.message; + } + + return result; + } + + /** + * Delete schedule override and cleanup EventBridge + */ + async deleteSchedule(scriptName) { + this._validateScriptExists(scriptName); + + // Delete from database + const deleteResult = await this.commands.deleteSchedule(scriptName); + + // Cleanup EventBridge if needed + const schedulerWarning = await this._cleanupEventBridgeSchedule( + scriptName, + deleteResult.deleted?.awsScheduleArn + ); + + // Get effective schedule after deletion + const definitionSchedule = this._getDefinitionSchedule(scriptName); + const effectiveSchedule = definitionSchedule?.enabled + ? { + source: 'definition', + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + } + : { source: 'none', enabled: false }; + + return { + success: true, + deletedCount: deleteResult.deletedCount, + message: deleteResult.deletedCount > 0 + ? 'Schedule override removed' + : 'No schedule override found', + effectiveSchedule, + ...(schedulerWarning && { schedulerWarning }), + }; + } + + /** + * Cleanup EventBridge schedule if it exists + * @private + */ + async _cleanupEventBridgeSchedule(scriptName, awsScheduleArn) { + if (!awsScheduleArn) { + return null; + } + + try { + await this.schedulerAdapter.deleteSchedule(scriptName); + return null; + } catch (error) { + // Non-fatal: DB is cleaned up, AWS can be retried + return error.message; + } + } +} + +module.exports = { ScheduleManagementUseCase }; diff --git a/packages/admin-scripts/src/application/script-factory.js b/packages/admin-scripts/src/application/script-factory.js new file mode 100644 index 000000000..8c6ba0229 --- /dev/null +++ b/packages/admin-scripts/src/application/script-factory.js @@ -0,0 +1,161 @@ +/** + * Script Factory + * + * Registry and factory for admin scripts. + * Manages script registration, validation, and instantiation. + * + * Usage: + * ```javascript + * const factory = new ScriptFactory(); + * factory.register(MyScript); + * const script = factory.createInstance('my-script', { executionId: '123' }); + * ``` + */ +class ScriptFactory { + constructor(scripts = []) { + this.registry = new Map(); + + // Register initial scripts + scripts.forEach((ScriptClass) => this.register(ScriptClass)); + } + + /** + * Register a script class + * @param {Function} ScriptClass - Script class extending AdminScriptBase + * @throws {Error} If script invalid or name collision + */ + register(ScriptClass) { + if (!ScriptClass || !ScriptClass.Definition) { + throw new Error('Script class must have a static Definition property'); + } + + const definition = ScriptClass.Definition; + const name = definition.name; + + if (!name) { + throw new Error('Script Definition must have a name'); + } + + if (this.registry.has(name)) { + throw new Error(`Script "${name}" is already registered`); + } + + this.registry.set(name, ScriptClass); + } + + /** + * Register multiple scripts at once + * @param {Array} scriptClasses - Array of script classes + */ + registerAll(scriptClasses) { + scriptClasses.forEach((ScriptClass) => this.register(ScriptClass)); + } + + /** + * Check if script is registered + * @param {string} name - Script name + * @returns {boolean} True if registered + */ + has(name) { + return this.registry.has(name); + } + + /** + * Get script class by name + * @param {string} name - Script name + * @returns {Function} Script class + * @throws {Error} If script not found + */ + get(name) { + const ScriptClass = this.registry.get(name); + if (!ScriptClass) { + throw new Error(`Script "${name}" not found`); + } + return ScriptClass; + } + + /** + * Get array of all registered script names + * @returns {Array} Array of script names + */ + getNames() { + return Array.from(this.registry.keys()); + } + + /** + * Get all registered scripts + * @returns {Array} Array of { name, definition, class } + */ + getAll() { + const scripts = []; + for (const [name, ScriptClass] of this.registry.entries()) { + scripts.push({ + name, + definition: ScriptClass.Definition, + class: ScriptClass, + }); + } + return scripts; + } + + /** + * Create script instance + * @param {string} name - Script name + * @param {Object} params - Constructor parameters + * @returns {Object} Script instance + * @throws {Error} If script not found + */ + createInstance(name, params = {}) { + const ScriptClass = this.get(name); + return new ScriptClass(params); + } + + /** + * Remove script from registry + * @param {string} name - Script name + * @returns {boolean} True if removed + */ + unregister(name) { + return this.registry.delete(name); + } + + /** + * Clear all registered scripts + */ + clear() { + this.registry.clear(); + } + + /** + * Get count of registered scripts + * @returns {number} Count + */ + get size() { + return this.registry.size; + } +} + +// Singleton instance for global use +let globalFactory = null; + +/** + * Get global script factory instance + * @returns {ScriptFactory} Global factory + */ +function getScriptFactory() { + if (!globalFactory) { + globalFactory = new ScriptFactory(); + } + return globalFactory; +} + +/** + * Create a new script factory instance + * @param {Array} scripts - Initial scripts to register + * @returns {ScriptFactory} New factory + */ +function createScriptFactory(scripts = []) { + return new ScriptFactory(scripts); +} + +module.exports = { ScriptFactory, getScriptFactory, createScriptFactory }; diff --git a/packages/admin-scripts/src/application/script-runner.js b/packages/admin-scripts/src/application/script-runner.js new file mode 100644 index 000000000..75158881b --- /dev/null +++ b/packages/admin-scripts/src/application/script-runner.js @@ -0,0 +1,144 @@ +const { getScriptFactory } = require('./script-factory'); +const { createAdminScriptContext } = require('./admin-frigg-commands'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +/** + * Script Runner + * + * Orchestrates script execution with: + * - Execution record creation + * - Script instantiation with context injection + * - Error handling + * - Status updates + */ +class ScriptRunner { + constructor(params = {}) { + this.scriptFactory = params.scriptFactory || getScriptFactory(); + this.commands = params.commands || createAdminScriptCommands(); + this.integrationFactory = params.integrationFactory || null; + } + + /** + * Execute a script + * @param {string} scriptName - Name of the script to run + * @param {Object} params - Script parameters + * @param {Object} options - Execution options + * @param {string} options.trigger - 'MANUAL' | 'SCHEDULED' | 'QUEUE' + * @param {string} options.mode - 'sync' | 'async' + * @param {Object} options.audit - Audit info { apiKeyName, apiKeyLast4, ipAddress } + * @param {string} options.executionId - Reuse existing AdminProcess record ID (NOT the Lambda execution ID). + * This is the database ID from the AdminProcess collection/table that tracks script executions. + * Pass this when resuming a queued execution to continue using the same execution record. + */ + async execute(scriptName, params = {}, options = {}) { + const { trigger, audit = {}, executionId: existingExecutionId } = options; + + if (!trigger) { + throw new Error('options.trigger is required (MANUAL | SCHEDULED | QUEUE)'); + } + + // Get script class + const scriptClass = this.scriptFactory.get(scriptName); + const definition = scriptClass.Definition; + + // Validate integrationFactory requirement + if (definition.config?.requireIntegrationInstance && !this.integrationFactory) { + throw new Error( + `Script "${scriptName}" requires integrationFactory but none was provided` + ); + } + + let executionId = existingExecutionId; + + // Create execution record if not provided + if (!executionId) { + const execution = await this.commands.createAdminProcess({ + scriptName, + scriptVersion: definition.version, + trigger, + mode: options.mode || 'async', + input: params, + audit, + }); + executionId = execution.id; + } + + const startTime = new Date(); + + try { + await this.commands.updateAdminProcessState(executionId, 'RUNNING'); + + // Create context for the script (facade over repositories, queue, logging) + const context = createAdminScriptContext({ + executionId, + integrationFactory: this.integrationFactory, + }); + + // Create script instance with context injected via constructor + const script = this.scriptFactory.createInstance(scriptName, { + context, + executionId, + integrationFactory: this.integrationFactory, + }); + + // Execute the script + const output = await script.execute(params); + + // Calculate metrics + const endTime = new Date(); + const durationMs = endTime - startTime; + + await this.commands.completeAdminProcess(executionId, { + state: 'COMPLETED', + output, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + + return { + executionId, + status: 'COMPLETED', + scriptName, + output, + metrics: { durationMs }, + }; + } catch (error) { + const endTime = new Date(); + const durationMs = endTime - startTime; + + await this.commands.completeAdminProcess(executionId, { + state: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + metrics: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + durationMs, + }, + }); + + return { + executionId, + status: 'FAILED', + scriptName, + error: { + name: error.name, + message: error.message, + }, + metrics: { durationMs }, + }; + } + } +} + +function createScriptRunner(params = {}) { + return new ScriptRunner(params); +} + +module.exports = { ScriptRunner, createScriptRunner }; diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js new file mode 100644 index 000000000..18158b374 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/delete-schedule-use-case.test.js @@ -0,0 +1,168 @@ +const { DeleteScheduleUseCase } = require('../delete-schedule-use-case'); + +describe('DeleteScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + deleteSchedule: jest.fn(), + }; + + mockSchedulerAdapter = { + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new DeleteScheduleUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should delete schedule and cleanup external scheduler', async () => { + const deletedSchedule = { + scriptName: 'test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: deletedSchedule, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(1); + expect(result.message).toBe('Schedule override removed'); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no external rule exists', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, // No externalScheduleId + }); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully with warning', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { + scriptName: 'test-script', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue( + new Error('Scheduler delete failed') + ); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('Scheduler delete failed'); + }); + + it('should return definition schedule as effective after deletion', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 6 * * *', + timezone: 'America/Los_Angeles', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.source).toBe('definition'); + expect(result.effectiveSchedule.enabled).toBe(true); + expect(result.effectiveSchedule.cronExpression).toBe('0 6 * * *'); + expect(result.effectiveSchedule.timezone).toBe('America/Los_Angeles'); + }); + + it('should default timezone to UTC when not in definition', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: true, cronExpression: '0 6 * * *' } }, + }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.timezone).toBe('UTC'); + }); + + it('should return none as effective when no definition schedule', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 1, + deleted: { scriptName: 'test-script' }, + }); + + const result = await useCase.execute('test-script'); + + expect(result.effectiveSchedule.source).toBe('none'); + expect(result.effectiveSchedule.enabled).toBe(false); + }); + + it('should return correct message when no schedule found', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.deleteSchedule.mockResolvedValue({ + deletedCount: 0, + deleted: null, + }); + + const result = await useCase.execute('test-script'); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(0); + expect(result.message).toBe('No schedule override found'); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent'); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js new file mode 100644 index 000000000..93852cf21 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/get-effective-schedule-use-case.test.js @@ -0,0 +1,114 @@ +const { GetEffectiveScheduleUseCase } = require('../get-effective-schedule-use-case'); + +describe('GetEffectiveScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + getScheduleByScriptName: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new GetEffectiveScheduleUseCase({ + commands: mockCommands, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(dbSchedule); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('database'); + expect(result.schedule).toEqual(dbSchedule); + }); + + it('should return definition schedule when no database override', async () => { + const definitionSchedule = { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/New_York', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: definitionSchedule }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('definition'); + expect(result.schedule.enabled).toBe(true); + expect(result.schedule.cronExpression).toBe('0 12 * * *'); + expect(result.schedule.timezone).toBe('America/New_York'); + }); + + it('should default timezone to UTC when not specified in definition', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: true, cronExpression: '0 12 * * *' } }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.schedule.timezone).toBe('UTC'); + }); + + it('should return none when no schedule configured', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ Definition: {} }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + expect(result.schedule.scriptName).toBe('test-script'); + }); + + it('should return none when definition schedule is disabled', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockScriptFactory.get.mockReturnValue({ + Definition: { schedule: { enabled: false } }, + }); + mockCommands.getScheduleByScriptName.mockResolvedValue(null); + + const result = await useCase.execute('test-script'); + + expect(result.source).toBe('none'); + expect(result.schedule.enabled).toBe(false); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent')) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent'); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js b/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js new file mode 100644 index 000000000..3d435c420 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/__tests__/upsert-schedule-use-case.test.js @@ -0,0 +1,201 @@ +const { UpsertScheduleUseCase } = require('../upsert-schedule-use-case'); + +describe('UpsertScheduleUseCase', () => { + let useCase; + let mockCommands; + let mockSchedulerAdapter; + let mockScriptFactory; + + beforeEach(() => { + mockCommands = { + upsertSchedule: jest.fn(), + updateScheduleExternalInfo: jest.fn(), + }; + + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + }; + + mockScriptFactory = { + has: jest.fn(), + get: jest.fn(), + }; + + useCase = new UpsertScheduleUseCase({ + commands: mockCommands, + schedulerAdapter: mockSchedulerAdapter, + scriptFactory: mockScriptFactory, + }); + }); + + describe('execute', () => { + it('should create schedule and provision external scheduler when enabled', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123:schedule/test', + scheduleName: 'frigg-script-test-script', + }); + mockCommands.updateScheduleExternalInfo.mockResolvedValue({ + ...savedSchedule, + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }); + + const result = await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + + expect(result.success).toBe(true); + expect(result.schedule.scriptName).toBe('test-script'); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalled(); + }); + + it('should default timezone to UTC', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue({ + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:test', + scheduleName: 'test', + }); + + await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'UTC', + }); + }); + + it('should delete external scheduler when disabling', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123:schedule/test', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + mockCommands.updateScheduleExternalInfo.mockResolvedValue({ + ...existingSchedule, + externalScheduleId: null, + }); + + const result = await useCase.execute('test-script', { + enabled: false, + }); + + expect(result.success).toBe(true); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should handle scheduler errors gracefully with warning', async () => { + const savedSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + }; + + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue(savedSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue( + new Error('Scheduler API error') + ); + + const result = await useCase.execute('test-script', { + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Should succeed with warning, not fail + expect(result.success).toBe(true); + expect(result.schedulerWarning).toBe('Scheduler API error'); + }); + + it('should throw SCRIPT_NOT_FOUND error when script does not exist', async () => { + mockScriptFactory.has.mockReturnValue(false); + + await expect(useCase.execute('non-existent', { enabled: true })) + .rejects.toThrow('Script "non-existent" not found'); + + try { + await useCase.execute('non-existent', { enabled: true }); + } catch (error) { + expect(error.code).toBe('SCRIPT_NOT_FOUND'); + } + }); + + it('should throw INVALID_INPUT error when enabled is not a boolean', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.execute('test-script', { enabled: 'yes' })) + .rejects.toThrow('enabled must be a boolean'); + + try { + await useCase.execute('test-script', { enabled: 'yes' }); + } catch (error) { + expect(error.code).toBe('INVALID_INPUT'); + } + }); + + it('should throw INVALID_INPUT error when enabled without cronExpression', async () => { + mockScriptFactory.has.mockReturnValue(true); + + await expect(useCase.execute('test-script', { enabled: true })) + .rejects.toThrow('cronExpression is required when enabled is true'); + + try { + await useCase.execute('test-script', { enabled: true }); + } catch (error) { + expect(error.code).toBe('INVALID_INPUT'); + } + }); + + it('should not require cronExpression when disabled', async () => { + mockScriptFactory.has.mockReturnValue(true); + mockCommands.upsertSchedule.mockResolvedValue({ + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + }); + + const result = await useCase.execute('test-script', { enabled: false }); + + expect(result.success).toBe(true); + expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + }); + }); + }); +}); diff --git a/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js new file mode 100644 index 000000000..41caf65e9 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/delete-schedule-use-case.js @@ -0,0 +1,108 @@ +/** + * Delete Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Deletes a schedule override and cleans up external scheduler resources. + * Returns the effective schedule after deletion (may fall back to definition). + */ +class DeleteScheduleUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Delete a schedule override + * @param {string} scriptName - Name of the script + * @returns {Promise<{success: boolean, deletedCount: number, message: string, effectiveSchedule: Object, schedulerWarning?: string}>} + */ + async execute(scriptName) { + this._validateScriptExists(scriptName); + + // Delete from database + const deleteResult = await this.commands.deleteSchedule(scriptName); + + // Cleanup external scheduler if needed + const schedulerWarning = await this._cleanupExternalScheduler( + scriptName, + deleteResult.deleted?.externalScheduleId + ); + + // Determine effective schedule after deletion + const effectiveSchedule = this._getEffectiveScheduleAfterDeletion(scriptName); + + return { + success: true, + deletedCount: deleteResult.deletedCount, + message: deleteResult.deletedCount > 0 + ? 'Schedule override removed' + : 'No schedule override found', + effectiveSchedule, + ...(schedulerWarning && { schedulerWarning }), + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * Get the definition schedule from a script class + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } + + /** + * Determine effective schedule after deletion + * @private + */ + _getEffectiveScheduleAfterDeletion(scriptName) { + const definitionSchedule = this._getDefinitionSchedule(scriptName); + + if (definitionSchedule?.enabled) { + return { + source: 'definition', + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }; + } + + return { + source: 'none', + enabled: false, + }; + } + + /** + * Cleanup external scheduler resources + * @private + */ + async _cleanupExternalScheduler(scriptName, externalScheduleId) { + if (!externalScheduleId) { + return null; + } + + try { + await this.schedulerAdapter.deleteSchedule(scriptName); + return null; + } catch (error) { + // Non-fatal: DB is cleaned up, external scheduler can be retried + return error.message; + } + } +} + +module.exports = { DeleteScheduleUseCase }; diff --git a/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js new file mode 100644 index 000000000..9bc3e6be9 --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/get-effective-schedule-use-case.js @@ -0,0 +1,78 @@ +/** + * Get Effective Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Resolves the effective schedule for a script following priority: + * 1. Database override (runtime configuration) + * 2. Definition default (code-defined schedule) + * 3. None (manual execution only) + */ +class GetEffectiveScheduleUseCase { + constructor({ commands, scriptFactory }) { + this.commands = commands; + this.scriptFactory = scriptFactory; + } + + /** + * Get effective schedule for a script + * @param {string} scriptName - Name of the script + * @returns {Promise<{source: 'database'|'definition'|'none', schedule: Object}>} + */ + async execute(scriptName) { + this._validateScriptExists(scriptName); + + // Priority 1: Database override + const dbSchedule = await this.commands.getScheduleByScriptName(scriptName); + if (dbSchedule) { + return { + source: 'database', + schedule: dbSchedule, + }; + } + + // Priority 2: Definition default + const definitionSchedule = this._getDefinitionSchedule(scriptName); + if (definitionSchedule?.enabled) { + return { + source: 'definition', + schedule: { + scriptName, + enabled: definitionSchedule.enabled, + cronExpression: definitionSchedule.cronExpression, + timezone: definitionSchedule.timezone || 'UTC', + }, + }; + } + + // Priority 3: No schedule + return { + source: 'none', + schedule: { + scriptName, + enabled: false, + }, + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * @private + */ + _getDefinitionSchedule(scriptName) { + const scriptClass = this.scriptFactory.get(scriptName); + return scriptClass.Definition?.schedule || null; + } +} + +module.exports = { GetEffectiveScheduleUseCase }; diff --git a/packages/admin-scripts/src/application/use-cases/index.js b/packages/admin-scripts/src/application/use-cases/index.js new file mode 100644 index 000000000..36baa33da --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/index.js @@ -0,0 +1,18 @@ +/** + * Schedule Management Use Cases + * + * Separated by Single Responsibility Principle: + * - GetEffectiveScheduleUseCase: Read schedule with priority resolution + * - UpsertScheduleUseCase: Create/update schedule with scheduler sync + * - DeleteScheduleUseCase: Delete schedule with scheduler cleanup + */ + +const { GetEffectiveScheduleUseCase } = require('./get-effective-schedule-use-case'); +const { UpsertScheduleUseCase } = require('./upsert-schedule-use-case'); +const { DeleteScheduleUseCase } = require('./delete-schedule-use-case'); + +module.exports = { + GetEffectiveScheduleUseCase, + UpsertScheduleUseCase, + DeleteScheduleUseCase, +}; diff --git a/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js b/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js new file mode 100644 index 000000000..ab795a77c --- /dev/null +++ b/packages/admin-scripts/src/application/use-cases/upsert-schedule-use-case.js @@ -0,0 +1,127 @@ +/** + * Upsert Schedule Use Case + * + * Application Layer - Hexagonal Architecture + * + * Creates or updates a schedule override with external scheduler provisioning. + * Abstracts scheduler provider (AWS EventBridge, etc.) behind schedulerAdapter. + */ +class UpsertScheduleUseCase { + constructor({ commands, schedulerAdapter, scriptFactory }) { + this.commands = commands; + this.schedulerAdapter = schedulerAdapter; + this.scriptFactory = scriptFactory; + } + + /** + * Create or update a schedule + * @param {string} scriptName - Name of the script + * @param {Object} input - Schedule configuration + * @param {boolean} input.enabled - Whether schedule is enabled + * @param {string} [input.cronExpression] - Cron expression (required if enabled) + * @param {string} [input.timezone] - Timezone (defaults to UTC) + * @returns {Promise<{success: boolean, schedule: Object, schedulerWarning?: string}>} + */ + async execute(scriptName, { enabled, cronExpression, timezone }) { + this._validateScriptExists(scriptName); + this._validateInput(enabled, cronExpression); + + // Save to database + const schedule = await this.commands.upsertSchedule({ + scriptName, + enabled, + cronExpression: cronExpression || null, + timezone: timezone || 'UTC', + }); + + // Sync with external scheduler (AWS EventBridge, etc.) + const schedulerResult = await this._syncExternalScheduler( + scriptName, + enabled, + cronExpression, + timezone, + schedule.externalScheduleId + ); + + return { + success: true, + schedule: { + ...schedule, + externalScheduleId: schedulerResult.externalScheduleId || schedule.externalScheduleId, + externalScheduleName: schedulerResult.externalScheduleName || schedule.externalScheduleName, + }, + ...(schedulerResult.warning && { schedulerWarning: schedulerResult.warning }), + }; + } + + /** + * @private + */ + _validateScriptExists(scriptName) { + if (!this.scriptFactory.has(scriptName)) { + const error = new Error(`Script "${scriptName}" not found`); + error.code = 'SCRIPT_NOT_FOUND'; + throw error; + } + } + + /** + * @private + */ + _validateInput(enabled, cronExpression) { + if (typeof enabled !== 'boolean') { + const error = new Error('enabled must be a boolean'); + error.code = 'INVALID_INPUT'; + throw error; + } + + if (enabled && !cronExpression) { + const error = new Error('cronExpression is required when enabled is true'); + error.code = 'INVALID_INPUT'; + throw error; + } + } + + /** + * Sync with external scheduler service + * Abstracts AWS EventBridge or other scheduler providers + * @private + */ + async _syncExternalScheduler(scriptName, enabled, cronExpression, timezone, existingId) { + const result = { externalScheduleId: null, externalScheduleName: null, warning: null }; + + try { + if (enabled && cronExpression) { + // Create/update external schedule + const schedulerInfo = await this.schedulerAdapter.createSchedule({ + scriptName, + cronExpression, + timezone: timezone || 'UTC', + }); + + if (schedulerInfo?.scheduleArn) { + await this.commands.updateScheduleExternalInfo(scriptName, { + externalScheduleId: schedulerInfo.scheduleArn, + externalScheduleName: schedulerInfo.scheduleName, + }); + result.externalScheduleId = schedulerInfo.scheduleArn; + result.externalScheduleName = schedulerInfo.scheduleName; + } + } else if (!enabled && existingId) { + // Delete external schedule + await this.schedulerAdapter.deleteSchedule(scriptName); + await this.commands.updateScheduleExternalInfo(scriptName, { + externalScheduleId: null, + externalScheduleName: null, + }); + } + } catch (error) { + // Non-fatal: DB schedule is saved, external scheduler can be retried + result.warning = error.message; + } + + return result; + } +} + +module.exports = { UpsertScheduleUseCase }; diff --git a/packages/admin-scripts/src/application/validate-script-input.js b/packages/admin-scripts/src/application/validate-script-input.js new file mode 100644 index 000000000..8a6cb6fdd --- /dev/null +++ b/packages/admin-scripts/src/application/validate-script-input.js @@ -0,0 +1,116 @@ +/** + * Validate Script Input + * + * Application Layer - Standalone validation for script inputs. + * Used by the /validate endpoint to preview what would be executed + * without actually running the script. + */ + +/** + * Validate script input parameters against the script's definition and schema. + * + * @param {Object} scriptFactory - Script factory instance + * @param {string} scriptName - Name of the script to validate + * @param {Object} params - Input parameters to validate + * @returns {Object} Validation preview result + */ +function validateScriptInput(scriptFactory, scriptName, params = {}) { + const scriptClass = scriptFactory.get(scriptName); + const definition = scriptClass.Definition; + const validation = validateParams(definition, params); + + return { + status: validation.valid ? 'VALID' : 'INVALID', + scriptName, + preview: { + script: { + name: definition.name, + version: definition.version, + description: definition.description, + requireIntegrationInstance: definition.config?.requireIntegrationInstance || false, + }, + input: params, + inputSchema: definition.inputSchema || null, + validation, + }, + message: validation.valid + ? 'Validation passed. Script is ready to execute with provided parameters.' + : `Validation failed: ${validation.errors.join(', ')}`, + }; +} + +/** + * Validate parameters against a script's input schema. + * + * @param {Object} definition - Script definition + * @param {Object} params - Input parameters + * @returns {Object} { valid: boolean, errors: string[] } + */ +function validateParams(definition, params) { + const errors = []; + const schema = definition.inputSchema; + + if (!schema) { + return { valid: true, errors: [] }; + } + + // Check required fields + if (schema.required && Array.isArray(schema.required)) { + for (const field of schema.required) { + if (params[field] === undefined || params[field] === null) { + errors.push(`Missing required parameter: ${field}`); + } + } + } + + // Basic type validation for properties + if (schema.properties) { + for (const [key, prop] of Object.entries(schema.properties)) { + const value = params[key]; + if (value !== undefined && value !== null) { + const typeError = validateType(key, value, prop); + if (typeError) { + errors.push(typeError); + } + } + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Validate a single parameter type. + * + * @param {string} key - Parameter name + * @param {*} value - Parameter value + * @param {Object} schema - JSON Schema property definition + * @returns {string|null} Error message or null if valid + */ +function validateType(key, value, schema) { + const expectedType = schema.type; + if (!expectedType) return null; + + if (expectedType === 'integer' && (typeof value !== 'number' || !Number.isInteger(value))) { + return `Parameter "${key}" must be an integer`; + } + if (expectedType === 'number' && typeof value !== 'number') { + return `Parameter "${key}" must be a number`; + } + if (expectedType === 'string' && typeof value !== 'string') { + return `Parameter "${key}" must be a string`; + } + if (expectedType === 'boolean' && typeof value !== 'boolean') { + return `Parameter "${key}" must be a boolean`; + } + if (expectedType === 'array' && !Array.isArray(value)) { + return `Parameter "${key}" must be an array`; + } + if (expectedType === 'object' && (typeof value !== 'object' || Array.isArray(value))) { + return `Parameter "${key}" must be an object`; + } + + return null; +} + +module.exports = { validateScriptInput, validateParams, validateType }; diff --git a/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js new file mode 100644 index 000000000..94781f329 --- /dev/null +++ b/packages/admin-scripts/src/builtins/__tests__/integration-health-check.test.js @@ -0,0 +1,607 @@ +const { IntegrationHealthCheckScript } = require('../integration-health-check'); + +describe('IntegrationHealthCheckScript', () => { + describe('Definition', () => { + it('should have correct name and metadata', () => { + expect(IntegrationHealthCheckScript.Definition.name).toBe('integration-health-check'); + expect(IntegrationHealthCheckScript.Definition.version).toBe('1.0.0'); + expect(IntegrationHealthCheckScript.Definition.source).toBe('BUILTIN'); + expect(IntegrationHealthCheckScript.Definition.config.requireIntegrationInstance).toBe(true); + }); + + it('should have valid input schema', () => { + const schema = IntegrationHealthCheckScript.Definition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.integrationIds).toBeDefined(); + expect(schema.properties.checkCredentials).toBeDefined(); + expect(schema.properties.checkConnectivity).toBeDefined(); + expect(schema.properties.updateStatus).toBeDefined(); + }); + + it('should have valid output schema', () => { + const schema = IntegrationHealthCheckScript.Definition.outputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.healthy).toBeDefined(); + expect(schema.properties.unhealthy).toBeDefined(); + expect(schema.properties.unknown).toBeDefined(); + expect(schema.properties.results).toBeDefined(); + }); + + it('should have schedule configuration', () => { + const schedule = IntegrationHealthCheckScript.Definition.schedule; + expect(schedule).toBeDefined(); + expect(schedule.enabled).toBe(false); + expect(schedule.cronExpression).toBe('cron(0 6 * * ? *)'); + }); + + it('should have appropriate timeout configuration', () => { + expect(IntegrationHealthCheckScript.Definition.config.timeout).toBe(900000); // 15 minutes + }); + + it('should have clean display object', () => { + // Display should only have UI-specific fields + expect(IntegrationHealthCheckScript.Definition.display.category).toBe('maintenance'); + // Should NOT have redundant label/description - they're derived from top-level + }); + }); + + describe('execute()', () => { + let script; + let mockContext; + + beforeEach(() => { + mockContext = { + log: jest.fn(), + integrationRepository: { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + updateIntegrationStatus: jest.fn(), + }, + instantiate: jest.fn(), + }; + script = new IntegrationHealthCheckScript({ context: mockContext }); + }); + + it('should return empty results when no integrations found', async () => { + mockContext.integrationRepository.findIntegrations.mockResolvedValue([]); + + const result = await script.execute({}); + + expect(result.healthy).toBe(0); + expect(result.unhealthy).toBe(0); + expect(result.unknown).toBe(0); + expect(result.results).toEqual([]); + }); + + it('should return healthy for valid integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: true + }); + + expect(result.healthy).toBe(1); + expect(result.unhealthy).toBe(0); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'healthy', + issues: [] + }); + expect(mockInstance.primary.api.getAuthenticationInfo).toHaveBeenCalled(); + }); + + it('should return unhealthy for missing access token', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // No access_token + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.healthy).toBe(0); + expect(result.unhealthy).toBe(1); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'unhealthy', + issues: ['Missing access token'] + }); + }); + + it('should return unhealthy for expired credentials', async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours ago + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: pastDate.toISOString() + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.unhealthy).toBe(1); + expect(result.results[0]).toMatchObject({ + integrationId: 'int-1', + status: 'unhealthy', + issues: ['Access token expired'] + }); + }); + + it('should return unhealthy for connectivity failures', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockRejectedValue(new Error('Network error')) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: true + }); + + expect(result.unhealthy).toBe(1); + expect(result.results[0].status).toBe('unhealthy'); + expect(result.results[0].issues).toContainEqual(expect.stringContaining('API connectivity failed')); + }); + + it('should update integration status when updateStatus is true', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.updateIntegrationStatus.mockResolvedValue(undefined); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: true, + updateStatus: true + }); + + expect(result.healthy).toBe(1); + expect(mockContext.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ACTIVE'); + }); + + it('should update integration status to ERROR for unhealthy integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // Missing credentials + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.integrationRepository.updateIntegrationStatus.mockResolvedValue(undefined); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: false, + updateStatus: true + }); + + expect(result.unhealthy).toBe(1); + expect(mockContext.integrationRepository.updateIntegrationStatus).toHaveBeenCalledWith('int-1', 'ERROR'); + }); + + it('should not update status when updateStatus is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + await script.execute({ + checkCredentials: true, + checkConnectivity: true, + updateStatus: false + }); + + expect(mockContext.integrationRepository.updateIntegrationStatus).not.toHaveBeenCalled(); + }); + + it('should handle status update failures gracefully', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + mockContext.integrationRepository.updateIntegrationStatus.mockRejectedValue(new Error('Update failed')); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: true, + updateStatus: true + }); + + expect(result.healthy).toBe(1); // Should still report healthy + expect(mockContext.log).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Failed to update status'), + expect.any(Object) + ); + }); + + it('should filter by specific integration IDs', async () => { + const integration1 = { + id: 'int-1', + config: { type: 'hubspot', credentials: { access_token: 'token1' } } + }; + const integration2 = { + id: 'int-2', + config: { type: 'salesforce', credentials: { access_token: 'token2' } } + }; + + mockContext.integrationRepository.findIntegrationById.mockImplementation((id) => { + if (id === 'int-1') return Promise.resolve(integration1); + if (id === 'int-2') return Promise.resolve(integration2); + return Promise.reject(new Error('Not found')); + }); + + const result = await script.execute({ + integrationIds: ['int-1', 'int-2'], + checkCredentials: true, + checkConnectivity: false + }); + + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); + expect(result.results).toHaveLength(2); + }); + + it('should handle errors when checking integrations', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: true + }); + + // Should still complete but mark as unknown or unhealthy + expect(result.results).toHaveLength(1); + expect(result.results[0].integrationId).toBe('int-1'); + }); + + it('should skip credential check when checkCredentials is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: {} // Missing credentials, but check is disabled + } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + checkCredentials: false, + checkConnectivity: true + }); + + expect(result.results[0].checks.credentials).toBeUndefined(); + expect(result.results[0].checks.connectivity).toBeDefined(); + }); + + it('should skip connectivity check when checkConnectivity is false', async () => { + const integration = { + id: 'int-1', + config: { + type: 'hubspot', + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({ + checkCredentials: true, + checkConnectivity: false + }); + + expect(result.results[0].checks.credentials).toBeDefined(); + expect(result.results[0].checks.connectivity).toBeUndefined(); + expect(mockContext.instantiate).not.toHaveBeenCalled(); + }); + }); + + describe('checkCredentialValidity()', () => { + let script; + + beforeEach(() => { + script = new IntegrationHealthCheckScript({ context: { log: jest.fn() } }); + }); + + it('should return valid for integrations with valid credentials', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + }); + + it('should return invalid for missing access token', () => { + const integration = { + id: 'int-1', + config: { + credentials: {} + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(false); + expect(result.issue).toBe('Missing access token'); + }); + + it('should return invalid for expired tokens', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() - 1000).toISOString() // Expired + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(false); + expect(result.issue).toBe('Access token expired'); + }); + + it('should return valid for credentials without expiry', () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123' + // No expires_at + } + } + }; + + const result = script.checkCredentialValidity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + }); + }); + + describe('checkApiConnectivity()', () => { + let script; + let mockContext; + + beforeEach(() => { + mockContext = { + log: jest.fn(), + instantiate: jest.fn(), + }; + script = new IntegrationHealthCheckScript({ context: mockContext }); + }); + + it('should return valid for successful API calls', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + }); + + it('should try getCurrentUser if getAuthenticationInfo is not available', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getCurrentUser: jest.fn().mockResolvedValue({ user: 'test' }) + } + } + }; + + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(integration); + + expect(result.valid).toBe(true); + expect(mockInstance.primary.api.getCurrentUser).toHaveBeenCalled(); + }); + + it('should return note when no health check endpoint is available', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: {} // No health check methods + } + }; + + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(integration); + + expect(result.valid).toBe(true); + expect(result.issue).toBeNull(); + expect(result.note).toBe('No health check endpoint available'); + }); + + it('should return invalid for API failures', async () => { + const integration = { + id: 'int-1', + config: { type: 'hubspot' } + }; + + const mockInstance = { + primary: { + api: { + getAuthenticationInfo: jest.fn().mockRejectedValue(new Error('Network error')) + } + } + }; + + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.checkApiConnectivity(integration); + + expect(result.valid).toBe(false); + expect(result.issue).toContain('API connectivity failed'); + expect(result.issue).toContain('Network error'); + }); + }); +}); diff --git a/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js new file mode 100644 index 000000000..9068ad17c --- /dev/null +++ b/packages/admin-scripts/src/builtins/__tests__/oauth-token-refresh.test.js @@ -0,0 +1,354 @@ +const { OAuthTokenRefreshScript } = require('../oauth-token-refresh'); + +describe('OAuthTokenRefreshScript', () => { + describe('Definition', () => { + it('should have correct name and metadata', () => { + expect(OAuthTokenRefreshScript.Definition.name).toBe('oauth-token-refresh'); + expect(OAuthTokenRefreshScript.Definition.version).toBe('1.0.0'); + expect(OAuthTokenRefreshScript.Definition.source).toBe('BUILTIN'); + expect(OAuthTokenRefreshScript.Definition.config.requireIntegrationInstance).toBe(true); + }); + + it('should have valid input schema', () => { + const schema = OAuthTokenRefreshScript.Definition.inputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.integrationIds).toBeDefined(); + expect(schema.properties.expiryThresholdHours).toBeDefined(); + expect(schema.properties.dryRun).toBeDefined(); + }); + + it('should have valid output schema', () => { + const schema = OAuthTokenRefreshScript.Definition.outputSchema; + expect(schema.type).toBe('object'); + expect(schema.properties.refreshed).toBeDefined(); + expect(schema.properties.failed).toBeDefined(); + expect(schema.properties.skipped).toBeDefined(); + expect(schema.properties.details).toBeDefined(); + }); + + it('should have appropriate timeout configuration', () => { + expect(OAuthTokenRefreshScript.Definition.config.timeout).toBe(600000); // 10 minutes + }); + + it('should have clean display object without redundant fields', () => { + expect(OAuthTokenRefreshScript.Definition.display).toBeDefined(); + expect(OAuthTokenRefreshScript.Definition.display.category).toBe('maintenance'); + // Should NOT have redundant label/description + expect(OAuthTokenRefreshScript.Definition.display.label).toBeUndefined(); + expect(OAuthTokenRefreshScript.Definition.display.description).toBeUndefined(); + }); + }); + + describe('execute()', () => { + let script; + let mockContext; + + beforeEach(() => { + mockContext = { + log: jest.fn(), + integrationRepository: { + findIntegrations: jest.fn(), + findIntegrationById: jest.fn(), + }, + instantiate: jest.fn(), + }; + script = new OAuthTokenRefreshScript({ context: mockContext }); + }); + + it('should return empty results when no integrations found', async () => { + mockContext.integrationRepository.findIntegrations.mockResolvedValue([]); + + const result = await script.execute({}); + + expect(result.refreshed).toBe(0); + expect(result.failed).toBe(0); + expect(result.skipped).toBe(0); + expect(result.details).toEqual([]); + expect(mockContext.log).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object)); + }); + + it('should skip integrations without OAuth credentials', async () => { + const integration = { + id: 'int-1', + config: {} // No credentials + }; + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({}); + + expect(result.skipped).toBe(1); + expect(result.refreshed).toBe(0); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'No OAuth credentials found' + }); + }); + + it('should skip integrations without expiry time', async () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123' + // No expires_at + } + } + }; + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({}); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'No expiry time found' + }); + }); + + it('should skip tokens not near expiry', async () => { + const farFutureExpiry = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours from now + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: farFutureExpiry.toISOString() + } + } + }; + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({ + expiryThresholdHours: 24 + }); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'Token not near expiry' + }); + }); + + it('should refresh tokens that are near expiry', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + refreshAccessToken: jest.fn().mockResolvedValue(undefined) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + expiryThresholdHours: 24 + }); + + expect(result.refreshed).toBe(1); + expect(result.skipped).toBe(0); + expect(mockInstance.primary.api.refreshAccessToken).toHaveBeenCalled(); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'refreshed' + }); + }); + + it('should handle dryRun mode correctly', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + + const result = await script.execute({ + expiryThresholdHours: 24, + dryRun: true + }); + + expect(result.refreshed).toBe(0); + expect(result.skipped).toBe(1); + expect(mockContext.instantiate).not.toHaveBeenCalled(); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'Dry run - would have refreshed' + }); + }); + + it('should handle refresh failures gracefully', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + refreshAccessToken: jest.fn().mockRejectedValue(new Error('API Error')) + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + expiryThresholdHours: 24 + }); + + expect(result.failed).toBe(1); + expect(result.refreshed).toBe(0); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'failed', + reason: 'API Error' + }); + }); + + it('should skip integrations without refresh support', async () => { + const soonExpiry = new Date(Date.now() + 12 * 60 * 60 * 1000); + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: soonExpiry.toISOString() + } + } + }; + + const mockInstance = { + primary: { + api: { + // No refreshAccessToken method + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockResolvedValue(mockInstance); + + const result = await script.execute({ + expiryThresholdHours: 24 + }); + + expect(result.skipped).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'skipped', + reason: 'API does not support token refresh' + }); + }); + + it('should filter by specific integration IDs', async () => { + const integration1 = { + id: 'int-1', + config: { credentials: { access_token: 'token1' } } + }; + const integration2 = { + id: 'int-2', + config: { credentials: { access_token: 'token2' } } + }; + + mockContext.integrationRepository.findIntegrationById.mockImplementation((id) => { + if (id === 'int-1') return Promise.resolve(integration1); + if (id === 'int-2') return Promise.resolve(integration2); + return Promise.reject(new Error('Not found')); + }); + + const result = await script.execute({ + integrationIds: ['int-1', 'int-2'] + }); + + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-1'); + expect(mockContext.integrationRepository.findIntegrationById).toHaveBeenCalledWith('int-2'); + expect(mockContext.integrationRepository.findIntegrations).not.toHaveBeenCalled(); + expect(result.details).toHaveLength(2); + }); + + it('should handle errors when processing integrations', async () => { + const integration = { + id: 'int-1', + config: { + credentials: { + access_token: 'token123', + expires_at: new Date(Date.now() + 12 * 60 * 60 * 1000).toISOString() + } + } + }; + + mockContext.integrationRepository.findIntegrations.mockResolvedValue([integration]); + mockContext.instantiate.mockRejectedValue(new Error('Instantiation failed')); + + const result = await script.execute({ + expiryThresholdHours: 24 + }); + + expect(result.failed).toBe(1); + expect(result.details[0]).toMatchObject({ + integrationId: 'int-1', + action: 'failed', + reason: 'Instantiation failed' + }); + }); + }); + + describe('processIntegration()', () => { + let script; + let mockContext; + + beforeEach(() => { + mockContext = { + log: jest.fn(), + instantiate: jest.fn(), + }; + script = new OAuthTokenRefreshScript({ context: mockContext }); + }); + + it('should return correct detail object for each scenario', async () => { + // Test various scenarios are covered in execute() tests above + // This test validates the method can be called directly + const integration = { + id: 'int-1', + config: {} + }; + + const result = await script.processIntegration(integration, { + expiryThresholdHours: 24, + dryRun: false + }); + + expect(result).toHaveProperty('integrationId'); + expect(result).toHaveProperty('action'); + expect(result).toHaveProperty('reason'); + }); + }); +}); diff --git a/packages/admin-scripts/src/builtins/index.js b/packages/admin-scripts/src/builtins/index.js new file mode 100644 index 000000000..03bde88bc --- /dev/null +++ b/packages/admin-scripts/src/builtins/index.js @@ -0,0 +1,28 @@ +const { OAuthTokenRefreshScript } = require('./oauth-token-refresh'); +const { IntegrationHealthCheckScript } = require('./integration-health-check'); + +/** + * Built-in Admin Scripts + * + * These scripts ship with @friggframework/admin-scripts and provide + * common maintenance and monitoring functionality. + */ +const builtinScripts = [ + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, +]; + +/** + * Register all built-in scripts with a factory + * @param {ScriptFactory} factory - Script factory to register with + */ +function registerBuiltinScripts(factory) { + factory.registerAll(builtinScripts); +} + +module.exports = { + OAuthTokenRefreshScript, + IntegrationHealthCheckScript, + builtinScripts, + registerBuiltinScripts, +}; diff --git a/packages/admin-scripts/src/builtins/integration-health-check.js b/packages/admin-scripts/src/builtins/integration-health-check.js new file mode 100644 index 000000000..a6ffe4ea2 --- /dev/null +++ b/packages/admin-scripts/src/builtins/integration-health-check.js @@ -0,0 +1,278 @@ +const { AdminScriptBase } = require('../application/admin-script-base'); + +/** + * Integration Health Check Script + * + * Checks the health of integrations by verifying: + * - Credential validity + * - API connectivity + * - Configuration integrity + */ +class IntegrationHealthCheckScript extends AdminScriptBase { + static Definition = { + name: 'integration-health-check', + version: '1.0.0', + description: 'Checks health of integrations and reports issues', + source: 'BUILTIN', + + inputSchema: { + type: 'object', + properties: { + integrationIds: { + type: 'array', + items: { type: 'string' }, + description: 'Specific integration IDs to check (optional, defaults to all)' + }, + checkCredentials: { + type: 'boolean', + default: true, + description: 'Verify credential validity' + }, + checkConnectivity: { + type: 'boolean', + default: true, + description: 'Test API connectivity' + }, + updateStatus: { + type: 'boolean', + default: false, + description: 'Update integration status based on health' + } + } + }, + + outputSchema: { + type: 'object', + properties: { + healthy: { type: 'number' }, + unhealthy: { type: 'number' }, + unknown: { type: 'number' }, + results: { type: 'array' } + } + }, + + config: { + timeout: 900000, // 15 minutes + maxRetries: 0, + requireIntegrationInstance: true, + }, + + schedule: { + enabled: false, // Can be enabled via API + cronExpression: 'cron(0 6 * * ? *)', // Daily at 6 AM UTC + }, + + // UI-specific overrides + display: { + category: 'maintenance', + }, + }; + + async execute(params = {}) { + const { + integrationIds = null, + checkCredentials = true, + checkConnectivity = true, + updateStatus = false + } = params; + + const summary = { + healthy: 0, + unhealthy: 0, + unknown: 0, + results: [] + }; + + this.context.log('info', 'Starting integration health check', { + checkCredentials, + checkConnectivity, + updateStatus, + specificIds: integrationIds?.length || 'all' + }); + + // Get integrations to check + let integrations; + if (integrationIds && integrationIds.length > 0) { + integrations = await Promise.all( + integrationIds.map(id => this.context.integrationRepository.findIntegrationById(id).catch(() => null)) + ); + integrations = integrations.filter(Boolean); + } else { + integrations = await this.getAllIntegrations(); + } + + this.context.log('info', `Checking ${integrations.length} integrations`); + + for (const integration of integrations) { + const result = await this.checkIntegration(integration, { + checkCredentials, + checkConnectivity + }); + + summary.results.push(result); + + if (result.status === 'healthy') { + summary.healthy++; + } else if (result.status === 'unhealthy') { + summary.unhealthy++; + } else { + summary.unknown++; + } + + // Optionally update integration status + if (updateStatus && result.status !== 'unknown') { + try { + const newStatus = result.status === 'healthy' ? 'ACTIVE' : 'ERROR'; + await this.context.integrationRepository.updateIntegrationStatus(integration.id, newStatus); + this.context.log('info', `Updated status for ${integration.id} to ${newStatus}`); + } catch (error) { + this.context.log('warn', `Failed to update status for ${integration.id}`, { + error: error.message + }); + } + } + } + + this.context.log('info', 'Health check completed', { + healthy: summary.healthy, + unhealthy: summary.unhealthy, + unknown: summary.unknown + }); + + return summary; + } + + async getAllIntegrations() { + return this.context.integrationRepository.findIntegrations({}); + } + + async checkIntegration(integration, options) { + const { checkCredentials, checkConnectivity } = options; + const result = this._createCheckResult(integration); + + try { + await this._runChecks(integration, result, { checkCredentials, checkConnectivity }); + this._determineOverallStatus(result); + } catch (error) { + this._handleCheckError(integration, result, error); + } + + return result; + } + + /** + * Create initial check result object + * @private + */ + _createCheckResult(integration) { + return { + integrationId: integration.id, + integrationType: integration.config?.type || 'unknown', + status: 'unknown', + checks: {}, + issues: [] + }; + } + + /** + * Run all requested checks + * @private + */ + async _runChecks(integration, result, options) { + const { checkCredentials, checkConnectivity } = options; + + if (checkCredentials) { + this._addCheckResult(result, 'credentials', this.checkCredentialValidity(integration)); + } + + if (checkConnectivity) { + this._addCheckResult(result, 'connectivity', await this.checkApiConnectivity(integration)); + } + } + + /** + * Add a check result and track any issues + * @private + */ + _addCheckResult(result, checkName, checkResult) { + result.checks[checkName] = checkResult; + if (!checkResult.valid) { + result.issues.push(checkResult.issue); + } + } + + /** + * Determine overall health status from issues + * @private + */ + _determineOverallStatus(result) { + result.status = result.issues.length === 0 ? 'healthy' : 'unhealthy'; + } + + /** + * Handle check error and update result + * @private + */ + _handleCheckError(integration, result, error) { + this.context.log('error', `Error checking integration ${integration.id}`, { + error: error.message + }); + result.status = 'unknown'; + result.issues.push(`Check failed: ${error.message}`); + } + + checkCredentialValidity(integration) { + const result = { valid: true, issue: null }; + + // Check for access token + if (!integration.config?.credentials?.access_token) { + result.valid = false; + result.issue = 'Missing access token'; + return result; + } + + // Check for expiry + const expiresAt = integration.config?.credentials?.expires_at; + if (expiresAt) { + const expiryTime = new Date(expiresAt); + if (expiryTime < new Date()) { + result.valid = false; + result.issue = 'Access token expired'; + return result; + } + } + + return result; + } + + async checkApiConnectivity(integration) { + const result = { valid: true, issue: null, responseTime: null }; + + try { + const startTime = Date.now(); + const instance = await this.context.instantiate(integration.id); + + // Try to make a simple API call + if (instance.primary?.api?.getAuthenticationInfo) { + await instance.primary.api.getAuthenticationInfo(); + } else if (instance.primary?.api?.getCurrentUser) { + await instance.primary.api.getCurrentUser(); + } else { + // No suitable health check method + result.valid = true; + result.issue = null; + result.note = 'No health check endpoint available'; + return result; + } + + result.responseTime = Date.now() - startTime; + } catch (error) { + result.valid = false; + result.issue = `API connectivity failed: ${error.message}`; + } + + return result; + } +} + +module.exports = { IntegrationHealthCheckScript }; diff --git a/packages/admin-scripts/src/builtins/oauth-token-refresh.js b/packages/admin-scripts/src/builtins/oauth-token-refresh.js new file mode 100644 index 000000000..6e895b4a6 --- /dev/null +++ b/packages/admin-scripts/src/builtins/oauth-token-refresh.js @@ -0,0 +1,220 @@ +const { AdminScriptBase } = require('../application/admin-script-base'); + +/** + * OAuth Token Refresh Script + * + * Refreshes OAuth tokens for integrations that are near expiry. + * This helps prevent authentication failures due to expired tokens. + */ +class OAuthTokenRefreshScript extends AdminScriptBase { + static Definition = { + name: 'oauth-token-refresh', + version: '1.0.0', + description: 'Refreshes OAuth tokens for integrations near expiry', + source: 'BUILTIN', + + inputSchema: { + type: 'object', + properties: { + integrationIds: { + type: 'array', + items: { type: 'string' }, + description: 'Specific integration IDs to refresh (optional, defaults to all)' + }, + expiryThresholdHours: { + type: 'number', + default: 24, + description: 'Refresh tokens expiring within this many hours' + }, + dryRun: { + type: 'boolean', + default: false, + description: 'Preview without making changes' + } + } + }, + + outputSchema: { + type: 'object', + properties: { + refreshed: { type: 'number' }, + failed: { type: 'number' }, + skipped: { type: 'number' }, + details: { type: 'array' } + } + }, + + config: { + timeout: 600000, // 10 minutes + maxRetries: 1, + requireIntegrationInstance: true, // Needs to call external APIs + }, + + // UI-specific overrides + display: { + category: 'maintenance', + }, + }; + + async execute(params = {}) { + const { + integrationIds = null, + expiryThresholdHours = 24, + dryRun = false + } = params; + + const results = { + refreshed: 0, + failed: 0, + skipped: 0, + details: [] + }; + + this.context.log('info', 'Starting OAuth token refresh', { + expiryThresholdHours, + dryRun, + specificIds: integrationIds?.length || 'all' + }); + + // Get integrations to check + let integrations; + if (integrationIds && integrationIds.length > 0) { + integrations = await Promise.all( + integrationIds.map(id => this.context.integrationRepository.findIntegrationById(id).catch(() => null)) + ); + integrations = integrations.filter(Boolean); + } else { + // Get all integrations (this would need to be paginated for large deployments) + integrations = await this.getAllIntegrations(); + } + + this.context.log('info', `Found ${integrations.length} integrations to check`); + + for (const integration of integrations) { + try { + const detail = await this.processIntegration(integration, { + expiryThresholdHours, + dryRun + }); + + results.details.push(detail); + + if (detail.action === 'refreshed') { + results.refreshed++; + } else if (detail.action === 'skipped') { + results.skipped++; + } else if (detail.action === 'failed') { + results.failed++; + } + } catch (error) { + this.context.log('error', `Error processing integration ${integration.id}`, { + error: error.message + }); + results.failed++; + results.details.push({ + integrationId: integration.id, + action: 'failed', + reason: error.message + }); + } + } + + this.context.log('info', 'OAuth token refresh completed', { + refreshed: results.refreshed, + failed: results.failed, + skipped: results.skipped + }); + + return results; + } + + async getAllIntegrations() { + // This is a simplified implementation + // In production, would need pagination for large datasets + return this.context.integrationRepository.findIntegrations({}); + } + + async processIntegration(integration, options) { + const { expiryThresholdHours, dryRun } = options; + + // Check prerequisites + const skipReason = this._checkRefreshPrerequisites(integration, expiryThresholdHours); + if (skipReason) { + return this._createResult(integration.id, 'skipped', skipReason); + } + + // Handle dry run + if (dryRun) { + this.context.log('info', `[DRY RUN] Would refresh token for ${integration.id}`); + return this._createResult(integration.id, 'skipped', 'Dry run - would have refreshed'); + } + + // Perform refresh + return this._performTokenRefresh(integration); + } + + /** + * Check if integration meets prerequisites for token refresh + * @private + * @returns {string|null} Skip reason or null if eligible + */ + _checkRefreshPrerequisites(integration, expiryThresholdHours) { + if (!integration.config?.credentials?.access_token) { + return 'No OAuth credentials found'; + } + + const expiresAt = integration.config?.credentials?.expires_at; + if (!expiresAt) { + return 'No expiry time found'; + } + + const expiryTime = new Date(expiresAt); + const thresholdTime = new Date(Date.now() + (expiryThresholdHours * 60 * 60 * 1000)); + + if (expiryTime > thresholdTime) { + return 'Token not near expiry'; + } + + return null; + } + + /** + * Perform the actual token refresh + * @private + */ + async _performTokenRefresh(integration) { + const expiresAt = integration.config?.credentials?.expires_at; + + try { + const instance = await this.context.instantiate(integration.id); + + if (!instance.primary?.api?.refreshAccessToken) { + return this._createResult(integration.id, 'skipped', 'API does not support token refresh'); + } + + await instance.primary.api.refreshAccessToken(); + this.context.log('info', `Refreshed token for integration ${integration.id}`); + + return { + integrationId: integration.id, + action: 'refreshed', + previousExpiry: expiresAt + }; + } catch (error) { + this.context.log('error', `Failed to refresh token for ${integration.id}`, { + error: error.message + }); + return this._createResult(integration.id, 'failed', error.message); + } + } + + /** + * Create a result object + * @private + */ + _createResult(integrationId, action, reason) { + return { integrationId, action, reason }; + } +} + +module.exports = { OAuthTokenRefreshScript }; diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js new file mode 100644 index 000000000..ed551332f --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-auth-middleware.test.js @@ -0,0 +1,85 @@ +const { validateAdminApiKey } = require('../admin-auth-middleware'); + +describe('validateAdminApiKey', () => { + let mockReq; + let mockRes; + let mockNext; + let originalEnv; + + beforeEach(() => { + originalEnv = process.env.ADMIN_API_KEY; + process.env.ADMIN_API_KEY = 'test-admin-key-123'; + + mockReq = { + headers: {}, + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockNext = jest.fn(); + }); + + afterEach(() => { + if (originalEnv) { + process.env.ADMIN_API_KEY = originalEnv; + } else { + delete process.env.ADMIN_API_KEY; + } + jest.clearAllMocks(); + }); + + describe('Environment configuration', () => { + it('should reject when ADMIN_API_KEY not configured', () => { + delete process.env.ADMIN_API_KEY; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Admin API key not configured', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('Header validation', () => { + it('should reject request without x-frigg-admin-api-key header', () => { + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'x-frigg-admin-api-key header required', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('API key validation', () => { + it('should reject request with invalid API key', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'invalid-key'; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'Unauthorized', + message: 'Invalid admin API key', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should accept request with valid API key', () => { + mockReq.headers['x-frigg-admin-api-key'] = 'test-admin-key-123'; + + validateAdminApiKey(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockRes.status).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js new file mode 100644 index 000000000..bb253b297 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/__tests__/admin-script-router.test.js @@ -0,0 +1,718 @@ +const request = require('supertest'); +const { app } = require('../admin-script-router'); +const { AdminScriptBase } = require('../../application/admin-script-base'); + +// Mock dependencies +jest.mock('../admin-auth-middleware', () => ({ + validateAdminApiKey: (req, res, next) => { + // Mock auth - no audit trail with simplified auth + next(); + }, +})); + +jest.mock('../../application/script-factory'); +jest.mock('../../application/script-runner'); +jest.mock('@friggframework/core/application/commands/admin-script-commands'); +jest.mock('@friggframework/core/queues'); +jest.mock('../../adapters/scheduler-adapter-factory'); + +const { getScriptFactory } = require('../../application/script-factory'); +const { createScriptRunner } = require('../../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); +const { QueuerUtil } = require('@friggframework/core/queues'); +const { createSchedulerAdapter } = require('../../adapters/scheduler-adapter-factory'); + +describe('Admin Script Router', () => { + let mockFactory; + let mockRunner; + let mockCommands; + let mockSchedulerAdapter; + + class TestScript extends AdminScriptBase { + static Definition = { + name: 'test-script', + version: '1.0.0', + description: 'Test script', + config: { timeout: 300000 }, + display: { category: 'test' }, + }; + + async execute(frigg, params) { + return { success: true, params }; + } + } + + beforeEach(() => { + mockFactory = { + getAll: jest.fn(), + has: jest.fn(), + get: jest.fn(), + }; + + mockRunner = { + execute: jest.fn(), + }; + + mockCommands = { + createAdminProcess: jest.fn(), + findAdminProcessById: jest.fn(), + findRecentExecutions: jest.fn(), + }; + + mockSchedulerAdapter = { + createSchedule: jest.fn(), + deleteSchedule: jest.fn(), + setScheduleEnabled: jest.fn(), + }; + + getScriptFactory.mockReturnValue(mockFactory); + createScriptRunner.mockReturnValue(mockRunner); + createAdminScriptCommands.mockReturnValue(mockCommands); + createSchedulerAdapter.mockReturnValue(mockSchedulerAdapter); + QueuerUtil.send = jest.fn().mockResolvedValue({}); + + // Default mock implementations + mockFactory.getAll.mockReturnValue([ + { + name: 'test-script', + definition: TestScript.Definition, + class: TestScript, + }, + ]); + + mockFactory.has.mockReturnValue(true); + mockFactory.get.mockReturnValue(TestScript); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /admin/scripts', () => { + it('should list all registered scripts', async () => { + const response = await request(app).get('/admin/scripts'); + + expect(response.status).toBe(200); + expect(response.body.scripts).toHaveLength(1); + expect(response.body.scripts[0]).toEqual({ + name: 'test-script', + version: '1.0.0', + description: 'Test script', + category: 'test', + requireIntegrationInstance: false, + schedule: null, + }); + }); + + it('should handle errors gracefully', async () => { + mockFactory.getAll.mockImplementation(() => { + throw new Error('Factory error'); + }); + + const response = await request(app).get('/admin/scripts'); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Failed to list scripts'); + }); + }); + + describe('GET /admin/scripts/:scriptName', () => { + it('should return script details', async () => { + const response = await request(app).get('/admin/scripts/test-script'); + + expect(response.status).toBe(200); + expect(response.body.name).toBe('test-script'); + expect(response.body.version).toBe('1.0.0'); + expect(response.body.description).toBe('Test script'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).get( + '/admin/scripts/non-existent-script' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('POST /admin/scripts/:scriptName', () => { + it('should execute script synchronously', async () => { + mockRunner.execute.mockResolvedValue({ + executionId: 'exec-123', + status: 'COMPLETED', + scriptName: 'test-script', + output: { success: true }, + metrics: { durationMs: 100 }, + }); + + const response = await request(app) + .post('/admin/scripts/test-script') + .send({ + params: { foo: 'bar' }, + mode: 'sync', + }); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('COMPLETED'); + expect(response.body.executionId).toBe('exec-123'); + expect(mockRunner.execute).toHaveBeenCalledWith( + 'test-script', + { foo: 'bar' }, + expect.objectContaining({ + trigger: 'MANUAL', + mode: 'sync', + }) + ); + }); + + it('should queue script for async execution', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; + mockCommands.createAdminProcess.mockResolvedValue({ + id: 'exec-456', + }); + + const response = await request(app) + .post('/admin/scripts/test-script') + .send({ + params: { foo: 'bar' }, + mode: 'async', + }); + + expect(response.status).toBe(202); + expect(response.body.status).toBe('QUEUED'); + expect(response.body.executionId).toBe('exec-456'); + expect(QueuerUtil.send).toHaveBeenCalledWith( + expect.objectContaining({ + scriptName: 'test-script', + executionId: 'exec-456', + }), + 'https://sqs.us-east-1.amazonaws.com/123/test-queue' + ); + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + }); + + it('should default to async mode', async () => { + process.env.ADMIN_SCRIPT_QUEUE_URL = 'https://sqs.us-east-1.amazonaws.com/123/test-queue'; + mockCommands.createAdminProcess.mockResolvedValue({ + id: 'exec-789', + }); + + const response = await request(app) + .post('/admin/scripts/test-script') + .send({ + params: { foo: 'bar' }, + }); + + expect(response.status).toBe(202); + expect(response.body.status).toBe('QUEUED'); + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + }); + + it('should return 503 when ADMIN_SCRIPT_QUEUE_URL is not set', async () => { + delete process.env.ADMIN_SCRIPT_QUEUE_URL; + + const response = await request(app) + .post('/admin/scripts/test-script') + .send({ + params: { foo: 'bar' }, + mode: 'async', + }); + + expect(response.status).toBe(503); + expect(response.body.code).toBe('QUEUE_NOT_CONFIGURED'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app) + .post('/admin/scripts/non-existent') + .send({ + params: {}, + }); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('GET /admin/scripts/:scriptName/executions/:executionId', () => { + it('should return execution details', async () => { + mockCommands.findAdminProcessById.mockResolvedValue({ + id: 'exec-123', + scriptName: 'test-script', + status: 'COMPLETED', + }); + + const response = await request(app).get('/admin/scripts/test-script/executions/exec-123'); + + expect(response.status).toBe(200); + expect(response.body.id).toBe('exec-123'); + expect(response.body.scriptName).toBe('test-script'); + }); + + it('should return 404 for non-existent execution', async () => { + mockCommands.findAdminProcessById.mockResolvedValue({ + error: 404, + reason: 'Execution not found', + code: 'EXECUTION_NOT_FOUND', + }); + + const response = await request(app).get( + '/admin/scripts/test-script/executions/non-existent' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('EXECUTION_NOT_FOUND'); + }); + }); + + describe('GET /admin/scripts/:scriptName/executions', () => { + it('should list executions for specific script', async () => { + mockCommands.findRecentExecutions.mockResolvedValue([ + { id: 'exec-1', scriptName: 'test-script', status: 'COMPLETED' }, + { id: 'exec-2', scriptName: 'test-script', status: 'RUNNING' }, + ]); + + const response = await request(app).get('/admin/scripts/test-script/executions'); + + expect(response.status).toBe(200); + expect(response.body.executions).toHaveLength(2); + expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({ + scriptName: 'test-script', + limit: 50, + }); + }); + + it('should accept query parameters', async () => { + mockCommands.findRecentExecutions.mockResolvedValue([]); + + await request(app).get( + '/admin/scripts/test-script/executions?status=COMPLETED&limit=10' + ); + + expect(mockCommands.findRecentExecutions).toHaveBeenCalledWith({ + scriptName: 'test-script', + status: 'COMPLETED', + limit: 10, + }); + }); + }); + + describe('GET /admin/scripts/:scriptName/schedule', () => { + it('should return database schedule when override exists', async () => { + const dbSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 9 * * *', + timezone: 'America/New_York', + lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), + nextTriggerAt: new Date('2025-01-02T09:00:00Z'), + externalScheduleId: 'arn:aws:events:us-east-1:123456789012:rule/test', + externalScheduleName: 'test-script-schedule', + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date('2025-01-01T00:00:00Z'), + }; + + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(dbSchedule); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('database'); + expect(response.body.enabled).toBe(true); + expect(response.body.cronExpression).toBe('0 9 * * *'); + expect(response.body.timezone).toBe('America/New_York'); + }); + + it('should return definition schedule when no database override', async () => { + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null); + + // Update test script to include schedule + class ScheduledTestScript extends TestScript { + static Definition = { + ...TestScript.Definition, + schedule: { + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'UTC', + }, + }; + } + + mockFactory.get.mockReturnValue(ScheduledTestScript); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('definition'); + expect(response.body.enabled).toBe(true); + expect(response.body.cronExpression).toBe('0 0 * * *'); + expect(response.body.timezone).toBe('UTC'); + }); + + it('should return none when no schedule configured', async () => { + mockCommands.getScheduleByScriptName = jest.fn().mockResolvedValue(null); + + const response = await request(app).get('/admin/scripts/test-script/schedule'); + + expect(response.status).toBe(200); + expect(response.body.source).toBe('none'); + expect(response.body.enabled).toBe(false); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).get( + '/admin/scripts/non-existent/schedule' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + }); + + describe('PUT /admin/scripts/:scriptName/schedule', () => { + it('should create new schedule', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + lastTriggeredAt: null, + nextTriggerAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedule.source).toBe('database'); + expect(response.body.schedule.enabled).toBe(true); + expect(response.body.schedule.cronExpression).toBe('0 12 * * *'); + expect(mockCommands.upsertSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + }); + + it('should update existing schedule', async () => { + const updatedSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + lastTriggeredAt: new Date('2025-01-01T09:00:00Z'), + nextTriggerAt: null, + createdAt: new Date('2025-01-01T00:00:00Z'), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(updatedSchedule); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: false, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedule.enabled).toBe(false); + }); + + it('should require enabled field', async () => { + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + cronExpression: '0 12 * * *', + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_INPUT'); + expect(response.body.error).toContain('enabled'); + }); + + it('should require cronExpression when enabled is true', async () => { + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_INPUT'); + expect(response.body.error).toContain('cronExpression'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app) + .put('/admin/scripts/non-existent/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + }); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + + it('should provision EventBridge schedule when enabled', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + lastTriggeredAt: null, + nextTriggerAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + mockCommands.updateScheduleExternalInfo = jest.fn().mockResolvedValue(newSchedule); + mockSchedulerAdapter.createSchedule.mockResolvedValue({ + scheduleArn: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + scheduleName: 'frigg-script-test-script', + }); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.createSchedule).toHaveBeenCalledWith({ + scriptName: 'test-script', + cronExpression: '0 12 * * *', + timezone: 'America/Los_Angeles', + }); + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalledWith('test-script', { + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', + }); + expect(response.body.schedule.externalScheduleId).toBe('arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script'); + }); + + it('should delete EventBridge schedule when disabling existing schedule', async () => { + const existingSchedule = { + scriptName: 'test-script', + enabled: false, + cronExpression: null, + timezone: 'UTC', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(existingSchedule); + mockCommands.updateScheduleExternalInfo = jest.fn().mockResolvedValue(existingSchedule); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: false, + }); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + expect(mockCommands.updateScheduleExternalInfo).toHaveBeenCalledWith('test-script', { + externalScheduleId: null, + externalScheduleName: null, + }); + }); + + it('should handle scheduler errors gracefully (non-fatal)', async () => { + const newSchedule = { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + timezone: 'UTC', + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCommands.upsertSchedule = jest.fn().mockResolvedValue(newSchedule); + mockSchedulerAdapter.createSchedule.mockRejectedValue(new Error('AWS Scheduler API error')); + + const response = await request(app) + .put('/admin/scripts/test-script/schedule') + .send({ + enabled: true, + cronExpression: '0 12 * * *', + }); + + // Request should succeed despite scheduler error + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedulerWarning).toBe('AWS Scheduler API error'); + }); + }); + + describe('DELETE /admin/scripts/:scriptName/schedule', () => { + it('should delete schedule override', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + }, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.deletedCount).toBe(1); + expect(response.body.message).toContain('removed'); + expect(mockCommands.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should return definition schedule after deleting override', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + }); + + // Update test script to include schedule + class ScheduledTestScript extends TestScript { + static Definition = { + ...TestScript.Definition, + schedule: { + enabled: true, + cronExpression: '0 0 * * *', + timezone: 'UTC', + }, + }; + } + + mockFactory.get.mockReturnValue(ScheduledTestScript); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.effectiveSchedule.source).toBe('definition'); + expect(response.body.effectiveSchedule.enabled).toBe(true); + }); + + it('should handle no schedule found', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 0, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(response.body.deletedCount).toBe(0); + expect(response.body.message).toContain('No schedule override found'); + }); + + it('should return 404 for non-existent script', async () => { + mockFactory.has.mockReturnValue(false); + + const response = await request(app).delete( + '/admin/scripts/non-existent/schedule' + ); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('SCRIPT_NOT_FOUND'); + }); + + it('should delete EventBridge schedule when external rule exists', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + externalScheduleName: 'frigg-script-test-script', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockResolvedValue(); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).toHaveBeenCalledWith('test-script'); + }); + + it('should not call scheduler when no external rule exists', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + // No externalScheduleId + }, + }); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + expect(response.status).toBe(200); + expect(mockSchedulerAdapter.deleteSchedule).not.toHaveBeenCalled(); + }); + + it('should handle scheduler delete errors gracefully (non-fatal)', async () => { + mockCommands.deleteSchedule = jest.fn().mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + deleted: { + scriptName: 'test-script', + enabled: true, + cronExpression: '0 12 * * *', + externalScheduleId: 'arn:aws:scheduler:us-east-1:123456789012:schedule/frigg-admin-scripts/frigg-script-test-script', + }, + }); + mockSchedulerAdapter.deleteSchedule.mockRejectedValue(new Error('Scheduler delete failed')); + + const response = await request(app).delete( + '/admin/scripts/test-script/schedule' + ); + + // Request should succeed despite scheduler error + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.schedulerWarning).toBe('Scheduler delete failed'); + }); + }); +}); diff --git a/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js new file mode 100644 index 000000000..e3a1501a3 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/admin-auth-middleware.js @@ -0,0 +1,11 @@ +/** + * Admin API Key Authentication Middleware + * + * Re-exports shared admin auth middleware from @friggframework/core. + * Uses simple ENV-based API key validation. + * Expects: x-frigg-admin-api-key header + */ + +const { validateAdminApiKey } = require('@friggframework/core/handlers/middleware/admin-auth'); + +module.exports = { validateAdminApiKey }; diff --git a/packages/admin-scripts/src/infrastructure/admin-script-router.js b/packages/admin-scripts/src/infrastructure/admin-script-router.js new file mode 100644 index 000000000..8c4215049 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/admin-script-router.js @@ -0,0 +1,344 @@ +const express = require('express'); +const serverless = require('serverless-http'); +const { validateAdminApiKey } = require('./admin-auth-middleware'); +const { getScriptFactory } = require('../application/script-factory'); +const { createScriptRunner } = require('../application/script-runner'); +const { validateScriptInput } = require('../application/validate-script-input'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); +const { QueuerUtil } = require('@friggframework/core/queues'); +const { createSchedulerAdapter } = require('../adapters/scheduler-adapter-factory'); +const { + GetEffectiveScheduleUseCase, + UpsertScheduleUseCase, + DeleteScheduleUseCase, +} = require('../application/use-cases'); + +const router = express.Router(); + +// Apply auth middleware to all admin routes +router.use(validateAdminApiKey); + +/** + * Create schedule use case instances + * @private + */ +function createScheduleUseCases() { + const commands = createAdminScriptCommands(); + const schedulerAdapter = createSchedulerAdapter({ + type: process.env.SCHEDULER_PROVIDER || 'local', + targetLambdaArn: process.env.ADMIN_SCRIPT_EXECUTOR_LAMBDA_ARN, + scheduleGroupName: process.env.ADMIN_SCRIPT_SCHEDULE_GROUP, + roleArn: process.env.SCHEDULER_ROLE_ARN, + }); + const scriptFactory = getScriptFactory(); + + return { + getEffectiveSchedule: new GetEffectiveScheduleUseCase({ commands, scriptFactory }), + upsertSchedule: new UpsertScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + deleteSchedule: new DeleteScheduleUseCase({ commands, schedulerAdapter, scriptFactory }), + }; +} + +/** + * GET /admin/scripts + * List all registered scripts + */ +router.get('/scripts', async (req, res) => { + try { + const factory = getScriptFactory(); + const scripts = factory.getAll(); + + res.json({ + scripts: scripts.map((s) => ({ + name: s.name, + version: s.definition.version, + description: s.definition.description, + category: s.definition.display?.category || 'custom', + requireIntegrationInstance: + s.definition.config?.requireIntegrationInstance || false, + schedule: s.definition.schedule || null, + })), + }); + } catch (error) { + console.error('Error listing scripts:', error); + res.status(500).json({ error: 'Failed to list scripts' }); + } +}); + +/** + * GET /admin/scripts/:scriptName + * Get script details + */ +router.get('/scripts/:scriptName', async (req, res) => { + try { + const { scriptName } = req.params; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + const scriptClass = factory.get(scriptName); + const definition = scriptClass.Definition; + + res.json({ + name: definition.name, + version: definition.version, + description: definition.description, + inputSchema: definition.inputSchema, + outputSchema: definition.outputSchema, + config: definition.config, + display: definition.display, + schedule: definition.schedule, + }); + } catch (error) { + console.error('Error getting script:', error); + res.status(500).json({ error: 'Failed to get script details' }); + } +}); + +/** + * POST /admin/scripts/:scriptName/validate + * Validate script inputs without executing (dry-run) + */ +router.post('/scripts/:scriptName/validate', async (req, res) => { + try { + const { scriptName } = req.params; + const { params = {} } = req.body; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + const result = validateScriptInput(factory, scriptName, params); + res.json(result); + } catch (error) { + console.error('Error validating script:', error); + res.status(500).json({ error: 'Failed to validate script' }); + } +}); + +/** + * POST /admin/scripts/:scriptName + * Execute a script (sync or async) + */ +router.post('/scripts/:scriptName', async (req, res) => { + try { + const { scriptName } = req.params; + const { params = {}, mode = 'async' } = req.body; + const factory = getScriptFactory(); + + if (!factory.has(scriptName)) { + return res.status(404).json({ + error: `Script "${scriptName}" not found`, + code: 'SCRIPT_NOT_FOUND', + }); + } + + if (mode === 'sync') { + const runner = createScriptRunner(); + const result = await runner.execute(scriptName, params, { + trigger: 'MANUAL', + mode: 'sync', + }); + return res.json(result); + } + + // Async execution - queue and return immediately + const queueUrl = process.env.ADMIN_SCRIPT_QUEUE_URL; + if (!queueUrl) { + return res.status(503).json({ + error: 'Async execution is not configured (ADMIN_SCRIPT_QUEUE_URL not set)', + code: 'QUEUE_NOT_CONFIGURED', + }); + } + + const commands = createAdminScriptCommands(); + const execution = await commands.createAdminProcess({ + scriptName, + scriptVersion: factory.get(scriptName).Definition.version, + trigger: 'MANUAL', + mode: 'async', + input: params, + }); + + // Queue the execution + await QueuerUtil.send( + { + scriptName, + executionId: execution.id, + trigger: 'MANUAL', + params, + }, + queueUrl + ); + + res.status(202).json({ + executionId: execution.id, + status: 'QUEUED', + scriptName, + message: 'Script queued for execution', + }); + } catch (error) { + console.error('Error executing script:', error); + res.status(500).json({ error: 'Failed to execute script' }); + } +}); + +/** + * GET /admin/scripts/:scriptName/executions/:executionId + * Get execution status for specific script + */ +router.get('/scripts/:scriptName/executions/:executionId', async (req, res) => { + try { + const { executionId } = req.params; + const commands = createAdminScriptCommands(); + const execution = await commands.findAdminProcessById(executionId); + + if (execution.error) { + return res.status(execution.error).json({ + error: execution.reason, + code: execution.code, + }); + } + + res.json(execution); + } catch (error) { + console.error('Error getting execution:', error); + res.status(500).json({ error: 'Failed to get execution' }); + } +}); + +/** + * GET /admin/scripts/:scriptName/executions + * List recent executions for specific script + */ +router.get('/scripts/:scriptName/executions', async (req, res) => { + try { + const { scriptName } = req.params; + const { status, limit = 50 } = req.query; + const commands = createAdminScriptCommands(); + + const executions = await commands.findRecentExecutions({ + scriptName, + status, + limit: Number.parseInt(limit, 10), + }); + + res.json({ executions }); + } catch (error) { + console.error('Error listing executions:', error); + res.status(500).json({ error: 'Failed to list executions' }); + } +}); + +/** + * GET /admin/scripts/:scriptName/schedule + * Get effective schedule (DB override > Definition default > none) + */ +router.get('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const { getEffectiveSchedule } = createScheduleUseCases(); + + const result = await getEffectiveSchedule.execute(scriptName); + + res.json({ + source: result.source, + scriptName, + ...result.schedule, + }); + } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { + return res.status(404).json({ + error: error.message, + code: error.code, + }); + } + console.error('Error getting schedule:', error); + res.status(500).json({ error: 'Failed to get schedule' }); + } +}); + +/** + * PUT /admin/scripts/:scriptName/schedule + * Create or update schedule override + */ +router.put('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const { enabled, cronExpression, timezone } = req.body; + const { upsertSchedule } = createScheduleUseCases(); + + const result = await upsertSchedule.execute(scriptName, { + enabled, + cronExpression, + timezone, + }); + + res.json({ + success: result.success, + schedule: { + source: 'database', + ...result.schedule, + }, + ...(result.schedulerWarning && { schedulerWarning: result.schedulerWarning }), + }); + } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { + return res.status(404).json({ + error: error.message, + code: error.code, + }); + } + if (error.code === 'INVALID_INPUT') { + return res.status(400).json({ + error: error.message, + code: error.code, + }); + } + console.error('Error updating schedule:', error); + res.status(500).json({ error: 'Failed to update schedule' }); + } +}); + +/** + * DELETE /admin/scripts/:scriptName/schedule + * Remove schedule override (revert to Definition default) + */ +router.delete('/scripts/:scriptName/schedule', async (req, res) => { + try { + const { scriptName } = req.params; + const { deleteSchedule } = createScheduleUseCases(); + + const result = await deleteSchedule.execute(scriptName); + + res.json(result); + } catch (error) { + if (error.code === 'SCRIPT_NOT_FOUND') { + return res.status(404).json({ + error: error.message, + code: error.code, + }); + } + console.error('Error deleting schedule:', error); + res.status(500).json({ error: 'Failed to delete schedule' }); + } +}); + +// Create Express app +const app = express(); +app.use(express.json()); +app.use('/admin', router); + +// Export for Lambda +const handler = serverless(app); + +module.exports = { router, app, handler }; diff --git a/packages/admin-scripts/src/infrastructure/script-executor-handler.js b/packages/admin-scripts/src/infrastructure/script-executor-handler.js new file mode 100644 index 000000000..8caf79369 --- /dev/null +++ b/packages/admin-scripts/src/infrastructure/script-executor-handler.js @@ -0,0 +1,79 @@ +const { createScriptRunner } = require('../application/script-runner'); +const { createAdminScriptCommands } = require('@friggframework/core/application/commands/admin-script-commands'); + +/** + * SQS Queue Worker Lambda Handler + * + * Processes script execution messages from AdminScriptQueue. + * Thin adapter: parses SQS messages and delegates to ScriptRunner. + * ScriptRunner handles execution tracking, error recording, and status updates. + */ +async function handler(event) { + const results = []; + + for (const record of event.Records) { + let scriptName; + let executionId; + + try { + const message = JSON.parse(record.body); + ({ scriptName, executionId } = message); + const { trigger, params } = message; + + if (!scriptName || !executionId) { + throw new Error(`Invalid SQS message: missing scriptName or executionId`); + } + + console.log(`Processing script: ${scriptName}, executionId: ${executionId}`); + + const runner = createScriptRunner(); + const result = await runner.execute(scriptName, params, { + trigger: trigger || 'QUEUE', + mode: 'async', + executionId, + }); + + console.log(`Script completed: ${scriptName}, status: ${result.status}`); + results.push({ + scriptName, + status: result.status, + executionId: result.executionId, + }); + } catch (error) { + // Only reaches here for unexpected failures (message parse errors, runner construction). + // Script execution errors are handled by ScriptRunner and returned as { status: 'FAILED' }. + console.error(`Unexpected error processing record:`, error); + + // If we have an executionId, mark the admin process as FAILED + // so the record doesn't stay stuck in a non-terminal state. + if (executionId) { + try { + const commands = createAdminScriptCommands(); + await commands.completeAdminProcess(executionId, { + state: 'FAILED', + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + } catch (updateError) { + console.error(`Failed to update execution ${executionId} state:`, updateError); + } + } + + results.push({ + scriptName: scriptName || 'unknown', + status: 'FAILED', + error: error.message, + }); + } + } + + return { + statusCode: 200, + body: JSON.stringify({ processed: results.length, results }), + }; +} + +module.exports = { handler };