From 89aa7ffc7315a110abbe50ea439da7c25d1ea34c Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Tue, 17 Sep 2024 11:41:18 +0300 Subject: [PATCH] feat: Implement rest API endpoint for admins to create user --- .../api/v1/admin/users/create-user.js | 22 ++++ .../api/v1/admin/users/create-user.test.js | 122 ++++++++++++++++++ packages/backend/src/models/user.js | 29 ++++- .../src/routes/api/v1/admin/users.ee.js | 2 + .../mocks/rest/api/v1/users/create-user.js | 29 +++++ 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/controllers/api/v1/admin/users/create-user.js create mode 100644 packages/backend/src/controllers/api/v1/admin/users/create-user.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/users/create-user.js diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.js new file mode 100644 index 00000000..9eacd69a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.js @@ -0,0 +1,22 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import User from '../../../../../models/user.js'; +import Role from '../../../../../models/role.js'; + +export default async (request, response) => { + const user = await User.query().insertAndFetch(await userParams(request)); + await user.sendInvitationEmail(); + + renderObject(response, user, { status: 201 }); +}; + +const userParams = async (request) => { + const { fullName, email } = request.body; + const roleId = request.body.roleId || (await Role.findAdmin()).id; + + return { + fullName, + status: 'invited', + email: email?.toLowerCase(), + roleId, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js new file mode 100644 index 00000000..39a85940 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/users/create-user.test.js @@ -0,0 +1,122 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import request from 'supertest'; +import app from '../../../../../app.js'; +import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; +import User from '../../../../../models/user.js'; +import Role from '../../../../../models/role.js'; +import { createUser } from '../../../../../../test/factories/user.js'; +import { createRole } from '../../../../../../test/factories/role.js'; +import createUserMock from '../../../../../../test/mocks/rest/api/v1/users/create-user.js'; + +describe('POST /api/v1/admin/users', () => { + let currentUser, adminRole, token; + + beforeEach(async () => { + adminRole = await createRole({ name: 'Admin' }); + currentUser = await createUser({ roleId: adminRole.id }); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should return created user with valid data', async () => { + const userRole = await createRole({ name: 'User' }); + + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + roleId: userRole.id, + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const expectedPayload = createUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedRegisteredUser.roleId).toStrictEqual(userRole.id); + }); + + it('should create user with admin role if there is no role id given', async () => { + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(201); + + const refetchedRegisteredUser = await User.query() + .findById(response.body.data.id) + .throwIfNotFound(); + + const refetchedUserRole = await Role.query().findById( + refetchedRegisteredUser.roleId + ); + + const expectedPayload = createUserMock(refetchedRegisteredUser); + + expect(response.body).toStrictEqual(expectedPayload); + expect(refetchedUserRole.name).toStrictEqual('Admin'); + }); + + it('should return unprocessable entity response with already used email', async () => { + await createRole({ name: 'User' }); + + await createUser({ + email: 'created@sample.com', + }); + + const userData = { + email: 'created@sample.com', + fullName: 'Full Name', + password: 'samplePassword123', + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.errors).toStrictEqual({ + email: ["'email' must be unique."], + }); + + expect(response.body.meta).toStrictEqual({ + type: 'UniqueViolationError', + }); + }); + + it('should return unprocessable entity response with invalid user data', async () => { + await createRole({ name: 'User' }); + + const userData = { + email: null, + fullName: null, + }; + + const response = await request(app) + .post('/api/v1/admin/users') + .set('Authorization', token) + .send(userData) + .expect(422); + + expect(response.body.meta.type).toStrictEqual('ModelValidation'); + expect(response.body.errors).toStrictEqual({ + email: ["must have required property 'email'"], + fullName: ['must be string'], + }); + }); +}); diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index aafbd010..c351fb91 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -230,7 +230,10 @@ class User extends Base { const invitationToken = crypto.randomBytes(64).toString('hex'); const invitationTokenSentAt = new Date().toISOString(); - await this.$query().patch({ invitationToken, invitationTokenSentAt }); + await this.$query().patchAndFetch({ + invitationToken, + invitationTokenSentAt, + }); } async resetPassword(password) { @@ -355,6 +358,30 @@ class User extends Base { return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; } + async sendInvitationEmail() { + await this.generateInvitationToken(); + + const jobName = `Invitation Email - ${this.id}`; + const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; + + const jobPayload = { + email: this.email, + subject: 'You are invited!', + template: 'invitation-instructions', + params: { + fullName: this.fullName, + acceptInvitationUrl, + }, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + await emailQueue.add(jobName, jobPayload, jobOptions); + } + isInvitationTokenValid() { if (!this.invitationTokenSentAt) { return false; diff --git a/packages/backend/src/routes/api/v1/admin/users.ee.js b/packages/backend/src/routes/api/v1/admin/users.ee.js index 357e0bd9..b42685fe 100644 --- a/packages/backend/src/routes/api/v1/admin/users.ee.js +++ b/packages/backend/src/routes/api/v1/admin/users.ee.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { authenticateUser } from '../../../../helpers/authentication.js'; import { authorizeAdmin } from '../../../../helpers/authorization.js'; import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js'; +import createUserAction from '../../../../controllers/api/v1/admin/users/create-user.js'; import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js'; import updateUserAction from '../../../../controllers/api/v1/admin/users/update-user.ee.js'; import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js'; @@ -9,6 +10,7 @@ import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete- const router = Router(); router.get('/', authenticateUser, authorizeAdmin, getUsersAction); +router.post('/', authenticateUser, authorizeAdmin, createUserAction); router.get('/:userId', authenticateUser, authorizeAdmin, getUserAction); router.patch('/:userId', authenticateUser, authorizeAdmin, updateUserAction); router.delete('/:userId', authenticateUser, authorizeAdmin, deleteUserAction); diff --git a/packages/backend/test/mocks/rest/api/v1/users/create-user.js b/packages/backend/test/mocks/rest/api/v1/users/create-user.js new file mode 100644 index 00000000..fca60d22 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/users/create-user.js @@ -0,0 +1,29 @@ +import appConfig from '../../../../../../src/config/app.js'; + +const createUserMock = (user) => { + const userData = { + createdAt: user.createdAt.getTime(), + email: user.email, + fullName: user.fullName, + id: user.id, + status: user.status, + updatedAt: user.updatedAt.getTime(), + }; + + if (appConfig.isCloud && user.trialExpiryDate) { + userData.trialExpiryDate = user.trialExpiryDate.toISOString(); + } + + return { + data: userData, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'User', + }, + }; +}; + +export default createUserMock;