diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.js b/packages/backend/src/controllers/api/v1/apps/create-connection.js new file mode 100644 index 00000000..40a081b9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.js @@ -0,0 +1,27 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const connection = await request.currentUser + .$relatedQuery('connections') + .insertAndFetch(connectionParams(request)); + + const connectionWithAppConfigAndAuthClient = await connection + .$query() + .withGraphFetched({ + appConfig: true, + appAuthClient: true, + }); + + renderObject(response, connectionWithAppConfigAndAuthClient, { status: 201 }); +}; + +const connectionParams = (request) => { + const { appAuthClientId, formattedData } = request.body; + + return { + key: request.params.appKey, + appAuthClientId, + formattedData, + verified: false, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.test.js b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js new file mode 100644 index 00000000..c6e3634f --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createAppConfig } from '../../../../../test/factories/app-config.js'; +import { createAppAuthClient } from '../../../../../test/factories/app-auth-client.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import { createRole } from '../../../../../test/factories/role.js'; +import createConnection from '../../../../../test/mocks/rest/api/v1/apps/create-connection.js'; + +describe('POST /api/v1/apps/:appKey/connections', () => { + let currentUser, token; + + beforeEach(async () => { + const role = await createRole(); + + await createPermission({ + action: 'read', + subject: 'Connection', + roleId: role.id, + }); + + await createPermission({ + action: 'create', + subject: 'Connection', + roleId: role.id, + }); + + currentUser = await createUser({ roleId: role.id }); + + currentUser = await currentUser + .$query() + .leftJoinRelated({ + role: true, + permissions: true, + }) + .withGraphFetched({ + role: true, + permissions: true, + }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + describe('with no app config', async () => { + it('should return created connection', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with app disabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: true, + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with custom connections enabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + allowCustomConnection: true, + }); + }); + + it('should return created conncetion', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with custom connections disabled', async () => { + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + allowCustomConnection: false, + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + formattedData: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + + describe('with auth clients enabled', async () => { + let appAuthClient; + + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + shared: true, + }); + + appAuthClient = await createAppAuthClient({ + appKey: 'gitlab', + active: true, + formattedAuthDefaults: { + oAuthRedirectUrl: 'http://localhost:3000/app/gitlab/connections/add', + instanceUrl: 'https://gitlab.com', + clientId: 'sample_client_id', + clientSecret: 'sample_client_secret', + }, + }); + }); + + it('should return created connection', async () => { + const connectionData = { + appAuthClientId: appAuthClient.id, + }; + + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(201); + + const fetchedConnection = + await currentUser.authorizedConnections.findById(response.body.data.id); + + const expectedPayload = createConnection({ + ...fetchedConnection, + formattedData: {}, + }); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not authorized response for appAuthClientId and formattedData together', async () => { + const connectionData = { + appAuthClientId: appAuthClient.id, + formattedData: {}, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); + describe('with auth clients disabled', async () => { + let appAuthClient; + + beforeEach(async () => { + await createAppConfig({ + key: 'gitlab', + disabled: false, + shared: false, + }); + + appAuthClient = await createAppAuthClient({ + appKey: 'gitlab', + }); + }); + + it('should return with not authorized response', async () => { + const connectionData = { + appAuthClientId: appAuthClient.id, + }; + + await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send(connectionData) + .expect(403); + }); + + it('should return not found response for invalid app key', async () => { + await request(app) + .post('/api/v1/apps/invalid-app-key/connections') + .set('Authorization', token) + .expect(404); + }); + + it('should return unprocesible entity response for invalid connection data', async () => { + const response = await request(app) + .post('/api/v1/apps/gitlab/connections') + .set('Authorization', token) + .send({ + formattedData: 123, + }) + .expect(422); + + expect(response.body).toStrictEqual({ + errors: { + formattedData: ['must be object'], + }, + meta: { + type: 'ModelValidation', + }, + }); + }); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index 36210615..1fff9d88 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -97,6 +97,10 @@ const authorizationList = { action: 'update', subject: 'Flow', }, + 'POST /api/v1/apps/:appKey/connections': { + action: 'create', + subject: 'Connection', + }, }; export const authorizeUser = async (request, response, next) => { diff --git a/packages/backend/src/models/connection.js b/packages/backend/src/models/connection.js index a9cfefb2..54819fad 100644 --- a/packages/backend/src/models/connection.js +++ b/packages/backend/src/models/connection.js @@ -9,6 +9,7 @@ import Step from './step.js'; import appConfig from '../config/app.js'; import Telemetry from '../helpers/telemetry/index.js'; import globalVariable from '../helpers/global-variable.js'; +import NotAuthorizedError from '../errors/not-authorized.js'; class Connection extends Base { static tableName = 'connections'; @@ -121,10 +122,51 @@ class Connection extends Base { return this.data ? true : false; } + async checkEligibilityForCreation() { + const app = await App.findOneByKey(this.key); + + const appConfig = await AppConfig.query().findOne({ key: this.key }); + + if (appConfig) { + if (appConfig.disabled) { + throw new NotAuthorizedError( + 'The application has been disabled for new connections!' + ); + } + + if (!appConfig.allowCustomConnection && this.formattedData) { + throw new NotAuthorizedError( + `New custom connections have been disabled for ${app.name}!` + ); + } + + if (!appConfig.shared && this.appAuthClientId) { + throw new NotAuthorizedError( + 'The connection with the given app auth client is not allowed!' + ); + } + + if (appConfig.shared && !this.formattedData) { + const authClient = await appConfig + .$relatedQuery('appAuthClients') + .findById(this.appAuthClientId) + .where({ active: true }) + .throwIfNotFound(); + + this.formattedData = authClient.formattedAuthDefaults; + } + } + + return this; + } + // TODO: Make another abstraction like beforeSave instead of using // beforeInsert and beforeUpdate separately for the same operation. async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); + + await this.checkEligibilityForCreation(); + this.encryptData(); } diff --git a/packages/backend/src/routes/api/v1/apps.js b/packages/backend/src/routes/api/v1/apps.js index 084099dc..5bdc27f1 100644 --- a/packages/backend/src/routes/api/v1/apps.js +++ b/packages/backend/src/routes/api/v1/apps.js @@ -14,6 +14,7 @@ import getTriggerSubstepsAction from '../../../controllers/api/v1/apps/get-trigg import getActionsAction from '../../../controllers/api/v1/apps/get-actions.js'; import getActionSubstepsAction from '../../../controllers/api/v1/apps/get-action-substeps.js'; import getFlowsAction from '../../../controllers/api/v1/apps/get-flows.js'; +import createConnectionAction from '../../../controllers/api/v1/apps/create-connection.js'; const router = Router(); @@ -28,6 +29,13 @@ router.get( getConnectionsAction ); +router.post( + '/:appKey/connections', + authenticateUser, + authorizeUser, + createConnectionAction +); + router.get( '/:appKey/config', authenticateUser, diff --git a/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js new file mode 100644 index 00000000..9e993a4c --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js @@ -0,0 +1,25 @@ +const createConnection = (connection) => { + const connectionData = { + id: connection.id, + key: connection.key, + reconnectable: connection.reconnectable || true, + appAuthClientId: connection.appAuthClientId, + formattedData: connection.formattedData, + verified: connection.verified || false, + createdAt: connection.createdAt.getTime(), + updatedAt: connection.updatedAt.getTime(), + }; + + return { + data: connectionData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Connection', + }, + }; +}; + +export default createConnection;