Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 4.

Mehdi JaiMehdi Jai
13 min read

Testing

We will implement End-to-End testing with Jest to test our API. We will test the email tokens as well, we will need those tokens to be able to test the API completely. For the testing we will use:

  • Jest: Testing package

  • nodemailer-mock: Testing email

  • supertest: Testing the Express API endpoints

  • cheerio: Parsing the email HTML and getting the tokens

Let's install and set them up

Installing dependencies

npm i cheerio supertest
npm i -D @types/supertest jest nodemailer-mock ts-jest

Setup and configurations

Jest Configurations

Create a file in the root directory named jest.config.js

/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
  testEnvironment: 'node',
  transform: {
    '^.+.tsx?$': ['ts-jest', {}],
  },
  preset: 'ts-jest',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverage: true,
  collectCoverageFrom: ['src/**/*.ts'],
  coveragePathIgnorePatterns: ['node_modules', '__tests__'],
  coverageReporters: ['json-summary', 'html'],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
  testMatch: ['**/__tests__/**/*.test.ts'],
  testPathIgnorePatterns: ['node_modules', 'dist', 'prisma'],
  verbose: true,
  forceExit: true,
  clearMocks: true,
  resetMocks: true,
  restoreMocks: true,
  resetModules: true,
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
  modulePathIgnorePatterns: ['<rootDir>/dist/'],
  testTimeout: 30000,
};

create two folders in src; one for nodemail-mock named __mocks__, and one for the test files __tests__

Test API Status

Inside __tests__ create a file named api.test.ts. In this file we will create a jest suit to test the API status and check if our server is working.

// src/__tests__/api.test.ts

import request from 'supertest';
import app from '@/app';
import appConfig, { parseAPIVersion } from '@/config/app.config';
import HttpStatusCode from '@/utils/HTTPStatusCodes';

describe('Test the API Status', () => {
  test('It should response the root GET method', async () => {
    // Send a GET request using supertest
    const response = await request(app).get(parseAPIVersion(1));
    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    // Validating the response body
    expect(response.body.name).toEqual(appConfig.apiName);
    expect(response.body.version).toEqual(appConfig.apiVersion);
    expect(response.body.status).toEqual('RUNNING');
  });
});

Testing Auth System

This test suite is large, we will go through it and explain it bit by bit, and I will link the file link to GitHub at the end. Before we start, Let's create the mail mock and some other functions we will use.

Mail mock

inside __mocks__ create a file named nodemailer.ts.

import nodemailer from 'nodemailer';
// pass it in when creating the mock using getMockFor()
const nodemailermock = require('nodemailer-mock').getMockFor(nodemailer);
// export the mocked module
module.exports = nodemailermock;

This will mock the nodemailer and the emails we send in the API.

Email Token reader

This function will parse the Email HTML and get us the token. Create a file inside utils named mailerUtils.ts

import { load } from 'cheerio';

export function getTokenFromMail(html: string) {
  const $ = load(html);
  // We have added an id in the a tags before. We use it to get the links and parse the token.
  const link = $('#token-link').attr("href")
  const parts = link.split('/')
  return parts[parts.length - 1]
}

Truncate DB

After testing, we will empty the DB. That's the purpose of this function; to truncate all the tables. Now, create a file inside utils named truncateDB.ts

Note: this function only works for Postgres.

import prisma from '@/services/prisma.service';
import { logger } from './winston';

export async function truncateAllTables() {
  // truncate only in TEST mode
  if (process.env.NODE_ENV === 'production' || process.env.STAGE !== 'TEST') {
    throw new Error('This function can only be used in test environment');
  }

  try {
    // Disable triggers
    await prisma.$queryRaw`SET session_replication_role = 'replica';`;
    // Get all table names
    const tables = await prisma.$queryRawUnsafe(`
        SELECT tablename FROM pg_tables WHERE schemaname = 'public';
      `);
    // Truncate each table
    for (const { tablename } of tables as { tablename: string }[]) {
      await prisma.$queryRawUnsafe(`TRUNCATE TABLE ${tablename} CASCADE;`);
    }
    // Re-enable triggers
    await prisma.$queryRawUnsafe(`SET session_replication_role = 'origin';`);
    logger.info('All tables truncated successfully');
  } catch (error) {
    logger.error('Error truncating tables:', error);
  } finally {
    await prisma.$disconnect();
  }
}

Now we are all set to start working on auth.test.ts. Create this file in __tests__. We create a test suite for the Auth system. And after the suite has finished, we truncate the DB.

