master
Kar l5 2024-08-06 19:37:37 +05:30
commit 722e98bd3e
78 changed files with 11944 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
.git
.gitignore

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# 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

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
bin

19
.eslintrc.json Normal file
View File

@ -0,0 +1,19 @@
{
"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"
}
}

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# Convert text file line endings to lf
* text eol=lf
*.js text

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Dependencies
node_modules
# yarn error logs
yarn-error.log
# Environment varibales
.env*
!.env*.example
# Code coverage
coverage

4
.husky/post-checkout Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn install

4
.husky/post-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
git status

4
.husky/pre-commit Normal file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged

3
.lintstagedrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"*.js": "eslint"
}

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
coverage

4
.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 125
}

20
.travis.yml Normal file
View File

@ -0,0 +1,20 @@
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

86
CHANGELOG.md Normal file
View File

@ -0,0 +1,86 @@
# 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

76
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,76 @@
# 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

32
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,32 @@
# 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.

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
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

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
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.

433
README.md Normal file
View File

@ -0,0 +1,433 @@
# RESTful API Node Server Boilerplate
## Quick Start
To create a project, simply run:
```bash
npx create-nodejs-express-app <project-name>
```
Or
```bash
npm init nodejs-express-app <project-name>
```
## 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('<path to src>/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
taken from https://github.com/hagopj13/node-express-boilerplate
[MIT](LICENSE)

111
bin/createNodejsApp.js Normal file
View File

@ -0,0 +1,111 @@
#!/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();

6
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-dev
command: yarn dev -L

6
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-prod
command: yarn start

6
docker-compose.test.yml Normal file
View File

@ -0,0 +1,6 @@
version: '3'
services:
node-app:
container_name: node-app-test
command: yarn test

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
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

15
ecosystem.config.json Normal file
View File

@ -0,0 +1,15 @@
{
"apps": [
{
"name": "app",
"script": "src/index.js",
"instances": 1,
"autorestart": true,
"watch": false,
"time": true,
"env": {
"NODE_ENV": "production"
}
}
]
}

9
jest.config.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
testEnvironment: 'node',
testEnvironmentOptions: {
NODE_ENV: 'test',
},
restoreMocks: true,
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.js', 'tests'],
coverageReporters: ['text', 'lcov', 'clover', 'html'],
};

95
package.json Normal file
View File

@ -0,0 +1,95 @@
{
"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 <hagopj13@gmail.com>",
"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"
}
}

67
src/app.js Normal file
View File

@ -0,0 +1,67 @@
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;

64
src/config/config.js Normal file
View File

@ -0,0 +1,64 @@
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,
},
};

26
src/config/logger.js Normal file
View File

@ -0,0 +1,26 @@
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;

24
src/config/morgan.js Normal file
View File

@ -0,0 +1,24 @@
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,
};

30
src/config/passport.js Normal file
View File

@ -0,0 +1,30 @@
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,
};

12
src/config/roles.js Normal file
View File

@ -0,0 +1,12 @@
const allRoles = {
user: [],
admin: ['getUsers', 'manageUsers'],
};
const roles = Object.keys(allRoles);
const roleRights = new Map(Object.entries(allRoles));
module.exports = {
roles,
roleRights,
};

10
src/config/tokens.js Normal file
View File

@ -0,0 +1,10 @@
const tokenTypes = {
ACCESS: 'access',
REFRESH: 'refresh',
RESET_PASSWORD: 'resetPassword',
VERIFY_EMAIL: 'verifyEmail',
};
module.exports = {
tokenTypes,
};

View File

@ -0,0 +1,59 @@
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,
};

2
src/controllers/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.authController = require('./auth.controller');
module.exports.userController = require('./user.controller');

View File

@ -0,0 +1,43 @@
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,
};

92
src/docs/components.yml Normal file
View File

@ -0,0 +1,92 @@
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

21
src/docs/swaggerDef.js Normal file
View File

@ -0,0 +1,21 @@
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;

38
src/index.js Normal file
View File

@ -0,0 +1,38 @@
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();
}
});

31
src/middlewares/auth.js Normal file
View File

@ -0,0 +1,31 @@
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;

44
src/middlewares/error.js Normal file
View File

