From c8d45a13f01255b2da9d0dbde7274c5e67e1a4b2 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 17 Feb 2022 14:21:29 +0800 Subject: [PATCH] test(core): add middleware tests [1 of 2] (#244) * test(core): add middleware tests add middleware tests * fix(ut): fix typo fix typo --- packages/core/jest.config.js | 2 +- packages/core/jest.setup.js | 12 ++ packages/core/src/app/init.test.ts | 11 -- packages/core/src/middleware/koa-auth.test.ts | 90 +++++++++++++ .../src/middleware/koa-error-handler.test.ts | 42 ++++++ .../core/src/middleware/koa-error-handler.ts | 4 +- .../core/src/middleware/koa-guard.test.ts | 123 ++++++++++++++++++ .../core/src/middleware/koa-i18next.test.ts | 27 ++++ packages/core/src/utils/test-utils.ts | 25 ++++ 9 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 packages/core/jest.setup.js create mode 100644 packages/core/src/middleware/koa-auth.test.ts create mode 100644 packages/core/src/middleware/koa-error-handler.test.ts create mode 100644 packages/core/src/middleware/koa-guard.test.ts create mode 100644 packages/core/src/middleware/koa-i18next.test.ts diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index 3e824db9c..933971de2 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -2,7 +2,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - setupFilesAfterEnv: ['jest-matcher-specific-error'], + setupFilesAfterEnv: ['./jest.setup.js', 'jest-matcher-specific-error'], globals: { 'ts-jest': { tsconfig: 'tsconfig.test.json', diff --git a/packages/core/jest.setup.js b/packages/core/jest.setup.js new file mode 100644 index 000000000..2a9545d0d --- /dev/null +++ b/packages/core/jest.setup.js @@ -0,0 +1,12 @@ +/** + * Setup environment variables for unit test + */ +const OIDC_PROVIDER_PRIVATE_KEY_BASE64 = + 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlDV2dJQkFBS0JnR3pLendQcVp6Q3dncjR5a0U1NTN2aWw3QTZYM2l1VnJ3TVJtbVJDTVNBL3lkUm04bXA1CjlHZUYyMlRCSVBtUEVNM29Lbnk4KytFL2FDRnByWXVDa0loREhodVR5N1diT25nd3kyb3JpYnNEQm1OS3FybTkKM0xkYWYrZm1aU2tsL0FMUjZNeUhNV2dTUkQrbFhxVnplNFdSRGIzVTlrTyt3RmVXUlNZNmlRL2pBZ01CQUFFQwpnWUJOZkczUjVpUTFJNk1iZ0x3VGlPM3N2NURRSEE3YmtETWt4bWJtdmRacmw4TlRDemZoNnBiUEhTSFVNMUlmCkxXelVtMldYanFzQUZiOCsvUnZrWDh3OHI3SENNUUdLVGs0ay9adkZ5YUhkM2tIUXhjSkJPakNOUUtjS2NZalUKRGdnTUVJeW5PblNZNjJpWEV6RExKVTJEMVUrY3JEbTZXUTVHaG1NS1p2Vnl3UUpCQU1lcFBFV2gwakNDOEdmQwpQQU1yT1JvOHJYeHYwVEdXNlJWYmxad0ppdjhNeGZacnpZT1cwZUFPek9IK0ZRWE90SjNTdUZONzdEcVQ5TDI3CmN2M3QySkVDUVFDTGZZeVl2ZUg0UnY2bnVET0RnckkzRUJHMFNJbURHcC94UUV2NEk5Z0hrRFF0aFF4bW5xNTEKZ1QxajhFN1lmRHEwMTkvN2htL3dmMXNzMERQNkpic3pBa0JqOEUzKy9MVGRHMjJDUWpNUDB2N09KemtmWkVqdAo3WC9WOVBXNkdQeStGWUt4aWR4ZzFZbFFBWmlFTms0SGppUFNLN3VmN2hPY2JwcStyYWt0ZVhSQkFrQmhaaFFECkh5c20wbVBFTnNGNWhZdnRHTUpUOFFaYnpmNTZWUnYyc3dpSUYyL25qT3hneDFJbjZFczJlamlEdnhLNjdiV1AKQ29zbEViaFhMVFh0NStTekFrQjJQOUYzNExubE9tVjh4Zjk1VmVlcXNPbDFmWWx2Uy9vUUx1a2ZxVkJsTmtzNgpzdmNLVDJOQjlzSHlCeE8vY3Zqa0ZpWXdHR2MzNjlmQklkcDU1S2IwCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t'; +const UI_SIGN_IN_ROUTE = '/sign-in'; + +process.env = { + ...process.env, + OIDC_PROVIDER_PRIVATE_KEY_BASE64, + UI_SIGN_IN_ROUTE, +}; diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index a1678acc3..9ddde9ddd 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -1,15 +1,5 @@ import Koa from 'koa'; -/** - * Need to mock env variables ahead - */ - -// eslint-disable-next-line import/order -import { envVariablesSetUp } from '@/utils/test-utils'; - -envVariablesSetUp(); - -/* eslint-disable import/first */ import * as koaErrorHandler from '@/middleware/koa-error-handler'; import * as koaI18next from '@/middleware/koa-i18next'; import * as koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; @@ -21,7 +11,6 @@ import * as initRouter from '@/routes/init'; import initI18n from '../i18n/init'; import initApp from './init'; -/* eslint-enable import/first */ describe('App Init', () => { const listenMock = jest.spyOn(Koa.prototype, 'listen').mockImplementation(jest.fn()); diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts new file mode 100644 index 000000000..f7fcdf2f0 --- /dev/null +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -0,0 +1,90 @@ +import { jwtVerify } from 'jose/jwt/verify'; +import { Context } from 'koa'; +import { IRouterParamContext } from 'koa-router'; + +import RequestError from '@/errors/RequestError'; +import { createContextWithRouteParamters } from '@/utils/test-utils'; + +import koaAuth, { WithAuthContext } from './koa-auth'; + +jest.mock('jose/jwt/verify', () => ({ + jwtVerify: jest.fn(() => ({ payload: { sub: 'fooUser' } })), +})); + +describe('koaAuth middleware', () => { + const baseCtx = createContextWithRouteParamters(); + + const ctx: WithAuthContext = { + ...baseCtx, + auth: '', + }; + + const unauthorizedError = new RequestError({ code: 'auth.unauthorized', status: 401 }); + + const next = jest.fn(); + + beforeEach(() => { + ctx.auth = ''; + ctx.request = baseCtx.request; + jest.resetModules(); + }); + + it('should read DEVELOPMENT_USER_ID from env variable first if not production', async () => { + // Mock the @/env/consts + jest.mock('@/env/consts', () => ({ + ...jest.requireActual('@/env/consts'), + developmentUserId: 'foo', + })); + + /* eslint-disable @typescript-eslint/no-require-imports */ + /* eslint-disable @typescript-eslint/no-var-requires */ + /* eslint-disable unicorn/prefer-module */ + const koaAuthModule = require('./koa-auth') as { default: typeof koaAuth }; + /* eslint-enable @typescript-eslint/no-require-imports */ + /* eslint-enable @typescript-eslint/no-var-requires */ + /* eslint-enable unicorn/prefer-module */ + + await koaAuthModule.default()(ctx, next); + expect(ctx.auth).toEqual('foo'); + }); + + it('should set user auth with given sub returned from accessToken', async () => { + ctx.request = { + ...ctx.request, + headers: { + authorization: 'Bearer access_token', + }, + }; + await koaAuth()(ctx, next); + expect(ctx.auth).toEqual('fooUser'); + }); + + it('expect to throw if authorization header is missing', async () => { + await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + }); + + it('expect to throw if authorization header token type not recognized ', async () => { + ctx.request = { + ...ctx.request, + headers: { + authorization: 'dummy access_token', + }, + }; + + await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + }); + + it('expect to throw if jwt sub is missing', async () => { + const mockJwtVerify = jwtVerify as jest.Mock; + mockJwtVerify.mockImplementationOnce(() => ({ payload: {} })); + + ctx.request = { + ...ctx.request, + headers: { + authorization: 'Bearer access_token', + }, + }; + + await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + }); +}); diff --git a/packages/core/src/middleware/koa-error-handler.test.ts b/packages/core/src/middleware/koa-error-handler.test.ts new file mode 100644 index 000000000..80b540f1a --- /dev/null +++ b/packages/core/src/middleware/koa-error-handler.test.ts @@ -0,0 +1,42 @@ +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import RequestError from '@/errors/RequestError'; + +import koaErrorHandler from './koa-error-handler'; + +describe('koaErrorHandler middleware', () => { + const mockBody = { data: 'foo' }; + + const ctx = createMockContext({ + customProperties: { + body: mockBody, + }, + }); + + const next = jest.fn().mockReturnValue(Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + ctx.body = mockBody; + ctx.status = 200; + }); + + it('expect to return error response if error type is RequestError', async () => { + const error = new RequestError('auth.unauthorized'); + next.mockRejectedValueOnce(error); + await koaErrorHandler()(ctx, next); + expect(ctx.status).toEqual(error.status); + expect(ctx.body).toEqual(error.body); + }); + + it('expect to return orginal body if not error found', async () => { + await koaErrorHandler()(ctx, next); + expect(ctx.status).toEqual(200); + expect(ctx.body).toEqual(mockBody); + }); + + it('expect to throw if error type is not RequestError', async () => { + next.mockRejectedValueOnce(new Error('err')); + await expect(koaErrorHandler()(ctx, next)).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index ea09d1768..28f855574 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -3,10 +3,10 @@ import { Middleware } from 'koa'; import RequestError from '@/errors/RequestError'; -export default function koaErrorHandler(): Middleware< +export default function koaErrorHandler(): Middleware< StateT, ContextT, - RequestErrorBody + BodyT | RequestErrorBody > { return async (ctx, next) => { try { diff --git a/packages/core/src/middleware/koa-guard.test.ts b/packages/core/src/middleware/koa-guard.test.ts new file mode 100644 index 000000000..87d699ad7 --- /dev/null +++ b/packages/core/src/middleware/koa-guard.test.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; + +import { emptyMiddleware, createContextWithRouteParamters } from '@/utils/test-utils'; + +import koaGuard, { isGuardMiddleware } from './koa-guard'; + +jest.mock('koa-body', () => emptyMiddleware); + +describe('koaGuardMiddleware', () => { + describe('isGuardMiddleware', () => { + it('isGuardMiddleware return false if name not match', () => { + const fooMiddleware = jest.fn(); + expect(isGuardMiddleware(fooMiddleware)).toEqual(false); + }); + + it('isGuardMiddleware return true if name is guardMiddleware & has config property', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const guardMiddleware = () => ({}); + + // eslint-disable-next-line @silverhand/fp/no-mutation + guardMiddleware.config = {}; + + expect(isGuardMiddleware(guardMiddleware)).toBe(true); + }); + + it('isGuardMiddleware return false if name is name is guardMiddleware but has no config property', () => { + // eslint-disable-next-line unicorn/consistent-function-scoping + const guardMiddleware = () => ({}); + + expect(isGuardMiddleware(guardMiddleware)).toBe(false); + }); + }); + + describe('guardMiddleware', () => { + const baseCtx = createContextWithRouteParamters(); + + const next = jest.fn(); + + const FooGuard = z.object({ + foo: z.string(), + }); + + // Use to bypass the context type assert + const defaultGuard = { body: undefined, query: undefined, params: undefined }; + + it('invalid body type should throw', async () => { + const ctx = { + ...baseCtx, + request: { + ...baseCtx.request, + body: {}, + }, + params: {}, + guard: { + ...defaultGuard, + body: { foo: '1' }, + }, + }; + + await expect(koaGuard({ body: FooGuard })(ctx, next)).rejects.toThrow(); + }); + + it('invalid query type should throw', async () => { + const ctx = { + ...baseCtx, + + request: { + ...baseCtx.request, + query: {}, + }, + params: {}, + guard: { + ...defaultGuard, + query: { foo: '1' }, + }, + }; + + await expect(koaGuard({ query: FooGuard })(ctx, next)).rejects.toThrow(); + }); + + it('invalid params type should throw', async () => { + const ctx = { + ...baseCtx, + params: {}, + guard: { + ...defaultGuard, + params: { foo: '1' }, + }, + }; + + await expect(koaGuard({ params: FooGuard })(ctx, next)).rejects.toThrow(); + }); + + it('valid body, query, params should pass', async () => { + const ctx = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + foo: '3', + }, + query: { + foo: '2', + }, + }, + params: { + foo: '1', + }, + guard: { + ...defaultGuard, + params: { foo: '1' }, + body: { foo: '1' }, + query: { foo: '1' }, + }, + }; + + await koaGuard({ params: FooGuard, query: FooGuard, body: FooGuard })(ctx, next); + expect(ctx.guard.body).toHaveProperty('foo', '3'); + expect(ctx.guard.query).toHaveProperty('foo', '2'); + expect(ctx.guard.params).toHaveProperty('foo', '1'); + }); + }); +}); diff --git a/packages/core/src/middleware/koa-i18next.test.ts b/packages/core/src/middleware/koa-i18next.test.ts new file mode 100644 index 000000000..443c6bdf2 --- /dev/null +++ b/packages/core/src/middleware/koa-i18next.test.ts @@ -0,0 +1,27 @@ +import i18next from 'i18next'; + +import initI18n from '@/i18n/init'; +import { createContextWithRouteParamters } from '@/utils/test-utils'; + +import koaI18next from './koa-i18next'; + +// Can not access outter scope function in jest mock +// eslint-disable-next-line unicorn/consistent-function-scoping +jest.mock('@/i18n/detect-language', () => () => ['zh-cn']); +const changLanguageSpy = jest.spyOn(i18next, 'changeLanguage'); + +describe('koaI18next', () => { + const next = jest.fn(); + + it('deteact language', async () => { + const ctx = { + ...createContextWithRouteParamters(), + query: {}, + locale: 'en', + }; + await initI18n(); + await koaI18next()(ctx, next); + expect(ctx.locale).toEqual('zh-CN'); + expect(changLanguageSpy).toBeCalled(); + }); +}); diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 8a15f4143..6f32fbaff 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -1,3 +1,6 @@ +import { createMockContext, Options } from '@shopify/jest-koa-mocks'; +import { MiddlewareType, Context } from 'koa'; +import Router, { IRouterParamContext } from 'koa-router'; import { createMockPool, createMockQueryResult, QueryResultRowType } from 'slonik'; import { PrimitiveValueExpressionType } from 'slonik/dist/src/types.d'; @@ -33,3 +36,25 @@ export const envVariablesSetUp = () => { UI_SIGN_IN_ROUTE, }; }; + +export const emptyMiddleware = + (): MiddlewareType => + // Intend to mock the async middleware + // eslint-disable-next-line unicorn/consistent-function-scoping + async (ctx, next) => { + return next(); + }; + +export const createContextWithRouteParamters = ( + mockContestOptions?: Options> +): Context & IRouterParamContext => { + const ctx = createMockContext(mockContestOptions); + + return { + ...ctx, + params: {}, + router: new Router(), + _matchedRoute: undefined, + _matchedRouteName: undefined, + }; +};