We start by creating the initial user payload. I created it outside the test functions to make global scope for all the suit. We will add the tokens to it and use them across the tests inside this suite.

In this test suite, we have 23 test. I tried to test all the use cases as possible, And that's the role of testing.

import request from 'supertest';
import app from '@/app';
import appConfig, { parseAPIVersion } from '@/config/app.config';
import { truncateAllTables } from '@/utils/truncateDB';
import HttpStatusCode from '@/utils/HTTPStatusCodes';
import wait from '@/utils/helpers';
import * as nodemailer from 'nodemailer';
import { NodemailerMock } from 'nodemailer-mock';
import { getTokenFromMail } from '@/utils/mailerUtils';
const { mock } = nodemailer as unknown as NodemailerMock;
import prisma from '@/services/prisma.service';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';

describe('Test Auth system', () => {
  const baseRoute = parseAPIVersion(1) + '/auth';
  afterAll(async () => {
    await truncateAllTables();
  });

  const userPayload: any = {
    name: 'Mehdi Jai',
    phone: '+212610010830',
    email: 'mjai@doctime.ma',
    password: '12345678',
    type: 'DOCTOR',
  };

});

Now, we have the skeleton of the Auth Test, Let's add tests one by one. Starting by creating a user.

Test Creating a user

We have 3 tests for creating a user. Simple and correct one, with email conflict, and validating user email.

Test Create User:

test('Test Create User', async () => {
    const response = await request(app)
      .post(baseRoute + '/register')
      .send(userPayload)
      .set('Accept', 'application/json');

    // Getting the emails sent.
    const sentEmails = mock.getSentMail();

    // Getting only the emails with "Verify Email" subject.
    const verifyEmail = sentEmails.find((email) => email.subject === 'Verify Email');

    // Testing if the Verification email is sent and exist
    expect(verifyEmail).toBeDefined();

    // Getting the token and adding it to the User Payload for next use
    userPayload.verificationToken = getTokenFromMail(verifyEmail.html.toString());

    // Validating the response body
    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.id).toBeDefined();
    expect(response.body.data.email).toEqual(userPayload.email);
    expect(response.body.data.userType).toEqual(userPayload.type);

    // Adding the userId to UserPayload
    userPayload.userId = response.body.data.id;
  });

We can move the email testing to another function, and call it. Create a function outside the scope of the test suite:

function testEmails(subject: string) {
  const sentEmails = mock.getSentMail();
  const email = sentEmails.find((email) => email.subject === subject);
  expect(email).toBeDefined();
  const token = getTokenFromMail(email.html.toString());
  expect(token).toBeDefined();
  return token;
}

Now update the test:

test('Test Create User', async () => {
    // ...
    const response = await request(app)
      .post(baseRoute + '/register')
      .send(userPayload)
      .set('Accept', 'application/json');

    userPayload.verificationToken = testEmails('Verify Email');
    // ...
    expect(response.status).toBe(HttpStatusCode.OK);
});

Test Create user with email conflict:

test('Test Create User email conflict', async () => {
    // We just re-send the same request, the user has already been created, therefore, this request will trigger Email Conflict
    const response = await request(app)
      .post(baseRoute + '/register')
      .send(userPayload)
      .set('Accept', 'application/json');

    // Validate the response
    expect(response.status).toBe(HttpStatusCode.CONFLICT);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.CONFLICT);
  });

Test Validate user email:

test('Test verify User', async () => {
    // We got the token prviously and stored it. Now we use it
    const response = await request(app)
      .post(baseRoute + '/verify-user')
      .send({
        token: userPayload.verificationToken,
      })
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);

    // Verify if the User has been updated
    const user = await prisma.user.findUnique({
      where: {
        id: userPayload.userId,
      },
    });

    expect(user).toBeDefined();
    expect(user.verifiedEmail).toEqual(true);
  });

Test Login

// Test Login process
test('Test Login', async () => {
    const loginPayload = {
      email: userPayload.email,
      password: userPayload.password,
      type: userPayload.type,
    };

    // Call the API
    const response = await request(app)
      .post(baseRoute + '/login')
      .send(loginPayload)
      .set('Accept', 'application/json');

    // Validate response
    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();

    // Validate reponse body
    expect(response.body.data.accessToken).toBeDefined();
    expect(response.body.data.accessToken.token).toBeDefined();
    expect(response.body.data.accessToken.refreshToken).toBeDefined();
    expect(response.body.data.user).toBeDefined();
    expect(response.body.data.user.id).toBeDefined();
    expect(response.body.data.user.id).toEqual(userPayload.userId);
    expect(response.body.data.user.email).toEqual(userPayload.email);
    expect(response.body.data.user.userType).toEqual(userPayload.type);

    // Store the tokens
    userPayload.accessToken = response.body.data.accessToken.token;
    userPayload.refreshToken = response.body.data.accessToken.refreshToken;
});

