From b92508db3a04aeb5ae77b192e1da4b014293070d Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 22 May 2023 19:01:54 +0800 Subject: [PATCH] feat(core,schemas): support get hook recent logs (#3859) --- packages/core/src/queries/log.ts | 10 ++++- packages/core/src/routes/hook.test.ts | 45 ++++++++++++++++++- packages/core/src/routes/hook.ts | 40 ++++++++++++++++- .../src/tests/api/hooks.test.ts | 36 ++++++++++++++- ...684739802-create-hook-id-index-for-logs.ts | 18 ++++++++ packages/schemas/tables/logs.sql | 3 ++ 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 packages/schemas/alterations/next-1684739802-create-hook-id-index-for-logs.ts diff --git a/packages/core/src/queries/log.ts b/packages/core/src/queries/log.ts index 03f6e6f64..8d7af358a 100644 --- a/packages/core/src/queries/log.ts +++ b/packages/core/src/queries/log.ts @@ -13,10 +13,12 @@ export type LogCondition = { logKey?: string; applicationId?: string; userId?: string; + hookId?: string; + startTimeExclusive?: number; }; const buildLogConditionSql = (logCondition: LogCondition) => - conditionalSql(logCondition, ({ logKey, applicationId, userId }) => { + conditionalSql(logCondition, ({ logKey, applicationId, userId, hookId, startTimeExclusive }) => { const subConditions = [ conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`), conditionalSql(userId, (userId) => sql`${fields.payload}->>'userId'=${userId}`), @@ -24,6 +26,12 @@ const buildLogConditionSql = (logCondition: LogCondition) => applicationId, (applicationId) => sql`${fields.payload}->>'applicationId'=${applicationId}` ), + conditionalSql(hookId, (hookId) => sql`${fields.payload}->>'hookId'=${hookId}`), + conditionalSql( + startTimeExclusive, + (startTimeExclusive) => + sql`${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)` + ), ].filter(({ sql }) => sql); return subConditions.length > 0 ? sql`where ${sql.join(subConditions, sql` and `)}` : sql``; diff --git a/packages/core/src/routes/hook.test.ts b/packages/core/src/routes/hook.test.ts index 416734126..b797aec99 100644 --- a/packages/core/src/routes/hook.test.ts +++ b/packages/core/src/routes/hook.test.ts @@ -4,8 +4,11 @@ import { type HookEvents, type HookConfig, type CreateHook, + LogResult, + type Log, } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; +import { subDays } from 'date-fns'; import { mockCreatedAtForHook, @@ -42,13 +45,34 @@ const hooks = { deleteHookById: jest.fn(), }; -const tenantContext = new MockTenant(undefined, { hooks }); +const mockLog: Log = { + tenantId: 'fake_tenant', + id: '1', + key: 'a', + payload: { key: 'a', result: LogResult.Success }, + createdAt: 123, +}; + +const logs = { + countLogs: jest.fn().mockResolvedValue({ + count: 1, + }), + findLogs: jest.fn().mockResolvedValue([mockLog]), +}; + +const { countLogs, findLogs } = logs; + +const tenantContext = new MockTenant(undefined, { hooks, logs }); const hookRoutes = await pickDefault(import('./hook.js')); describe('hook routes', () => { const hookRequest = createRequester({ authedRoutes: hookRoutes, tenantContext }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('GET /hooks', async () => { const response = await hookRequest.get('/hooks'); expect(response.status).toEqual(200); @@ -63,6 +87,25 @@ describe('hook routes', () => { expect(response.body.id).toBe(hookIdInMockList); }); + it('GET /hooks/:id/recent-logs should call countLogs and findLogs with correct parameters', async () => { + jest.useFakeTimers().setSystemTime(100_000); + + const hookId = 'foo'; + const logKey = 'TriggerHook.PostSignIn'; + const page = 1; + const pageSize = 5; + + const startTimeExclusive = subDays(new Date(100_000), 1).getTime(); + + await hookRequest.get( + `/hooks/${hookId}/recent-logs?logKey=${logKey}&page=${page}&page_size=${pageSize}` + ); + expect(countLogs).toHaveBeenCalledWith({ hookId, logKey, startTimeExclusive }); + expect(findLogs).toHaveBeenCalledWith(5, 0, { hookId, logKey, startTimeExclusive }); + + jest.useRealTimers(); + }); + it('POST /hooks', async () => { const name = 'fooName'; const events: HookEvents = [HookEvent.PostRegister]; diff --git a/packages/core/src/routes/hook.ts b/packages/core/src/routes/hook.ts index 13d02d58b..5eea2e6bc 100644 --- a/packages/core/src/routes/hook.ts +++ b/packages/core/src/routes/hook.ts @@ -1,10 +1,12 @@ -import { Hooks, hookEventsGuard } from '@logto/schemas'; +import { Hooks, Logs, hookEventsGuard } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { conditional, deduplicate } from '@silverhand/essentials'; +import { subDays } from 'date-fns'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; import assertThat from '#src/utils/assert-that.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; @@ -16,7 +18,10 @@ const nonemptyUniqueHookEventsGuard = hookEventsGuard export default function hookRoutes( ...[router, { queries }]: RouterInitArgs ) { - const { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById } = queries.hooks; + const { + hooks: { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById }, + logs: { countLogs, findLogs }, + } = queries; router.get( '/hooks', @@ -46,6 +51,37 @@ export default function hookRoutes( } ); + router.get( + '/hooks/:id/recent-logs', + koaPagination(), + koaGuard({ + params: z.object({ id: z.string() }), + query: z.object({ logKey: z.string().optional() }), + response: Logs.guard.omit({ tenantId: true }).array(), + status: 200, + }), + async (ctx, next) => { + const { limit, offset } = ctx.pagination; + + const { + params: { id }, + query: { logKey }, + } = ctx.guard; + + const startTimeExclusive = subDays(new Date(), 1).getTime(); + + const [{ count }, logs] = await Promise.all([ + countLogs({ logKey, hookId: id, startTimeExclusive }), + findLogs(limit, offset, { logKey, hookId: id, startTimeExclusive }), + ]); + + ctx.pagination.totalCount = count; + ctx.body = logs; + + return next(); + } + ); + router.post( '/hooks', koaGuard({ diff --git a/packages/integration-tests/src/tests/api/hooks.test.ts b/packages/integration-tests/src/tests/api/hooks.test.ts index 3ebca0e88..1a55dac3e 100644 --- a/packages/integration-tests/src/tests/api/hooks.test.ts +++ b/packages/integration-tests/src/tests/api/hooks.test.ts @@ -1,4 +1,4 @@ -import type { Hook, HookConfig, LogKey } from '@logto/schemas'; +import type { Hook, HookConfig, Log, LogKey } from '@logto/schemas'; import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas'; import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js'; @@ -225,4 +225,38 @@ describe('hooks', () => { ]); await deleteUser(id); }); + + it('should get recent hook logs correctly', async () => { + const createdHook = await authedAdminApi + .post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') }) + .json(); + + // Init session and submit + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initClient(); + await client.send(putInteraction, { + event: InteractionEvent.Register, + profile: { + username, + password, + }, + }); + const { redirectTo } = await client.submitInteraction(); + const id = await processSession(client, redirectTo); + await waitFor(500); // Wait for hooks execution + + const logs = await authedAdminApi + .get(`hooks/${createdHook.id}/recent-logs?page_size=100`) + .json(); + expect( + logs.some( + ({ payload: { hookId, result } }) => + hookId === createdHook.id && result === LogResult.Success + ) + ).toBeTruthy(); + + await authedAdminApi.delete(`hooks/${createdHook.id}`); + + await deleteUser(id); + }); }); diff --git a/packages/schemas/alterations/next-1684739802-create-hook-id-index-for-logs.ts b/packages/schemas/alterations/next-1684739802-create-hook-id-index-for-logs.ts new file mode 100644 index 000000000..ed2c9bde0 --- /dev/null +++ b/packages/schemas/alterations/next-1684739802-create-hook-id-index-for-logs.ts @@ -0,0 +1,18 @@ +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + create index logs__hook_id on logs (tenant_id, (payload->>'hookId')); + `); + }, + down: async (pool) => { + await pool.query(sql` + drop index logs__hook_id; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/tables/logs.sql b/packages/schemas/tables/logs.sql index 1d6b75220..3fd3bed42 100644 --- a/packages/schemas/tables/logs.sql +++ b/packages/schemas/tables/logs.sql @@ -19,3 +19,6 @@ create index logs__user_id create index logs__application_id on logs (tenant_id, (payload->>'applicationId')); + +create index logs__hook_id + on logs (tenant_id, (payload->>'hookId'));