diff --git a/package.json b/package.json index e26d541..a849e04 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "scripts": { "start": "node index.js", "test": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/", + "test:unit:lib": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/biz-logic/", + "test:unit:rest": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/rest-api/", + "test:integration": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/integration/rest-api/", "lint": "standard --env mocha --fix", "docs": "./node_modules/.bin/apidoc -i src/ -o docs", "coverage": "export KOA_ENV=test && npm run prep-test && nyc report --reporter=text-lcov | coveralls", diff --git a/src/lib/users.js b/src/lib/users.js new file mode 100644 index 0000000..6bc8afc --- /dev/null +++ b/src/lib/users.js @@ -0,0 +1,56 @@ +/* + This library contains business-logic for dealing with users. Most of these + functions are called by the /user REST API endpoints. +*/ + +const UserModel = require('../models/users') +const wlogger = require('./wlogger') + +class UserLib { + constructor (configObj) { + // Encapsulate dependencies + this.UserModel = UserModel + } + + // Returns an array of all user models in the Mongo database. + async getAllUsers () { + try { + // Get all user models. Delete the password property from each model. + const users = await this.UserModel.find({}, '-password') + + return users + } catch (err) { + wlogger.error('Error in lib/users.js/getAllUsers()') + throw err + } + } + + // Get the model for a specific user. + async getUser (params) { + try { + const { id } = params + + const user = await this.UserModel.findById(id, '-password') + + // Throw a 404 error if the user isn't found. + if (!user) { + const err = new Error('User not found') + err.status = 404 + throw err + } + + return user + } catch (err) { + // console.log('Error in getUser: ', err) + + if (err.status === 404) throw err + + // Return 422 for any other error + err.status = 422 + err.message = 'Unprocessable Entity' + throw err + } + } +} + +module.exports = UserLib diff --git a/src/modules/users/controller.js b/src/modules/users/controller.js index 47418b6..b964dc4 100644 --- a/src/modules/users/controller.js +++ b/src/modules/users/controller.js @@ -1,10 +1,18 @@ +// User database model. const User = require('../../models/users') +// User library for business logic. +const UserLib = require('../../lib/users') + +const wlogger = require('../../lib/wlogger') + let _this class UserController { constructor () { _this = this + this.User = User + this.userLib = new UserLib() } /** @@ -50,21 +58,13 @@ class UserController { const userObj = ctx.request.body.user try { /* - * ERROR HANDLERS - * + * Input Validation */ // Required property if (!userObj.email || typeof userObj.email !== 'string') { throw new Error("Property 'email' must be a string!") } - // This validation is not permissive to different TLDs like this one: - // someone@somewhere.link. Removing it until it can be updated. - // const isEmail = await _this.validateEmail(user.email) - // if (!isEmail) { - // throw new Error("Property 'email' must be email format!") - // } - if (!userObj.password || typeof userObj.password !== 'string') { throw new Error("Property 'password' must be a string!") } @@ -74,6 +74,7 @@ class UserController { } const user = new _this.User(userObj) + // Enforce default value of 'user' user.type = 'user' @@ -125,10 +126,12 @@ class UserController { */ async getUsers (ctx) { try { - const users = await _this.User.find({}, '-password') + const users = await _this.userLib.getAllUsers() + ctx.body = { users } - } catch (error) { - ctx.throw(404) + } catch (err) { + wlogger.error('Error in users/controller.js/getUsers(): '.err) + ctx.throw(422, err.message) } } @@ -160,28 +163,15 @@ class UserController { * * @apiUse TokenError */ - async getUser (ctx, next) { try { - const user = await _this.User.findById(ctx.params.id, '-password') - if (!user) { - ctx.throw(404) - } + const user = await _this.userLib.getUser(ctx.params) ctx.body = { user } } catch (err) { - // Handle different error types. - if ( - err === 404 || - err.name === 'CastError' || - err.message.toString().includes('Not Found') - ) { - ctx.throw(404) - } - - ctx.throw(500) + _this.handleError(ctx, err) } if (next) { @@ -316,6 +306,21 @@ class UserController { } } + // DRY error handler + handleError (ctx, err) { + // If an HTTP status is specified by the buisiness logic, use that. + if (err.status) { + if (err.message) { + ctx.throw(err.status, err.message) + } else { + ctx.throw(err.status) + } + } else { + // By default use a 422 error if the HTTP status is not specified. + ctx.throw(422, err.message) + } + } + // Validate Email Format async validateEmail (email) { // eslint-disable-next-line no-useless-escape diff --git a/test/integration/rest-api/a01-auth.rest-integration.js b/test/integration/rest-api/a01-auth.rest-integration.js new file mode 100644 index 0000000..ee29367 --- /dev/null +++ b/test/integration/rest-api/a01-auth.rest-integration.js @@ -0,0 +1,102 @@ +const app = require('../../../bin/server') +const utils = require('../../utils/test-utils') +const config = require('../../../config') +const assert = require('chai').assert + +const axios = require('axios').default + +// const request = supertest.agent(app.listen()) +const context = {} + +const LOCALHOST = `http://localhost:${config.port}` + +describe('Auth', () => { + before(async () => { + // This should be the first instruction. It starts the REST API server. + await app.startServer() + + const userObj = { + email: 'test@test.com', + password: 'pass' + } + const testUser = await utils.createUser(userObj) + console.log(`TestUser : ${testUser}`) + + context.user = testUser.user + context.token = testUser.token + }) + + describe('POST /auth', () => { + it('should throw 401 if credentials are incorrect', async () => { + try { + const options = { + method: 'post', + url: `${LOCALHOST}/auth`, + data: { + email: 'test@test.com', + password: 'wrongpassword' + } + } + + const result = await axios(options) + + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + console.log( + `result stringified: ${JSON.stringify(result.data, null, 2)}` + ) + assert(false, 'Unexpected result') + } catch (err) { + assert(err.response.status === 401, 'Error code 401 expected.') + } + }) + + it('should throw 401 if email is wrong format', async () => { + try { + const options = { + method: 'post', + url: `${LOCALHOST}/auth`, + data: { + email: 'wrongEmail', + password: 'wrongpassword' + } + } + + await axios(options) + assert(false, 'Unexpected result') + } catch (err) { + assert(err.response.status === 401, 'Error code 401 expected.') + } + }) + + it('should auth user', async () => { + try { + const options = { + method: 'post', + url: `${LOCALHOST}/auth`, + data: { + email: 'test@test.com', + password: 'pass' + } + } + const result = await axios(options) + // console.log(`result: ${JSON.stringify(result.data, null, 2)}`) + + assert(result.status === 200, 'Status Code 200 expected.') + assert( + result.data.user.email === 'test@test.com', + 'Email of test expected' + ) + assert( + result.data.user.password === undefined, + 'Password expected to be omited' + ) + } catch (err) { + console.log( + 'Error authenticating test user: ' + JSON.stringify(err, null, 2) + ) + throw err + } + }) + }) +}) diff --git a/test/integration/rest-api/a02-users.rest-integration.js b/test/integration/rest-api/a02-users.rest-integration.js new file mode 100644 index 0000000..51fdff5 --- /dev/null +++ b/test/integration/rest-api/a02-users.rest-integration.js @@ -0,0 +1,823 @@ +const testUtils = require('../../utils/test-utils') +const assert = require('chai').assert +const config = require('../../../config') +const axios = require('axios').default +const sinon = require('sinon') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +const LOCALHOST = `http://localhost:${config.port}` + +const context = {} + +const UserController = require('../../../src/modules/users/controller') +let uut +let sandbox + +// const mockContext = require('../../unit/mocks/ctx-mock').context + +describe('Users', () => { + before(async () => { + // console.log(`config: ${JSON.stringify(config, null, 2)}`) + + // Create a second test user. + const userObj = { + email: 'test2@test.com', + password: 'pass2' + } + const testUser = await testUtils.createUser(userObj) + // console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`) + + context.user2 = testUser.user + context.token2 = testUser.token + context.id2 = testUser.user._id + + // Get the JWT used to log in as the admin 'system' user. + const adminJWT = await testUtils.getAdminJWT() + // console.log(`adminJWT: ${adminJWT}`) + context.adminJWT = adminJWT + + // const admin = await testUtils.loginAdminUser() + // context.adminJWT = admin.token + + // const admin = await adminLib.loginAdmin() + // console.log(`admin: ${JSON.stringify(admin, null, 2)}`) + }) + + beforeEach(() => { + uut = new UserController() + + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + describe('POST /users - Create User', () => { + it('should reject signup when data is incomplete', async () => { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/users`, + data: { + email: 'test2@test.com' + } + } + + await axios(options) + + /* console.log( + `result stringified: ${JSON.stringify(result.data, null, 2)}` + ) */ + assert(false, 'Unexpected result') + } catch (err) { + assert(err.response.status === 422, 'Error code 422 expected.') + } + }) + + it('should reject signup if no email property is provided', async () => { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/users`, + data: { + user: { + password: 'pass2' + } + } + } + await axios(options) + + assert(false, 'Unexpected result') + } catch (err) { + // console.log('err', err) + assert.equal(err.response.status, 422) + assert.include(err.response.data, "Property 'email' must be a string") + } + }) + + it('should reject signup if no password property is provided', async () => { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/users`, + data: { + user: { + email: 'test2@test.com' + } + } + } + await axios(options) + + assert(false, 'Unexpected result') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include( + err.response.data, + "Property 'password' must be a string" + ) + } + }) + + it('should reject if name property property is not string', async () => { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/users`, + data: { + user: { + email: 'test322@test.com', + password: 'supersecretpassword', + name: 1234 + } + } + } + await axios(options) + + assert(false, 'Unexpected result') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include(err.response.data, "Property 'name' must be a string") + } + }) + + it("should signup of type 'user' by default", async () => { + const options = { + method: 'post', + url: `${LOCALHOST}/users`, + data: { + user: { + email: 'test3@test.com', + password: 'supersecretpassword' + } + } + } + const result = await axios(options) + // console.log(`result: ${JSON.stringify(result, null, 2)}`) + + context.user = result.data.user + context.token = result.data.token + + assert(result.status === 200, 'Status Code 200 expected.') + assert( + result.data.user.email === 'test3@test.com', + 'Email of test expected' + ) + assert( + result.data.user.password === undefined, + 'Password expected to be omited' + ) + assert.property(result.data, 'token', 'Token property exists.') + assert.equal(result.data.user.type, 'user') + }) + }) + + describe('GET /users', () => { + it('should not fetch users if the authorization header is missing', async () => { + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not fetch users if the authorization header is missing the scheme', async () => { + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json', + Authorization: '1' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not fetch users if the authorization header has invalid scheme', async () => { + const { token } = context + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json', + Authorization: `Unknown ${token}` + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not fetch users if token is invalid', async () => { + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json', + Authorization: 'Bearer 1' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should fetch all users', async () => { + const { token } = context + + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + const result = await axios(options) + + const users = result.data.users + // console.log(`users: ${util.inspect(users)}`) + + assert.hasAnyKeys(users[0], ['type', '_id', 'email']) + assert.isNumber(users.length) + }) + + it('should return a 422 http status if biz-logic throws an error', async () => { + try { + const { token } = context + + // Force an error + sandbox + .stub(uut.userLib, 'getAllUsers') + .rejects(new Error('test error')) + + const options = { + method: 'GET', + url: `${LOCALHOST}/users`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + await axios(options) + + assert.fail('Unexpected code path!') + } catch (err) { + assert.equal(err.response.status, 422) + assert.equal(err.response.data, 'test error') + } + }) + }) + + describe('GET /users/:id', () => { + it('should not fetch user if token is invalid', async () => { + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: 'Bearer 1' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it("should throw 404 if user doesn't exist", async () => { + const { token } = context + + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users/5fa4bd7ee1828f5f4d8ed004`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 404) + } + }) + + it('should throw 422 for invalid input', async () => { + const { token } = context + + try { + const options = { + method: 'GET', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 422) + } + }) + + it('should fetch own user', async () => { + const _id = context.user._id + const token = context.token + + const options = { + method: 'GET', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + const result = await axios(options) + + const user = result.data.user + // console.log(`user: ${util.inspect(user)}`) + + assert.property(user, 'type') + assert.property(user, 'email') + + assert.property(user, '_id') + assert.equal(user._id, _id) + + assert.notProperty( + user, + 'password', + 'Password property should not be returned' + ) + }) + }) + + describe('PUT /users/:id', () => { + it('should not update user if token is invalid', async () => { + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: 'Bearer 1' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should throw 401 if non-admin updating other user', async () => { + const { token } = context + + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not be able to update user type', async () => { + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${context.user._id.toString()}`, + headers: { + Authorization: `Bearer ${context.token}` + }, + data: { + user: { + name: 'new name', + type: 'test' + } + } + } + await axios(options) + + // console.log(`Users: ${JSON.stringify(result.data, null, 2)}`) + + // assert(result.status === 200, 'Status Code 200 expected.') + // assert(result.data.user.type === 'user', 'Type should be unchanged.') + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include( + err.response.data, + "Property 'type' can only be changed by Admin user" + ) + } + }) + + it('should not be able to update other user when not admin', async () => { + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${context.user2._id.toString()}`, + headers: { + Authorization: `Bearer ${context.token}` + }, + data: { + user: { + name: 'This should not work' + } + } + } + await axios(options) + + // console.log(`result: ${JSON.stringify(result.data, null, 2)}`) + + assert(false, 'Unexpected result') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not be able to update if name property is wrong', async () => { + try { + const _id = context.user._id + const token = context.token + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + email: 'testToUpdate@test.com', + name: {} + } + } + } + await axios(options) + } catch (error) { + assert.equal(error.response.status, 422) + assert.include(error.response.data, "Property 'name' must be a string!") + } + }) + it('should not be able to update if password property is not string', async () => { + const { token } = context + const _id = context.user._id + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + password: 1234 + } + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include( + err.response.data, + "Property 'password' must be a string!" + ) + } + }) + it('should not be able to update if project property is not array', async () => { + const { token } = context + const _id = context.user._id + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + projects: 'projects' + } + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include( + err.response.data, + "Property 'projects' must be a Array!" + ) + } + }) + it('should not be able to update if email is not string', async () => { + const { token } = context + const _id = context.user._id + try { + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + email: 1234 + } + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 422) + assert.include(err.response.data, "Property 'email' must be a string!") + } + }) + it('should not be able to update if email is wrong format', async () => { + try { + const _id = context.user._id + const token = context.token + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + email: 'badEmailFormat' + } + } + } + await axios(options) + } catch (err) { + assert.equal(err.response.status, 422) + assert.include( + err.response.data, + "Property 'email' must be email format!" + ) + } + }) + it('should not be able to update type property if is not string', async () => { + try { + const _id = context.user._id + const token = context.token + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + type: 1 + } + } + } + await axios(options) + } catch (err) { + assert.equal(err.response.status, 422) + assert.include(err.response.data, "Property 'type' must be a string!") + } + }) + + it('should be able to update other user when admin', async () => { + const adminJWT = context.adminJWT + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${context.user2._id.toString()}`, + headers: { + Authorization: `Bearer ${adminJWT}` + }, + data: { + user: { + name: 'This should work' + } + } + } + const result = await axios(options) + // console.log(`result stringified: ${JSON.stringify(result, null, 2)}`) + + const userName = result.data.user.name + assert.equal(userName, 'This should work') + }) + it('should update user with minimum inputs', async () => { + const _id = context.user._id + const token = context.token + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { email: 'testToUpdate@test.com' } + } + } + + const result = await axios(options) + const user = result.data.user + // console.log(`user: ${util.inspect(user)}`) + + assert.property(user, 'type') + assert.property(user, 'email') + + assert.property(user, '_id') + assert.equal(user._id, _id) + + assert.notProperty( + user, + 'password', + 'Password property should not be returned' + ) + assert.equal(user.email, 'testToUpdate@test.com') + }) + + it('should update user with all inputs', async () => { + const _id = context.user._id + const token = context.token + + const options = { + method: 'PUT', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + }, + data: { + user: { + email: 'testToUpdate@test.com', + name: 'my name', + username: 'myUsername' + } + } + } + const result = await axios(options) + + const user = result.data.user + // console.log(`user: ${util.inspect(user)}`) + + assert.property(user, 'type') + assert.property(user, 'email') + assert.property(user, 'name') + + assert.property(user, '_id') + assert.equal(user._id, _id) + assert.notProperty( + user, + 'password', + 'Password property should not be returned' + ) + assert.equal(user.name, 'my name') + assert.equal(user.email, 'testToUpdate@test.com') + assert.equal(user.username, 'myUsername') + }) + }) + + describe('DELETE /users/:id', () => { + it('should not delete user if token is invalid', async () => { + try { + const options = { + method: 'DELETE', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: 'Bearer 1' + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should throw 401 if deleting invalid user', async () => { + const { token } = context + + try { + const options = { + method: 'DELETE', + url: `${LOCALHOST}/users/1`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + await axios(options) + + assert.equal(true, false, 'Unexpected behavior') + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should not be able to delete other users unless admin', async () => { + try { + const options = { + method: 'DELETE', + url: `${LOCALHOST}/users/${context.user2._id.toString()}`, + headers: { + Authorization: `Bearer ${context.token}` + } + } + await axios(options) + } catch (err) { + assert.equal(err.response.status, 401) + } + }) + + it('should delete own user', async () => { + const _id = context.user._id + const token = context.token + + const options = { + method: 'DELETE', + url: `${LOCALHOST}/users/${_id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}` + } + } + const result = await axios(options) + // console.log(`result: ${util.inspect(result.data.success)}`) + + assert.equal(result.data.success, true) + }) + + it('should be able to delete other users when admin', async () => { + const id = context.id2 + const adminJWT = context.adminJWT + + const options = { + method: 'DELETE', + url: `${LOCALHOST}/users/${id}`, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${adminJWT}` + } + } + const result = await axios(options) + // console.log(`result: ${util.inspect(result.data)}`) + + assert.equal(result.data.success, true) + }) + }) +}) diff --git a/test/unit/biz-logic/a02-users.lib-unit.js b/test/unit/biz-logic/a02-users.lib-unit.js new file mode 100644 index 0000000..12681f5 --- /dev/null +++ b/test/unit/biz-logic/a02-users.lib-unit.js @@ -0,0 +1,44 @@ +/* + Unit tests for the src/lib/users.js business logic library. +*/ + +// Public npm libraries +const mongoose = require('mongoose') +const assert = require('chai').assert + +// Local support libraries +const config = require('../../../config') + +// Unit under test (uut) +const UserLib = require('../../../src/lib/users') + +describe('#users', () => { + let uut + + before(async () => { + // Connect to the Mongo Database. + mongoose.Promise = global.Promise + mongoose.set('useCreateIndex', true) // Stop deprecation warning. + await mongoose.connect(config.database, { + useUnifiedTopology: true, + useNewUrlParser: true + }) + }) + + beforeEach(() => { + uut = new UserLib() + }) + + after(() => { + mongoose.connection.close() + }) + + describe('#getAllUsers', () => { + it('should return all users from the database', async () => { + const users = await uut.getAllUsers() + // console.log(`users: ${JSON.stringify(users, null, 2)}`) + + assert.isArray(users) + }) + }) +}) diff --git a/test/unit/rest-api/a02-users.rest-unit.js b/test/unit/rest-api/a02-users.rest-unit.js new file mode 100644 index 0000000..0345dd9 --- /dev/null +++ b/test/unit/rest-api/a02-users.rest-unit.js @@ -0,0 +1,109 @@ +// const testUtils = require('../../utils/test-utils') +const assert = require('chai').assert +const config = require('../../../config') +// const axios = require('axios').default +const sinon = require('sinon') +const mongoose = require('mongoose') + +const util = require('util') +util.inspect.defaultOptions = { depth: 1 } + +// const LOCALHOST = `http://localhost:${config.port}` + +// const context = {} + +const UserController = require('../../../src/modules/users/controller') +let uut +let sandbox + +const mockContext = require('../../unit/mocks/ctx-mock').context + +describe('Users', () => { + before(async () => { + // Connect to the Mongo Database. + mongoose.Promise = global.Promise + mongoose.set('useCreateIndex', true) // Stop deprecation warning. + await mongoose.connect(config.database, { + useUnifiedTopology: true, + useNewUrlParser: true + }) + + // console.log(`config: ${JSON.stringify(config, null, 2)}`) + + // Create a second test user. + // const userObj = { + // email: 'test2@test.com', + // password: 'pass2' + // } + // const testUser = await testUtils.createUser(userObj) + // console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`) + + // context.user2 = testUser.user + // context.token2 = testUser.token + // context.id2 = testUser.user._id + + // Get the JWT used to log in as the admin 'system' user. + // const adminJWT = await testUtils.getAdminJWT() + // // console.log(`adminJWT: ${adminJWT}`) + // context.adminJWT = adminJWT + + // const admin = await testUtils.loginAdminUser() + // context.adminJWT = admin.token + + // const admin = await adminLib.loginAdmin() + // console.log(`admin: ${JSON.stringify(admin, null, 2)}`) + }) + + beforeEach(() => { + uut = new UserController() + + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + after(() => { + mongoose.connection.close() + }) + + describe('GET /users', () => { + it('should catch and handle errors', async () => { + try { + // Force an error + sandbox + .stub(uut.userLib, 'getAllUsers') + .rejects(new Error('test error')) + + // Mock the context object. + const ctx = mockContext() + + await uut.getUsers(ctx) + + assert.fail('Unexpected result') + } catch (err) { + console.log('err: ', err) + assert.include(err.message, 'Not Found') + } + }) + }) + + describe('GET /users/:id', () => { + it('should catch and handle errors', async () => { + try { + // Force an error + sandbox + .stub(uut.userLib, 'getUser') + .rejects(new Error('test error')) + + // Mock the context object. + const ctx = mockContext() + + await uut.getUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.include(err.message, 'Internal Server Error') + } + }) + }) +}) diff --git a/test/utils/test-utils.js b/test/utils/test-utils.js new file mode 100644 index 0000000..91cc79a --- /dev/null +++ b/test/utils/test-utils.js @@ -0,0 +1,135 @@ +const mongoose = require('mongoose') +const config = require('../../config') +const axios = require('axios').default + +const LOCALHOST = `http://localhost:${config.port}` + +// Remove all collections from the DB. +async function cleanDb () { + for (const collection in mongoose.connection.collections) { + const collections = mongoose.connection.collections + if (collections.collection) { + // const thisCollection = mongoose.connection.collections[collection] + // console.log(`thisCollection: ${JSON.stringify(thisCollection, null, 2)}`) + + await collection.deleteMany() + } + } +} + +// This function is used to create new users. +// userObj = { +// username, +// password +// } +async function createUser (userObj) { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/users`, + data: { + user: { + email: userObj.email, + password: userObj.password + } + } + } + + const result = await axios(options) + + const retObj = { + user: result.data.user, + token: result.data.token + } + + return retObj + } catch (err) { + console.log('Error in utils.js/createUser(): ' + JSON.stringify(err, null, 2)) + throw err + } +} + +async function loginTestUser () { + try { + const options = { + method: 'POST', + url: `${LOCALHOST}/auth`, + data: { + email: 'test@test.com', + password: 'pass' + } + } + + const result = await axios(options) + + // console.log(`result: ${JSON.stringify(result.data, null, 2)}`) + + const retObj = { + token: result.data.token, + user: result.data.user.username, + id: result.data.user._id.toString() + } + + return retObj + } catch (err) { + console.log('Error authenticating test user: ' + JSON.stringify(err, null, 2)) + throw err + } +} + +async function loginAdminUser () { + try { + const FILENAME = `../../config/system-user-${config.env}.json` + const adminUserData = require(FILENAME) + console.log(`adminUserData: ${JSON.stringify(adminUserData, null, 2)}`) + + const options = { + method: 'POST', + url: `${LOCALHOST}/auth`, + data: { + email: adminUserData.email, + password: adminUserData.password + } + } + + const result = await axios(options) + + // console.log(`result: ${JSON.stringify(result.data, null, 2)}`) + + const retObj = { + token: result.data.token, + user: result.data.user.username, + id: result.data.user._id.toString() + } + + return retObj + } catch (err) { + console.log('Error authenticating test admin user: ' + JSON.stringify(err, null, 2)) + throw err + } +} + +// Retrieve the admin user JWT token from the JSON file it's saved at. +async function getAdminJWT () { + try { + // process.env.KOA_ENV = process.env.KOA_ENV || 'dev' + // console.log(`env: ${process.env.KOA_ENV}`) + + const FILENAME = `../../config/system-user-${config.env}.json` + const adminUserData = require(FILENAME) + // console.log(`adminUserData: ${JSON.stringify(adminUserData, null, 2)}`) + + return adminUserData.token + } catch (err) { + console.error('Error in test/utils.js/getAdminJWT()') + throw err + } +} + +module.exports = { + cleanDb, + createUser, + loginTestUser, + loginAdminUser, + getAdminJWT +}