test('Test Login none existing email', async () => {
    // Testing the case of wrong email
    const loginPayload = {
      email: 'other-mail@mail.com',
      password: userPayload.password,
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/login')
      .send(loginPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.message).toEqual('Credentials Error');
});

test('Test Login wrong password', async () => {
    const loginPayload = {
      email: userPayload.email,
      password: userPayload.password + '78',
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/login')
      .send(loginPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.message).toEqual('Password not match');
});

// Test Authentication
test('Test unauthenticated user', async () => {
    // Send a request to the protected route we created in route index
    // We send request without the authorization header
    const response = await request(app)
      .get(parseAPIVersion(1) + '/protected')
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
});

test('Test authenticated user', async () => {
    // We send the authorization header
    const response = await request(app)
      .get(parseAPIVersion(1) + '/protected')
      .set('Authorization', 'Bearer ' + userPayload.accessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body.protected).toEqual(true);
});

Testing Refresh Token

test('Test refresh token', async () => {
    // Sending the stored refresh token
    const payload = {
      refreshToken: userPayload.refreshToken,
    };
    // Updating the Expiration period. The default one is 15 days.
    appConfig.jwt.expiresIn = '2s';

    const response = await request(app)
      .post(baseRoute + '/refresh-token')
      .send(payload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.accessToken).toBeDefined();
    expect(response.body.data.refreshToken).toBeDefined();

    // Storing the new token
    userPayload.newAccessToken = response.body.data.accessToken;
    userPayload.newRefreshToken = response.body.data.refreshToken;
});

test('Test new token', async () => {
    const response = await request(app)
      .get(parseAPIVersion(1) + '/protected')    
      .set('Authorization', 'Bearer ' + userPayload.newAccessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body.protected).toEqual(true);
});

test('Test token expiration', async () => {
    // The new token expires in 2s, so we wait 3s to test it's expiration
    await wait(3000);
    const response = await request(app)
      .get(parseAPIVersion(1) + '/protected')
      .set('Authorization', 'Bearer ' + userPayload.newAccessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
});

Testing forgetting password

Note:mock.getSentMail() returns all the emails send in this instance.

test('Test forget password', async () => {
    const response = await request(app)
      .post(baseRoute + '/forget-password')
      .send({
        email: userPayload.email,
        type: userPayload.type,
      })
      .set('Accept', 'application/json');

    userPayload.forgotPasswordToken = testEmails('Reset Password');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);
});

test('Test forget password wrong email', async () => {
    const response = await request(app)
      .post(baseRoute + '/forget-password')
      .send({
        email: 'fake-' + userPayload.email,
        type: userPayload.type,
      })
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.NOT_FOUND);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.NOT_FOUND);
});

test('Test reset password wrong token', async () => {
    const response = await request(app)
      .post(baseRoute + '/reset-password')
      .send({
        newPassword: userPayload.password,
        token: uuidv4(),
      })
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.FORBIDDEN);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.FORBIDDEN);
    expect(response.body.error.message).toEqual('Invalid or expired token');
});

Testing Reset password

test('Test reset password', async () => {
    // Create and switch the password
    userPayload.oldPassword = userPayload.password;
    userPayload.password = '123456789';

    const response = await request(app)
      .post(baseRoute + '/reset-password')
      .send({
        newPassword: userPayload.password,
        token: userPayload.forgotPasswordToken,
      })
      .set('Accept', 'application/json');

    // Validate the response
    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);

    // Test if the password is updated in DB
    const user = await prisma.user.findUnique({
      where: {
        id: userPayload.userId,
      },
    });
    expect(user).toBeDefined();
    const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
    expect(isValidPassword).toEqual(true);
});

