diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution.js b/packages/backend/src/controllers/api/v1/executions/get-execution.js new file mode 100644 index 00000000..c3b0d115 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution.js @@ -0,0 +1,15 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const execution = await request.currentUser.authorizedExecutions + .withGraphFetched({ + flow: { + steps: true, + }, + }) + .withSoftDeleted() + .findById(request.params.executionId) + .throwIfNotFound(); + + renderObject(response, execution); +}; diff --git a/packages/backend/src/controllers/api/v1/executions/get-execution.test.js b/packages/backend/src/controllers/api/v1/executions/get-execution.test.js new file mode 100644 index 00000000..47473d90 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/executions/get-execution.test.js @@ -0,0 +1,111 @@ +import { 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 { createFlow } from '../../../../../test/factories/flow.js'; +import { createExecution } from '../../../../../test/factories/execution.js'; +import { createPermission } from '../../../../../test/factories/permission'; +import getExecutionMock from '../../../../../test/mocks/rest/api/v1/executions/get-execution'; + +describe('GET /api/v1/executions/:executionId', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = createAuthTokenByUserId(currentUser.id); + }); + + it('should return the execution data of current user', async () => { + const currentUserFlow = await createFlow({ + userId: currentUser.id, + }); + + const currentUserExecution = await createExecution({ + flowId: currentUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .get(`/api/v1/executions/${currentUserExecution.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionMock( + currentUserExecution, + currentUserFlow + ); + + expect(response.body).toEqual(expectedPayload); + }); + + it('should return the execution data of another user', async () => { + const anotherUser = await createUser(); + + const anotherUserFlow = await createFlow({ + userId: anotherUser.id, + }); + + const anotherUserExecution = await createExecution({ + flowId: anotherUserFlow.id, + }); + + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .get(`/api/v1/executions/${anotherUserExecution.id}`) + .set('Authorization', token) + .expect(200); + + const expectedPayload = await getExecutionMock( + anotherUserExecution, + anotherUserFlow + ); + + expect(response.body).toEqual(expectedPayload); + }); + + it('should return not found response for not existing execution UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + const notExistingExcecutionUUID = Crypto.randomUUID(); + + await request(app) + .get(`/api/v1/executions/${notExistingExcecutionUUID}`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Execution', + roleId: currentUserRole.id, + conditions: [], + }); + + await request(app) + .get('/api/v1/executions/invalidExecutionUUID') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 63ce286c..e6d09d5a 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -11,6 +11,10 @@ const authorizationList = { action: 'read', subject: 'Flow', }, + 'GET /api/v1/executions/:executionId': { + action: 'read', + subject: 'Execution', + }, }; export const authorizeUser = async (request, response, next) => { diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index 0724185e..2d199881 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -149,6 +149,13 @@ class User extends Base { return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query(); } + get authorizedExecutions() { + const conditions = this.can('read', 'Execution'); + return conditions.isCreator + ? this.$relatedQuery('executions') + : Execution.query(); + } + login(password) { return bcrypt.compare(password, this.password); } diff --git a/packages/backend/src/routes/api/v1/executions.js b/packages/backend/src/routes/api/v1/executions.js new file mode 100644 index 00000000..8deee5a9 --- /dev/null +++ b/packages/backend/src/routes/api/v1/executions.js @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import asyncHandler from 'express-async-handler'; +import { authenticateUser } from '../../../helpers/authentication.js'; +import { authorizeUser } from '../../../helpers/authorization.js'; +import getExecutionAction from '../../../controllers/api/v1/executions/get-execution.js'; + +const router = Router(); + +router.get( + '/:executionId', + authenticateUser, + authorizeUser, + asyncHandler(getExecutionAction) +); + +export default router; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index 0933ac94..d7084881 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -9,6 +9,7 @@ import paymentRouter from './api/v1/payment.ee.js'; import appAuthClientsRouter from './api/v1/app-auth-clients.js'; import flowsRouter from './api/v1/flows.js'; import appsRouter from './api/v1/apps.js'; +import executionsRouter from './api/v1/executions.js'; import samlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js'; import rolesRouter from './api/v1/admin/roles.ee.js'; import permissionsRouter from './api/v1/admin/permissions.ee.js'; @@ -27,6 +28,7 @@ router.use('/api/v1/payment', paymentRouter); router.use('/api/v1/app-auth-clients', appAuthClientsRouter); router.use('/api/v1/flows', flowsRouter); router.use('/api/v1/apps', appsRouter); +router.use('/api/v1/executions', executionsRouter); router.use('/api/v1/admin/saml-auth-providers', samlAuthProvidersRouter); router.use('/api/v1/admin/roles', rolesRouter); router.use('/api/v1/admin/permissions', permissionsRouter); diff --git a/packages/backend/src/serializers/execution.js b/packages/backend/src/serializers/execution.js new file mode 100644 index 00000000..e0d8a4fa --- /dev/null +++ b/packages/backend/src/serializers/execution.js @@ -0,0 +1,18 @@ +import flowSerializer from './flow.js'; + +const executionSerializer = (execution) => { + let executionData = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + }; + + if (execution.flow) { + executionData.flow = flowSerializer(execution.flow); + } + + return executionData; +}; + +export default executionSerializer; diff --git a/packages/backend/src/serializers/execution.test.js b/packages/backend/src/serializers/execution.test.js new file mode 100644 index 00000000..0bdf5092 --- /dev/null +++ b/packages/backend/src/serializers/execution.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import executionSerializer from './execution'; +import flowSerializer from './flow'; +import { createExecution } from '../../test/factories/execution'; +import { createFlow } from '../../test/factories/flow'; + +describe('executionSerializer', () => { + let flow, execution; + + beforeEach(async () => { + flow = await createFlow(); + + execution = await createExecution({ + flowId: flow.id, + }); + }); + + it('should return the execution data', async () => { + const expectedPayload = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + }; + + expect(executionSerializer(execution)).toEqual(expectedPayload); + }); + + it('should return the execution data with the flow', async () => { + execution.flow = flow; + + const expectedPayload = { + flow: flowSerializer(flow), + }; + + expect(executionSerializer(execution)).toMatchObject(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/flow.js b/packages/backend/src/serializers/flow.js index b1b131cf..7cc16e2a 100644 --- a/packages/backend/src/serializers/flow.js +++ b/packages/backend/src/serializers/flow.js @@ -8,7 +8,7 @@ const flowSerializer = (flow) => { status: flow.status, }; - if (flow.steps) { + if (flow.steps?.length > 0) { flowData.steps = flow.steps.map((step) => stepSerializer(step)); } diff --git a/packages/backend/src/serializers/index.js b/packages/backend/src/serializers/index.js index aa1a37c3..4e42b160 100644 --- a/packages/backend/src/serializers/index.js +++ b/packages/backend/src/serializers/index.js @@ -9,6 +9,7 @@ import appSerializer from './app.js'; import authSerializer from './auth.js'; import triggerSerializer from './trigger.js'; import actionSerializer from './action.js'; +import executionSerializer from './execution.js'; const serializers = { User: userSerializer, @@ -22,6 +23,7 @@ const serializers = { Auth: authSerializer, Trigger: triggerSerializer, Action: actionSerializer, + Execution: executionSerializer, }; export default serializers; diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js new file mode 100644 index 00000000..bf823ded --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js @@ -0,0 +1,27 @@ +const getExecutionMock = async (execution, flow) => { + const data = { + id: execution.id, + testRun: execution.testRun, + createdAt: execution.createdAt.getTime(), + updatedAt: execution.updatedAt.getTime(), + flow: { + id: flow.id, + name: flow.name, + active: flow.active, + status: flow.active ? 'published' : 'draft', + }, + }; + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Execution', + }, + }; +}; + +export default getExecutionMock;