Testing with Jest: Password Authentication in a Mongoose Model

Testing with Jest: Password Authentication in a Mongoose Model

User authentication is an important criteria for almost all real-world web applications. It is a common and essential mechanism for not only protecting user data, but it can also help personalize user experience. In this article, we will add basic password authentication to a User Model implemented using Mongoose in a Node and MongoDB application. In addition, we will use Jest to write tests for this implementation.

The complete code for the example discussed can be found at github.com/shamahoque/mongoose-user-model-testing

Building a Node and MongoDB project with Jest Integration

Before getting started with this example, make sure you have Node installed, and that MongoDB is installed and running. Then, initialize the project code by creating your project folder, and running npm init from the command line. This will create a package.json file in the folder. Next, we will set up Mongoose to implement a user model, and Jest to start writing test code.

Mongoose Model

Before we implement the user model, we need to install the mongoose npm module by running the command:

npm install --save mongoose

We will use Mongoose to start with a simple user model, which will be defined and exported from a file called user.model.js in the project folder. The initial version of the user schema for this model will only contain a username and an email field.

// File: user.model.js
const mongoose = require ( 'mongoose' )

const UserSchema = new mongoose.Schema ({
  username: {
    type: String,
    required: "username is required"
  },
  email: {
    type: String,
    unique: 'email already exists',
    match: [ /. +\@. +\.. + /, 'Please give a valid email address' ]
  }
})

module.exports = mongoose.model ( 'User', UserSchema )

Besides defining the value type for each field, we added built-in validators to make the username a required field in the schema, and to ensure the email value is unique and also matches a valid email pattern. In the rest of this example, we will extend this initial version of the User Model to add password authentication.

Testing with Jest

We will use Jest, a JavaScript Testing Framework, to write and run our tests for the password authentication implementation. We add Jest by installing the jest npm module as a development dependency.

npm install --save-dev jest

Next, we will add a test script to package.json, so we can run tests from the command line.

// File: package.json
"scripts": {
  "test": "jest"
},

The tests will run with the command npm run test, and display the test outcomes.

Jest Tests

By default, Jest assumes that the tests will run in a browser environment, but since our tests will be running in the node environment, we need to configure Jest to specify this. In a jest.config.js file, we will add the following configuration:

// File: jest.config.js
module.exports = {
  testEnvironment: 'node'
}

Finally, we will create a __tests__ folder in the project directory and add a user.model.test.js file which will contain User Model specific tests. When we run Jest, it will look in this folder for tests to execute.

Connecting to MongoDB Before Running Tests

Before writing and executing specific test cases for the User Model implementation, we will use Mongoose to connect to MongoDB as follows:

// File: __tests__/user.model.test.js
const mongoose = require( 'mongoose' )
mongoose.Promise = global.Promise
mongoose.connect ( 'mongodb://localhost/testUser', {
    useNewUrlParser: true
})
mongoose.connection.on( 'error', () => {
  throw new Error(`unable to connect to database: `)
})

With the MongoDB connection ready, we can access and modify the User collection in the tests to check our implementation of the Mongoose User Model. We will also add code in an afterAll block, which will close the established database after all the tests are run.

// File: __tests__/user.model.test.js
afterAll( async () => {
  try {
    await mongoose.connection.close()
  } catch (err) {
    console.log(err)
  }
})

Test Block for User Password Authentication

We will use a describe block to group the test cases for the password authentication implementation in the User Model.

// File: __tests__/user.model.test.js
const User = require ( './../user.model.js' )

describe ( "User Password Authentication", () => {
    // test cases
})

Now that a Node app is initialized, testing with Jest is set up, MongoDB is connected, and a simple User model is already implemented, next we can look into how to add password authentication.

Implementing and Testing User Password Authentication

While implementing user password authentication, we never directly store the plain-text password value entered by the user when they add a new password. Instead we generate a salted password hash of the plain-text input, and store the generated hash along with the corresponding unique salt. Then, when we need to authenticate a user sign-in attempt, we regenerate the hash with the user input and compare it against the stored value.

In this section, we will see how to use this salted password hashing mechanism to enable user authentication with the User Model.

Hashing a Password

