From 9d345a29b9a50cd472e4f5d42eaa4b2f4bd48364 Mon Sep 17 00:00:00 2001 From: Walter Moar Date: Thu, 29 Feb 2024 07:57:09 -0800 Subject: [PATCH] test: FORMS-914 rate limiter unit tests (#1300) * test: FORMS-914 rate limiter unit tests * Undid unnecessary change * switched rate limit strings to template literals * changed if clause formatting for consistency * tests missing async keyword --- .../forms/common/middleware/rateLimiter.js | 4 +- .../common/middleware/rateLimiter.spec.js | 144 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 app/tests/unit/forms/common/middleware/rateLimiter.spec.js diff --git a/app/src/forms/common/middleware/rateLimiter.js b/app/src/forms/common/middleware/rateLimiter.js index ed673918b..b31d9ec69 100644 --- a/app/src/forms/common/middleware/rateLimiter.js +++ b/app/src/forms/common/middleware/rateLimiter.js @@ -7,8 +7,8 @@ const apiKeyRateLimiter = rateLimit({ limit: config.get('server.rateLimit.public.max'), - // Skip Bearer token auth so that CHEFS app users are not limited. - skip: (req) => req.headers && req.headers.authorization && !req.headers.authorization.startsWith('Basic '), + // Skip everything except Basic auth so that CHEFS app users are not limited. + skip: (req) => !req.headers?.authorization || !req.headers.authorization.startsWith('Basic '), // Use the latest draft of the IETF standard for rate limiting headers. standardHeaders: 'draft-7', diff --git a/app/tests/unit/forms/common/middleware/rateLimiter.spec.js b/app/tests/unit/forms/common/middleware/rateLimiter.spec.js new file mode 100644 index 000000000..001daebfc --- /dev/null +++ b/app/tests/unit/forms/common/middleware/rateLimiter.spec.js @@ -0,0 +1,144 @@ +const { getMockReq, getMockRes } = require('@jest-mock/express'); +const uuid = require('uuid'); + +const { apiKeyRateLimiter } = require('../../../../../src/forms/common/middleware'); + +const rateLimit = 7; +const rateWindowSeconds = 11; + +jest.mock('config', () => { + return { + get: jest.fn((key) => { + if (key === 'server.rateLimit.public.max') { + return rateLimit; + } + + if (key === 'server.rateLimit.public.windowMs') { + return rateWindowSeconds * 1000; + } + }), + }; +}); + +// Headers for Draft 7 of the standard. +const rateLimitName = 'RateLimit'; +const rateLimitValue = `limit=${rateLimit}, remaining=${rateLimit - 1}, reset=${rateWindowSeconds}`; +const rateLimitPolicyName = 'RateLimit-Policy'; +const rateLimitPolicyValue = `${rateLimit};w=${rateWindowSeconds}`; + +const ipAddress = '1.2.3.4'; + +const formId = uuid.v4(); +const secret = uuid.v4(); +const basicToken = Buffer.from(`${formId}:${secret}`).toString('base64'); + +const bearerToken = Math.random().toString(36).substring(2); + +beforeEach(() => { + // Reset the rate limiting to be able to call the rate limiter multiple times + apiKeyRateLimiter.resetKey(ipAddress); +}); + +describe('apiKeyRateLimiter', () => { + it('rate limits basic auth', async () => { + const req = getMockReq({ + headers: { + authorization: 'Basic ' + basicToken, + }, + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(2); + // These also test that the rate limiter uses our custom config values. + expect(res.setHeader).toHaveBeenNthCalledWith(1, rateLimitPolicyName, rateLimitPolicyValue); + expect(res.setHeader).toHaveBeenNthCalledWith(2, rateLimitName, rateLimitValue); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + describe('skips rate limiting for', () => { + test('no headers', async () => { + const req = getMockReq({ + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + test('no authorization header', async () => { + const req = getMockReq({ + headers: {}, + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + test('empty authorization header', async () => { + const req = getMockReq({ + headers: { + authorization: '', + }, + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + test('unexpected authorization type', async () => { + const req = getMockReq({ + headers: { + authorization: Math.random().toString(36).substring(2), + }, + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + + test('bearer auth', async () => { + const req = getMockReq({ + headers: { + authorization: 'Bearer ' + bearerToken, + }, + ip: ipAddress, + }); + req.app.get = jest.fn().mockReturnValue(); + const { res, next } = getMockRes(); + + await apiKeyRateLimiter(req, res, next); + + expect(res.setHeader).toHaveBeenCalledTimes(0); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith(); + }); + }); +});