diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index f750a27a7..3ab0c1d7b 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -1,4 +1,4 @@ -import { mockEsmDefault, pickDefault } from '@logto/shared/esm'; +import { mockEsm, pickDefault } from '@logto/shared/esm'; import Koa from 'koa'; import { emptyMiddleware } from '#src/utils/test-utils.js'; @@ -14,7 +14,10 @@ const middlewareList = [ 'spa-proxy', ].map((name) => { const mock = jest.fn(() => emptyMiddleware); - mockEsmDefault(`#src/middleware/koa-${name}.js`, () => mock); + mockEsm(`#src/middleware/koa-${name}.js`, () => ({ + default: mock, + ...(name === 'audit-log' && { LogEntry: jest.fn() }), + })); return mock; }); diff --git a/packages/core/src/libraries/hook.test.ts b/packages/core/src/libraries/hook.test.ts index af1e3fb1c..75ae591da 100644 --- a/packages/core/src/libraries/hook.test.ts +++ b/packages/core/src/libraries/hook.test.ts @@ -1,3 +1,4 @@ +import { InteractionEvent, LogResult } 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'; @@ -24,8 +25,20 @@ 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() }))); +const post = jest + .spyOn(got, 'post') + // @ts-expect-error for testing + .mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' }))); + +// TODO: @Gao fix `mockEsm()` import issue +const nanoIdMock = 'mockId'; +jest.unstable_mockModule('@logto/core-kit', () => ({ + generateStandardId: () => nanoIdMock, +})); + +const { insertLog } = mockEsm('#src/queries/log.js', () => ({ + insertLog: jest.fn(), +})); mockEsm('#src/queries/user.js', () => ({ findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }), @@ -46,7 +59,7 @@ describe('triggerInteractionHooksIfNeeded()', () => { }); it('should return if no user ID found', async () => { - await triggerInteractionHooksIfNeeded(Event.SignIn); + await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn); expect(queryFunction).not.toBeCalled(); }); @@ -55,7 +68,7 @@ describe('triggerInteractionHooksIfNeeded()', () => { jest.useFakeTimers().setSystemTime(100_000); await triggerInteractionHooksIfNeeded( - Event.SignIn, + InteractionEvent.SignIn, // @ts-expect-error for testing { jti: 'some_jti', @@ -80,6 +93,18 @@ describe('triggerInteractionHooksIfNeeded()', () => { retry: { limit: 3 }, timeout: { request: 10_000 }, }); + + const calledPayload: unknown = insertLog.mock.calls[0][0]; + expect(calledPayload).toHaveProperty('id', nanoIdMock); + expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + HookEvent.PostSignIn); + expect(calledPayload).toHaveProperty('payload.result', LogResult.Success); + expect(calledPayload).toHaveProperty('payload.hookId', 'foo'); + expect(calledPayload).toHaveProperty('payload.json.event', HookEvent.PostSignIn); + expect(calledPayload).toHaveProperty('payload.json.interactionEvent', InteractionEvent.SignIn); + expect(calledPayload).toHaveProperty('payload.json.hookId', 'foo'); + expect(calledPayload).toHaveProperty('payload.json.userId', '123'); + expect(calledPayload).toHaveProperty('payload.response.statusCode', 200); + expect(calledPayload).toHaveProperty('payload.response.body.message', 'ok'); jest.useRealTimers(); }); }); diff --git a/packages/core/src/libraries/hook.ts b/packages/core/src/libraries/hook.ts index f04abb4fb..300afa32c 100644 --- a/packages/core/src/libraries/hook.ts +++ b/packages/core/src/libraries/hook.ts @@ -28,7 +28,7 @@ const eventToHook: Record = { export type Interaction = Awaited>; export const triggerInteractionHooksIfNeeded = async ( - event: Event, + event: InteractionEvent, details?: Interaction, userAgent?: string ) => { @@ -87,7 +87,8 @@ export const triggerInteractionHooksIfNeeded = async ( .catch(async (error) => { logEntry.append({ result: LogResult.Error, - response: error instanceof HTTPError ? parseResponse(error.response) : String(error), + response: conditional(error instanceof HTTPError && parseResponse(error.response)), + error: conditional(error instanceof Error && String(error)), }); }); diff --git a/packages/core/src/middleware/koa-audit-log.test.ts b/packages/core/src/middleware/koa-audit-log.test.ts index 982c50cd9..ca9bcf577 100644 --- a/packages/core/src/middleware/koa-audit-log.test.ts +++ b/packages/core/src/middleware/koa-audit-log.test.ts @@ -10,14 +10,14 @@ import type { WithLogContext, LogPayload } from './koa-audit-log.js'; const { jest } = import.meta; -const nanoIdMock = 'mockId'; - const { insertLog } = mockEsm('#src/queries/log.js', () => ({ insertLog: jest.fn(), })); -mockEsm('nanoid', () => ({ - nanoid: () => nanoIdMock, +// TODO: @Gao fix `mockEsm()` import issue +const nanoIdMock = 'mockId'; +jest.unstable_mockModule('@logto/core-kit', () => ({ + generateStandardId: () => nanoIdMock, })); const koaLog = await pickDefault(import('./koa-audit-log.js')); diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index 2ab79db76..7aaa57e6e 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -92,15 +92,17 @@ const { sendPasscodeToIdentifier } = await mockEsmWithActual( const { createLog, prependAllLogEntries } = createMockLogContext(); -mockEsmDefault( +await mockEsmWithActual( '#src/middleware/koa-audit-log.js', - // eslint-disable-next-line unicorn/consistent-function-scoping - (): typeof koaAuditLog => () => async (ctx, next) => { - ctx.createLog = createLog; - ctx.prependAllLogEntries = prependAllLogEntries; + (): { default: typeof koaAuditLog } => ({ + // eslint-disable-next-line unicorn/consistent-function-scoping + default: () => async (ctx, next) => { + ctx.createLog = createLog; + ctx.prependAllLogEntries = prependAllLogEntries; - return next(); - } + return next(); + }, + }) ); const { @@ -272,7 +274,7 @@ describe('session -> interactionRoutes', () => { }); it('should not call validateMandatoryUserProfile for forgot password request', async () => { - getInteractionStorage.mockReturnValueOnce({ + getInteractionStorage.mockReturnValue({ event: InteractionEvent.ForgotPassword, }); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 2abf65589..4a20d5d6b 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -218,7 +218,6 @@ export default function interactionRoutes( async (ctx, next) => { const { interactionDetails } = ctx; const interactionStorage = getInteractionStorage(interactionDetails.result); - const { event } = interactionStorage; const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage); diff --git a/packages/integration-tests/jest.setup.js b/packages/integration-tests/jest.setup.js index f60a41079..ac2df4e12 100644 --- a/packages/integration-tests/jest.setup.js +++ b/packages/integration-tests/jest.setup.js @@ -16,4 +16,4 @@ global.TextDecoder = TextDecoder; global.TextEncoder = TextEncoder; /* eslint-enable @silverhand/fp/no-mutation */ -jest.setTimeout(10_000); +jest.setTimeout(15_000); diff --git a/packages/integration-tests/src/tests/api/hooks.test.ts b/packages/integration-tests/src/tests/api/hooks.test.ts index 57a421e14..787f6f466 100644 --- a/packages/integration-tests/src/tests/api/hooks.test.ts +++ b/packages/integration-tests/src/tests/api/hooks.test.ts @@ -1,13 +1,15 @@ import type { LogKey } from '@logto/schemas'; -import { LogResult, Event } from '@logto/schemas'; +import { SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas'; import type { Hooks } from '@logto/schemas/models'; import { HookEvent } from '@logto/schemas/models'; import type { InferModelType } from '@withtyped/server'; import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js'; import { createMockServer } from '#src/helpers.js'; +import { waitFor } from '#src/utils.js'; import { initClient, processSession } from './interaction/utils/client.js'; +import { enableAllPasswordSignInMethods } from './interaction/utils/sign-in-experience.js'; import { generateNewUser, generateNewUserProfile } from './interaction/utils/user.js'; type Hook = InferModelType; @@ -22,6 +24,21 @@ const createPayload = (event: HookEvent, url = 'not_work_url'): Partial => }); describe('hooks', () => { + const { listen, close } = createMockServer(9999); + + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + await listen(); + }); + + afterAll(async () => { + await close(); + }); + it('should be able to create, query, and delete a hook', async () => { const payload = createPayload(HookEvent.PostRegister); const created = await authedAdminApi.post('hooks', { json: payload }).json(); @@ -51,19 +68,23 @@ describe('hooks', () => { } = await generateNewUser({ username: true, password: true }); const client = await initClient(); await client.successSend(putInteraction, { - event: Event.SignIn, + event: InteractionEvent.SignIn, identifier: { username, password, }, }); await client.submitInteraction(); + await waitFor(500); // Wait for hooks execution // Check hook trigger log const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' })); expect( logs.some( - ({ payload: { hookId, result } }) => hookId === createdHook.id && result === LogResult.Error + ({ payload: { hookId, result, error } }) => + hookId === createdHook.id && + result === LogResult.Error && + error === 'RequestError: Invalid URL' ) ).toBeTruthy(); @@ -80,14 +101,12 @@ describe('hooks', () => { .json(), ]); const logKey: LogKey = 'TriggerHook.PostRegister'; - const { listen, close } = createMockServer(9999); - await listen(); // Start mock server // Init session and submit const { username, password } = generateNewUserProfile({ username: true, password: true }); const client = await initClient(); await client.send(putInteraction, { - event: Event.Register, + event: InteractionEvent.Register, profile: { username, password, @@ -95,13 +114,14 @@ describe('hooks', () => { }); const { redirectTo } = await client.submitInteraction(); const id = await processSession(client, redirectTo); - await close(); // Stop mock server early + await waitFor(500); // Wait for hooks execution // Check hook trigger log const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' })); expect( logs.some( - ({ payload: { hookId, result } }) => hookId === hook1.id && result === LogResult.Error + ({ payload: { hookId, result, error } }) => + hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL' ) ).toBeTruthy(); expect( diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts index 5c4bf47ae..c14acebd4 100644 --- a/packages/integration-tests/src/utils.ts +++ b/packages/integration-tests/src/utils.ts @@ -12,3 +12,8 @@ export const generatePhone = () => { return crypto.getRandomValues(array).join(''); }; + +export const waitFor = async (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + });