Browse Source

initial commit

features/employee
commit
9cfbf9ba17
37 changed files with 9909 additions and 0 deletions
  1. +14
    -0
      Dockerfile
  2. +153
    -0
      README.md
  3. +12
    -0
      deploy.sh
  4. +6
    -0
      docker-compose.dev.yml
  5. +6
    -0
      docker-compose.prod.yml
  6. +6
    -0
      docker-compose.test.yml
  7. +17
    -0
      docker-compose.yml
  8. +100
    -0
      package.json
  9. +146
    -0
      src/api/controllers/auth.controller.js
  10. +102
    -0
      src/api/controllers/user.controller.js
  11. +54
    -0
      src/api/middlewares/auth.js
  12. +62
    -0
      src/api/middlewares/error.js
  13. +57
    -0
      src/api/models/passwordResetToken.model.js
  14. +54
    -0
      src/api/models/refreshToken.model.js
  15. +236
    -0
      src/api/models/user.model.js
  16. +150
    -0
      src/api/routes/v1/auth.route.js
  17. +20
    -0
      src/api/routes/v1/index.js
  18. +193
    -0
      src/api/routes/v1/user.route.js
  19. +35
    -0
      src/api/services/authProviders.js
  20. +77
    -0
      src/api/services/emails/emailProvider.js
  21. +407
    -0
      src/api/services/emails/passwordChange/html.pug
  22. +1
    -0
      src/api/services/emails/passwordChange/subject.pug
  23. +436
    -0
      src/api/services/emails/passwordReset/html.pug
  24. +1
    -0
      src/api/services/emails/passwordReset/subject.pug
  25. +528
    -0
      src/api/tests/integration/auth.test.js
  26. +550
    -0
      src/api/tests/integration/user.test.js
  27. +46
    -0
      src/api/utils/APIError.js
  28. +584
    -0
      src/api/utils/passwordResetEmailTemplate.html
  29. +69
    -0
      src/api/validations/auth.validation.js
  30. +52
    -0
      src/api/validations/user.validation.js
  31. +58
    -0
      src/config/express.js
  32. +32
    -0
      src/config/logger.js
  33. +36
    -0
      src/config/mongoose.js
  34. +35
    -0
      src/config/passport.js
  35. +24
    -0
      src/config/vars.js
  36. +17
    -0
      src/index.js
  37. +5533
    -0
      yarn.lock

+ 14
- 0
Dockerfile View File

@ -0,0 +1,14 @@
FROM node:8-alpine
EXPOSE 3000
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
RUN mkdir /app
WORKDIR /app
ADD package.json yarn.lock /app/
RUN yarn --pure-lockfile
ADD . /app
CMD ["yarn", "docker:start"]

+ 153
- 0
README.md View File

