├── .dockerignore ├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .husky ├── post-checkout ├── post-commit └── pre-commit ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── bin └── createNodejsApp.js ├── docker-compose.dev.yml ├── docker-compose.prod.yml ├── docker-compose.test.yml ├── docker-compose.yml ├── ecosystem.config.json ├── jest.config.js ├── package.json ├── src ├── app.js ├── config │ ├── config.js │ ├── logger.js │ ├── morgan.js │ ├── passport.js │ ├── roles.js │ └── tokens.js ├── controllers │ ├── auth.controller.js │ ├── index.js │ └── user.controller.js ├── docs │ ├── components.yml │ └── swaggerDef.js ├── index.js ├── middlewares │ ├── auth.js │ ├── error.js │ ├── rateLimiter.js │ └── validate.js ├── models │ ├── index.js │ ├── plugins │ │ ├── index.js │ │ ├── paginate.plugin.js │ │ └── toJSON.plugin.js │ ├── token.model.js │ └── user.model.js ├── routes │ └── v1 │ │ ├── auth.route.js │ │ ├── docs.route.js │ │ ├── index.js │ │ └── user.route.js ├── services │ ├── auth.service.js │ ├── email.service.js │ ├── index.js │ ├── token.service.js │ └── user.service.js ├── utils │ ├── ApiError.js │ ├── catchAsync.js │ └── pick.js └── validations │ ├── auth.validation.js │ ├── custom.validation.js │ ├── index.js │ └── user.validation.js ├── tests ├── fixtures │ ├── token.fixture.js │ └── user.fixture.js ├── integration │ ├── auth.test.js │ ├── docs.test.js │ └── user.test.js ├── unit │ ├── middlewares │ │ └── error.test.js │ └── models │ │ ├── plugins │ │ ├── paginate.plugin.test.js │ │ └── toJSON.plugin.test.js │ │ └── user.model.test.js └── utils │ └── setupTestDB.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Port number 2 | PORT=3000 3 | 4 | # URL of the Mongo DB 5 | MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate 6 | 7 | # JWT 8 | # JWT secret key 9 | JWT_SECRET=thisisasamplesecret 10 | # Number of minutes after which an access token expires 11 | JWT_ACCESS_EXPIRATION_MINUTES=30 12 | # Number of days after which a refresh token expires 13 | JWT_REFRESH_EXPIRATION_DAYS=30 14 | # Number of minutes after which a reset password token expires 15 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10 16 | # Number of minutes after which a verify email token expires 17 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10 18 | 19 | # SMTP configuration options for the email service 20 | # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create 21 | SMTP_HOST=email-server 22 | SMTP_PORT=587 23 | SMTP_USERNAME=email-server-username 24 | SMTP_PASSWORD=email-server-password 25 | EMAIL_FROM=support@yourapp.com 26 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jest": true 5 | }, 6 | "extends": ["airbnb-base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"], 7 | "plugins": ["jest", "security", "prettier"], 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "rules": { 12 | "no-console": "error", 13 | "func-names": "off", 14 | "no-underscore-dangle": "off", 15 | "consistent-return": "off", 16 | "jest/expect-expect": "off", 17 | "security/detect-object-injection": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Convert text file line endings to lf 2 | * text eol=lf 3 | *.js text 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # yarn error logs 5 | yarn-error.log 6 | 7 | # Environment varibales 8 | .env* 9 | !.env*.example 10 | 11 | # Code coverage 12 | coverage 13 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn install 5 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | git status 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 125 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | services: 5 | - mongodb 6 | cache: yarn 7 | branches: 8 | only: 9 | - master 10 | env: 11 | global: 12 | - PORT=3000 13 | - MONGODB_URL=mongodb://localhost:27017/node-boilerplate 14 | - JWT_SECRET=thisisasamplesecret 15 | - JWT_ACCESS_EXPIRATION_MINUTES=30 16 | - JWT_REFRESH_EXPIRATION_DAYS=30 17 | script: 18 | - yarn lint 19 | - yarn test 20 | after_success: yarn coverage:coveralls 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.7.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.6.0...v1.7.0) (2021-03-30) 6 | 7 | ### Features 8 | 9 | - add email verification feature ([#78](https://github.com/hagopj13/node-express-boilerplate/pull/78)) ([9dae3f2](https://github.com/hagopj13/node-express-boilerplate/commit/9dae3f27df371103b6a9f96924980d2d8d7ba14e)) 10 | 11 | ## [1.6.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.5.0...v1.6.0) (2020-12-27) 12 | 13 | ### Features 14 | 15 | - add script to create app using npm init ([acf6fdf](https://github.com/hagopj13/node-express-boilerplate/commit/acf6fdfd105bba476efb171f8cd92d752ecad691)) 16 | - disable docs in production ([#59](https://github.com/hagopj13/node-express-boilerplate/pull/59)) ([68d1e33](https://github.com/hagopj13/node-express-boilerplate/commit/68d1e33194c46df93fc99d6e65ecf5feeecd354b)) 17 | - add populate feature to the paginate plugin ([#45](https://github.com/hagopj13/node-express-boilerplate/pull/45)) ([9cf9535](https://github.com/hagopj13/node-express-boilerplate/commit/9cf953553556bc5060821dc630a2d2d5e12da37f)) 18 | - add nested private fields feature to the toJSON plugin ([#47](https://github.com/hagopj13/node-express-boilerplate/pull/47)) ([5ba8628](https://github.com/hagopj13/node-express-boilerplate/commit/5ba8628ea18ffc90d39f0b8bb1241bebdb6cf675)) 19 | 20 | ## [1.5.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.4.1...v1.5.0) (2020-09-28) 21 | 22 | ### Features 23 | 24 | - add sorting by multiple criteria option ([677ee12](https://github.com/hagopj13/node-express-boilerplate/commit/677ee12808ba1cf02e422498ae464159345dc76f)), closes [#29](https://github.com/hagopj13/node-express-boilerplate/issues/29) 25 | 26 | ## [1.4.1](https://github.com/hagopj13/node-express-boilerplate/compare/v1.4.0...v1.4.1) (2020-09-14) 27 | 28 | ### Bug Fixes 29 | 30 | - upgrade mongoose to solve vulnerability issue ([1650bdf](https://github.com/hagopj13/node-express-boilerplate/commit/1650bdf1bf36ce13597c0ed3503c7b4abef01ee5)) 31 | - add type to token payloads ([eb5de2c](https://github.com/hagopj13/node-express-boilerplate/commit/eb5de2c7523ac166ca933bff83ef1e87274f3478)), closes [#28](https://github.com/hagopj13/node-express-boilerplate/issues/28) 32 | 33 | ## [1.4.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.3.0...v1.4.0) (2020-08-22) 34 | 35 | ### Features 36 | 37 | - use native functions instead of Lodash ([66c9e33](https://github.com/hagopj13/node-express-boilerplate/commit/66c9e33d65c88989634fc485e89b396645670730)), closes [#18](https://github.com/hagopj13/node-express-boilerplate/issues/18) 38 | - add logout endpoint ([750feb5](https://github.com/hagopj13/node-express-boilerplate/commit/750feb5b1ddadb4da6742b445cdb1112a615ace4)), closes [#19](https://github.com/hagopj13/node-express-boilerplate/issues/19) 39 | 40 | ## [1.3.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.2.0...v1.3.0) (2020-05-17) 41 | 42 | ### Features 43 | 44 | - add toJSON custom mongoose schema plugin ([f8ba3f6](https://github.com/hagopj13/node-express-boilerplate/commit/f8ba3f619ac42f2030c358fb44095b72fb37013b)) 45 | - add paginate custom mongoose schema plugin ([97fef4c](https://github.com/hagopj13/node-express-boilerplate/commit/97fef4cac91c86e4d33e9010705775fa9f160e96)), closes [#13](https://github.com/hagopj13/node-express-boilerplate/issues/13) 46 | 47 | ## [1.2.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.3...v1.2.0) (2020-05-13) 48 | 49 | ### Features 50 | 51 | - add api documentation ([#12](https://github.com/hagopj13/node-express-boilerplate/pull/12)) ([0777889](https://github.com/hagopj13/node-express-boilerplate/commit/07778894b706ef94e35f87046db112b39b58316c)), closes [#3](https://github.com/hagopj13/node-express-boilerplate/issues/3) 52 | 53 | ### Bug Fixes 54 | 55 | - run app with a non-root user inside docker ([#10](https://github.com/hagopj13/node-express-boilerplate/pull/10)) ([1e3195d](https://github.com/hagopj13/node-express-boilerplate/commit/1e3195d547510d51804028d4ab447cbc53372e48)) 56 | 57 | ## [1.1.3](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.2...v1.1.3) (2020-03-14) 58 | 59 | ### Bug Fixes 60 | 61 | - fix vulnerability issues by upgrading dependencies ([9c15650](https://github.com/hagopj13/node-express-boilerplate/commit/9c15650acfb0d991b621abc60ba534c904fd3fd1)) 62 | 63 | ## [1.1.2](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.1...v1.1.2) (2020-02-16) 64 | 65 | ### Bug Fixes 66 | 67 | - fix issue with incorrect stack for errors that are not of type AppError ([48d1a5a](https://github.com/hagopj13/node-express-boilerplate/commit/48d1a5ada5e5fe0975a17b521d3d7a6e1f4cab3b)) 68 | 69 | ## [1.1.1](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.0...v1.1.1) (2019-12-04) 70 | 71 | ### Bug Fixes 72 | 73 | - use JWT iat as seconds from epoch instead of milliseconds ([#4](https://github.com/hagopj13/node-express-boilerplate/pull/4)) ([c4e1a84](https://github.com/hagopj13/node-express-boilerplate/commit/c4e1a8487c6d41cc20944a081a13a2a1990de0cd)) 74 | 75 | ## [1.1.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.0.0...v1.1.0) (2019-11-23) 76 | 77 | ### Features 78 | 79 | - add docker support ([3401449](https://github.com/hagopj13/node-express-boilerplate/commit/340144979cf5e84abb047a891a0b908b01af3645)), closes [#2](https://github.com/hagopj13/node-express-boilerplate/issues/2) 80 | - verify connection to email server at startup ([f38d86a](https://github.com/hagopj13/node-express-boilerplate/commit/f38d86a181f1816d720e009aa94619e25ef4bf93)) 81 | 82 | ## 1.0.0 (2019-11-22) 83 | 84 | ### Features 85 | 86 | - initial release 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at hagopj13@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you so much for taking the time to contribute. All contributions are more than welcome! 4 | 5 | ## How can I contribute? 6 | 7 | If you have an awesome new feature that you want to implement or you found a bug that you would like to fix, here are some instructions to guide you through the process: 8 | 9 | - **Create an issue** to explain and discuss the details 10 | - **Fork the repo** 11 | - **Clone the repo** and set it up (check out the [manual installation](https://github.com/hagopj13/node-express-boilerplate#manual-installation) section in README.md) 12 | - **Implement** the necessary changes 13 | - **Create tests** to keep the code coverage high 14 | - **Send a pull request** 15 | 16 | ## Guidelines 17 | 18 | ### Git commit messages 19 | 20 | - Limit the subject line to 72 characters 21 | - Capitalize the first letter of the subject line 22 | - Use the present tense ("Add feature" instead of "Added feature") 23 | - Separate the subject from the body with a blank line 24 | - Reference issues and pull requests in the body 25 | 26 | ### Coding style guide 27 | 28 | We are using ESLint to ensure a consistent code style in the project, based on [Airbnb's JS style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base). 29 | 30 | Some other ESLint plugins are also being used, such as the [Prettier](https://github.com/prettier/eslint-plugin-prettier) and [Jest](https://github.com/jest-community/eslint-plugin-jest) plugins. 31 | 32 | Please make sure that the code you are pushing conforms to the style guides mentioned above. 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | RUN mkdir -p /usr/src/node-app && chown -R node:node /usr/src/node-app 4 | 5 | WORKDIR /usr/src/node-app 6 | 7 | COPY package.json yarn.lock ./ 8 | 9 | USER node 10 | 11 | RUN yarn install --pure-lockfile 12 | 13 | COPY --chown=node:node . . 14 | 15 | EXPOSE 3000 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Hagop Jamkojian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RESTful API Node Server Boilerplate 2 | 3 | [](https://travis-ci.org/hagopj13/node-express-boilerplate) 4 | [](https://coveralls.io/github/hagopj13/node-express-boilerplate?branch=master) 5 | [](http://makeapullrequest.com) 6 | 7 | A boilerplate/starter project for quickly building RESTful APIs using Node.js, Express, and Mongoose. 8 | 9 | By running a single command, you will get a production-ready Node.js app installed and fully configured on your machine. The app comes with many built-in features, such as authentication using JWT, request validation, unit and integration tests, continuous integration, docker support, API documentation, pagination, etc. For more details, check the features list below. 10 | 11 | ## Quick Start 12 | 13 | To create a project, simply run: 14 | 15 | ```bash 16 | npx create-nodejs-express-app <project-name> 17 | ``` 18 | 19 | Or 20 | 21 | ```bash 22 | npm init nodejs-express-app <project-name> 23 | ``` 24 | 25 | ## Manual Installation 26 | 27 | If you would still prefer to do the installation manually, follow these steps: 28 | 29 | Clone the repo: 30 | 31 | ```bash 32 | git clone --depth 1 https://github.com/hagopj13/node-express-boilerplate.git 33 | cd node-express-boilerplate 34 | npx rimraf ./.git 35 | ``` 36 | 37 | Install the dependencies: 38 | 39 | ```bash 40 | yarn install 41 | ``` 42 | 43 | Set the environment variables: 44 | 45 | ```bash 46 | cp .env.example .env 47 | 48 | # open .env and modify the environment variables (if needed) 49 | ``` 50 | 51 | ## Table of Contents 52 | 53 | - [Features](#features) 54 | - [Commands](#commands) 55 | - [Environment Variables](#environment-variables) 56 | - [Project Structure](#project-structure) 57 | - [API Documentation](#api-documentation) 58 | - [Error Handling](#error-handling) 59 | - [Validation](#validation) 60 | - [Authentication](#authentication) 61 | - [Authorization](#authorization) 62 | - [Logging](#logging) 63 | - [Custom Mongoose Plugins](#custom-mongoose-plugins) 64 | - [Linting](#linting) 65 | - [Contributing](#contributing) 66 | 67 | ## Features 68 | 69 | - **NoSQL database**: [MongoDB](https://www.mongodb.com) object data modeling using [Mongoose](https://mongoosejs.com) 70 | - **Authentication and authorization**: using [passport](http://www.passportjs.org) 71 | - **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) 72 | - **Logging**: using [winston](https://github.com/winstonjs/winston) and [morgan](https://github.com/expressjs/morgan) 73 | - **Testing**: unit and integration tests using [Jest](https://jestjs.io) 74 | - **Error handling**: centralized error handling mechanism 75 | - **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) 76 | - **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) 77 | - **Dependency management**: with [Yarn](https://yarnpkg.com) 78 | - **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) 79 | - **Security**: set security HTTP headers using [helmet](https://helmetjs.github.io) 80 | - **Santizing**: sanitize request data against xss and query injection 81 | - **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) 82 | - **Compression**: gzip compression with [compression](https://github.com/expressjs/compression) 83 | - **CI**: continuous integration with [Travis CI](https://travis-ci.org) 84 | - **Docker support** 85 | - **Code coverage**: using [coveralls](https://coveralls.io) 86 | - **Code quality**: with [Codacy](https://www.codacy.com) 87 | - **Git hooks**: with [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) 88 | - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) 89 | - **Editor config**: consistent editor configuration using [EditorConfig](https://editorconfig.org) 90 | 91 | ## Commands 92 | 93 | Running locally: 94 | 95 | ```bash 96 | yarn dev 97 | ``` 98 | 99 | Running in production: 100 | 101 | ```bash 102 | yarn start 103 | ``` 104 | 105 | Testing: 106 | 107 | ```bash 108 | # run all tests 109 | yarn test 110 | 111 | # run all tests in watch mode 112 | yarn test:watch 113 | 114 | # run test coverage 115 | yarn coverage 116 | ``` 117 | 118 | Docker: 119 | 120 | ```bash 121 | # run docker container in development mode 122 | yarn docker:dev 123 | 124 | # run docker container in production mode 125 | yarn docker:prod 126 | 127 | # run all tests in a docker container 128 | yarn docker:test 129 | ``` 130 | 131 | Linting: 132 | 133 | ```bash 134 | # run ESLint 135 | yarn lint 136 | 137 | # fix ESLint errors 138 | yarn lint:fix 139 | 140 | # run prettier 141 | yarn prettier 142 | 143 | # fix prettier errors 144 | yarn prettier:fix 145 | ``` 146 | 147 | ## Environment Variables 148 | 149 | The environment variables can be found and modified in the `.env` file. They come with these default values: 150 | 151 | ```bash 152 | # Port number 153 | PORT=3000 154 | 155 | # URL of the Mongo DB 156 | MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate 157 | 158 | # JWT 159 | # JWT secret key 160 | JWT_SECRET=thisisasamplesecret 161 | # Number of minutes after which an access token expires 162 | JWT_ACCESS_EXPIRATION_MINUTES=30 163 | # Number of days after which a refresh token expires 164 | JWT_REFRESH_EXPIRATION_DAYS=30 165 | 166 | # SMTP configuration options for the email service 167 | # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create 168 | SMTP_HOST=email-server 169 | SMTP_PORT=587 170 | SMTP_USERNAME=email-server-username 171 | SMTP_PASSWORD=email-server-password 172 | EMAIL_FROM=support@yourapp.com 173 | ``` 174 | 175 | ## Project Structure 176 | 177 | ``` 178 | src\ 179 | |--config\ # Environment variables and configuration related things 180 | |--controllers\ # Route controllers (controller layer) 181 | |--docs\ # Swagger files 182 | |--middlewares\ # Custom express middlewares 183 | |--models\ # Mongoose models (data layer) 184 | |--routes\ # Routes 185 | |--services\ # Business logic (service layer) 186 | |--utils\ # Utility classes and functions 187 | |--validations\ # Request data validation schemas 188 | |--app.js # Express app 189 | |--index.js # App entry point 190 | ``` 191 | 192 | ## API Documentation 193 | 194 | To view the list of available APIs and their specifications, run the server and go to `http://localhost:3000/v1/docs` in your browser. This documentation page is automatically generated using the [swagger](https://swagger.io/) definitions written as comments in the route files. 195 | 196 | ### API Endpoints 197 | 198 | List of available routes: 199 | 200 | **Auth routes**:\ 201 | `POST /v1/auth/register` - register\ 202 | `POST /v1/auth/login` - login\ 203 | `POST /v1/auth/refresh-tokens` - refresh auth tokens\ 204 | `POST /v1/auth/forgot-password` - send reset password email\ 205 | `POST /v1/auth/reset-password` - reset password\ 206 | `POST /v1/auth/send-verification-email` - send verification email\ 207 | `POST /v1/auth/verify-email` - verify email 208 | 209 | **User routes**:\ 210 | `POST /v1/users` - create a user\ 211 | `GET /v1/users` - get all users\ 212 | `GET /v1/users/:userId` - get user\ 213 | `PATCH /v1/users/:userId` - update user\ 214 | `DELETE /v1/users/:userId` - delete user 215 | 216 | ## Error Handling 217 | 218 | The app has a centralized error handling mechanism. 219 | 220 | Controllers should try to catch the errors and forward them to the error handling middleware (by calling `next(error)`). For convenience, you can also wrap the controller inside the catchAsync utility wrapper, which forwards the error. 221 | 222 | ```javascript 223 | const catchAsync = require('../utils/catchAsync'); 224 | 225 | const controller = catchAsync(async (req, res) => { 226 | // this error will be forwarded to the error handling middleware 227 | throw new Error('Something wrong happened'); 228 | }); 229 | ``` 230 | 231 | The error handling middleware sends an error response, which has the following format: 232 | 233 | ```json 234 | { 235 | "code": 404, 236 | "message": "Not found" 237 | } 238 | ``` 239 | 240 | When running in development mode, the error response also contains the error stack. 241 | 242 | The app has a utility ApiError class to which you can attach a response code and a message, and then throw it from anywhere (catchAsync will catch it). 243 | 244 | For example, if you are trying to get a user from the DB who is not found, and you want to send a 404 error, the code should look something like: 245 | 246 | ```javascript 247 | const httpStatus = require('http-status'); 248 | const ApiError = require('../utils/ApiError'); 249 | const User = require('../models/User'); 250 | 251 | const getUser = async (userId) => { 252 | const user = await User.findById(userId); 253 | if (!user) { 254 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 255 | } 256 | }; 257 | ``` 258 | 259 | ## Validation 260 | 261 | Request data is validated using [Joi](https://joi.dev/). Check the [documentation](https://joi.dev/api/) for more details on how to write Joi validation schemas. 262 | 263 | The validation schemas are defined in the `src/validations` directory and are used in the routes by providing them as parameters to the `validate` middleware. 264 | 265 | ```javascript 266 | const express = require('express'); 267 | const validate = require('../../middlewares/validate'); 268 | const userValidation = require('../../validations/user.validation'); 269 | const userController = require('../../controllers/user.controller'); 270 | 271 | const router = express.Router(); 272 | 273 | router.post('/users', validate(userValidation.createUser), userController.createUser); 274 | ``` 275 | 276 | ## Authentication 277 | 278 | To require authentication for certain routes, you can use the `auth` middleware. 279 | 280 | ```javascript 281 | const express = require('express'); 282 | const auth = require('../../middlewares/auth'); 283 | const userController = require('../../controllers/user.controller'); 284 | 285 | const router = express.Router(); 286 | 287 | router.post('/users', auth(), userController.createUser); 288 | ``` 289 | 290 | These routes require a valid JWT access token in the Authorization request header using the Bearer schema. If the request does not contain a valid access token, an Unauthorized (401) error is thrown. 291 | 292 | **Generating Access Tokens**: 293 | 294 | An access token can be generated by making a successful call to the register (`POST /v1/auth/register`) or login (`POST /v1/auth/login`) endpoints. The response of these endpoints also contains refresh tokens (explained below). 295 | 296 | An access token is valid for 30 minutes. You can modify this expiration time by changing the `JWT_ACCESS_EXPIRATION_MINUTES` environment variable in the .env file. 297 | 298 | **Refreshing Access Tokens**: 299 | 300 | After the access token expires, a new access token can be generated, by making a call to the refresh token endpoint (`POST /v1/auth/refresh-tokens`) and sending along a valid refresh token in the request body. This call returns a new access token and a new refresh token. 301 | 302 | A refresh token is valid for 30 days. You can modify this expiration time by changing the `JWT_REFRESH_EXPIRATION_DAYS` environment variable in the .env file. 303 | 304 | ## Authorization 305 | 306 | The `auth` middleware can also be used to require certain rights/permissions to access a route. 307 | 308 | ```javascript 309 | const express = require('express'); 310 | const auth = require('../../middlewares/auth'); 311 | const userController = require('../../controllers/user.controller'); 312 | 313 | const router = express.Router(); 314 | 315 | router.post('/users', auth('manageUsers'), userController.createUser); 316 | ``` 317 | 318 | In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. 319 | 320 | The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. 321 | 322 | If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. 323 | 324 | ## Logging 325 | 326 | Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. 327 | 328 | Logging should be done according to the following severity levels (ascending order from most important to least important): 329 | 330 | ```javascript 331 | const logger = require('<path to src>/config/logger'); 332 | 333 | logger.error('message'); // level 0 334 | logger.warn('message'); // level 1 335 | logger.info('message'); // level 2 336 | logger.http('message'); // level 3 337 | logger.verbose('message'); // level 4 338 | logger.debug('message'); // level 5 339 | ``` 340 | 341 | In development mode, log messages of all severity levels will be printed to the console. 342 | 343 | In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ 344 | It is up to the server (or process manager) to actually read them from the console and store them in log files.\ 345 | This app uses pm2 in production mode, which is already configured to store the logs in log files. 346 | 347 | Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). 348 | 349 | ## Custom Mongoose Plugins 350 | 351 | The app also contains 2 custom mongoose plugins that you can attach to any mongoose model schema. You can find the plugins in `src/models/plugins`. 352 | 353 | ```javascript 354 | const mongoose = require('mongoose'); 355 | const { toJSON, paginate } = require('./plugins'); 356 | 357 | const userSchema = mongoose.Schema( 358 | { 359 | /* schema definition here */ 360 | }, 361 | { timestamps: true } 362 | ); 363 | 364 | userSchema.plugin(toJSON); 365 | userSchema.plugin(paginate); 366 | 367 | const User = mongoose.model('User', userSchema); 368 | ``` 369 | 370 | ### toJSON 371 | 372 | The toJSON plugin applies the following changes in the toJSON transform call: 373 | 374 | - removes \_\_v, createdAt, updatedAt, and any schema path that has private: true 375 | - replaces \_id with id 376 | 377 | ### paginate 378 | 379 | The paginate plugin adds the `paginate` static method to the mongoose schema. 380 | 381 | Adding this plugin to the `User` model schema will allow you to do the following: 382 | 383 | ```javascript 384 | const queryUsers = async (filter, options) => { 385 | const users = await User.paginate(filter, options); 386 | return users; 387 | }; 388 | ``` 389 | 390 | The `filter` param is a regular mongo filter. 391 | 392 | The `options` param can have the following (optional) fields: 393 | 394 | ```javascript 395 | const options = { 396 | sortBy: 'name:desc', // sort order 397 | limit: 5, // maximum results per page 398 | page: 2, // page number 399 | }; 400 | ``` 401 | 402 | The plugin also supports sorting by multiple criteria (separated by a comma): `sortBy: name:desc,role:asc` 403 | 404 | The `paginate` method returns a Promise, which fulfills with an object having the following properties: 405 | 406 | ```json 407 | { 408 | "results": [], 409 | "page": 2, 410 | "limit": 5, 411 | "totalPages": 10, 412 | "totalResults": 48 413 | } 414 | ``` 415 | 416 | ## Linting 417 | 418 | Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). 419 | 420 | In this app, ESLint is configured to follow the [Airbnb JavaScript style guide](https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb-base) with some modifications. It also extends [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) to turn off all rules that are unnecessary or might conflict with Prettier. 421 | 422 | To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. 423 | 424 | To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. 425 | 426 | To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` 427 | 428 | ## Contributing 429 | 430 | Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). 431 | 432 | ## Inspirations 433 | 434 | - [danielfsousa/express-rest-es2017-boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate) 435 | - [madhums/node-express-mongoose](https://github.com/madhums/node-express-mongoose) 436 | - [kunalkapadia/express-mongoose-es6-rest-api](https://github.com/kunalkapadia/express-mongoose-es6-rest-api) 437 | 438 | ## License 439 | 440 | [MIT](LICENSE) 441 | -------------------------------------------------------------------------------- /bin/createNodejsApp.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const util = require('util'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const { execSync } = require('child_process'); 6 | 7 | // Utility functions 8 | const exec = util.promisify(require('child_process').exec); 9 | async function runCmd(command) { 10 | try { 11 | const { stdout, stderr } = await exec(command); 12 | console.log(stdout); 13 | console.log(stderr); 14 | } catch { 15 | (error) => { 16 | console.log(error); 17 | }; 18 | } 19 | } 20 | 21 | async function hasYarn() { 22 | try { 23 | await execSync('yarnpkg --version', { stdio: 'ignore' }); 24 | return true; 25 | } catch { 26 | return false; 27 | } 28 | } 29 | 30 | // Validate arguments 31 | if (process.argv.length < 3) { 32 | console.log('Please specify the target project directory.'); 33 | console.log('For example:'); 34 | console.log(' npx create-nodejs-app my-app'); 35 | console.log(' OR'); 36 | console.log(' npm init nodejs-app my-app'); 37 | process.exit(1); 38 | } 39 | 40 | // Define constants 41 | const ownPath = process.cwd(); 42 | const folderName = process.argv[2]; 43 | const appPath = path.join(ownPath, folderName); 44 | const repo = 'https://github.com/hagopj13/node-express-boilerplate.git'; 45 | 46 | // Check if directory already exists 47 | try { 48 | fs.mkdirSync(appPath); 49 | } catch (err) { 50 | if (err.code === 'EEXIST') { 51 | console.log('Directory already exists. Please choose another name for the project.'); 52 | } else { 53 | console.log(err); 54 | } 55 | process.exit(1); 56 | } 57 | 58 | async function setup() { 59 | try { 60 | // Clone repo 61 | console.log(`Downloading files from repo ${repo}`); 62 | await runCmd(`git clone --depth 1 ${repo} ${folderName}`); 63 | console.log('Cloned successfully.'); 64 | console.log(''); 65 | 66 | // Change directory 67 | process.chdir(appPath); 68 | 69 | // Install dependencies 70 | const useYarn = await hasYarn(); 71 | console.log('Installing dependencies...'); 72 | if (useYarn) { 73 | await runCmd('yarn install'); 74 | } else { 75 | await runCmd('npm install'); 76 | } 77 | console.log('Dependencies installed successfully.'); 78 | console.log(); 79 | 80 | // Copy envornment variables 81 | fs.copyFileSync(path.join(appPath, '.env.example'), path.join(appPath, '.env')); 82 | console.log('Environment files copied.'); 83 | 84 | // Delete .git folder 85 | await runCmd('npx rimraf ./.git'); 86 | 87 | // Remove extra files 88 | fs.unlinkSync(path.join(appPath, 'CHANGELOG.md')); 89 | fs.unlinkSync(path.join(appPath, 'CODE_OF_CONDUCT.md')); 90 | fs.unlinkSync(path.join(appPath, 'CONTRIBUTING.md')); 91 | fs.unlinkSync(path.join(appPath, 'bin', 'createNodejsApp.js')); 92 | fs.rmdirSync(path.join(appPath, 'bin')); 93 | if (!useYarn) { 94 | fs.unlinkSync(path.join(appPath, 'yarn.lock')); 95 | } 96 | 97 | console.log('Installation is now complete!'); 98 | console.log(); 99 | 100 | console.log('We suggest that you start by typing:'); 101 | console.log(` cd ${folderName}`); 102 | console.log(useYarn ? ' yarn dev' : ' npm run dev'); 103 | console.log(); 104 | console.log('Enjoy your production-ready Node.js app, which already supports a large number of ready-made features!'); 105 | console.log('Check README.md for more info.'); 106 | } catch (error) { 107 | console.log(error); 108 | } 109 | } 110 | 111 | setup(); 112 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node-app: 5 | container_name: node-app-dev 6 | command: yarn dev -L 7 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node-app: 5 | container_name: node-app-prod 6 | command: yarn start 7 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node-app: 5 | container_name: node-app-test 6 | command: yarn test 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node-app: 5 | build: . 6 | image: node-app 7 | environment: 8 | - MONGODB_URL=mongodb://mongodb:27017/node-boilerplate 9 | ports: 10 | - '3000:3000' 11 | depends_on: 12 | - mongodb 13 | volumes: 14 | - .:/usr/src/node-app 15 | networks: 16 | - node-network 17 | 18 | mongodb: 19 | image: mongo:4.2.1-bionic 20 | ports: 21 | - '27017:27017' 22 | volumes: 23 | - dbdata:/data/db 24 | networks: 25 | - node-network 26 | 27 | volumes: 28 | dbdata: 29 | 30 | networks: 31 | node-network: 32 | driver: bridge 33 | -------------------------------------------------------------------------------- /ecosystem.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "app", 5 | "script": "src/index.js", 6 | "instances": 1, 7 | "autorestart": true, 8 | "watch": false, 9 | "time": true, 10 | "env": { 11 | "NODE_ENV": "production" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testEnvironmentOptions: { 4 | NODE_ENV: 'test', 5 | }, 6 | restoreMocks: true, 7 | coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.js', 'tests'], 8 | coverageReporters: ['text', 'lcov', 'clover', 'html'], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-nodejs-express-app", 3 | "version": "1.7.0", 4 | "description": "Create a Node.js app for building production-ready RESTful APIs using Express, by running one command", 5 | "bin": "bin/createNodejsApp.js", 6 | "main": "src/index.js", 7 | "repository": "https://github.com/hagopj13/node-express-boilerplate.git", 8 | "author": "Hagop Jamkojian <hagopj13@gmail.com>", 9 | "license": "MIT", 10 | "engines": { 11 | "node": ">=12.0.0" 12 | }, 13 | "scripts": { 14 | "start": "pm2 start ecosystem.config.json --no-daemon", 15 | "dev": "cross-env NODE_ENV=development nodemon src/index.js", 16 | "test": "jest -i --colors --verbose --detectOpenHandles", 17 | "test:watch": "jest -i --watchAll", 18 | "coverage": "jest -i --coverage", 19 | "coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls", 20 | "lint": "eslint .", 21 | "lint:fix": "eslint . --fix", 22 | "prettier": "prettier --check **/*.js", 23 | "prettier:fix": "prettier --write **/*.js", 24 | "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", 25 | "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", 26 | "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up", 27 | "prepare": "husky install" 28 | }, 29 | "keywords": [ 30 | "node", 31 | "node.js", 32 | "boilerplate", 33 | "generator", 34 | "express", 35 | "rest", 36 | "api", 37 | "mongodb", 38 | "mongoose", 39 | "es6", 40 | "es7", 41 | "es8", 42 | "es9", 43 | "jest", 44 | "travis", 45 | "docker", 46 | "passport", 47 | "joi", 48 | "eslint", 49 | "prettier" 50 | ], 51 | "dependencies": { 52 | "bcryptjs": "^2.4.3", 53 | "compression": "^1.7.4", 54 | "cors": "^2.8.5", 55 | "cross-env": "^7.0.0", 56 | "dotenv": "^10.0.0", 57 | "express": "^4.17.1", 58 | "express-mongo-sanitize": "^2.0.0", 59 | "express-rate-limit": "^5.0.0", 60 | "helmet": "^4.1.0", 61 | "http-status": "^1.4.0", 62 | "joi": "^17.3.0", 63 | "jsonwebtoken": "^8.5.1", 64 | "moment": "^2.24.0", 65 | "mongoose": "^5.7.7", 66 | "morgan": "^1.9.1", 67 | "nodemailer": "^6.3.1", 68 | "passport": "^0.4.0", 69 | "passport-jwt": "^4.0.0", 70 | "pm2": "^5.1.0", 71 | "swagger-jsdoc": "^6.0.8", 72 | "swagger-ui-express": "^4.1.6", 73 | "validator": "^13.0.0", 74 | "winston": "^3.2.1", 75 | "xss-clean": "^0.1.1" 76 | }, 77 | "devDependencies": { 78 | "coveralls": "^3.0.7", 79 | "eslint": "^7.0.0", 80 | "eslint-config-airbnb-base": "^14.0.0", 81 | "eslint-config-prettier": "^8.1.0", 82 | "eslint-plugin-import": "^2.18.2", 83 | "eslint-plugin-jest": "^24.0.1", 84 | "eslint-plugin-prettier": "^3.1.1", 85 | "eslint-plugin-security": "^1.4.0", 86 | "faker": "^5.1.0", 87 | "husky": "7.0.4", 88 | "jest": "^26.0.1", 89 | "lint-staged": "^11.0.0", 90 | "node-mocks-http": "^1.8.0", 91 | "nodemon": "^2.0.0", 92 | "prettier": "^2.0.5", 93 | "supertest": "^6.0.1" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const helmet = require('helmet'); 3 | const xss = require('xss-clean'); 4 | const mongoSanitize = require('express-mongo-sanitize'); 5 | const compression = require('compression'); 6 | const cors = require('cors'); 7 | const passport = require('passport'); 8 | const httpStatus = require('http-status'); 9 | const config = require('./config/config'); 10 | const morgan = require('./config/morgan'); 11 | const { jwtStrategy } = require('./config/passport'); 12 | const { authLimiter } = require('./middlewares/rateLimiter'); 13 | const routes = require('./routes/v1'); 14 | const { errorConverter, errorHandler } = require('./middlewares/error'); 15 | const ApiError = require('./utils/ApiError'); 16 | 17 | const app = express(); 18 | 19 | if (config.env !== 'test') { 20 | app.use(morgan.successHandler); 21 | app.use(morgan.errorHandler); 22 | } 23 | 24 | // set security HTTP headers 25 | app.use(helmet()); 26 | 27 | // parse json request body 28 | app.use(express.json()); 29 | 30 | // parse urlencoded request body 31 | app.use(express.urlencoded({ extended: true })); 32 | 33 | // sanitize request data 34 | app.use(xss()); 35 | app.use(mongoSanitize()); 36 | 37 | // gzip compression 38 | app.use(compression()); 39 | 40 | // enable cors 41 | app.use(cors()); 42 | app.options('*', cors()); 43 | 44 | // jwt authentication 45 | app.use(passport.initialize()); 46 | passport.use('jwt', jwtStrategy); 47 | 48 | // limit repeated failed requests to auth endpoints 49 | if (config.env === 'production') { 50 | app.use('/v1/auth', authLimiter); 51 | } 52 | 53 | // v1 api routes 54 | app.use('/v1', routes); 55 | 56 | // send back a 404 error for any unknown api request 57 | app.use((req, res, next) => { 58 | next(new ApiError(httpStatus.NOT_FOUND, 'Not found')); 59 | }); 60 | 61 | // convert error to ApiError, if needed 62 | app.use(errorConverter); 63 | 64 | // handle error 65 | app.use(errorHandler); 66 | 67 | module.exports = app; 68 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const path = require('path'); 3 | const Joi = require('joi'); 4 | 5 | dotenv.config({ path: path.join(__dirname, '../../.env') }); 6 | 7 | const envVarsSchema = Joi.object() 8 | .keys({ 9 | NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), 10 | PORT: Joi.number().default(3000), 11 | MONGODB_URL: Joi.string().required().description('Mongo DB url'), 12 | JWT_SECRET: Joi.string().required().description('JWT secret key'), 13 | JWT_ACCESS_EXPIRATION_MINUTES: Joi.number().default(30).description('minutes after which access tokens expire'), 14 | JWT_REFRESH_EXPIRATION_DAYS: Joi.number().default(30).description('days after which refresh tokens expire'), 15 | JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number() 16 | .default(10) 17 | .description('minutes after which reset password token expires'), 18 | JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number() 19 | .default(10) 20 | .description('minutes after which verify email token expires'), 21 | SMTP_HOST: Joi.string().description('server that will send the emails'), 22 | SMTP_PORT: Joi.number().description('port to connect to the email server'), 23 | SMTP_USERNAME: Joi.string().description('username for email server'), 24 | SMTP_PASSWORD: Joi.string().description('password for email server'), 25 | EMAIL_FROM: Joi.string().description('the from field in the emails sent by the app'), 26 | }) 27 | .unknown(); 28 | 29 | const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env); 30 | 31 | if (error) { 32 | throw new Error(`Config validation error: ${error.message}`); 33 | } 34 | 35 | module.exports = { 36 | env: envVars.NODE_ENV, 37 | port: envVars.PORT, 38 | mongoose: { 39 | url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''), 40 | options: { 41 | useCreateIndex: true, 42 | useNewUrlParser: true, 43 | useUnifiedTopology: true, 44 | }, 45 | }, 46 | jwt: { 47 | secret: envVars.JWT_SECRET, 48 | accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, 49 | refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, 50 | resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, 51 | verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES, 52 | }, 53 | email: { 54 | smtp: { 55 | host: envVars.SMTP_HOST, 56 | port: envVars.SMTP_PORT, 57 | auth: { 58 | user: envVars.SMTP_USERNAME, 59 | pass: envVars.SMTP_PASSWORD, 60 | }, 61 | }, 62 | from: envVars.EMAIL_FROM, 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/config/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const config = require('./config'); 3 | 4 | const enumerateErrorFormat = winston.format((info) => { 5 | if (info instanceof Error) { 6 | Object.assign(info, { message: info.stack }); 7 | } 8 | return info; 9 | }); 10 | 11 | const logger = winston.createLogger({ 12 | level: config.env === 'development' ? 'debug' : 'info', 13 | format: winston.format.combine( 14 | enumerateErrorFormat(), 15 | config.env === 'development' ? winston.format.colorize() : winston.format.uncolorize(), 16 | winston.format.splat(), 17 | winston.format.printf(({ level, message }) => `${level}: ${message}`) 18 | ), 19 | transports: [ 20 | new winston.transports.Console({ 21 | stderrLevels: ['error'], 22 | }), 23 | ], 24 | }); 25 | 26 | module.exports = logger; 27 | -------------------------------------------------------------------------------- /src/config/morgan.js: -------------------------------------------------------------------------------- 1 | const morgan = require('morgan'); 2 | const config = require('./config'); 3 | const logger = require('./logger'); 4 | 5 | morgan.token('message', (req, res) => res.locals.errorMessage || ''); 6 | 7 | const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : ''); 8 | const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`; 9 | const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`; 10 | 11 | const successHandler = morgan(successResponseFormat, { 12 | skip: (req, res) => res.statusCode >= 400, 13 | stream: { write: (message) => logger.info(message.trim()) }, 14 | }); 15 | 16 | const errorHandler = morgan(errorResponseFormat, { 17 | skip: (req, res) => res.statusCode < 400, 18 | stream: { write: (message) => logger.error(message.trim()) }, 19 | }); 20 | 21 | module.exports = { 22 | successHandler, 23 | errorHandler, 24 | }; 25 | -------------------------------------------------------------------------------- /src/config/passport.js: -------------------------------------------------------------------------------- 1 | const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); 2 | const config = require('./config'); 3 | const { tokenTypes } = require('./tokens'); 4 | const { User } = require('../models'); 5 | 6 | const jwtOptions = { 7 | secretOrKey: config.jwt.secret, 8 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 9 | }; 10 | 11 | const jwtVerify = async (payload, done) => { 12 | try { 13 | if (payload.type !== tokenTypes.ACCESS) { 14 | throw new Error('Invalid token type'); 15 | } 16 | const user = await User.findById(payload.sub); 17 | if (!user) { 18 | return done(null, false); 19 | } 20 | done(null, user); 21 | } catch (error) { 22 | done(error, false); 23 | } 24 | }; 25 | 26 | const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify); 27 | 28 | module.exports = { 29 | jwtStrategy, 30 | }; 31 | -------------------------------------------------------------------------------- /src/config/roles.js: -------------------------------------------------------------------------------- 1 | const allRoles = { 2 | user: [], 3 | admin: ['getUsers', 'manageUsers'], 4 | }; 5 | 6 | const roles = Object.keys(allRoles); 7 | const roleRights = new Map(Object.entries(allRoles)); 8 | 9 | module.exports = { 10 | roles, 11 | roleRights, 12 | }; 13 | -------------------------------------------------------------------------------- /src/config/tokens.js: -------------------------------------------------------------------------------- 1 | const tokenTypes = { 2 | ACCESS: 'access', 3 | REFRESH: 'refresh', 4 | RESET_PASSWORD: 'resetPassword', 5 | VERIFY_EMAIL: 'verifyEmail', 6 | }; 7 | 8 | module.exports = { 9 | tokenTypes, 10 | }; 11 | -------------------------------------------------------------------------------- /src/controllers/auth.controller.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const catchAsync = require('../utils/catchAsync'); 3 | const { authService, userService, tokenService, emailService } = require('../services'); 4 | 5 | const register = catchAsync(async (req, res) => { 6 | const user = await userService.createUser(req.body); 7 | const tokens = await tokenService.generateAuthTokens(user); 8 | res.status(httpStatus.CREATED).send({ user, tokens }); 9 | }); 10 | 11 | const login = catchAsync(async (req, res) => { 12 | const { email, password } = req.body; 13 | const user = await authService.loginUserWithEmailAndPassword(email, password); 14 | const tokens = await tokenService.generateAuthTokens(user); 15 | res.send({ user, tokens }); 16 | }); 17 | 18 | const logout = catchAsync(async (req, res) => { 19 | await authService.logout(req.body.refreshToken); 20 | res.status(httpStatus.NO_CONTENT).send(); 21 | }); 22 | 23 | const refreshTokens = catchAsync(async (req, res) => { 24 | const tokens = await authService.refreshAuth(req.body.refreshToken); 25 | res.send({ ...tokens }); 26 | }); 27 | 28 | const forgotPassword = catchAsync(async (req, res) => { 29 | const resetPasswordToken = await tokenService.generateResetPasswordToken(req.body.email); 30 | await emailService.sendResetPasswordEmail(req.body.email, resetPasswordToken); 31 | res.status(httpStatus.NO_CONTENT).send(); 32 | }); 33 | 34 | const resetPassword = catchAsync(async (req, res) => { 35 | await authService.resetPassword(req.query.token, req.body.password); 36 | res.status(httpStatus.NO_CONTENT).send(); 37 | }); 38 | 39 | const sendVerificationEmail = catchAsync(async (req, res) => { 40 | const verifyEmailToken = await tokenService.generateVerifyEmailToken(req.user); 41 | await emailService.sendVerificationEmail(req.user.email, verifyEmailToken); 42 | res.status(httpStatus.NO_CONTENT).send(); 43 | }); 44 | 45 | const verifyEmail = catchAsync(async (req, res) => { 46 | await authService.verifyEmail(req.query.token); 47 | res.status(httpStatus.NO_CONTENT).send(); 48 | }); 49 | 50 | module.exports = { 51 | register, 52 | login, 53 | logout, 54 | refreshTokens, 55 | forgotPassword, 56 | resetPassword, 57 | sendVerificationEmail, 58 | verifyEmail, 59 | }; 60 | -------------------------------------------------------------------------------- /src/controllers/index.js: -------------------------------------------------------------------------------- 1 | module.exports.authController = require('./auth.controller'); 2 | module.exports.userController = require('./user.controller'); 3 | -------------------------------------------------------------------------------- /src/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const pick = require('../utils/pick'); 3 | const ApiError = require('../utils/ApiError'); 4 | const catchAsync = require('../utils/catchAsync'); 5 | const { userService } = require('../services'); 6 | 7 | const createUser = catchAsync(async (req, res) => { 8 | const user = await userService.createUser(req.body); 9 | res.status(httpStatus.CREATED).send(user); 10 | }); 11 | 12 | const getUsers = catchAsync(async (req, res) => { 13 | const filter = pick(req.query, ['name', 'role']); 14 | const options = pick(req.query, ['sortBy', 'limit', 'page']); 15 | const result = await userService.queryUsers(filter, options); 16 | res.send(result); 17 | }); 18 | 19 | const getUser = catchAsync(async (req, res) => { 20 | const user = await userService.getUserById(req.params.userId); 21 | if (!user) { 22 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 23 | } 24 | res.send(user); 25 | }); 26 | 27 | const updateUser = catchAsync(async (req, res) => { 28 | const user = await userService.updateUserById(req.params.userId, req.body); 29 | res.send(user); 30 | }); 31 | 32 | const deleteUser = catchAsync(async (req, res) => { 33 | await userService.deleteUserById(req.params.userId); 34 | res.status(httpStatus.NO_CONTENT).send(); 35 | }); 36 | 37 | module.exports = { 38 | createUser, 39 | getUsers, 40 | getUser, 41 | updateUser, 42 | deleteUser, 43 | }; 44 | -------------------------------------------------------------------------------- /src/docs/components.yml: -------------------------------------------------------------------------------- 1 | components: 2 | schemas: 3 | User: 4 | type: object 5 | properties: 6 | id: 7 | type: string 8 | email: 9 | type: string 10 | format: email 11 | name: 12 | type: string 13 | role: 14 | type: string 15 | enum: [user, admin] 16 | example: 17 | id: 5ebac534954b54139806c112 18 | email: fake@example.com 19 | name: fake name 20 | role: user 21 | 22 | Token: 23 | type: object 24 | properties: 25 | token: 26 | type: string 27 | expires: 28 | type: string 29 | format: date-time 30 | example: 31 | token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 32 | expires: 2020-05-12T16:18:04.793Z 33 | 34 | AuthTokens: 35 | type: object 36 | properties: 37 | access: 38 | $ref: '#/components/schemas/Token' 39 | refresh: 40 | $ref: '#/components/schemas/Token' 41 | 42 | Error: 43 | type: object 44 | properties: 45 | code: 46 | type: number 47 | message: 48 | type: string 49 | 50 | responses: 51 | DuplicateEmail: 52 | description: Email already taken 53 | content: 54 | application/json: 55 | schema: 56 | $ref: '#/components/schemas/Error' 57 | example: 58 | code: 400 59 | message: Email already taken 60 | Unauthorized: 61 | description: Unauthorized 62 | content: 63 | application/json: 64 | schema: 65 | $ref: '#/components/schemas/Error' 66 | example: 67 | code: 401 68 | message: Please authenticate 69 | Forbidden: 70 | description: Forbidden 71 | content: 72 | application/json: 73 | schema: 74 | $ref: '#/components/schemas/Error' 75 | example: 76 | code: 403 77 | message: Forbidden 78 | NotFound: 79 | description: Not found 80 | content: 81 | application/json: 82 | schema: 83 | $ref: '#/components/schemas/Error' 84 | example: 85 | code: 404 86 | message: Not found 87 | 88 | securitySchemes: 89 | bearerAuth: 90 | type: http 91 | scheme: bearer 92 | bearerFormat: JWT 93 | -------------------------------------------------------------------------------- /src/docs/swaggerDef.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../../package.json'); 2 | const config = require('../config/config'); 3 | 4 | const swaggerDef = { 5 | openapi: '3.0.0', 6 | info: { 7 | title: 'node-express-boilerplate API documentation', 8 | version, 9 | license: { 10 | name: 'MIT', 11 | url: 'https://github.com/hagopj13/node-express-boilerplate/blob/master/LICENSE', 12 | }, 13 | }, 14 | servers: [ 15 | { 16 | url: `http://localhost:${config.port}/v1`, 17 | }, 18 | ], 19 | }; 20 | 21 | module.exports = swaggerDef; 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const app = require('./app'); 3 | const config = require('./config/config'); 4 | const logger = require('./config/logger'); 5 | 6 | let server; 7 | mongoose.connect(config.mongoose.url, config.mongoose.options).then(() => { 8 | logger.info('Connected to MongoDB'); 9 | server = app.listen(config.port, () => { 10 | logger.info(`Listening to port ${config.port}`); 11 | }); 12 | }); 13 | 14 | const exitHandler = () => { 15 | if (server) { 16 | server.close(() => { 17 | logger.info('Server closed'); 18 | process.exit(1); 19 | }); 20 | } else { 21 | process.exit(1); 22 | } 23 | }; 24 | 25 | const unexpectedErrorHandler = (error) => { 26 | logger.error(error); 27 | exitHandler(); 28 | }; 29 | 30 | process.on('uncaughtException', unexpectedErrorHandler); 31 | process.on('unhandledRejection', unexpectedErrorHandler); 32 | 33 | process.on('SIGTERM', () => { 34 | logger.info('SIGTERM received'); 35 | if (server) { 36 | server.close(); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const httpStatus = require('http-status'); 3 | const ApiError = require('../utils/ApiError'); 4 | const { roleRights } = require('../config/roles'); 5 | 6 | const verifyCallback = (req, resolve, reject, requiredRights) => async (err, user, info) => { 7 | if (err || info || !user) { 8 | return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); 9 | } 10 | req.user = user; 11 | 12 | if (requiredRights.length) { 13 | const userRights = roleRights.get(user.role); 14 | const hasRequiredRights = requiredRights.every((requiredRight) => userRights.includes(requiredRight)); 15 | if (!hasRequiredRights && req.params.userId !== user.id) { 16 | return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden')); 17 | } 18 | } 19 | 20 | resolve(); 21 | }; 22 | 23 | const auth = (...requiredRights) => async (req, res, next) => { 24 | return new Promise((resolve, reject) => { 25 | passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject, requiredRights))(req, res, next); 26 | }) 27 | .then(() => next()) 28 | .catch((err) => next(err)); 29 | }; 30 | 31 | module.exports = auth; 32 | -------------------------------------------------------------------------------- /src/middlewares/error.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const httpStatus = require('http-status'); 3 | const config = require('../config/config'); 4 | const logger = require('../config/logger'); 5 | const ApiError = require('../utils/ApiError'); 6 | 7 | const errorConverter = (err, req, res, next) => { 8 | let error = err; 9 | if (!(error instanceof ApiError)) { 10 | const statusCode = 11 | error.statusCode || error instanceof mongoose.Error ? httpStatus.BAD_REQUEST : httpStatus.INTERNAL_SERVER_ERROR; 12 | const message = error.message || httpStatus[statusCode]; 13 | error = new ApiError(statusCode, message, false, err.stack); 14 | } 15 | next(error); 16 | }; 17 | 18 | // eslint-disable-next-line no-unused-vars 19 | const errorHandler = (err, req, res, next) => { 20 | let { statusCode, message } = err; 21 | if (config.env === 'production' && !err.isOperational) { 22 | statusCode = httpStatus.INTERNAL_SERVER_ERROR; 23 | message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR]; 24 | } 25 | 26 | res.locals.errorMessage = err.message; 27 | 28 | const response = { 29 | code: statusCode, 30 | message, 31 | ...(config.env === 'development' && { stack: err.stack }), 32 | }; 33 | 34 | if (config.env === 'development') { 35 | logger.error(err); 36 | } 37 | 38 | res.status(statusCode).send(response); 39 | }; 40 | 41 | module.exports = { 42 | errorConverter, 43 | errorHandler, 44 | }; 45 | -------------------------------------------------------------------------------- /src/middlewares/rateLimiter.js: -------------------------------------------------------------------------------- 1 | const rateLimit = require('express-rate-limit'); 2 | 3 | const authLimiter = rateLimit({ 4 | windowMs: 15 * 60 * 1000, 5 | max: 20, 6 | skipSuccessfulRequests: true, 7 | }); 8 | 9 | module.exports = { 10 | authLimiter, 11 | }; 12 | -------------------------------------------------------------------------------- /src/middlewares/validate.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const httpStatus = require('http-status'); 3 | const pick = require('../utils/pick'); 4 | const ApiError = require('../utils/ApiError'); 5 | 6 | const validate = (schema) => (req, res, next) => { 7 | const validSchema = pick(schema, ['params', 'query', 'body']); 8 | const object = pick(req, Object.keys(validSchema)); 9 | const { value, error } = Joi.compile(validSchema) 10 | .prefs({ errors: { label: 'key' }, abortEarly: false }) 11 | .validate(object); 12 | 13 | if (error) { 14 | const errorMessage = error.details.map((details) => details.message).join(', '); 15 | return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); 16 | } 17 | Object.assign(req, value); 18 | return next(); 19 | }; 20 | 21 | module.exports = validate; 22 | -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | module.exports.Token = require('./token.model'); 2 | module.exports.User = require('./user.model'); 3 | -------------------------------------------------------------------------------- /src/models/plugins/index.js: -------------------------------------------------------------------------------- 1 | module.exports.toJSON = require('./toJSON.plugin'); 2 | module.exports.paginate = require('./paginate.plugin'); 3 | -------------------------------------------------------------------------------- /src/models/plugins/paginate.plugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | const paginate = (schema) => { 4 | /** 5 | * @typedef {Object} QueryResult 6 | * @property {Document[]} results - Results found 7 | * @property {number} page - Current page 8 | * @property {number} limit - Maximum number of results per page 9 | * @property {number} totalPages - Total number of pages 10 | * @property {number} totalResults - Total number of documents 11 | */ 12 | /** 13 | * Query for documents with pagination 14 | * @param {Object} [filter] - Mongo filter 15 | * @param {Object} [options] - Query options 16 | * @param {string} [options.sortBy] - Sorting criteria using the format: sortField:(desc|asc). Multiple sorting criteria should be separated by commas (,) 17 | * @param {string} [options.populate] - Populate data fields. Hierarchy of fields should be separated by (.). Multiple populating criteria should be separated by commas (,) 18 | * @param {number} [options.limit] - Maximum number of results per page (default = 10) 19 | * @param {number} [options.page] - Current page (default = 1) 20 | * @returns {Promise<QueryResult>} 21 | */ 22 | schema.statics.paginate = async function (filter, options) { 23 | let sort = ''; 24 | if (options.sortBy) { 25 | const sortingCriteria = []; 26 | options.sortBy.split(',').forEach((sortOption) => { 27 | const [key, order] = sortOption.split(':'); 28 | sortingCriteria.push((order === 'desc' ? '-' : '') + key); 29 | }); 30 | sort = sortingCriteria.join(' '); 31 | } else { 32 | sort = 'createdAt'; 33 | } 34 | 35 | const limit = options.limit && parseInt(options.limit, 10) > 0 ? parseInt(options.limit, 10) : 10; 36 | const page = options.page && parseInt(options.page, 10) > 0 ? parseInt(options.page, 10) : 1; 37 | const skip = (page - 1) * limit; 38 | 39 | const countPromise = this.countDocuments(filter).exec(); 40 | let docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit); 41 | 42 | if (options.populate) { 43 | options.populate.split(',').forEach((populateOption) => { 44 | docsPromise = docsPromise.populate( 45 | populateOption 46 | .split('.') 47 | .reverse() 48 | .reduce((a, b) => ({ path: b, populate: a })) 49 | ); 50 | }); 51 | } 52 | 53 | docsPromise = docsPromise.exec(); 54 | 55 | return Promise.all([countPromise, docsPromise]).then((values) => { 56 | const [totalResults, results] = values; 57 | const totalPages = Math.ceil(totalResults / limit); 58 | const result = { 59 | results, 60 | page, 61 | limit, 62 | totalPages, 63 | totalResults, 64 | }; 65 | return Promise.resolve(result); 66 | }); 67 | }; 68 | }; 69 | 70 | module.exports = paginate; 71 | -------------------------------------------------------------------------------- /src/models/plugins/toJSON.plugin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | 3 | /** 4 | * A mongoose schema plugin which applies the following in the toJSON transform call: 5 | * - removes __v, createdAt, updatedAt, and any path that has private: true 6 | * - replaces _id with id 7 | */ 8 | 9 | const deleteAtPath = (obj, path, index) => { 10 | if (index === path.length - 1) { 11 | delete obj[path[index]]; 12 | return; 13 | } 14 | deleteAtPath(obj[path[index]], path, index + 1); 15 | }; 16 | 17 | const toJSON = (schema) => { 18 | let transform; 19 | if (schema.options.toJSON && schema.options.toJSON.transform) { 20 | transform = schema.options.toJSON.transform; 21 | } 22 | 23 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { 24 | transform(doc, ret, options) { 25 | Object.keys(schema.paths).forEach((path) => { 26 | if (schema.paths[path].options && schema.paths[path].options.private) { 27 | deleteAtPath(ret, path.split('.'), 0); 28 | } 29 | }); 30 | 31 | ret.id = ret._id.toString(); 32 | delete ret._id; 33 | delete ret.__v; 34 | delete ret.createdAt; 35 | delete ret.updatedAt; 36 | if (transform) { 37 | return transform(doc, ret, options); 38 | } 39 | }, 40 | }); 41 | }; 42 | 43 | module.exports = toJSON; 44 | -------------------------------------------------------------------------------- /src/models/token.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { toJSON } = require('./plugins'); 3 | const { tokenTypes } = require('../config/tokens'); 4 | 5 | const tokenSchema = mongoose.Schema( 6 | { 7 | token: { 8 | type: String, 9 | required: true, 10 | index: true, 11 | }, 12 | user: { 13 | type: mongoose.SchemaTypes.ObjectId, 14 | ref: 'User', 15 | required: true, 16 | }, 17 | type: { 18 | type: String, 19 | enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL], 20 | required: true, 21 | }, 22 | expires: { 23 | type: Date, 24 | required: true, 25 | }, 26 | blacklisted: { 27 | type: Boolean, 28 | default: false, 29 | }, 30 | }, 31 | { 32 | timestamps: true, 33 | } 34 | ); 35 | 36 | // add plugin that converts mongoose to json 37 | tokenSchema.plugin(toJSON); 38 | 39 | /** 40 | * @typedef Token 41 | */ 42 | const Token = mongoose.model('Token', tokenSchema); 43 | 44 | module.exports = Token; 45 | -------------------------------------------------------------------------------- /src/models/user.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const validator = require('validator'); 3 | const bcrypt = require('bcryptjs'); 4 | const { toJSON, paginate } = require('./plugins'); 5 | const { roles } = require('../config/roles'); 6 | 7 | const userSchema = mongoose.Schema( 8 | { 9 | name: { 10 | type: String, 11 | required: true, 12 | trim: true, 13 | }, 14 | email: { 15 | type: String, 16 | required: true, 17 | unique: true, 18 | trim: true, 19 | lowercase: true, 20 | validate(value) { 21 | if (!validator.isEmail(value)) { 22 | throw new Error('Invalid email'); 23 | } 24 | }, 25 | }, 26 | password: { 27 | type: String, 28 | required: true, 29 | trim: true, 30 | minlength: 8, 31 | validate(value) { 32 | if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { 33 | throw new Error('Password must contain at least one letter and one number'); 34 | } 35 | }, 36 | private: true, // used by the toJSON plugin 37 | }, 38 | role: { 39 | type: String, 40 | enum: roles, 41 | default: 'user', 42 | }, 43 | isEmailVerified: { 44 | type: Boolean, 45 | default: false, 46 | }, 47 | }, 48 | { 49 | timestamps: true, 50 | } 51 | ); 52 | 53 | // add plugin that converts mongoose to json 54 | userSchema.plugin(toJSON); 55 | userSchema.plugin(paginate); 56 | 57 | /** 58 | * Check if email is taken 59 | * @param {string} email - The user's email 60 | * @param {ObjectId} [excludeUserId] - The id of the user to be excluded 61 | * @returns {Promise<boolean>} 62 | */ 63 | userSchema.statics.isEmailTaken = async function (email, excludeUserId) { 64 | const user = await this.findOne({ email, _id: { $ne: excludeUserId } }); 65 | return !!user; 66 | }; 67 | 68 | /** 69 | * Check if password matches the user's password 70 | * @param {string} password 71 | * @returns {Promise<boolean>} 72 | */ 73 | userSchema.methods.isPasswordMatch = async function (password) { 74 | const user = this; 75 | return bcrypt.compare(password, user.password); 76 | }; 77 | 78 | userSchema.pre('save', async function (next) { 79 | const user = this; 80 | if (user.isModified('password')) { 81 | user.password = await bcrypt.hash(user.password, 8); 82 | } 83 | next(); 84 | }); 85 | 86 | /** 87 | * @typedef User 88 | */ 89 | const User = mongoose.model('User', userSchema); 90 | 91 | module.exports = User; 92 | -------------------------------------------------------------------------------- /src/routes/v1/auth.route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const validate = require('../../middlewares/validate'); 3 | const authValidation = require('../../validations/auth.validation'); 4 | const authController = require('../../controllers/auth.controller'); 5 | const auth = require('../../middlewares/auth'); 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/register', validate(authValidation.register), authController.register); 10 | router.post('/login', validate(authValidation.login), authController.login); 11 | router.post('/logout', validate(authValidation.logout), authController.logout); 12 | router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); 13 | router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); 14 | router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); 15 | router.post('/send-verification-email', auth(), authController.sendVerificationEmail); 16 | router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); 17 | 18 | module.exports = router; 19 | 20 | /** 21 | * @swagger 22 | * tags: 23 | * name: Auth 24 | * description: Authentication 25 | */ 26 | 27 | /** 28 | * @swagger 29 | * /auth/register: 30 | * post: 31 | * summary: Register as user 32 | * tags: [Auth] 33 | * requestBody: 34 | * required: true 35 | * content: 36 | * application/json: 37 | * schema: 38 | * type: object 39 | * required: 40 | * - name 41 | * - email 42 | * - password 43 | * properties: 44 | * name: 45 | * type: string 46 | * email: 47 | * type: string 48 | * format: email 49 | * description: must be unique 50 | * password: 51 | * type: string 52 | * format: password 53 | * minLength: 8 54 | * description: At least one number and one letter 55 | * example: 56 | * name: fake name 57 | * email: fake@example.com 58 | * password: password1 59 | * responses: 60 | * "201": 61 | * description: Created 62 | * content: 63 | * application/json: 64 | * schema: 65 | * type: object 66 | * properties: 67 | * user: 68 | * $ref: '#/components/schemas/User' 69 | * tokens: 70 | * $ref: '#/components/schemas/AuthTokens' 71 | * "400": 72 | * $ref: '#/components/responses/DuplicateEmail' 73 | */ 74 | 75 | /** 76 | * @swagger 77 | * /auth/login: 78 | * post: 79 | * summary: Login 80 | * tags: [Auth] 81 | * requestBody: 82 | * required: true 83 | * content: 84 | * application/json: 85 | * schema: 86 | * type: object 87 | * required: 88 | * - email 89 | * - password 90 | * properties: 91 | * email: 92 | * type: string 93 | * format: email 94 | * password: 95 | * type: string 96 | * format: password 97 | * example: 98 | * email: fake@example.com 99 | * password: password1 100 | * responses: 101 | * "200": 102 | * description: OK 103 | * content: 104 | * application/json: 105 | * schema: 106 | * type: object 107 | * properties: 108 | * user: 109 | * $ref: '#/components/schemas/User' 110 | * tokens: 111 | * $ref: '#/components/schemas/AuthTokens' 112 | * "401": 113 | * description: Invalid email or password 114 | * content: 115 | * application/json: 116 | * schema: 117 | * $ref: '#/components/schemas/Error' 118 | * example: 119 | * code: 401 120 | * message: Invalid email or password 121 | */ 122 | 123 | /** 124 | * @swagger 125 | * /auth/logout: 126 | * post: 127 | * summary: Logout 128 | * tags: [Auth] 129 | * requestBody: 130 | * required: true 131 | * content: 132 | * application/json: 133 | * schema: 134 | * type: object 135 | * required: 136 | * - refreshToken 137 | * properties: 138 | * refreshToken: 139 | * type: string 140 | * example: 141 | * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 142 | * responses: 143 | * "204": 144 | * description: No content 145 | * "404": 146 | * $ref: '#/components/responses/NotFound' 147 | */ 148 | 149 | /** 150 | * @swagger 151 | * /auth/refresh-tokens: 152 | * post: 153 | * summary: Refresh auth tokens 154 | * tags: [Auth] 155 | * requestBody: 156 | * required: true 157 | * content: 158 | * application/json: 159 | * schema: 160 | * type: object 161 | * required: 162 | * - refreshToken 163 | * properties: 164 | * refreshToken: 165 | * type: string 166 | * example: 167 | * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg 168 | * responses: 169 | * "200": 170 | * description: OK 171 | * content: 172 | * application/json: 173 | * schema: 174 | * $ref: '#/components/schemas/AuthTokens' 175 | * "401": 176 | * $ref: '#/components/responses/Unauthorized' 177 | */ 178 | 179 | /** 180 | * @swagger 181 | * /auth/forgot-password: 182 | * post: 183 | * summary: Forgot password 184 | * description: An email will be sent to reset password. 185 | * tags: [Auth] 186 | * requestBody: 187 | * required: true 188 | * content: 189 | * application/json: 190 | * schema: 191 | * type: object 192 | * required: 193 | * - email 194 | * properties: 195 | * email: 196 | * type: string 197 | * format: email 198 | * example: 199 | * email: fake@example.com 200 | * responses: 201 | * "204": 202 | * description: No content 203 | * "404": 204 | * $ref: '#/components/responses/NotFound' 205 | */ 206 | 207 | /** 208 | * @swagger 209 | * /auth/reset-password: 210 | * post: 211 | * summary: Reset password 212 | * tags: [Auth] 213 | * parameters: 214 | * - in: query 215 | * name: token 216 | * required: true 217 | * schema: 218 | * type: string 219 | * description: The reset password token 220 | * requestBody: 221 | * required: true 222 | * content: 223 | * application/json: 224 | * schema: 225 | * type: object 226 | * required: 227 | * - password 228 | * properties: 229 | * password: 230 | * type: string 231 | * format: password 232 | * minLength: 8 233 | * description: At least one number and one letter 234 | * example: 235 | * password: password1 236 | * responses: 237 | * "204": 238 | * description: No content 239 | * "401": 240 | * description: Password reset failed 241 | * content: 242 | * application/json: 243 | * schema: 244 | * $ref: '#/components/schemas/Error' 245 | * example: 246 | * code: 401 247 | * message: Password reset failed 248 | */ 249 | 250 | /** 251 | * @swagger 252 | * /auth/send-verification-email: 253 | * post: 254 | * summary: Send verification email 255 | * description: An email will be sent to verify email. 256 | * tags: [Auth] 257 | * security: 258 | * - bearerAuth: [] 259 | * responses: 260 | * "204": 261 | * description: No content 262 | * "401": 263 | * $ref: '#/components/responses/Unauthorized' 264 | */ 265 | 266 | /** 267 | * @swagger 268 | * /auth/verify-email: 269 | * post: 270 | * summary: verify email 271 | * tags: [Auth] 272 | * parameters: 273 | * - in: query 274 | * name: token 275 | * required: true 276 | * schema: 277 | * type: string 278 | * description: The verify email token 279 | * responses: 280 | * "204": 281 | * description: No content 282 | * "401": 283 | * description: verify email failed 284 | * content: 285 | * application/json: 286 | * schema: 287 | * $ref: '#/components/schemas/Error' 288 | * example: 289 | * code: 401 290 | * message: verify email failed 291 | */ 292 | -------------------------------------------------------------------------------- /src/routes/v1/docs.route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const swaggerJsdoc = require('swagger-jsdoc'); 3 | const swaggerUi = require('swagger-ui-express'); 4 | const swaggerDefinition = require('../../docs/swaggerDef'); 5 | 6 | const router = express.Router(); 7 | 8 | const specs = swaggerJsdoc({ 9 | swaggerDefinition, 10 | apis: ['src/docs/*.yml', 'src/routes/v1/*.js'], 11 | }); 12 | 13 | router.use('/', swaggerUi.serve); 14 | router.get( 15 | '/', 16 | swaggerUi.setup(specs, { 17 | explorer: true, 18 | }) 19 | ); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /src/routes/v1/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const authRoute = require('./auth.route'); 3 | const userRoute = require('./user.route'); 4 | const docsRoute = require('./docs.route'); 5 | const config = require('../../config/config'); 6 | 7 | const router = express.Router(); 8 | 9 | const defaultRoutes = [ 10 | { 11 | path: '/auth', 12 | route: authRoute, 13 | }, 14 | { 15 | path: '/users', 16 | route: userRoute, 17 | }, 18 | ]; 19 | 20 | const devRoutes = [ 21 | // routes available only in development mode 22 | { 23 | path: '/docs', 24 | route: docsRoute, 25 | }, 26 | ]; 27 | 28 | defaultRoutes.forEach((route) => { 29 | router.use(route.path, route.route); 30 | }); 31 | 32 | /* istanbul ignore next */ 33 | if (config.env === 'development') { 34 | devRoutes.forEach((route) => { 35 | router.use(route.path, route.route); 36 | }); 37 | } 38 | 39 | module.exports = router; 40 | -------------------------------------------------------------------------------- /src/routes/v1/user.route.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const auth = require('../../middlewares/auth'); 3 | const validate = require('../../middlewares/validate'); 4 | const userValidation = require('../../validations/user.validation'); 5 | const userController = require('../../controllers/user.controller'); 6 | 7 | const router = express.Router(); 8 | 9 | router 10 | .route('/') 11 | .post(auth('manageUsers'), validate(userValidation.createUser), userController.createUser) 12 | .get(auth('getUsers'), validate(userValidation.getUsers), userController.getUsers); 13 | 14 | router 15 | .route('/:userId') 16 | .get(auth('getUsers'), validate(userValidation.getUser), userController.getUser) 17 | .patch(auth('manageUsers'), validate(userValidation.updateUser), userController.updateUser) 18 | .delete(auth('manageUsers'), validate(userValidation.deleteUser), userController.deleteUser); 19 | 20 | module.exports = router; 21 | 22 | /** 23 | * @swagger 24 | * tags: 25 | * name: Users 26 | * description: User management and retrieval 27 | */ 28 | 29 | /** 30 | * @swagger 31 | * /users: 32 | * post: 33 | * summary: Create a user 34 | * description: Only admins can create other users. 35 | * tags: [Users] 36 | * security: 37 | * - bearerAuth: [] 38 | * requestBody: 39 | * required: true 40 | * content: 41 | * application/json: 42 | * schema: 43 | * type: object 44 | * required: 45 | * - name 46 | * - email 47 | * - password 48 | * - role 49 | * properties: 50 | * name: 51 | * type: string 52 | * email: 53 | * type: string 54 | * format: email 55 | * description: must be unique 56 | * password: 57 | * type: string 58 | * format: password 59 | * minLength: 8 60 | * description: At least one number and one letter 61 | * role: 62 | * type: string 63 | * enum: [user, admin] 64 | * example: 65 | * name: fake name 66 | * email: fake@example.com 67 | * password: password1 68 | * role: user 69 | * responses: 70 | * "201": 71 | * description: Created 72 | * content: 73 | * application/json: 74 | * schema: 75 | * $ref: '#/components/schemas/User' 76 | * "400": 77 | * $ref: '#/components/responses/DuplicateEmail' 78 | * "401": 79 | * $ref: '#/components/responses/Unauthorized' 80 | * "403": 81 | * $ref: '#/components/responses/Forbidden' 82 | * 83 | * get: 84 | * summary: Get all users 85 | * description: Only admins can retrieve all users. 86 | * tags: [Users] 87 | * security: 88 | * - bearerAuth: [] 89 | * parameters: 90 | * - in: query 91 | * name: name 92 | * schema: 93 | * type: string 94 | * description: User name 95 | * - in: query 96 | * name: role 97 | * schema: 98 | * type: string 99 | * description: User role 100 | * - in: query 101 | * name: sortBy 102 | * schema: 103 | * type: string 104 | * description: sort by query in the form of field:desc/asc (ex. name:asc) 105 | * - in: query 106 | * name: limit 107 | * schema: 108 | * type: integer 109 | * minimum: 1 110 | * default: 10 111 | * description: Maximum number of users 112 | * - in: query 113 | * name: page 114 | * schema: 115 | * type: integer 116 | * minimum: 1 117 | * default: 1 118 | * description: Page number 119 | * responses: 120 | * "200": 121 | * description: OK 122 | * content: 123 | * application/json: 124 | * schema: 125 | * type: object 126 | * properties: 127 | * results: 128 | * type: array 129 | * items: 130 | * $ref: '#/components/schemas/User' 131 | * page: 132 | * type: integer 133 | * example: 1 134 | * limit: 135 | * type: integer 136 | * example: 10 137 | * totalPages: 138 | * type: integer 139 | * example: 1 140 | * totalResults: 141 | * type: integer 142 | * example: 1 143 | * "401": 144 | * $ref: '#/components/responses/Unauthorized' 145 | * "403": 146 | * $ref: '#/components/responses/Forbidden' 147 | */ 148 | 149 | /** 150 | * @swagger 151 | * /users/{id}: 152 | * get: 153 | * summary: Get a user 154 | * description: Logged in users can fetch only their own user information. Only admins can fetch other users. 155 | * tags: [Users] 156 | * security: 157 | * - bearerAuth: [] 158 | * parameters: 159 | * - in: path 160 | * name: id 161 | * required: true 162 | * schema: 163 | * type: string 164 | * description: User id 165 | * responses: 166 | * "200": 167 | * description: OK 168 | * content: 169 | * application/json: 170 | * schema: 171 | * $ref: '#/components/schemas/User' 172 | * "401": 173 | * $ref: '#/components/responses/Unauthorized' 174 | * "403": 175 | * $ref: '#/components/responses/Forbidden' 176 | * "404": 177 | * $ref: '#/components/responses/NotFound' 178 | * 179 | * patch: 180 | * summary: Update a user 181 | * description: Logged in users can only update their own information. Only admins can update other users. 182 | * tags: [Users] 183 | * security: 184 | * - bearerAuth: [] 185 | * parameters: 186 | * - in: path 187 | * name: id 188 | * required: true 189 | * schema: 190 | * type: string 191 | * description: User id 192 | * requestBody: 193 | * required: true 194 | * content: 195 | * application/json: 196 | * schema: 197 | * type: object 198 | * properties: 199 | * name: 200 | * type: string 201 | * email: 202 | * type: string 203 | * format: email 204 | * description: must be unique 205 | * password: 206 | * type: string 207 | * format: password 208 | * minLength: 8 209 | * description: At least one number and one letter 210 | * example: 211 | * name: fake name 212 | * email: fake@example.com 213 | * password: password1 214 | * responses: 215 | * "200": 216 | * description: OK 217 | * content: 218 | * application/json: 219 | * schema: 220 | * $ref: '#/components/schemas/User' 221 | * "400": 222 | * $ref: '#/components/responses/DuplicateEmail' 223 | * "401": 224 | * $ref: '#/components/responses/Unauthorized' 225 | * "403": 226 | * $ref: '#/components/responses/Forbidden' 227 | * "404": 228 | * $ref: '#/components/responses/NotFound' 229 | * 230 | * delete: 231 | * summary: Delete a user 232 | * description: Logged in users can delete only themselves. Only admins can delete other users. 233 | * tags: [Users] 234 | * security: 235 | * - bearerAuth: [] 236 | * parameters: 237 | * - in: path 238 | * name: id 239 | * required: true 240 | * schema: 241 | * type: string 242 | * description: User id 243 | * responses: 244 | * "200": 245 | * description: No content 246 | * "401": 247 | * $ref: '#/components/responses/Unauthorized' 248 | * "403": 249 | * $ref: '#/components/responses/Forbidden' 250 | * "404": 251 | * $ref: '#/components/responses/NotFound' 252 | */ 253 | -------------------------------------------------------------------------------- /src/services/auth.service.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const tokenService = require('./token.service'); 3 | const userService = require('./user.service'); 4 | const Token = require('../models/token.model'); 5 | const ApiError = require('../utils/ApiError'); 6 | const { tokenTypes } = require('../config/tokens'); 7 | 8 | /** 9 | * Login with username and password 10 | * @param {string} email 11 | * @param {string} password 12 | * @returns {Promise<User>} 13 | */ 14 | const loginUserWithEmailAndPassword = async (email, password) => { 15 | const user = await userService.getUserByEmail(email); 16 | if (!user || !(await user.isPasswordMatch(password))) { 17 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password'); 18 | } 19 | return user; 20 | }; 21 | 22 | /** 23 | * Logout 24 | * @param {string} refreshToken 25 | * @returns {Promise} 26 | */ 27 | const logout = async (refreshToken) => { 28 | const refreshTokenDoc = await Token.findOne({ token: refreshToken, type: tokenTypes.REFRESH, blacklisted: false }); 29 | if (!refreshTokenDoc) { 30 | throw new ApiError(httpStatus.NOT_FOUND, 'Not found'); 31 | } 32 | await refreshTokenDoc.remove(); 33 | }; 34 | 35 | /** 36 | * Refresh auth tokens 37 | * @param {string} refreshToken 38 | * @returns {Promise<Object>} 39 | */ 40 | const refreshAuth = async (refreshToken) => { 41 | try { 42 | const refreshTokenDoc = await tokenService.verifyToken(refreshToken, tokenTypes.REFRESH); 43 | const user = await userService.getUserById(refreshTokenDoc.user); 44 | if (!user) { 45 | throw new Error(); 46 | } 47 | await refreshTokenDoc.remove(); 48 | return tokenService.generateAuthTokens(user); 49 | } catch (error) { 50 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); 51 | } 52 | }; 53 | 54 | /** 55 | * Reset password 56 | * @param {string} resetPasswordToken 57 | * @param {string} newPassword 58 | * @returns {Promise} 59 | */ 60 | const resetPassword = async (resetPasswordToken, newPassword) => { 61 | try { 62 | const resetPasswordTokenDoc = await tokenService.verifyToken(resetPasswordToken, tokenTypes.RESET_PASSWORD); 63 | const user = await userService.getUserById(resetPasswordTokenDoc.user); 64 | if (!user) { 65 | throw new Error(); 66 | } 67 | await userService.updateUserById(user.id, { password: newPassword }); 68 | await Token.deleteMany({ user: user.id, type: tokenTypes.RESET_PASSWORD }); 69 | } catch (error) { 70 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); 71 | } 72 | }; 73 | 74 | /** 75 | * Verify email 76 | * @param {string} verifyEmailToken 77 | * @returns {Promise} 78 | */ 79 | const verifyEmail = async (verifyEmailToken) => { 80 | try { 81 | const verifyEmailTokenDoc = await tokenService.verifyToken(verifyEmailToken, tokenTypes.VERIFY_EMAIL); 82 | const user = await userService.getUserById(verifyEmailTokenDoc.user); 83 | if (!user) { 84 | throw new Error(); 85 | } 86 | await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFY_EMAIL }); 87 | await userService.updateUserById(user.id, { isEmailVerified: true }); 88 | } catch (error) { 89 | throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed'); 90 | } 91 | }; 92 | 93 | module.exports = { 94 | loginUserWithEmailAndPassword, 95 | logout, 96 | refreshAuth, 97 | resetPassword, 98 | verifyEmail, 99 | }; 100 | -------------------------------------------------------------------------------- /src/services/email.service.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | const config = require('../config/config'); 3 | const logger = require('../config/logger'); 4 | 5 | const transport = nodemailer.createTransport(config.email.smtp); 6 | /* istanbul ignore next */ 7 | if (config.env !== 'test') { 8 | transport 9 | .verify() 10 | .then(() => logger.info('Connected to email server')) 11 | .catch(() => logger.warn('Unable to connect to email server. Make sure you have configured the SMTP options in .env')); 12 | } 13 | 14 | /** 15 | * Send an email 16 | * @param {string} to 17 | * @param {string} subject 18 | * @param {string} text 19 | * @returns {Promise} 20 | */ 21 | const sendEmail = async (to, subject, text) => { 22 | const msg = { from: config.email.from, to, subject, text }; 23 | await transport.sendMail(msg); 24 | }; 25 | 26 | /** 27 | * Send reset password email 28 | * @param {string} to 29 | * @param {string} token 30 | * @returns {Promise} 31 | */ 32 | const sendResetPasswordEmail = async (to, token) => { 33 | const subject = 'Reset password'; 34 | // replace this url with the link to the reset password page of your front-end app 35 | const resetPasswordUrl = `http://link-to-app/reset-password?token=${token}`; 36 | const text = `Dear user, 37 | To reset your password, click on this link: ${resetPasswordUrl} 38 | If you did not request any password resets, then ignore this email.`; 39 | await sendEmail(to, subject, text); 40 | }; 41 | 42 | /** 43 | * Send verification email 44 | * @param {string} to 45 | * @param {string} token 46 | * @returns {Promise} 47 | */ 48 | const sendVerificationEmail = async (to, token) => { 49 | const subject = 'Email Verification'; 50 | // replace this url with the link to the email verification page of your front-end app 51 | const verificationEmailUrl = `http://link-to-app/verify-email?token=${token}`; 52 | const text = `Dear user, 53 | To verify your email, click on this link: ${verificationEmailUrl} 54 | If you did not create an account, then ignore this email.`; 55 | await sendEmail(to, subject, text); 56 | }; 57 | 58 | module.exports = { 59 | transport, 60 | sendEmail, 61 | sendResetPasswordEmail, 62 | sendVerificationEmail, 63 | }; 64 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | module.exports.authService = require('./auth.service'); 2 | module.exports.emailService = require('./email.service'); 3 | module.exports.tokenService = require('./token.service'); 4 | module.exports.userService = require('./user.service'); 5 | -------------------------------------------------------------------------------- /src/services/token.service.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const moment = require('moment'); 3 | const httpStatus = require('http-status'); 4 | const config = require('../config/config'); 5 | const userService = require('./user.service'); 6 | const { Token } = require('../models'); 7 | const ApiError = require('../utils/ApiError'); 8 | const { tokenTypes } = require('../config/tokens'); 9 | 10 | /** 11 | * Generate token 12 | * @param {ObjectId} userId 13 | * @param {Moment} expires 14 | * @param {string} type 15 | * @param {string} [secret] 16 | * @returns {string} 17 | */ 18 | const generateToken = (userId, expires, type, secret = config.jwt.secret) => { 19 | const payload = { 20 | sub: userId, 21 | iat: moment().unix(), 22 | exp: expires.unix(), 23 | type, 24 | }; 25 | return jwt.sign(payload, secret); 26 | }; 27 | 28 | /** 29 | * Save a token 30 | * @param {string} token 31 | * @param {ObjectId} userId 32 | * @param {Moment} expires 33 | * @param {string} type 34 | * @param {boolean} [blacklisted] 35 | * @returns {Promise<Token>} 36 | */ 37 | const saveToken = async (token, userId, expires, type, blacklisted = false) => { 38 | const tokenDoc = await Token.create({ 39 | token, 40 | user: userId, 41 | expires: expires.toDate(), 42 | type, 43 | blacklisted, 44 | }); 45 | return tokenDoc; 46 | }; 47 | 48 | /** 49 | * Verify token and return token doc (or throw an error if it is not valid) 50 | * @param {string} token 51 | * @param {string} type 52 | * @returns {Promise<Token>} 53 | */ 54 | const verifyToken = async (token, type) => { 55 | const payload = jwt.verify(token, config.jwt.secret); 56 | const tokenDoc = await Token.findOne({ token, type, user: payload.sub, blacklisted: false }); 57 | if (!tokenDoc) { 58 | throw new Error('Token not found'); 59 | } 60 | return tokenDoc; 61 | }; 62 | 63 | /** 64 | * Generate auth tokens 65 | * @param {User} user 66 | * @returns {Promise<Object>} 67 | */ 68 | const generateAuthTokens = async (user) => { 69 | const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 70 | const accessToken = generateToken(user.id, accessTokenExpires, tokenTypes.ACCESS); 71 | 72 | const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days'); 73 | const refreshToken = generateToken(user.id, refreshTokenExpires, tokenTypes.REFRESH); 74 | await saveToken(refreshToken, user.id, refreshTokenExpires, tokenTypes.REFRESH); 75 | 76 | return { 77 | access: { 78 | token: accessToken, 79 | expires: accessTokenExpires.toDate(), 80 | }, 81 | refresh: { 82 | token: refreshToken, 83 | expires: refreshTokenExpires.toDate(), 84 | }, 85 | }; 86 | }; 87 | 88 | /** 89 | * Generate reset password token 90 | * @param {string} email 91 | * @returns {Promise<string>} 92 | */ 93 | const generateResetPasswordToken = async (email) => { 94 | const user = await userService.getUserByEmail(email); 95 | if (!user) { 96 | throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email'); 97 | } 98 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 99 | const resetPasswordToken = generateToken(user.id, expires, tokenTypes.RESET_PASSWORD); 100 | await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); 101 | return resetPasswordToken; 102 | }; 103 | 104 | /** 105 | * Generate verify email token 106 | * @param {User} user 107 | * @returns {Promise<string>} 108 | */ 109 | const generateVerifyEmailToken = async (user) => { 110 | const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); 111 | const verifyEmailToken = generateToken(user.id, expires, tokenTypes.VERIFY_EMAIL); 112 | await saveToken(verifyEmailToken, user.id, expires, tokenTypes.VERIFY_EMAIL); 113 | return verifyEmailToken; 114 | }; 115 | 116 | module.exports = { 117 | generateToken, 118 | saveToken, 119 | verifyToken, 120 | generateAuthTokens, 121 | generateResetPasswordToken, 122 | generateVerifyEmailToken, 123 | }; 124 | -------------------------------------------------------------------------------- /src/services/user.service.js: -------------------------------------------------------------------------------- 1 | const httpStatus = require('http-status'); 2 | const { User } = require('../models'); 3 | const ApiError = require('../utils/ApiError'); 4 | 5 | /** 6 | * Create a user 7 | * @param {Object} userBody 8 | * @returns {Promise<User>} 9 | */ 10 | const createUser = async (userBody) => { 11 | if (await User.isEmailTaken(userBody.email)) { 12 | throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); 13 | } 14 | return User.create(userBody); 15 | }; 16 | 17 | /** 18 | * Query for users 19 | * @param {Object} filter - Mongo filter 20 | * @param {Object} options - Query options 21 | * @param {string} [options.sortBy] - Sort option in the format: sortField:(desc|asc) 22 | * @param {number} [options.limit] - Maximum number of results per page (default = 10) 23 | * @param {number} [options.page] - Current page (default = 1) 24 | * @returns {Promise<QueryResult>} 25 | */ 26 | const queryUsers = async (filter, options) => { 27 | const users = await User.paginate(filter, options); 28 | return users; 29 | }; 30 | 31 | /** 32 | * Get user by id 33 | * @param {ObjectId} id 34 | * @returns {Promise<User>} 35 | */ 36 | const getUserById = async (id) => { 37 | return User.findById(id); 38 | }; 39 | 40 | /** 41 | * Get user by email 42 | * @param {string} email 43 | * @returns {Promise<User>} 44 | */ 45 | const getUserByEmail = async (email) => { 46 | return User.findOne({ email }); 47 | }; 48 | 49 | /** 50 | * Update user by id 51 | * @param {ObjectId} userId 52 | * @param {Object} updateBody 53 | * @returns {Promise<User>} 54 | */ 55 | const updateUserById = async (userId, updateBody) => { 56 | const user = await getUserById(userId); 57 | if (!user) { 58 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 59 | } 60 | if (updateBody.email && (await User.isEmailTaken(updateBody.email, userId))) { 61 | throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); 62 | } 63 | Object.assign(user, updateBody); 64 | await user.save(); 65 | return user; 66 | }; 67 | 68 | /** 69 | * Delete user by id 70 | * @param {ObjectId} userId 71 | * @returns {Promise<User>} 72 | */ 73 | const deleteUserById = async (userId) => { 74 | const user = await getUserById(userId); 75 | if (!user) { 76 | throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); 77 | } 78 | await user.remove(); 79 | return user; 80 | }; 81 | 82 | module.exports = { 83 | createUser, 84 | queryUsers, 85 | getUserById, 86 | getUserByEmail, 87 | updateUserById, 88 | deleteUserById, 89 | }; 90 | -------------------------------------------------------------------------------- /src/utils/ApiError.js: -------------------------------------------------------------------------------- 1 | class ApiError extends Error { 2 | constructor(statusCode, message, isOperational = true, stack = '') { 3 | super(message); 4 | this.statusCode = statusCode; 5 | this.isOperational = isOperational; 6 | if (stack) { 7 | this.stack = stack; 8 | } else { 9 | Error.captureStackTrace(this, this.constructor); 10 | } 11 | } 12 | } 13 | 14 | module.exports = ApiError; 15 | -------------------------------------------------------------------------------- /src/utils/catchAsync.js: -------------------------------------------------------------------------------- 1 | const catchAsync = (fn) => (req, res, next) => { 2 | Promise.resolve(fn(req, res, next)).catch((err) => next(err)); 3 | }; 4 | 5 | module.exports = catchAsync; 6 | -------------------------------------------------------------------------------- /src/utils/pick.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create an object composed of the picked object properties 3 | * @param {Object} object 4 | * @param {string[]} keys 5 | * @returns {Object} 6 | */ 7 | const pick = (object, keys) => { 8 | return keys.reduce((obj, key) => { 9 | if (object && Object.prototype.hasOwnProperty.call(object, key)) { 10 | // eslint-disable-next-line no-param-reassign 11 | obj[key] = object[key]; 12 | } 13 | return obj; 14 | }, {}); 15 | }; 16 | 17 | module.exports = pick; 18 | -------------------------------------------------------------------------------- /src/validations/auth.validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const { password } = require('./custom.validation'); 3 | 4 | const register = { 5 | body: Joi.object().keys({ 6 | email: Joi.string().required().email(), 7 | password: Joi.string().required().custom(password), 8 | name: Joi.string().required(), 9 | }), 10 | }; 11 | 12 | const login = { 13 | body: Joi.object().keys({ 14 | email: Joi.string().required(), 15 | password: Joi.string().required(), 16 | }), 17 | }; 18 | 19 | const logout = { 20 | body: Joi.object().keys({ 21 | refreshToken: Joi.string().required(), 22 | }), 23 | }; 24 | 25 | const refreshTokens = { 26 | body: Joi.object().keys({ 27 | refreshToken: Joi.string().required(), 28 | }), 29 | }; 30 | 31 | const forgotPassword = { 32 | body: Joi.object().keys({ 33 | email: Joi.string().email().required(), 34 | }), 35 | }; 36 | 37 | const resetPassword = { 38 | query: Joi.object().keys({ 39 | token: Joi.string().required(), 40 | }), 41 | body: Joi.object().keys({ 42 | password: Joi.string().required().custom(password), 43 | }), 44 | }; 45 | 46 | const verifyEmail = { 47 | query: Joi.object().keys({ 48 | token: Joi.string().required(), 49 | }), 50 | }; 51 | 52 | module.exports = { 53 | register, 54 | login, 55 | logout, 56 | refreshTokens, 57 | forgotPassword, 58 | resetPassword, 59 | verifyEmail, 60 | }; 61 | -------------------------------------------------------------------------------- /src/validations/custom.validation.js: -------------------------------------------------------------------------------- 1 | const objectId = (value, helpers) => { 2 | if (!value.match(/^[0-9a-fA-F]{24}$/)) { 3 | return helpers.message('"{{#label}}" must be a valid mongo id'); 4 | } 5 | return value; 6 | }; 7 | 8 | const password = (value, helpers) => { 9 | if (value.length < 8) { 10 | return helpers.message('password must be at least 8 characters'); 11 | } 12 | if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { 13 | return helpers.message('password must contain at least 1 letter and 1 number'); 14 | } 15 | return value; 16 | }; 17 | 18 | module.exports = { 19 | objectId, 20 | password, 21 | }; 22 | -------------------------------------------------------------------------------- /src/validations/index.js: -------------------------------------------------------------------------------- 1 | module.exports.authValidation = require('./auth.validation'); 2 | module.exports.userValidation = require('./user.validation'); 3 | -------------------------------------------------------------------------------- /src/validations/user.validation.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const { password, objectId } = require('./custom.validation'); 3 | 4 | const createUser = { 5 | body: Joi.object().keys({ 6 | email: Joi.string().required().email(), 7 | password: Joi.string().required().custom(password), 8 | name: Joi.string().required(), 9 | role: Joi.string().required().valid('user', 'admin'), 10 | }), 11 | }; 12 | 13 | const getUsers = { 14 | query: Joi.object().keys({ 15 | name: Joi.string(), 16 | role: Joi.string(), 17 | sortBy: Joi.string(), 18 | limit: Joi.number().integer(), 19 | page: Joi.number().integer(), 20 | }), 21 | }; 22 | 23 | const getUser = { 24 | params: Joi.object().keys({ 25 | userId: Joi.string().custom(objectId), 26 | }), 27 | }; 28 | 29 | const updateUser = { 30 | params: Joi.object().keys({ 31 | userId: Joi.required().custom(objectId), 32 | }), 33 | body: Joi.object() 34 | .keys({ 35 | email: Joi.string().email(), 36 | password: Joi.string().custom(password), 37 | name: Joi.string(), 38 | }) 39 | .min(1), 40 | }; 41 | 42 | const deleteUser = { 43 | params: Joi.object().keys({ 44 | userId: Joi.string().custom(objectId), 45 | }), 46 | }; 47 | 48 | module.exports = { 49 | createUser, 50 | getUsers, 51 | getUser, 52 | updateUser, 53 | deleteUser, 54 | }; 55 | -------------------------------------------------------------------------------- /tests/fixtures/token.fixture.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const config = require('../../src/config/config'); 3 | const { tokenTypes } = require('../../src/config/tokens'); 4 | const tokenService = require('../../src/services/token.service'); 5 | const { userOne, admin } = require('./user.fixture'); 6 | 7 | const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 8 | const userOneAccessToken = tokenService.generateToken(userOne._id, accessTokenExpires, tokenTypes.ACCESS); 9 | const adminAccessToken = tokenService.generateToken(admin._id, accessTokenExpires, tokenTypes.ACCESS); 10 | 11 | module.exports = { 12 | userOneAccessToken, 13 | adminAccessToken, 14 | }; 15 | -------------------------------------------------------------------------------- /tests/fixtures/user.fixture.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const bcrypt = require('bcryptjs'); 3 | const faker = require('faker'); 4 | const User = require('../../src/models/user.model'); 5 | 6 | const password = 'password1'; 7 | const salt = bcrypt.genSaltSync(8); 8 | const hashedPassword = bcrypt.hashSync(password, salt); 9 | 10 | const userOne = { 11 | _id: mongoose.Types.ObjectId(), 12 | name: faker.name.findName(), 13 | email: faker.internet.email().toLowerCase(), 14 | password, 15 | role: 'user', 16 | isEmailVerified: false, 17 | }; 18 | 19 | const userTwo = { 20 | _id: mongoose.Types.ObjectId(), 21 | name: faker.name.findName(), 22 | email: faker.internet.email().toLowerCase(), 23 | password, 24 | role: 'user', 25 | isEmailVerified: false, 26 | }; 27 | 28 | const admin = { 29 | _id: mongoose.Types.ObjectId(), 30 | name: faker.name.findName(), 31 | email: faker.internet.email().toLowerCase(), 32 | password, 33 | role: 'admin', 34 | isEmailVerified: false, 35 | }; 36 | 37 | const insertUsers = async (users) => { 38 | await User.insertMany(users.map((user) => ({ ...user, password: hashedPassword }))); 39 | }; 40 | 41 | module.exports = { 42 | userOne, 43 | userTwo, 44 | admin, 45 | insertUsers, 46 | }; 47 | -------------------------------------------------------------------------------- /tests/integration/auth.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const faker = require('faker'); 3 | const httpStatus = require('http-status'); 4 | const httpMocks = require('node-mocks-http'); 5 | const moment = require('moment'); 6 | const bcrypt = require('bcryptjs'); 7 | const app = require('../../src/app'); 8 | const config = require('../../src/config/config'); 9 | const auth = require('../../src/middlewares/auth'); 10 | const { tokenService, emailService } = require('../../src/services'); 11 | const ApiError = require('../../src/utils/ApiError'); 12 | const setupTestDB = require('../utils/setupTestDB'); 13 | const { User, Token } = require('../../src/models'); 14 | const { roleRights } = require('../../src/config/roles'); 15 | const { tokenTypes } = require('../../src/config/tokens'); 16 | const { userOne, admin, insertUsers } = require('../fixtures/user.fixture'); 17 | const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); 18 | 19 | setupTestDB(); 20 | 21 | describe('Auth routes', () => { 22 | describe('POST /v1/auth/register', () => { 23 | let newUser; 24 | beforeEach(() => { 25 | newUser = { 26 | name: faker.name.findName(), 27 | email: faker.internet.email().toLowerCase(), 28 | password: 'password1', 29 | }; 30 | }); 31 | 32 | test('should return 201 and successfully register user if request data is ok', async () => { 33 | const res = await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.CREATED); 34 | 35 | expect(res.body.user).not.toHaveProperty('password'); 36 | expect(res.body.user).toEqual({ 37 | id: expect.anything(), 38 | name: newUser.name, 39 | email: newUser.email, 40 | role: 'user', 41 | isEmailVerified: false, 42 | }); 43 | 44 | const dbUser = await User.findById(res.body.user.id); 45 | expect(dbUser).toBeDefined(); 46 | expect(dbUser.password).not.toBe(newUser.password); 47 | expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user', isEmailVerified: false }); 48 | 49 | expect(res.body.tokens).toEqual({ 50 | access: { token: expect.anything(), expires: expect.anything() }, 51 | refresh: { token: expect.anything(), expires: expect.anything() }, 52 | }); 53 | }); 54 | 55 | test('should return 400 error if email is invalid', async () => { 56 | newUser.email = 'invalidEmail'; 57 | 58 | await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); 59 | }); 60 | 61 | test('should return 400 error if email is already used', async () => { 62 | await insertUsers([userOne]); 63 | newUser.email = userOne.email; 64 | 65 | await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); 66 | }); 67 | 68 | test('should return 400 error if password length is less than 8 characters', async () => { 69 | newUser.password = 'passwo1'; 70 | 71 | await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); 72 | }); 73 | 74 | test('should return 400 error if password does not contain both letters and numbers', async () => { 75 | newUser.password = 'password'; 76 | 77 | await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); 78 | 79 | newUser.password = '11111111'; 80 | 81 | await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); 82 | }); 83 | }); 84 | 85 | describe('POST /v1/auth/login', () => { 86 | test('should return 200 and login user if email and password match', async () => { 87 | await insertUsers([userOne]); 88 | const loginCredentials = { 89 | email: userOne.email, 90 | password: userOne.password, 91 | }; 92 | 93 | const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.OK); 94 | 95 | expect(res.body.user).toEqual({ 96 | id: expect.anything(), 97 | name: userOne.name, 98 | email: userOne.email, 99 | role: userOne.role, 100 | isEmailVerified: userOne.isEmailVerified, 101 | }); 102 | 103 | expect(res.body.tokens).toEqual({ 104 | access: { token: expect.anything(), expires: expect.anything() }, 105 | refresh: { token: expect.anything(), expires: expect.anything() }, 106 | }); 107 | }); 108 | 109 | test('should return 401 error if there are no users with that email', async () => { 110 | const loginCredentials = { 111 | email: userOne.email, 112 | password: userOne.password, 113 | }; 114 | 115 | const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); 116 | 117 | expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); 118 | }); 119 | 120 | test('should return 401 error if password is wrong', async () => { 121 | await insertUsers([userOne]); 122 | const loginCredentials = { 123 | email: userOne.email, 124 | password: 'wrongPassword1', 125 | }; 126 | 127 | const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); 128 | 129 | expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); 130 | }); 131 | }); 132 | 133 | describe('POST /v1/auth/logout', () => { 134 | test('should return 204 if refresh token is valid', async () => { 135 | await insertUsers([userOne]); 136 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 137 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 138 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); 139 | 140 | await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NO_CONTENT); 141 | 142 | const dbRefreshTokenDoc = await Token.findOne({ token: refreshToken }); 143 | expect(dbRefreshTokenDoc).toBe(null); 144 | }); 145 | 146 | test('should return 400 error if refresh token is missing from request body', async () => { 147 | await request(app).post('/v1/auth/logout').send().expect(httpStatus.BAD_REQUEST); 148 | }); 149 | 150 | test('should return 404 error if refresh token is not found in the database', async () => { 151 | await insertUsers([userOne]); 152 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 153 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 154 | 155 | await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); 156 | }); 157 | 158 | test('should return 404 error if refresh token is blacklisted', async () => { 159 | await insertUsers([userOne]); 160 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 161 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 162 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); 163 | 164 | await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); 165 | }); 166 | }); 167 | 168 | describe('POST /v1/auth/refresh-tokens', () => { 169 | test('should return 200 and new auth tokens if refresh token is valid', async () => { 170 | await insertUsers([userOne]); 171 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 172 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 173 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); 174 | 175 | const res = await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.OK); 176 | 177 | expect(res.body).toEqual({ 178 | access: { token: expect.anything(), expires: expect.anything() }, 179 | refresh: { token: expect.anything(), expires: expect.anything() }, 180 | }); 181 | 182 | const dbRefreshTokenDoc = await Token.findOne({ token: res.body.refresh.token }); 183 | expect(dbRefreshTokenDoc).toMatchObject({ type: tokenTypes.REFRESH, user: userOne._id, blacklisted: false }); 184 | 185 | const dbRefreshTokenCount = await Token.countDocuments(); 186 | expect(dbRefreshTokenCount).toBe(1); 187 | }); 188 | 189 | test('should return 400 error if refresh token is missing from request body', async () => { 190 | await request(app).post('/v1/auth/refresh-tokens').send().expect(httpStatus.BAD_REQUEST); 191 | }); 192 | 193 | test('should return 401 error if refresh token is signed using an invalid secret', async () => { 194 | await insertUsers([userOne]); 195 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 196 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH, 'invalidSecret'); 197 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); 198 | 199 | await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); 200 | }); 201 | 202 | test('should return 401 error if refresh token is not found in the database', async () => { 203 | await insertUsers([userOne]); 204 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 205 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 206 | 207 | await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); 208 | }); 209 | 210 | test('should return 401 error if refresh token is blacklisted', async () => { 211 | await insertUsers([userOne]); 212 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 213 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 214 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); 215 | 216 | await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); 217 | }); 218 | 219 | test('should return 401 error if refresh token is expired', async () => { 220 | await insertUsers([userOne]); 221 | const expires = moment().subtract(1, 'minutes'); 222 | const refreshToken = tokenService.generateToken(userOne._id, expires); 223 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); 224 | 225 | await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); 226 | }); 227 | 228 | test('should return 401 error if user is not found', async () => { 229 | const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); 230 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 231 | await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); 232 | 233 | await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); 234 | }); 235 | }); 236 | 237 | describe('POST /v1/auth/forgot-password', () => { 238 | beforeEach(() => { 239 | jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); 240 | }); 241 | 242 | test('should return 204 and send reset password email to the user', async () => { 243 | await insertUsers([userOne]); 244 | const sendResetPasswordEmailSpy = jest.spyOn(emailService, 'sendResetPasswordEmail'); 245 | 246 | await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NO_CONTENT); 247 | 248 | expect(sendResetPasswordEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); 249 | const resetPasswordToken = sendResetPasswordEmailSpy.mock.calls[0][1]; 250 | const dbResetPasswordTokenDoc = await Token.findOne({ token: resetPasswordToken, user: userOne._id }); 251 | expect(dbResetPasswordTokenDoc).toBeDefined(); 252 | }); 253 | 254 | test('should return 400 if email is missing', async () => { 255 | await insertUsers([userOne]); 256 | 257 | await request(app).post('/v1/auth/forgot-password').send().expect(httpStatus.BAD_REQUEST); 258 | }); 259 | 260 | test('should return 404 if email does not belong to any user', async () => { 261 | await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NOT_FOUND); 262 | }); 263 | }); 264 | 265 | describe('POST /v1/auth/reset-password', () => { 266 | test('should return 204 and reset the password', async () => { 267 | await insertUsers([userOne]); 268 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 269 | const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); 270 | await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); 271 | 272 | await request(app) 273 | .post('/v1/auth/reset-password') 274 | .query({ token: resetPasswordToken }) 275 | .send({ password: 'password2' }) 276 | .expect(httpStatus.NO_CONTENT); 277 | 278 | const dbUser = await User.findById(userOne._id); 279 | const isPasswordMatch = await bcrypt.compare('password2', dbUser.password); 280 | expect(isPasswordMatch).toBe(true); 281 | 282 | const dbResetPasswordTokenCount = await Token.countDocuments({ user: userOne._id, type: tokenTypes.RESET_PASSWORD }); 283 | expect(dbResetPasswordTokenCount).toBe(0); 284 | }); 285 | 286 | test('should return 400 if reset password token is missing', async () => { 287 | await insertUsers([userOne]); 288 | 289 | await request(app).post('/v1/auth/reset-password').send({ password: 'password2' }).expect(httpStatus.BAD_REQUEST); 290 | }); 291 | 292 | test('should return 401 if reset password token is blacklisted', async () => { 293 | await insertUsers([userOne]); 294 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 295 | const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); 296 | await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD, true); 297 | 298 | await request(app) 299 | .post('/v1/auth/reset-password') 300 | .query({ token: resetPasswordToken }) 301 | .send({ password: 'password2' }) 302 | .expect(httpStatus.UNAUTHORIZED); 303 | }); 304 | 305 | test('should return 401 if reset password token is expired', async () => { 306 | await insertUsers([userOne]); 307 | const expires = moment().subtract(1, 'minutes'); 308 | const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); 309 | await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); 310 | 311 | await request(app) 312 | .post('/v1/auth/reset-password') 313 | .query({ token: resetPasswordToken }) 314 | .send({ password: 'password2' }) 315 | .expect(httpStatus.UNAUTHORIZED); 316 | }); 317 | 318 | test('should return 401 if user is not found', async () => { 319 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 320 | const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); 321 | await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); 322 | 323 | await request(app) 324 | .post('/v1/auth/reset-password') 325 | .query({ token: resetPasswordToken }) 326 | .send({ password: 'password2' }) 327 | .expect(httpStatus.UNAUTHORIZED); 328 | }); 329 | 330 | test('should return 400 if password is missing or invalid', async () => { 331 | await insertUsers([userOne]); 332 | const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); 333 | const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); 334 | await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); 335 | 336 | await request(app).post('/v1/auth/reset-password').query({ token: resetPasswordToken }).expect(httpStatus.BAD_REQUEST); 337 | 338 | await request(app) 339 | .post('/v1/auth/reset-password') 340 | .query({ token: resetPasswordToken }) 341 | .send({ password: 'short1' }) 342 | .expect(httpStatus.BAD_REQUEST); 343 | 344 | await request(app) 345 | .post('/v1/auth/reset-password') 346 | .query({ token: resetPasswordToken }) 347 | .send({ password: 'password' }) 348 | .expect(httpStatus.BAD_REQUEST); 349 | 350 | await request(app) 351 | .post('/v1/auth/reset-password') 352 | .query({ token: resetPasswordToken }) 353 | .send({ password: '11111111' }) 354 | .expect(httpStatus.BAD_REQUEST); 355 | }); 356 | }); 357 | 358 | describe('POST /v1/auth/send-verification-email', () => { 359 | beforeEach(() => { 360 | jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); 361 | }); 362 | 363 | test('should return 204 and send verification email to the user', async () => { 364 | await insertUsers([userOne]); 365 | const sendVerificationEmailSpy = jest.spyOn(emailService, 'sendVerificationEmail'); 366 | 367 | await request(app) 368 | .post('/v1/auth/send-verification-email') 369 | .set('Authorization', `Bearer ${userOneAccessToken}`) 370 | .expect(httpStatus.NO_CONTENT); 371 | 372 | expect(sendVerificationEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); 373 | const verifyEmailToken = sendVerificationEmailSpy.mock.calls[0][1]; 374 | const dbVerifyEmailToken = await Token.findOne({ token: verifyEmailToken, user: userOne._id }); 375 | 376 | expect(dbVerifyEmailToken).toBeDefined(); 377 | }); 378 | 379 | test('should return 401 error if access token is missing', async () => { 380 | await insertUsers([userOne]); 381 | 382 | await request(app).post('/v1/auth/send-verification-email').send().expect(httpStatus.UNAUTHORIZED); 383 | }); 384 | }); 385 | 386 | describe('POST /v1/auth/verify-email', () => { 387 | test('should return 204 and verify the email', async () => { 388 | await insertUsers([userOne]); 389 | const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); 390 | const verifyEmailToken = tokenService.generateToken(userOne._id, expires); 391 | await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); 392 | 393 | await request(app) 394 | .post('/v1/auth/verify-email') 395 | .query({ token: verifyEmailToken }) 396 | .send() 397 | .expect(httpStatus.NO_CONTENT); 398 | 399 | const dbUser = await User.findById(userOne._id); 400 | 401 | expect(dbUser.isEmailVerified).toBe(true); 402 | 403 | const dbVerifyEmailToken = await Token.countDocuments({ 404 | user: userOne._id, 405 | type: tokenTypes.VERIFY_EMAIL, 406 | }); 407 | expect(dbVerifyEmailToken).toBe(0); 408 | }); 409 | 410 | test('should return 400 if verify email token is missing', async () => { 411 | await insertUsers([userOne]); 412 | 413 | await request(app).post('/v1/auth/verify-email').send().expect(httpStatus.BAD_REQUEST); 414 | }); 415 | 416 | test('should return 401 if verify email token is blacklisted', async () => { 417 | await insertUsers([userOne]); 418 | const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); 419 | const verifyEmailToken = tokenService.generateToken(userOne._id, expires); 420 | await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL, true); 421 | 422 | await request(app) 423 | .post('/v1/auth/verify-email') 424 | .query({ token: verifyEmailToken }) 425 | .send() 426 | .expect(httpStatus.UNAUTHORIZED); 427 | }); 428 | 429 | test('should return 401 if verify email token is expired', async () => { 430 | await insertUsers([userOne]); 431 | const expires = moment().subtract(1, 'minutes'); 432 | const verifyEmailToken = tokenService.generateToken(userOne._id, expires); 433 | await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); 434 | 435 | await request(app) 436 | .post('/v1/auth/verify-email') 437 | .query({ token: verifyEmailToken }) 438 | .send() 439 | .expect(httpStatus.UNAUTHORIZED); 440 | }); 441 | 442 | test('should return 401 if user is not found', async () => { 443 | const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); 444 | const verifyEmailToken = tokenService.generateToken(userOne._id, expires); 445 | await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); 446 | 447 | await request(app) 448 | .post('/v1/auth/verify-email') 449 | .query({ token: verifyEmailToken }) 450 | .send() 451 | .expect(httpStatus.UNAUTHORIZED); 452 | }); 453 | }); 454 | }); 455 | 456 | describe('Auth middleware', () => { 457 | test('should call next with no errors if access token is valid', async () => { 458 | await insertUsers([userOne]); 459 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); 460 | const next = jest.fn(); 461 | 462 | await auth()(req, httpMocks.createResponse(), next); 463 | 464 | expect(next).toHaveBeenCalledWith(); 465 | expect(req.user._id).toEqual(userOne._id); 466 | }); 467 | 468 | test('should call next with unauthorized error if access token is not found in header', async () => { 469 | await insertUsers([userOne]); 470 | const req = httpMocks.createRequest(); 471 | const next = jest.fn(); 472 | 473 | await auth()(req, httpMocks.createResponse(), next); 474 | 475 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 476 | expect(next).toHaveBeenCalledWith( 477 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 478 | ); 479 | }); 480 | 481 | test('should call next with unauthorized error if access token is not a valid jwt token', async () => { 482 | await insertUsers([userOne]); 483 | const req = httpMocks.createRequest({ headers: { Authorization: 'Bearer randomToken' } }); 484 | const next = jest.fn(); 485 | 486 | await auth()(req, httpMocks.createResponse(), next); 487 | 488 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 489 | expect(next).toHaveBeenCalledWith( 490 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 491 | ); 492 | }); 493 | 494 | test('should call next with unauthorized error if the token is not an access token', async () => { 495 | await insertUsers([userOne]); 496 | const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 497 | const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); 498 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${refreshToken}` } }); 499 | const next = jest.fn(); 500 | 501 | await auth()(req, httpMocks.createResponse(), next); 502 | 503 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 504 | expect(next).toHaveBeenCalledWith( 505 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 506 | ); 507 | }); 508 | 509 | test('should call next with unauthorized error if access token is generated with an invalid secret', async () => { 510 | await insertUsers([userOne]); 511 | const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); 512 | const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS, 'invalidSecret'); 513 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); 514 | const next = jest.fn(); 515 | 516 | await auth()(req, httpMocks.createResponse(), next); 517 | 518 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 519 | expect(next).toHaveBeenCalledWith( 520 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 521 | ); 522 | }); 523 | 524 | test('should call next with unauthorized error if access token is expired', async () => { 525 | await insertUsers([userOne]); 526 | const expires = moment().subtract(1, 'minutes'); 527 | const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS); 528 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); 529 | const next = jest.fn(); 530 | 531 | await auth()(req, httpMocks.createResponse(), next); 532 | 533 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 534 | expect(next).toHaveBeenCalledWith( 535 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 536 | ); 537 | }); 538 | 539 | test('should call next with unauthorized error if user is not found', async () => { 540 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); 541 | const next = jest.fn(); 542 | 543 | await auth()(req, httpMocks.createResponse(), next); 544 | 545 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 546 | expect(next).toHaveBeenCalledWith( 547 | expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) 548 | ); 549 | }); 550 | 551 | test('should call next with forbidden error if user does not have required rights and userId is not in params', async () => { 552 | await insertUsers([userOne]); 553 | const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); 554 | const next = jest.fn(); 555 | 556 | await auth('anyRight')(req, httpMocks.createResponse(), next); 557 | 558 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 559 | expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: httpStatus.FORBIDDEN, message: 'Forbidden' })); 560 | }); 561 | 562 | test('should call next with no errors if user does not have required rights but userId is in params', async () => { 563 | await insertUsers([userOne]); 564 | const req = httpMocks.createRequest({ 565 | headers: { Authorization: `Bearer ${userOneAccessToken}` }, 566 | params: { userId: userOne._id.toHexString() }, 567 | }); 568 | const next = jest.fn(); 569 | 570 | await auth('anyRight')(req, httpMocks.createResponse(), next); 571 | 572 | expect(next).toHaveBeenCalledWith(); 573 | }); 574 | 575 | test('should call next with no errors if user has required rights', async () => { 576 | await insertUsers([admin]); 577 | const req = httpMocks.createRequest({ 578 | headers: { Authorization: `Bearer ${adminAccessToken}` }, 579 | params: { userId: userOne._id.toHexString() }, 580 | }); 581 | const next = jest.fn(); 582 | 583 | await auth(...roleRights.get('admin'))(req, httpMocks.createResponse(), next); 584 | 585 | expect(next).toHaveBeenCalledWith(); 586 | }); 587 | }); 588 | -------------------------------------------------------------------------------- /tests/integration/docs.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const httpStatus = require('http-status'); 3 | const app = require('../../src/app'); 4 | const config = require('../../src/config/config'); 5 | 6 | describe('Auth routes', () => { 7 | describe('GET /v1/docs', () => { 8 | test('should return 404 when running in production', async () => { 9 | config.env = 'production'; 10 | await request(app).get('/v1/docs').send().expect(httpStatus.NOT_FOUND); 11 | config.env = process.env.NODE_ENV; 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/integration/user.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const faker = require('faker'); 3 | const httpStatus = require('http-status'); 4 | const app = require('../../src/app'); 5 | const setupTestDB = require('../utils/setupTestDB'); 6 | const { User } = require('../../src/models'); 7 | const { userOne, userTwo, admin, insertUsers } = require('../fixtures/user.fixture'); 8 | const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); 9 | 10 | setupTestDB(); 11 | 12 | describe('User routes', () => { 13 | describe('POST /v1/users', () => { 14 | let newUser; 15 | 16 | beforeEach(() => { 17 | newUser = { 18 | name: faker.name.findName(), 19 | email: faker.internet.email().toLowerCase(), 20 | password: 'password1', 21 | role: 'user', 22 | }; 23 | }); 24 | 25 | test('should return 201 and successfully create new user if data is ok', async () => { 26 | await insertUsers([admin]); 27 | 28 | const res = await request(app) 29 | .post('/v1/users') 30 | .set('Authorization', `Bearer ${adminAccessToken}`) 31 | .send(newUser) 32 | .expect(httpStatus.CREATED); 33 | 34 | expect(res.body).not.toHaveProperty('password'); 35 | expect(res.body).toEqual({ 36 | id: expect.anything(), 37 | name: newUser.name, 38 | email: newUser.email, 39 | role: newUser.role, 40 | isEmailVerified: false, 41 | }); 42 | 43 | const dbUser = await User.findById(res.body.id); 44 | expect(dbUser).toBeDefined(); 45 | expect(dbUser.password).not.toBe(newUser.password); 46 | expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVerified: false }); 47 | }); 48 | 49 | test('should be able to create an admin as well', async () => { 50 | await insertUsers([admin]); 51 | newUser.role = 'admin'; 52 | 53 | const res = await request(app) 54 | .post('/v1/users') 55 | .set('Authorization', `Bearer ${adminAccessToken}`) 56 | .send(newUser) 57 | .expect(httpStatus.CREATED); 58 | 59 | expect(res.body.role).toBe('admin'); 60 | 61 | const dbUser = await User.findById(res.body.id); 62 | expect(dbUser.role).toBe('admin'); 63 | }); 64 | 65 | test('should return 401 error if access token is missing', async () => { 66 | await request(app).post('/v1/users').send(newUser).expect(httpStatus.UNAUTHORIZED); 67 | }); 68 | 69 | test('should return 403 error if logged in user is not admin', async () => { 70 | await insertUsers([userOne]); 71 | 72 | await request(app) 73 | .post('/v1/users') 74 | .set('Authorization', `Bearer ${userOneAccessToken}`) 75 | .send(newUser) 76 | .expect(httpStatus.FORBIDDEN); 77 | }); 78 | 79 | test('should return 400 error if email is invalid', async () => { 80 | await insertUsers([admin]); 81 | newUser.email = 'invalidEmail'; 82 | 83 | await request(app) 84 | .post('/v1/users') 85 | .set('Authorization', `Bearer ${adminAccessToken}`) 86 | .send(newUser) 87 | .expect(httpStatus.BAD_REQUEST); 88 | }); 89 | 90 | test('should return 400 error if email is already used', async () => { 91 | await insertUsers([admin, userOne]); 92 | newUser.email = userOne.email; 93 | 94 | await request(app) 95 | .post('/v1/users') 96 | .set('Authorization', `Bearer ${adminAccessToken}`) 97 | .send(newUser) 98 | .expect(httpStatus.BAD_REQUEST); 99 | }); 100 | 101 | test('should return 400 error if password length is less than 8 characters', async () => { 102 | await insertUsers([admin]); 103 | newUser.password = 'passwo1'; 104 | 105 | await request(app) 106 | .post('/v1/users') 107 | .set('Authorization', `Bearer ${adminAccessToken}`) 108 | .send(newUser) 109 | .expect(httpStatus.BAD_REQUEST); 110 | }); 111 | 112 | test('should return 400 error if password does not contain both letters and numbers', async () => { 113 | await insertUsers([admin]); 114 | newUser.password = 'password'; 115 | 116 | await request(app) 117 | .post('/v1/users') 118 | .set('Authorization', `Bearer ${adminAccessToken}`) 119 | .send(newUser) 120 | .expect(httpStatus.BAD_REQUEST); 121 | 122 | newUser.password = '1111111'; 123 | 124 | await request(app) 125 | .post('/v1/users') 126 | .set('Authorization', `Bearer ${adminAccessToken}`) 127 | .send(newUser) 128 | .expect(httpStatus.BAD_REQUEST); 129 | }); 130 | 131 | test('should return 400 error if role is neither user nor admin', async () => { 132 | await insertUsers([admin]); 133 | newUser.role = 'invalid'; 134 | 135 | await request(app) 136 | .post('/v1/users') 137 | .set('Authorization', `Bearer ${adminAccessToken}`) 138 | .send(newUser) 139 | .expect(httpStatus.BAD_REQUEST); 140 | }); 141 | }); 142 | 143 | describe('GET /v1/users', () => { 144 | test('should return 200 and apply the default query options', async () => { 145 | await insertUsers([userOne, userTwo, admin]); 146 | 147 | const res = await request(app) 148 | .get('/v1/users') 149 | .set('Authorization', `Bearer ${adminAccessToken}`) 150 | .send() 151 | .expect(httpStatus.OK); 152 | 153 | expect(res.body).toEqual({ 154 | results: expect.any(Array), 155 | page: 1, 156 | limit: 10, 157 | totalPages: 1, 158 | totalResults: 3, 159 | }); 160 | expect(res.body.results).toHaveLength(3); 161 | expect(res.body.results[0]).toEqual({ 162 | id: userOne._id.toHexString(), 163 | name: userOne.name, 164 | email: userOne.email, 165 | role: userOne.role, 166 | isEmailVerified: userOne.isEmailVerified, 167 | }); 168 | }); 169 | 170 | test('should return 401 if access token is missing', async () => { 171 | await insertUsers([userOne, userTwo, admin]); 172 | 173 | await request(app).get('/v1/users').send().expect(httpStatus.UNAUTHORIZED); 174 | }); 175 | 176 | test('should return 403 if a non-admin is trying to access all users', async () => { 177 | await insertUsers([userOne, userTwo, admin]); 178 | 179 | await request(app) 180 | .get('/v1/users') 181 | .set('Authorization', `Bearer ${userOneAccessToken}`) 182 | .send() 183 | .expect(httpStatus.FORBIDDEN); 184 | }); 185 | 186 | test('should correctly apply filter on name field', async () => { 187 | await insertUsers([userOne, userTwo, admin]); 188 | 189 | const res = await request(app) 190 | .get('/v1/users') 191 | .set('Authorization', `Bearer ${adminAccessToken}`) 192 | .query({ name: userOne.name }) 193 | .send() 194 | .expect(httpStatus.OK); 195 | 196 | expect(res.body).toEqual({ 197 | results: expect.any(Array), 198 | page: 1, 199 | limit: 10, 200 | totalPages: 1, 201 | totalResults: 1, 202 | }); 203 | expect(res.body.results).toHaveLength(1); 204 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 205 | }); 206 | 207 | test('should correctly apply filter on role field', async () => { 208 | await insertUsers([userOne, userTwo, admin]); 209 | 210 | const res = await request(app) 211 | .get('/v1/users') 212 | .set('Authorization', `Bearer ${adminAccessToken}`) 213 | .query({ role: 'user' }) 214 | .send() 215 | .expect(httpStatus.OK); 216 | 217 | expect(res.body).toEqual({ 218 | results: expect.any(Array), 219 | page: 1, 220 | limit: 10, 221 | totalPages: 1, 222 | totalResults: 2, 223 | }); 224 | expect(res.body.results).toHaveLength(2); 225 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 226 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 227 | }); 228 | 229 | test('should correctly sort the returned array if descending sort param is specified', async () => { 230 | await insertUsers([userOne, userTwo, admin]); 231 | 232 | const res = await request(app) 233 | .get('/v1/users') 234 | .set('Authorization', `Bearer ${adminAccessToken}`) 235 | .query({ sortBy: 'role:desc' }) 236 | .send() 237 | .expect(httpStatus.OK); 238 | 239 | expect(res.body).toEqual({ 240 | results: expect.any(Array), 241 | page: 1, 242 | limit: 10, 243 | totalPages: 1, 244 | totalResults: 3, 245 | }); 246 | expect(res.body.results).toHaveLength(3); 247 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 248 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 249 | expect(res.body.results[2].id).toBe(admin._id.toHexString()); 250 | }); 251 | 252 | test('should correctly sort the returned array if ascending sort param is specified', async () => { 253 | await insertUsers([userOne, userTwo, admin]); 254 | 255 | const res = await request(app) 256 | .get('/v1/users') 257 | .set('Authorization', `Bearer ${adminAccessToken}`) 258 | .query({ sortBy: 'role:asc' }) 259 | .send() 260 | .expect(httpStatus.OK); 261 | 262 | expect(res.body).toEqual({ 263 | results: expect.any(Array), 264 | page: 1, 265 | limit: 10, 266 | totalPages: 1, 267 | totalResults: 3, 268 | }); 269 | expect(res.body.results).toHaveLength(3); 270 | expect(res.body.results[0].id).toBe(admin._id.toHexString()); 271 | expect(res.body.results[1].id).toBe(userOne._id.toHexString()); 272 | expect(res.body.results[2].id).toBe(userTwo._id.toHexString()); 273 | }); 274 | 275 | test('should correctly sort the returned array if multiple sorting criteria are specified', async () => { 276 | await insertUsers([userOne, userTwo, admin]); 277 | 278 | const res = await request(app) 279 | .get('/v1/users') 280 | .set('Authorization', `Bearer ${adminAccessToken}`) 281 | .query({ sortBy: 'role:desc,name:asc' }) 282 | .send() 283 | .expect(httpStatus.OK); 284 | 285 | expect(res.body).toEqual({ 286 | results: expect.any(Array), 287 | page: 1, 288 | limit: 10, 289 | totalPages: 1, 290 | totalResults: 3, 291 | }); 292 | expect(res.body.results).toHaveLength(3); 293 | 294 | const expectedOrder = [userOne, userTwo, admin].sort((a, b) => { 295 | if (a.role < b.role) { 296 | return 1; 297 | } 298 | if (a.role > b.role) { 299 | return -1; 300 | } 301 | return a.name < b.name ? -1 : 1; 302 | }); 303 | 304 | expectedOrder.forEach((user, index) => { 305 | expect(res.body.results[index].id).toBe(user._id.toHexString()); 306 | }); 307 | }); 308 | 309 | test('should limit returned array if limit param is specified', async () => { 310 | await insertUsers([userOne, userTwo, admin]); 311 | 312 | const res = await request(app) 313 | .get('/v1/users') 314 | .set('Authorization', `Bearer ${adminAccessToken}`) 315 | .query({ limit: 2 }) 316 | .send() 317 | .expect(httpStatus.OK); 318 | 319 | expect(res.body).toEqual({ 320 | results: expect.any(Array), 321 | page: 1, 322 | limit: 2, 323 | totalPages: 2, 324 | totalResults: 3, 325 | }); 326 | expect(res.body.results).toHaveLength(2); 327 | expect(res.body.results[0].id).toBe(userOne._id.toHexString()); 328 | expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); 329 | }); 330 | 331 | test('should return the correct page if page and limit params are specified', async () => { 332 | await insertUsers([userOne, userTwo, admin]); 333 | 334 | const res = await request(app) 335 | .get('/v1/users') 336 | .set('Authorization', `Bearer ${adminAccessToken}`) 337 | .query({ page: 2, limit: 2 }) 338 | .send() 339 | .expect(httpStatus.OK); 340 | 341 | expect(res.body).toEqual({ 342 | results: expect.any(Array), 343 | page: 2, 344 | limit: 2, 345 | totalPages: 2, 346 | totalResults: 3, 347 | }); 348 | expect(res.body.results).toHaveLength(1); 349 | expect(res.body.results[0].id).toBe(admin._id.toHexString()); 350 | }); 351 | }); 352 | 353 | describe('GET /v1/users/:userId', () => { 354 | test('should return 200 and the user object if data is ok', async () => { 355 | await insertUsers([userOne]); 356 | 357 | const res = await request(app) 358 | .get(`/v1/users/${userOne._id}`) 359 | .set('Authorization', `Bearer ${userOneAccessToken}`) 360 | .send() 361 | .expect(httpStatus.OK); 362 | 363 | expect(res.body).not.toHaveProperty('password'); 364 | expect(res.body).toEqual({ 365 | id: userOne._id.toHexString(), 366 | email: userOne.email, 367 | name: userOne.name, 368 | role: userOne.role, 369 | isEmailVerified: userOne.isEmailVerified, 370 | }); 371 | }); 372 | 373 | test('should return 401 error if access token is missing', async () => { 374 | await insertUsers([userOne]); 375 | 376 | await request(app).get(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); 377 | }); 378 | 379 | test('should return 403 error if user is trying to get another user', async () => { 380 | await insertUsers([userOne, userTwo]); 381 | 382 | await request(app) 383 | .get(`/v1/users/${userTwo._id}`) 384 | .set('Authorization', `Bearer ${userOneAccessToken}`) 385 | .send() 386 | .expect(httpStatus.FORBIDDEN); 387 | }); 388 | 389 | test('should return 200 and the user object if admin is trying to get another user', async () => { 390 | await insertUsers([userOne, admin]); 391 | 392 | await request(app) 393 | .get(`/v1/users/${userOne._id}`) 394 | .set('Authorization', `Bearer ${adminAccessToken}`) 395 | .send() 396 | .expect(httpStatus.OK); 397 | }); 398 | 399 | test('should return 400 error if userId is not a valid mongo id', async () => { 400 | await insertUsers([admin]); 401 | 402 | await request(app) 403 | .get('/v1/users/invalidId') 404 | .set('Authorization', `Bearer ${adminAccessToken}`) 405 | .send() 406 | .expect(httpStatus.BAD_REQUEST); 407 | }); 408 | 409 | test('should return 404 error if user is not found', async () => { 410 | await insertUsers([admin]); 411 | 412 | await request(app) 413 | .get(`/v1/users/${userOne._id}`) 414 | .set('Authorization', `Bearer ${adminAccessToken}`) 415 | .send() 416 | .expect(httpStatus.NOT_FOUND); 417 | }); 418 | }); 419 | 420 | describe('DELETE /v1/users/:userId', () => { 421 | test('should return 204 if data is ok', async () => { 422 | await insertUsers([userOne]); 423 | 424 | await request(app) 425 | .delete(`/v1/users/${userOne._id}`) 426 | .set('Authorization', `Bearer ${userOneAccessToken}`) 427 | .send() 428 | .expect(httpStatus.NO_CONTENT); 429 | 430 | const dbUser = await User.findById(userOne._id); 431 | expect(dbUser).toBeNull(); 432 | }); 433 | 434 | test('should return 401 error if access token is missing', async () => { 435 | await insertUsers([userOne]); 436 | 437 | await request(app).delete(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); 438 | }); 439 | 440 | test('should return 403 error if user is trying to delete another user', async () => { 441 | await insertUsers([userOne, userTwo]); 442 | 443 | await request(app) 444 | .delete(`/v1/users/${userTwo._id}`) 445 | .set('Authorization', `Bearer ${userOneAccessToken}`) 446 | .send() 447 | .expect(httpStatus.FORBIDDEN); 448 | }); 449 | 450 | test('should return 204 if admin is trying to delete another user', async () => { 451 | await insertUsers([userOne, admin]); 452 | 453 | await request(app) 454 | .delete(`/v1/users/${userOne._id}`) 455 | .set('Authorization', `Bearer ${adminAccessToken}`) 456 | .send() 457 | .expect(httpStatus.NO_CONTENT); 458 | }); 459 | 460 | test('should return 400 error if userId is not a valid mongo id', async () => { 461 | await insertUsers([admin]); 462 | 463 | await request(app) 464 | .delete('/v1/users/invalidId') 465 | .set('Authorization', `Bearer ${adminAccessToken}`) 466 | .send() 467 | .expect(httpStatus.BAD_REQUEST); 468 | }); 469 | 470 | test('should return 404 error if user already is not found', async () => { 471 | await insertUsers([admin]); 472 | 473 | await request(app) 474 | .delete(`/v1/users/${userOne._id}`) 475 | .set('Authorization', `Bearer ${adminAccessToken}`) 476 | .send() 477 | .expect(httpStatus.NOT_FOUND); 478 | }); 479 | }); 480 | 481 | describe('PATCH /v1/users/:userId', () => { 482 | test('should return 200 and successfully update user if data is ok', async () => { 483 | await insertUsers([userOne]); 484 | const updateBody = { 485 | name: faker.name.findName(), 486 | email: faker.internet.email().toLowerCase(), 487 | password: 'newPassword1', 488 | }; 489 | 490 | const res = await request(app) 491 | .patch(`/v1/users/${userOne._id}`) 492 | .set('Authorization', `Bearer ${userOneAccessToken}`) 493 | .send(updateBody) 494 | .expect(httpStatus.OK); 495 | 496 | expect(res.body).not.toHaveProperty('password'); 497 | expect(res.body).toEqual({ 498 | id: userOne._id.toHexString(), 499 | name: updateBody.name, 500 | email: updateBody.email, 501 | role: 'user', 502 | isEmailVerified: false, 503 | }); 504 | 505 | const dbUser = await User.findById(userOne._id); 506 | expect(dbUser).toBeDefined(); 507 | expect(dbUser.password).not.toBe(updateBody.password); 508 | expect(dbUser).toMatchObject({ name: updateBody.name, email: updateBody.email, role: 'user' }); 509 | }); 510 | 511 | test('should return 401 error if access token is missing', async () => { 512 | await insertUsers([userOne]); 513 | const updateBody = { name: faker.name.findName() }; 514 | 515 | await request(app).patch(`/v1/users/${userOne._id}`).send(updateBody).expect(httpStatus.UNAUTHORIZED); 516 | }); 517 | 518 | test('should return 403 if user is updating another user', async () => { 519 | await insertUsers([userOne, userTwo]); 520 | const updateBody = { name: faker.name.findName() }; 521 | 522 | await request(app) 523 | .patch(`/v1/users/${userTwo._id}`) 524 | .set('Authorization', `Bearer ${userOneAccessToken}`) 525 | .send(updateBody) 526 | .expect(httpStatus.FORBIDDEN); 527 | }); 528 | 529 | test('should return 200 and successfully update user if admin is updating another user', async () => { 530 | await insertUsers([userOne, admin]); 531 | const updateBody = { name: faker.name.findName() }; 532 | 533 | await request(app) 534 | .patch(`/v1/users/${userOne._id}`) 535 | .set('Authorization', `Bearer ${adminAccessToken}`) 536 | .send(updateBody) 537 | .expect(httpStatus.OK); 538 | }); 539 | 540 | test('should return 404 if admin is updating another user that is not found', async () => { 541 | await insertUsers([admin]); 542 | const updateBody = { name: faker.name.findName() }; 543 | 544 | await request(app) 545 | .patch(`/v1/users/${userOne._id}`) 546 | .set('Authorization', `Bearer ${adminAccessToken}`) 547 | .send(updateBody) 548 | .expect(httpStatus.NOT_FOUND); 549 | }); 550 | 551 | test('should return 400 error if userId is not a valid mongo id', async () => { 552 | await insertUsers([admin]); 553 | const updateBody = { name: faker.name.findName() }; 554 | 555 | await request(app) 556 | .patch(`/v1/users/invalidId`) 557 | .set('Authorization', `Bearer ${adminAccessToken}`) 558 | .send(updateBody) 559 | .expect(httpStatus.BAD_REQUEST); 560 | }); 561 | 562 | test('should return 400 if email is invalid', async () => { 563 | await insertUsers([userOne]); 564 | const updateBody = { email: 'invalidEmail' }; 565 | 566 | await request(app) 567 | .patch(`/v1/users/${userOne._id}`) 568 | .set('Authorization', `Bearer ${userOneAccessToken}`) 569 | .send(updateBody) 570 | .expect(httpStatus.BAD_REQUEST); 571 | }); 572 | 573 | test('should return 400 if email is already taken', async () => { 574 | await insertUsers([userOne, userTwo]); 575 | const updateBody = { email: userTwo.email }; 576 | 577 | await request(app) 578 | .patch(`/v1/users/${userOne._id}`) 579 | .set('Authorization', `Bearer ${userOneAccessToken}`) 580 | .send(updateBody) 581 | .expect(httpStatus.BAD_REQUEST); 582 | }); 583 | 584 | test('should not return 400 if email is my email', async () => { 585 | await insertUsers([userOne]); 586 | const updateBody = { email: userOne.email }; 587 | 588 | await request(app) 589 | .patch(`/v1/users/${userOne._id}`) 590 | .set('Authorization', `Bearer ${userOneAccessToken}`) 591 | .send(updateBody) 592 | .expect(httpStatus.OK); 593 | }); 594 | 595 | test('should return 400 if password length is less than 8 characters', async () => { 596 | await insertUsers([userOne]); 597 | const updateBody = { password: 'passwo1' }; 598 | 599 | await request(app) 600 | .patch(`/v1/users/${userOne._id}`) 601 | .set('Authorization', `Bearer ${userOneAccessToken}`) 602 | .send(updateBody) 603 | .expect(httpStatus.BAD_REQUEST); 604 | }); 605 | 606 | test('should return 400 if password does not contain both letters and numbers', async () => { 607 | await insertUsers([userOne]); 608 | const updateBody = { password: 'password' }; 609 | 610 | await request(app) 611 | .patch(`/v1/users/${userOne._id}`) 612 | .set('Authorization', `Bearer ${userOneAccessToken}`) 613 | .send(updateBody) 614 | .expect(httpStatus.BAD_REQUEST); 615 | 616 | updateBody.password = '11111111'; 617 | 618 | await request(app) 619 | .patch(`/v1/users/${userOne._id}`) 620 | .set('Authorization', `Bearer ${userOneAccessToken}`) 621 | .send(updateBody) 622 | .expect(httpStatus.BAD_REQUEST); 623 | }); 624 | }); 625 | }); 626 | -------------------------------------------------------------------------------- /tests/unit/middlewares/error.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const httpStatus = require('http-status'); 3 | const httpMocks = require('node-mocks-http'); 4 | const { errorConverter, errorHandler } = require('../../../src/middlewares/error'); 5 | const ApiError = require('../../../src/utils/ApiError'); 6 | const config = require('../../../src/config/config'); 7 | const logger = require('../../../src/config/logger'); 8 | 9 | describe('Error middlewares', () => { 10 | describe('Error converter', () => { 11 | test('should return the same ApiError object it was called with', () => { 12 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 13 | const next = jest.fn(); 14 | 15 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 16 | 17 | expect(next).toHaveBeenCalledWith(error); 18 | }); 19 | 20 | test('should convert an Error to ApiError and preserve its status and message', () => { 21 | const error = new Error('Any error'); 22 | error.statusCode = httpStatus.BAD_REQUEST; 23 | const next = jest.fn(); 24 | 25 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 26 | 27 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 28 | expect(next).toHaveBeenCalledWith( 29 | expect.objectContaining({ 30 | statusCode: error.statusCode, 31 | message: error.message, 32 | isOperational: false, 33 | }) 34 | ); 35 | }); 36 | 37 | test('should convert an Error without status to ApiError with status 500', () => { 38 | const error = new Error('Any error'); 39 | const next = jest.fn(); 40 | 41 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 42 | 43 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 44 | expect(next).toHaveBeenCalledWith( 45 | expect.objectContaining({ 46 | statusCode: httpStatus.INTERNAL_SERVER_ERROR, 47 | message: error.message, 48 | isOperational: false, 49 | }) 50 | ); 51 | }); 52 | 53 | test('should convert an Error without message to ApiError with default message of that http status', () => { 54 | const error = new Error(); 55 | error.statusCode = httpStatus.BAD_REQUEST; 56 | const next = jest.fn(); 57 | 58 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 59 | 60 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 61 | expect(next).toHaveBeenCalledWith( 62 | expect.objectContaining({ 63 | statusCode: error.statusCode, 64 | message: httpStatus[error.statusCode], 65 | isOperational: false, 66 | }) 67 | ); 68 | }); 69 | 70 | test('should convert a Mongoose error to ApiError with status 400 and preserve its message', () => { 71 | const error = new mongoose.Error('Any mongoose error'); 72 | const next = jest.fn(); 73 | 74 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 75 | 76 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 77 | expect(next).toHaveBeenCalledWith( 78 | expect.objectContaining({ 79 | statusCode: httpStatus.BAD_REQUEST, 80 | message: error.message, 81 | isOperational: false, 82 | }) 83 | ); 84 | }); 85 | 86 | test('should convert any other object to ApiError with status 500 and its message', () => { 87 | const error = {}; 88 | const next = jest.fn(); 89 | 90 | errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); 91 | 92 | expect(next).toHaveBeenCalledWith(expect.any(ApiError)); 93 | expect(next).toHaveBeenCalledWith( 94 | expect.objectContaining({ 95 | statusCode: httpStatus.INTERNAL_SERVER_ERROR, 96 | message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], 97 | isOperational: false, 98 | }) 99 | ); 100 | }); 101 | }); 102 | 103 | describe('Error handler', () => { 104 | beforeEach(() => { 105 | jest.spyOn(logger, 'error').mockImplementation(() => {}); 106 | }); 107 | 108 | test('should send proper error response and put the error message in res.locals', () => { 109 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 110 | const res = httpMocks.createResponse(); 111 | const sendSpy = jest.spyOn(res, 'send'); 112 | 113 | errorHandler(error, httpMocks.createRequest(), res); 114 | 115 | expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ code: error.statusCode, message: error.message })); 116 | expect(res.locals.errorMessage).toBe(error.message); 117 | }); 118 | 119 | test('should put the error stack in the response if in development mode', () => { 120 | config.env = 'development'; 121 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 122 | const res = httpMocks.createResponse(); 123 | const sendSpy = jest.spyOn(res, 'send'); 124 | 125 | errorHandler(error, httpMocks.createRequest(), res); 126 | 127 | expect(sendSpy).toHaveBeenCalledWith( 128 | expect.objectContaining({ code: error.statusCode, message: error.message, stack: error.stack }) 129 | ); 130 | config.env = process.env.NODE_ENV; 131 | }); 132 | 133 | test('should send internal server error status and message if in production mode and error is not operational', () => { 134 | config.env = 'production'; 135 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error', false); 136 | const res = httpMocks.createResponse(); 137 | const sendSpy = jest.spyOn(res, 'send'); 138 | 139 | errorHandler(error, httpMocks.createRequest(), res); 140 | 141 | expect(sendSpy).toHaveBeenCalledWith( 142 | expect.objectContaining({ 143 | code: httpStatus.INTERNAL_SERVER_ERROR, 144 | message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], 145 | }) 146 | ); 147 | expect(res.locals.errorMessage).toBe(error.message); 148 | config.env = process.env.NODE_ENV; 149 | }); 150 | 151 | test('should preserve original error status and message if in production mode and error is operational', () => { 152 | config.env = 'production'; 153 | const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); 154 | const res = httpMocks.createResponse(); 155 | const sendSpy = jest.spyOn(res, 'send'); 156 | 157 | errorHandler(error, httpMocks.createRequest(), res); 158 | 159 | expect(sendSpy).toHaveBeenCalledWith( 160 | expect.objectContaining({ 161 | code: error.statusCode, 162 | message: error.message, 163 | }) 164 | ); 165 | config.env = process.env.NODE_ENV; 166 | }); 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /tests/unit/models/plugins/paginate.plugin.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const setupTestDB = require('../../../utils/setupTestDB'); 3 | const paginate = require('../../../../src/models/plugins/paginate.plugin'); 4 | 5 | const projectSchema = mongoose.Schema({ 6 | name: { 7 | type: String, 8 | required: true, 9 | }, 10 | }); 11 | 12 | projectSchema.virtual('tasks', { 13 | ref: 'Task', 14 | localField: '_id', 15 | foreignField: 'project', 16 | }); 17 | 18 | projectSchema.plugin(paginate); 19 | const Project = mongoose.model('Project', projectSchema); 20 | 21 | const taskSchema = mongoose.Schema({ 22 | name: { 23 | type: String, 24 | required: true, 25 | }, 26 | project: { 27 | type: mongoose.SchemaTypes.ObjectId, 28 | ref: 'Project', 29 | required: true, 30 | }, 31 | }); 32 | 33 | taskSchema.plugin(paginate); 34 | const Task = mongoose.model('Task', taskSchema); 35 | 36 | setupTestDB(); 37 | 38 | describe('paginate plugin', () => { 39 | describe('populate option', () => { 40 | test('should populate the specified data fields', async () => { 41 | const project = await Project.create({ name: 'Project One' }); 42 | const task = await Task.create({ name: 'Task One', project: project._id }); 43 | 44 | const taskPages = await Task.paginate({ _id: task._id }, { populate: 'project' }); 45 | 46 | expect(taskPages.results[0].project).toHaveProperty('_id', project._id); 47 | }); 48 | 49 | test('should populate nested fields', async () => { 50 | const project = await Project.create({ name: 'Project One' }); 51 | const task = await Task.create({ name: 'Task One', project: project._id }); 52 | 53 | const projectPages = await Project.paginate({ _id: project._id }, { populate: 'tasks.project' }); 54 | const { tasks } = projectPages.results[0]; 55 | 56 | expect(tasks).toHaveLength(1); 57 | expect(tasks[0]).toHaveProperty('_id', task._id); 58 | expect(tasks[0].project).toHaveProperty('_id', project._id); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/unit/models/plugins/toJSON.plugin.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { toJSON } = require('../../../../src/models/plugins'); 3 | 4 | describe('toJSON plugin', () => { 5 | let connection; 6 | 7 | beforeEach(() => { 8 | connection = mongoose.createConnection(); 9 | }); 10 | 11 | it('should replace _id with id', () => { 12 | const schema = mongoose.Schema(); 13 | schema.plugin(toJSON); 14 | const Model = connection.model('Model', schema); 15 | const doc = new Model(); 16 | expect(doc.toJSON()).not.toHaveProperty('_id'); 17 | expect(doc.toJSON()).toHaveProperty('id', doc._id.toString()); 18 | }); 19 | 20 | it('should remove __v', () => { 21 | const schema = mongoose.Schema(); 22 | schema.plugin(toJSON); 23 | const Model = connection.model('Model', schema); 24 | const doc = new Model(); 25 | expect(doc.toJSON()).not.toHaveProperty('__v'); 26 | }); 27 | 28 | it('should remove createdAt and updatedAt', () => { 29 | const schema = mongoose.Schema({}, { timestamps: true }); 30 | schema.plugin(toJSON); 31 | const Model = connection.model('Model', schema); 32 | const doc = new Model(); 33 | expect(doc.toJSON()).not.toHaveProperty('createdAt'); 34 | expect(doc.toJSON()).not.toHaveProperty('updatedAt'); 35 | }); 36 | 37 | it('should remove any path set as private', () => { 38 | const schema = mongoose.Schema({ 39 | public: { type: String }, 40 | private: { type: String, private: true }, 41 | }); 42 | schema.plugin(toJSON); 43 | const Model = connection.model('Model', schema); 44 | const doc = new Model({ public: 'some public value', private: 'some private value' }); 45 | expect(doc.toJSON()).not.toHaveProperty('private'); 46 | expect(doc.toJSON()).toHaveProperty('public'); 47 | }); 48 | 49 | it('should remove any nested paths set as private', () => { 50 | const schema = mongoose.Schema({ 51 | public: { type: String }, 52 | nested: { 53 | private: { type: String, private: true }, 54 | }, 55 | }); 56 | schema.plugin(toJSON); 57 | const Model = connection.model('Model', schema); 58 | const doc = new Model({ 59 | public: 'some public value', 60 | nested: { 61 | private: 'some nested private value', 62 | }, 63 | }); 64 | expect(doc.toJSON()).not.toHaveProperty('nested.private'); 65 | expect(doc.toJSON()).toHaveProperty('public'); 66 | }); 67 | 68 | it('should also call the schema toJSON transform function', () => { 69 | const schema = mongoose.Schema( 70 | { 71 | public: { type: String }, 72 | private: { type: String }, 73 | }, 74 | { 75 | toJSON: { 76 | transform: (doc, ret) => { 77 | // eslint-disable-next-line no-param-reassign 78 | delete ret.private; 79 | }, 80 | }, 81 | } 82 | ); 83 | schema.plugin(toJSON); 84 | const Model = connection.model('Model', schema); 85 | const doc = new Model({ public: 'some public value', private: 'some private value' }); 86 | expect(doc.toJSON()).not.toHaveProperty('private'); 87 | expect(doc.toJSON()).toHaveProperty('public'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /tests/unit/models/user.model.test.js: -------------------------------------------------------------------------------- 1 | const faker = require('faker'); 2 | const { User } = require('../../../src/models'); 3 | 4 | describe('User model', () => { 5 | describe('User validation', () => { 6 | let newUser; 7 | beforeEach(() => { 8 | newUser = { 9 | name: faker.name.findName(), 10 | email: faker.internet.email().toLowerCase(), 11 | password: 'password1', 12 | role: 'user', 13 | }; 14 | }); 15 | 16 | test('should correctly validate a valid user', async () => { 17 | await expect(new User(newUser).validate()).resolves.toBeUndefined(); 18 | }); 19 | 20 | test('should throw a validation error if email is invalid', async () => { 21 | newUser.email = 'invalidEmail'; 22 | await expect(new User(newUser).validate()).rejects.toThrow(); 23 | }); 24 | 25 | test('should throw a validation error if password length is less than 8 characters', async () => { 26 | newUser.password = 'passwo1'; 27 | await expect(new User(newUser).validate()).rejects.toThrow(); 28 | }); 29 | 30 | test('should throw a validation error if password does not contain numbers', async () => { 31 | newUser.password = 'password'; 32 | await expect(new User(newUser).validate()).rejects.toThrow(); 33 | }); 34 | 35 | test('should throw a validation error if password does not contain letters', async () => { 36 | newUser.password = '11111111'; 37 | await expect(new User(newUser).validate()).rejects.toThrow(); 38 | }); 39 | 40 | test('should throw a validation error if role is unknown', async () => { 41 | newUser.role = 'invalid'; 42 | await expect(new User(newUser).validate()).rejects.toThrow(); 43 | }); 44 | }); 45 | 46 | describe('User toJSON()', () => { 47 | test('should not return user password when toJSON is called', () => { 48 | const newUser = { 49 | name: faker.name.findName(), 50 | email: faker.internet.email().toLowerCase(), 51 | password: 'password1', 52 | role: 'user', 53 | }; 54 | expect(new User(newUser).toJSON()).not.toHaveProperty('password'); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/utils/setupTestDB.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const config = require('../../src/config/config'); 3 | 4 | const setupTestDB = () => { 5 | beforeAll(async () => { 6 | await mongoose.connect(config.mongoose.url, config.mongoose.options); 7 | }); 8 | 9 | beforeEach(async () => { 10 | await Promise.all(Object.values(mongoose.connection.collections).map(async (collection) => collection.deleteMany())); 11 | }); 12 | 13 | afterAll(async () => { 14 | await mongoose.disconnect(); 15 | }); 16 | }; 17 | 18 | module.exports = setupTestDB; 19 | --------------------------------------------------------------------------------