From 4491eab5b48fff31ad492e1d75d7af89973fb1c6 Mon Sep 17 00:00:00 2001 From: "IceHe.xyz" Date: Wed, 20 Apr 2022 14:56:33 +0800 Subject: [PATCH] feat(core): koa-log middleware (#590) --- packages/core/src/app/init.test.ts | 2 + packages/core/src/app/init.ts | 3 +- packages/core/src/middleware/koa-log.test.ts | 110 +++++++++++++++++++ packages/core/src/middleware/koa-log.ts | 55 ++++++++++ packages/schemas/src/types/index.ts | 3 +- packages/schemas/src/types/log.ts | 8 ++ 6 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/middleware/koa-log.test.ts create mode 100644 packages/core/src/middleware/koa-log.ts create mode 100644 packages/schemas/src/types/log.ts diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index 2b141c4cd..934c1fb25 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -2,6 +2,7 @@ import Koa from 'koa'; import * as koaErrorHandler from '@/middleware/koa-error-handler'; import * as koaI18next from '@/middleware/koa-i18next'; +import * as koaLog from '@/middleware/koa-log'; import * as koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; import * as koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; import * as koaSpaProxy from '@/middleware/koa-spa-proxy'; @@ -18,6 +19,7 @@ describe('App Init', () => { const middlewareList = [ koaErrorHandler, koaI18next, + koaLog, koaOIDCErrorHandler, koaSlonikErrorHandler, koaSpaProxy, diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index dc8a99e41..138c832ea 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -9,6 +9,7 @@ import envSet, { MountedApps } from '@/env-set'; import koaConnectorErrorHandler from '@/middleware/koa-connector-error-handle'; import koaErrorHandler from '@/middleware/koa-error-handler'; import koaI18next from '@/middleware/koa-i18next'; +import koaLog from '@/middleware/koa-log'; import koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; import koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; import koaSpaProxy from '@/middleware/koa-spa-proxy'; @@ -22,8 +23,8 @@ export default async function initApp(app: Koa): Promise { app.use(koaSlonikErrorHandler()); app.use(koaConnectorErrorHandler()); - // TODO move to specific router (LOG-454) app.use(koaUserLog()); + app.use(koaLog()); app.use(koaLogger()); app.use(koaI18next()); diff --git a/packages/core/src/middleware/koa-log.test.ts b/packages/core/src/middleware/koa-log.test.ts new file mode 100644 index 000000000..2afc3ffd7 --- /dev/null +++ b/packages/core/src/middleware/koa-log.test.ts @@ -0,0 +1,110 @@ +import { LogType, LogResult } from '@logto/schemas'; + +import { insertLog } from '@/queries/log'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaLog, { WithLogContext, LogContext } from './koa-log'; + +const nanoIdMock = 'mockId'; + +jest.mock('@/queries/log', () => ({ + insertLog: jest.fn(async () => Promise.resolve()), +})); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => nanoIdMock), +})); + +describe('koaLog middleware', () => { + const insertLogMock = insertLog as jest.Mock; + const next = jest.fn(); + + const logMock: Partial = { + type: LogType.SignInUsernamePassword, + applicationId: 'foo', + userId: 'foo', + username: 'Foo Bar', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('insert log with success response', async () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters(), + log: {}, // Bypass middleware context type assert + }; + + next.mockImplementationOnce(async () => { + ctx.log = logMock; + }); + + await koaLog()(ctx, next); + + const { type, ...rest } = logMock; + expect(insertLogMock).toBeCalledWith({ + id: nanoIdMock, + type, + payload: { + ...rest, + result: LogResult.Success, + }, + }); + }); + + it('should not block request if insertLog throws error', async () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters(), + log: {}, // Bypass middleware context type assert + }; + + const error = new Error('Failed to insert log'); + insertLogMock.mockImplementationOnce(async () => { + throw error; + }); + + next.mockImplementationOnce(async () => { + ctx.log = logMock; + }); + + await koaLog()(ctx, next); + + const { type, ...rest } = logMock; + expect(insertLogMock).toBeCalledWith({ + id: nanoIdMock, + type, + payload: { + ...rest, + result: LogResult.Success, + }, + }); + }); + + it('should insert log with failed result if next throws error', async () => { + const ctx: WithLogContext> = { + ...createContextWithRouteParameters(), + log: {}, // Bypass middleware context type assert + }; + + const error = new Error('next error'); + + next.mockImplementationOnce(async () => { + ctx.log = logMock; + throw error; + }); + + await expect(koaLog()(ctx, next)).rejects.toMatchError(error); + + const { type, ...rest } = logMock; + expect(insertLogMock).toBeCalledWith({ + id: nanoIdMock, + type, + payload: { + ...rest, + result: LogResult.Error, + error: String(error), + }, + }); + }); +}); diff --git a/packages/core/src/middleware/koa-log.ts b/packages/core/src/middleware/koa-log.ts new file mode 100644 index 000000000..0256d8807 --- /dev/null +++ b/packages/core/src/middleware/koa-log.ts @@ -0,0 +1,55 @@ +import { LogResult, LogType } from '@logto/schemas'; +import { Context, MiddlewareType } from 'koa'; +import { nanoid } from 'nanoid'; + +import { insertLog } from '@/queries/log'; + +export type WithLogContext = ContextT & { + log: LogContext; +}; + +export interface LogContext { + [key: string]: unknown; + type?: LogType; +} + +const log = async (ctx: WithLogContext, result: LogResult) => { + const { type, ...rest } = ctx.log; + + if (!type) { + return; + } + + try { + await insertLog({ + id: nanoid(), + type, + payload: { + ...rest, + result, + }, + }); + } catch (error: unknown) { + console.error('An error occurred while inserting log'); + console.error(error); + } +}; + +export default function koaLog(): MiddlewareType< + StateT, + WithLogContext, + ResponseBodyT +> { + return async (ctx, next) => { + ctx.log = {}; + + try { + await next(); + await log(ctx, LogResult.Success); + } catch (error: unknown) { + ctx.log.error = String(error); + await log(ctx, LogResult.Error); + throw error; + } + }; +} diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index f233e035f..c22dd0bc7 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -1,3 +1,4 @@ -export * from './user'; export * from './connector'; +export * from './log'; export * from './oidc-config'; +export * from './user'; diff --git a/packages/schemas/src/types/log.ts b/packages/schemas/src/types/log.ts new file mode 100644 index 000000000..5da607b6d --- /dev/null +++ b/packages/schemas/src/types/log.ts @@ -0,0 +1,8 @@ +export enum LogType { + SignInUsernamePassword = 'SignInUsernamePassword', +} + +export enum LogResult { + Success = 'Success', + Error = 'Error', +}