diff --git a/package.json b/package.json index e26d541..a098d15 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,15 @@ "main": "index.js", "scripts": { "start": "node index.js", - "test": "export KOA_ENV=test && npm run prep-test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/", + "test": "npm run test:all", + "test:all": "export KOA_ENV=test && nyc --reporter=text mocha --exit --timeout 15000 test/unit/biz-logic/ test/unit/rest-api/ test/e2e/automated/", + "test:unit:lib": "export KOA_ENV=test && mocha --exit --timeout 15000 test/unit/biz-logic/", + "test:unit:rest": "export KOA_ENV=test && mocha --exit --timeout 15000 test/unit/rest-api/", + "test:e2e:auto": "export KOA_ENV=test && mocha --exit --timeout 15000 test/e2e/automated/", "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", - "coverage:report": "export KOA_ENV=test && npm run prep-test && nyc --reporter=html mocha --exit --timeout 15000 test/unit/", - "prep-test": "node util/users/delete-all-test-users.js" + "coverage": "nyc report --reporter=text-lcov | coveralls", + "coverage:report": "export KOA_ENV=test && nyc --reporter=html mocha --exit --timeout 15000 test/unit/biz-logic/ test/unit/rest-api/ test/e2e/automated/" }, "keywords": [ "koa-api-boilerplate", diff --git a/src/lib/admin.js b/src/lib/admin.js index e6bfdc8..7b08455 100644 --- a/src/lib/admin.js +++ b/src/lib/admin.js @@ -49,11 +49,14 @@ class Admin { data: { user: { email: 'system@system.com', - password: context.password + password: context.password, + name: 'admin' } } } const result = await _this.axios.request(options) + // console.log('admin.data: ', result.data) + context.email = result.data.user.email context.id = result.data.user._id context.token = result.data.token diff --git a/src/lib/nodemailer.js b/src/lib/nodemailer.js index 4835bfe..426fae6 100644 --- a/src/lib/nodemailer.js +++ b/src/lib/nodemailer.js @@ -20,6 +20,7 @@ class NodeMailer { _this.transporter = _this.createTransporter() } + // Define an email server 'transport' for nodemailer createTransporter () { const transporter = _this.nodemailer.createTransport({ host: _this.config.emailServer, @@ -78,7 +79,7 @@ class NodeMailer { const subject = data.subject const to = data.to - const emailUser = data.email // email from the user who initiated the sharing email + // const emailUser = data.email // email from the user who initiated the sharing email const payload = data.payloadTitle const bodyJson = data @@ -103,9 +104,7 @@ class NodeMailer { }) // This paragraph just be added to the message for // emails that are not password reset ones - const paragraphTag = `

${emailUser} would like to share the document ${payload} with you through - LaunchpadIP.net. -

` + const paragraphTag = 'This is a test email' const htmlMsg = `

${subject}:

${payload === 'Email reset' ? '' : paragraphTag} diff --git a/src/lib/users.js b/src/lib/users.js new file mode 100644 index 0000000..ee55d6c --- /dev/null +++ b/src/lib/users.js @@ -0,0 +1,150 @@ +/* + 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 + } + + // Create a new user model and add it to the Mongo database. + async createUser (userObj) { + try { + // Input Validation + if (!userObj.email || typeof userObj.email !== 'string') { + throw new Error("Property 'email' must be a string!") + } + if (!userObj.password || typeof userObj.password !== 'string') { + throw new Error("Property 'password' must be a string!") + } + if (!userObj.name || typeof userObj.name !== 'string') { + throw new Error("Property 'name' must be a string!") + } + + const user = new this.UserModel(userObj) + + // Enforce default value of 'user' + user.type = 'user' + + // Save the new user model to the database. + await user.save() + + // Generate a JWT token for the user. + const token = user.generateToken() + + // Convert the database model to a JSON object. + const userData = user.toJSON() + + // Delete the password property. + delete userData.password + + return { userData, token } + } catch (err) { + // console.log('createUser() error: ', err) + wlogger.error('Error in lib/users.js/createUser()') + throw err + } + } + + // 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 + } + } + + async updateUser (existingUser, newData) { + try { + // Input Validation + // Optional inputs, but they must be strings if included. + if (newData.email && typeof newData.email !== 'string') { + throw new Error("Property 'email' must be a string!") + } + if (newData.name && typeof newData.name !== 'string') { + throw new Error("Property 'name' must be a string!") + } + if (newData.password && typeof newData.password !== 'string') { + throw new Error("Property 'password' must be a string!") + } + + // Save a copy of the original user type. + const userType = existingUser.type + // console.log('userType: ', userType) + + // If user 'type' property is sent by the client + if (newData.type) { + if (typeof newData.type !== 'string') { + throw new Error("Property 'type' must be a string!") + } + + // Unless the calling user is an admin, they can not change the user type. + if (userType !== 'admin') { + throw new Error("Property 'type' can only be changed by Admin user") + } + } + + // Overwrite any existing data with the new data. + Object.assign(existingUser, newData) + + // Save the changes to the database. + await existingUser.save() + + // Delete the password property. + delete existingUser.password + + return existingUser + } catch (err) { + wlogger.error('Error in lib/users.js/updateUser()') + throw err + } + } + + async deleteUser (user) { + try { + await user.remove() + } catch (err) { + wlogger.error('Error in lib/users.js/deleteUser()') + throw err + } + } +} + +module.exports = UserLib diff --git a/src/middleware/validators.js b/src/middleware/validators.js index 2fb4e15..9e9dc86 100644 --- a/src/middleware/validators.js +++ b/src/middleware/validators.js @@ -120,6 +120,7 @@ class Validators { // console.log(`Err: Could not find user.`) ctx.throw(401) } + // console.log('ctx.state.user: ', ctx.state.user) // console.log(`ctx.state.user: ${JSON.stringify(ctx.state.user, null, 2)}`) // Ensure the calling user and the target user are the same. diff --git a/src/modules/users/controller.js b/src/modules/users/controller.js index 47418b6..e1dbb5f 100644 --- a/src/modules/users/controller.js +++ b/src/modules/users/controller.js @@ -1,10 +1,19 @@ +// 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 + // Encapsulate dependencies this.User = User + this.userLib = new UserLib() + + _this = this } /** @@ -47,51 +56,22 @@ class UserController { * } */ async createUser (ctx) { - const userObj = ctx.request.body.user try { - /* - * ERROR HANDLERS - * - */ - // Required property - if (!userObj.email || typeof userObj.email !== 'string') { - throw new Error("Property 'email' must be a string!") - } + const userObj = ctx.request.body.user - // 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!") - } - - if (userObj.name && typeof userObj.name !== 'string') { - throw new Error("Property 'name' must be a string!") - } - - const user = new _this.User(userObj) - // Enforce default value of 'user' - user.type = 'user' - - await user.save() - - const token = user.generateToken() - const response = user.toJSON() - - delete response.password + const { userData, token } = await _this.userLib.createUser(userObj) + // console.log('userData: ', userData) + // console.log('token: ', token) ctx.body = { - user: response, + user: userData, token } } catch (err) { // console.log(`err.message: ${err.message}`) // console.log('err: ', err) - ctx.throw(422, err.message) + // ctx.throw(422, err.message) + _this.handleError(ctx, err) } } @@ -125,10 +105,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 +142,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) { @@ -231,60 +200,17 @@ class UserController { * @apiUse TokenError */ async updateUser (ctx) { - // Values obtain from user request. - // This variable is intended to validate the properties - // sent by the client - const userObj = ctx.request.body.user - - const user = ctx.body.user try { - /* - * ERROR HANDLERS - * - */ - // Required property - if (userObj.email && typeof userObj.email !== 'string') { - throw new Error("Property 'email' must be a string!") - } + const existingUser = ctx.body.user + const newData = ctx.request.body.user - const isEmail = await _this.validateEmail(userObj.email) - if (userObj.email && !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!") - } - if (userObj.name && typeof userObj.name !== 'string') { - throw new Error("Property 'name' must be a string!") - } - if (userObj.projects && !Array.isArray(userObj.projects)) { - throw new Error("Property 'projects' must be a Array!") - } - // Save a copy of the original user type. - const userType = user.type - - // If user type property is sent by the client - if (userObj.type) { - if (typeof userObj.type !== 'string') { - throw new Error("Property 'type' must be a string!") - } - // TODO: Here we can validate the user types allowed - - // Unless the calling user is an admin, they can not change the user type. - if (userType !== 'admin') { - throw new Error("Property 'type' can only be changed by Admin user") - } - } - - Object.assign(user, ctx.request.body.user) - - await user.save() + const user = await _this.userLib.updateUser(existingUser, newData) ctx.body = { user } - } catch (error) { - ctx.throw(422, error.message) + } catch (err) { + ctx.throw(422, err.message) } } @@ -308,11 +234,33 @@ class UserController { * @apiUse TokenError */ async deleteUser (ctx) { - const user = ctx.body.user - await user.remove() - ctx.status = 200 - ctx.body = { - success: true + try { + const user = ctx.body.user + + // await user.remove() + await _this.userLib.deleteUser(user) + + ctx.status = 200 + ctx.body = { + success: true + } + } catch (err) { + ctx.throw(422, err.message) + } + } + + // 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) } } diff --git a/test/e2e/automated/README.md b/test/e2e/automated/README.md new file mode 100644 index 0000000..283c815 --- /dev/null +++ b/test/e2e/automated/README.md @@ -0,0 +1,7 @@ +# Automated End-to-end Tests + +This contains the original boilerplate tests, which are end-to-end tests. These tests are fully automated and test the system directly by making REST API calls with axios. + +These tests function exactly the same as a normal user would, by making real REST API calls to the software. As a result, they are fine for testing internal system components like authorization and user handling. However, they are inappropriate for testing sophisticated endpoints that involve complex operations. For example, interacting with a blockchain, pinging other network systems, or writing data to a secondary database. + +There is some redundancy between these tests and the unit tests. The focus is on *how* the tests are executed. The unit tests call the libraries directly (internally). These e2e tests use the REST API (externally). diff --git a/test/e2e/automated/a01-auth.rest-e2e.js b/test/e2e/automated/a01-auth.rest-e2e.js new file mode 100644 index 0000000..52099ee --- /dev/null +++ b/test/e2e/automated/a01-auth.rest-e2e.js @@ -0,0 +1,119 @@ +/* + End-to-end tests for /auth endpoints. + + This test sets up the environment for other e2e tests. +*/ + +// Public npm libraries +const assert = require('chai').assert +const axios = require('axios').default + +// Local support libraries +const config = require('../../../config') +const app = require('../../../bin/server') +const testUtils = require('../../utils/test-utils') +const AdminLib = require('../../../src/lib/admin') +const adminLib = new AdminLib() + +// 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() + + // Delete all previous users in the database. + await testUtils.deleteAllUsers() + + // Create a new admin user. + await adminLib.createSystemUser() + + const userObj = { + email: 'test@test.com', + password: 'pass', + name: 'test' + } + const testUser = await testUtils.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/e2e/automated/a02-users.rest-e2e.js b/test/e2e/automated/a02-users.rest-e2e.js new file mode 100644 index 0000000..5ecea89 --- /dev/null +++ b/test/e2e/automated/a02-users.rest-e2e.js @@ -0,0 +1,786 @@ +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', + name: 'test2' + } + 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: ${admi nJWT}`) + 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.fail('Unexpected result') + } catch (err) { + // console.log(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', + name: 'test3' + } + } + } + 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: { + email: 'test@test.com', + password: 'password', + 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: {}, + password: 'password' + } + } + } + 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, + email: 'test@test.com', + name: 'test' + } + } + } + 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 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 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, + email: 'test@test.com', + name: 'test', + password: 'password' + } + } + } + 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', + email: 'test4@test.com', + password: 'password' + } + } + } + + 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/README.md b/test/unit/README.md index 8cddb82..acd1a9d 100644 --- a/test/unit/README.md +++ b/test/unit/README.md @@ -1,3 +1,8 @@ -The files in this directory are named so as to control the order in which -the files are executed by mocha. a01... runs first. The order is important, -as some tests downstream depend on tests upstream. +# Unit Tests +Unit tests are defined as testing the smallest possible unit of a function. They also do not make any live network calls. + +Unit tests are broken up by directory: + +- [biz-logic](./biz-logic) tests the business logic libraries. +- [rest-api](./rest-api) tests the REST API specific handling of the router. +- json-rpc (coming soon) tests the JSON-RPC routing using ipfs-coord library. diff --git a/test/unit/biz-logic/README.md b/test/unit/biz-logic/README.md new file mode 100644 index 0000000..8c5cdff --- /dev/null +++ b/test/unit/biz-logic/README.md @@ -0,0 +1,3 @@ +# Business Logic Unit Tests + +The unit tests in this directly are concerned with business logic libraries in the /src/lib folder. These are the methods that should be triggered by REST API endpoints. These tests are not concerned with the handling of the REST API request/response, but by the code that is triggered by those endpoints. It also tests any business logic that is not directly associated with a REST API endpoint. 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..2885e2f --- /dev/null +++ b/test/unit/biz-logic/a02-users.lib-unit.js @@ -0,0 +1,357 @@ +/* + Unit tests for the src/lib/users.js business logic library. + + TODO: verify that an admin can change the type of a user +*/ + +// Public npm libraries +const mongoose = require('mongoose') +const assert = require('chai').assert +const sinon = require('sinon') + +// Local support libraries +const config = require('../../../config') +const testUtils = require('../../utils/test-utils') + +// Unit under test (uut) +const UserLib = require('../../../src/lib/users') + +describe('#users', () => { + let uut + let sandbox + let testUser = {} + + before(async () => { + // Connect to the Mongo Database. + console.log(`Connecting to database: ${config.database}`) + mongoose.Promise = global.Promise + mongoose.set('useCreateIndex', true) // Stop deprecation warning. + await mongoose.connect(config.database, { + useUnifiedTopology: true, + useNewUrlParser: true + }) + + // Delete all previous users in the database. + await testUtils.deleteAllUsers() + }) + + beforeEach(() => { + sandbox = sinon.createSandbox() + + uut = new UserLib() + }) + + afterEach(() => sandbox.restore()) + + after(() => { + mongoose.connection.close() + }) + + describe('#createUser', () => { + it('should throw an error if no input is given', async () => { + try { + await uut.createUser() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + // assert.equal(err.status, 422) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should throw an error if email is not provided', async () => { + try { + await uut.createUser({}) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, "Property 'email' must be a string!") + } + }) + + it('should throw an error if password is not provided', async () => { + try { + const usrObj = { + email: 'test@test.com' + } + + await uut.createUser(usrObj) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, "Property 'password' must be a string!") + } + }) + + it('should throw an error if name is not provided', async () => { + try { + const usrObj = { + email: 'test@test.com', + password: 'password' + } + + await uut.createUser(usrObj) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, "Property 'name' must be a string!") + } + }) + + it('should catch and throw DB errors', async () => { + try { + // Force an error with the database. + sandbox.stub(uut, 'UserModel').throws(new Error('test error')) + + const usrObj = { + email: 'test@test.com', + password: 'password', + name: 'test' + } + + await uut.createUser(usrObj) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should create a new user in the DB', async () => { + // Note: The user created in this test is used by the getUser, update, + // and delete tests. + + const usrObj = { + email: 'test01@test.com', + password: 'test', + name: 'test01' + } + + const { userData, token } = await uut.createUser(usrObj) + + testUser = userData + + // Assert that the user model has the expected properties with expected values. + assert.property(userData, 'type') + assert.equal(userData.type, 'user') + assert.property(userData, '_id') + assert.property(userData, 'email') + assert.property(userData, 'name') + + // Assert that the JWT token was generated for this user. + assert.isString(token) + assert.include(token, 'eyJ') + }) + }) + + 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) + }) + + it('should catch and throw an error', async () => { + try { + // Force an error. + sandbox.stub(uut.UserModel, 'find').rejects(new Error('test error')) + + await uut.getAllUsers() + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + }) + + describe('#getUser', () => { + it('should throw 422 if no id given.', async () => { + try { + await uut.getUser() + + assert.fail('Unexpected code path.') + } catch (err) { + // console.log(err) + assert.equal(err.status, 422) + assert.include(err.message, 'Unprocessable Entity') + } + }) + + it('should throw 422 for malformed id', async () => { + try { + const params = { id: 1 } + await uut.getUser(params) + + assert.fail('Unexpected code path.') + } catch (err) { + // console.log(err) + assert.equal(err.status, 422) + assert.include(err.message, 'Unprocessable Entity') + } + }) + + it('should throw 404 if user is not found', async () => { + try { + const params = { id: '5fa4bd7ee1828f5f4d3ed004' } + await uut.getUser(params) + + assert.fail('Unexpected code path.') + } catch (err) { + // console.log(err) + assert.equal(err.status, 404) + assert.include(err.message, 'User not found') + } + }) + + it('should return the user model', async () => { + const params = { id: testUser._id } + const result = await uut.getUser(params) + // console.log('result: ', result) + + // Replace the JSON model with an actual Mongoos model. Used by later + // test cases. + testUser = result + + // Assert that the expected properties for the user model exist. + assert.property(result, 'type') + assert.property(result, '_id') + assert.property(result, 'email') + assert.property(result, 'name') + }) + }) + + describe('#updateUser', () => { + it('should throw an error if no input is given', async () => { + try { + await uut.updateUser() + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should throw an error if email is not a string', async () => { + try { + await uut.updateUser(testUser, { + email: 1234 + }) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, "Property 'email' must be a string!") + } + }) + + it('should throw an error if name is not a string', async () => { + try { + const newData = { + name: 1234 + } + + await uut.updateUser(testUser, newData) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, "Property 'name' must be a string!") + } + }) + + it('should throw an error if non-string password given', async () => { + try { + const newData = { + email: 'test@test.com', + name: 'test', + password: 1234 + } + + await uut.updateUser(testUser, newData) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, "Property 'password' must be a string!") + } + }) + + it('should throw an error for malformed type given', async () => { + try { + const newData = { + email: 'test@test.com', + password: 'password', + name: 'test', + type: 1234 + } + + await uut.updateUser(testUser, newData) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, "Property 'type' must be a string!") + } + }) + + it('should throw an error if normal user tries to change themselves into an admin', async () => { + try { + const newData = { + email: 'test@test.com', + password: 'password', + name: 'test', + type: 'admin' + } + + await uut.updateUser(testUser, newData) + + assert.fail('Unexpected code path') + } catch (err) { + // console.log(err) + assert.include(err.message, "Property 'type' can only be changed by Admin user") + } + }) + + it('should update the user model', async () => { + const newData = { + email: 'test@test.com', + password: 'password', + name: 'testy tester' + } + + const result = await uut.updateUser(testUser, newData) + + // Assert that expected properties and values exist. + assert.property(result, '_id') + assert.property(result, 'email') + assert.equal(result.email, 'test@test.com') + assert.property(result, 'name') + assert.equal(result.name, 'testy tester') + }) + + // TODO: verify that an admin can change the type of a user + }) + + describe('#deleteUser', () => { + it('should throw error if no user provided', async () => { + try { + await uut.deleteUser() + + assert.fail('Unexpected code path.') + } catch (err) { + // console.log(err) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should delete the user from the database', async () => { + await uut.deleteUser(testUser) + + assert.isOk('Not throwing an error is a pass!') + }) + }) +}) diff --git a/test/unit/a01-auth.spec.js b/test/unit/old-tests/a01-auth.spec.js similarity index 100% rename from test/unit/a01-auth.spec.js rename to test/unit/old-tests/a01-auth.spec.js diff --git a/test/unit/a02-users.spec.js b/test/unit/old-tests/a02-users.spec.js similarity index 100% rename from test/unit/a02-users.spec.js rename to test/unit/old-tests/a02-users.spec.js diff --git a/test/unit/a03-nodemailer.spec.js b/test/unit/old-tests/a03-nodemailer.spec.js similarity index 100% rename from test/unit/a03-nodemailer.spec.js rename to test/unit/old-tests/a03-nodemailer.spec.js diff --git a/test/unit/a04-contact.spec.js b/test/unit/old-tests/a04-contact.spec.js similarity index 100% rename from test/unit/a04-contact.spec.js rename to test/unit/old-tests/a04-contact.spec.js diff --git a/test/unit/a05-passport.spec.js b/test/unit/old-tests/a05-passport.spec.js similarity index 100% rename from test/unit/a05-passport.spec.js rename to test/unit/old-tests/a05-passport.spec.js diff --git a/test/unit/a06-logapi.spec.js b/test/unit/old-tests/a06-logapi.spec.js similarity index 100% rename from test/unit/a06-logapi.spec.js rename to test/unit/old-tests/a06-logapi.spec.js diff --git a/test/unit/a07-json-files.spec.js b/test/unit/old-tests/a07-json-files.spec.js similarity index 100% rename from test/unit/a07-json-files.spec.js rename to test/unit/old-tests/a07-json-files.spec.js diff --git a/test/unit/a08-validators.spec.js b/test/unit/old-tests/a08-validators.spec.js similarity index 100% rename from test/unit/a08-validators.spec.js rename to test/unit/old-tests/a08-validators.spec.js diff --git a/test/unit/a09-admin.spec.js b/test/unit/old-tests/a09-admin.spec.js similarity index 100% rename from test/unit/a09-admin.spec.js rename to test/unit/old-tests/a09-admin.spec.js diff --git a/test/unit/utils.js b/test/unit/old-tests/utils.js similarity index 100% rename from test/unit/utils.js rename to test/unit/old-tests/utils.js diff --git a/test/unit/rest-api/README.md b/test/unit/rest-api/README.md new file mode 100644 index 0000000..eeab4e9 --- /dev/null +++ b/test/unit/rest-api/README.md @@ -0,0 +1,9 @@ +# REST API Unit Tests + +The tests in this directory are unit tests of REST API. These tests are not +concerned with the business logic behind the endpoints. They are only concerned +with the handling of the REST API endpoint. These tests answer questions like: + +- Is the endpoint responding properly when the business logic throws an error? +- When returning an error, is it returning the proper HTTP response? +- When returning success, is it returning the correct payload? 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..0ec57b2 --- /dev/null +++ b/test/unit/rest-api/a02-users.rest-unit.js @@ -0,0 +1,254 @@ +/* + Unit tests for the REST API handler for the /users endpoints. +*/ + +// Public npm libraries +const assert = require('chai').assert +const sinon = require('sinon') +const mongoose = require('mongoose') + +// Local support libraries +const config = require('../../../config') +const testUtils = require('../../utils/test-utils') +const User = require('../../../src/models/users') + +const UserController = require('../../../src/modules/users/controller') +let uut +let sandbox +let ctx + +const mockContext = require('../../unit/mocks/ctx-mock').context + +describe('Users', () => { + let testUser = {} + + 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 + }) + + // Delete all previous users in the database. + await testUtils.deleteAllUsers() + + // 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() + + // Mock the context object. + ctx = mockContext() + }) + + afterEach(() => sandbox.restore()) + + after(() => { + mongoose.connection.close() + }) + + describe('#POST /users', () => { + it('should return 422 status on biz logic error', async () => { + try { + await uut.createUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + // console.log(err) + assert.equal(err.status, 422) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should return 200 status on success', async () => { + ctx.request.body = { + user: { + email: 'test02@test.com', + password: 'test', + name: 'test02' + } + } + + await uut.createUser(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + + // Assert that expected properties exist in the returned data. + assert.property(ctx.response.body, 'user') + assert.property(ctx.response.body, 'token') + + // Used by downstream tests. + testUser = ctx.response.body.user + // console.log('testUser: ', testUser) + }) + }) + + describe('GET /users', () => { + it('should return 422 status on arbitrary biz logic error', async () => { + try { + // Force an error + sandbox + .stub(uut.userLib, 'getAllUsers') + .rejects(new Error('test error')) + + await uut.getUsers(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.equal(err.status, 422) + assert.include(err.message, 'test error') + } + }) + + it('should return 200 status on success', async () => { + await uut.getUsers(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + + // Assert that expected properties exist in the returned data. + assert.property(ctx.response.body, 'users') + }) + }) + + describe('GET /users/:id', () => { + it('should return 422 status on arbitrary biz logic error', async () => { + try { + // Force an error + sandbox.stub(uut.userLib, 'getUser').rejects(new Error('test error')) + + await uut.getUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.equal(err.status, 422) + assert.include(err.message, 'test error') + } + }) + + it('should return 200 status on success', async () => { + // Mock dependencies + sandbox.stub(uut.userLib, 'getUser').resolves({ _id: '123' }) + + await uut.getUser(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + + // Assert that expected properties exist in the returned data. + assert.property(ctx.response.body, 'user') + }) + + it('should return other error status passed by biz logic', async () => { + try { + // Mock dependencies + const testErr = new Error('test error') + testErr.status = 404 + sandbox.stub(uut.userLib, 'getUser').rejects(testErr) + + await uut.getUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + assert.equal(err.status, 404) + assert.include(err.message, 'test error') + } + }) + }) + + describe('PUT /users/:id', () => { + it('should return 422 if no input data given', async () => { + try { + await uut.updateUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + // console.log(err) + assert.equal(err.status, 422) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should return 200 on success', async () => { + // Prep the testUser data. + // console.log('testUser: ', testUser) + testUser.password = 'password' + delete testUser.type + + // Replace the testUser variable with an actual model from the DB. + const existingUser = await User.findById(testUser._id) + + ctx.body = { + user: existingUser + } + ctx.request.body = { + user: testUser + } + + await uut.updateUser(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + + // Assert that expected properties exist in the returned data. + assert.property(ctx.response.body, 'user') + }) + }) + + describe('DELETE /users/:id', () => { + it('should return 422 if no input data given', async () => { + try { + await uut.deleteUser(ctx) + + assert.fail('Unexpected result') + } catch (err) { + // console.log(err) + assert.equal(err.status, 422) + assert.include(err.message, 'Cannot read property') + } + }) + + it('should return 200 status on success', async () => { + // Replace the testUser variable with an actual model from the DB. + const existingUser = await User.findById(testUser._id) + + ctx.body = { + user: existingUser + } + + await uut.deleteUser(ctx) + + // Assert the expected HTTP response + assert.equal(ctx.status, 200) + }) + }) +}) diff --git a/test/utils/test-utils.js b/test/utils/test-utils.js new file mode 100644 index 0000000..31b26b0 --- /dev/null +++ b/test/utils/test-utils.js @@ -0,0 +1,170 @@ +/* + Utility functions used to prepare the environment for tests. +*/ + +// Public NPM libraries +const mongoose = require('mongoose') +const axios = require('axios').default + +// Local libraries +const config = require('../../config') +const User = require('../../src/models/users') + +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() + } + } +} + +// Delete all users in the database. This ensures there is no previous state +// to confuse tests. +async function deleteAllUsers () { + try { + // Get all the users in the DB. + const users = await User.find({}, '-password') + // console.log(`users: ${JSON.stringify(users, null, 2)}`) + + // Delete each user. + for (let i = 0; i < users.length; i++) { + const thisUser = users[i] + await thisUser.remove() + } + } catch (err) { + console.error('Error in test-utils.js/deleteAllUsers()') + } +} + +// 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, + name: userObj.name + } + } + } + + 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, + name: 'admin' + } + } + + 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, + deleteAllUsers +}