Repository: hagopj13/node-express-boilerplate Branch: master Commit: 179ae84efec6 Files: 77 Total size: 148.4 KB Directory structure: gitextract_unlxa9cc/ ├── .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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules .git .gitignore ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true ================================================ FILE: .env.example ================================================ # Port number PORT=3000 # URL of the Mongo DB MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate # JWT # JWT secret key JWT_SECRET=thisisasamplesecret # Number of minutes after which an access token expires JWT_ACCESS_EXPIRATION_MINUTES=30 # Number of days after which a refresh token expires JWT_REFRESH_EXPIRATION_DAYS=30 # Number of minutes after which a reset password token expires JWT_RESET_PASSWORD_EXPIRATION_MINUTES=10 # Number of minutes after which a verify email token expires JWT_VERIFY_EMAIL_EXPIRATION_MINUTES=10 # SMTP configuration options for the email service # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create SMTP_HOST=email-server SMTP_PORT=587 SMTP_USERNAME=email-server-username SMTP_PASSWORD=email-server-password EMAIL_FROM=support@yourapp.com ================================================ FILE: .eslintignore ================================================ node_modules bin ================================================ FILE: .eslintrc.json ================================================ { "env": { "node": true, "jest": true }, "extends": ["airbnb-base", "plugin:jest/recommended", "plugin:security/recommended", "plugin:prettier/recommended"], "plugins": ["jest", "security", "prettier"], "parserOptions": { "ecmaVersion": 2018 }, "rules": { "no-console": "error", "func-names": "off", "no-underscore-dangle": "off", "consistent-return": "off", "jest/expect-expect": "off", "security/detect-object-injection": "off" } } ================================================ FILE: .gitattributes ================================================ # Convert text file line endings to lf * text eol=lf *.js text ================================================ FILE: .gitignore ================================================ # Dependencies node_modules # yarn error logs yarn-error.log # Environment varibales .env* !.env*.example # Code coverage coverage ================================================ FILE: .husky/post-checkout ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" yarn install ================================================ FILE: .husky/post-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" git status ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" yarn lint-staged ================================================ FILE: .lintstagedrc.json ================================================ { "*.js": "eslint" } ================================================ FILE: .prettierignore ================================================ node_modules coverage ================================================ FILE: .prettierrc.json ================================================ { "singleQuote": true, "printWidth": 125 } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - '12' services: - mongodb cache: yarn branches: only: - master env: global: - PORT=3000 - MONGODB_URL=mongodb://localhost:27017/node-boilerplate - JWT_SECRET=thisisasamplesecret - JWT_ACCESS_EXPIRATION_MINUTES=30 - JWT_REFRESH_EXPIRATION_DAYS=30 script: - yarn lint - yarn test after_success: yarn coverage:coveralls ================================================ FILE: CHANGELOG.md ================================================ # Changelog 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. ## [1.7.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.6.0...v1.7.0) (2021-03-30) ### Features - add email verification feature ([#78](https://github.com/hagopj13/node-express-boilerplate/pull/78)) ([9dae3f2](https://github.com/hagopj13/node-express-boilerplate/commit/9dae3f27df371103b6a9f96924980d2d8d7ba14e)) ## [1.6.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.5.0...v1.6.0) (2020-12-27) ### Features - add script to create app using npm init ([acf6fdf](https://github.com/hagopj13/node-express-boilerplate/commit/acf6fdfd105bba476efb171f8cd92d752ecad691)) - disable docs in production ([#59](https://github.com/hagopj13/node-express-boilerplate/pull/59)) ([68d1e33](https://github.com/hagopj13/node-express-boilerplate/commit/68d1e33194c46df93fc99d6e65ecf5feeecd354b)) - 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)) - 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)) ## [1.5.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.4.1...v1.5.0) (2020-09-28) ### Features - 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) ## [1.4.1](https://github.com/hagopj13/node-express-boilerplate/compare/v1.4.0...v1.4.1) (2020-09-14) ### Bug Fixes - upgrade mongoose to solve vulnerability issue ([1650bdf](https://github.com/hagopj13/node-express-boilerplate/commit/1650bdf1bf36ce13597c0ed3503c7b4abef01ee5)) - 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) ## [1.4.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.3.0...v1.4.0) (2020-08-22) ### Features - 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) - add logout endpoint ([750feb5](https://github.com/hagopj13/node-express-boilerplate/commit/750feb5b1ddadb4da6742b445cdb1112a615ace4)), closes [#19](https://github.com/hagopj13/node-express-boilerplate/issues/19) ## [1.3.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.2.0...v1.3.0) (2020-05-17) ### Features - add toJSON custom mongoose schema plugin ([f8ba3f6](https://github.com/hagopj13/node-express-boilerplate/commit/f8ba3f619ac42f2030c358fb44095b72fb37013b)) - 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) ## [1.2.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.3...v1.2.0) (2020-05-13) ### Features - 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) ### Bug Fixes - 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)) ## [1.1.3](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.2...v1.1.3) (2020-03-14) ### Bug Fixes - fix vulnerability issues by upgrading dependencies ([9c15650](https://github.com/hagopj13/node-express-boilerplate/commit/9c15650acfb0d991b621abc60ba534c904fd3fd1)) ## [1.1.2](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.1...v1.1.2) (2020-02-16) ### Bug Fixes - fix issue with incorrect stack for errors that are not of type AppError ([48d1a5a](https://github.com/hagopj13/node-express-boilerplate/commit/48d1a5ada5e5fe0975a17b521d3d7a6e1f4cab3b)) ## [1.1.1](https://github.com/hagopj13/node-express-boilerplate/compare/v1.1.0...v1.1.1) (2019-12-04) ### Bug Fixes - 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)) ## [1.1.0](https://github.com/hagopj13/node-express-boilerplate/compare/v1.0.0...v1.1.0) (2019-11-23) ### Features - add docker support ([3401449](https://github.com/hagopj13/node-express-boilerplate/commit/340144979cf5e84abb047a891a0b908b01af3645)), closes [#2](https://github.com/hagopj13/node-express-boilerplate/issues/2) - verify connection to email server at startup ([f38d86a](https://github.com/hagopj13/node-express-boilerplate/commit/f38d86a181f1816d720e009aa94619e25ef4bf93)) ## 1.0.0 (2019-11-22) ### Features - initial release ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hagopj13@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing First off, thank you so much for taking the time to contribute. All contributions are more than welcome! ## How can I contribute? 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: - **Create an issue** to explain and discuss the details - **Fork the repo** - **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) - **Implement** the necessary changes - **Create tests** to keep the code coverage high - **Send a pull request** ## Guidelines ### Git commit messages - Limit the subject line to 72 characters - Capitalize the first letter of the subject line - Use the present tense ("Add feature" instead of "Added feature") - Separate the subject from the body with a blank line - Reference issues and pull requests in the body ### Coding style guide 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). 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. Please make sure that the code you are pushing conforms to the style guides mentioned above. ================================================ FILE: Dockerfile ================================================ FROM node:alpine RUN mkdir -p /usr/src/node-app && chown -R node:node /usr/src/node-app WORKDIR /usr/src/node-app COPY package.json yarn.lock ./ USER node RUN yarn install --pure-lockfile COPY --chown=node:node . . EXPOSE 3000 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 Hagop Jamkojian Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # RESTful API Node Server Boilerplate [![Build Status](https://travis-ci.org/hagopj13/node-express-boilerplate.svg?branch=master)](https://travis-ci.org/hagopj13/node-express-boilerplate) [![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) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) A boilerplate/starter project for quickly building RESTful APIs using Node.js, Express, and Mongoose. 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. ## Quick Start To create a project, simply run: ```bash npx create-nodejs-express-app ``` Or ```bash npm init nodejs-express-app ``` ## Manual Installation If you would still prefer to do the installation manually, follow these steps: Clone the repo: ```bash git clone --depth 1 https://github.com/hagopj13/node-express-boilerplate.git cd node-express-boilerplate npx rimraf ./.git ``` Install the dependencies: ```bash yarn install ``` Set the environment variables: ```bash cp .env.example .env # open .env and modify the environment variables (if needed) ``` ## Table of Contents - [Features](#features) - [Commands](#commands) - [Environment Variables](#environment-variables) - [Project Structure](#project-structure) - [API Documentation](#api-documentation) - [Error Handling](#error-handling) - [Validation](#validation) - [Authentication](#authentication) - [Authorization](#authorization) - [Logging](#logging) - [Custom Mongoose Plugins](#custom-mongoose-plugins) - [Linting](#linting) - [Contributing](#contributing) ## Features - **NoSQL database**: [MongoDB](https://www.mongodb.com) object data modeling using [Mongoose](https://mongoosejs.com) - **Authentication and authorization**: using [passport](http://www.passportjs.org) - **Validation**: request data validation using [Joi](https://github.com/hapijs/joi) - **Logging**: using [winston](https://github.com/winstonjs/winston) and [morgan](https://github.com/expressjs/morgan) - **Testing**: unit and integration tests using [Jest](https://jestjs.io) - **Error handling**: centralized error handling mechanism - **API documentation**: with [swagger-jsdoc](https://github.com/Surnet/swagger-jsdoc) and [swagger-ui-express](https://github.com/scottie1984/swagger-ui-express) - **Process management**: advanced production process management using [PM2](https://pm2.keymetrics.io) - **Dependency management**: with [Yarn](https://yarnpkg.com) - **Environment variables**: using [dotenv](https://github.com/motdotla/dotenv) and [cross-env](https://github.com/kentcdodds/cross-env#readme) - **Security**: set security HTTP headers using [helmet](https://helmetjs.github.io) - **Santizing**: sanitize request data against xss and query injection - **CORS**: Cross-Origin Resource-Sharing enabled using [cors](https://github.com/expressjs/cors) - **Compression**: gzip compression with [compression](https://github.com/expressjs/compression) - **CI**: continuous integration with [Travis CI](https://travis-ci.org) - **Docker support** - **Code coverage**: using [coveralls](https://coveralls.io) - **Code quality**: with [Codacy](https://www.codacy.com) - **Git hooks**: with [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/okonet/lint-staged) - **Linting**: with [ESLint](https://eslint.org) and [Prettier](https://prettier.io) - **Editor config**: consistent editor configuration using [EditorConfig](https://editorconfig.org) ## Commands Running locally: ```bash yarn dev ``` Running in production: ```bash yarn start ``` Testing: ```bash # run all tests yarn test # run all tests in watch mode yarn test:watch # run test coverage yarn coverage ``` Docker: ```bash # run docker container in development mode yarn docker:dev # run docker container in production mode yarn docker:prod # run all tests in a docker container yarn docker:test ``` Linting: ```bash # run ESLint yarn lint # fix ESLint errors yarn lint:fix # run prettier yarn prettier # fix prettier errors yarn prettier:fix ``` ## Environment Variables The environment variables can be found and modified in the `.env` file. They come with these default values: ```bash # Port number PORT=3000 # URL of the Mongo DB MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate # JWT # JWT secret key JWT_SECRET=thisisasamplesecret # Number of minutes after which an access token expires JWT_ACCESS_EXPIRATION_MINUTES=30 # Number of days after which a refresh token expires JWT_REFRESH_EXPIRATION_DAYS=30 # SMTP configuration options for the email service # For testing, you can use a fake SMTP service like Ethereal: https://ethereal.email/create SMTP_HOST=email-server SMTP_PORT=587 SMTP_USERNAME=email-server-username SMTP_PASSWORD=email-server-password EMAIL_FROM=support@yourapp.com ``` ## Project Structure ``` src\ |--config\ # Environment variables and configuration related things |--controllers\ # Route controllers (controller layer) |--docs\ # Swagger files |--middlewares\ # Custom express middlewares |--models\ # Mongoose models (data layer) |--routes\ # Routes |--services\ # Business logic (service layer) |--utils\ # Utility classes and functions |--validations\ # Request data validation schemas |--app.js # Express app |--index.js # App entry point ``` ## API Documentation 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. ### API Endpoints List of available routes: **Auth routes**:\ `POST /v1/auth/register` - register\ `POST /v1/auth/login` - login\ `POST /v1/auth/refresh-tokens` - refresh auth tokens\ `POST /v1/auth/forgot-password` - send reset password email\ `POST /v1/auth/reset-password` - reset password\ `POST /v1/auth/send-verification-email` - send verification email\ `POST /v1/auth/verify-email` - verify email **User routes**:\ `POST /v1/users` - create a user\ `GET /v1/users` - get all users\ `GET /v1/users/:userId` - get user\ `PATCH /v1/users/:userId` - update user\ `DELETE /v1/users/:userId` - delete user ## Error Handling The app has a centralized error handling mechanism. 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. ```javascript const catchAsync = require('../utils/catchAsync'); const controller = catchAsync(async (req, res) => { // this error will be forwarded to the error handling middleware throw new Error('Something wrong happened'); }); ``` The error handling middleware sends an error response, which has the following format: ```json { "code": 404, "message": "Not found" } ``` When running in development mode, the error response also contains the error stack. 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). 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: ```javascript const httpStatus = require('http-status'); const ApiError = require('../utils/ApiError'); const User = require('../models/User'); const getUser = async (userId) => { const user = await User.findById(userId); if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); } }; ``` ## Validation 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. 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. ```javascript const express = require('express'); const validate = require('../../middlewares/validate'); const userValidation = require('../../validations/user.validation'); const userController = require('../../controllers/user.controller'); const router = express.Router(); router.post('/users', validate(userValidation.createUser), userController.createUser); ``` ## Authentication To require authentication for certain routes, you can use the `auth` middleware. ```javascript const express = require('express'); const auth = require('../../middlewares/auth'); const userController = require('../../controllers/user.controller'); const router = express.Router(); router.post('/users', auth(), userController.createUser); ``` 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. **Generating Access Tokens**: 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). 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. **Refreshing Access Tokens**: 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. 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. ## Authorization The `auth` middleware can also be used to require certain rights/permissions to access a route. ```javascript const express = require('express'); const auth = require('../../middlewares/auth'); const userController = require('../../controllers/user.controller'); const router = express.Router(); router.post('/users', auth('manageUsers'), userController.createUser); ``` In the example above, an authenticated user can access this route only if that user has the `manageUsers` permission. The permissions are role-based. You can view the permissions/rights of each role in the `src/config/roles.js` file. If the user making the request does not have the required permissions to access this route, a Forbidden (403) error is thrown. ## Logging Import the logger from `src/config/logger.js`. It is using the [Winston](https://github.com/winstonjs/winston) logging library. Logging should be done according to the following severity levels (ascending order from most important to least important): ```javascript const logger = require('/config/logger'); logger.error('message'); // level 0 logger.warn('message'); // level 1 logger.info('message'); // level 2 logger.http('message'); // level 3 logger.verbose('message'); // level 4 logger.debug('message'); // level 5 ``` In development mode, log messages of all severity levels will be printed to the console. In production mode, only `info`, `warn`, and `error` logs will be printed to the console.\ It is up to the server (or process manager) to actually read them from the console and store them in log files.\ This app uses pm2 in production mode, which is already configured to store the logs in log files. Note: API request information (request url, response code, timestamp, etc.) are also automatically logged (using [morgan](https://github.com/expressjs/morgan)). ## Custom Mongoose Plugins 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`. ```javascript const mongoose = require('mongoose'); const { toJSON, paginate } = require('./plugins'); const userSchema = mongoose.Schema( { /* schema definition here */ }, { timestamps: true } ); userSchema.plugin(toJSON); userSchema.plugin(paginate); const User = mongoose.model('User', userSchema); ``` ### toJSON The toJSON plugin applies the following changes in the toJSON transform call: - removes \_\_v, createdAt, updatedAt, and any schema path that has private: true - replaces \_id with id ### paginate The paginate plugin adds the `paginate` static method to the mongoose schema. Adding this plugin to the `User` model schema will allow you to do the following: ```javascript const queryUsers = async (filter, options) => { const users = await User.paginate(filter, options); return users; }; ``` The `filter` param is a regular mongo filter. The `options` param can have the following (optional) fields: ```javascript const options = { sortBy: 'name:desc', // sort order limit: 5, // maximum results per page page: 2, // page number }; ``` The plugin also supports sorting by multiple criteria (separated by a comma): `sortBy: name:desc,role:asc` The `paginate` method returns a Promise, which fulfills with an object having the following properties: ```json { "results": [], "page": 2, "limit": 5, "totalPages": 10, "totalResults": 48 } ``` ## Linting Linting is done using [ESLint](https://eslint.org/) and [Prettier](https://prettier.io). 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. To modify the ESLint configuration, update the `.eslintrc.json` file. To modify the Prettier configuration, update the `.prettierrc.json` file. To prevent a certain file or directory from being linted, add it to `.eslintignore` and `.prettierignore`. To maintain a consistent coding style across different IDEs, the project contains `.editorconfig` ## Contributing Contributions are more than welcome! Please check out the [contributing guide](CONTRIBUTING.md). ## Inspirations - [danielfsousa/express-rest-es2017-boilerplate](https://github.com/danielfsousa/express-rest-es2017-boilerplate) - [madhums/node-express-mongoose](https://github.com/madhums/node-express-mongoose) - [kunalkapadia/express-mongoose-es6-rest-api](https://github.com/kunalkapadia/express-mongoose-es6-rest-api) ## License [MIT](LICENSE) ================================================ FILE: bin/createNodejsApp.js ================================================ #!/usr/bin/env node const util = require('util'); const path = require('path'); const fs = require('fs'); const { execSync } = require('child_process'); // Utility functions const exec = util.promisify(require('child_process').exec); async function runCmd(command) { try { const { stdout, stderr } = await exec(command); console.log(stdout); console.log(stderr); } catch { (error) => { console.log(error); }; } } async function hasYarn() { try { await execSync('yarnpkg --version', { stdio: 'ignore' }); return true; } catch { return false; } } // Validate arguments if (process.argv.length < 3) { console.log('Please specify the target project directory.'); console.log('For example:'); console.log(' npx create-nodejs-app my-app'); console.log(' OR'); console.log(' npm init nodejs-app my-app'); process.exit(1); } // Define constants const ownPath = process.cwd(); const folderName = process.argv[2]; const appPath = path.join(ownPath, folderName); const repo = 'https://github.com/hagopj13/node-express-boilerplate.git'; // Check if directory already exists try { fs.mkdirSync(appPath); } catch (err) { if (err.code === 'EEXIST') { console.log('Directory already exists. Please choose another name for the project.'); } else { console.log(err); } process.exit(1); } async function setup() { try { // Clone repo console.log(`Downloading files from repo ${repo}`); await runCmd(`git clone --depth 1 ${repo} ${folderName}`); console.log('Cloned successfully.'); console.log(''); // Change directory process.chdir(appPath); // Install dependencies const useYarn = await hasYarn(); console.log('Installing dependencies...'); if (useYarn) { await runCmd('yarn install'); } else { await runCmd('npm install'); } console.log('Dependencies installed successfully.'); console.log(); // Copy envornment variables fs.copyFileSync(path.join(appPath, '.env.example'), path.join(appPath, '.env')); console.log('Environment files copied.'); // Delete .git folder await runCmd('npx rimraf ./.git'); // Remove extra files fs.unlinkSync(path.join(appPath, 'CHANGELOG.md')); fs.unlinkSync(path.join(appPath, 'CODE_OF_CONDUCT.md')); fs.unlinkSync(path.join(appPath, 'CONTRIBUTING.md')); fs.unlinkSync(path.join(appPath, 'bin', 'createNodejsApp.js')); fs.rmdirSync(path.join(appPath, 'bin')); if (!useYarn) { fs.unlinkSync(path.join(appPath, 'yarn.lock')); } console.log('Installation is now complete!'); console.log(); console.log('We suggest that you start by typing:'); console.log(` cd ${folderName}`); console.log(useYarn ? ' yarn dev' : ' npm run dev'); console.log(); console.log('Enjoy your production-ready Node.js app, which already supports a large number of ready-made features!'); console.log('Check README.md for more info.'); } catch (error) { console.log(error); } } setup(); ================================================ FILE: docker-compose.dev.yml ================================================ version: '3' services: node-app: container_name: node-app-dev command: yarn dev -L ================================================ FILE: docker-compose.prod.yml ================================================ version: '3' services: node-app: container_name: node-app-prod command: yarn start ================================================ FILE: docker-compose.test.yml ================================================ version: '3' services: node-app: container_name: node-app-test command: yarn test ================================================ FILE: docker-compose.yml ================================================ version: '3' services: node-app: build: . image: node-app environment: - MONGODB_URL=mongodb://mongodb:27017/node-boilerplate ports: - '3000:3000' depends_on: - mongodb volumes: - .:/usr/src/node-app networks: - node-network mongodb: image: mongo:4.2.1-bionic ports: - '27017:27017' volumes: - dbdata:/data/db networks: - node-network volumes: dbdata: networks: node-network: driver: bridge ================================================ FILE: ecosystem.config.json ================================================ { "apps": [ { "name": "app", "script": "src/index.js", "instances": 1, "autorestart": true, "watch": false, "time": true, "env": { "NODE_ENV": "production" } } ] } ================================================ FILE: jest.config.js ================================================ module.exports = { testEnvironment: 'node', testEnvironmentOptions: { NODE_ENV: 'test', }, restoreMocks: true, coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.js', 'tests'], coverageReporters: ['text', 'lcov', 'clover', 'html'], }; ================================================ FILE: package.json ================================================ { "name": "create-nodejs-express-app", "version": "1.7.0", "description": "Create a Node.js app for building production-ready RESTful APIs using Express, by running one command", "bin": "bin/createNodejsApp.js", "main": "src/index.js", "repository": "https://github.com/hagopj13/node-express-boilerplate.git", "author": "Hagop Jamkojian ", "license": "MIT", "engines": { "node": ">=12.0.0" }, "scripts": { "start": "pm2 start ecosystem.config.json --no-daemon", "dev": "cross-env NODE_ENV=development nodemon src/index.js", "test": "jest -i --colors --verbose --detectOpenHandles", "test:watch": "jest -i --watchAll", "coverage": "jest -i --coverage", "coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls", "lint": "eslint .", "lint:fix": "eslint . --fix", "prettier": "prettier --check **/*.js", "prettier:fix": "prettier --write **/*.js", "docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up", "docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up", "docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up", "prepare": "husky install" }, "keywords": [ "node", "node.js", "boilerplate", "generator", "express", "rest", "api", "mongodb", "mongoose", "es6", "es7", "es8", "es9", "jest", "travis", "docker", "passport", "joi", "eslint", "prettier" ], "dependencies": { "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", "cross-env": "^7.0.0", "dotenv": "^10.0.0", "express": "^4.17.1", "express-mongo-sanitize": "^2.0.0", "express-rate-limit": "^5.0.0", "helmet": "^4.1.0", "http-status": "^1.4.0", "joi": "^17.3.0", "jsonwebtoken": "^8.5.1", "moment": "^2.24.0", "mongoose": "^5.7.7", "morgan": "^1.9.1", "nodemailer": "^6.3.1", "passport": "^0.4.0", "passport-jwt": "^4.0.0", "pm2": "^5.1.0", "swagger-jsdoc": "^6.0.8", "swagger-ui-express": "^4.1.6", "validator": "^13.0.0", "winston": "^3.2.1", "xss-clean": "^0.1.1" }, "devDependencies": { "coveralls": "^3.0.7", "eslint": "^7.0.0", "eslint-config-airbnb-base": "^14.0.0", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-jest": "^24.0.1", "eslint-plugin-prettier": "^3.1.1", "eslint-plugin-security": "^1.4.0", "faker": "^5.1.0", "husky": "7.0.4", "jest": "^26.0.1", "lint-staged": "^11.0.0", "node-mocks-http": "^1.8.0", "nodemon": "^2.0.0", "prettier": "^2.0.5", "supertest": "^6.0.1" } } ================================================ FILE: src/app.js ================================================ const express = require('express'); const helmet = require('helmet'); const xss = require('xss-clean'); const mongoSanitize = require('express-mongo-sanitize'); const compression = require('compression'); const cors = require('cors'); const passport = require('passport'); const httpStatus = require('http-status'); const config = require('./config/config'); const morgan = require('./config/morgan'); const { jwtStrategy } = require('./config/passport'); const { authLimiter } = require('./middlewares/rateLimiter'); const routes = require('./routes/v1'); const { errorConverter, errorHandler } = require('./middlewares/error'); const ApiError = require('./utils/ApiError'); const app = express(); if (config.env !== 'test') { app.use(morgan.successHandler); app.use(morgan.errorHandler); } // set security HTTP headers app.use(helmet()); // parse json request body app.use(express.json()); // parse urlencoded request body app.use(express.urlencoded({ extended: true })); // sanitize request data app.use(xss()); app.use(mongoSanitize()); // gzip compression app.use(compression()); // enable cors app.use(cors()); app.options('*', cors()); // jwt authentication app.use(passport.initialize()); passport.use('jwt', jwtStrategy); // limit repeated failed requests to auth endpoints if (config.env === 'production') { app.use('/v1/auth', authLimiter); } // v1 api routes app.use('/v1', routes); // send back a 404 error for any unknown api request app.use((req, res, next) => { next(new ApiError(httpStatus.NOT_FOUND, 'Not found')); }); // convert error to ApiError, if needed app.use(errorConverter); // handle error app.use(errorHandler); module.exports = app; ================================================ FILE: src/config/config.js ================================================ const dotenv = require('dotenv'); const path = require('path'); const Joi = require('joi'); dotenv.config({ path: path.join(__dirname, '../../.env') }); const envVarsSchema = Joi.object() .keys({ NODE_ENV: Joi.string().valid('production', 'development', 'test').required(), PORT: Joi.number().default(3000), MONGODB_URL: Joi.string().required().description('Mongo DB url'), JWT_SECRET: Joi.string().required().description('JWT secret key'), JWT_ACCESS_EXPIRATION_MINUTES: Joi.number().default(30).description('minutes after which access tokens expire'), JWT_REFRESH_EXPIRATION_DAYS: Joi.number().default(30).description('days after which refresh tokens expire'), JWT_RESET_PASSWORD_EXPIRATION_MINUTES: Joi.number() .default(10) .description('minutes after which reset password token expires'), JWT_VERIFY_EMAIL_EXPIRATION_MINUTES: Joi.number() .default(10) .description('minutes after which verify email token expires'), SMTP_HOST: Joi.string().description('server that will send the emails'), SMTP_PORT: Joi.number().description('port to connect to the email server'), SMTP_USERNAME: Joi.string().description('username for email server'), SMTP_PASSWORD: Joi.string().description('password for email server'), EMAIL_FROM: Joi.string().description('the from field in the emails sent by the app'), }) .unknown(); const { value: envVars, error } = envVarsSchema.prefs({ errors: { label: 'key' } }).validate(process.env); if (error) { throw new Error(`Config validation error: ${error.message}`); } module.exports = { env: envVars.NODE_ENV, port: envVars.PORT, mongoose: { url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''), options: { useCreateIndex: true, useNewUrlParser: true, useUnifiedTopology: true, }, }, jwt: { secret: envVars.JWT_SECRET, accessExpirationMinutes: envVars.JWT_ACCESS_EXPIRATION_MINUTES, refreshExpirationDays: envVars.JWT_REFRESH_EXPIRATION_DAYS, resetPasswordExpirationMinutes: envVars.JWT_RESET_PASSWORD_EXPIRATION_MINUTES, verifyEmailExpirationMinutes: envVars.JWT_VERIFY_EMAIL_EXPIRATION_MINUTES, }, email: { smtp: { host: envVars.SMTP_HOST, port: envVars.SMTP_PORT, auth: { user: envVars.SMTP_USERNAME, pass: envVars.SMTP_PASSWORD, }, }, from: envVars.EMAIL_FROM, }, }; ================================================ FILE: src/config/logger.js ================================================ const winston = require('winston'); const config = require('./config'); const enumerateErrorFormat = winston.format((info) => { if (info instanceof Error) { Object.assign(info, { message: info.stack }); } return info; }); const logger = winston.createLogger({ level: config.env === 'development' ? 'debug' : 'info', format: winston.format.combine( enumerateErrorFormat(), config.env === 'development' ? winston.format.colorize() : winston.format.uncolorize(), winston.format.splat(), winston.format.printf(({ level, message }) => `${level}: ${message}`) ), transports: [ new winston.transports.Console({ stderrLevels: ['error'], }), ], }); module.exports = logger; ================================================ FILE: src/config/morgan.js ================================================ const morgan = require('morgan'); const config = require('./config'); const logger = require('./logger'); morgan.token('message', (req, res) => res.locals.errorMessage || ''); const getIpFormat = () => (config.env === 'production' ? ':remote-addr - ' : ''); const successResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms`; const errorResponseFormat = `${getIpFormat()}:method :url :status - :response-time ms - message: :message`; const successHandler = morgan(successResponseFormat, { skip: (req, res) => res.statusCode >= 400, stream: { write: (message) => logger.info(message.trim()) }, }); const errorHandler = morgan(errorResponseFormat, { skip: (req, res) => res.statusCode < 400, stream: { write: (message) => logger.error(message.trim()) }, }); module.exports = { successHandler, errorHandler, }; ================================================ FILE: src/config/passport.js ================================================ const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const config = require('./config'); const { tokenTypes } = require('./tokens'); const { User } = require('../models'); const jwtOptions = { secretOrKey: config.jwt.secret, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), }; const jwtVerify = async (payload, done) => { try { if (payload.type !== tokenTypes.ACCESS) { throw new Error('Invalid token type'); } const user = await User.findById(payload.sub); if (!user) { return done(null, false); } done(null, user); } catch (error) { done(error, false); } }; const jwtStrategy = new JwtStrategy(jwtOptions, jwtVerify); module.exports = { jwtStrategy, }; ================================================ FILE: src/config/roles.js ================================================ const allRoles = { user: [], admin: ['getUsers', 'manageUsers'], }; const roles = Object.keys(allRoles); const roleRights = new Map(Object.entries(allRoles)); module.exports = { roles, roleRights, }; ================================================ FILE: src/config/tokens.js ================================================ const tokenTypes = { ACCESS: 'access', REFRESH: 'refresh', RESET_PASSWORD: 'resetPassword', VERIFY_EMAIL: 'verifyEmail', }; module.exports = { tokenTypes, }; ================================================ FILE: src/controllers/auth.controller.js ================================================ const httpStatus = require('http-status'); const catchAsync = require('../utils/catchAsync'); const { authService, userService, tokenService, emailService } = require('../services'); const register = catchAsync(async (req, res) => { const user = await userService.createUser(req.body); const tokens = await tokenService.generateAuthTokens(user); res.status(httpStatus.CREATED).send({ user, tokens }); }); const login = catchAsync(async (req, res) => { const { email, password } = req.body; const user = await authService.loginUserWithEmailAndPassword(email, password); const tokens = await tokenService.generateAuthTokens(user); res.send({ user, tokens }); }); const logout = catchAsync(async (req, res) => { await authService.logout(req.body.refreshToken); res.status(httpStatus.NO_CONTENT).send(); }); const refreshTokens = catchAsync(async (req, res) => { const tokens = await authService.refreshAuth(req.body.refreshToken); res.send({ ...tokens }); }); const forgotPassword = catchAsync(async (req, res) => { const resetPasswordToken = await tokenService.generateResetPasswordToken(req.body.email); await emailService.sendResetPasswordEmail(req.body.email, resetPasswordToken); res.status(httpStatus.NO_CONTENT).send(); }); const resetPassword = catchAsync(async (req, res) => { await authService.resetPassword(req.query.token, req.body.password); res.status(httpStatus.NO_CONTENT).send(); }); const sendVerificationEmail = catchAsync(async (req, res) => { const verifyEmailToken = await tokenService.generateVerifyEmailToken(req.user); await emailService.sendVerificationEmail(req.user.email, verifyEmailToken); res.status(httpStatus.NO_CONTENT).send(); }); const verifyEmail = catchAsync(async (req, res) => { await authService.verifyEmail(req.query.token); res.status(httpStatus.NO_CONTENT).send(); }); module.exports = { register, login, logout, refreshTokens, forgotPassword, resetPassword, sendVerificationEmail, verifyEmail, }; ================================================ FILE: src/controllers/index.js ================================================ module.exports.authController = require('./auth.controller'); module.exports.userController = require('./user.controller'); ================================================ FILE: src/controllers/user.controller.js ================================================ const httpStatus = require('http-status'); const pick = require('../utils/pick'); const ApiError = require('../utils/ApiError'); const catchAsync = require('../utils/catchAsync'); const { userService } = require('../services'); const createUser = catchAsync(async (req, res) => { const user = await userService.createUser(req.body); res.status(httpStatus.CREATED).send(user); }); const getUsers = catchAsync(async (req, res) => { const filter = pick(req.query, ['name', 'role']); const options = pick(req.query, ['sortBy', 'limit', 'page']); const result = await userService.queryUsers(filter, options); res.send(result); }); const getUser = catchAsync(async (req, res) => { const user = await userService.getUserById(req.params.userId); if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); } res.send(user); }); const updateUser = catchAsync(async (req, res) => { const user = await userService.updateUserById(req.params.userId, req.body); res.send(user); }); const deleteUser = catchAsync(async (req, res) => { await userService.deleteUserById(req.params.userId); res.status(httpStatus.NO_CONTENT).send(); }); module.exports = { createUser, getUsers, getUser, updateUser, deleteUser, }; ================================================ FILE: src/docs/components.yml ================================================ components: schemas: User: type: object properties: id: type: string email: type: string format: email name: type: string role: type: string enum: [user, admin] example: id: 5ebac534954b54139806c112 email: fake@example.com name: fake name role: user Token: type: object properties: token: type: string expires: type: string format: date-time example: token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg expires: 2020-05-12T16:18:04.793Z AuthTokens: type: object properties: access: $ref: '#/components/schemas/Token' refresh: $ref: '#/components/schemas/Token' Error: type: object properties: code: type: number message: type: string responses: DuplicateEmail: description: Email already taken content: application/json: schema: $ref: '#/components/schemas/Error' example: code: 400 message: Email already taken Unauthorized: description: Unauthorized content: application/json: schema: $ref: '#/components/schemas/Error' example: code: 401 message: Please authenticate Forbidden: description: Forbidden content: application/json: schema: $ref: '#/components/schemas/Error' example: code: 403 message: Forbidden NotFound: description: Not found content: application/json: schema: $ref: '#/components/schemas/Error' example: code: 404 message: Not found securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT ================================================ FILE: src/docs/swaggerDef.js ================================================ const { version } = require('../../package.json'); const config = require('../config/config'); const swaggerDef = { openapi: '3.0.0', info: { title: 'node-express-boilerplate API documentation', version, license: { name: 'MIT', url: 'https://github.com/hagopj13/node-express-boilerplate/blob/master/LICENSE', }, }, servers: [ { url: `http://localhost:${config.port}/v1`, }, ], }; module.exports = swaggerDef; ================================================ FILE: src/index.js ================================================ const mongoose = require('mongoose'); const app = require('./app'); const config = require('./config/config'); const logger = require('./config/logger'); let server; mongoose.connect(config.mongoose.url, config.mongoose.options).then(() => { logger.info('Connected to MongoDB'); server = app.listen(config.port, () => { logger.info(`Listening to port ${config.port}`); }); }); const exitHandler = () => { if (server) { server.close(() => { logger.info('Server closed'); process.exit(1); }); } else { process.exit(1); } }; const unexpectedErrorHandler = (error) => { logger.error(error); exitHandler(); }; process.on('uncaughtException', unexpectedErrorHandler); process.on('unhandledRejection', unexpectedErrorHandler); process.on('SIGTERM', () => { logger.info('SIGTERM received'); if (server) { server.close(); } }); ================================================ FILE: src/middlewares/auth.js ================================================ const passport = require('passport'); const httpStatus = require('http-status'); const ApiError = require('../utils/ApiError'); const { roleRights } = require('../config/roles'); const verifyCallback = (req, resolve, reject, requiredRights) => async (err, user, info) => { if (err || info || !user) { return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); } req.user = user; if (requiredRights.length) { const userRights = roleRights.get(user.role); const hasRequiredRights = requiredRights.every((requiredRight) => userRights.includes(requiredRight)); if (!hasRequiredRights && req.params.userId !== user.id) { return reject(new ApiError(httpStatus.FORBIDDEN, 'Forbidden')); } } resolve(); }; const auth = (...requiredRights) => async (req, res, next) => { return new Promise((resolve, reject) => { passport.authenticate('jwt', { session: false }, verifyCallback(req, resolve, reject, requiredRights))(req, res, next); }) .then(() => next()) .catch((err) => next(err)); }; module.exports = auth; ================================================ FILE: src/middlewares/error.js ================================================ const mongoose = require('mongoose'); const httpStatus = require('http-status'); const config = require('../config/config'); const logger = require('../config/logger'); const ApiError = require('../utils/ApiError'); const errorConverter = (err, req, res, next) => { let error = err; if (!(error instanceof ApiError)) { const statusCode = error.statusCode || error instanceof mongoose.Error ? httpStatus.BAD_REQUEST : httpStatus.INTERNAL_SERVER_ERROR; const message = error.message || httpStatus[statusCode]; error = new ApiError(statusCode, message, false, err.stack); } next(error); }; // eslint-disable-next-line no-unused-vars const errorHandler = (err, req, res, next) => { let { statusCode, message } = err; if (config.env === 'production' && !err.isOperational) { statusCode = httpStatus.INTERNAL_SERVER_ERROR; message = httpStatus[httpStatus.INTERNAL_SERVER_ERROR]; } res.locals.errorMessage = err.message; const response = { code: statusCode, message, ...(config.env === 'development' && { stack: err.stack }), }; if (config.env === 'development') { logger.error(err); } res.status(statusCode).send(response); }; module.exports = { errorConverter, errorHandler, }; ================================================ FILE: src/middlewares/rateLimiter.js ================================================ const rateLimit = require('express-rate-limit'); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, skipSuccessfulRequests: true, }); module.exports = { authLimiter, }; ================================================ FILE: src/middlewares/validate.js ================================================ const Joi = require('joi'); const httpStatus = require('http-status'); const pick = require('../utils/pick'); const ApiError = require('../utils/ApiError'); const validate = (schema) => (req, res, next) => { const validSchema = pick(schema, ['params', 'query', 'body']); const object = pick(req, Object.keys(validSchema)); const { value, error } = Joi.compile(validSchema) .prefs({ errors: { label: 'key' }, abortEarly: false }) .validate(object); if (error) { const errorMessage = error.details.map((details) => details.message).join(', '); return next(new ApiError(httpStatus.BAD_REQUEST, errorMessage)); } Object.assign(req, value); return next(); }; module.exports = validate; ================================================ FILE: src/models/index.js ================================================ module.exports.Token = require('./token.model'); module.exports.User = require('./user.model'); ================================================ FILE: src/models/plugins/index.js ================================================ module.exports.toJSON = require('./toJSON.plugin'); module.exports.paginate = require('./paginate.plugin'); ================================================ FILE: src/models/plugins/paginate.plugin.js ================================================ /* eslint-disable no-param-reassign */ const paginate = (schema) => { /** * @typedef {Object} QueryResult * @property {Document[]} results - Results found * @property {number} page - Current page * @property {number} limit - Maximum number of results per page * @property {number} totalPages - Total number of pages * @property {number} totalResults - Total number of documents */ /** * Query for documents with pagination * @param {Object} [filter] - Mongo filter * @param {Object} [options] - Query options * @param {string} [options.sortBy] - Sorting criteria using the format: sortField:(desc|asc). Multiple sorting criteria should be separated by commas (,) * @param {string} [options.populate] - Populate data fields. Hierarchy of fields should be separated by (.). Multiple populating criteria should be separated by commas (,) * @param {number} [options.limit] - Maximum number of results per page (default = 10) * @param {number} [options.page] - Current page (default = 1) * @returns {Promise} */ schema.statics.paginate = async function (filter, options) { let sort = ''; if (options.sortBy) { const sortingCriteria = []; options.sortBy.split(',').forEach((sortOption) => { const [key, order] = sortOption.split(':'); sortingCriteria.push((order === 'desc' ? '-' : '') + key); }); sort = sortingCriteria.join(' '); } else { sort = 'createdAt'; } const limit = options.limit && parseInt(options.limit, 10) > 0 ? parseInt(options.limit, 10) : 10; const page = options.page && parseInt(options.page, 10) > 0 ? parseInt(options.page, 10) : 1; const skip = (page - 1) * limit; const countPromise = this.countDocuments(filter).exec(); let docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit); if (options.populate) { options.populate.split(',').forEach((populateOption) => { docsPromise = docsPromise.populate( populateOption .split('.') .reverse() .reduce((a, b) => ({ path: b, populate: a })) ); }); } docsPromise = docsPromise.exec(); return Promise.all([countPromise, docsPromise]).then((values) => { const [totalResults, results] = values; const totalPages = Math.ceil(totalResults / limit); const result = { results, page, limit, totalPages, totalResults, }; return Promise.resolve(result); }); }; }; module.exports = paginate; ================================================ FILE: src/models/plugins/toJSON.plugin.js ================================================ /* eslint-disable no-param-reassign */ /** * A mongoose schema plugin which applies the following in the toJSON transform call: * - removes __v, createdAt, updatedAt, and any path that has private: true * - replaces _id with id */ const deleteAtPath = (obj, path, index) => { if (index === path.length - 1) { delete obj[path[index]]; return; } deleteAtPath(obj[path[index]], path, index + 1); }; const toJSON = (schema) => { let transform; if (schema.options.toJSON && schema.options.toJSON.transform) { transform = schema.options.toJSON.transform; } schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { transform(doc, ret, options) { Object.keys(schema.paths).forEach((path) => { if (schema.paths[path].options && schema.paths[path].options.private) { deleteAtPath(ret, path.split('.'), 0); } }); ret.id = ret._id.toString(); delete ret._id; delete ret.__v; delete ret.createdAt; delete ret.updatedAt; if (transform) { return transform(doc, ret, options); } }, }); }; module.exports = toJSON; ================================================ FILE: src/models/token.model.js ================================================ const mongoose = require('mongoose'); const { toJSON } = require('./plugins'); const { tokenTypes } = require('../config/tokens'); const tokenSchema = mongoose.Schema( { token: { type: String, required: true, index: true, }, user: { type: mongoose.SchemaTypes.ObjectId, ref: 'User', required: true, }, type: { type: String, enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL], required: true, }, expires: { type: Date, required: true, }, blacklisted: { type: Boolean, default: false, }, }, { timestamps: true, } ); // add plugin that converts mongoose to json tokenSchema.plugin(toJSON); /** * @typedef Token */ const Token = mongoose.model('Token', tokenSchema); module.exports = Token; ================================================ FILE: src/models/user.model.js ================================================ const mongoose = require('mongoose'); const validator = require('validator'); const bcrypt = require('bcryptjs'); const { toJSON, paginate } = require('./plugins'); const { roles } = require('../config/roles'); const userSchema = mongoose.Schema( { name: { type: String, required: true, trim: true, }, email: { type: String, required: true, unique: true, trim: true, lowercase: true, validate(value) { if (!validator.isEmail(value)) { throw new Error('Invalid email'); } }, }, password: { type: String, required: true, trim: true, minlength: 8, validate(value) { if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { throw new Error('Password must contain at least one letter and one number'); } }, private: true, // used by the toJSON plugin }, role: { type: String, enum: roles, default: 'user', }, isEmailVerified: { type: Boolean, default: false, }, }, { timestamps: true, } ); // add plugin that converts mongoose to json userSchema.plugin(toJSON); userSchema.plugin(paginate); /** * Check if email is taken * @param {string} email - The user's email * @param {ObjectId} [excludeUserId] - The id of the user to be excluded * @returns {Promise} */ userSchema.statics.isEmailTaken = async function (email, excludeUserId) { const user = await this.findOne({ email, _id: { $ne: excludeUserId } }); return !!user; }; /** * Check if password matches the user's password * @param {string} password * @returns {Promise} */ userSchema.methods.isPasswordMatch = async function (password) { const user = this; return bcrypt.compare(password, user.password); }; userSchema.pre('save', async function (next) { const user = this; if (user.isModified('password')) { user.password = await bcrypt.hash(user.password, 8); } next(); }); /** * @typedef User */ const User = mongoose.model('User', userSchema); module.exports = User; ================================================ FILE: src/routes/v1/auth.route.js ================================================ const express = require('express'); const validate = require('../../middlewares/validate'); const authValidation = require('../../validations/auth.validation'); const authController = require('../../controllers/auth.controller'); const auth = require('../../middlewares/auth'); const router = express.Router(); router.post('/register', validate(authValidation.register), authController.register); router.post('/login', validate(authValidation.login), authController.login); router.post('/logout', validate(authValidation.logout), authController.logout); router.post('/refresh-tokens', validate(authValidation.refreshTokens), authController.refreshTokens); router.post('/forgot-password', validate(authValidation.forgotPassword), authController.forgotPassword); router.post('/reset-password', validate(authValidation.resetPassword), authController.resetPassword); router.post('/send-verification-email', auth(), authController.sendVerificationEmail); router.post('/verify-email', validate(authValidation.verifyEmail), authController.verifyEmail); module.exports = router; /** * @swagger * tags: * name: Auth * description: Authentication */ /** * @swagger * /auth/register: * post: * summary: Register as user * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * - email * - password * properties: * name: * type: string * email: * type: string * format: email * description: must be unique * password: * type: string * format: password * minLength: 8 * description: At least one number and one letter * example: * name: fake name * email: fake@example.com * password: password1 * responses: * "201": * description: Created * content: * application/json: * schema: * type: object * properties: * user: * $ref: '#/components/schemas/User' * tokens: * $ref: '#/components/schemas/AuthTokens' * "400": * $ref: '#/components/responses/DuplicateEmail' */ /** * @swagger * /auth/login: * post: * summary: Login * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - email * - password * properties: * email: * type: string * format: email * password: * type: string * format: password * example: * email: fake@example.com * password: password1 * responses: * "200": * description: OK * content: * application/json: * schema: * type: object * properties: * user: * $ref: '#/components/schemas/User' * tokens: * $ref: '#/components/schemas/AuthTokens' * "401": * description: Invalid email or password * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * example: * code: 401 * message: Invalid email or password */ /** * @swagger * /auth/logout: * post: * summary: Logout * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - refreshToken * properties: * refreshToken: * type: string * example: * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg * responses: * "204": * description: No content * "404": * $ref: '#/components/responses/NotFound' */ /** * @swagger * /auth/refresh-tokens: * post: * summary: Refresh auth tokens * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - refreshToken * properties: * refreshToken: * type: string * example: * refreshToken: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1ZWJhYzUzNDk1NGI1NDEzOTgwNmMxMTIiLCJpYXQiOjE1ODkyOTg0ODQsImV4cCI6MTU4OTMwMDI4NH0.m1U63blB0MLej_WfB7yC2FTMnCziif9X8yzwDEfJXAg * responses: * "200": * description: OK * content: * application/json: * schema: * $ref: '#/components/schemas/AuthTokens' * "401": * $ref: '#/components/responses/Unauthorized' */ /** * @swagger * /auth/forgot-password: * post: * summary: Forgot password * description: An email will be sent to reset password. * tags: [Auth] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - email * properties: * email: * type: string * format: email * example: * email: fake@example.com * responses: * "204": * description: No content * "404": * $ref: '#/components/responses/NotFound' */ /** * @swagger * /auth/reset-password: * post: * summary: Reset password * tags: [Auth] * parameters: * - in: query * name: token * required: true * schema: * type: string * description: The reset password token * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - password * properties: * password: * type: string * format: password * minLength: 8 * description: At least one number and one letter * example: * password: password1 * responses: * "204": * description: No content * "401": * description: Password reset failed * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * example: * code: 401 * message: Password reset failed */ /** * @swagger * /auth/send-verification-email: * post: * summary: Send verification email * description: An email will be sent to verify email. * tags: [Auth] * security: * - bearerAuth: [] * responses: * "204": * description: No content * "401": * $ref: '#/components/responses/Unauthorized' */ /** * @swagger * /auth/verify-email: * post: * summary: verify email * tags: [Auth] * parameters: * - in: query * name: token * required: true * schema: * type: string * description: The verify email token * responses: * "204": * description: No content * "401": * description: verify email failed * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * example: * code: 401 * message: verify email failed */ ================================================ FILE: src/routes/v1/docs.route.js ================================================ const express = require('express'); const swaggerJsdoc = require('swagger-jsdoc'); const swaggerUi = require('swagger-ui-express'); const swaggerDefinition = require('../../docs/swaggerDef'); const router = express.Router(); const specs = swaggerJsdoc({ swaggerDefinition, apis: ['src/docs/*.yml', 'src/routes/v1/*.js'], }); router.use('/', swaggerUi.serve); router.get( '/', swaggerUi.setup(specs, { explorer: true, }) ); module.exports = router; ================================================ FILE: src/routes/v1/index.js ================================================ const express = require('express'); const authRoute = require('./auth.route'); const userRoute = require('./user.route'); const docsRoute = require('./docs.route'); const config = require('../../config/config'); const router = express.Router(); const defaultRoutes = [ { path: '/auth', route: authRoute, }, { path: '/users', route: userRoute, }, ]; const devRoutes = [ // routes available only in development mode { path: '/docs', route: docsRoute, }, ]; defaultRoutes.forEach((route) => { router.use(route.path, route.route); }); /* istanbul ignore next */ if (config.env === 'development') { devRoutes.forEach((route) => { router.use(route.path, route.route); }); } module.exports = router; ================================================ FILE: src/routes/v1/user.route.js ================================================ const express = require('express'); const auth = require('../../middlewares/auth'); const validate = require('../../middlewares/validate'); const userValidation = require('../../validations/user.validation'); const userController = require('../../controllers/user.controller'); const router = express.Router(); router .route('/') .post(auth('manageUsers'), validate(userValidation.createUser), userController.createUser) .get(auth('getUsers'), validate(userValidation.getUsers), userController.getUsers); router .route('/:userId') .get(auth('getUsers'), validate(userValidation.getUser), userController.getUser) .patch(auth('manageUsers'), validate(userValidation.updateUser), userController.updateUser) .delete(auth('manageUsers'), validate(userValidation.deleteUser), userController.deleteUser); module.exports = router; /** * @swagger * tags: * name: Users * description: User management and retrieval */ /** * @swagger * /users: * post: * summary: Create a user * description: Only admins can create other users. * tags: [Users] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - name * - email * - password * - role * properties: * name: * type: string * email: * type: string * format: email * description: must be unique * password: * type: string * format: password * minLength: 8 * description: At least one number and one letter * role: * type: string * enum: [user, admin] * example: * name: fake name * email: fake@example.com * password: password1 * role: user * responses: * "201": * description: Created * content: * application/json: * schema: * $ref: '#/components/schemas/User' * "400": * $ref: '#/components/responses/DuplicateEmail' * "401": * $ref: '#/components/responses/Unauthorized' * "403": * $ref: '#/components/responses/Forbidden' * * get: * summary: Get all users * description: Only admins can retrieve all users. * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: query * name: name * schema: * type: string * description: User name * - in: query * name: role * schema: * type: string * description: User role * - in: query * name: sortBy * schema: * type: string * description: sort by query in the form of field:desc/asc (ex. name:asc) * - in: query * name: limit * schema: * type: integer * minimum: 1 * default: 10 * description: Maximum number of users * - in: query * name: page * schema: * type: integer * minimum: 1 * default: 1 * description: Page number * responses: * "200": * description: OK * content: * application/json: * schema: * type: object * properties: * results: * type: array * items: * $ref: '#/components/schemas/User' * page: * type: integer * example: 1 * limit: * type: integer * example: 10 * totalPages: * type: integer * example: 1 * totalResults: * type: integer * example: 1 * "401": * $ref: '#/components/responses/Unauthorized' * "403": * $ref: '#/components/responses/Forbidden' */ /** * @swagger * /users/{id}: * get: * summary: Get a user * description: Logged in users can fetch only their own user information. Only admins can fetch other users. * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: User id * responses: * "200": * description: OK * content: * application/json: * schema: * $ref: '#/components/schemas/User' * "401": * $ref: '#/components/responses/Unauthorized' * "403": * $ref: '#/components/responses/Forbidden' * "404": * $ref: '#/components/responses/NotFound' * * patch: * summary: Update a user * description: Logged in users can only update their own information. Only admins can update other users. * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: User id * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * name: * type: string * email: * type: string * format: email * description: must be unique * password: * type: string * format: password * minLength: 8 * description: At least one number and one letter * example: * name: fake name * email: fake@example.com * password: password1 * responses: * "200": * description: OK * content: * application/json: * schema: * $ref: '#/components/schemas/User' * "400": * $ref: '#/components/responses/DuplicateEmail' * "401": * $ref: '#/components/responses/Unauthorized' * "403": * $ref: '#/components/responses/Forbidden' * "404": * $ref: '#/components/responses/NotFound' * * delete: * summary: Delete a user * description: Logged in users can delete only themselves. Only admins can delete other users. * tags: [Users] * security: * - bearerAuth: [] * parameters: * - in: path * name: id * required: true * schema: * type: string * description: User id * responses: * "200": * description: No content * "401": * $ref: '#/components/responses/Unauthorized' * "403": * $ref: '#/components/responses/Forbidden' * "404": * $ref: '#/components/responses/NotFound' */ ================================================ FILE: src/services/auth.service.js ================================================ const httpStatus = require('http-status'); const tokenService = require('./token.service'); const userService = require('./user.service'); const Token = require('../models/token.model'); const ApiError = require('../utils/ApiError'); const { tokenTypes } = require('../config/tokens'); /** * Login with username and password * @param {string} email * @param {string} password * @returns {Promise} */ const loginUserWithEmailAndPassword = async (email, password) => { const user = await userService.getUserByEmail(email); if (!user || !(await user.isPasswordMatch(password))) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Incorrect email or password'); } return user; }; /** * Logout * @param {string} refreshToken * @returns {Promise} */ const logout = async (refreshToken) => { const refreshTokenDoc = await Token.findOne({ token: refreshToken, type: tokenTypes.REFRESH, blacklisted: false }); if (!refreshTokenDoc) { throw new ApiError(httpStatus.NOT_FOUND, 'Not found'); } await refreshTokenDoc.remove(); }; /** * Refresh auth tokens * @param {string} refreshToken * @returns {Promise} */ const refreshAuth = async (refreshToken) => { try { const refreshTokenDoc = await tokenService.verifyToken(refreshToken, tokenTypes.REFRESH); const user = await userService.getUserById(refreshTokenDoc.user); if (!user) { throw new Error(); } await refreshTokenDoc.remove(); return tokenService.generateAuthTokens(user); } catch (error) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } }; /** * Reset password * @param {string} resetPasswordToken * @param {string} newPassword * @returns {Promise} */ const resetPassword = async (resetPasswordToken, newPassword) => { try { const resetPasswordTokenDoc = await tokenService.verifyToken(resetPasswordToken, tokenTypes.RESET_PASSWORD); const user = await userService.getUserById(resetPasswordTokenDoc.user); if (!user) { throw new Error(); } await userService.updateUserById(user.id, { password: newPassword }); await Token.deleteMany({ user: user.id, type: tokenTypes.RESET_PASSWORD }); } catch (error) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed'); } }; /** * Verify email * @param {string} verifyEmailToken * @returns {Promise} */ const verifyEmail = async (verifyEmailToken) => { try { const verifyEmailTokenDoc = await tokenService.verifyToken(verifyEmailToken, tokenTypes.VERIFY_EMAIL); const user = await userService.getUserById(verifyEmailTokenDoc.user); if (!user) { throw new Error(); } await Token.deleteMany({ user: user.id, type: tokenTypes.VERIFY_EMAIL }); await userService.updateUserById(user.id, { isEmailVerified: true }); } catch (error) { throw new ApiError(httpStatus.UNAUTHORIZED, 'Email verification failed'); } }; module.exports = { loginUserWithEmailAndPassword, logout, refreshAuth, resetPassword, verifyEmail, }; ================================================ FILE: src/services/email.service.js ================================================ const nodemailer = require('nodemailer'); const config = require('../config/config'); const logger = require('../config/logger'); const transport = nodemailer.createTransport(config.email.smtp); /* istanbul ignore next */ if (config.env !== 'test') { transport .verify() .then(() => logger.info('Connected to email server')) .catch(() => logger.warn('Unable to connect to email server. Make sure you have configured the SMTP options in .env')); } /** * Send an email * @param {string} to * @param {string} subject * @param {string} text * @returns {Promise} */ const sendEmail = async (to, subject, text) => { const msg = { from: config.email.from, to, subject, text }; await transport.sendMail(msg); }; /** * Send reset password email * @param {string} to * @param {string} token * @returns {Promise} */ const sendResetPasswordEmail = async (to, token) => { const subject = 'Reset password'; // replace this url with the link to the reset password page of your front-end app const resetPasswordUrl = `http://link-to-app/reset-password?token=${token}`; const text = `Dear user, To reset your password, click on this link: ${resetPasswordUrl} If you did not request any password resets, then ignore this email.`; await sendEmail(to, subject, text); }; /** * Send verification email * @param {string} to * @param {string} token * @returns {Promise} */ const sendVerificationEmail = async (to, token) => { const subject = 'Email Verification'; // replace this url with the link to the email verification page of your front-end app const verificationEmailUrl = `http://link-to-app/verify-email?token=${token}`; const text = `Dear user, To verify your email, click on this link: ${verificationEmailUrl} If you did not create an account, then ignore this email.`; await sendEmail(to, subject, text); }; module.exports = { transport, sendEmail, sendResetPasswordEmail, sendVerificationEmail, }; ================================================ FILE: src/services/index.js ================================================ module.exports.authService = require('./auth.service'); module.exports.emailService = require('./email.service'); module.exports.tokenService = require('./token.service'); module.exports.userService = require('./user.service'); ================================================ FILE: src/services/token.service.js ================================================ const jwt = require('jsonwebtoken'); const moment = require('moment'); const httpStatus = require('http-status'); const config = require('../config/config'); const userService = require('./user.service'); const { Token } = require('../models'); const ApiError = require('../utils/ApiError'); const { tokenTypes } = require('../config/tokens'); /** * Generate token * @param {ObjectId} userId * @param {Moment} expires * @param {string} type * @param {string} [secret] * @returns {string} */ const generateToken = (userId, expires, type, secret = config.jwt.secret) => { const payload = { sub: userId, iat: moment().unix(), exp: expires.unix(), type, }; return jwt.sign(payload, secret); }; /** * Save a token * @param {string} token * @param {ObjectId} userId * @param {Moment} expires * @param {string} type * @param {boolean} [blacklisted] * @returns {Promise} */ const saveToken = async (token, userId, expires, type, blacklisted = false) => { const tokenDoc = await Token.create({ token, user: userId, expires: expires.toDate(), type, blacklisted, }); return tokenDoc; }; /** * Verify token and return token doc (or throw an error if it is not valid) * @param {string} token * @param {string} type * @returns {Promise} */ const verifyToken = async (token, type) => { const payload = jwt.verify(token, config.jwt.secret); const tokenDoc = await Token.findOne({ token, type, user: payload.sub, blacklisted: false }); if (!tokenDoc) { throw new Error('Token not found'); } return tokenDoc; }; /** * Generate auth tokens * @param {User} user * @returns {Promise} */ const generateAuthTokens = async (user) => { const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); const accessToken = generateToken(user.id, accessTokenExpires, tokenTypes.ACCESS); const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = generateToken(user.id, refreshTokenExpires, tokenTypes.REFRESH); await saveToken(refreshToken, user.id, refreshTokenExpires, tokenTypes.REFRESH); return { access: { token: accessToken, expires: accessTokenExpires.toDate(), }, refresh: { token: refreshToken, expires: refreshTokenExpires.toDate(), }, }; }; /** * Generate reset password token * @param {string} email * @returns {Promise} */ const generateResetPasswordToken = async (email) => { const user = await userService.getUserByEmail(email); if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email'); } const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); const resetPasswordToken = generateToken(user.id, expires, tokenTypes.RESET_PASSWORD); await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD); return resetPasswordToken; }; /** * Generate verify email token * @param {User} user * @returns {Promise} */ const generateVerifyEmailToken = async (user) => { const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); const verifyEmailToken = generateToken(user.id, expires, tokenTypes.VERIFY_EMAIL); await saveToken(verifyEmailToken, user.id, expires, tokenTypes.VERIFY_EMAIL); return verifyEmailToken; }; module.exports = { generateToken, saveToken, verifyToken, generateAuthTokens, generateResetPasswordToken, generateVerifyEmailToken, }; ================================================ FILE: src/services/user.service.js ================================================ const httpStatus = require('http-status'); const { User } = require('../models'); const ApiError = require('../utils/ApiError'); /** * Create a user * @param {Object} userBody * @returns {Promise} */ const createUser = async (userBody) => { if (await User.isEmailTaken(userBody.email)) { throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); } return User.create(userBody); }; /** * Query for users * @param {Object} filter - Mongo filter * @param {Object} options - Query options * @param {string} [options.sortBy] - Sort option in the format: sortField:(desc|asc) * @param {number} [options.limit] - Maximum number of results per page (default = 10) * @param {number} [options.page] - Current page (default = 1) * @returns {Promise} */ const queryUsers = async (filter, options) => { const users = await User.paginate(filter, options); return users; }; /** * Get user by id * @param {ObjectId} id * @returns {Promise} */ const getUserById = async (id) => { return User.findById(id); }; /** * Get user by email * @param {string} email * @returns {Promise} */ const getUserByEmail = async (email) => { return User.findOne({ email }); }; /** * Update user by id * @param {ObjectId} userId * @param {Object} updateBody * @returns {Promise} */ const updateUserById = async (userId, updateBody) => { const user = await getUserById(userId); if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); } if (updateBody.email && (await User.isEmailTaken(updateBody.email, userId))) { throw new ApiError(httpStatus.BAD_REQUEST, 'Email already taken'); } Object.assign(user, updateBody); await user.save(); return user; }; /** * Delete user by id * @param {ObjectId} userId * @returns {Promise} */ const deleteUserById = async (userId) => { const user = await getUserById(userId); if (!user) { throw new ApiError(httpStatus.NOT_FOUND, 'User not found'); } await user.remove(); return user; }; module.exports = { createUser, queryUsers, getUserById, getUserByEmail, updateUserById, deleteUserById, }; ================================================ FILE: src/utils/ApiError.js ================================================ class ApiError extends Error { constructor(statusCode, message, isOperational = true, stack = '') { super(message); this.statusCode = statusCode; this.isOperational = isOperational; if (stack) { this.stack = stack; } else { Error.captureStackTrace(this, this.constructor); } } } module.exports = ApiError; ================================================ FILE: src/utils/catchAsync.js ================================================ const catchAsync = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch((err) => next(err)); }; module.exports = catchAsync; ================================================ FILE: src/utils/pick.js ================================================ /** * Create an object composed of the picked object properties * @param {Object} object * @param {string[]} keys * @returns {Object} */ const pick = (object, keys) => { return keys.reduce((obj, key) => { if (object && Object.prototype.hasOwnProperty.call(object, key)) { // eslint-disable-next-line no-param-reassign obj[key] = object[key]; } return obj; }, {}); }; module.exports = pick; ================================================ FILE: src/validations/auth.validation.js ================================================ const Joi = require('joi'); const { password } = require('./custom.validation'); const register = { body: Joi.object().keys({ email: Joi.string().required().email(), password: Joi.string().required().custom(password), name: Joi.string().required(), }), }; const login = { body: Joi.object().keys({ email: Joi.string().required(), password: Joi.string().required(), }), }; const logout = { body: Joi.object().keys({ refreshToken: Joi.string().required(), }), }; const refreshTokens = { body: Joi.object().keys({ refreshToken: Joi.string().required(), }), }; const forgotPassword = { body: Joi.object().keys({ email: Joi.string().email().required(), }), }; const resetPassword = { query: Joi.object().keys({ token: Joi.string().required(), }), body: Joi.object().keys({ password: Joi.string().required().custom(password), }), }; const verifyEmail = { query: Joi.object().keys({ token: Joi.string().required(), }), }; module.exports = { register, login, logout, refreshTokens, forgotPassword, resetPassword, verifyEmail, }; ================================================ FILE: src/validations/custom.validation.js ================================================ const objectId = (value, helpers) => { if (!value.match(/^[0-9a-fA-F]{24}$/)) { return helpers.message('"{{#label}}" must be a valid mongo id'); } return value; }; const password = (value, helpers) => { if (value.length < 8) { return helpers.message('password must be at least 8 characters'); } if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) { return helpers.message('password must contain at least 1 letter and 1 number'); } return value; }; module.exports = { objectId, password, }; ================================================ FILE: src/validations/index.js ================================================ module.exports.authValidation = require('./auth.validation'); module.exports.userValidation = require('./user.validation'); ================================================ FILE: src/validations/user.validation.js ================================================ const Joi = require('joi'); const { password, objectId } = require('./custom.validation'); const createUser = { body: Joi.object().keys({ email: Joi.string().required().email(), password: Joi.string().required().custom(password), name: Joi.string().required(), role: Joi.string().required().valid('user', 'admin'), }), }; const getUsers = { query: Joi.object().keys({ name: Joi.string(), role: Joi.string(), sortBy: Joi.string(), limit: Joi.number().integer(), page: Joi.number().integer(), }), }; const getUser = { params: Joi.object().keys({ userId: Joi.string().custom(objectId), }), }; const updateUser = { params: Joi.object().keys({ userId: Joi.required().custom(objectId), }), body: Joi.object() .keys({ email: Joi.string().email(), password: Joi.string().custom(password), name: Joi.string(), }) .min(1), }; const deleteUser = { params: Joi.object().keys({ userId: Joi.string().custom(objectId), }), }; module.exports = { createUser, getUsers, getUser, updateUser, deleteUser, }; ================================================ FILE: tests/fixtures/token.fixture.js ================================================ const moment = require('moment'); const config = require('../../src/config/config'); const { tokenTypes } = require('../../src/config/tokens'); const tokenService = require('../../src/services/token.service'); const { userOne, admin } = require('./user.fixture'); const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); const userOneAccessToken = tokenService.generateToken(userOne._id, accessTokenExpires, tokenTypes.ACCESS); const adminAccessToken = tokenService.generateToken(admin._id, accessTokenExpires, tokenTypes.ACCESS); module.exports = { userOneAccessToken, adminAccessToken, }; ================================================ FILE: tests/fixtures/user.fixture.js ================================================ const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); const faker = require('faker'); const User = require('../../src/models/user.model'); const password = 'password1'; const salt = bcrypt.genSaltSync(8); const hashedPassword = bcrypt.hashSync(password, salt); const userOne = { _id: mongoose.Types.ObjectId(), name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password, role: 'user', isEmailVerified: false, }; const userTwo = { _id: mongoose.Types.ObjectId(), name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password, role: 'user', isEmailVerified: false, }; const admin = { _id: mongoose.Types.ObjectId(), name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password, role: 'admin', isEmailVerified: false, }; const insertUsers = async (users) => { await User.insertMany(users.map((user) => ({ ...user, password: hashedPassword }))); }; module.exports = { userOne, userTwo, admin, insertUsers, }; ================================================ FILE: tests/integration/auth.test.js ================================================ const request = require('supertest'); const faker = require('faker'); const httpStatus = require('http-status'); const httpMocks = require('node-mocks-http'); const moment = require('moment'); const bcrypt = require('bcryptjs'); const app = require('../../src/app'); const config = require('../../src/config/config'); const auth = require('../../src/middlewares/auth'); const { tokenService, emailService } = require('../../src/services'); const ApiError = require('../../src/utils/ApiError'); const setupTestDB = require('../utils/setupTestDB'); const { User, Token } = require('../../src/models'); const { roleRights } = require('../../src/config/roles'); const { tokenTypes } = require('../../src/config/tokens'); const { userOne, admin, insertUsers } = require('../fixtures/user.fixture'); const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); setupTestDB(); describe('Auth routes', () => { describe('POST /v1/auth/register', () => { let newUser; beforeEach(() => { newUser = { name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password: 'password1', }; }); test('should return 201 and successfully register user if request data is ok', async () => { const res = await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.CREATED); expect(res.body.user).not.toHaveProperty('password'); expect(res.body.user).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: 'user', isEmailVerified: false, }); const dbUser = await User.findById(res.body.user.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: 'user', isEmailVerified: false }); expect(res.body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() }, }); }); test('should return 400 error if email is invalid', async () => { newUser.email = 'invalidEmail'; await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if email is already used', async () => { await insertUsers([userOne]); newUser.email = userOne.email; await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if password length is less than 8 characters', async () => { newUser.password = 'passwo1'; await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if password does not contain both letters and numbers', async () => { newUser.password = 'password'; await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); newUser.password = '11111111'; await request(app).post('/v1/auth/register').send(newUser).expect(httpStatus.BAD_REQUEST); }); }); describe('POST /v1/auth/login', () => { test('should return 200 and login user if email and password match', async () => { await insertUsers([userOne]); const loginCredentials = { email: userOne.email, password: userOne.password, }; const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.OK); expect(res.body.user).toEqual({ id: expect.anything(), name: userOne.name, email: userOne.email, role: userOne.role, isEmailVerified: userOne.isEmailVerified, }); expect(res.body.tokens).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() }, }); }); test('should return 401 error if there are no users with that email', async () => { const loginCredentials = { email: userOne.email, password: userOne.password, }; const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); }); test('should return 401 error if password is wrong', async () => { await insertUsers([userOne]); const loginCredentials = { email: userOne.email, password: 'wrongPassword1', }; const res = await request(app).post('/v1/auth/login').send(loginCredentials).expect(httpStatus.UNAUTHORIZED); expect(res.body).toEqual({ code: httpStatus.UNAUTHORIZED, message: 'Incorrect email or password' }); }); }); describe('POST /v1/auth/logout', () => { test('should return 204 if refresh token is valid', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NO_CONTENT); const dbRefreshTokenDoc = await Token.findOne({ token: refreshToken }); expect(dbRefreshTokenDoc).toBe(null); }); test('should return 400 error if refresh token is missing from request body', async () => { await request(app).post('/v1/auth/logout').send().expect(httpStatus.BAD_REQUEST); }); test('should return 404 error if refresh token is not found in the database', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); }); test('should return 404 error if refresh token is blacklisted', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); await request(app).post('/v1/auth/logout').send({ refreshToken }).expect(httpStatus.NOT_FOUND); }); }); describe('POST /v1/auth/refresh-tokens', () => { test('should return 200 and new auth tokens if refresh token is valid', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); const res = await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.OK); expect(res.body).toEqual({ access: { token: expect.anything(), expires: expect.anything() }, refresh: { token: expect.anything(), expires: expect.anything() }, }); const dbRefreshTokenDoc = await Token.findOne({ token: res.body.refresh.token }); expect(dbRefreshTokenDoc).toMatchObject({ type: tokenTypes.REFRESH, user: userOne._id, blacklisted: false }); const dbRefreshTokenCount = await Token.countDocuments(); expect(dbRefreshTokenCount).toBe(1); }); test('should return 400 error if refresh token is missing from request body', async () => { await request(app).post('/v1/auth/refresh-tokens').send().expect(httpStatus.BAD_REQUEST); }); test('should return 401 error if refresh token is signed using an invalid secret', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH, 'invalidSecret'); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); }); test('should return 401 error if refresh token is not found in the database', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); }); test('should return 401 error if refresh token is blacklisted', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH, true); await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); }); test('should return 401 error if refresh token is expired', async () => { await insertUsers([userOne]); const expires = moment().subtract(1, 'minutes'); const refreshToken = tokenService.generateToken(userOne._id, expires); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); }); test('should return 401 error if user is not found', async () => { const expires = moment().add(config.jwt.refreshExpirationDays, 'days'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); await tokenService.saveToken(refreshToken, userOne._id, expires, tokenTypes.REFRESH); await request(app).post('/v1/auth/refresh-tokens').send({ refreshToken }).expect(httpStatus.UNAUTHORIZED); }); }); describe('POST /v1/auth/forgot-password', () => { beforeEach(() => { jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); }); test('should return 204 and send reset password email to the user', async () => { await insertUsers([userOne]); const sendResetPasswordEmailSpy = jest.spyOn(emailService, 'sendResetPasswordEmail'); await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NO_CONTENT); expect(sendResetPasswordEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); const resetPasswordToken = sendResetPasswordEmailSpy.mock.calls[0][1]; const dbResetPasswordTokenDoc = await Token.findOne({ token: resetPasswordToken, user: userOne._id }); expect(dbResetPasswordTokenDoc).toBeDefined(); }); test('should return 400 if email is missing', async () => { await insertUsers([userOne]); await request(app).post('/v1/auth/forgot-password').send().expect(httpStatus.BAD_REQUEST); }); test('should return 404 if email does not belong to any user', async () => { await request(app).post('/v1/auth/forgot-password').send({ email: userOne.email }).expect(httpStatus.NOT_FOUND); }); }); describe('POST /v1/auth/reset-password', () => { test('should return 204 and reset the password', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'password2' }) .expect(httpStatus.NO_CONTENT); const dbUser = await User.findById(userOne._id); const isPasswordMatch = await bcrypt.compare('password2', dbUser.password); expect(isPasswordMatch).toBe(true); const dbResetPasswordTokenCount = await Token.countDocuments({ user: userOne._id, type: tokenTypes.RESET_PASSWORD }); expect(dbResetPasswordTokenCount).toBe(0); }); test('should return 400 if reset password token is missing', async () => { await insertUsers([userOne]); await request(app).post('/v1/auth/reset-password').send({ password: 'password2' }).expect(httpStatus.BAD_REQUEST); }); test('should return 401 if reset password token is blacklisted', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD, true); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'password2' }) .expect(httpStatus.UNAUTHORIZED); }); test('should return 401 if reset password token is expired', async () => { await insertUsers([userOne]); const expires = moment().subtract(1, 'minutes'); const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'password2' }) .expect(httpStatus.UNAUTHORIZED); }); test('should return 401 if user is not found', async () => { const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'password2' }) .expect(httpStatus.UNAUTHORIZED); }); test('should return 400 if password is missing or invalid', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes'); const resetPasswordToken = tokenService.generateToken(userOne._id, expires, tokenTypes.RESET_PASSWORD); await tokenService.saveToken(resetPasswordToken, userOne._id, expires, tokenTypes.RESET_PASSWORD); await request(app).post('/v1/auth/reset-password').query({ token: resetPasswordToken }).expect(httpStatus.BAD_REQUEST); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'short1' }) .expect(httpStatus.BAD_REQUEST); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: 'password' }) .expect(httpStatus.BAD_REQUEST); await request(app) .post('/v1/auth/reset-password') .query({ token: resetPasswordToken }) .send({ password: '11111111' }) .expect(httpStatus.BAD_REQUEST); }); }); describe('POST /v1/auth/send-verification-email', () => { beforeEach(() => { jest.spyOn(emailService.transport, 'sendMail').mockResolvedValue(); }); test('should return 204 and send verification email to the user', async () => { await insertUsers([userOne]); const sendVerificationEmailSpy = jest.spyOn(emailService, 'sendVerificationEmail'); await request(app) .post('/v1/auth/send-verification-email') .set('Authorization', `Bearer ${userOneAccessToken}`) .expect(httpStatus.NO_CONTENT); expect(sendVerificationEmailSpy).toHaveBeenCalledWith(userOne.email, expect.any(String)); const verifyEmailToken = sendVerificationEmailSpy.mock.calls[0][1]; const dbVerifyEmailToken = await Token.findOne({ token: verifyEmailToken, user: userOne._id }); expect(dbVerifyEmailToken).toBeDefined(); }); test('should return 401 error if access token is missing', async () => { await insertUsers([userOne]); await request(app).post('/v1/auth/send-verification-email').send().expect(httpStatus.UNAUTHORIZED); }); }); describe('POST /v1/auth/verify-email', () => { test('should return 204 and verify the email', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); const verifyEmailToken = tokenService.generateToken(userOne._id, expires); await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); await request(app) .post('/v1/auth/verify-email') .query({ token: verifyEmailToken }) .send() .expect(httpStatus.NO_CONTENT); const dbUser = await User.findById(userOne._id); expect(dbUser.isEmailVerified).toBe(true); const dbVerifyEmailToken = await Token.countDocuments({ user: userOne._id, type: tokenTypes.VERIFY_EMAIL, }); expect(dbVerifyEmailToken).toBe(0); }); test('should return 400 if verify email token is missing', async () => { await insertUsers([userOne]); await request(app).post('/v1/auth/verify-email').send().expect(httpStatus.BAD_REQUEST); }); test('should return 401 if verify email token is blacklisted', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); const verifyEmailToken = tokenService.generateToken(userOne._id, expires); await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL, true); await request(app) .post('/v1/auth/verify-email') .query({ token: verifyEmailToken }) .send() .expect(httpStatus.UNAUTHORIZED); }); test('should return 401 if verify email token is expired', async () => { await insertUsers([userOne]); const expires = moment().subtract(1, 'minutes'); const verifyEmailToken = tokenService.generateToken(userOne._id, expires); await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); await request(app) .post('/v1/auth/verify-email') .query({ token: verifyEmailToken }) .send() .expect(httpStatus.UNAUTHORIZED); }); test('should return 401 if user is not found', async () => { const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes'); const verifyEmailToken = tokenService.generateToken(userOne._id, expires); await tokenService.saveToken(verifyEmailToken, userOne._id, expires, tokenTypes.VERIFY_EMAIL); await request(app) .post('/v1/auth/verify-email') .query({ token: verifyEmailToken }) .send() .expect(httpStatus.UNAUTHORIZED); }); }); }); describe('Auth middleware', () => { test('should call next with no errors if access token is valid', async () => { await insertUsers([userOne]); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(); expect(req.user._id).toEqual(userOne._id); }); test('should call next with unauthorized error if access token is not found in header', async () => { await insertUsers([userOne]); const req = httpMocks.createRequest(); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with unauthorized error if access token is not a valid jwt token', async () => { await insertUsers([userOne]); const req = httpMocks.createRequest({ headers: { Authorization: 'Bearer randomToken' } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with unauthorized error if the token is not an access token', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); const refreshToken = tokenService.generateToken(userOne._id, expires, tokenTypes.REFRESH); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${refreshToken}` } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with unauthorized error if access token is generated with an invalid secret', async () => { await insertUsers([userOne]); const expires = moment().add(config.jwt.accessExpirationMinutes, 'minutes'); const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS, 'invalidSecret'); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with unauthorized error if access token is expired', async () => { await insertUsers([userOne]); const expires = moment().subtract(1, 'minutes'); const accessToken = tokenService.generateToken(userOne._id, expires, tokenTypes.ACCESS); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${accessToken}` } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with unauthorized error if user is not found', async () => { const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); const next = jest.fn(); await auth()(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.UNAUTHORIZED, message: 'Please authenticate' }) ); }); test('should call next with forbidden error if user does not have required rights and userId is not in params', async () => { await insertUsers([userOne]); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` } }); const next = jest.fn(); await auth('anyRight')(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: httpStatus.FORBIDDEN, message: 'Forbidden' })); }); test('should call next with no errors if user does not have required rights but userId is in params', async () => { await insertUsers([userOne]); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${userOneAccessToken}` }, params: { userId: userOne._id.toHexString() }, }); const next = jest.fn(); await auth('anyRight')(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(); }); test('should call next with no errors if user has required rights', async () => { await insertUsers([admin]); const req = httpMocks.createRequest({ headers: { Authorization: `Bearer ${adminAccessToken}` }, params: { userId: userOne._id.toHexString() }, }); const next = jest.fn(); await auth(...roleRights.get('admin'))(req, httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(); }); }); ================================================ FILE: tests/integration/docs.test.js ================================================ const request = require('supertest'); const httpStatus = require('http-status'); const app = require('../../src/app'); const config = require('../../src/config/config'); describe('Auth routes', () => { describe('GET /v1/docs', () => { test('should return 404 when running in production', async () => { config.env = 'production'; await request(app).get('/v1/docs').send().expect(httpStatus.NOT_FOUND); config.env = process.env.NODE_ENV; }); }); }); ================================================ FILE: tests/integration/user.test.js ================================================ const request = require('supertest'); const faker = require('faker'); const httpStatus = require('http-status'); const app = require('../../src/app'); const setupTestDB = require('../utils/setupTestDB'); const { User } = require('../../src/models'); const { userOne, userTwo, admin, insertUsers } = require('../fixtures/user.fixture'); const { userOneAccessToken, adminAccessToken } = require('../fixtures/token.fixture'); setupTestDB(); describe('User routes', () => { describe('POST /v1/users', () => { let newUser; beforeEach(() => { newUser = { name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password: 'password1', role: 'user', }; }); test('should return 201 and successfully create new user if data is ok', async () => { await insertUsers([admin]); const res = await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.CREATED); expect(res.body).not.toHaveProperty('password'); expect(res.body).toEqual({ id: expect.anything(), name: newUser.name, email: newUser.email, role: newUser.role, isEmailVerified: false, }); const dbUser = await User.findById(res.body.id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(newUser.password); expect(dbUser).toMatchObject({ name: newUser.name, email: newUser.email, role: newUser.role, isEmailVerified: false }); }); test('should be able to create an admin as well', async () => { await insertUsers([admin]); newUser.role = 'admin'; const res = await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.CREATED); expect(res.body.role).toBe('admin'); const dbUser = await User.findById(res.body.id); expect(dbUser.role).toBe('admin'); }); test('should return 401 error if access token is missing', async () => { await request(app).post('/v1/users').send(newUser).expect(httpStatus.UNAUTHORIZED); }); test('should return 403 error if logged in user is not admin', async () => { await insertUsers([userOne]); await request(app) .post('/v1/users') .set('Authorization', `Bearer ${userOneAccessToken}`) .send(newUser) .expect(httpStatus.FORBIDDEN); }); test('should return 400 error if email is invalid', async () => { await insertUsers([admin]); newUser.email = 'invalidEmail'; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if email is already used', async () => { await insertUsers([admin, userOne]); newUser.email = userOne.email; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if password length is less than 8 characters', async () => { await insertUsers([admin]); newUser.password = 'passwo1'; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if password does not contain both letters and numbers', async () => { await insertUsers([admin]); newUser.password = 'password'; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); newUser.password = '1111111'; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 error if role is neither user nor admin', async () => { await insertUsers([admin]); newUser.role = 'invalid'; await request(app) .post('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send(newUser) .expect(httpStatus.BAD_REQUEST); }); }); describe('GET /v1/users', () => { test('should return 200 and apply the default query options', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 3, }); expect(res.body.results).toHaveLength(3); expect(res.body.results[0]).toEqual({ id: userOne._id.toHexString(), name: userOne.name, email: userOne.email, role: userOne.role, isEmailVerified: userOne.isEmailVerified, }); }); test('should return 401 if access token is missing', async () => { await insertUsers([userOne, userTwo, admin]); await request(app).get('/v1/users').send().expect(httpStatus.UNAUTHORIZED); }); test('should return 403 if a non-admin is trying to access all users', async () => { await insertUsers([userOne, userTwo, admin]); await request(app) .get('/v1/users') .set('Authorization', `Bearer ${userOneAccessToken}`) .send() .expect(httpStatus.FORBIDDEN); }); test('should correctly apply filter on name field', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ name: userOne.name }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 1, }); expect(res.body.results).toHaveLength(1); expect(res.body.results[0].id).toBe(userOne._id.toHexString()); }); test('should correctly apply filter on role field', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ role: 'user' }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 2, }); expect(res.body.results).toHaveLength(2); expect(res.body.results[0].id).toBe(userOne._id.toHexString()); expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); }); test('should correctly sort the returned array if descending sort param is specified', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ sortBy: 'role:desc' }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 3, }); expect(res.body.results).toHaveLength(3); expect(res.body.results[0].id).toBe(userOne._id.toHexString()); expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); expect(res.body.results[2].id).toBe(admin._id.toHexString()); }); test('should correctly sort the returned array if ascending sort param is specified', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ sortBy: 'role:asc' }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 3, }); expect(res.body.results).toHaveLength(3); expect(res.body.results[0].id).toBe(admin._id.toHexString()); expect(res.body.results[1].id).toBe(userOne._id.toHexString()); expect(res.body.results[2].id).toBe(userTwo._id.toHexString()); }); test('should correctly sort the returned array if multiple sorting criteria are specified', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ sortBy: 'role:desc,name:asc' }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 10, totalPages: 1, totalResults: 3, }); expect(res.body.results).toHaveLength(3); const expectedOrder = [userOne, userTwo, admin].sort((a, b) => { if (a.role < b.role) { return 1; } if (a.role > b.role) { return -1; } return a.name < b.name ? -1 : 1; }); expectedOrder.forEach((user, index) => { expect(res.body.results[index].id).toBe(user._id.toHexString()); }); }); test('should limit returned array if limit param is specified', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ limit: 2 }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 1, limit: 2, totalPages: 2, totalResults: 3, }); expect(res.body.results).toHaveLength(2); expect(res.body.results[0].id).toBe(userOne._id.toHexString()); expect(res.body.results[1].id).toBe(userTwo._id.toHexString()); }); test('should return the correct page if page and limit params are specified', async () => { await insertUsers([userOne, userTwo, admin]); const res = await request(app) .get('/v1/users') .set('Authorization', `Bearer ${adminAccessToken}`) .query({ page: 2, limit: 2 }) .send() .expect(httpStatus.OK); expect(res.body).toEqual({ results: expect.any(Array), page: 2, limit: 2, totalPages: 2, totalResults: 3, }); expect(res.body.results).toHaveLength(1); expect(res.body.results[0].id).toBe(admin._id.toHexString()); }); }); describe('GET /v1/users/:userId', () => { test('should return 200 and the user object if data is ok', async () => { await insertUsers([userOne]); const res = await request(app) .get(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send() .expect(httpStatus.OK); expect(res.body).not.toHaveProperty('password'); expect(res.body).toEqual({ id: userOne._id.toHexString(), email: userOne.email, name: userOne.name, role: userOne.role, isEmailVerified: userOne.isEmailVerified, }); }); test('should return 401 error if access token is missing', async () => { await insertUsers([userOne]); await request(app).get(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); }); test('should return 403 error if user is trying to get another user', async () => { await insertUsers([userOne, userTwo]); await request(app) .get(`/v1/users/${userTwo._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send() .expect(httpStatus.FORBIDDEN); }); test('should return 200 and the user object if admin is trying to get another user', async () => { await insertUsers([userOne, admin]); await request(app) .get(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.OK); }); test('should return 400 error if userId is not a valid mongo id', async () => { await insertUsers([admin]); await request(app) .get('/v1/users/invalidId') .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.BAD_REQUEST); }); test('should return 404 error if user is not found', async () => { await insertUsers([admin]); await request(app) .get(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.NOT_FOUND); }); }); describe('DELETE /v1/users/:userId', () => { test('should return 204 if data is ok', async () => { await insertUsers([userOne]); await request(app) .delete(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send() .expect(httpStatus.NO_CONTENT); const dbUser = await User.findById(userOne._id); expect(dbUser).toBeNull(); }); test('should return 401 error if access token is missing', async () => { await insertUsers([userOne]); await request(app).delete(`/v1/users/${userOne._id}`).send().expect(httpStatus.UNAUTHORIZED); }); test('should return 403 error if user is trying to delete another user', async () => { await insertUsers([userOne, userTwo]); await request(app) .delete(`/v1/users/${userTwo._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send() .expect(httpStatus.FORBIDDEN); }); test('should return 204 if admin is trying to delete another user', async () => { await insertUsers([userOne, admin]); await request(app) .delete(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.NO_CONTENT); }); test('should return 400 error if userId is not a valid mongo id', async () => { await insertUsers([admin]); await request(app) .delete('/v1/users/invalidId') .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.BAD_REQUEST); }); test('should return 404 error if user already is not found', async () => { await insertUsers([admin]); await request(app) .delete(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send() .expect(httpStatus.NOT_FOUND); }); }); describe('PATCH /v1/users/:userId', () => { test('should return 200 and successfully update user if data is ok', async () => { await insertUsers([userOne]); const updateBody = { name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password: 'newPassword1', }; const res = await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.OK); expect(res.body).not.toHaveProperty('password'); expect(res.body).toEqual({ id: userOne._id.toHexString(), name: updateBody.name, email: updateBody.email, role: 'user', isEmailVerified: false, }); const dbUser = await User.findById(userOne._id); expect(dbUser).toBeDefined(); expect(dbUser.password).not.toBe(updateBody.password); expect(dbUser).toMatchObject({ name: updateBody.name, email: updateBody.email, role: 'user' }); }); test('should return 401 error if access token is missing', async () => { await insertUsers([userOne]); const updateBody = { name: faker.name.findName() }; await request(app).patch(`/v1/users/${userOne._id}`).send(updateBody).expect(httpStatus.UNAUTHORIZED); }); test('should return 403 if user is updating another user', async () => { await insertUsers([userOne, userTwo]); const updateBody = { name: faker.name.findName() }; await request(app) .patch(`/v1/users/${userTwo._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.FORBIDDEN); }); test('should return 200 and successfully update user if admin is updating another user', async () => { await insertUsers([userOne, admin]); const updateBody = { name: faker.name.findName() }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send(updateBody) .expect(httpStatus.OK); }); test('should return 404 if admin is updating another user that is not found', async () => { await insertUsers([admin]); const updateBody = { name: faker.name.findName() }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${adminAccessToken}`) .send(updateBody) .expect(httpStatus.NOT_FOUND); }); test('should return 400 error if userId is not a valid mongo id', async () => { await insertUsers([admin]); const updateBody = { name: faker.name.findName() }; await request(app) .patch(`/v1/users/invalidId`) .set('Authorization', `Bearer ${adminAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 if email is invalid', async () => { await insertUsers([userOne]); const updateBody = { email: 'invalidEmail' }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 if email is already taken', async () => { await insertUsers([userOne, userTwo]); const updateBody = { email: userTwo.email }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); }); test('should not return 400 if email is my email', async () => { await insertUsers([userOne]); const updateBody = { email: userOne.email }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.OK); }); test('should return 400 if password length is less than 8 characters', async () => { await insertUsers([userOne]); const updateBody = { password: 'passwo1' }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); }); test('should return 400 if password does not contain both letters and numbers', async () => { await insertUsers([userOne]); const updateBody = { password: 'password' }; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); updateBody.password = '11111111'; await request(app) .patch(`/v1/users/${userOne._id}`) .set('Authorization', `Bearer ${userOneAccessToken}`) .send(updateBody) .expect(httpStatus.BAD_REQUEST); }); }); }); ================================================ FILE: tests/unit/middlewares/error.test.js ================================================ const mongoose = require('mongoose'); const httpStatus = require('http-status'); const httpMocks = require('node-mocks-http'); const { errorConverter, errorHandler } = require('../../../src/middlewares/error'); const ApiError = require('../../../src/utils/ApiError'); const config = require('../../../src/config/config'); const logger = require('../../../src/config/logger'); describe('Error middlewares', () => { describe('Error converter', () => { test('should return the same ApiError object it was called with', () => { const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(error); }); test('should convert an Error to ApiError and preserve its status and message', () => { const error = new Error('Any error'); error.statusCode = httpStatus.BAD_REQUEST; const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: error.statusCode, message: error.message, isOperational: false, }) ); }); test('should convert an Error without status to ApiError with status 500', () => { const error = new Error('Any error'); const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.INTERNAL_SERVER_ERROR, message: error.message, isOperational: false, }) ); }); test('should convert an Error without message to ApiError with default message of that http status', () => { const error = new Error(); error.statusCode = httpStatus.BAD_REQUEST; const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: error.statusCode, message: httpStatus[error.statusCode], isOperational: false, }) ); }); test('should convert a Mongoose error to ApiError with status 400 and preserve its message', () => { const error = new mongoose.Error('Any mongoose error'); const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.BAD_REQUEST, message: error.message, isOperational: false, }) ); }); test('should convert any other object to ApiError with status 500 and its message', () => { const error = {}; const next = jest.fn(); errorConverter(error, httpMocks.createRequest(), httpMocks.createResponse(), next); expect(next).toHaveBeenCalledWith(expect.any(ApiError)); expect(next).toHaveBeenCalledWith( expect.objectContaining({ statusCode: httpStatus.INTERNAL_SERVER_ERROR, message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], isOperational: false, }) ); }); }); describe('Error handler', () => { beforeEach(() => { jest.spyOn(logger, 'error').mockImplementation(() => {}); }); test('should send proper error response and put the error message in res.locals', () => { const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); const res = httpMocks.createResponse(); const sendSpy = jest.spyOn(res, 'send'); errorHandler(error, httpMocks.createRequest(), res); expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ code: error.statusCode, message: error.message })); expect(res.locals.errorMessage).toBe(error.message); }); test('should put the error stack in the response if in development mode', () => { config.env = 'development'; const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); const res = httpMocks.createResponse(); const sendSpy = jest.spyOn(res, 'send'); errorHandler(error, httpMocks.createRequest(), res); expect(sendSpy).toHaveBeenCalledWith( expect.objectContaining({ code: error.statusCode, message: error.message, stack: error.stack }) ); config.env = process.env.NODE_ENV; }); test('should send internal server error status and message if in production mode and error is not operational', () => { config.env = 'production'; const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error', false); const res = httpMocks.createResponse(); const sendSpy = jest.spyOn(res, 'send'); errorHandler(error, httpMocks.createRequest(), res); expect(sendSpy).toHaveBeenCalledWith( expect.objectContaining({ code: httpStatus.INTERNAL_SERVER_ERROR, message: httpStatus[httpStatus.INTERNAL_SERVER_ERROR], }) ); expect(res.locals.errorMessage).toBe(error.message); config.env = process.env.NODE_ENV; }); test('should preserve original error status and message if in production mode and error is operational', () => { config.env = 'production'; const error = new ApiError(httpStatus.BAD_REQUEST, 'Any error'); const res = httpMocks.createResponse(); const sendSpy = jest.spyOn(res, 'send'); errorHandler(error, httpMocks.createRequest(), res); expect(sendSpy).toHaveBeenCalledWith( expect.objectContaining({ code: error.statusCode, message: error.message, }) ); config.env = process.env.NODE_ENV; }); }); }); ================================================ FILE: tests/unit/models/plugins/paginate.plugin.test.js ================================================ const mongoose = require('mongoose'); const setupTestDB = require('../../../utils/setupTestDB'); const paginate = require('../../../../src/models/plugins/paginate.plugin'); const projectSchema = mongoose.Schema({ name: { type: String, required: true, }, }); projectSchema.virtual('tasks', { ref: 'Task', localField: '_id', foreignField: 'project', }); projectSchema.plugin(paginate); const Project = mongoose.model('Project', projectSchema); const taskSchema = mongoose.Schema({ name: { type: String, required: true, }, project: { type: mongoose.SchemaTypes.ObjectId, ref: 'Project', required: true, }, }); taskSchema.plugin(paginate); const Task = mongoose.model('Task', taskSchema); setupTestDB(); describe('paginate plugin', () => { describe('populate option', () => { test('should populate the specified data fields', async () => { const project = await Project.create({ name: 'Project One' }); const task = await Task.create({ name: 'Task One', project: project._id }); const taskPages = await Task.paginate({ _id: task._id }, { populate: 'project' }); expect(taskPages.results[0].project).toHaveProperty('_id', project._id); }); test('should populate nested fields', async () => { const project = await Project.create({ name: 'Project One' }); const task = await Task.create({ name: 'Task One', project: project._id }); const projectPages = await Project.paginate({ _id: project._id }, { populate: 'tasks.project' }); const { tasks } = projectPages.results[0]; expect(tasks).toHaveLength(1); expect(tasks[0]).toHaveProperty('_id', task._id); expect(tasks[0].project).toHaveProperty('_id', project._id); }); }); }); ================================================ FILE: tests/unit/models/plugins/toJSON.plugin.test.js ================================================ const mongoose = require('mongoose'); const { toJSON } = require('../../../../src/models/plugins'); describe('toJSON plugin', () => { let connection; beforeEach(() => { connection = mongoose.createConnection(); }); it('should replace _id with id', () => { const schema = mongoose.Schema(); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model(); expect(doc.toJSON()).not.toHaveProperty('_id'); expect(doc.toJSON()).toHaveProperty('id', doc._id.toString()); }); it('should remove __v', () => { const schema = mongoose.Schema(); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model(); expect(doc.toJSON()).not.toHaveProperty('__v'); }); it('should remove createdAt and updatedAt', () => { const schema = mongoose.Schema({}, { timestamps: true }); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model(); expect(doc.toJSON()).not.toHaveProperty('createdAt'); expect(doc.toJSON()).not.toHaveProperty('updatedAt'); }); it('should remove any path set as private', () => { const schema = mongoose.Schema({ public: { type: String }, private: { type: String, private: true }, }); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model({ public: 'some public value', private: 'some private value' }); expect(doc.toJSON()).not.toHaveProperty('private'); expect(doc.toJSON()).toHaveProperty('public'); }); it('should remove any nested paths set as private', () => { const schema = mongoose.Schema({ public: { type: String }, nested: { private: { type: String, private: true }, }, }); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model({ public: 'some public value', nested: { private: 'some nested private value', }, }); expect(doc.toJSON()).not.toHaveProperty('nested.private'); expect(doc.toJSON()).toHaveProperty('public'); }); it('should also call the schema toJSON transform function', () => { const schema = mongoose.Schema( { public: { type: String }, private: { type: String }, }, { toJSON: { transform: (doc, ret) => { // eslint-disable-next-line no-param-reassign delete ret.private; }, }, } ); schema.plugin(toJSON); const Model = connection.model('Model', schema); const doc = new Model({ public: 'some public value', private: 'some private value' }); expect(doc.toJSON()).not.toHaveProperty('private'); expect(doc.toJSON()).toHaveProperty('public'); }); }); ================================================ FILE: tests/unit/models/user.model.test.js ================================================ const faker = require('faker'); const { User } = require('../../../src/models'); describe('User model', () => { describe('User validation', () => { let newUser; beforeEach(() => { newUser = { name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password: 'password1', role: 'user', }; }); test('should correctly validate a valid user', async () => { await expect(new User(newUser).validate()).resolves.toBeUndefined(); }); test('should throw a validation error if email is invalid', async () => { newUser.email = 'invalidEmail'; await expect(new User(newUser).validate()).rejects.toThrow(); }); test('should throw a validation error if password length is less than 8 characters', async () => { newUser.password = 'passwo1'; await expect(new User(newUser).validate()).rejects.toThrow(); }); test('should throw a validation error if password does not contain numbers', async () => { newUser.password = 'password'; await expect(new User(newUser).validate()).rejects.toThrow(); }); test('should throw a validation error if password does not contain letters', async () => { newUser.password = '11111111'; await expect(new User(newUser).validate()).rejects.toThrow(); }); test('should throw a validation error if role is unknown', async () => { newUser.role = 'invalid'; await expect(new User(newUser).validate()).rejects.toThrow(); }); }); describe('User toJSON()', () => { test('should not return user password when toJSON is called', () => { const newUser = { name: faker.name.findName(), email: faker.internet.email().toLowerCase(), password: 'password1', role: 'user', }; expect(new User(newUser).toJSON()).not.toHaveProperty('password'); }); }); }); ================================================ FILE: tests/utils/setupTestDB.js ================================================ const mongoose = require('mongoose'); const config = require('../../src/config/config'); const setupTestDB = () => { beforeAll(async () => { await mongoose.connect(config.mongoose.url, config.mongoose.options); }); beforeEach(async () => { await Promise.all(Object.values(mongoose.connection.collections).map(async (collection) => collection.deleteMany())); }); afterAll(async () => { await mongoose.disconnect(); }); }; module.exports = setupTestDB;