DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Setup express with Typescript - testing and security middleware

Introduction

In this series we will setup an express server using Typescript, we will be using TypeOrm as our ORM for querying a PostgresSql Database, we will also use Jest and SuperTest for testing. The goal of this series is not to create a full-fledged node backend but to setup an express starter project using typescript which can be used as a starting point if you want to develop a node backend using express and typescript.

Overview

This series is not recommended for beginners some familiarity and experience working with nodejs, express, typescript and typeorm is expected. In this post which is part seven of our series we will : -

  • Set up jest and supertest.
  • Setup security middlewares.
  • Setup pino logging.
  • Setup import aliases (optional)

Step One: Setup jest and supertest

In this section we will setup testing. This is how I do it. You might use some other configurations and might have other setups, please feel free to share your thoughts. In your terminal lets install the following dependencies -

npm install -D jest @types/jest ts-jest supertest @types/supertest
Enter fullscreen mode Exit fullscreen mode

Now under the root of our project create a file jest.config.js-

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node'
};
Enter fullscreen mode Exit fullscreen mode

Under the root of our project create a new folder tests and under it create a new file todos.test.ts, and add a simple test

describe('test todos endpoint with db connection', () => {
  beforeAll(async () => {
    await AppDataSource.initialize();
  });

  afterAll(async () => {
    // await AppDataSource.createEntityManager().query(
    //   'truncate table todos cascade'
    // );
    await AppDataSource.destroy();
  });

  it('should fetch todos successfully', async () => {
    const response = await supertest(expressApp).get('/api/todos');
    expect(response.statusCode).toBe(200);
    expect(response.body).toHaveProperty('todos');
  });

  it('should create a todo successfully', async () => {
    const response = await supertest(expressApp).post('/api/todos').send({
      text: 'setup testing',
      status: 'done',
    });
    expect(response.statusCode).toBe(201);
    expect(response.body).toHaveProperty('todo');
  });
});
Enter fullscreen mode Exit fullscreen mode

I hope you remember from our first part, that we had our express app initialized in server.ts and under app.ts we started our server. And with this separation we can easily import our express app with all its routes in our tests. The above test is an integration test not an unit test. I prefer integration testing for backend apis, here we can test a lot of different scenarios with real-data and I don't have to mock a lot of stuff, like mocking middlewares, controllers, etc.

In our integration test, as you might have noticed we are connecting to our database, when we run jest our NODE_ENV environment variable is set to test and we pick the test database configuration from our src/config/dbConfig.ts file. Which means we connect to our test database. In our test-db we should have our todos table, we will run our migrations against the test-db. One of the benefits of using migrations as opposed to typeorm's synchronize: true option. In your terminal run -

NODE_ENV=test npm run migration:run
Enter fullscreen mode Exit fullscreen mode

We can also programatically run typeorm's migrations in the beforeAll call - say for each endpoint, we can run migration for each table. You might have also noticed a truncate call in afterAll, this is very handy when you want to get rid of your testing data, but only when you are testing locally. If your tests run in a CI environment it might cause problems, when in your team multiple developers are running CI tests simultaneously. I personally use RDS, therefore I have a lambda that re-creates my test-db periodically. Lets test our function in terminal run -

npx jest
Enter fullscreen mode Exit fullscreen mode

But what if you want to run a unit test without this db connection, lets do it I will show you one unit test where we will mock our service call. I hope you remember from our previous tutorials, we discussed that we should have our database calls in separate service files as opposed to having them in our controller functions. It has a lot of benefits, one being testing them, mocking becomes very easy. In your tests/todo.test.ts -

const getTodoByIdMock = jest
  .spyOn(TodosService.prototype, 'getTodoById')
  .mockImplementation(async () => {
    return {} as Todos;
  });