Hashing should enable us to protect a user password, and store it securely, while also letting us verify that a user's password is correct. Hashing algorithms will generate the same hash for the same input value, but we should ensure two users don’t end up with the same hashed password value. Salted password hashing can help in this regard.

Generating the hash with a unique salt value for every new password will make sure the hashes are unique even if the underlying plain-text value is the same. This will also make it difficult to guess the hashing algorithm being used, because the same user input is seemingly generating different hashes.

Generating a Salt

We will start by implementing a salt generation function for the User Model, and add it as a static method to the User Schema. Using a static method in this example, also makes it easier to call and test it independently in the test code as we will see later.

// File: user.model.js
UserSchema.statics.generateSalt = function() {
  return Math.round((new Date().valueOf() * Math.random())) + ''
}

We use the current timestamp at execution and Math.random() to make the returned salt value unique and random. This static method can be used in the User Model when a new password is added, to first create the salt, and then use it to generate the salted password hash.

Generating a Hash

We will add another static method to the User Schema, which will take the plain-text password and generated salt, then create and return the corresponding hash of these values. We will use the crypto module in Node, which provides a range of cryptographic functionality including some standard cryptographic hashing algorithms.

// File: user.model.js
const crypto = require ( 'crypto' )
...
UserSchema.statics.generateHash = function(password, salt) {
  try {
    const hmac = crypto.createHmac('sha1',salt)
    hmac.update(password)
    return hmac.digest('hex')
  } catch (err) {
    return err
  }
}

For this example, we use the SHA1 hashing algorithm, and crypto’s createHmac to generate the cryptographic HMAC hash from the password text and provided salt pair.

Testing Hash Generation

Let’s test this hashing implementation by adding a test case to the User Password Authentication test block. In this test, we will check whether the same hash is generated given the same password text and the same salt value on each invocation of the generateHash method.

// File: __tests__/user.model.test.js
  it("should generate the same hash given the same password text and salt", () => {
    try {
      let salt = User.generateSalt()
      let hash = User.generateHash("qwer213", salt)
      expect(hash).toEqual(User.generateHash("qwer213", salt))
    }
    catch (err) {
      throw new Error(err)
    }
  })

You can also add a test to check if different hashes are generated for the same plain-text password but with different salt values.

With these tests passing successfully, we can use this hashing implementation to authenticate a user provided password against stored hashed password and salt values.

Storing User Password

As the plain-text password won’t be stored directly in the database, we will update the User Schema in the model to store the hashed password and salt values instead.

// File: user.model.js
...
hashed_password: {
    type: String,
    required: true
},
salt: String
...

When a new password is received to be stored in the database, the UserSchema will map the plain-text password to the hashed_pasword field by generating the salt and related hash, then setting these values in the corresponding fields.

// File: user.model.js
UserSchema
  .virtual('password')
  .set(function(password) {
    this._password = password
    this.salt = this.model('User').generateSalt()
    this.hashed_password = this.model('User').generateHash(password, this.salt)
  })
  .get(function() {
    return this._password
  })

In the User Schema, we also set the plain-text password to a temporary virtual password property - a property which is not stored in the database. But it can be used to add custom validation logic for passwords in the User Model.

Testing Password Storage

In order to ensure all users are stored with hashed_password and salt properties, we can write a test to check if these property keys are present when a new user is created.

// File: __tests__/user.model.test.js
  it("should save a user with hashed_password and salt attributes", async () => {
    try {
        let result = await new User({ username: "sam", email: "sam@ed.info", password: 'qwer213'}).save()
        expect(Object.keys(result._doc)).toEqual(expect.arrayContaining( ['salt', 'hashed_password']))
    }
    catch (err) {
        throw new Error(err)
    }
  })

In this test, we create a new user with username, email and password values. Then we check if the saved user object contains the salt and hashed_password keys.

We will also add an afterEach block to the test suite, which will delete all the documents in the User collection after each test, so the same user is not created multiple times when we save new users in other tests or re-run the tests.

// File: __tests__/user.model.test.js
afterEach(async () => {
  try {
    await User.deleteMany({})
  } catch (err) {
    console.log(err)
  }
})

Validating a Password

We can add validation criteria to new password texts provided by the user to ensure it is secure. In this example, we want the password text to not be empty, and be at least 6 characters in length.