@ -0,0 +1,153 @@
# Express ES2017 REST API Boilerplate
Boilerplate/Generator/Starter Project for building RESTful APIs and microservices using Node.js, Express and MongoDB
## Features
- No transpilers, just vanilla javascript
- ES2017 latest features like Async/Await
- CORS enabled
- Uses [yarn](https://yarnpkg.com)
- Express + MongoDB ([Mongoose](http://mongoosejs.com/))
- Consistent coding styles with [editorconfig](http://editorconfig.org)
- [Docker](https://www.docker.com/) support
- Uses [helmet](https://github.com/helmetjs/helmet) to set some HTTP headers for security
- Load environment variables from .env files with [dotenv](https://github.com/rolodato/dotenv-safe)
- Request validation with [joi](https://github.com/hapijs/joi)
- Gzip compression with [compression](https://github.com/expressjs/compression)
- Linting with [eslint](http://eslint.org)
- Tests with [mocha](https://mochajs.org), [chai](http://chaijs.com) and [sinon](http://sinonjs.org)
- Code coverage with [istanbul](https://istanbul.js.org) and [coveralls](https://coveralls.io)
- Git hooks with [husky](https://github.com/typicode/husky)
- Logging with [morgan](https://github.com/expressjs/morgan)
- Authentication and Authorization with [passport](http://passportjs.org)
- API documentation generation with [apidoc](http://apidocjs.com)
- Continuous integration support with [travisCI](https://travis-ci.org)
- Monitoring with [pm2](https://github.com/Unitech/pm2)
## Requirements
- [Node v7.6+](https://nodejs.org/en/download/current/) or [Docker](https://www.docker.com/)
- [Yarn](https://yarnpkg.com/en/docs/install)
## Getting Started
#### Clone the repo and make it yours:
```bash
git clone --depth 1 https://github.com/krishfsousa/sg-node-express-rest-api
cd sg-node-express-rest-api
rm -rf .git
```
#### Install dependencies:
```bash
yarn
```
#### Set environment variables:
```bash
cp .env.example .env
```
## Running Locally
```bash
yarn dev
```
## Running in Production
```bash
yarn start
```
## Lint
```bash
# lint code with ESLint
yarn lint
# try to fix ESLint errors
yarn lint:fix
# lint and watch for changes
yarn lint:watch
```
## Test
```bash
# run all tests with Mocha
yarn test
# run unit tests
yarn test:unit
# run integration tests
yarn test:integration
# run all tests and watch for changes
yarn test:watch
# open nyc test coverage reports
yarn coverage
```
## Validate
```bash
# run lint and tests
yarn validate
```
## Logs
```bash
# show logs in production
pm2 logs
```
## Documentation
```bash
# generate and open api documentation
yarn docs
```
## Docker
```bash
# run container locally
yarn docker:dev
# run container in production
yarn docker:prod
# run tests
yarn docker:test
```
## Deploy
Set your server ip:
```bash
DEPLOY_SERVER=127.0.0.1
```
Replace my Docker username with yours:
```bash
nano deploy.sh
```
Run deploy script:
```bash
yarn deploy
```

+ 12
- 0
deploy.sh View File

@ -0,0 +1,12 @@
#!/bin/bash
docker build -t krishfsousa/sg-node-express-rest-api .
docker push krishfsousa/sg-node-express-rest-api
ssh deploy@$DEPLOY_SERVER << EOF
docker pull krishfsousa/sg-node-express-rest-api
docker stop api-boilerplate || true
docker rm api-boilerplate || true
docker rmi krishfsousa/sg-node-express-rest-api:current || true
docker tag krishfsousa/sg-node-express-rest-api:latest krishfsousa/sg-node-express-rest-api:current
docker run -d --restart always --name api-boilerplate -p 3000:3000 krishfsousa/sg-node-express-rest-api:current
EOF

+ 6
- 0
docker-compose.dev.yml View File

@ -0,0 +1,6 @@
version: "2"
services:
boilerplate-api:
command: yarn dev -- -L
environment:
- NODE_ENV=development

+ 6
- 0
docker-compose.prod.yml View File

@ -0,0 +1,6 @@
version: "2"
services:
boilerplate-api:
command: yarn start
environment:
- NODE_ENV=production

+ 6
- 0
docker-compose.test.yml View File

@ -0,0 +1,6 @@
version: "2"
services:
boilerplate-api:
command: yarn test
environment:
- NODE_ENV=test

+ 17
- 0
docker-compose.yml View File

@ -0,0 +1,17 @@
version: "2"
services:
boilerplate-api:
build: .
environment:
- MONGO_URI=mongodb://mongodb:27017/sg-node-express-rest-api
volumes:
- .:/app
ports:
- "3000:3000"
depends_on:
- mongodb
mongodb:
image: mongo
ports:
- "27017:27017"

+ 100
- 0
package.json View File

@ -0,0 +1,100 @@
{
"name": "sg-node-express-rest-api",
"version": "0.0.2",
"description": "Sentientgeeks Boilerplate Node Express RESTApi ES2017",
"author": "sentientgeeks",
"main": "src/index.js",
"engines": {
"node": ">=8",
"yarn": "*"
},
"scripts": {
"precommit": "yarn lint",
"prestart": "yarn docs",
"start": "cross-env NODE_ENV=production pm2 start ./src/index.js",
"dev": "nodemon ./src/index.js",
"lint": "eslint ./src/ --ignore-path .gitignore --ignore-pattern internals/scripts",
"lint:fix": "yarn lint --fix",
"lint:watch": "yarn lint --watch",
"test": "cross-env NODE_ENV=test nyc --reporter=html --reporter=text mocha --timeout 20000 --exit --recursive src/api/tests",
"test:unit": "cross-env NODE_ENV=test mocha src/api/tests/unit",
"test:integration": "cross-env NODE_ENV=test mocha --timeout 20000 --exit src/api/tests/integration",
"test:watch": "cross-env NODE_ENV=test mocha --watch src/api/tests/unit",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"postcoverage": "open-cli coverage/lcov-report/index.html",
"validate": "yarn lint && yarn test",
"postpublish": "git push --tags",
"deploy": "sh ./deploy.sh",
"docs": "apidoc -i src -o docs",
"postdocs": "open-cli docs/index.html",
"docker:start": "cross-env NODE_ENV=production pm2-docker start ./src/index.js",
"docker:prod": "docker-compose -f docker-compose.yml -f docker-compose.prod.yml up",
"docker:dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up",
"docker:test": "docker-compose -f docker-compose.yml -f docker-compose.test.yml up --abort-on-container-exit"
},
"repository": {
"type": "git",
"url": "https://krishbhatt@bitbucket.org/krishbhatt/sg-node-express-rest-api.git"
},
"keywords": [
"express",
"node",
"node.js",
"mongodb",
"mongoose",
"passport",
"es6",
"es7",
"es8",
"es2017",
"mocha",
"istanbul",
"nyc",
"eslint",
"Travis CI",
"coveralls",
"REST",
"API",
"boilerplate",
"generator",
"starter project"
],
"dependencies": {
"axios": "^0.19.0",
"bcryptjs": "2.4.3",
"bluebird": "^3.5.0",
"body-parser": "^1.17.0",
"compression": "^1.6.2",
"cors": "^2.8.3",
"cross-env": "^6.0.3",
"dotenv-safe": "^6.0.0",
"email-templates": "^6.0.3",
"express": "^4.15.2",
"express-validation": "^1.0.2",
"nodemailer": "^6.3.1",
"passport": "^0.4.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "4.0.0",
"pm2": "^4.2.0",
"pug": "^2.0.4",
"uuid": "^3.1.0",
"winston": "^3.1.0"
},
"devDependencies": {
"apidoc": "^0.17.5",
"chai": "^4.1.0",
"chai-as-promised": "^7.1.1",
"coveralls": "^3.0.0",
"eslint": "^6.4.0",
"eslint-config-airbnb-base": "^12.0.1",
"eslint-plugin-import": "^2.2.0",
"husky": "^3.0.7",
"mocha": "^6.2.2",
"nodemon": "^2.0.1",
"nyc": "^14.1.1",
"opn-cli": "^5.0.0",
"sinon": "^7.5.0",
"sinon-chai": "^3.0.0",
"supertest": "^4.0.2"
}
}

+ 146
- 0
src/api/controllers/auth.controller.js View File

@ -0,0 +1,146 @@
const httpStatus = require('http-status');
const User = require('../models/user.model');
const RefreshToken = require('../models/refreshToken.model');
const PasswordResetToken = require('../models/passwordResetToken.model');
const moment = require('moment-timezone');
const { jwtExpirationInterval } = require('../../config/vars');
const { omit } = require('lodash');
const APIError = require('../utils/APIError');
const emailProvider = require('../services/emails/emailProvider');
/**
* Returns a formated object with tokens
* @private
*/
function generateTokenResponse(user, accessToken) {
const tokenType = 'Bearer';
const refreshToken = RefreshToken.generate(user).token;
const expiresIn = moment().add(jwtExpirationInterval, 'minutes');
return {
tokenType,
accessToken,
refreshToken,
expiresIn,
};
}
/**
* Returns jwt token if registration was successful
* @public
*/
exports.register = async (req, res, next) => {
try {
const userData = omit(req.body, 'role');
const user = await new User(userData).save();
const userTransformed = user.transform();
const token = generateTokenResponse(user, user.token());
res.status(httpStatus.CREATED);
return res.json({ token, user: userTransformed });
} catch (error) {
return next(User.checkDuplicateEmail(error));
}
};
/**
* Returns jwt token if valid username and password is provided
* @public
*/
exports.login = async (req, res, next) => {
try {
const { user, accessToken } = await User.findAndGenerateToken(req.body);
const token = generateTokenResponse(user, accessToken);
const userTransformed = user.transform();
return res.json({ token, user: userTransformed });
} catch (error) {
return next(error);
}
};
/**
* login with an existing user or creates a new one if valid accessToken token
* Returns jwt token
* @public
*/
exports.oAuth = async (req, res, next) => {
try {
const { user } = req;
const accessToken = user.token();
const token = generateTokenResponse(user, accessToken);
const userTransformed = user.transform();
return res.json({ token, user: userTransformed });
} catch (error) {
return next(error);
}
};
/**
* Returns a new jwt when given a valid refresh token
* @public
*/
exports.refresh = async (req, res, next) => {
try {
const { email, refreshToken } = req.body;
const refreshObject = await RefreshToken.findOneAndRemove({
userEmail: email,
token: refreshToken,
});
const { user, accessToken } = await User.findAndGenerateToken({ email, refreshObject });
const response = generateTokenResponse(user, accessToken);
return res.json(response);
} catch (error) {
return next(error);
}
};
exports.sendPasswordReset = async (req, res, next) => {
try {
const { email } = req.body;
const user = await User.findOne({ email }).exec();
if (user) {
const passwordResetObj = await PasswordResetToken.generate(user);
emailProvider.sendPasswordReset(passwordResetObj);
res.status(httpStatus.OK);
return res.json('success');
}
throw new APIError({
status: httpStatus.UNAUTHORIZED,
message: 'No account found with that email',
});
} catch (error) {
return next(error);
}
};
exports.resetPassword = async (req, res, next) => {
try {
const { email, password, resetToken } = req.body;
const resetTokenObject = await PasswordResetToken.findOneAndRemove({
userEmail: email,
resetToken,
});
const err = {
status: httpStatus.UNAUTHORIZED,
isPublic: true,
};
if (!resetTokenObject) {
err.message = 'Cannot find matching reset token';
throw new APIError(err);
}
if (moment().isAfter(resetTokenObject.expires)) {
err.message = 'Reset token is expired';
throw new APIError(err);
}
const user = await User.findOne({ email: resetTokenObject.userEmail }).exec();
user.password = password;
await user.save();
emailProvider.sendPasswordChangeEmail(user);
res.status(httpStatus.OK);
return res.json('Password Updated');
} catch (error) {
return next(error);
}
};

+ 102
- 0
src/api/controllers/user.controller.js View File

@ -0,0 +1,102 @@
const httpStatus = require('http-status');
const { omit } = require('lodash');
const User = require('../models/user.model');
/**
* Load user and append to req.
* @public
*/
exports.load = async (req, res, next, id) => {
try {
const user = await User.get(id);
req.locals = { user };
return next();
} catch (error) {
return next(error);
}
};
/**
* Get user
* @public
*/
exports.get = (req, res) => res.json(req.locals.user.transform());
/**
* Get logged in user info
* @public
*/
exports.loggedIn = (req, res) => res.json(req.user.transform());
/**
* Create new user
* @public
*/
exports.create = async (req, res, next) => {
try {
const user = new User(req.body);
const savedUser = await user.save();
res.status(httpStatus.CREATED);
res.json(savedUser.transform());
} catch (error) {
next(User.checkDuplicateEmail(error));
}
};
/**
* Replace existing user
* @public
*/
exports.replace = async (req, res, next) => {
try {
const { user } = req.locals;
const newUser = new User(req.body);
const ommitRole = user.role !== 'admin' ? 'role' : '';
const newUserObject = omit(newUser.toObject(), '_id', ommitRole);
res.json(savedUser.transform());
} catch (error) {
next(User.checkDuplicateEmail(error));
}
};
/**
* Update existing user
* @public
*/
exports.update = (req, res, next) => {
const ommitRole = req.locals.user.role !== 'admin' ? 'role' : '';
const updatedUser = omit(req.body, ommitRole);
const user = Object.assign(req.locals.user, updatedUser);
user.save()
.then(savedUser => res.json(savedUser.transform()))
.catch(e => next(User.checkDuplicateEmail(e)));
};
/**
* Get user list
* @public
*/
exports.list = async (req, res, next) => {
try {
const users = await User.list(req.query);
const transformedUsers = users.map(user => user.transform());
res.json(transformedUsers);
} catch (error) {
next(error);
}
};
/**
* Delete user
* @public
*/
exports.remove = (req, res, next) => {
const { user } = req.locals;
user.remove()
.then(() => res.status(httpStatus.NO_CONTENT).end())
.catch(e => next(e));
};

+ 54
- 0
src/api/middlewares/auth.js View File

@ -0,0 +1,54 @@
const httpStatus = require('http-status');
const passport = require('passport');
const User = require('../models/user.model');
const APIError = require('../utils/APIError');
const ADMIN = 'admin';
const LOGGED_USER = '_loggedUser';
const handleJWT = (req, res, next, roles) => async (err, user, info) => {
const error = err || info;
const logIn = Promise.promisify(req.logIn);
const apiError = new APIError({
message: error ? error.message : 'Unauthorized',
status: httpStatus.UNAUTHORIZED,
stack: error ? error.stack : undefined,
});
try {
if (error || !user) throw error;
await logIn(user, { session: false });
} catch (e) {
return next(apiError);
}
if (roles === LOGGED_USER) {
if (user.role !== 'admin' && req.params.userId !== user._id.toString()) {
apiError.status = httpStatus.FORBIDDEN;
apiError.message = 'Forbidden';
return next(apiError);
}
} else if (!roles.includes(user.role)) {
apiError.status = httpStatus.FORBIDDEN;
apiError.message = 'Forbidden';
return next(apiError);
} else if (err || !user) {
return next(apiError);
}
req.user = user;
return next();
};
exports.ADMIN = ADMIN;
exports.LOGGED_USER = LOGGED_USER;
exports.authorize = (roles = User.roles) => (req, res, next) =>
passport.authenticate(
'jwt', { session: false },
handleJWT(req, res, next, roles),
)(req, res, next);
exports.oAuth = service =>
passport.authenticate(service, { session: false });

+ 62
- 0
src/api/middlewares/error.js View File

@ -0,0 +1,62 @@
const httpStatus = require('http-status');
const expressValidation = require('express-validation');
const APIError = require('../utils/APIError');
const { env } = require('../../config/vars');
/**
* Error handler. Send stacktrace only during development
* @public
*/
const handler = (err, req, res, next) => {
const response = {
code: err.status,
message: err.message || httpStatus[err.status],
errors: err.errors,
stack: err.stack,
};
if (env !== 'development') {
delete response.stack;
}
res.status(err.status);
res.json(response);
};
exports.handler = handler;
/**
* If error is not an instanceOf APIError, convert it.
* @public
*/
exports.converter = (err, req, res, next) => {
let convertedError = err;
if (err instanceof expressValidation.ValidationError) {
convertedError = new APIError({
message: 'Validation Error',
errors: err.errors,
status: err.status,
stack: err.stack,
});
} else if (!(err instanceof APIError)) {
convertedError = new APIError({
message: err.message,
status: err.status,
stack: err.stack,
});
}
return handler(convertedError, req, res);
};
/**
* Catch 404 and forward to error handler
* @public
*/
exports.notFound = (req, res, next) => {
const err = new APIError({
message: 'Not found',
status: httpStatus.NOT_FOUND,
});
return handler(err, req, res);
};

+ 57
- 0
src/api/models/passwordResetToken.model.js View File

@ -0,0 +1,57 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const moment = require('moment-timezone');
/**
* Refresh Token Schema
* @private
*/
const passwordResetTokenSchema = new mongoose.Schema({
resetToken: {
type: String,
required: true,
index: true,
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
userEmail: {
type: 'String',
ref: 'User',
required: true,
},
expires: { type: Date },
});
passwordResetTokenSchema.statics = {
/**
* Generate a reset token object and saves it into the database
*
* @param {User} user
* @returns {ResetToken}
*/
async generate(user) {
const userId = user._id;
const userEmail = user.email;
const resetToken = `${userId}.${crypto.randomBytes(40).toString('hex')}`;
const expires = moment()
.add(2, 'hours')
.toDate();
const ResetTokenObject = new PasswordResetToken({
resetToken,
userId,
userEmail,
expires,
});
await ResetTokenObject.save();
return ResetTokenObject;
},
};
/**
* @typedef RefreshToken
*/
const PasswordResetToken = mongoose.model('PasswordResetToken', passwordResetTokenSchema);
module.exports = PasswordResetToken;

+ 54
- 0
src/api/models/refreshToken.model.js View File

@ -0,0 +1,54 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const moment = require('moment-timezone');
/**
* Refresh Token Schema
* @private
*/
const refreshTokenSchema = new mongoose.Schema({
token: {
type: String,
required: true,
index: true,
},
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
userEmail: {
type: 'String',
ref: 'User',
required: true,
},
expires: { type: Date },
});
refreshTokenSchema.statics = {
/**
* Generate a refresh token object and saves it into the database
*
* @param {User} user
* @returns {RefreshToken}
*/
generate(user) {
const userId = user._id;
const userEmail = user.email;
const token = `${userId}.${crypto.randomBytes(40).toString('hex')}`;
const expires = moment().add(30, 'days').toDate();
const tokenObject = new RefreshToken({
token, userId, userEmail, expires,
});
tokenObject.save();
return tokenObject;
},
};
/**
* @typedef RefreshToken
*/
const RefreshToken = mongoose.model('RefreshToken', refreshTokenSchema);
module.exports = RefreshToken;

+ 236
- 0
src/api/models/user.model.js View File

@ -0,0 +1,236 @@
const mongoose = require('mongoose');
const httpStatus = require('http-status');
const { omitBy, isNil } = require('lodash');
const bcrypt = require('bcryptjs');
const moment = require('moment-timezone');
const jwt = require('jwt-simple');
const uuidv4 = require('uuid/v4');
const APIError = require('../utils/APIError');
const { env, jwtSecret, jwtExpirationInterval } = require('../../config/vars');
/**
* User Roles
*/
const roles = ['user', 'admin'];
/**
* User Schema
* @private
*/
const userSchema = new mongoose.Schema({
email: {
type: String,
match: /^\S+@\S+\.\S+$/,
required: true,
unique: true,
trim: true,
lowercase: true,
},
password: {
type: String,
required: true,
minlength: 6,
maxlength: 128,
},
name: {
type: String,
maxlength: 128,
index: true,
trim: true,
},
services: {
facebook: String,
google: String,
},
role: {
type: String,
enum: roles,
default: 'user',
},
picture: {
type: String,
trim: true,
},
}, {
timestamps: true,
});
/**
* Add your
* - pre-save hooks
* - validations
* - virtuals
*/
userSchema.pre('save', async function save(next) {
try {
if (!this.isModified('password')) return next();
const rounds = env === 'test' ? 1 : 10;
const hash = await bcrypt.hash(this.password, rounds);
this.password = hash;
return next();
} catch (error) {
return next(error);
}
});
/**
* Methods
*/
userSchema.method({
transform() {
const transformed = {};
const fields = ['id', 'name', 'email', 'picture', 'role', 'createdAt'];
fields.forEach((field) => {
transformed[field] = this[field];
});
return transformed;
},
token() {
const payload = {
exp: moment().add(jwtExpirationInterval, 'minutes').unix(),
iat: moment().unix(),
sub: this._id,
};
return jwt.encode(payload, jwtSecret);
},
async passwordMatches(password) {
return bcrypt.compare(password, this.password);
},
});
/**
* Statics
*/
userSchema.statics = {
roles,
/**
* Get user
*
* @param {ObjectId} id - The objectId of user.
* @returns {Promise<User, APIError>}
*/
async get(id) {
try {
let user;
if (mongoose.Types.ObjectId.isValid(id)) {
user = await this.findById(id).exec();
}
if (user) {
return user;
}
throw new APIError({
message: 'User does not exist',
status: httpStatus.NOT_FOUND,
});
} catch (error) {
throw error;
}
},
/**
* Find user by email and tries to generate a JWT token
*
* @param {ObjectId} id - The objectId of user.
* @returns {Promise<User, APIError>}
*/
async findAndGenerateToken(options) {
const { email, password, refreshObject } = options;
if (!email) throw new APIError({ message: 'An email is required to generate a token' });
const user = await this.findOne({ email }).exec();
const err = {
status: httpStatus.UNAUTHORIZED,
isPublic: true,
};
if (password) {
if (user && await user.passwordMatches(password)) {
return { user, accessToken: user.token() };
}
err.message = 'Incorrect email or password';
} else if (refreshObject && refreshObject.userEmail === email) {
if (moment(refreshObject.expires).isBefore()) {
err.message = 'Invalid refresh token.';
} else {
return { user, accessToken: user.token() };
}
} else {
err.message = 'Incorrect email or refreshToken';
}
throw new APIError(err);
},
/**
* List users in descending order of 'createdAt' timestamp.
*
* @param {number} skip - Number of users to be skipped.
* @param {number} limit - Limit number of users to be returned.
* @returns {Promise<User[]>}
*/
list({
page = 1, perPage = 30, name, email, role,
}) {
const options = omitBy({ name, email, role }, isNil);
return this.find(options)
.sort({ createdAt: -1 })
.skip(perPage * (page - 1))
.limit(perPage)
.exec();
},
/**
* Return new validation error
* if error is a mongoose duplicate key error
*
* @param {Error} error
* @returns {Error|APIError}
*/
checkDuplicateEmail(error) {
if (error.name === 'MongoError' && error.code === 11000) {
return new APIError({
message: 'Validation Error',
errors: [{
field: 'email',
location: 'body',
messages: ['"email" already exists'],
}],
status: httpStatus.CONFLICT,
isPublic: true,
stack: error.stack,
});
}
return error;
},
async oAuthLogin({
service, id, email, name, picture,
}) {
const user = await this.findOne({ $or: [{ [`services.${service}`]: id }, { email }] });
if (user) {
user.services[service] = id;
if (!user.name) user.name = name;
if (!user.picture) user.picture = picture;
return user.save();
}
const password = uuidv4();
return this.create({
services: { [service]: id }, email, password, name, picture,
});
},
};
/**
* @typedef User
*/
module.exports = mongoose.model('User', userSchema);

+ 150
- 0
src/api/routes/v1/auth.route.js View File

@ -0,0 +1,150 @@
const express = require('express');
const validate = require('express-validation');
const controller = require('../../controllers/auth.controller');
const oAuthLogin = require('../../middlewares/auth').oAuth;
const {
login,
register,
oAuth,
refresh,
sendPasswordReset,
passwordReset,
} = require('../../validations/auth.validation');
const router = express.Router();
/**
* @api {post} v1/auth/register Register
* @apiDescription Register a new user
* @apiVersion 1.0.0
* @apiName Register
* @apiGroup Auth
* @apiPermission public
*
* @apiParam {String} email User's email
* @apiParam {String{6..128}} password User's password
*
* @apiSuccess (Created 201) {String} token.tokenType Access Token's type
* @apiSuccess (Created 201) {String} token.accessToken Authorization Token
* @apiSuccess (Created 201) {String} token.refreshToken Token to get a new accessToken
* after expiration time
* @apiSuccess (Created 201) {Number} token.expiresIn Access Token's expiration time
* in miliseconds
* @apiSuccess (Created 201) {String} token.timezone The server's Timezone
*
* @apiSuccess (Created 201) {String} user.id User's id
* @apiSuccess (Created 201) {String} user.name User's name
* @apiSuccess (Created 201) {String} user.email User's email
* @apiSuccess (Created 201) {String} user.role User's role
* @apiSuccess (Created 201) {Date} user.createdAt Timestamp
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
*/
router.route('/register')
.post(validate(register), controller.register);
/**
* @api {post} v1/auth/login Login
* @apiDescription Get an accessToken
* @apiVersion 1.0.0
* @apiName Login
* @apiGroup Auth
* @apiPermission public
*
* @apiParam {String} email User's email
* @apiParam {String{..128}} password User's password
*
* @apiSuccess {String} token.tokenType Access Token's type
* @apiSuccess {String} token.accessToken Authorization Token
* @apiSuccess {String} token.refreshToken Token to get a new accessToken
* after expiration time
* @apiSuccess {Number} token.expiresIn Access Token's expiration time
* in miliseconds
*
* @apiSuccess {String} user.id User's id
* @apiSuccess {String} user.name User's name
* @apiSuccess {String} user.email User's email
* @apiSuccess {String} user.role User's role
* @apiSuccess {Date} user.createdAt Timestamp
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Incorrect email or password
*/
router.route('/login')
.post(validate(login), controller.login);
/**
* @api {post} v1/auth/refresh-token Refresh Token
* @apiDescription Refresh expired accessToken
* @apiVersion 1.0.0
* @apiName RefreshToken
* @apiGroup Auth
* @apiPermission public
*
* @apiParam {String} email User's email
* @apiParam {String} refreshToken Refresh token aquired when user logged in
*
* @apiSuccess {String} tokenType Access Token's type
* @apiSuccess {String} accessToken Authorization Token
* @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Incorrect email or refreshToken
*/
router.route('/refresh-token')
.post(validate(refresh), controller.refresh);
router.route('/send-password-reset')
.post(validate(sendPasswordReset), controller.sendPasswordReset);
router.route('/reset-password')
.post(validate(passwordReset), controller.resetPassword);
/**
* @api {post} v1/auth/facebook Facebook Login
* @apiDescription Login with facebook. Creates a new user if it does not exist
* @apiVersion 1.0.0
* @apiName FacebookLogin
* @apiGroup Auth
* @apiPermission public
*
* @apiParam {String} access_token Facebook's access_token
*
* @apiSuccess {String} tokenType Access Token's type
* @apiSuccess {String} accessToken Authorization Token
* @apiSuccess {String} refreshToken Token to get a new accessToken after expiration time
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Incorrect access_token
*/
router.route('/facebook')
.post(validate(oAuth), oAuthLogin('facebook'), controller.oAuth);
/**
* @api {post} v1/auth/google Google Login
* @apiDescription Login with google. Creates a new user if it does not exist
* @apiVersion 1.0.0
* @apiName GoogleLogin
* @apiGroup Auth
* @apiPermission public
*
* @apiParam {String} access_token Google's access_token
*
* @apiSuccess {String} tokenType Access Token's type
* @apiSuccess {String} accessToken Authorization Token
* @apiSuccess {String} refreshToken Token to get a new accpessToken after expiration time
* @apiSuccess {Number} expiresIn Access Token's expiration time in miliseconds
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Incorrect access_token
*/
router.route('/google')
.post(validate(oAuth), oAuthLogin('google'), controller.oAuth);
module.exports = router;

+ 20
- 0
src/api/routes/v1/index.js View File

@ -0,0 +1,20 @@
const express = require('express');
const userRoutes = require('./user.route');
const authRoutes = require('./auth.route');
const router = express.Router();
/**
* GET v1/status
*/
router.get('/status', (req, res) => res.send('OK'));
/**
* GET v1/docs
*/
router.use('/docs', express.static('docs'));
router.use('/users', userRoutes);
router.use('/auth', authRoutes);
module.exports = router;

+ 193
- 0
src/api/routes/v1/user.route.js View File

@ -0,0 +1,193 @@
const express = require('express');
const validate = require('express-validation');
const controller = require('../../controllers/user.controller');
const { authorize, ADMIN, LOGGED_USER } = require('../../middlewares/auth');
const {
listUsers,
createUser,
replaceUser,
updateUser,
} = require('../../validations/user.validation');
const router = express.Router();
/**
* Load user when API with userId route parameter is hit
*/
router.param('userId', controller.load);
router
.route('/')
/**
* @api {get} v1/users List Users
* @apiDescription Get a list of users
* @apiVersion 1.0.0
* @apiName ListUsers
* @apiGroup User
* @apiPermission admin
*
* @apiHeader {String} Authorization User's access token
*
* @apiParam {Number{1-}} [page=1] List page
* @apiParam {Number{1-100}} [perPage=1] Users per page
* @apiParam {String} [name] User's name
* @apiParam {String} [email] User's email
* @apiParam {String=user,admin} [role] User's role
*
* @apiSuccess {Object[]} users List of users.
*
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data
* @apiError (Forbidden 403) Forbidden Only admins can access the data
*/
.get(authorize(ADMIN), validate(listUsers), controller.list)
/**
* @api {post} v1/users Create User
* @apiDescription Create a new user
* @apiVersion 1.0.0
* @apiName CreateUser
* @apiGroup User
* @apiPermission admin
*
* @apiHeader {String} Authorization User's access token
*
* @apiParam {String} email User's email
* @apiParam {String{6..128}} password User's password
* @apiParam {String{..128}} [name] User's name
* @apiParam {String=user,admin} [role] User's role
*
* @apiSuccess (Created 201) {String} id User's id
* @apiSuccess (Created 201) {String} name User's name
* @apiSuccess (Created 201) {String} email User's email
* @apiSuccess (Created 201) {String} role User's role
* @apiSuccess (Created 201) {Date} createdAt Timestamp
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can create the data
* @apiError (Forbidden 403) Forbidden Only admins can create the data
*/
.post(authorize(ADMIN), validate(createUser), controller.create);
router
.route('/profile')
/**
* @api {get} v1/users/profile User Profile
* @apiDescription Get logged in user profile information
* @apiVersion 1.0.0
* @apiName UserProfile
* @apiGroup User
* @apiPermission user
*
* @apiHeader {String} Authorization User's access token
*
* @apiSuccess {String} id User's id
* @apiSuccess {String} name User's name
* @apiSuccess {String} email User's email
* @apiSuccess {String} role User's role
* @apiSuccess {Date} createdAt Timestamp
*
* @apiError (Unauthorized 401) Unauthorized Only authenticated Users can access the data
*/
.get(authorize(), controller.loggedIn);
router
.route('/:userId')
/**
* @api {get} v1/users/:id Get User
* @apiDescription Get user information
* @apiVersion 1.0.0
* @apiName GetUser
* @apiGroup User
* @apiPermission user
*
* @apiHeader {String} Authorization User's access token
*
* @apiSuccess {String} id User's id
* @apiSuccess {String} name User's name
* @apiSuccess {String} email User's email
* @apiSuccess {String} role User's role
* @apiSuccess {Date} createdAt Timestamp
*
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can access the data
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can access the data
* @apiError (Not Found 404) NotFound User does not exist
*/
.get(authorize(LOGGED_USER), controller.get)
/**
* @api {put} v1/users/:id Replace User
* @apiDescription Replace the whole user document with a new one
* @apiVersion 1.0.0
* @apiName ReplaceUser
* @apiGroup User
* @apiPermission user
*
* @apiHeader {String} Authorization User's access token
*
* @apiParam {String} email User's email
* @apiParam {String{6..128}} password User's password
* @apiParam {String{..128}} [name] User's name
* @apiParam {String=user,admin} [role] User's role
* (You must be an admin to change the user's role)
*
* @apiSuccess {String} id User's id
* @apiSuccess {String} name User's name
* @apiSuccess {String} email User's email
* @apiSuccess {String} role User's role
* @apiSuccess {Date} createdAt Timestamp
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data
* @apiError (Not Found 404) NotFound User does not exist
*/
.put(authorize(LOGGED_USER), validate(replaceUser), controller.replace)
/**
* @api {patch} v1/users/:id Update User
* @apiDescription Update some fields of a user document
* @apiVersion 1.0.0
* @apiName UpdateUser
* @apiGroup User
* @apiPermission user
*
* @apiHeader {String} Authorization User's access token
*
* @apiParam {String} email User's email
* @apiParam {String{6..128}} password User's password
* @apiParam {String{..128}} [name] User's name
* @apiParam {String=user,admin} [role] User's role
* (You must be an admin to change the user's role)
*
* @apiSuccess {String} id User's id
* @apiSuccess {String} name User's name
* @apiSuccess {String} email User's email
* @apiSuccess {String} role User's role
* @apiSuccess {Date} createdAt Timestamp
*
* @apiError (Bad Request 400) ValidationError Some parameters may contain invalid values
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can modify the data
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can modify the data
* @apiError (Not Found 404) NotFound User does not exist
*/
.patch(authorize(LOGGED_USER), validate(updateUser), controller.update)
/**
* @api {patch} v1/users/:id Delete User
* @apiDescription Delete a user
* @apiVersion 1.0.0
* @apiName DeleteUser
* @apiGroup User
* @apiPermission user
*
* @apiHeader {String} Authorization User's access token
*
* @apiSuccess (No Content 204) Successfully deleted
*
* @apiError (Unauthorized 401) Unauthorized Only authenticated users can delete the data
* @apiError (Forbidden 403) Forbidden Only user with same id or admins can delete the data
* @apiError (Not Found 404) NotFound User does not exist
*/
.delete(authorize(LOGGED_USER), controller.remove);
module.exports = router;

+ 35
- 0
src/api/services/authProviders.js View File

@ -0,0 +1,35 @@
/* eslint-disable camelcase */
const axios = require('axios');
exports.facebook = async (access_token) => {
const fields = 'id, name, email, picture';
const url = 'https://graph.facebook.com/me';
const params = { access_token, fields };
const response = await axios.get(url, { params });
const {
id, name, email, picture,
} = response.data;
return {
service: 'facebook',
picture: picture.data.url,
id,
name,
email,
};
};
exports.google = async (access_token) => {
const url = 'https://www.googleapis.com/oauth2/v3/userinfo';
const params = { access_token };
const response = await axios.get(url, { params });
const {
sub, name, email, picture,
} = response.data;
return {
service: 'google',
picture,
id: sub,
name,
email,
};
};

+ 77
- 0
src/api/services/emails/emailProvider.js View File

@ -0,0 +1,77 @@
const nodemailer = require('nodemailer');
const { emailConfig } = require('../../../config/vars');
const Email = require('email-templates');
// SMTP is the main transport in Nodemailer for delivering messages.
// SMTP is also the protocol used between almost all email hosts, so its truly universal.
// if you dont want to use SMTP you can create your own transport here
// such as an email service API or nodemailer-sendgrid-transport
const transporter = nodemailer.createTransport({
port: emailConfig.port,
host: emailConfig.host,
auth: {
user: emailConfig.username,
pass: emailConfig.password,
},
secure: false, // upgrades later with STARTTLS -- change this based on the PORT
});
// verify connection configuration
transporter.verify((error) => {
if (error) {
console.log('error with email connection');
}
});
exports.sendPasswordReset = async (passwordResetObject) => {
const email = new Email({
views: { root: __dirname },
message: {
from: 'support@your-app.com',
},
// uncomment below to send emails in development/test env:
send: true,
transport: transporter,
});
email
.send({
template: 'passwordReset',
message: {
to: passwordResetObject.userEmail,
},
locals: {
productName: 'Test App',
// passwordResetUrl should be a URL to your app that displays a view where they
// can enter a new password along with passing the resetToken in the params
passwordResetUrl: `https://your-app/new-password/view?resetToken=${passwordResetObject.resetToken}`,
},
})
.catch(() => console.log('error sending password reset email'));
};
exports.sendPasswordChangeEmail = async (user) => {
const email = new Email({
views: { root: __dirname },
message: {
from: 'support@your-app.com',
},
// uncomment below to send emails in development/test env:
send: true,
transport: transporter,
});
email
.send({
template: 'passwordChange',
message: {
to: user.email,
},
locals: {
productName: 'Test App',
name: user.name,
},
})
.catch(() => console.log('error sending change password email'));
};

+ 407
- 0
src/api/services/emails/passwordChange/html.pug View File

@ -0,0 +1,407 @@
doctype transitional
head
meta(name='viewport' content='width=device-width, initial-scale=1.0')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='Content-Type' content='text/html; charset=UTF-8')
title
style(type='text/css' rel='stylesheet' media='all').
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
//if mso
style(type='text/css').
.f-fallback {
font-family: Arial, sans-serif;
}
style(type='text/css' rel='stylesheet' media='all').
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #FFF;
color: #333;
}
span.preheader(style='display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;')
| Hello #{name},
table.email-wrapper(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
table.email-content(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
tr
td.email-masthead(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;' align='center')
a.f-fallback.email-masthead_name(href='https://example.com' style='color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;')
| #{productName}
// Email Body
tr
td.email-body(width='570' cellpadding='0' cellspacing='0' style='word-break: break-word; margin: 0; padding: 0; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;')
table.email-body_inner(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;')
// Body content
tr
td.content-cell(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
.f-fallback
h1(style='margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;' align='left') Hello,
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| Your password for #{productName} has been changed successfully. You can now login with your new password
// Action
table.body-action(align='center' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
//
Border based button
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design
table(width='100%' border='0' cellspacing='0' cellpadding='0' role='presentation')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
tr
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
table.email-footer(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;')
tr
td.content-cell(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center') &copy; 2019 #{productName}. All rights reserved.

+ 1
- 0
src/api/services/emails/passwordChange/subject.pug View File

@ -0,0 +1 @@
= `${productName} Password Changed`

+ 436
- 0
src/api/services/emails/passwordReset/html.pug View File

@ -0,0 +1,436 @@
doctype transitional
head
meta(name='viewport' content='width=device-width, initial-scale=1.0')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='Content-Type' content='text/html; charset=UTF-8')
title
style(type='text/css' rel='stylesheet' media='all').
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
//if mso
style(type='text/css').
.f-fallback {
font-family: Arial, sans-serif;
}
style(type='text/css' rel='stylesheet' media='all').
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #FFF;
color: #333;
}
span.preheader(style='display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;')
| Use
| this link to reset your password. The link is only valid for 2 hours.
table.email-wrapper(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
table.email-content(width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;')
tr
td.email-masthead(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;' align='center')
a.f-fallback.email-masthead_name(href='https://example.com' style='color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;')
#{productName}
// Email Body
tr
td.email-body(width='570' cellpadding='0' cellspacing='0' style='word-break: break-word; margin: 0; padding: 0; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;')
table.email-body_inner(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;')
// Body content
tr
td.content-cell(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
.f-fallback
h1(style='margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;' align='left') Hi,
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| You recently requested to reset your password for your #{productName}
| account. Use the button below to reset it.
strong
| This password reset
| is only valid for the next 2 hours.
// Action
table.body-action(align='center' width='100%' cellpadding='0' cellspacing='0' role='presentation' style='width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
//
Border based button
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design
table(width='100%' border='0' cellspacing='0' cellpadding='0' role='presentation')
tr
td(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
a.f-fallback.button.button--green(href='#{passwordResetUrl}' target='_blank' style='color: #FFF; border-color: #22bc66; border-style: solid; border-width: 10px 18px; background-color: #22BC66; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box;')
| Reset
| your password
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| If you did not request a password reset,
| please ignore this email.
p(style='font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| Thanks,
br
| The #{productName} Team
// Sub copy
table.body-sub(role='presentation' style='margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;')
tr
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
p.f-fallback.sub(style='font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| If you&rsquo;re having trouble with the button above, copy and
| paste the URL below into your web browser.
p.f-fallback.sub(style='font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;')
| #{passwordResetUrl}
tr
td(style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px;')
table.email-footer(align='center' width='570' cellpadding='0' cellspacing='0' role='presentation' style='width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;')
tr
td.content-cell(align='center' style='word-break: break-word; font-family: "Nunito Sans", Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;')
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center') &copy; 2019 [Product Name]. All rights reserved.
p.f-fallback.sub.align-center(style='font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;' align='center')
| [Company Name, LLC]
br
| 1234 Street Rd.
br
| Suite 1234

+ 1
- 0
src/api/services/emails/passwordReset/subject.pug View File

@ -0,0 +1 @@
= `${productName} Password Reset`

+ 528
- 0
src/api/tests/integration/auth.test.js View File

@ -0,0 +1,528 @@
/* eslint-disable arrow-body-style */
const request = require('supertest');
const httpStatus = require('http-status');
const { expect } = require('chai');
const sinon = require('sinon');
const moment = require('moment-timezone');
const app = require('../../../index');
const User = require('../../models/user.model');
const RefreshToken = require('../../models/refreshToken.model');
const PasswordResetToken = require('../../models/passwordResetToken.model');
const authProviders = require('../../services/authProviders');
const emailProvider = require('../../services/emails/emailProvider');
const sandbox = sinon.createSandbox();
const fakeOAuthRequest = () =>
Promise.resolve({
service: 'facebook',
id: '123',
name: 'user',
email: 'test@test.com',
picture: 'test.jpg',
});
describe('Authentication API', () => {
let dbUser;
let user;
let refreshToken;
let resetToken;
let expiredRefreshToken;
let expiredResetToken;
beforeEach(async () => {
dbUser = {
email: 'branstark@gmail.com',
password: 'mypassword',
name: 'Bran Stark',
role: 'admin',
};
user = {
email: 'krish@gmail.com',
password: '123456',
name: 'krish Sousa',
};
refreshToken = {
token:
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
userId: '5947397b323ae82d8c3a333b',
userEmail: dbUser.email,
expires: moment()
.add(1, 'day')
.toDate(),
};
resetToken = {
resetToken:
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
userId: '5947397b323ae82d8c3a333b',
userEmail: dbUser.email,
expires: moment()
.add(2, 'hours')
.toDate(),
};
expiredRefreshToken = {
token:
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
userId: '5947397b323ae82d8c3a333b',
userEmail: dbUser.email,
expires: moment()
.subtract(1, 'day')
.toDate(),
};
expiredResetToken = {
resetToken:
'5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d',
userId: '5947397b323ae82d8c3a333b',
userEmail: dbUser.email,
expires: moment()
.subtract(2, 'hours')
.toDate(),
};
await User.deleteMany({});
await User.create(dbUser);
await RefreshToken.deleteMany({});
await PasswordResetToken.deleteMany({});
});
afterEach(() => sandbox.restore());
describe('POST /v1/auth/register', () => {
it('should register a new user when request is ok', () => {
return request(app)
.post('/v1/auth/register')
.send(user)
.expect(httpStatus.CREATED)
.then((res) => {
delete user.password;
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.include(user);
});
});
it('should report error when email already exists', () => {
return request(app)
.post('/v1/auth/register')
.send(dbUser)
.expect(httpStatus.CONFLICT)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" already exists');
});
});
it('should report error when the email provided is not valid', () => {
user.email = 'this_is_not_an_email';
return request(app)
.post('/v1/auth/register')
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" must be a valid email');
});
});
it('should report error when email and password are not provided', () => {
return request(app)
.post('/v1/auth/register')
.send({})
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" is required');
});
});
});
describe('POST /v1/auth/login', () => {
it('should return an accessToken and a refreshToken when email and password matches', () => {
return request(app)
.post('/v1/auth/login')
.send(dbUser)
.expect(httpStatus.OK)
.then((res) => {
delete dbUser.password;
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.include(dbUser);
});
});
it('should report error when email and password are not provided', () => {
return request(app)
.post('/v1/auth/login')
.send({})
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" is required');
});
});
it('should report error when the email provided is not valid', () => {
user.email = 'this_is_not_an_email';
return request(app)
.post('/v1/auth/login')
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" must be a valid email');
});
});
it("should report error when email and password don't match", () => {
dbUser.password = 'xxx';
return request(app)
.post('/v1/auth/login')
.send(dbUser)
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
const { code } = res.body;
const { message } = res.body;
expect(code).to.be.equal(401);
expect(message).to.be.equal('Incorrect email or password');
});
});
});
describe('POST /v1/auth/facebook', () => {
it('should create a new user and return an accessToken when user does not exist', () => {
sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest);
return request(app)
.post('/v1/auth/facebook')
.send({ access_token: '123' })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.be.an('object');
});
});
it('should return an accessToken when user already exists', async () => {
dbUser.email = 'test@test.com';
await User.create(dbUser);
sandbox.stub(authProviders, 'facebook').callsFake(fakeOAuthRequest);
return request(app)
.post('/v1/auth/facebook')
.send({ access_token: '123' })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.be.an('object');
});
});
it('should return error when access_token is not provided', async () => {
return request(app)
.post('/v1/auth/facebook')
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('access_token');
expect(location).to.be.equal('body');
expect(messages).to.include('"access_token" is required');
});
});
});
describe('POST /v1/auth/google', () => {
it('should create a new user and return an accessToken when user does not exist', () => {
sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest);
return request(app)
.post('/v1/auth/google')
.send({ access_token: '123' })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.be.an('object');
});
});
it('should return an accessToken when user already exists', async () => {
dbUser.email = 'test@test.com';
await User.create(dbUser);
sandbox.stub(authProviders, 'google').callsFake(fakeOAuthRequest);
return request(app)
.post('/v1/auth/google')
.send({ access_token: '123' })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.token).to.have.a.property('accessToken');
expect(res.body.token).to.have.a.property('refreshToken');
expect(res.body.token).to.have.a.property('expiresIn');
expect(res.body.user).to.be.an('object');
});
});
it('should return error when access_token is not provided', async () => {
return request(app)
.post('/v1/auth/google')
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('access_token');
expect(location).to.be.equal('body');
expect(messages).to.include('"access_token" is required');
});
});
});
describe('POST /v1/auth/refresh-token', () => {
it('should return a new accessToken when refreshToken and email match', async () => {
await RefreshToken.create(refreshToken);
return request(app)
.post('/v1/auth/refresh-token')
.send({ email: dbUser.email, refreshToken: refreshToken.token })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.have.a.property('accessToken');
expect(res.body).to.have.a.property('refreshToken');
expect(res.body).to.have.a.property('expiresIn');
});
});
it("should report error when email and refreshToken don't match", async () => {
await RefreshToken.create(refreshToken);
return request(app)
.post('/v1/auth/refresh-token')
.send({ email: user.email, refreshToken: refreshToken.token })
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
const { code } = res.body;
const { message } = res.body;
expect(code).to.be.equal(401);
expect(message).to.be.equal('Incorrect email or refreshToken');
});
});
it('should report error when email and refreshToken are not provided', () => {
return request(app)
.post('/v1/auth/refresh-token')
.send({})
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const field1 = res.body.errors[0].field;
const location1 = res.body.errors[0].location;
const messages1 = res.body.errors[0].messages;
const field2 = res.body.errors[1].field;
const location2 = res.body.errors[1].location;
const messages2 = res.body.errors[1].messages;
expect(field1).to.be.equal('email');
expect(location1).to.be.equal('body');
expect(messages1).to.include('"email" is required');
expect(field2).to.be.equal('refreshToken');
expect(location2).to.be.equal('body');
expect(messages2).to.include('"refreshToken" is required');
});
});
it('should report error when the refreshToken is expired', async () => {
await RefreshToken.create(expiredRefreshToken);
return request(app)
.post('/v1/auth/refresh-token')
.send({ email: dbUser.email, refreshToken: expiredRefreshToken.token })
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
expect(res.body.code).to.be.equal(401);
expect(res.body.message).to.be.equal('Invalid refresh token.');
});
});
});
describe('POST /v1/auth/send-password-reset', () => {
it('should send an email with password reset link when email matches a user', async () => {
const PasswordResetTokenObj = await PasswordResetToken.create(resetToken);
expect(PasswordResetTokenObj.resetToken).to.be.equal('5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d');
expect(PasswordResetTokenObj.userId.toString()).to.be.equal('5947397b323ae82d8c3a333b');
expect(PasswordResetTokenObj.userEmail).to.be.equal(dbUser.email);
expect(PasswordResetTokenObj.expires).to.be.above(moment()
.add(1, 'hour')
.toDate());
sandbox
.stub(emailProvider, 'sendPasswordReset')
.callsFake(() => Promise.resolve('email sent'));
return request(app)
.post('/v1/auth/send-password-reset')
.send({ email: dbUser.email })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.be.equal('success');
});
});
it("should report error when email doesn't match a user", async () => {
await PasswordResetToken.create(resetToken);
return request(app)
.post('/v1/auth/send-password-reset')
.send({ email: user.email })
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
const { code } = res.body;
const { message } = res.body;
expect(code).to.be.equal(httpStatus.UNAUTHORIZED);
expect(message).to.be.equal('No account found with that email');
});
});
it('should report error when email is not provided', () => {
return request(app)
.post('/v1/auth/send-password-reset')
.send({})
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const field1 = res.body.errors[0].field;
const location1 = res.body.errors[0].location;
const messages1 = res.body.errors[0].messages;
expect(field1).to.be.equal('email');
expect(location1).to.be.equal('body');
expect(messages1).to.include('"email" is required');
});
});
});
describe('POST /v1/auth/reset-password', () => {
it('should update password and send confirmation email when email and reset token are valid', async () => {
await PasswordResetToken.create(resetToken);
sandbox
.stub(emailProvider, 'sendPasswordChangeEmail')
.callsFake(() => Promise.resolve('email sent'));
return request(app)
.post('/v1/auth/reset-password')
.send({
email: dbUser.email,
password: 'updatedPassword2',
resetToken: resetToken.resetToken,
})
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.be.equal('Password Updated');
});
});
it("should report error when email and reset token doesn't match a user", async () => {
await PasswordResetToken.create(resetToken);
return request(app)
.post('/v1/auth/reset-password')
.send({
email: user.email,
password: 'updatedPassword',
resetToken: resetToken.resetToken,
})
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
const { code } = res.body;
const { message } = res.body;
expect(code).to.be.equal(401);
expect(message).to.be.equal('Cannot find matching reset token');
});
});
it('should report error when email is not provided', () => {
return request(app)
.post('/v1/auth/reset-password')
.send({ password: 'updatedPassword', resetToken: resetToken.resetToken })
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const field1 = res.body.errors[0].field;
const location1 = res.body.errors[0].location;
const messages1 = res.body.errors[0].messages;
expect(field1).to.be.equal('email');
expect(location1).to.be.equal('body');
expect(messages1).to.include('"email" is required');
});
});
it('should report error when reset token is not provided', () => {
return request(app)
.post('/v1/auth/reset-password')
.send({ email: dbUser.email, password: 'updatedPassword' })
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const field1 = res.body.errors[0].field;
const location1 = res.body.errors[0].location;
const messages1 = res.body.errors[0].messages;
expect(field1).to.be.equal('resetToken');
expect(location1).to.be.equal('body');
expect(messages1).to.include('"resetToken" is required');
});
});
it('should report error when password is not provided', () => {
return request(app)
.post('/v1/auth/reset-password')
.send({ email: dbUser.email, resetToken: resetToken.resetToken })
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const field1 = res.body.errors[0].field;
const location1 = res.body.errors[0].location;
const messages1 = res.body.errors[0].messages;
expect(field1).to.be.equal('password');
expect(location1).to.be.equal('body');
expect(messages1).to.include('"password" is required');
});
});
it('should report error when the resetToken is expired', async () => {
const expiredPasswordResetTokenObj = await PasswordResetToken.create(expiredResetToken);
expect(expiredPasswordResetTokenObj.resetToken).to.be.equal('5947397b323ae82d8c3a333b.c69d0435e62c9f4953af912442a3d064e20291f0d228c0552ed4be473e7d191ba40b18c2c47e8b9d');
expect(expiredPasswordResetTokenObj.userId.toString()).to.be.equal('5947397b323ae82d8c3a333b');
expect(expiredPasswordResetTokenObj.userEmail).to.be.equal(dbUser.email);
expect(expiredPasswordResetTokenObj.expires).to.be.below(moment().toDate());
return request(app)
.post('/v1/auth/reset-password')
.send({
email: dbUser.email,
password: 'updated password',
resetToken: expiredResetToken.resetToken,
})
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
expect(res.body.code).to.be.equal(401);
expect(res.body.message).to.include('Reset token is expired');
});
});
});
});

