Merge pull request #99 from christroutner/ct-better-tests

Major refactor of tests
This commit is contained in:
Chris Troutner
2021-03-28 14:22:29 -08:00
committed by GitHub
25 changed files with 1934 additions and 120 deletions
+7 -4
View File
@@ -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",
+4 -1
View File
@@ -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
+3 -4
View File
@@ -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 = `<p>${emailUser} would like to share the document <b>${payload}</b> with you through
<a href="https://launchpadip.net">LaunchpadIP.net</a>.
</p>`
const paragraphTag = 'This is a test email'
const htmlMsg = `<h3>${subject}:</h3>
${payload === 'Email reset' ? '' : paragraphTag}
+150
View File
@@ -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
+1
View File
@@ -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.
+56 -108
View File
@@ -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)
}
}
+7
View File
@@ -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).
+119
View File
@@ -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
}
})
})
})
+786
View File
@@ -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)
})
})
})
+8 -3
View File
@@ -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.
+3
View File
@@ -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.
+357
View File
@@ -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!')
})
})
})
+9
View File
@@ -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?
+254
View File
@@ -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)
})
})
})
+170
View File
@@ -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
}