From 9c3f01770484b3c2f4bc6f9944462fd730f28bd4 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 18 Feb 2022 16:25:32 +0800 Subject: [PATCH] test(core): add more middleware tests [2 of 2] (#247) * test(core): add more middleware tests add more middleware tests * fix(ut): fix koaUserInfo ut fix koaUserInfo ut * fix(ut): fix ut fix ut * fix(ut): revert chagnes revert changes * fix(ut): fix ut fail after schema update bug fix ut fail after schema update bug --- packages/core/src/middleware/koa-auth.test.ts | 4 +- .../core/src/middleware/koa-guard.test.ts | 4 +- .../core/src/middleware/koa-i18next.test.ts | 4 +- .../middleware/koa-oidc-error-handler.test.ts | 116 ++++++++++++++++ .../src/middleware/koa-oidc-error-handler.ts | 2 +- .../koa-slonik-error-handler.test.ts | 64 +++++++++ .../core/src/middleware/koa-ui-proxy.test.ts | 88 ++++++++++++ .../core/src/middleware/koa-user-info.test.ts | 55 ++++++++ .../core/src/middleware/koa-user-log.test.ts | 127 ++++++++++++++++++ packages/core/src/utils/test-utils.ts | 2 +- 10 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/middleware/koa-oidc-error-handler.test.ts create mode 100644 packages/core/src/middleware/koa-slonik-error-handler.test.ts create mode 100644 packages/core/src/middleware/koa-ui-proxy.test.ts create mode 100644 packages/core/src/middleware/koa-user-info.test.ts create mode 100644 packages/core/src/middleware/koa-user-log.test.ts diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index f7fcdf2f0..85c3b7902 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -3,7 +3,7 @@ import { Context } from 'koa'; import { IRouterParamContext } from 'koa-router'; import RequestError from '@/errors/RequestError'; -import { createContextWithRouteParamters } from '@/utils/test-utils'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; import koaAuth, { WithAuthContext } from './koa-auth'; @@ -12,7 +12,7 @@ jest.mock('jose/jwt/verify', () => ({ })); describe('koaAuth middleware', () => { - const baseCtx = createContextWithRouteParamters(); + const baseCtx = createContextWithRouteParameters(); const ctx: WithAuthContext = { ...baseCtx, diff --git a/packages/core/src/middleware/koa-guard.test.ts b/packages/core/src/middleware/koa-guard.test.ts index 87d699ad7..ee052c00f 100644 --- a/packages/core/src/middleware/koa-guard.test.ts +++ b/packages/core/src/middleware/koa-guard.test.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { emptyMiddleware, createContextWithRouteParamters } from '@/utils/test-utils'; +import { emptyMiddleware, createContextWithRouteParameters } from '@/utils/test-utils'; import koaGuard, { isGuardMiddleware } from './koa-guard'; @@ -32,7 +32,7 @@ describe('koaGuardMiddleware', () => { }); describe('guardMiddleware', () => { - const baseCtx = createContextWithRouteParamters(); + const baseCtx = createContextWithRouteParameters(); const next = jest.fn(); diff --git a/packages/core/src/middleware/koa-i18next.test.ts b/packages/core/src/middleware/koa-i18next.test.ts index 443c6bdf2..9f9b45bdf 100644 --- a/packages/core/src/middleware/koa-i18next.test.ts +++ b/packages/core/src/middleware/koa-i18next.test.ts @@ -1,7 +1,7 @@ import i18next from 'i18next'; import initI18n from '@/i18n/init'; -import { createContextWithRouteParamters } from '@/utils/test-utils'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; import koaI18next from './koa-i18next'; @@ -15,7 +15,7 @@ describe('koaI18next', () => { it('deteact language', async () => { const ctx = { - ...createContextWithRouteParamters(), + ...createContextWithRouteParameters(), query: {}, locale: 'en', }; diff --git a/packages/core/src/middleware/koa-oidc-error-handler.test.ts b/packages/core/src/middleware/koa-oidc-error-handler.test.ts new file mode 100644 index 000000000..4bbc0b601 --- /dev/null +++ b/packages/core/src/middleware/koa-oidc-error-handler.test.ts @@ -0,0 +1,116 @@ +import { errors } from 'oidc-provider'; + +import RequestError from '@/errors/RequestError'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaOIDCErrorHandler from './koa-oidc-error-handler'; + +describe('koaOIDCErrorHandler middleware', () => { + const next = jest.fn(); + const ctx = createContextWithRouteParameters(); + + it('should throw no errors if no errors are catched', async () => { + await expect(koaOIDCErrorHandler()(ctx, next)).resolves.not.toThrow(); + }); + + it('should throw original error if error type is no OIDCProviderError', async () => { + const error = new Error('err'); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaOIDCErrorHandler()(ctx, next)).rejects.toMatchError(error); + }); + + it('Invalid Scope', async () => { + const error_description = 'Mock scope is invalid'; + const mockScope = 'read:foo'; + const error = new errors.InvalidScope(error_description, mockScope); + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaOIDCErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError( + { + code: 'oidc.invalid_scope', + status: error.status, + expose: true, + scope: mockScope, + }, + { error_description } + ) + ); + }); + + it('Session Not Found', async () => { + const error_description = 'session not found'; + const error = new errors.SessionNotFound('session not found'); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaOIDCErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError( + { + code: 'session.not_found', + status: error.status, + expose: true, + }, + { + error_description, + } + ) + ); + }); + + it('Insufficient Scope', async () => { + const error_description = 'Insufficient scope for access_token'; + const scope = 'read:foo'; + + const error = new errors.InsufficientScope(error_description, scope); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaOIDCErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError( + { + code: 'oidc.insufficient_scope', + status: error.status, + expose: true, + scopes: scope, + }, + { + error_description, + } + ) + ); + }); + + it('Unhandled OIDCProvider Error', async () => { + const error = new errors.AuthorizationPending(); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaOIDCErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError( + { + code: 'oidc.provider_error', + status: error.status, + expose: true, + message: error.message, + }, + { + error_description: error.error_description, + error_detail: error.error_detail, + } + ) + ); + }); +}); diff --git a/packages/core/src/middleware/koa-oidc-error-handler.ts b/packages/core/src/middleware/koa-oidc-error-handler.ts index 4d000cd55..572d505ce 100644 --- a/packages/core/src/middleware/koa-oidc-error-handler.ts +++ b/packages/core/src/middleware/koa-oidc-error-handler.ts @@ -68,7 +68,7 @@ export default function koaOIDCErrorHandler(): Middleware { + const next = jest.fn(); + const ctx = createContextWithRouteParameters(); + + it('should throw no errors if no errors are catched', async () => { + await expect(koaSlonikErrorHandler()(ctx, next)).resolves.not.toThrow(); + }); + + it('should throw original error if error type are not Slonik', async () => { + const error = new Error('foo'); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(error); + }); + + it('should throw original error if not intend to handled', async () => { + const error = new SlonikError(); + + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError(error); + }); + + it('Deletion Error', async () => { + const error = new DeletionError(); + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError({ + code: 'entity.not_found', + status: 404, + }) + ); + }); + + it('NotFoundError', async () => { + const error = new NotFoundError(); + next.mockImplementationOnce(() => { + throw error; + }); + + await expect(koaSlonikErrorHandler()(ctx, next)).rejects.toMatchError( + new RequestError({ + code: 'entity.not_found', + status: 404, + }) + ); + }); +}); diff --git a/packages/core/src/middleware/koa-ui-proxy.test.ts b/packages/core/src/middleware/koa-ui-proxy.test.ts new file mode 100644 index 000000000..5de958606 --- /dev/null +++ b/packages/core/src/middleware/koa-ui-proxy.test.ts @@ -0,0 +1,88 @@ +import { mountedApps } from '@/env/consts'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaUIProxy from './koa-ui-proxy'; + +const mockProxyMiddleware = jest.fn(); +const mockStaticMiddleware = jest.fn(); + +jest.mock('fs/promises', () => ({ + ...jest.requireActual('fs/promises'), + readdir: jest.fn().mockResolvedValue(['sign-in']), +})); + +jest.mock('koa-proxies', () => jest.fn(() => mockProxyMiddleware)); +jest.mock('koa-static', () => jest.fn(() => mockStaticMiddleware)); + +describe('koaUIProxy middleware', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + const next = jest.fn(); + + for (const app of mountedApps) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + it(`${app} path should not call uiProxy`, async () => { + const ctx = createContextWithRouteParameters({ + url: `/${app}/foo`, + }); + + await koaUIProxy()(ctx, next); + + expect(mockProxyMiddleware).not.toBeCalled(); + }); + } + + it('dev env should call proxy middleware for ui paths', async () => { + const ctx = createContextWithRouteParameters(); + await koaUIProxy()(ctx, next); + expect(mockProxyMiddleware).toBeCalled(); + }); + + it('production env should overwrite the request path to root if no target ui file are detected', async () => { + // Mock the @/env/consts + jest.mock('@/env/consts', () => ({ + ...jest.requireActual('@/env/consts'), + isProduction: true, + })); + + /* eslint-disable @typescript-eslint/no-require-imports */ + /* eslint-disable @typescript-eslint/no-var-requires */ + /* eslint-disable unicorn/prefer-module */ + const koaUIProxyModule = require('./koa-ui-proxy') as { default: typeof koaUIProxy }; + /* eslint-enable @typescript-eslint/no-require-imports */ + /* eslint-enable @typescript-eslint/no-var-requires */ + /* eslint-enable unicorn/prefer-module */ + const ctx = createContextWithRouteParameters({ + url: '/foo', + }); + + await koaUIProxyModule.default()(ctx, next); + expect(mockStaticMiddleware).toBeCalled(); + expect(ctx.request.path).toEqual('/'); + }); + + it('production env should call the static middleware if path hit the ui file directory', async () => { + // Mock the @/env/consts + jest.mock('@/env/consts', () => ({ + ...jest.requireActual('@/env/consts'), + isProduction: true, + })); + + /* eslint-disable @typescript-eslint/no-require-imports */ + /* eslint-disable @typescript-eslint/no-var-requires */ + /* eslint-disable unicorn/prefer-module */ + const koaUIProxyModule = require('./koa-ui-proxy') as { default: typeof koaUIProxy }; + /* eslint-enable @typescript-eslint/no-require-imports */ + /* eslint-enable @typescript-eslint/no-var-requires */ + /* eslint-enable unicorn/prefer-module */ + const ctx = createContextWithRouteParameters({ + url: '/sign-in', + }); + + await koaUIProxyModule.default()(ctx, next); + expect(mockStaticMiddleware).toBeCalled(); + }); +}); diff --git a/packages/core/src/middleware/koa-user-info.test.ts b/packages/core/src/middleware/koa-user-info.test.ts new file mode 100644 index 000000000..791633037 --- /dev/null +++ b/packages/core/src/middleware/koa-user-info.test.ts @@ -0,0 +1,55 @@ +import { User, userInfoSelectFields } from '@logto/schemas'; +import pick from 'lodash.pick'; + +import RequestError from '@/errors/RequestError'; +import * as userQueries from '@/queries/user'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaUserInfo from './koa-user-info'; + +const findUserByIdSpy = jest.spyOn(userQueries, 'findUserById'); + +const mockUser: User = { + id: 'foo', + username: 'foo', + primaryEmail: 'foo@logto.io', + primaryPhone: '111111', + roleNames: ['admin'], + passwordEncrypted: null, + passwordEncryptionMethod: null, + passwordEncryptionSalt: null, + name: null, + avatar: null, + identities: {}, + customData: {}, +}; + +describe('koaUserInfo middleware', () => { + const next = jest.fn(); + + it('should set userInfo to the context', async () => { + findUserByIdSpy.mockImplementationOnce(async () => Promise.resolve(mockUser)); + + const ctx = { + ...createContextWithRouteParameters(), + auth: 'foo', + userInfo: { id: '' }, // Bypass the middleware Context type + }; + + await koaUserInfo()(ctx, next); + + expect(ctx.userInfo).toEqual(pick(mockUser, ...userInfoSelectFields)); + }); + + it('should throw if is not authenticated', async () => { + const ctx = { + ...createContextWithRouteParameters(), + auth: 'foo', + userInfo: { id: '' }, // Bypass the middleware Context type + }; + + await expect(koaUserInfo()(ctx, next)).rejects.toMatchError( + new RequestError({ code: 'auth.unauthorized', status: 401 }) + ); + }); +}); diff --git a/packages/core/src/middleware/koa-user-log.test.ts b/packages/core/src/middleware/koa-user-log.test.ts new file mode 100644 index 000000000..9b18307dd --- /dev/null +++ b/packages/core/src/middleware/koa-user-log.test.ts @@ -0,0 +1,127 @@ +import { UserLogType, UserLogResult } from '@logto/schemas'; + +import { insertUserLog } from '@/queries/user-log'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaUserLog, { WithUserLogContext, LogContext } from './koa-user-log'; + +const nanoIdMock = 'mockId'; + +jest.mock('@/queries/user-log', () => ({ + insertUserLog: jest.fn(async () => Promise.resolve()), +})); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => nanoIdMock), +})); + +describe('koaUserLog middleware', () => { + const insertUserLogMock = insertUserLog as jest.Mock; + const next = jest.fn(); + + const userLogMock: Partial = { + userId: 'foo', + type: UserLogType.SignInEmail, + email: 'foo@logto.io', + payload: { applicationId: 'foo' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('insert userLog with success response', async () => { + const ctx: WithUserLogContext> = { + ...createContextWithRouteParameters(), + userLog: { + payload: {}, + createdAt: 0, + }, // Bypass middleware context type assert + }; + + next.mockImplementationOnce(async () => { + ctx.userLog = { + ...ctx.userLog, + ...userLogMock, + }; + }); + + await koaUserLog()(ctx, next); + expect(ctx.userLog).toHaveProperty('userId', userLogMock.userId); + expect(ctx.userLog).toHaveProperty('type', userLogMock.type); + expect(ctx.userLog).toHaveProperty('email', userLogMock.email); + expect(ctx.userLog).toHaveProperty('payload', userLogMock.payload); + expect(ctx.userLog.createdAt).not.toBeFalsy(); + expect(insertUserLogMock).toBeCalledWith({ + id: nanoIdMock, + userId: ctx.userLog.userId, + type: ctx.userLog.type, + result: UserLogResult.Success, + payload: ctx.userLog.payload, + }); + }); + + it('should not block request if insertLog throws error', async () => { + const ctx: WithUserLogContext> = { + ...createContextWithRouteParameters(), + userLog: { + payload: {}, + createdAt: 0, + }, // Bypass middleware context type assert + }; + + insertUserLogMock.mockRejectedValue(new Error(' ')); + + next.mockImplementationOnce(async () => { + ctx.userLog = { + ...ctx.userLog, + ...userLogMock, + }; + }); + + await koaUserLog()(ctx, next); + expect(ctx.userLog).toHaveProperty('userId', userLogMock.userId); + expect(ctx.userLog).toHaveProperty('type', userLogMock.type); + expect(ctx.userLog).toHaveProperty('email', userLogMock.email); + expect(ctx.userLog).toHaveProperty('payload', userLogMock.payload); + expect(ctx.userLog.createdAt).not.toBeFalsy(); + expect(insertUserLogMock).toBeCalledWith({ + id: nanoIdMock, + userId: ctx.userLog.userId, + type: ctx.userLog.type, + result: UserLogResult.Success, + payload: ctx.userLog.payload, + }); + }); + + it('should insert userLog with failed result if next throws error', async () => { + const ctx: WithUserLogContext> = { + ...createContextWithRouteParameters(), + userLog: { + payload: {}, + createdAt: 0, + }, // Bypass middleware context type assert + }; + + const error = new Error('next error'); + + next.mockImplementationOnce(async () => { + ctx.userLog = { + ...ctx.userLog, + ...userLogMock, + }; + throw error; + }); + + await expect(koaUserLog()(ctx, next)).rejects.toMatchError(error); + + expect(ctx.userLog.createdAt).not.toBeFalsy(); + expect(insertUserLogMock).toBeCalledWith({ + id: nanoIdMock, + userId: ctx.userLog.userId, + type: ctx.userLog.type, + result: UserLogResult.Failed, + payload: ctx.userLog.payload, + }); + }); +}); diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index 6f32fbaff..bcc44855b 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -45,7 +45,7 @@ export const emptyMiddleware = return next(); }; -export const createContextWithRouteParamters = ( +export const createContextWithRouteParameters = ( mockContestOptions?: Options> ): Context & IRouterParamContext => { const ctx = createMockContext(mockContestOptions);