describe('test todos endpoint with mocks', () => {
  it('should fetch todo successfully', async () => {
    const response = await supertest(expressApp).get(
      '/api/todos/0d853566-fe0d-11ec-92c2-0214694f2400'
    );
    expect(response.statusCode).toBe(200);
    expect(response.body).toHaveProperty('todo');
    expect(getTodoByIdMock).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Given the fact that our services are classes I used jest's spyOn method and returned an empty todo. You can check jest's docs on how to mock a class method. Given that we are mocking our database call we don't need a database connection for our test suite. Here you can unit test the getTodoById function.

Step Two: setup security middlewares

There are some middleware libraries that help secure our express server. First lets install all of them -

npm install cors express-rate-limit helmet hpp
npm install --save @types/cors @types/hpp
Enter fullscreen mode Exit fullscreen mode

Now in your server.ts under function addMiddlewares -

  // configure middlewares for express
  private addMiddlewares() {
    // for parsing application/json
    this.app.use(express.json());
    // for parsing application/x-www-form-urlencoded
    this.app.use(express.urlencoded({ extended: true }));
    // add cors
    this.app.use(cors());
    // add security to the server using helmet middleware
    this.app.use(helmet());
    // protect against HTTP Parameter Pollution attacks
    this.app.use(hpp());
    // add rate limit to the whole server
    this.app.use(
      expressRateLimit({
        windowMs: 15 * 60 * 1000,
        max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
      })
    );
    this.app.set('trust proxy', 1);
  }
Enter fullscreen mode Exit fullscreen mode

Let us understand what each middleware is doing -

  • We use cors for cross-origin, when we are calling our apis from a frontend.
  • helmet helps us secure our express server, it adds some http security headers like xssFilter, contentSecurityPolicy, etc.
  • hpp is used to avoid parameter pollution, check its docs.
  • expressRateLimit is a very useful middleware. Lets say an attacker is sending 100 req/s to your server by running a script. This will slow down your server, make it go out of memory, your clients will see very large response times. To avoid this we use this middleware, basically we limit every Ip to a fixed number of connections over a period of time. Say every connection can only send 100 request to our server in a window of 15 minutes.

You might also notice we this.app.set('trust proxy', 1), this is recommended by express-rate-limiter lib, for environments that have a server proxy. Like a load balancer sitting between the client and our server, in such a case all the request ips we receive are not the client ip but the load balancer's ip and we might end up limiting its requests, therefore we set trust proxy with the number of proxies which is 1 in my case. You can skip it if you don't have any proxies. To get the number of proxies we add a route to our server -

 // configure routes for express
  private addRoutes() {
    // route to know the number of proxies
    this.app.get('/ip', (request, response) => response.send(request.ip));
    this.app.use('/api/todos', todosRouter);
  }
Enter fullscreen mode Exit fullscreen mode

Go to /ip and see the IP address returned in the response. If it matches your IP address (which you can get by going to https://api.ipify.org/), then the number of proxies is correct and the rate limiter should now work correctly. If not, then keep increasing the number until it does.

Step Three: Setup a logger

Loggers are important, when you deploy your application to say AWS EBS, EC2 all your logs are stored in cloudwatch. This comes in handy when you want to inspect your server logs in case of any error. In your terminal run the following -

npm install pino pino-pretty express-pino-logger
npm install --save-dev @types/express-pino-logger
Enter fullscreen mode Exit fullscreen mode

Under src/utils create a new file logger.ts -

import pino from 'pino';

const levels = {
  http: 10,
  debug: 20,
  info: 30,
  warn: 40,
  error: 50,
  fatal: 60,
};

export const logger = pino({
  customLevels: levels,
  useOnlyCustomLevels: true,
  level: 'http',
  transport: {
    target: 'pino-pretty',
    options: {
      colorize: true,
      levelFirst: true,
      translateTime: 'yyyy-dd-mm, h:MM:ss TT',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Now under server.ts lets setup the logger middleware -

import expressPinoLogger from 'express-pino-logger';
// configure middlewares for express
  private addMiddlewares() {
    // ...other middleware setup code
    // add logger middleware
    this.app.use(this.loggerSetup());
  }

  private loggerSetup() {
    return expressPinoLogger({
      logger: logger,
      autoLogging: false,
    });
  }
Enter fullscreen mode Exit fullscreen mode

Now you can use the our logger, you need to import it first -

private startServer() {
    this.server.listen(this.port, async () => {
      logger.info(`Server started on port ${this.port}`);
      try {
        await AppDataSource.initialize();
        logger.info('Database Connected');
      } catch (error) {
        logger.fatal(`Error connecting to Database - ${error}`);
      }
    });
 }
Enter fullscreen mode Exit fullscreen mode

Step Four: Add import aliases

I like to setup import aliases for my projects. First install -

npm install module-alias
npm install --save-dev tsconfig-paths @types/module-alias
Enter fullscreen mode Exit fullscreen mode

Now in the tsconfig.json add the following -

"baseUrl": "./src",
"moduleResolution": "node",
 "paths": {
   "@api/*": ["api/*"],
   "@utils/*": ["utils/*"],
   "@middlewares/*": ["middlewares/*"],
   "@config/*": ["config/*"]
}
Enter fullscreen mode Exit fullscreen mode

We add baseUrl to be "src", meaning all our aliases will be resolved from the src folder.

With the above setup you can start importing your files using aliases -

import { asyncHandler } from '@middlewares/asyncHandler';
import { BaseRouter } from '@utils/BaseRouter';
import {
  validateRequestBody,
  validateRequestParams,
} from '@middlewares/validate';
Enter fullscreen mode Exit fullscreen mode

But there is one caveat this will work in typescript but not in javascript, when we build our project javascript has not idea of @middlewares/validate. To use aliases in javascript we use module-alias. Create a new file under src called path.ts -

import * as moduleAlias from 'module-alias';

moduleAlias.addAliases({
  '@api': `${__dirname}/api`,
  '@utils': `${__dirname}/utils`,
  '@middlewares': `${__dirname}/middlewares`,
  '@config': `${__dirname}/config`,
});
Enter fullscreen mode Exit fullscreen mode

Now import this path.ts in your app.ts at the top -

import 'dotenv/config';
import 'reflect-metadata';
import './path';
import * as http from 'http';
Enter fullscreen mode Exit fullscreen mode

Now build your project - npm run build and run it using npm run start test all the endpoints it should work as expected.

Another small issue is our tests will fail, becuase jest has no idea of resolving @middlewares/validate. So in your jest.config.js paste the following code -

const { pathsToModuleNameMapper } = require('ts-jest');

const { compilerOptions } = require('./tsconfig.json');

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
    prefix: '<rootDir>/src',
  }),
};
Enter fullscreen mode Exit fullscreen mode

Run your tests once to verify everything works as expected.

Overview

In this tutorial we finished setting up tests and added some handy security middlewares. All the code for this tutorial can be found under the feat/security-middleware branch here. Until next time PEACE.

Top comments (0)