Initial project

This commit is contained in:
= 2020-11-28 19:30:45 +01:00
parent 28ec3b053c
commit 31c5b22011
66 changed files with 10612 additions and 0 deletions

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

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
PORT=3000
MONGODB_URL=mongodb://127.0.0.1:27017/node-boilerplate
JWT_SECRET=thisisasamplesecret
JWT_ACCESS_EXPIRATION_MINUTES=30
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

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
node_modules

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

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
# Dependencies
node_modules
# package-lock.json (use yarn.lock instead)
package-lock.json
# yarn error logs
yarn-error.log
# Environment varibales
.env*
!.env*.example
# Code coverage
coverage

7
.huskyrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"hooks": {
"post-checkout": "yarn",
"pre-commit": "lint-staged",
"post-commit": "git status"
}
}

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
}

5
TODO.md Normal file
View File

@ -0,0 +1,5 @@
[ ] formula
Women: BMR = 655 + (9.6 x weight in kg) + (1.8 x height in cm) - (4.7 x age in years)
Men: BMR = 66 + (13.7 x weight in kg) + (5 x height in cm) - (6.8 x age in years)
* activity
+- 100 kcal

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'],
};

91
package.json Normal file
View File

@ -0,0 +1,91 @@
{
"name": "fitvawe-backend",
"version": "1.0.0",
"description": "A fitwave app backend",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://git.wmi.amu.edu.pl/s458003/fitwave.git"
},
"author": "Dawid Wesołowski <dawwes@st.amu.edu.pl>",
"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",
"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"
},
"keywords": [
"node",
"node.js",
"boilerplate",
"generator",
"express",
"rest",
"api",
"mongodb",
"mongoose",
"es6",
"es7",
"es8",
"es9",
"jest",
"passport",
"joi",
"eslint",
"prettier"
],
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"cross-env": "^7.0.0",
"dotenv": "^8.2.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": "^4.1.2",
"swagger-jsdoc": "^4.0.0",
"swagger-ui-express": "^4.1.4",
"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": "^6.4.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": "^4.2.3",
"jest": "^26.0.1",
"lint-staged": "^10.0.7",
"node-mocks-http": "^1.8.0",
"nodemon": "^2.0.0",
"prettier": "^2.0.5",
"supertest": "^4.0.2"
}
}

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;

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

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

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

@ -0,0 +1,10 @@
const roles = ['user', 'admin'];
const roleRights = new Map();
roleRights.set(roles[0], []);
roleRights.set(roles[1], ['getUsers', 'manageUsers']);
module.exports = {
roles,
roleRights,
};

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

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

View File

@ -0,0 +1,46 @@
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();
});
module.exports = {
register,
login,
logout,
refreshTokens,
forgotPassword,
resetPassword,
};

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-mongoose-boilerplate API documentation',
version,
license: {
name: 'MIT',
url: 'https://github.com/hagopj13/node-express-mongoose-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;

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

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

View File

@ -0,0 +1,28 @@
const mongoose = require('mongoose');
const { toJSON } = require('./plugins');
const activitySchema = mongoose.Schema(
{
type: {
type: String,
required: true,
},
factor: {
type: Number,
required: true,
},
},
{
timestamps: true,
}
);
// add plugin that converts mongoose to json
activitySchema.plugin(toJSON);
/**
* @typedef Activity
*/
const Activity = mongoose.model('Activity', activitySchema);
module.exports = Activity;

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,56 @@
/* 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 {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();
const docsPromise = this.find(filter).sort(sort).skip(skip).limit(limit).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,34 @@
/* 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 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) {
delete ret[path];
}
});
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;

View File

@ -0,0 +1,54 @@
const mongoose = require('mongoose');
const { toJSON, paginate } = require('./plugins');
const productSchema = mongoose.Schema(
{
barcode: {
type: Number,
unique: true,
},
capacity: {
type: Number,
required: true,
},
kcal: {
type: Number,
required: true,
},
fat: {
type: Number,
required: true,
},
carbohydrates: {
type: Number,
required: true,
},
protein: {
type: Number,
required: true,
},
salt: {
type: Number,
required: true,
},
unit: {
type: mongoose.SchemaTypes.ObjectId,
ref: 'Unit',
required: true,
},
},
{
timestamps: true,
}
);
// add plugin that converts mongoose to json
productSchema.plugin(toJSON);
productSchema.plugin(paginate);
/**
* @typedef Product
*/
const Product = mongoose.model('Product', productSchema);
module.exports = Product;

View File

@ -0,0 +1,53 @@
const mongoose = require('mongoose');
const { toJSON } = require('./plugins');
const profileSchema = mongoose.Schema(
{
sex: {
type: String,
enum: ['male', 'female'],
required: true,
},
birthday: {
type: Date,
required: true,
},
height: {
type: Number,
required: true,
},
currentWeight: {
type: Number,
required: true,
},
targetWeight: {
type: Number,
},
rateOfChange: {
type: Number,
},
activity: {
type: mongoose.SchemaTypes.ObjectId,
ref: 'Activity',
required: true,
},
user: {
type: mongoose.SchemaTypes.ObjectId,
ref: 'User',
required: true,
},
},
{
timestamps: true,
}
);
// add plugin that converts mongoose to json
profileSchema.plugin(toJSON);
/**
* @typedef Profile
*/
const Profile = mongoose.model('Profile', profileSchema);
module.exports = Profile;

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],
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;

