From 644183e4a8080016010d951d89c805ae5d50b86d Mon Sep 17 00:00:00 2001 From: Kizito Akhilome Date: Mon, 1 Oct 2018 09:10:59 +0100 Subject: [PATCH] feat(auth-signup): implement user signup functionality - create POST /api/v1/auth/signup endpoint - install and use bcrypt to hash user passwords - write tests for POST /api/v1/auth/signup endpoint - configure travis to create postgres database [Finishes #160819724] --- .babelrc | 10 +++- .travis.yml | 4 ++ package-lock.json | 5 ++ package.json | 1 + server/controllers/authController.js | 33 +++++++++++ server/db/config.js | 6 +- server/index.js | 5 +- server/middleware/sanitizer.js | 43 ++++++++++++++ server/routes/authRouter.js | 8 +++ server/validators/validator.js | 24 ++++++++ tests/routes/auth.spec.js | 87 ++++++++++++++++++++++++++++ tests/seed/seed.js | 56 ++++++++++++++++++ 12 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 server/controllers/authController.js create mode 100644 server/middleware/sanitizer.js create mode 100644 server/routes/authRouter.js create mode 100644 server/validators/validator.js create mode 100644 tests/routes/auth.spec.js create mode 100644 tests/seed/seed.js diff --git a/.babelrc b/.babelrc index f64da13..f5806d5 100644 --- a/.babelrc +++ b/.babelrc @@ -1,6 +1,12 @@ { "presets": [ - "env", - "stage-2" + "stage-2", + [ + "env", { + "targets": { + "node": "8.12.0" + } + } + ] ] } diff --git a/.travis.yml b/.travis.yml index 6a82f9f..c86a3e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,5 +4,9 @@ node_js: cache: directories: - "node_modules" +services: + - postgresql +before_script: + - psql -c 'create database fastfoodfast;' -U postgres after_success: - npm run cover diff --git a/package-lock.json b/package-lock.json index 516f94d..1a2859a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1368,6 +1368,11 @@ "tweetnacl": "^0.14.3" } }, + "bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=" + }, "binary-extensions": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.11.0.tgz", diff --git a/package.json b/package.json index fc5f464..2f2dd94 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "babel-cli": "^6.26.0", "babel-preset-env": "^1.7.0", "babel-preset-stage-2": "^6.24.1", + "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", "dotenv": "^6.0.0", "express": "^4.16.3", diff --git a/server/controllers/authController.js b/server/controllers/authController.js new file mode 100644 index 0000000..d63ae4c --- /dev/null +++ b/server/controllers/authController.js @@ -0,0 +1,33 @@ +import bcrpyt from 'bcryptjs'; +import pool from '../db/config'; + +class AuthController { + static async signup(req, res) { + const { name, email, password } = req; + const isAdmin = email === 'hovkard@gmail.com' ? 't' : 'f'; + + try { + // Check if a user with the provided email already exists + const existingUser = (await pool.query('SELECT * FROM users WHERE email=$1', [email])).rowCount; + if (existingUser) { + return res.status(400).json({ + status: 'error', + message: 'a user with that email already exists', + }); + } + // Hash password and save user to database + const hashedPassword = await bcrpyt.hash(password, 10); + const dbQuery = 'INSERT INTO users(name, email, password, is_admin) VALUES($1, $2, $3, $4) RETURNING id, name, email'; + const user = (await pool.query(dbQuery, [name, email, hashedPassword, isAdmin])).rows[0]; + return res.status(201).json({ + status: 'success', + message: 'user created successfully', + user, + }); + } catch (error) { + return res.status(400).json({ error }); + } + } +} + +export default AuthController; diff --git a/server/db/config.js b/server/db/config.js index f07f1de..7de55a2 100644 --- a/server/db/config.js +++ b/server/db/config.js @@ -4,13 +4,13 @@ import { Pool } from 'pg'; dotenv.config(); const env = process.env.NODE_ENV || 'development'; +/* eslint-disable */ let pool; -let testPool; if (env === 'test') { - testPool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }); + pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }); } else { pool = new Pool({ connectionString: process.env.DATABASE_URL }); } -export default { pool, testPool }; +export default pool; diff --git a/server/index.js b/server/index.js index 32be219..affcafa 100644 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,7 @@ import express from 'express'; import bodyParser from 'body-parser'; import dotenv from 'dotenv'; import router from './routes/routes'; +import authRouter from './routes/authRouter'; dotenv.config(); const app = express(); @@ -13,9 +14,11 @@ app.get('/', (req, res) => { }); app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.urlencoded({ extended: false })); app.use('/api/v1', router); +// Auth routes +app.use('/api/v1/auth/', authRouter); app.listen(process.env.PORT); diff --git a/server/middleware/sanitizer.js b/server/middleware/sanitizer.js new file mode 100644 index 0000000..894beb9 --- /dev/null +++ b/server/middleware/sanitizer.js @@ -0,0 +1,43 @@ +import Validator from '../validators/validator'; + +class Sanitize { + static signup(req, res, next) { + const { + name, + email, + password, + confirmPassword, + } = req.body; + + const missingFields = [name, email, password, confirmPassword].map((field, index) => { + const keys = { + 0: 'name', + 1: 'email', + 2: 'password', + 3: 'confirm password', + }; + return field === undefined ? keys[index] : null; + }).filter(field => field !== null).join(', '); + + if (!name || !email || !password || !confirmPassword) { + return res.status(400).json({ + status: 'error', + message: `you're missing these fields: ${missingFields}`, + }); + } + + const response = message => res.status(400).json({ status: 'error', message }); + + if (!Validator.isValidName(name)) return response('invalid name'); + if (!Validator.isEmail(email)) return response('invalid email'); + if (!Validator.isMatchingPasswords(password, confirmPassword)) return response('provided passwords donot match'); + if (!Validator.isValidPassword(password)) return response('invalid password'); + + req.name = name.trim(); + req.email = email.trim(); + req.password = password.trim(); + return next(); + } +} + +export default Sanitize; diff --git a/server/routes/authRouter.js b/server/routes/authRouter.js new file mode 100644 index 0000000..df724a7 --- /dev/null +++ b/server/routes/authRouter.js @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import AuthController from '../controllers/authController'; +import Sanitize from '../middleware/sanitizer'; + +const router = new Router(); +router.post('/signup', Sanitize.signup, AuthController.signup); + +export default router; diff --git a/server/validators/validator.js b/server/validators/validator.js new file mode 100644 index 0000000..1b4442f --- /dev/null +++ b/server/validators/validator.js @@ -0,0 +1,24 @@ +class Validator { + static isEmail(email) { + const re = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/ig; + return re.test(email.trim().toLowerCase()); + } + + static isValidPassword(password) { + return password.trim().length > 5; + } + + static isMatchingPasswords(password, confirmPassword) { + return password.trim() === confirmPassword.trim(); + } + + static isValidName(name) { + return name.trim().length >= 2; + } +} + +/* Refs. + email regex pattern credit: https://www.regular-expressions.info/email.html?wlr=1 +*/ + +export default Validator; diff --git a/tests/routes/auth.spec.js b/tests/routes/auth.spec.js new file mode 100644 index 0000000..a0e818a --- /dev/null +++ b/tests/routes/auth.spec.js @@ -0,0 +1,87 @@ +import chai from 'chai'; +import 'chai/register-should'; +import chaiHttp from 'chai-http'; +import dirtyChai from 'dirty-chai'; +import app from '../../server/index'; +import { seedData, populateTables } from '../seed/seed'; + +chai.use(chaiHttp); +chai.use(dirtyChai); + +beforeEach(populateTables); + +describe('POST /auth/signup', () => { + it('should signup a valid user successfully', (done) => { + chai.request(app) + .post('/api/v1/auth/signup') + .send(seedData.users.validUser) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(201); + res.body.should.be.an('object').that.has.keys(['status', 'message', 'user']); + res.body.status.should.eql('success'); + res.body.user.should.have.keys(['id', 'name', 'email']); + res.body.user.name.should.eql(seedData.users.validUser.name); + res.body.user.email.should.eql(seedData.users.validUser.email); + done(); + }); + }); + + it('should not signup a user with no name', (done) => { + chai.request(app) + .post('/api/v1/auth/signup') + .send(seedData.users.invalidUserNoName) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + res.body.should.have.keys(['status', 'message']); + res.body.should.not.have.keys(['user']); + done(); + }); + }); + + it('should not signup a user with no email', (done) => { + chai.request(app) + .post('/api/v1/auth/signup') + .send(seedData.users.invalidUserNoEmail) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + res.body.should.have.keys(['status', 'message']); + res.body.should.not.have.keys(['user']); + done(); + }); + }); + + it('should not signup a user with no password', (done) => { + chai.request(app) + .post('/api/v1/auth/signup') + .send(seedData.users.invalidUserNoPass) + .end((err, res) => { + if (err) done(err); + + res.status.should.eql(400); + res.body.should.have.keys(['status', 'message']); + res.body.should.not.have.keys(['user']); + done(); + }); + }); + + it('should not signup a user with missmatching passwords', (done) => { + chai.request(app) + .post('/api/v1/auth/signup') + .send(seedData.users.invalidUserPassMissMatch) + .end((err, res) => { + if (err) done(err); + res.status.should.eql(400); + + res.body.should.have.keys(['status', 'message']); + res.body.should.not.have.keys(['user']); + res.body.message.should.eql('provided passwords donot match'); + done(); + }); + }); +}); diff --git a/tests/seed/seed.js b/tests/seed/seed.js new file mode 100644 index 0000000..f7da46a --- /dev/null +++ b/tests/seed/seed.js @@ -0,0 +1,56 @@ +import pool from '../../server/db/config'; + +const seedData = { + users: { + admin: { + name: 'Kizito', + email: 'hovkard@gmail.com', + password: 'suppersecurepassword', + confirmPassword: 'suppersecurepassword', + }, + validUser: { + name: 'James', + email: 'daniel@james.com', + password: 'pixel2user', + confirmPassword: 'pixel2user', + }, + invalidUserNoData: {}, + invalidUserNoName: { + email: 'unserious@lad.com', + password: 'insecure', + confirmPassword: 'insecure', + }, + invalidUserNoEmail: { + name: 'Name?', + password: 'pass', + confirmPassword: 'pass', + }, + invalidUserNoPass: { + name: 'Magician', + email: 'an@email.address', + }, + invalidUserPassMissMatch: { + name: 'Olodo', + email: 'another@sweet.email', + password: 'oneThing', + confirmPassword: 'anEntirelyDifferentThing', + }, + }, +}; + +const populateTables = async () => { + const dropUsersTableQuery = 'DROP TABLE IF EXISTS users'; + + const createUsersTableQuery = `CREATE TABLE users ( + id serial PRIMARY KEY, + name VARCHAR(50) NOT NULL, + email VARCHAR(50) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + is_admin BOOLEAN NOT NULL + )`; + + await pool.query(dropUsersTableQuery); + await pool.query(createUsersTableQuery); +}; + +export { seedData, populateTables };