The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | [![Build Status](https://travis-ci.org/hagopj13/node-express-boilerplate.svg?branch=master)](https://travis-ci.org/hagopj13/node-express-boilerplate)
  4 | [![Coverage Status](https://coveralls.io/repos/github/hagopj13/node-express-boilerplate/badge.svg?branch=master)](https://coveralls.io/github/hagopj13/node-express-boilerplate?branch=master)
  5 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 | 


--------------------------------------------------------------------------------