26
src/models/units.model.js Normal file
View File

@ -0,0 +1,26 @@
const mongoose = require('mongoose');
const { toJSON } = require('./plugins');
const unitSchema = mongoose.Schema(
{
type: {
type: String,
unique: true,
required: true,
trim: true,
},
},
{
timestamps: true,
}
);
// add plugin that converts mongoose to json
unitSchema.plugin(toJSON);
/**
* @typedef Unit
*/
const Unit = mongoose.model('Unit', unitSchema);
module.exports = Unit;

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

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

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

@ -0,0 +1,251 @@
const express = require('express');
const validate = require('../../middlewares/validate');
const authValidation = require('../../validations/auth.validation');
const authController = require('../../controllers/auth.controller');
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);
module.exports = router;
/**
* @swagger
* tags:
* name: Auth
* description: Authentication
*/
/**
* @swagger
* path:
* /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
* path:
* /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
* path:
* /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
* path:
* /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
* path:
* /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
* path:
* /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
*/

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;

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

@ -0,0 +1,12 @@
const express = require('express');
const authRoute = require('./auth.route');
const userRoute = require('./user.route');
const docsRoute = require('./docs.route');
const router = express.Router();
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/docs', docsRoute);
module.exports = router;

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

@ -0,0 +1,254 @@
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
* path:
* /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
* path:
* /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,79 @@
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 Token.deleteMany({ user: user.id, type: tokenTypes.RESET_PASSWORD });
await userService.updateUserById(user.id, { password: newPassword });
} catch (error) {
throw new ApiError(httpStatus.UNAUTHORIZED, 'Password reset failed');
}
};
module.exports = {
loginUserWithEmailAndPassword,
logout,
refreshAuth,
resetPassword,
};

View File

@ -0,0 +1,46 @@
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);
};
module.exports = {
transport,
sendEmail,
sendResetPasswordEmail,
};

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,109 @@
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} [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;
};
module.exports = {
generateToken,
saveToken,
verifyToken,
generateAuthTokens,
generateResetPasswordToken,
};

View File

@ -0,0 +1,90 @@
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');
}
const user = await User.create(userBody);
return user;
};
/**
* 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,53 @@
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),
}),
};
module.exports = {
register,
login,
logout,
refreshTokens,
forgotPassword,
resetPassword,
};

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,
};

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

@ -0,0 +1,43 @@
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',
};
const userTwo = {
_id: mongoose.Types.ObjectId(),
name: faker.name.findName(),
email: faker.internet.email().toLowerCase(),
password,
role: 'user',
};
const admin = {
_id: mongoose.Types.ObjectId(),
name: faker.name.findName(),
email: faker.internet.email().toLowerCase(),
password,
role: 'admin',
};
const insertUsers = async (users) => {
await User.insertMany(users.map((user) => ({ ...user, password: hashedPassword })));
};
module.exports = {
userOne,
userTwo,
admin,
insertUsers,
};

View File

@ -0,0 +1,483 @@
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' });
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' });
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,
});
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('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,616 @@
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 });
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 });
});
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 is 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,
});
});
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,
});
});
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',
});
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,151 @@
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 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,70 @@
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 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;

6975
yarn.lock Normal file

File diff suppressed because it is too large Load Diff