From 7e89d42382020b493cc5dc039d98ca167a7bb8d5 Mon Sep 17 00:00:00 2001 From: Fortune Ochi Date: Mon, 21 Oct 2019 12:12:41 +0100 Subject: [PATCH] Admin user management (#80) * Updated BaseEntity navigation properties to reference User type * Used auto mapper's array mapper for mapping array to DTO * Removed vscode local settings from version control * Updated createdBy field in user entity * Added user auto mapper profile * Added user auto mapper profile * Updated package.json and set tenant and user globally. Closes #16, closes #17 * Implement save or update feature. Closes #11 * Moved hooks to base entity class and implemented soft-delete * Implement soft delete feature. Closes #56 * Fixed #63, and refactor some part of test files (#14) * Simplify returned entity in signUp * Removed global variables and wrote UserController tests * Correct model class extensions * Implement user management by admin * User management * Updated swagger and packages --- package-lock.json | 12 +- package.json | 4 +- src/domain/model/base.ts | 22 ++- src/domain/model/user.ts | 18 +- .../db/repositories/base_repository.ts | 73 ++++++-- src/ui/api/controllers/base_controller.ts | 15 +- src/ui/api/controllers/tenant_controller.ts | 7 +- src/ui/api/controllers/user_controller.ts | 46 ++++- src/ui/api/middleware/auth_middleware.ts | 13 +- .../api/middleware/interceptor_middleware.ts | 2 +- src/ui/api/routes.ts | 115 ++++++++++++ src/ui/interfaces/user_service.ts | 3 +- src/ui/models/base_dto.ts | 2 +- src/ui/models/user_dto.ts | 37 +++- src/ui/services/auth_service.ts | 13 +- src/ui/services/user_service.ts | 72 ++++++-- swagger.json | 166 +++++++++++++++++- test/ui/api/user_controller.test.ts | 117 ++++++++++-- 18 files changed, 645 insertions(+), 92 deletions(-) diff --git a/package-lock.json b/package-lock.json index d17d5e1..062fd04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -357,9 +357,9 @@ } }, "@types/mongoose": { - "version": "5.5.21", - "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.5.21.tgz", - "integrity": "sha512-vQFa53WOqDmsQzzPGcOQE5F64RLJMBvHJoYdmQ6ksQdbbd5H1qssxeQArSnII45jbIvOPCOCNo+rdp5U/NPtkA==", + "version": "5.5.22", + "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.5.22.tgz", + "integrity": "sha512-1AXWXzpt7MZTxsm0cg2Nd6vkZHtKF5poHESJAIp31AKDxN6YXTThg1LRUmKC9tIsdKHD+y8v2KeoO/sZeR/+OQ==", "dev": true, "requires": { "@types/mongodb": "*", @@ -5632,9 +5632,9 @@ } }, "mocha": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.1.tgz", - "integrity": "sha512-VCcWkLHwk79NYQc8cxhkmI8IigTIhsCwZ6RTxQsqK6go4UvEhzJkYuHm8B2YtlSxcYq2fY+ucr4JBwoD6ci80A==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.2.tgz", + "integrity": "sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A==", "dev": true, "requires": { "ansi-colors": "3.2.3", diff --git a/package.json b/package.json index ad91be5..3470157 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@types/mailgun-js": "^0.22.3", "@types/method-override": "0.0.31", "@types/mocha": "^5.2.7", - "@types/mongoose": "^5.5.21", + "@types/mongoose": "^5.5.22", "@types/supertest": "^2.0.8", "@types/swagger-ui-express": "^3.0.1", "@types/winston": "^2.4.4", @@ -110,7 +110,7 @@ "eslint-plugin-chai-friendly": "^0.4.1", "eslint-plugin-import": "^2.18.2", "ghooks": "^2.0.4", - "mocha": "^6.2.1", + "mocha": "^6.2.2", "mongodb-memory-server": "^5.2.8", "nodemon": "^1.19.4", "nyc": "^14.1.1", diff --git a/src/domain/model/base.ts b/src/domain/model/base.ts index 8ef6ff6..512e8ed 100644 --- a/src/domain/model/base.ts +++ b/src/domain/model/base.ts @@ -1,12 +1,11 @@ import { instanceMethod, pre, prop, Ref, Typegoose } from "@hasezoey/typegoose"; import { Expose } from "class-transformer"; import { Query } from "mongoose"; - -import { Writable } from "../utils/writable"; -import { User } from "./user"; import { iocContainer } from "../../infrastructure/config/ioc"; -import { TYPES } from "../constants/types"; import { DecodedJwt } from "../../ui/services/auth_service"; +import { TYPES } from "../constants/types"; +import { Writable } from "../utils/writable"; +import { User } from "./user"; // eslint-disable-next-line @pre("findOneAndUpdate", function(this: Query, next) { @@ -27,13 +26,17 @@ import { DecodedJwt } from "../../ui/services/auth_service"; } }) // eslint-disable-next-line -@pre("save", function(next) { +@pre("save", function(next) { try { if (iocContainer.isBound(TYPES.DecodedJwt)) { const currentUser = iocContainer.get(TYPES.DecodedJwt); - this.setCreatedBy(currentUser.userId); + (this as Writable< + BaseEntity + >).createdBy = currentUser.userId as any; + if (this.type === "User" && !this.tenant) + this.setTenant(currentUser.tenantId); } else if (this.type === "User") { - this.setCreatedBy(this); + (this as Writable).createdBy = this; } next(); } catch (error) { @@ -91,9 +94,4 @@ export abstract class BaseEntity extends Typegoose { activate(): void { (this as Writable).isActive = true; } - - @instanceMethod - private setCreatedBy(user: any) { - (this as Writable).createdBy = user; - } } diff --git a/src/domain/model/user.ts b/src/domain/model/user.ts index 0bcb25d..f76f745 100644 --- a/src/domain/model/user.ts +++ b/src/domain/model/user.ts @@ -103,7 +103,6 @@ export class User extends BaseEntity implements IMustHaveTenant { tenantId?: string; }) => { const id = tenantId || iocContainer.get(TYPES.TenantId); - if (!id) throw new Error("Tenant Id is required"); return new User({ @@ -162,15 +161,18 @@ export class User extends BaseEntity implements IMustHaveTenant { setPassword(password: string) { (this as Writable).password = password; } - + @instanceMethod + setTenant(tenant: any) { + (this as Writable).tenant = tenant; + } @instanceMethod update(user: Partial): void { - if (this.firstName) this.setFirstName(user.firstName as string); - if (this.lastName) this.setLastName(user.lastName as string); - if (this.password) this.setPassword(user.password as string); - if (this.username) this.setUsername(user.username as string); - if (this.email) this.setEmail(user.email as string); - if (this.role) this.setRole(user.role as UserRole); + if (user.firstName) this.setFirstName(user.firstName as string); + if (user.lastName) this.setLastName(user.lastName as string); + if (user.password) this.setPassword(user.password as string); + if (user.username) this.setUsername(user.username as string); + if (user.email) this.setEmail(user.email as string); + if (user.role) this.setRole(user.role as UserRole); } @instanceMethod diff --git a/src/infrastructure/db/repositories/base_repository.ts b/src/infrastructure/db/repositories/base_repository.ts index fb0ff00..d9cd82d 100644 --- a/src/infrastructure/db/repositories/base_repository.ts +++ b/src/infrastructure/db/repositories/base_repository.ts @@ -1,12 +1,13 @@ import { plainToClassFromExist } from "class-transformer"; import { unmanaged } from "inversify"; import { Document, Model } from "mongoose"; +import { TYPES } from "../../../domain/constants/types"; import { IBaseRepository, Query } from "../../../domain/interfaces/repositories"; import { BaseEntity } from "../../../domain/model/base"; -import { provideSingleton } from "../../config/ioc"; +import { iocContainer, provideSingleton } from "../../config/ioc"; @provideSingleton(BaseRepository) export class BaseRepository @@ -14,6 +15,7 @@ export class BaseRepository protected Model: Model; protected _constructor: () => TEntity; + public constructor( @unmanaged() model: Model, @unmanaged() constructor: () => TEntity @@ -24,7 +26,13 @@ export class BaseRepository public async findAll() { return new Promise((resolve, reject) => { - this.Model.find({ isDeleted: { $ne: true } }, (err, res) => { + const query = JSON.parse( + JSON.stringify({ + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); + this.Model.find(query, "-__v", (err, res) => { if (err) return reject(err); const results = res.map(r => this.readMapper(r)); return resolve(results); @@ -33,13 +41,19 @@ export class BaseRepository } public async findById(id: string) { + const query = JSON.parse( + JSON.stringify({ + _id: id, + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); + return new Promise((resolve, reject) => { - this.Model.findById(id, "-__v", (err, res) => { + this.Model.findOne(query, "-__v", (err, res) => { if (err) return reject(err); if (!res) return resolve(); - - const result = this.readMapper(res); - resolve(result.isDeleted ? undefined : result); + resolve(this.readMapper(res)); }); }); } @@ -58,8 +72,15 @@ export class BaseRepository public async insertOrUpdate(doc: TEntity): Promise { return new Promise((resolve, reject) => { if (doc.id) { + const query = JSON.parse( + JSON.stringify({ + _id: doc.id, + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); this.Model.findByIdAndUpdate( - { _id: doc.id }, + query, doc, { new: true }, (err, res) => { @@ -81,35 +102,52 @@ export class BaseRepository } public findManyById(ids: string[]) { + const query = JSON.parse( + JSON.stringify({ + _id: { $in: ids }, + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); return new Promise((resolve, reject) => { - const query = { _id: { $in: ids } }; this.Model.find(query, (err, res) => { if (err) return reject(err); - let results = res.map(r => this.readMapper(r)); - results = results.filter(r => !r.isDeleted); + const results = res.map(r => this.readMapper(r)); resolve(results); }); }); } public findManyByQuery(query: Query) { + query = JSON.parse( + JSON.stringify({ + ...query, + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); return new Promise((resolve, reject) => { this.Model.find(query, "-__v", (err, res) => { if (err) return reject(err); if (!res) return resolve(); - let result = res.map(r => this.readMapper(r)); - result = result.filter(r => !r.isDeleted); + const result = res.map(r => this.readMapper(r)); resolve(result); }); }); } public async findOneByQuery(query: Query) { + query = JSON.parse( + JSON.stringify({ + ...query, + isDeleted: { $ne: true }, + tenant: this.getCurrentTenant() + }) + ); return new Promise((resolve, reject) => { this.Model.findOne(query, "-__v", (err, res) => { if (err) return reject(err); if (!res) return resolve(); - const result = this.readMapper(res); - resolve(result.isDeleted ? undefined : result); + resolve(this.readMapper(res)); }); }); } @@ -148,6 +186,7 @@ export class BaseRepository // throw new Error("Method not implemented."); // } + // #region Helper methods /** * Maps '_id' from mongodb to 'id' of TEntity * @@ -167,4 +206,10 @@ export class BaseRepository delete obj._id; return plainToClassFromExist(entity, obj); } + private getCurrentTenant() { + return this._constructor().type !== "Tenant" + ? iocContainer.get(TYPES.TenantId) + : undefined; + } + // #endregion } diff --git a/src/ui/api/controllers/base_controller.ts b/src/ui/api/controllers/base_controller.ts index 8459235..1b1f76f 100644 --- a/src/ui/api/controllers/base_controller.ts +++ b/src/ui/api/controllers/base_controller.ts @@ -1,8 +1,9 @@ -import httpStatus from "http-status-codes"; import { validate, ValidationError } from "class-validator"; +import httpStatus from "http-status-codes"; import { Controller } from "tsoa"; -import { BaseCreateEntityDto } from "../../models/base_dto"; +import { isIdValid } from "../../../infrastructure/utils/server_utils"; import { HttpError } from "../../error"; +import { BaseCreateEntityDto } from "../../models/base_dto"; export abstract class BaseController extends Controller { protected async checkBadRequest(input: BaseCreateEntityDto) { @@ -22,4 +23,14 @@ export abstract class BaseController extends Controller { throw new HttpError(httpStatus.CONFLICT); } } + protected async checkUUID(id: any) { + if (!isIdValid(id)) { + this.setStatus(httpStatus.BAD_REQUEST); + + throw new HttpError( + httpStatus.BAD_REQUEST, + `ID "${id}" is invalid` + ); + } + } } diff --git a/src/ui/api/controllers/tenant_controller.ts b/src/ui/api/controllers/tenant_controller.ts index bb1c4c4..d42e37b 100644 --- a/src/ui/api/controllers/tenant_controller.ts +++ b/src/ui/api/controllers/tenant_controller.ts @@ -7,7 +7,7 @@ import { provideSingleton } from "../../../infrastructure/config/ioc"; import { isIdValid } from "../../../infrastructure/utils/server_utils"; import { HttpError } from "../../error"; import { ITenantService } from "../../interfaces/tenant_service"; -import { CreateTenantInput } from "../../models/tenant_dto"; +import { CreateTenantInput, TenantDto } from "../../models/tenant_dto"; import { TenantService } from "../../services/tenant_service"; import { BaseController } from "./base_controller"; @@ -18,9 +18,10 @@ export class TenantController extends BaseController { @inject(TenantService) private readonly _tenantService: ITenantService; @Get("{tenantName}") - public async get(tenantName: string) { + public async get(tenantName: string): Promise { const tenant = await this._tenantService.get(tenantName); - return tenant || this.setStatus(httpStatus.NOT_FOUND); + if (!tenant) throw new HttpError(httpStatus.NOT_FOUND); + return tenant; } @Post() @Security("X-Auth-Token", ["admin"]) diff --git a/src/ui/api/controllers/user_controller.ts b/src/ui/api/controllers/user_controller.ts index 1af841c..742ce24 100644 --- a/src/ui/api/controllers/user_controller.ts +++ b/src/ui/api/controllers/user_controller.ts @@ -1,7 +1,14 @@ -import { Body, Post, Route, Security, Tags } from "tsoa"; +import { plainToClass } from "class-transformer"; +import httpStatus from "http-status-codes"; +import { Body, Delete, Get, Post, Put, Route, Security, Tags } from "tsoa"; import { inject, provideSingleton } from "../../../infrastructure/config/ioc"; +import { HttpError } from "../../error"; import { IUserService } from "../../interfaces/user_service"; -import { UserDto, UserSignUpInput } from "../../models/user_dto"; +import { + UserDto, + UserSignUpInput, + UserUpdateInput +} from "../../models/user_dto"; import { UserService } from "../../services/user_service"; import { BaseController } from "./base_controller"; @@ -13,7 +20,40 @@ export class UserController extends BaseController { @Post() @Security("X-Auth-Token", ["admin"]) - async create(@Body() input: UserSignUpInput): Promise { + public async create(@Body() input: UserSignUpInput): Promise { + await this.checkBadRequest(plainToClass(UserSignUpInput, input)); return this._userService.create(input); } + @Put("{id}") + @Security("X-Auth-Token", ["admin"]) + public async update( + id: string, + @Body() input: UserUpdateInput + ): Promise { + this.checkUUID(id); + if (!input) return this.setStatus(httpStatus.NO_CONTENT); + await this.checkBadRequest(plainToClass(UserUpdateInput, input)); + input = JSON.parse(JSON.stringify(input)); + await this._userService.update({ ...input, id }); + } + @Get("{id}") + @Security("X-Auth-Token", ["admin"]) + public async get(id: string): Promise { + this.checkUUID(id); + const userDto = await this._userService.get(id); + if (userDto) return userDto; + throw new HttpError(httpStatus.NOT_FOUND); + } + @Get() + @Security("X-Auth-Token", ["admin"]) + public async getAll(): Promise { + return this._userService.getAll(); + } + @Delete("{id}") + @Security("X-Auth-Token", ["admin"]) + public async delete(id: string) { + this.checkUUID(id); + const isDeleted = await this._userService.delete(id); + if (!isDeleted) this.setStatus(httpStatus.NOT_FOUND); + } } diff --git a/src/ui/api/middleware/auth_middleware.ts b/src/ui/api/middleware/auth_middleware.ts index a927f25..a28d716 100644 --- a/src/ui/api/middleware/auth_middleware.ts +++ b/src/ui/api/middleware/auth_middleware.ts @@ -25,7 +25,6 @@ function authentication(iocContainer: Container) { switch (securityName) { case X_AUTH_TOKEN_KEY: { const token = req.header(X_AUTH_TOKEN_KEY); - // Check if X-Auth-Token header was passed in sign-up endpoint if (!token) throw new HttpError( @@ -45,9 +44,11 @@ function authentication(iocContainer: Container) { await assignTenantToReqAsync(iocContainer, tenantId); return tenantId; } - default: - throw new Error("Invalid security name"); + throw new HttpError( + httpStatus.INTERNAL_SERVER_ERROR, + "Invalid security name" + ); } }; } @@ -59,7 +60,6 @@ async function assignJwt( ) { try { const decodedJwt = jwt.verify(token, env.jwtSecret) as DecodedJwt; - const expectedUserRole = (UserRole as any)[scopes[0].toUpperCase()]; if ( expectedUserRole !== UserRole.USER && @@ -77,6 +77,11 @@ async function assignJwt( httpStatus.UNAUTHORIZED, "Tenant is not available!" ); + if (!iocContainer.isBound(TYPES.TenantId)) + iocContainer + .bind(TYPES.TenantId) + .toConstantValue(decodedJwt.tenantId); + if (iocContainer.isBound(TYPES.DecodedJwt)) iocContainer.unbind(TYPES.DecodedJwt); iocContainer diff --git a/src/ui/api/middleware/interceptor_middleware.ts b/src/ui/api/middleware/interceptor_middleware.ts index 7065d30..e908079 100644 --- a/src/ui/api/middleware/interceptor_middleware.ts +++ b/src/ui/api/middleware/interceptor_middleware.ts @@ -18,7 +18,7 @@ export class RequestMiddleware extends BaseMiddleware { // REQUEST MIDDLEWARE // HTTP ${req.method} ${req.url} // ---------------------------------- - // `); + // `); next(); } } diff --git a/src/ui/api/routes.ts b/src/ui/api/routes.ts index f7ec3b2..dd47cb8 100644 --- a/src/ui/api/routes.ts +++ b/src/ui/api/routes.ts @@ -77,6 +77,17 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "UserUpdateInput": { + "dataType": "refObject", + "properties": { + "firstName": { "dataType": "string" }, + "lastName": { "dataType": "string" }, + "email": { "dataType": "string" }, + "username": { "dataType": "string" }, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const validationService = new ValidationService(models); @@ -242,6 +253,110 @@ export function RegisterRoutes(app: express.Express) { promiseHandler(controller, promise, response, next); }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.put('/api/v1/users/:id', + authenticateMiddleware([{ "X-Auth-Token": ["admin"] }]), + function(request: any, response: any, next: any) { + const args = { + id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, + input: { "in": "body", "name": "input", "required": true, "ref": "UserUpdateInput" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request); + } catch (err) { + return next(err); + } + + const controller = iocContainer.get(UserController); + if (typeof controller['setStatus'] === 'function') { + (controller).setStatus(undefined); + } + + + const promise = controller.update.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, next); + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/api/v1/users/:id', + authenticateMiddleware([{ "X-Auth-Token": ["admin"] }]), + function(request: any, response: any, next: any) { + const args = { + id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request); + } catch (err) { + return next(err); + } + + const controller = iocContainer.get(UserController); + if (typeof controller['setStatus'] === 'function') { + (controller).setStatus(undefined); + } + + + const promise = controller.get.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, next); + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.get('/api/v1/users', + authenticateMiddleware([{ "X-Auth-Token": ["admin"] }]), + function(request: any, response: any, next: any) { + const args = { + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request); + } catch (err) { + return next(err); + } + + const controller = iocContainer.get(UserController); + if (typeof controller['setStatus'] === 'function') { + (controller).setStatus(undefined); + } + + + const promise = controller.getAll.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, next); + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.delete('/api/v1/users/:id', + authenticateMiddleware([{ "X-Auth-Token": ["admin"] }]), + function(request: any, response: any, next: any) { + const args = { + id: { "in": "path", "name": "id", "required": true, "dataType": "string" }, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request); + } catch (err) { + return next(err); + } + + const controller = iocContainer.get(UserController); + if (typeof controller['setStatus'] === 'function') { + (controller).setStatus(undefined); + } + + + const promise = controller.delete.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, next); + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/src/ui/interfaces/user_service.ts b/src/ui/interfaces/user_service.ts index e75b392..0055a0a 100644 --- a/src/ui/interfaces/user_service.ts +++ b/src/ui/interfaces/user_service.ts @@ -3,6 +3,7 @@ import { UserDto, UserSignUpInput } from "../models/user_dto"; export interface IUserService { create(user: UserSignUpInput): Promise; get(id: string): Promise; + getAll(): Promise; update(user: Partial): Promise; - delete(id: string): Promise; + delete(id: string): Promise; } diff --git a/src/ui/models/base_dto.ts b/src/ui/models/base_dto.ts index 1a7c093..f181413 100644 --- a/src/ui/models/base_dto.ts +++ b/src/ui/models/base_dto.ts @@ -6,6 +6,6 @@ export abstract class BaseCreateEntityDto {} export abstract class BaseEntityDto { @IsUUID() @Expose() - id: string; + id!: string; } export abstract class BaseUpdateDto extends BaseEntityDto {} diff --git a/src/ui/models/user_dto.ts b/src/ui/models/user_dto.ts index e3edca8..96b7507 100644 --- a/src/ui/models/user_dto.ts +++ b/src/ui/models/user_dto.ts @@ -1,5 +1,11 @@ import { Expose } from "class-transformer"; -import { IsEmail, IsNotEmpty, IsString, MaxLength } from "class-validator"; +import { + IsEmail, + IsNotEmpty, + IsString, + MaxLength, + IsOptional +} from "class-validator"; import { MAX_NAME_LENGTH } from "../../domain/model/user"; import { BaseEntityDto } from "./base_dto"; @@ -37,21 +43,50 @@ export interface UserSignUpDto { export class UserDto extends BaseEntityDto { @MaxLength(MAX_NAME_LENGTH) @IsNotEmpty() + @IsOptional() @IsString() @Expose() firstName: string; @MaxLength(MAX_NAME_LENGTH) @IsNotEmpty() + @IsOptional() @IsString() @Expose() lastName: string; @MaxLength(MAX_NAME_LENGTH) @IsEmail() + @IsOptional() @Expose() email: string; @MaxLength(MAX_NAME_LENGTH) @IsNotEmpty() + @IsOptional() @IsString() @Expose() username: string; } +export class UserUpdateInput { + @MaxLength(MAX_NAME_LENGTH) + @IsNotEmpty() + @IsOptional() + @IsString() + @Expose() + firstName?: string; + @MaxLength(MAX_NAME_LENGTH) + @IsNotEmpty() + @IsOptional() + @IsString() + @Expose() + lastName?: string; + @MaxLength(MAX_NAME_LENGTH) + @IsEmail() + @IsOptional() + @Expose() + email?: string; + @MaxLength(MAX_NAME_LENGTH) + @IsNotEmpty() + @IsOptional() + @IsString() + @Expose() + username?: string; +} diff --git a/src/ui/services/auth_service.ts b/src/ui/services/auth_service.ts index 9a96442..ccc1274 100644 --- a/src/ui/services/auth_service.ts +++ b/src/ui/services/auth_service.ts @@ -4,20 +4,20 @@ import { EventDispatcher } from "event-dispatch"; import httpStatus from "http-status-codes"; import jwt from "jsonwebtoken"; import { eventDispatcher } from "../../domain/constants/decorators"; -import { TYPES } from "../../domain/constants/types"; import { IUserRepository } from "../../domain/interfaces/repositories"; import { PASSWORD_SALT_ROUND, User, UserRole } from "../../domain/model/user"; import { config } from "../../infrastructure/config"; import { inject, - iocContainer, - provideSingleton + provideSingleton, + iocContainer } from "../../infrastructure/config/ioc"; import { UserRepository } from "../../infrastructure/db/repositories/user_repository"; import { HttpError } from "../error"; import { IAuthService } from "../interfaces/auth_service"; import { UserDto, UserSignInInput, UserSignUpInput } from "../models/user_dto"; import { events } from "../subscribers/events"; +import { TYPES } from "../../domain/constants/types"; export interface DecodedJwt { userId: string; @@ -42,10 +42,8 @@ export class AuthService implements IAuthService { : PASSWORD_SALT_ROUND; const hashedPassword = await bcrypt.hash(dto.password, saltRound); - const tenantId = iocContainer.get(TYPES.TenantId); let user = await this._userRepository.findOneByQuery({ - email: dto.email, - tenant: tenantId + email: dto.email }); if (user) throw new HttpError( @@ -53,8 +51,7 @@ export class AuthService implements IAuthService { `Email "${dto.email.toLowerCase()}" is already taken` ); user = await this._userRepository.findOneByQuery({ - username: dto.username, - tenant: tenantId + username: dto.username }); if (user) diff --git a/src/ui/services/user_service.ts b/src/ui/services/user_service.ts index d0ed8d0..9597b2d 100644 --- a/src/ui/services/user_service.ts +++ b/src/ui/services/user_service.ts @@ -1,10 +1,12 @@ import bcrypt from "bcrypt"; import { plainToClass } from "class-transformer"; +import httpStatus from "http-status-codes"; import { IUserRepository } from "../../domain/interfaces/repositories"; import { PASSWORD_SALT_ROUND, User } from "../../domain/model/user"; import { config } from "../../infrastructure/config"; import { inject, provideSingleton } from "../../infrastructure/config/ioc"; import { UserRepository } from "../../infrastructure/db/repositories/user_repository"; +import { HttpError } from "../error"; import { IUserService } from "../interfaces/user_service"; import { UserDto, UserSignUpInput } from "../models/user_dto"; @@ -13,14 +15,33 @@ export class UserService implements IUserService { @inject(UserRepository) private readonly _userRepository: IUserRepository; async create(user: UserSignUpInput): Promise { + let newUser = await this._userRepository.findOneByQuery({ + email: user.email + }); + if (newUser) + throw new HttpError( + httpStatus.CONFLICT, + `User with email "${newUser.email}" already exist` + ); + + newUser = await this._userRepository.findOneByQuery({ + username: user.username + }); + + if (newUser) + throw new HttpError( + httpStatus.CONFLICT, + `User with username "${newUser.username}" already exist` + ); + const saltRound = config.env === "development" || config.env === "test" ? 1 : PASSWORD_SALT_ROUND; - const password = await bcrypt.hash(user.password, saltRound); - user.password = password; + user.password = await bcrypt.hash(user.password, saltRound); + + newUser = User.createInstance({ ...user }); - const newUser = User.createInstance({ ...user }); await this._userRepository.insertOrUpdate(newUser); return plainToClass(UserDto, newUser, { @@ -30,22 +51,53 @@ export class UserService implements IUserService { } async get(id: string): Promise { const user = await this._userRepository.findById(id); - return plainToClass(UserDto, user, { + return plainToClass(UserDto, user, { + enableImplicitConversion: true, + excludeExtraneousValues: true + }); + } + async getAll(): Promise { + const users = await this._userRepository.findAll(); + return plainToClass(UserDto, users, { enableImplicitConversion: true, excludeExtraneousValues: true }); } - async update(user: Partial): Promise { - if (!user.id) throw new Error("User ID is missing"); + async update(user: Partial): Promise { const userToUpdate = await this._userRepository.findById(user.id); - if (!userToUpdate) throw new Error("User with given ID does not exist"); + if (!userToUpdate) + throw new HttpError( + httpStatus.NOT_FOUND, + `User with ID "${user.id}" does not exist` + ); + // check that userToUpdate does not overwrite an existing email or username + let existingUser: User; + if (user.email) { + existingUser = await this._userRepository.findOneByQuery({ + email: user.email + }); + if (existingUser) + throw new HttpError( + httpStatus.CONFLICT, + `User with email "${user.email}" already exist` + ); + } + if (user.username) { + existingUser = await this._userRepository.findOneByQuery({ + username: user.username + }); + if (existingUser) + throw new HttpError( + httpStatus.CONFLICT, + `User with username "${user.username}" already exist` + ); + } userToUpdate.update(user); - await this._userRepository.insertOrUpdate(userToUpdate); } - async delete(id: string): Promise { - await this._userRepository.deleteById(id); + async delete(id: string): Promise { + return this._userRepository.deleteById(id); } } diff --git a/swagger.json b/swagger.json index 355da7a..cc5b716 100644 --- a/swagger.json +++ b/swagger.json @@ -133,6 +133,28 @@ ], "type": "object", "additionalProperties": false + }, + "UserUpdateInput": { + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string", + "nullable": true + }, + "username": { + "type": "string", + "nullable": true + } + }, + "type": "object", + "additionalProperties": false } }, "securitySchemes": { @@ -243,12 +265,7 @@ "content": { "application/json": { "schema": { - "oneOf": [ - {}, - { - "$ref": "#/components/schemas/TenantDto" - } - ] + "$ref": "#/components/schemas/TenantDto" } } }, @@ -376,6 +393,143 @@ } } } + }, + "get": { + "operationId": "GetAll", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserDto" + }, + "type": "array" + } + } + }, + "description": "Ok" + } + }, + "tags": [ + "Users" + ], + "security": [ + { + "X-Auth-Token": [ + "admin" + ] + } + ], + "parameters": [] + } + }, + "/users/{id}": { + "put": { + "operationId": "Update", + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "No content" + } + }, + "tags": [ + "Users" + ], + "security": [ + { + "X-Auth-Token": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateInput" + } + } + } + } + }, + "get": { + "operationId": "Get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + }, + "description": "Ok" + } + }, + "tags": [ + "Users" + ], + "security": [ + { + "X-Auth-Token": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "delete": { + "operationId": "Delete", + "responses": { + "204": { + "content": { + "application/json": {} + }, + "description": "No content" + } + }, + "tags": [ + "Users" + ], + "security": [ + { + "X-Auth-Token": [ + "admin" + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ] } } }, diff --git a/test/ui/api/user_controller.test.ts b/test/ui/api/user_controller.test.ts index 80772d1..64ea5cd 100644 --- a/test/ui/api/user_controller.test.ts +++ b/test/ui/api/user_controller.test.ts @@ -15,11 +15,16 @@ import { X_AUTH_TOKEN_KEY, X_TENANT_ID } from "../../../src/ui/constants/header_constants"; +import { + UserDto, + UserSignUpInput, + UserSignInInput, + UserUpdateInput +} from "../../../src/ui/models/user_dto"; import { cleanupDb, req } from "../../setup"; -import { UserSignUpInput } from "../../../src/ui/models/user_dto"; describe("User controller", () => { - let jwt: string; + let adminJwt: string; let tenant: Tenant; let userSignUpInput: UserSignUpInput; let userRepository: IUserRepository; @@ -46,7 +51,7 @@ describe("User controller", () => { firstName: "Admin", lastName: "Admin", username: "admin", - email: "email@gmail.com", + email: "admin@gmail.com", password: hashedPassword }; adminUser = User.createInstance({ @@ -55,31 +60,123 @@ describe("User controller", () => { }); adminUser.setRole(UserRole.ADMIN); await userRepository.insertOrUpdate(adminUser); + const input: UserSignInInput = { + emailOrUsername: adminUser.email, + password + }; const { body } = await req - .post(`${config.api.prefix}/auth/signIn`) - .send({ emailOrUsername: userSignUpInput.email, password }) + .post(`${config.api.prefix}/auth/signin`) + .send(input) .set(X_TENANT_ID, tenant.id); - jwt = body.token; + adminJwt = body.token; }); describe("Admin User CRUD Operations", () => { + let createdUser: UserDto; + let userRecord: User; it("should create a new user if signed-in user is admin", async () => { const newUser = { ...userSignUpInput }; + newUser.email = "different_email@email.com"; + newUser.username = "different_username"; const { body } = await req .post(endpoint) .send(newUser) - .set(X_AUTH_TOKEN_KEY, jwt) + .set(X_AUTH_TOKEN_KEY, adminJwt) .expect(httpStatus.OK); expect(body).to.contain.keys("id"); - const user = await userRepository.findById(body.id); - expect(user.createdBy.toString()).to.equal(adminUser.id.toString()); + const userRecordFromDb = await userRepository.findById(body.id); + expect(userRecordFromDb.createdBy.toString()).to.equal( + adminUser.id.toString() + ); + + expect(adminUser.tenant.toString()).to.equal( + userRecordFromDb.tenant.toString() + ); + + expect(userRecordFromDb.tenant.toString()).to.equal( + adminUser.tenant.toString() + ); + createdUser = body; + userRecord = userRecordFromDb; + }); + it("should get all users if signed-in user is an admin", async () => { + const { body } = await req + .get(endpoint) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.OK); + + expect(body).to.be.an("array"); + expect(body).to.deep.include(createdUser); + }); + it("should get a user by ID if signed-in user is an admin", async () => { + const { body } = await req + .get(`${endpoint}/${createdUser.id}`) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.OK); + + expect(body.id).to.equal(createdUser.id); + }); + it("should return email conflict if admin user tries to create user with the same email", async () => { + const newUser = { ...userSignUpInput }; + newUser.username += "nonConflictUsername"; + + await req + .post(endpoint) + .send(newUser) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.CONFLICT); + }); + it("should return username conflict if admin user tries to create user with the same username", async () => { + const newUser = { ...userSignUpInput }; + newUser.email = "newMail@gmail.com"; - expect(user.tenant.toString()).to.equal( + await req + .post(endpoint) + .send(newUser) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.CONFLICT); + }); + it("should update a user if signed-in user is admin", async () => { + const userUpdateInput: UserUpdateInput = { + email: "updated@email.com" + }; + await req + .put(`${endpoint}/${createdUser.id}`) + .send(userUpdateInput) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.NO_CONTENT); + userRecord = await userRepository.findById(createdUser.id); + expect(userRecord.email).to.equal(userUpdateInput.email); + expect(userRecord.updatedBy.toString()).to.equal( + adminUser.id.toString() + ); + expect(userRecord.tenant.toString()).to.equal( + adminUser.tenant.toString() + ); + expect(userRecord.updatedAt).to.be.greaterThan( + userRecord.createdAt + ); + }); + it("should soft-delete a user if signed-in user is admin", async () => { + await req + .delete(`${endpoint}/${createdUser.id}`) + .set(X_AUTH_TOKEN_KEY, adminJwt) + .expect(httpStatus.NO_CONTENT); + + userRecord = await userRepository.findById(createdUser.id); + expect(userRecord).to.be.undefined; + + userRecord = await userRepository.hardFindById(createdUser.id); + expect(userRecord.deletedBy.toString()).to.equal( + adminUser.id.toString() + ); + expect(userRecord.tenant.toString()).to.equal( adminUser.tenant.toString() ); + expect(userRecord.deletionTime).to.be.ok; }); }); describe("Non-Admin Users CRUD Operations", () => {});