diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.js b/packages/backend/src/controllers/api/v1/steps/update-step.js new file mode 100644 index 00000000..5d8aa899 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/update-step.js @@ -0,0 +1,33 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + let step = await request.currentUser.authorizedSteps + .findById(request.params.stepId) + .throwIfNotFound(); + + const stepData = stepParams(request); + + if (stepData.connectionId && (stepData.appKey || step.appKey)) { + await request.currentUser.authorizedConnections + .findOne({ + id: stepData.connectionId, + key: stepData.appKey || step.appKey, + }) + .throwIfNotFound(); + } + + step = await step.update(stepData); + + renderObject(response, step); +}; + +const stepParams = (request) => { + const { connectionId, appKey, key, parameters } = request.body; + + return { + connectionId, + appKey, + key, + parameters, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.test.js b/packages/backend/src/controllers/api/v1/steps/update-step.test.js new file mode 100644 index 00000000..1a5ae1b9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/steps/update-step.test.js @@ -0,0 +1,211 @@ +import { describe, it, beforeEach, expect } 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.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createConnection } from '../../../../../test/factories/connection.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import updateStepMock from '../../../../../test/mocks/rest/api/v1/steps/update-step.js'; + +describe('PATCH /api/v1/steps/:stepId', () => { + let currentUser, token; + + beforeEach(async () => { + currentUser = await createUser(); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should update the step of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + const currentUserConnection = await createConnection({ + key: 'deepl', + }); + + await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + connectionId: currentUserConnection.id, + appKey: 'deepl', + key: 'translateText', + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + const response = await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + parameters: { + text: 'Hello world!', + targetLanguage: 'de', + }, + }) + .expect(200); + + const refetchedStep = await actionStep.$query(); + + const expectedResponse = updateStepMock(refetchedStep); + + expect(response.body).toStrictEqual(expectedResponse); + }); + + it('should update the step of the another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + const anotherUserConnection = await createConnection({ + key: 'deepl', + }); + + await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + connectionId: anotherUserConnection.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + const response = await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + parameters: { + text: 'Hello world!', + targetLanguage: 'de', + }, + }) + .expect(200); + + const refetchedStep = await actionStep.$query(); + + const expectedResponse = updateStepMock(refetchedStep); + + expect(response.body).toStrictEqual(expectedResponse); + }); + + it('should return not found response for inaccessible connection', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const anotherUser = await createUser(); + const anotherUserConnection = await createConnection({ + key: 'deepl', + userId: anotherUser.id, + }); + + await createStep({ + flowId: currentUserFlow.id, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: currentUser.roleId, + conditions: ['isCreator'], + }); + + await request(app) + .patch(`/api/v1/steps/${actionStep.id}`) + .set('Authorization', token) + .send({ + connectionId: anotherUserConnection.id, + }) + .expect(404); + }); + + it('should return not found response for not existing step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + const notExistingStepUUID = Crypto.randomUUID(); + + await request(app) + .patch(`/api/v1/steps/${notExistingStepUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid step UUID', async () => { + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUser.roleId, + conditions: [], + }); + + await request(app) + .patch('/api/v1/steps/invalidStepUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index b59cbcc0..c9f6329f 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -37,6 +37,10 @@ const authorizationList = { action: 'read', subject: 'Flow', }, + 'PATCH /api/v1/steps/:stepId': { + action: 'update', + subject: 'Flow', + }, 'POST /api/v1/steps/:stepId/test': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js index 4781d8b6..7cbf9a6e 100644 --- a/packages/backend/src/models/step.js +++ b/packages/backend/src/models/step.js @@ -334,6 +334,30 @@ class Step extends Base { await Promise.all(nextStepQueries); } + + async update(newStepData) { + const { connectionId, appKey, key, parameters } = newStepData; + + if (this.isTrigger && appKey && key) { + await App.checkAppAndTrigger(appKey, key); + } + + if (this.isAction && appKey && key) { + await App.checkAppAndAction(appKey, key); + } + + const updatedStep = await this.$query().patchAndFetch({ + key: key, + appKey: appKey, + connectionId: connectionId, + parameters: parameters, + status: 'incomplete', + }); + + await updatedStep.updateWebhookUrl(); + + return updatedStep; + } } export default Step; diff --git a/packages/backend/src/routes/api/v1/steps.js b/packages/backend/src/routes/api/v1/steps.js index 36305d9f..bcfc8074 100644 --- a/packages/backend/src/routes/api/v1/steps.js +++ b/packages/backend/src/routes/api/v1/steps.js @@ -7,6 +7,7 @@ import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previo import createDynamicFieldsAction from '../../../controllers/api/v1/steps/create-dynamic-fields.js'; import createDynamicDataAction from '../../../controllers/api/v1/steps/create-dynamic-data.js'; import deleteStepAction from '../../../controllers/api/v1/steps/delete-step.js'; +import updateStepAction from '../../../controllers/api/v1/steps/update-step.js'; const router = Router(); @@ -40,6 +41,7 @@ router.post( createDynamicDataAction ); +router.patch('/:stepId', authenticateUser, authorizeUser, updateStepAction); router.delete('/:stepId', authenticateUser, authorizeUser, deleteStepAction); export default router; diff --git a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js new file mode 100644 index 00000000..87514ef9 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js @@ -0,0 +1,26 @@ +const updateStepMock = (step) => { + const data = { + id: step.id, + type: step.type || 'action', + key: step.key || null, + appKey: step.appKey || null, + iconUrl: step.iconUrl || null, + webhookUrl: step.webhookUrl || null, + status: step.status || 'incomplete', + position: step.position, + parameters: step.parameters || {}, + }; + + return { + data, + meta: { + type: 'Step', + count: 1, + isArray: false, + currentPage: null, + totalPages: null, + }, + }; +}; + +export default updateStepMock;