diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js new file mode 100644 index 00000000..90cb4d8f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.js @@ -0,0 +1,18 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const step = await request.currentUser.authorizedSteps + .clone() + .where('steps.id', request.params.stepId) + .whereNotNull('steps.app_key') + .whereNotNull('steps.connection_id') + .first() + .throwIfNotFound(); + + const dynamicData = await step.createDynamicData( + request.body.dynamicDataKey, + request.body.parameters + ); + + renderObject(response, dynamicData); +}; diff --git a/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js new file mode 100644 index 00000000..699eb23b --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/create-dynamic-data.test.js @@ -0,0 +1,244 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; +import { createUser } from '../../../../../test/factories/user'; +import { createConnection } from '../../../../../test/factories/connection'; +import { createFlow } from '../../../../../test/factories/flow'; +import { createStep } from '../../../../../test/factories/step'; +import { createPermission } from '../../../../../test/factories/permission'; +import listRepos from '../../../../apps/github/dynamic-data/list-repos/index.js'; +import HttpError from '../../../../errors/http.js'; + +describe('POST /api/v1/steps/:stepId/dynamic-data', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = createAuthTokenByUserId(currentUser.id); + }); + + describe('should return dynamically created data', () => { + let repositories; + + beforeEach(async () => { + repositories = [ + { + value: 'automatisch/automatisch', + name: 'automatisch/automatisch', + }, + { + value: 'automatisch/sample', + name: 'automatisch/sample', + }, + ]; + + vi.spyOn(listRepos, 'run').mockImplementation(async () => { + return { + data: repositories, + }; + }); + }); + + it('of the current users step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const connection = await createConnection({ userId: currentUser.id }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(200); + + expect(response.body.data).toEqual(repositories); + }); + + it('of the another users step', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const connection = await createConnection({ userId: anotherUser.id }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(200); + + expect(response.body.data).toEqual(repositories); + }); + }); + + describe('should return error for dynamically created data', () => { + let errors; + + beforeEach(async () => { + errors = { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/rest/users/users#get-a-user', + }; + + vi.spyOn(listRepos, 'run').mockImplementation(async () => { + throw new HttpError({ message: errors }); + }); + }); + + it('of the current users step', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const connection = await createConnection({ userId: currentUser.id }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: connection.id, + type: 'action', + appKey: 'github', + key: 'createIssue', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/steps/${actionStep.id}/dynamic-data`) + .set('Authorization', token) + .send({ + dynamicDataKey: 'listRepos', + parameters: {}, + }) + .expect(200); + + expect(response.body.errors).toEqual(errors); + }); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/steps/${notExistingStepUUID}/dynamic-data`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for existing step UUID without app key', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const step = await createStep({ appKey: null }); + + await request(app) + .get(`/api/v1/steps/${step.id}/dynamic-data`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .post('/api/v1/steps/invalidStepUUID/dynamic-fields') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index d4376b35..ee393014 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -27,6 +27,10 @@ const authorizationList = { action: 'update', subject: 'Flow', }, + 'POST /api/v1/steps/:stepId/dynamic-data': { + action: 'update', + subject: 'Flow', + }, 'GET /api/v1/connections/:connectionId/flows': { action: 'read', subject: 'Flow', diff --git a/packages/backend/src/helpers/error-handler.js b/packages/backend/src/helpers/error-handler.js index 1520abaf..14a120dc 100644 --- a/packages/backend/src/helpers/error-handler.js +++ b/packages/backend/src/helpers/error-handler.js @@ -2,6 +2,7 @@ import logger from './logger.js'; import objection from 'objection'; import * as Sentry from './sentry.ee.js'; const { NotFoundError, DataError } = objection; +import HttpError from '../errors/http.js'; // Do not remove `next` argument as the function signature will not fit for an error handler middleware // eslint-disable-next-line no-unused-vars @@ -18,6 +19,17 @@ const errorHandler = (error, request, response, next) => { response.status(400).end(); } + if (error instanceof HttpError) { + const httpErrorPayload = { + errors: JSON.parse(error.message), + meta: { + type: 'HttpError', + }, + }; + + response.status(200).json(httpErrorPayload); + } + const statusCode = error.statusCode || 500; logger.error(request.method + ' ' + request.url + ' ' + statusCode); diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js index 9304afa5..59ddb835 100644 --- a/packages/backend/src/models/step.js +++ b/packages/backend/src/models/step.js @@ -8,6 +8,7 @@ import ExecutionStep from './execution-step.js'; import Telemetry from '../helpers/telemetry/index.js'; import appConfig from '../config/app.js'; import globalVariable from '../helpers/global-variable.js'; +import computeParameters from '../helpers/compute-parameters.js'; class Step extends Base { static tableName = 'steps'; @@ -217,6 +218,39 @@ class Step extends Base { return dynamicFields; } + async createDynamicData(dynamicDataKey, parameters) { + const connection = await this.$relatedQuery('connection'); + const flow = await this.$relatedQuery('flow'); + const app = await this.getApp(); + const $ = await globalVariable({ connection, app, flow, step: this }); + + const command = app.dynamicData.find((data) => data.key === dynamicDataKey); + + for (const parameterKey in parameters) { + const parameterValue = parameters[parameterKey]; + $.step.parameters[parameterKey] = parameterValue; + } + + const lastExecution = await flow.$relatedQuery('lastExecution'); + const lastExecutionId = lastExecution?.id; + + const priorExecutionSteps = lastExecutionId + ? await ExecutionStep.query().where({ + execution_id: lastExecutionId, + }) + : []; + + const computedParameters = computeParameters( + $.step.parameters, + priorExecutionSteps + ); + + $.step.parameters = computedParameters; + const dynamicData = (await command.run($)).data; + + return dynamicData; + } + async updateWebhookUrl() { if (this.isAction) return this; diff --git a/packages/backend/src/routes/api/v1/steps.js b/packages/backend/src/routes/api/v1/steps.js index 674b9da0..e6594483 100644 --- a/packages/backend/src/routes/api/v1/steps.js +++ b/packages/backend/src/routes/api/v1/steps.js @@ -5,6 +5,7 @@ import { authorizeUser } from '../../../helpers/authorization.js'; import getConnectionAction from '../../../controllers/api/v1/steps/get-connection.js'; import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previous-steps.js'; import createDynamicFieldsAction from '../../../controllers/api/v1/steps/create-dynamic-fields.js'; +import createDynamicDataAction from '../../../controllers/api/v1/steps/create-dynamic-data.js'; const router = Router(); @@ -29,4 +30,11 @@ router.post( asyncHandler(createDynamicFieldsAction) ); +router.post( + '/:stepId/dynamic-data', + authenticateUser, + authorizeUser, + asyncHandler(createDynamicDataAction) +); + export default router; diff --git a/packages/backend/test/factories/step.js b/packages/backend/test/factories/step.js index 52a878dd..15573ae2 100644 --- a/packages/backend/test/factories/step.js +++ b/packages/backend/test/factories/step.js @@ -19,6 +19,8 @@ export const createStep = async (params = {}) => { params.appKey = params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook'); + params.parameters = params?.parameters || {}; + const step = await Step.query().insertAndFetch(params); return step; diff --git a/packages/backend/test/setup/global-hooks.js b/packages/backend/test/setup/global-hooks.js index e20adce2..d6f8a562 100644 --- a/packages/backend/test/setup/global-hooks.js +++ b/packages/backend/test/setup/global-hooks.js @@ -1,6 +1,7 @@ import { Model } from 'objection'; import { client as knex } from '../../src/config/database.js'; import logger from '../../src/helpers/logger.js'; +import { vi } from 'vitest'; global.beforeAll(async () => { global.knex = null; @@ -22,8 +23,8 @@ global.afterEach(async () => { await global.knex.rollback(); Model.knex(knex); - // jest.restoreAllMocks(); - // jest.clearAllMocks(); + vi.restoreAllMocks(); + vi.clearAllMocks(); }); global.afterAll(async () => {