+ 550
- 0
src/api/tests/integration/user.test.js View File

@ -0,0 +1,550 @@
/* eslint-disable arrow-body-style */
/* eslint-disable no-unused-expressions */
const request = require('supertest');
const httpStatus = require('http-status');
const { expect } = require('chai');
const sinon = require('sinon');
const bcrypt = require('bcryptjs');
const { some, omitBy, isNil } = require('lodash');
const app = require('../../../index');
const User = require('../../models/user.model');
const JWT_EXPIRATION = require('../../../config/vars').jwtExpirationInterval;
/**
* root level hooks
*/
async function format(user) {
const formated = user;
// delete password
delete formated.password;
// get users from database
const dbUser = (await User.findOne({ email: user.email })).transform();
// remove null and undefined properties
return omitBy(dbUser, isNil);
}
describe('Users API', async () => {
let adminAccessToken;
let userAccessToken;
let dbUsers;
let user;
let admin;
const password = '123456';
const passwordHashed = await bcrypt.hash(password, 1);
beforeEach(async () => {
dbUsers = {
branStark: {
email: 'branstark@gmail.com',
password: passwordHashed,
name: 'Bran Stark',
role: 'admin',
},
jonSnow: {
email: 'jonsnow@gmail.com',
password: passwordHashed,
name: 'Jon Snow',
},
};
user = {
email: 'sousa.dfs@gmail.com',
password,
name: 'krish Sousa',
};
admin = {
email: 'sousa.dfs@gmail.com',
password,
name: 'krish Sousa',
role: 'admin',
};
await User.deleteMany({});
await User.insertMany([dbUsers.branStark, dbUsers.jonSnow]);
dbUsers.branStark.password = password;
dbUsers.jonSnow.password = password;
adminAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken;
userAccessToken = (await User.findAndGenerateToken(dbUsers.jonSnow)).accessToken;
});
describe('POST /v1/users', () => {
it('should create a new user when request is ok', () => {
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(admin)
.expect(httpStatus.CREATED)
.then((res) => {
delete admin.password;
expect(res.body).to.include(admin);
});
});
it('should create a new user and set default role to "user"', () => {
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.CREATED)
.then((res) => {
expect(res.body.role).to.be.equal('user');
});
});
it('should report error when email already exists', () => {
user.email = dbUsers.branStark.email;
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.CONFLICT)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" already exists');
});
});
it('should report error when email is not provided', () => {
delete user.email;
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" is required');
});
});
it('should report error when password length is less than 6', () => {
user.password = '12345';
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('password');
expect(location).to.be.equal('body');
expect(messages).to.include('"password" length must be at least 6 characters long');
});
});
it('should report error when logged user is not an admin', () => {
return request(app)
.post('/v1/users')
.set('Authorization', `Bearer ${userAccessToken}`)
.send(user)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
});
describe('GET /v1/users', () => {
it('should get all users', () => {
return request(app)
.get('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.OK)
.then(async (res) => {
const bran = await format(dbUsers.branStark);
const john = await format(dbUsers.jonSnow);
// before comparing it is necessary to convert String to Date
res.body[0].createdAt = new Date(res.body[0].createdAt);
res.body[1].createdAt = new Date(res.body[1].createdAt);
const includesBranStark = some(res.body, bran);
const includesjonSnow = some(res.body, john);
expect(res.body).to.be.an('array');
expect(res.body).to.have.lengthOf(2);
expect(includesBranStark).to.be.true;
expect(includesjonSnow).to.be.true;
});
});
it('should get all users with pagination', () => {
return request(app)
.get('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.query({ page: 2, perPage: 1 })
.expect(httpStatus.OK)
.then(async (res) => {
delete dbUsers.jonSnow.password;
expect(res.body).to.be.an('array');
expect(res.body[0]).to.be.an('object');
expect(res.body).to.have.lengthOf(1);
expect(res.body[0].name).to.be.equal('Jon Snow');
});
});
it('should filter users', () => {
return request(app)
.get('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.query({ email: dbUsers.jonSnow.email })
.expect(httpStatus.OK)
.then(async (res) => {
delete dbUsers.jonSnow.password;
const john = await format(dbUsers.jonSnow);
// before comparing it is necessary to convert String to Date
res.body[0].createdAt = new Date(res.body[0].createdAt);
const includesjonSnow = some(res.body, john);
expect(res.body).to.be.an('array');
expect(res.body).to.have.lengthOf(1);
expect(includesjonSnow).to.be.true;
});
});
it('should report error when pagination\'s parameters are not a number', () => {
return request(app)
.get('/v1/users')
.set('Authorization', `Bearer ${adminAccessToken}`)
.query({ page: '?', perPage: 'whaat' })
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('page');
expect(location).to.be.equal('query');
expect(messages).to.include('"page" must be a number');
return Promise.resolve(res);
})
.then((res) => {
const { field } = res.body.errors[1];
const { location } = res.body.errors[1];
const { messages } = res.body.errors[1];
expect(field).to.be.equal('perPage');
expect(location).to.be.equal('query');
expect(messages).to.include('"perPage" must be a number');
});
});
it('should report error if logged user is not an admin', () => {
return request(app)
.get('/v1/users')
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
});
describe('GET /v1/users/:userId', () => {
it('should get user', async () => {
const id = (await User.findOne({}))._id;
delete dbUsers.branStark.password;
return request(app)
.get(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.include(dbUsers.branStark);
});
});
it('should report error "User does not exist" when user does not exists', () => {
return request(app)
.get('/v1/users/56c787ccc67fc16ccc1a5e92')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NOT_FOUND)
.then((res) => {
expect(res.body.code).to.be.equal(404);
expect(res.body.message).to.be.equal('User does not exist');
});
});
it('should report error "User does not exist" when id is not a valid ObjectID', () => {
return request(app)
.get('/v1/users/palmeiras1914')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NOT_FOUND)
.then((res) => {
expect(res.body.code).to.be.equal(404);
expect(res.body.message).to.equal('User does not exist');
});
});
it('should report error when logged user is not the same as the requested one', async () => {
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
return request(app)
.get(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
});
describe('PUT /v1/users/:userId', () => {
it('should replace user', async () => {
delete dbUsers.branStark.password;
const id = (await User.findOne(dbUsers.branStark))._id;
return request(app)
.put(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.OK)
.then((res) => {
delete user.password;
expect(res.body).to.include(user);
expect(res.body.role).to.be.equal('user');
});
});
it('should report error when email is not provided', async () => {
const id = (await User.findOne({}))._id;
delete user.email;
return request(app)
.put(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('email');
expect(location).to.be.equal('body');
expect(messages).to.include('"email" is required');
});
});
it('should report error user when password length is less than 6', async () => {
const id = (await User.findOne({}))._id;
user.password = '12345';
return request(app)
.put(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(user)
.expect(httpStatus.BAD_REQUEST)
.then((res) => {
const { field } = res.body.errors[0];
const { location } = res.body.errors[0];
const { messages } = res.body.errors[0];
expect(field).to.be.equal('password');
expect(location).to.be.equal('body');
expect(messages).to.include('"password" length must be at least 6 characters long');
});
});
it('should report error "User does not exist" when user does not exists', () => {
return request(app)
.put('/v1/users/palmeiras1914')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NOT_FOUND)
.then((res) => {
expect(res.body.code).to.be.equal(404);
expect(res.body.message).to.be.equal('User does not exist');
});
});
it('should report error when logged user is not the same as the requested one', async () => {
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
return request(app)
.put(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
it('should not replace the role of the user (not admin)', async () => {
const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id;
const role = 'admin';
return request(app)
.put(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.send(admin)
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.role).to.not.be.equal(role);
});
});
});
describe('PATCH /v1/users/:userId', () => {
it('should update user', async () => {
delete dbUsers.branStark.password;
const id = (await User.findOne(dbUsers.branStark))._id;
const { name } = user;
return request(app)
.patch(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ name })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.name).to.be.equal(name);
expect(res.body.email).to.be.equal(dbUsers.branStark.email);
});
});
it('should not update user when no parameters were given', async () => {
delete dbUsers.branStark.password;
const id = (await User.findOne(dbUsers.branStark))._id;
return request(app)
.patch(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send()
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.include(dbUsers.branStark);
});
});
it('should report error "User does not exist" when user does not exists', () => {
return request(app)
.patch('/v1/users/palmeiras1914')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NOT_FOUND)
.then((res) => {
expect(res.body.code).to.be.equal(404);
expect(res.body.message).to.be.equal('User does not exist');
});
});
it('should report error when logged user is not the same as the requested one', async () => {
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
return request(app)
.patch(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
it('should not update the role of the user (not admin)', async () => {
const id = (await User.findOne({ email: dbUsers.jonSnow.email }))._id;
const role = 'admin';
return request(app)
.patch(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.send({ role })
.expect(httpStatus.OK)
.then((res) => {
expect(res.body.role).to.not.be.equal(role);
});
});
});
describe('DELETE /v1/users', () => {
it('should delete user', async () => {
const id = (await User.findOne({}))._id;
return request(app)
.delete(`/v1/users/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NO_CONTENT)
.then(() => request(app).get('/v1/users'))
.then(async () => {
const users = await User.find({});
expect(users).to.have.lengthOf(1);
});
});
it('should report error "User does not exist" when user does not exists', () => {
return request(app)
.delete('/v1/users/palmeiras1914')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(httpStatus.NOT_FOUND)
.then((res) => {
expect(res.body.code).to.be.equal(404);
expect(res.body.message).to.be.equal('User does not exist');
});
});
it('should report error when logged user is not the same as the requested one', async () => {
const id = (await User.findOne({ email: dbUsers.branStark.email }))._id;
return request(app)
.delete(`/v1/users/${id}`)
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.FORBIDDEN)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.FORBIDDEN);
expect(res.body.message).to.be.equal('Forbidden');
});
});
});
describe('GET /v1/users/profile', () => {
it('should get the logged user\'s info', () => {
delete dbUsers.jonSnow.password;
return request(app)
.get('/v1/users/profile')
.set('Authorization', `Bearer ${userAccessToken}`)
.expect(httpStatus.OK)
.then((res) => {
expect(res.body).to.include(dbUsers.jonSnow);
});
});
it('should report error without stacktrace when accessToken is expired', async () => {
// fake time
const clock = sinon.useFakeTimers();
const expiredAccessToken = (await User.findAndGenerateToken(dbUsers.branStark)).accessToken;
// move clock forward by minutes set in config + 1 minute
clock.tick((JWT_EXPIRATION * 60000) + 60000);
return request(app)
.get('/v1/users/profile')
.set('Authorization', `Bearer ${expiredAccessToken}`)
.expect(httpStatus.UNAUTHORIZED)
.then((res) => {
expect(res.body.code).to.be.equal(httpStatus.UNAUTHORIZED);
expect(res.body.message).to.be.equal('jwt expired');
expect(res.body).to.not.have.a.property('stack');
});
});
});
});

+ 46
- 0
src/api/utils/APIError.js View File

@ -0,0 +1,46 @@
const httpStatus = require('http-status');
/**
* @extends Error
*/
class ExtendableError extends Error {
constructor({
message, errors, status, isPublic, stack,
}) {
super(message);
this.name = this.constructor.name;
this.message = message;
this.errors = errors;
this.status = status;
this.isPublic = isPublic;
this.isOperational = true; // This is required since bluebird 4 doesn't append it anymore.
this.stack = stack;
// Error.captureStackTrace(this, this.constructor.name);
}
}
/**
* Class representing an API error.
* @extends ExtendableError
*/
class APIError extends ExtendableError {
/**
* Creates an API error.
* @param {string} message - Error message.
* @param {number} status - HTTP status code of error.
* @param {boolean} isPublic - Whether the message should be visible to user or not.
*/
constructor({
message,
errors,
stack,
status = httpStatus.INTERNAL_SERVER_ERROR,
isPublic = false,
}) {
super({
message, errors, status, isPublic, stack,
});
}
}
module.exports = APIError;

+ 584
- 0
src/api/utils/passwordResetEmailTemplate.html View File

@ -0,0 +1,584 @@
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #FFF;
color: #333;
}
</style>
</head>
<body
style="width: 100% !important; height: 100%; -webkit-text-size-adjust: none; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; background-color: #FFF; color: #333; margin: 0;"
bgcolor="#FFF">
<span class="preheader"
style="display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;">Use
this link to reset your password. The link is only valid for 24 hours.</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td align="center"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td class="email-masthead"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;"
align="center">
<a href="https://example.com" class="f-fallback email-masthead_name"
style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
[Product Name]
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0"
style="word-break: break-word; margin: 0; padding: 0; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0;">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0"
role="presentation"
style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0 auto; padding: 0;">
<!-- Body content -->
<tr>
<td class="content-cell"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;">
<div class="f-fallback">
<h1 style="margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;"
align="left">Hi {{name}},</h1>
<p
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
You recently requested to reset your password for your [Product Name]
account. Use the button below to reset it. <strong>This password reset
is only valid for the next 2 hours.</strong></p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0"
cellspacing="0" role="presentation"
style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
<tr>
<td align="center"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<!-- Border based button
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<table width="100%" border="0" cellspacing="0" cellpadding="0"
role="presentation">
<tr>
<td align="center"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<a href="{{action_url}}"
class="f-fallback button button--green"
target="_blank"
style="color: #FFF; border-color: #22bc66; border-style: solid; border-width: 10px 18px; background-color: #22BC66; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box;">Reset
your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
For security, this request was received from a {{operating_system}}
device using {{browser_name}}. If you did not request a password reset,
please ignore this email or <a href="{{support_url}}"
style="color: #3869D4;">contact support</a> if you have questions.
</p>
<p
style="font-size: 16px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
Thanks,
<br />The [Product Name] Team</p>
<!-- Sub copy -->
<table class="body-sub" role="presentation"
style="margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;">
<tr>
<td
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<p class="f-fallback sub"
style="font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
If you’re having trouble with the button above, copy and
paste the URL below into your web browser.</p>
<p class="f-fallback sub"
style="font-size: 13px; line-height: 1.625; color: #333; margin: .4em 0 1.1875em;">
{{action_url}}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0"
role="presentation"
style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 0 auto; padding: 0;">
<tr>
<td class="content-cell" align="center"
style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; padding: 35px;">
<p class="f-fallback sub align-center"
style="font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;"
align="center">© 2019 [Product Name]. All rights reserved.</p>
<p class="f-fallback sub align-center"
style="font-size: 13px; line-height: 1.625; text-align: center; color: #A8AAAF; margin: .4em 0 1.1875em;"
align="center">
[Company Name, LLC]
<br />1234 Street Rd.
<br />Suite 1234
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

+ 69
- 0
src/api/validations/auth.validation.js View File

@ -0,0 +1,69 @@
const Joi = require('joi');
module.exports = {
// POST /v1/auth/register
register: {
body: {
email: Joi.string()
.email()
.required(),
password: Joi.string()
.required()
.min(6)
.max(128),
},
},
// POST /v1/auth/login
login: {
body: {
email: Joi.string()
.email()
.required(),
password: Joi.string()
.required()
.max(128),
},
},
// POST /v1/auth/facebook
// POST /v1/auth/google
oAuth: {
body: {
access_token: Joi.string().required(),
},
},
// POST /v1/auth/refresh
refresh: {
body: {
email: Joi.string()
.email()
.required(),
refreshToken: Joi.string().required(),
},
},
// POST /v1/auth/refresh
sendPasswordReset: {
body: {
email: Joi.string()
.email()
.required(),
},
},
// POST /v1/auth/password-reset
passwordReset: {
body: {
email: Joi.string()
.email()
.required(),
password: Joi.string()
.required()
.min(6)
.max(128),
resetToken: Joi.string().required(),
},
},
};

+ 52
- 0
src/api/validations/user.validation.js View File

@ -0,0 +1,52 @@
const Joi = require('joi');
const User = require('../models/user.model');
module.exports = {
// GET /v1/users
listUsers: {
query: {
page: Joi.number().min(1),
perPage: Joi.number().min(1).max(100),
name: Joi.string(),
email: Joi.string(),
role: Joi.string().valid(User.roles),
},
},
// POST /v1/users
createUser: {
body: {
email: Joi.string().email().required(),
password: Joi.string().min(6).max(128).required(),
name: Joi.string().max(128),
role: Joi.string().valid(User.roles),
},
},
// PUT /v1/users/:userId
replaceUser: {
body: {
email: Joi.string().email().required(),
password: Joi.string().min(6).max(128).required(),
name: Joi.string().max(128),
role: Joi.string().valid(User.roles),
},
params: {
userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(),
},
},
// PATCH /v1/users/:userId
updateUser: {
body: {
email: Joi.string().email(),
password: Joi.string().min(6).max(128),
name: Joi.string().max(128),
role: Joi.string().valid(User.roles),
},
params: {
userId: Joi.string().regex(/^[a-fA-F0-9]{24}$/).required(),
},
},
};

+ 58
- 0
src/config/express.js View File

@ -0,0 +1,58 @@
const express = require('express');
const morgan = require('morgan');
const bodyParser = require('body-parser');
const compress = require('compression');
const methodOverride = require('method-override');
const cors = require('cors');
const helmet = require('helmet');
const passport = require('passport');
const routes = require('../api/routes/v1');
const { logs } = require('./vars');
const strategies = require('./passport');
const error = require('../api/middlewares/error');
/**
* Express instance
* @public
*/
const app = express();
// request logging. dev: console | production: file
app.use(morgan(logs));
// parse body params and attache them to req.body
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// gzip compression
app.use(compress());
// lets you use HTTP verbs such as PUT or DELETE
// in places where the client doesn't support it
app.use(methodOverride());
// secure apps by setting various HTTP headers
app.use(helmet());
// enable CORS - Cross Origin Resource Sharing
app.use(cors());
// enable authentication
app.use(passport.initialize());
passport.use('jwt', strategies.jwt);
passport.use('facebook', strategies.facebook);
passport.use('google', strategies.google);
// mount api v1 routes
app.use('/v1', routes);
// if error is not an instanceOf APIError, convert it.
app.use(error.converter);
// catch 404 and forward to error handler
app.use(error.notFound);
// error handler, send stacktrace only during development
app.use(error.handler);
module.exports = app;

+ 32
- 0
src/config/logger.js View File

@ -0,0 +1,32 @@
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
//
// If we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
//
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple(),
}));
}
logger.stream = {
write: (message) => {
logger.info(message.trim());
},
};
module.exports = logger;

+ 36
- 0
src/config/mongoose.js View File

@ -0,0 +1,36 @@
const mongoose = require('mongoose');
const logger = require('./../config/logger');
const { mongo, env } = require('./vars');
// set mongoose Promise to Bluebird
mongoose.Promise = Promise;
// Exit application on error
mongoose.connection.on('error', (err) => {
logger.error(`MongoDB connection error: ${err}`);
process.exit(-1);
});
// print mongoose logs in dev env
if (env === 'development') {
mongoose.set('debug', true);
}
/**
* Connect to mongo db
*
* @returns {object} Mongoose connection
* @public
*/
exports.connect = () => {
mongoose
.connect(mongo.uri, {
useCreateIndex: true,
keepAlive: 1,
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
})
.then(() => console.log('mongoDB connected...'));
return mongoose.connection;
};

+ 35
- 0
src/config/passport.js View File

@ -0,0 +1,35 @@
const JwtStrategy = require('passport-jwt').Strategy;
const BearerStrategy = require('passport-http-bearer');
const { ExtractJwt } = require('passport-jwt');
const { jwtSecret } = require('./vars');
const authProviders = require('../api/services/authProviders');
const User = require('../api/models/user.model');
const jwtOptions = {
secretOrKey: jwtSecret,
jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer'),
};
const jwt = async (payload, done) => {
try {
const user = await User.findById(payload.sub);
if (user) return done(null, user);
return done(null, false);
} catch (error) {
return done(error, false);
}
};
const oAuth = service => async (token, done) => {
try {
const userData = await authProviders[service](token);
const user = await User.oAuthLogin(userData);
return done(null, user);
} catch (err) {
return done(err);
}
};
exports.jwt = new JwtStrategy(jwtOptions, jwt);
exports.facebook = new BearerStrategy(oAuth('facebook'));
exports.google = new BearerStrategy(oAuth('google'));

+ 24
- 0
src/config/vars.js View File

@ -0,0 +1,24 @@
const path = require('path');
// import .env variables
require('dotenv-safe').load({
path: path.join(__dirname, '../../.env'),
sample: path.join(__dirname, '../../.env.example'),
});
module.exports = {
env: process.env.NODE_ENV,
port: process.env.PORT,
jwtSecret: process.env.JWT_SECRET,
jwtExpirationInterval: process.env.JWT_EXPIRATION_MINUTES,
mongo: {
uri: process.env.NODE_ENV === 'test' ? process.env.MONGO_URI_TESTS : process.env.MONGO_URI,
},
logs: process.env.NODE_ENV === 'production' ? 'combined' : 'dev',
emailConfig: {
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
username: process.env.EMAIL_USERNAME,
password: process.env.EMAIL_PASSWORD,
},
};

+ 17
- 0
src/index.js View File

@ -0,0 +1,17 @@
// make bluebird default Promise
Promise = require('bluebird'); // eslint-disable-line no-global-assign
const { port, env } = require('./config/vars');
const logger = require('./config/logger');
const app = require('./config/express');
const mongoose = require('./config/mongoose');
// open mongoose connection
// listen to requests
app.listen(port, () => logger.info(`server started on port ${port} (${env})`));
/**
* Exports express
* @public
*/
module.exports = app;

+ 5533
- 0
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save