// File: user.model.js
UserSchema.path('hashed_password').validate(function(v) {
  if (this._password && this._password.length < 6) {
    this.invalidate('password', 'Password must be at least 6 characters.')
  }
  if (this.isNew && !this._password) {
    this.invalidate('password', 'Password is required')
  }
}, null)

In this custom validation code, which is added against the hashed_password field, we invalidate the entry unless a password value is received, as it is a required field, and also if the received password text is less than 6 characters in length.

Testing Password Validation

We can test this custom validation implementation by writing tests which create new users with valid and invalid password values.

// File: __tests__/user.model.test.js
it("should throw an error if the password value is empty", async () => {
    try {
      await new User({
        username: "sam",
        email: "sam@ed.info",
        password: ""
      }).save()
    } catch (err) {
      expect(err.errors.password.message).toEqual("Password is required")
    }
  })

  it("should throw an error if password length is less than 6", async () => {
    try {
      await new User({
        username: "sam",
        email: "sam@ed.info",
        password: "123"
      }).save()
    } catch (err) {
      expect(err.errors.password.message).toEqual("Password must be at least 6 characters.")
    }
  })

The test cases above try to create a user with an empty password and then with a password containing less than 6 characters. In both cases, the User Model should return the appropriate error messages.

Authenticating user password

With password validation, encryption, and storage already taken care of, we can now add an authenticate function to the User Model, that can be called to authenticate a user password when an existing user attempts to sign in.

// File: user.model.js
UserSchema.statics.authenticate = function(given_password, hashed_password, salt) {
  return UserSchema.statics.generateHash(given_password, salt) === hashed_password
}

This static authenticate method simply takes the plain-text password provided by the user, for example when an existing user is trying to sign in, and also takes the corresponding user’s hashed_password and salt values already stored in the database. Then, compares the stored hashed_password against a hash generated with the given password and stored salt. If the hashes match, then the user is authenticated, otherwise the given password is wrong.

Testing Authentication for Correct and Wrong Passwords

We can test this authentication implementation by creating a new user and then checking if the authenticate method catches a wrong password entry when we call it with the same user’s hashed_password and salt values.

// File: __tests__/user.model.test.js
  it("should throw an error if authentication is given a wrong password", async () => {
    try {
      await new User({ username: "sam", email: "sam@ed.info", password: 'qwer213'}).save()
      let result = await User.findOne({ email: "sam@ed.info" })
      let wrongPassword = "123456"
      let auth = User.authenticate(wrongPassword, result.hashed_password, result.salt)
      expect(auth).toEqual(false)
    }
    catch (err) {
      throw new Error(err)
    }
  })

We can also write a similar test to check the opposite - whether the right password entry is matched correctly in the authenticate method.

// File: __tests__/user.model.test.js
  it("should authenticate successfully if given correct password", async () => {
    try {
      await new User({ username: "sam", email: "sam@ed.info", password: 'qwer213'}).save()
      let result = await User.findOne({ email: "sam@ed.info" })
      let rightPassword = "qwer213"
      let auth = User.authenticate(rightPassword, result.hashed_password, result.salt)
      expect(auth).toEqual(true)
    }
    catch (err) {
      throw new Error(err)
    }
  })

This wraps up our example of how to implement and test basic password authentication in a Mongoose Model. With this example, we worked through:

  • Setting up a Node, Mongoose and MongoDB application with Jest integration for testing
  • Implementing salted password hashing using the Crypto module in Node
  • Storing hashed_password and salt instead of the plain-text password
  • Adding custom validation for the plain-text password using a virtual property in the schema
  • Authenticating a user by comparing the given password’s hash with the already stored hash and salt
  • Writing test cases to run and test these auth related features and implementations

Both the authentication implementation and test scenarios demonstrated in this example can be extended further to cover more advanced authentication concepts and features.

About the Author
Shama Hoque

Shama Hoque

Shama Hoque is a software developer, author, and mentor with more than 9 years of experience. Currently, she makes web-based prototypes for R&D startups in California, while training aspiring software engineers and teaching web development to CS undergrads in Bangladesh. She is the author of Packt's Full-Stack React Projects book.

The Web Dev Monthly

Sign up for a free monthly scoop of news and features articles handpicked by our staff.

Unsubscribe at any time. No hidden catch.