diff --git a/packages/core/src/libraries/hook.test.ts b/packages/core/src/libraries/hook.test.ts new file mode 100644 index 000000000..09a4a83af --- /dev/null +++ b/packages/core/src/libraries/hook.test.ts @@ -0,0 +1,89 @@ +import { Event } from '@logto/schemas'; +import { HookEvent } from '@logto/schemas/lib/models/hooks.js'; +import { mockEsm, mockEsmDefault } from '@logto/shared/esm'; +import type { InferModelType } from '@withtyped/server'; +import { got } from 'got'; + +import modelRouters from '#src/model-routers/index.js'; +import { MockQueryClient } from '#src/test-utils/query-client.js'; + +import type { Interaction } from './hook.js'; + +const { jest } = import.meta; + +const queryClient = new MockQueryClient(); +const queryFunction = jest.fn(); + +const url = 'https://logto.gg'; +const hook: InferModelType = { + id: 'foo', + event: HookEvent.PostSignIn, + config: { headers: { bar: 'baz' }, url, retries: 3 }, + createdAt: new Date(), +}; +const readAll = jest + .spyOn(modelRouters.hook.client, 'readAll') + .mockResolvedValue({ rows: [hook], rowCount: 1 }); + +// @ts-expect-error for testing +const post = jest.spyOn(got, 'post').mockImplementation(jest.fn(() => ({ json: jest.fn() }))); + +mockEsm('#src/queries/user.js', () => ({ + findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }), +})); +mockEsm('#src/queries/application.js', () => ({ + findApplicationById: () => ({ id: 'app_id', extraField: 'not_ok' }), +})); +mockEsm('#src/connectors/index.js', () => ({ + getLogtoConnectorById: () => ({ metadata: { id: 'connector_id', extraField: 'not_ok' } }), +})); +// eslint-disable-next-line unicorn/consistent-function-scoping +mockEsmDefault('#src/env-set/create-query-client-by-env.js', () => () => queryClient); +jest.spyOn(queryClient, 'query').mockImplementation(queryFunction); + +const { triggerInteractionHooksIfNeeded } = await import('./hook.js'); + +describe('triggerInteractionHooksIfNeeded()', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return if no user ID found', async () => { + await triggerInteractionHooksIfNeeded({ event: Event.SignIn }); + + expect(queryFunction).not.toBeCalled(); + }); + + it('should set correct payload when hook triggered', async () => { + jest.useFakeTimers().setSystemTime(100_000); + + await triggerInteractionHooksIfNeeded( + { event: Event.SignIn, identifier: { connectorId: 'bar' } }, + // @ts-expect-error for testing + { + jti: 'some_jti', + result: { login: { accountId: '123' } }, + params: { client_id: 'some_client' }, + } as Interaction + ); + + expect(readAll).toHaveBeenCalled(); + expect(post).toHaveBeenCalledWith(url, { + headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' }, + json: { + hookId: 'foo', + event: 'PostSignIn', + interactionEvent: 'SignIn', + sessionId: 'some_jti', + userId: '123', + user: { id: 'user_id', username: 'user' }, + application: { id: 'app_id' }, + connectors: [{ id: 'connector_id' }], + createdAt: new Date(100_000).toISOString(), + }, + retry: { limit: 3 }, + timeout: { request: 10_000 }, + }); + jest.useRealTimers(); + }); +}); diff --git a/packages/core/src/libraries/hook.ts b/packages/core/src/libraries/hook.ts index a07991691..3c8082b9d 100644 --- a/packages/core/src/libraries/hook.ts +++ b/packages/core/src/libraries/hook.ts @@ -1,5 +1,5 @@ import { Event, userInfoSelectFields } from '@logto/schemas'; -import { HookEvent } from '@logto/schemas/models'; +import { HookEventPayload, HookEvent } from '@logto/schemas/models'; import { trySafe } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; import { got } from 'got'; @@ -17,19 +17,6 @@ const eventToHook: Record = { [Event.ForgotPassword]: HookEvent.PostResetPassword, }; -export type HookEventPayload = { - hookId: string; - event: HookEvent; - Event: Event; - createdAt: string; - sessionId?: string; - userAgent?: string; - userId?: string; - user?: Record; - application?: Record; - connectors?: Array>; -}; - // TODO: replace `lodash.pick` const pick = >( object: T, @@ -41,9 +28,11 @@ const pick = >( }; }; +export type Interaction = Awaited>; + export const triggerInteractionHooksIfNeeded = async ( interactionPayload: InteractionPayload, - details?: Awaited>, + details?: Interaction, userAgent?: string ) => { const userId = details?.result?.login?.accountId; @@ -70,9 +59,9 @@ export const triggerInteractionHooksIfNeeded = async ( ) ), ]); - const payload: Omit = { + const payload = { event: hookEvent, - Event: event, + interactionEvent: event, createdAt: new Date().toISOString(), sessionId, userAgent, @@ -82,7 +71,7 @@ export const triggerInteractionHooksIfNeeded = async ( connectors: connector && [ pick(connector.metadata, 'id', 'name', 'description', 'platform', 'target', 'isStandard'), ], - }; + } satisfies Omit; await Promise.all( rows diff --git a/packages/schemas/src/models/hooks.ts b/packages/schemas/src/models/hooks.ts index 70f96fd32..595b8c8b9 100644 --- a/packages/schemas/src/models/hooks.ts +++ b/packages/schemas/src/models/hooks.ts @@ -2,12 +2,29 @@ import { generateStandardId } from '@logto/core-kit'; import { createModel } from '@withtyped/server'; import { z } from 'zod'; +import type { Application, Connector, User } from '../db-entries/index.js'; +import type { userInfoSelectFields } from '../types/index.js'; + export enum HookEvent { PostRegister = 'PostRegister', PostSignIn = 'PostSignIn', PostResetPassword = 'PostResetPassword', } +export type HookEventPayload = { + hookId: string; + event: HookEvent; + createdAt: string; + sessionId?: string; + userAgent?: string; + userId?: string; + user?: Pick; + application?: Pick; + connectors?: Array< + Pick & Pick & Record + >; +} & Record; + export type HookConfig = { /** We don't need `type` since v1 only has web hook */ // type: 'web';