test('Test old password', async () => {
    // test login with the old password
    const loginPayload = {
      email: userPayload.email,
      password: userPayload.oldPassword,
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/login')
      .send(loginPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.message).toEqual('Password not match');
});

test('Test new password', async () => {
    // Test login with the new password
    const loginPayload = {
      email: userPayload.email,
      password: userPayload.password,
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/login')
      .send(loginPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
});

Test Update password

test('Test update password -- Wrong password', async () => {
    // Sending wrong password
    const updatePasswordPayload = {
      oldPassword: userPayload.oldPassword,
      newPassword: userPayload.password,
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/update-password')
      .send(updatePasswordPayload)
      .set('Authorization', 'Bearer ' + userPayload.accessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.UNAUTHORIZED);
    expect(response.body.error.message).toEqual('Invalid old password');
});

test('Test update password -- password characters not enough', async () => {
    // Sending invalid password. The min password charachters is 8
    const updatePasswordPayload = {
      oldPassword: userPayload.password,
      newPassword: '1234',
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/update-password')
      .send(updatePasswordPayload)
      .set('Authorization', 'Bearer ' + userPayload.accessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNPROCESSABLE_ENTITY);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.UNPROCESSABLE_ENTITY);
});

test('Test update password -- unauthorized', async () => {
    // Sending the request without the authorization header
    const updatePasswordPayload = {
      oldPassword: userPayload.password,
      newPassword: userPayload.oldPassword,
      type: userPayload.type,
    };

    const response = await request(app)
      .post(baseRoute + '/update-password')
      .send(updatePasswordPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeUndefined();
    expect(response.body.error).toBeDefined();
    expect(response.body.error.code).toEqual(HttpStatusCode.UNAUTHORIZED);
});

test('Test direct update password', async () => {
    // disabling updatePasswordRequireVerification to update password without email confirmation
    appConfig.updatePasswordRequireVerification = false;
    const updatePasswordPayload = {
      oldPassword: userPayload.password,
      newPassword: userPayload.oldPassword,
      type: userPayload.type,
    };

    userPayload.password = userPayload.oldPassword;
    const response = await request(app)
      .post(baseRoute + '/update-password')
      .send(updatePasswordPayload)
      .set('Authorization', 'Bearer ' + userPayload.accessToken)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);

    const user = await prisma.user.findUnique({
      where: {
        id: userPayload.userId,
      },
    });

    expect(user).toBeDefined();
    const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
    expect(isValidPassword).toEqual(true);
});

Testing update password with email confirmation:

test('Test confirming update password', async () => {
    // re-activating updatePasswordRequireVerification 
    appConfig.updatePasswordRequireVerification = true;
    const updatePasswordPayload = {
      oldPassword: userPayload.password,
      newPassword: userPayload.oldPassword,
      type: userPayload.type,
    };

    userPayload.password = userPayload.oldPassword;

    const response = await request(app)
      .post(baseRoute + '/update-password')
      .send(updatePasswordPayload)
      .set('Authorization', 'Bearer ' + userPayload.accessToken)
      .set('Accept', 'application/json');

    // Get the confimation token
    userPayload.updatePasswordToken = testEmails('Update Password');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);
});

test('Test confirm update password', async () => {
    // Send the confirmation token
    const confirmUpdatePasswordPayload = {
      token: userPayload.updatePasswordToken,
    };

    const response = await request(app)
      .post(baseRoute + '/confirm-update-password')
      .send(confirmUpdatePasswordPayload)
      .set('Accept', 'application/json');

    expect(response.status).toBe(HttpStatusCode.OK);
    expect(response.body).toBeDefined();
    expect(response.body.data).toBeDefined();
    expect(response.body.error).toBeUndefined();
    expect(response.body.data.status).toEqual(true);

    // Verifying if the password has changed
    const user = await prisma.user.findUnique({
      where: {
        id: userPayload.userId,
      },
    });

    expect(user).toBeDefined();
    const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
    expect(isValidPassword).toEqual(true);
});

You will find all these functions in one file here

Run testing

npm run test

All tests passed successfully!

Postman Collection

We created the tests for our auth system. And we can use Postman to test even further. I created a postman collection to test these APIs. I might deploy the API and add the endpoint to this Postman's collection. So you can test the API online as a DEMO.

https://documenter.getpostman.com/view/13363083/2sA3kYjLAb

Summary

This article was a rich documentation of creating a real API, this API is a part of a real project I'm working on. We can add more features to this API. Such as MFA, 2FA, SMS and OTPs, Handlebars for beautiful HTML Email UIs... We might work on them in another article in the future. Following the Kaizen approach, we can add small improvement in each step.

See you in the next one 💪


0
Subscribe to my newsletter

Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mehdi Jai
Mehdi Jai

Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.