@ -0,0 +1,44 @@
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,
};

View File

@ -0,0 +1,11 @@
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
skipSuccessfulRequests: true,
});
module.exports = {
authLimiter,
};

View File

@ -0,0 +1,21 @@
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;

2
src/models/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.Token = require('./token.model');
module.exports.User = require('./user.model');

View File

@ -0,0 +1,2 @@
module.exports.toJSON = require('./toJSON.plugin');
module.exports.paginate = require('./paginate.plugin');

View File

@ -0,0 +1,70 @@
/* 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<QueryResult>}
*/
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;

View File

@ -0,0 +1,43 @@
/* 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;

44
src/models/token.model.js Normal file
View File

@ -0,0 +1,44 @@
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;

91
src/models/user.model.js Normal file
View File

@ -0,0 +1,91 @@
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<boolean>}
*/
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<boolean>}
*/
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;

291
src/routes/v1/auth.route.js Normal file
View File

@ -0,0 +1,291 @@
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
*/

View File

@ -0,0 +1,21 @@
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;

39
src/routes/v1/index.js Normal file
View File

@ -0,0 +1,39 @@
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;

252
src/routes/v1/user.route.js Normal file
View File

@ -0,0 +1,252 @@
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'
*/

View File

@ -0,0 +1,99 @@
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<User>}
*/
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<Object>}
*/
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,
};

View File

@ -0,0 +1,63 @@
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,
};

4
src/services/index.js Normal file
View File

@ -0,0 +1,4 @@
module.exports.authService = require('./auth.service');
module.exports.emailService = require('./email.service');
module.exports.tokenService = require('./token.service');
module.exports.userService = require('./user.service');

View File

@ -0,0 +1,123 @@
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<Token>}
*/
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<Token>}
*/
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<Object>}
*/
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<string>}
*/
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<string>}
*/
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,
};

View File

@ -0,0 +1,89 @@
const httpStatus = require('http-status');
const { User } = require('../models');
const ApiError = require('../utils/ApiError');
/**
* Create a user
* @param {Object} userBody
* @returns {Promise<User>}
*/
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<QueryResult>}
*/
const queryUsers = async (filter, options) => {
const users = await User.paginate(filter, options);
return users;
};
/**
* Get user by id
* @param {ObjectId} id
* @returns {Promise<User>}
*/
const getUserById = async (id) => {
return User.findById(id);
};
/**
* Get user by email
* @param {string} email
* @returns {Promise<User>}
*/
const getUserByEmail = async (email) => {
return User.findOne({ email });
};
/**
* Update user by id
* @param {ObjectId} userId
* @param {Object} updateBody
* @returns {Promise<User>}
*/
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<User>}
*/
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,
};

14
src/utils/ApiError.js Normal file
View File

@ -0,0 +1,14 @@
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;

5
src/utils/catchAsync.js Normal file
View File

@ -0,0 +1,5 @@
const catchAsync = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
};
module.exports = catchAsync;

17
src/utils/pick.js Normal file
View File

@ -0,0 +1,17 @@
/**
* 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;

View File

@ -0,0 +1,60 @@
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,
};

View File

@ -0,0 +1,21 @@
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,
};

2
src/validations/index.js Normal file
View File

@ -0,0 +1,2 @@
module.exports.authValidation = require('./auth.validation');
module.exports.userValidation = require('./user.validation');

View File

@ -0,0 +1,54 @@
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,
};

14
tests/fixtures/token.fixture.js vendored Normal file
View File

@ -0,0 +1,14 @@
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,
};

46
tests/fixtures/user.fixture.js vendored Normal file
View File

@ -0,0 +1,46 @@
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,
};

View File

@ -0,0 +1,587 @@
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();
});
});

View File

@ -0,0 +1,14 @@
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;
});
});
});

View File

@ -0,0 +1,625 @@
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);
});
});
});

View File

@ -0,0 +1,168 @@
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;
});
});
});

View File

@ -0,0 +1,61 @@
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);
});
});
});

View File

@ -0,0 +1,89 @@
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');
});
});

View File

@ -0,0 +1,57 @@
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');
});
});
});

View File

@ -0,0 +1,18 @@
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;

7206
yarn.lock Normal file

File diff suppressed because it is too large Load Diff