From 0c759a450c2c9cfefb663406f6e20134956d31ca Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 13 May 2024 11:48:13 +0800 Subject: [PATCH] test(core): add integration tests add integration tests for interaction hooks --- .../middleware/koa-interaction-hooks.ts | 6 +- .../src/helpers/connector.ts | 11 +- .../integration-tests/src/helpers/hook.ts | 32 +- .../src/helpers/interactions.ts | 43 +- .../src/tests/api/hook/WebhookMockServer.ts | 29 +- .../tests/api/hook/hook.trigger.data.test.ts | 69 +-- .../api/hook/hook.trigger.interaction.test.ts | 439 +++++++++--------- .../src/tests/api/hook/hook.trigger.test.ts | 270 +++++++++++ .../happy-path.test.ts | 202 ++------ 9 files changed, 674 insertions(+), 427 deletions(-) create mode 100644 packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts index 188f81f35256..7e51f0041bdc 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts @@ -71,13 +71,13 @@ export default function koaInteractionHooks< }); // Assign user and event data to the data hook context - const assignDataHookContext: AssignDataHookContext = ({ event, user, data }) => { + const assignDataHookContext: AssignDataHookContext = ({ event, user, data: extraData }) => { dataHookContext.appendContext({ event, data: { // Only return the selected user fields - ...conditional(user && { user: pick(user, ...userInfoSelectFields) }), - ...data, + ...conditional(user && pick(user, ...userInfoSelectFields)), + ...extraData, }, }); }; diff --git a/packages/integration-tests/src/helpers/connector.ts b/packages/integration-tests/src/helpers/connector.ts index f52d84b1c77d..5789bc8f685f 100644 --- a/packages/integration-tests/src/helpers/connector.ts +++ b/packages/integration-tests/src/helpers/connector.ts @@ -1,14 +1,14 @@ -import type { ConnectorType } from '@logto/schemas'; +import { ConnectorType } from '@logto/schemas'; import { mockEmailConnectorConfig, mockEmailConnectorId, mockSmsConnectorConfig, mockSmsConnectorId, - mockSocialConnectorId, mockSocialConnectorConfig, + mockSocialConnectorId, } from '#src/__mocks__/connectors-mock.js'; -import { listConnectors, deleteConnectorById, postConnector } from '#src/api/index.js'; +import { deleteConnectorById, listConnectors, postConnector } from '#src/api/index.js'; import { deleteSsoConnectorById, getSsoConnectors } from '#src/api/sso-connector.js'; export const clearConnectorsByTypes = async (types: ConnectorType[]) => { @@ -41,3 +41,8 @@ export const setSocialConnector = async () => connectorId: mockSocialConnectorId, config: mockSocialConnectorConfig, }); + +export const resetPasswordlessConnectors = async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await Promise.all([setEmailConnector(), setSmsConnector()]); +}; diff --git a/packages/integration-tests/src/helpers/hook.ts b/packages/integration-tests/src/helpers/hook.ts index b2dea044be3d..d730d8862936 100644 --- a/packages/integration-tests/src/helpers/hook.ts +++ b/packages/integration-tests/src/helpers/hook.ts @@ -1,4 +1,6 @@ -import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas'; +import { type CreateHook, type Hook, type HookConfig, type HookEvent } from '@logto/schemas'; + +import { authedAdminApi } from '#src/api/api.js'; type HookCreationPayload = Pick & { config: HookConfig; @@ -15,3 +17,31 @@ export const getHookCreationPayload = ( headers: { foo: 'bar' }, }, }); + +export class WebHookApiTest { + readonly #hooks = new Map(); + + get hooks(): Map { + return this.#hooks; + } + + async create(json: Omit): Promise { + const hook = await authedAdminApi.post('hooks', { json }).json(); + this.#hooks.set(hook.name, hook); + + return hook; + } + + async delete(name: string): Promise { + const hook = this.#hooks.get(name); + + if (hook) { + await authedAdminApi.delete(`hooks/${hook.id}`); + this.#hooks.delete(name); + } + } + + async cleanUp(): Promise { + await Promise.all(Array.from(this.#hooks.keys()).map(async (name) => this.delete(name))); + } +} diff --git a/packages/integration-tests/src/helpers/interactions.ts b/packages/integration-tests/src/helpers/interactions.ts index a744d8bae951..19e180846c08 100644 --- a/packages/integration-tests/src/helpers/interactions.ts +++ b/packages/integration-tests/src/helpers/interactions.ts @@ -1,20 +1,20 @@ import type { - UsernamePasswordPayload, EmailPasswordPayload, PhonePasswordPayload, + UsernamePasswordPayload, } from '@logto/schemas'; import { InteractionEvent } from '@logto/schemas'; import { - putInteraction, createSocialAuthorizationUri, patchInteractionIdentifiers, + putInteraction, putInteractionProfile, sendVerificationCode, } from '#src/api/index.js'; import { generateUserId } from '#src/utils.js'; -import { initClient, processSession, logoutClient } from './client.js'; +import { initClient, logoutClient, processSession } from './client.js'; import { expectRejects, readConnectorMessage } from './index.js'; import { enableAllPasswordSignInMethods } from './sign-in-experience.js'; import { generateNewUser } from './user.js'; @@ -90,6 +90,43 @@ export const createNewSocialUserWithUsernameAndPassword = async (connectorId: st return processSession(client, redirectTo); }; +export const signInWithUsernamePasswordAndUpdateEmailOrPhone = async ( + username: string, + password: string, + profile: { email: string } | { phone: string } +) => { + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + username, + password, + }, + }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_profile', + status: 422, + }); + + await client.successSend(sendVerificationCode, profile); + + const { code } = await readConnectorMessage('email' in profile ? 'Email' : 'Sms'); + + await client.successSend(patchInteractionIdentifiers, { + ...profile, + verificationCode: code, + }); + + await client.successSend(putInteractionProfile, profile); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); +}; + export const resetPassword = async ( profile: { email: string } | { phone: string }, newPassword: string diff --git a/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts b/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts index 952b8f95542c..1b9c77b09779 100644 --- a/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts +++ b/packages/integration-tests/src/tests/api/hook/WebhookMockServer.ts @@ -1,5 +1,9 @@ +import { createHmac } from 'node:crypto'; import { createServer, type RequestListener, type Server } from 'node:http'; +import { hookEventGuard } from '@logto/schemas'; +import { z } from 'zod'; + /** * A mock server that listens for incoming requests and responds with the request body. * @@ -28,11 +32,14 @@ class WebhookMockServer { request.on('end', () => { response.writeHead(200, { 'Content-Type': 'application/json' }); - const payload: unknown = JSON.parse(Buffer.concat(data).toString()); + // Keep the raw payload for signature verification + const rawPayload = Buffer.concat(data).toString(); + const payload: unknown = JSON.parse(rawPayload); const body = JSON.stringify({ signature: request.headers['logto-signature-sha-256'], payload, + rawPayload, }); requestCallback?.(body); @@ -61,4 +68,24 @@ class WebhookMockServer { } } +export const mockHookResponseGuard = z.object({ + body: z.object({ + signature: z.string(), + payload: z + .object({ + event: hookEventGuard, + createdAt: z.string(), + hookId: z.string(), + }) + .catchall(z.any()), + // Use the raw payload for signature verification + rawPayload: z.string(), + }), +}); + export default WebhookMockServer; + +export const verifySignature = (payload: string, secret: string, signature: string) => { + const calculatedSignature = createHmac('sha256', secret).update(payload).digest('hex'); + return calculatedSignature === signature; +}; diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts index d744c0e44ed4..5cf6b6bc210f 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -4,14 +4,15 @@ import { hookEvents, jsonGuard, managementApiHooksRegistration, - type Hook, type Role, } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; import { z } from 'zod'; import { authedAdminApi } from '#src/api/api.js'; import { createResource } from '#src/api/resource.js'; import { createScope } from '#src/api/scope.js'; +import { WebHookApiTest } from '#src/helpers/hook.js'; import { OrganizationApiTest, OrganizationRoleApiTest, @@ -20,7 +21,7 @@ import { import { UserApiTest, generateNewUser } from '#src/helpers/user.js'; import { generateName, waitFor } from '#src/utils.js'; -import WebhookMockServer from './WebhookMockServer.js'; +import WebhookMockServer, { verifySignature } from './WebhookMockServer.js'; import { organizationDataHookTestCases, organizationRoleDataHookTestCases, @@ -32,24 +33,28 @@ import { const mockHookResponseGuard = z.object({ signature: z.string(), - payload: z.object({ - event: hookEventGuard, - createdAt: z.string(), - hookId: z.string(), - data: jsonGuard.optional(), - method: z - .string() - .optional() - .transform((value) => value?.toUpperCase()), - matchedRoute: z.string().optional(), - }), + payload: z + .object({ + event: hookEventGuard, + createdAt: z.string(), + hookId: z.string(), + data: jsonGuard.optional(), + method: z + .string() + .optional() + .transform((value) => value?.toUpperCase()), + matchedRoute: z.string().optional(), + }) + .catchall(z.any()), + // Keep the raw payload for signature verification + rawPayload: z.string(), }); type MockHookResponse = z.infer; const hookName = 'management-api-hook'; -const webhooks = new Map(); const webhookResults = new Map(); +const webHookApi = new WebHookApiTest(); // Record the hook response to the webhookResults map. // Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered. @@ -80,27 +85,17 @@ const webhookServer = new WebhookMockServer(9999, webhookResponseHandler); beforeAll(async () => { await webhookServer.listen(); - const webhookInstance = await authedAdminApi - .post('hooks', { - json: { - name: hookName, - events: [...hookEvents], - config: { - url: webhookServer.endpoint, - headers: { foo: 'bar' }, - }, - }, - }) - .json(); - - webhooks.set(hookName, webhookInstance); + await webHookApi.create({ + name: hookName, + events: [...hookEvents], + config: { + url: webhookServer.endpoint, + }, + }); }); afterAll(async () => { - await Promise.all( - Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`)) - ); - + await webHookApi.cleanUp(); await webhookServer.close(); }); @@ -332,11 +327,17 @@ describe('organization role data hook events', () => { ); }); -describe('data hook events coverage', () => { +describe('data hook events coverage and signature verification', () => { const keys = Object.keys(managementApiHooksRegistration); + it.each(keys)('should have test case for %s', async (key) => { + const webhook = webHookApi.hooks.get(hookName)!; + const webhookResult = await getWebhookResult(key); expect(webhookResult).toBeDefined(); - expect(webhookResult?.signature).toBeDefined(); + assert(webhookResult, new Error('webhookResult is undefined')); + + const { signature, rawPayload } = webhookResult; + expect(verifySignature(rawPayload, webhook.signingKey, signature)).toBeTruthy(); }); }); diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts index 0ea20f897c40..5d9fa08b8d2e 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.interaction.test.ts @@ -1,270 +1,295 @@ -import { createHmac } from 'node:crypto'; -import { type RequestListener } from 'node:http'; - import { - ConnectorType, + InteractionEvent, InteractionHookEvent, LogResult, SignInIdentifier, + hookEvents, type Hook, - type Log, - type LogContextPayload, - type LogKey, + type HookEvent, } from '@logto/schemas'; -import { type Optional } from '@silverhand/essentials'; +import { assert } from '@silverhand/essentials'; -import { deleteUser } from '#src/api/admin-user.js'; import { authedAdminApi } from '#src/api/api.js'; import { getWebhookRecentLogs } from '#src/api/logs.js'; +import { resetPasswordlessConnectors } from '#src/helpers/connector.js'; +import { WebHookApiTest } from '#src/helpers/hook.js'; import { - clearConnectorsByTypes, - setEmailConnector, - setSmsConnector, -} from '#src/helpers/connector.js'; -import { getHookCreationPayload } from '#src/helpers/hook.js'; -import { createMockServer } from '#src/helpers/index.js'; -import { registerNewUser, resetPassword, signInWithPassword } from '#src/helpers/interactions.js'; + registerNewUser, + resetPassword, + signInWithPassword, + signInWithUsernamePasswordAndUpdateEmailOrPhone, +} from '#src/helpers/interactions.js'; import { enableAllPasswordSignInMethods, enableAllVerificationCodeSignInMethods, } from '#src/helpers/sign-in-experience.js'; -import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; -import { generatePassword, waitFor } from '#src/utils.js'; +import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js'; +import { generateEmail, generatePassword } from '#src/utils.js'; -type HookSecureData = { - signature: string; - payload: string; -}; +import WebhookMockServer, { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js'; -// Note: return hook payload and signature for webhook security testing -const hookServerRequestListener: RequestListener = (request, response) => { - // eslint-disable-next-line @silverhand/fp/no-mutation - response.statusCode = 204; +const webbHookMockServer = new WebhookMockServer(9999); +const userNamePrefix = 'hookTriggerTestUser'; +const username = `${userNamePrefix}_0`; +const password = generatePassword(); +// For email fulfilling and reset password use +const email = generateEmail(); - const data: Uint8Array[] = []; - request.on('data', (chunk: Uint8Array) => { - // eslint-disable-next-line @silverhand/fp/no-mutating-methods - data.push(chunk); - }); +const userApi = new UserApiTest(); +const webHookApi = new WebHookApiTest(); - request.on('end', () => { - response.writeHead(200, { 'Content-Type': 'application/json' }); - const payload = Buffer.concat(data).toString(); - response.end( - JSON.stringify({ - signature: request.headers['logto-signature-sha-256'] as string, - payload, - } satisfies HookSecureData) - ); - }); -}; +const assertHookLogResult = async ( + { id: hookId, signingKey }: Hook, + event: HookEvent, + assertions: { + errorMessage?: string; + toBeUndefined?: boolean; + hookPayload?: Record; + } +) => { + const logs = await getWebhookRecentLogs( + hookId, + new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' }) + ); -const assertHookLogError = ({ result, error }: LogContextPayload, errorMessage: string) => - result === LogResult.Error && typeof error === 'string' && error.includes(errorMessage); + const logEntry = logs[0]; -describe('trigger hooks', () => { - const { listen, close } = createMockServer(9999, hookServerRequestListener); + if (assertions.toBeUndefined) { + expect(logEntry).toBeUndefined(); + return; + } - beforeAll(async () => { - await enableAllPasswordSignInMethods({ + expect(logEntry).toBeTruthy(); + assert(logEntry, new Error('Log entry not found')); + + const { payload } = logEntry; + + expect(payload.hookId).toEqual(hookId); + expect(payload.key).toEqual(`TriggerHook.${event}`); + + const { result, error } = payload; + + if (result === LogResult.Success) { + expect(payload.response).toBeTruthy(); + + const { body } = mockHookResponseGuard.parse(payload.response); + expect(verifySignature(body.rawPayload, signingKey, body.signature)).toBeTruthy(); + + if (assertions.hookPayload) { + expect(body.payload).toEqual(expect.objectContaining(assertions.hookPayload)); + } + } + + if (assertions.errorMessage) { + expect(result).toEqual(LogResult.Error); + expect(error).toContain(assertions.errorMessage); + } +}; + +beforeAll(async () => { + await Promise.all([ + resetPasswordlessConnectors(), + enableAllPasswordSignInMethods({ identifiers: [SignInIdentifier.Username], password: true, verify: false, - }); - await listen(); - }); - - afterAll(async () => { - await close(); - }); + }), + webbHookMockServer.listen(), + userApi.create({ username, password }), + ]); +}); - it('should trigger sign-in hook and record error when interaction finished', async () => { - const createdHook = await authedAdminApi - .post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostSignIn) }) - .json(); - const logKey: LogKey = 'TriggerHook.PostSignIn'; +afterAll(async () => { + await Promise.all([userApi.cleanUp(), webbHookMockServer.close()]); +}); - const { - userProfile: { username, password }, - user, - } = await generateNewUser({ username: true, password: true }); +describe('trigger invalid hook', () => { + beforeAll(async () => { + await webHookApi.create({ + name: 'invalidHookEventListener', + events: [InteractionHookEvent.PostSignIn], + config: { url: 'not_work_url' }, + }); + }); + it('should log invalid hook url error', async () => { await signInWithPassword({ username, password }); - // Check hook trigger log - const logs = await getWebhookRecentLogs( - createdHook.id, - new URLSearchParams({ logKey, page_size: '100' }) - ); - - const hookLog = logs.find(({ payload: { hookId } }) => hookId === createdHook.id); - expect(hookLog).toBeTruthy(); + const hook = webHookApi.hooks.get('invalidHookEventListener')!; - if (hookLog) { - expect( - assertHookLogError(hookLog.payload, 'Failed to parse URL from not_work_url') - ).toBeTruthy(); - } + await assertHookLogResult(hook, InteractionHookEvent.PostSignIn, { + errorMessage: 'Failed to parse URL from not_work_url', + }); + }); - // Clean up - await authedAdminApi.delete(`hooks/${createdHook.id}`); - await deleteUser(user.id); + afterAll(async () => { + await webHookApi.cleanUp(); }); +}); - it('should trigger multiple register hooks and record properly when interaction finished', async () => { - const [hook1, hook2, hook3] = await Promise.all([ - authedAdminApi - .post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostRegister) }) - .json(), - authedAdminApi - .post('hooks', { - json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'), - }) - .json(), - // Using the old API to create a hook - authedAdminApi - .post('hooks', { - json: { - event: InteractionHookEvent.PostRegister, - config: { url: 'http://localhost:9999', retries: 2 }, - }, - }) - .json(), +describe('interaction api trigger hooks', () => { + // Use new hooks for each test to ensure test isolation + beforeEach(async () => { + await Promise.all([ + webHookApi.create({ + name: 'interactionHookEventListener', + events: Object.values(InteractionHookEvent), + config: { url: webbHookMockServer.endpoint }, + }), + webHookApi.create({ + name: 'dataHookEventListener', + events: hookEvents.filter((event) => !(event in InteractionHookEvent)), + config: { url: webbHookMockServer.endpoint }, + }), + webHookApi.create({ + name: 'registerOnlyInteractionHookEventListener', + events: [InteractionHookEvent.PostRegister], + config: { url: webbHookMockServer.endpoint }, + }), ]); - const logKey: LogKey = 'TriggerHook.PostRegister'; + }); + + afterEach(async () => { + await webHookApi.cleanUp(); + }); + it('new user registration interaction API', async () => { + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const registerHook = webHookApi.hooks.get('registerOnlyInteractionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; const { username, password } = generateNewUserProfile({ username: true, password: true }); const userId = await registerNewUser(username, password); - type HookRequest = { - body: { - userIp?: string; - } & Record; + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostRegister, + interactionEvent: InteractionEvent.Register, + sessionId: expect.any(String), + user: expect.objectContaining({ id: userId, username }), }; - // Check hook trigger log - for (const [hook, expectedResult, expectedError] of [ - [hook1, LogResult.Error, 'Failed to parse URL from not_work_url'], - [hook2, LogResult.Success, undefined], - [hook3, LogResult.Success, undefined], - ] satisfies Array<[Hook, LogResult, Optional]>) { - // eslint-disable-next-line no-await-in-loop - const logs = await getWebhookRecentLogs( - hook.id, - new URLSearchParams({ logKey, page_size: '100' }) - ); - - const log = logs.find(({ payload: { hookId } }) => hookId === hook.id); - - expect(log).toBeTruthy(); - - // Skip the test if the log is not found - if (!log) { - return; - } + await assertHookLogResult(interactionHook, InteractionHookEvent.PostRegister, { + hookPayload: interactionHookEventPayload, + }); - // Assert user ip is in the hook request - expect((log.payload.hookRequest as HookRequest).body.userIp).toBeTruthy(); + // Verify multiple hooks can be triggered with the same event + await assertHookLogResult(registerHook, InteractionHookEvent.PostRegister, { + hookPayload: interactionHookEventPayload, + }); - // Assert the log result and error message - expect(log.payload.result).toEqual(expectedResult); + // Verify data hook is triggered + await assertHookLogResult(dataHook, 'User.Created', { + hookPayload: { + event: 'User.Created', + interactionEvent: InteractionEvent.Register, + sessionId: expect.any(String), + data: expect.objectContaining({ id: userId, username }), + }, + }); - if (expectedError) { - expect(assertHookLogError(log.payload, expectedError)).toBeTruthy(); - } - } + // Assert user updated event is not triggered + await assertHookLogResult(dataHook, 'User.Updated', { + toBeUndefined: true, + }); // Clean up - await Promise.all([ - authedAdminApi.delete(`hooks/${hook1.id}`), - authedAdminApi.delete(`hooks/${hook2.id}`), - authedAdminApi.delete(`hooks/${hook3.id}`), - ]); - await deleteUser(userId); + await authedAdminApi.delete(`users/${userId}`); }); - it('should secure webhook payload data successfully', async () => { - const createdHook = await authedAdminApi - .post('hooks', { - json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'), - }) - .json(); + it('user sign in interaction API without profile update', async () => { + await signInWithPassword({ username, password }); - const { username, password } = generateNewUserProfile({ username: true, password: true }); - const userId = await registerNewUser(username, password); + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; - const logs = await authedAdminApi - .get(`hooks/${createdHook.id}/recent-logs?page_size=100`) - .json(); + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostSignIn, + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username }), + }; - const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id); - expect(log).toBeTruthy(); + await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, { + hookPayload: interactionHookEventPayload, + }); - const response = log?.payload.response; - expect(response).toBeTruthy(); + // Verify user create data hook is not triggered + await assertHookLogResult(dataHook, 'User.Created', { + toBeUndefined: true, + }); - const { - body: { signature, payload }, - } = response as { body: HookSecureData }; + await assertHookLogResult(dataHook, 'User.Updated', { + toBeUndefined: true, + }); + }); - expect(signature).toBeTruthy(); - expect(payload).toBeTruthy(); + it('user sign in interaction API with profile update', async () => { + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }); - const calculateSignature = createHmac('sha256', createdHook.signingKey) - .update(payload) - .digest('hex'); + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; - expect(calculateSignature).toEqual(signature); + await signInWithUsernamePasswordAndUpdateEmailOrPhone(username, password, { + email, + }); - await authedAdminApi.delete(`hooks/${createdHook.id}`); + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostSignIn, + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username }), + }; - await deleteUser(userId); + await assertHookLogResult(interactionHook, InteractionHookEvent.PostSignIn, { + hookPayload: interactionHookEventPayload, + }); + + // Verify user create data hook is not triggered + await assertHookLogResult(dataHook, 'User.Created', { + toBeUndefined: true, + }); + + await assertHookLogResult(dataHook, 'User.Updated', { + hookPayload: { + event: 'User.Updated', + interactionEvent: InteractionEvent.SignIn, + sessionId: expect.any(String), + data: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }, + }); }); - it('should trigger reset password hook and record properly when interaction finished', async () => { - await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); - await setEmailConnector(); - await setSmsConnector(); - await enableAllVerificationCodeSignInMethods({ - identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone], - password: true, - verify: true, + it('password reset interaction API', async () => { + const newPassword = generatePassword(); + const interactionHook = webHookApi.hooks.get('interactionHookEventListener')!; + const dataHook = webHookApi.hooks.get('dataHookEventListener')!; + const user = userApi.users.find(({ username: name }) => name === username)!; + + await resetPassword({ email }, newPassword); + + const interactionHookEventPayload: Record = { + event: InteractionHookEvent.PostResetPassword, + interactionEvent: InteractionEvent.ForgotPassword, + sessionId: expect.any(String), + user: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }; + + await assertHookLogResult(interactionHook, InteractionHookEvent.PostResetPassword, { + hookPayload: interactionHookEventPayload, }); - // Create a reset password hook - const resetPasswordHook = await authedAdminApi - .post('hooks', { - json: getHookCreationPayload( - InteractionHookEvent.PostResetPassword, - 'http://localhost:9999' - ), - }) - .json(); - const logKey: LogKey = 'TriggerHook.PostResetPassword'; - - const { user, userProfile } = await generateNewUser({ - primaryPhone: true, - primaryEmail: true, - password: true, + + await assertHookLogResult(dataHook, 'User.Updated', { + hookPayload: { + event: 'User.Updated', + interactionEvent: InteractionEvent.ForgotPassword, + sessionId: expect.any(String), + data: expect.objectContaining({ id: user.id, username, primaryEmail: email }), + }, }); - // Reset Password by Email - await resetPassword({ email: userProfile.primaryEmail }, generatePassword()); - // Reset Password by Phone - await resetPassword({ phone: userProfile.primaryPhone }, generatePassword()); - // Wait for the hook to be trigged - await waitFor(1000); - - const relatedLogs = await getWebhookRecentLogs( - resetPasswordHook.id, - new URLSearchParams({ logKey, page_size: '100' }) - ); - const succeedLogs = relatedLogs.filter( - ({ payload: { result } }) => result === LogResult.Success - ); - - expect(succeedLogs).toHaveLength(2); - - await authedAdminApi.delete(`hooks/${resetPasswordHook.id}`); - await deleteUser(user.id); - await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); }); }); diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts new file mode 100644 index 000000000000..0ea20f897c40 --- /dev/null +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.test.ts @@ -0,0 +1,270 @@ +import { createHmac } from 'node:crypto'; +import { type RequestListener } from 'node:http'; + +import { + ConnectorType, + InteractionHookEvent, + LogResult, + SignInIdentifier, + type Hook, + type Log, + type LogContextPayload, + type LogKey, +} from '@logto/schemas'; +import { type Optional } from '@silverhand/essentials'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { authedAdminApi } from '#src/api/api.js'; +import { getWebhookRecentLogs } from '#src/api/logs.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { getHookCreationPayload } from '#src/helpers/hook.js'; +import { createMockServer } from '#src/helpers/index.js'; +import { registerNewUser, resetPassword, signInWithPassword } from '#src/helpers/interactions.js'; +import { + enableAllPasswordSignInMethods, + enableAllVerificationCodeSignInMethods, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; +import { generatePassword, waitFor } from '#src/utils.js'; + +type HookSecureData = { + signature: string; + payload: string; +}; + +// Note: return hook payload and signature for webhook security testing +const hookServerRequestListener: RequestListener = (request, response) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + response.statusCode = 204; + + const data: Uint8Array[] = []; + request.on('data', (chunk: Uint8Array) => { + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + data.push(chunk); + }); + + request.on('end', () => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + const payload = Buffer.concat(data).toString(); + response.end( + JSON.stringify({ + signature: request.headers['logto-signature-sha-256'] as string, + payload, + } satisfies HookSecureData) + ); + }); +}; + +const assertHookLogError = ({ result, error }: LogContextPayload, errorMessage: string) => + result === LogResult.Error && typeof error === 'string' && error.includes(errorMessage); + +describe('trigger hooks', () => { + const { listen, close } = createMockServer(9999, hookServerRequestListener); + + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + await listen(); + }); + + afterAll(async () => { + await close(); + }); + + it('should trigger sign-in hook and record error when interaction finished', async () => { + const createdHook = await authedAdminApi + .post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostSignIn) }) + .json(); + const logKey: LogKey = 'TriggerHook.PostSignIn'; + + const { + userProfile: { username, password }, + user, + } = await generateNewUser({ username: true, password: true }); + + await signInWithPassword({ username, password }); + + // Check hook trigger log + const logs = await getWebhookRecentLogs( + createdHook.id, + new URLSearchParams({ logKey, page_size: '100' }) + ); + + const hookLog = logs.find(({ payload: { hookId } }) => hookId === createdHook.id); + expect(hookLog).toBeTruthy(); + + if (hookLog) { + expect( + assertHookLogError(hookLog.payload, 'Failed to parse URL from not_work_url') + ).toBeTruthy(); + } + + // Clean up + await authedAdminApi.delete(`hooks/${createdHook.id}`); + await deleteUser(user.id); + }); + + it('should trigger multiple register hooks and record properly when interaction finished', async () => { + const [hook1, hook2, hook3] = await Promise.all([ + authedAdminApi + .post('hooks', { json: getHookCreationPayload(InteractionHookEvent.PostRegister) }) + .json(), + authedAdminApi + .post('hooks', { + json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'), + }) + .json(), + // Using the old API to create a hook + authedAdminApi + .post('hooks', { + json: { + event: InteractionHookEvent.PostRegister, + config: { url: 'http://localhost:9999', retries: 2 }, + }, + }) + .json(), + ]); + const logKey: LogKey = 'TriggerHook.PostRegister'; + + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const userId = await registerNewUser(username, password); + + type HookRequest = { + body: { + userIp?: string; + } & Record; + }; + + // Check hook trigger log + for (const [hook, expectedResult, expectedError] of [ + [hook1, LogResult.Error, 'Failed to parse URL from not_work_url'], + [hook2, LogResult.Success, undefined], + [hook3, LogResult.Success, undefined], + ] satisfies Array<[Hook, LogResult, Optional]>) { + // eslint-disable-next-line no-await-in-loop + const logs = await getWebhookRecentLogs( + hook.id, + new URLSearchParams({ logKey, page_size: '100' }) + ); + + const log = logs.find(({ payload: { hookId } }) => hookId === hook.id); + + expect(log).toBeTruthy(); + + // Skip the test if the log is not found + if (!log) { + return; + } + + // Assert user ip is in the hook request + expect((log.payload.hookRequest as HookRequest).body.userIp).toBeTruthy(); + + // Assert the log result and error message + expect(log.payload.result).toEqual(expectedResult); + + if (expectedError) { + expect(assertHookLogError(log.payload, expectedError)).toBeTruthy(); + } + } + + // Clean up + await Promise.all([ + authedAdminApi.delete(`hooks/${hook1.id}`), + authedAdminApi.delete(`hooks/${hook2.id}`), + authedAdminApi.delete(`hooks/${hook3.id}`), + ]); + await deleteUser(userId); + }); + + it('should secure webhook payload data successfully', async () => { + const createdHook = await authedAdminApi + .post('hooks', { + json: getHookCreationPayload(InteractionHookEvent.PostRegister, 'http://localhost:9999'), + }) + .json(); + + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const userId = await registerNewUser(username, password); + + const logs = await authedAdminApi + .get(`hooks/${createdHook.id}/recent-logs?page_size=100`) + .json(); + + const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id); + expect(log).toBeTruthy(); + + const response = log?.payload.response; + expect(response).toBeTruthy(); + + const { + body: { signature, payload }, + } = response as { body: HookSecureData }; + + expect(signature).toBeTruthy(); + expect(payload).toBeTruthy(); + + const calculateSignature = createHmac('sha256', createdHook.signingKey) + .update(payload) + .digest('hex'); + + expect(calculateSignature).toEqual(signature); + + await authedAdminApi.delete(`hooks/${createdHook.id}`); + + await deleteUser(userId); + }); + + it('should trigger reset password hook and record properly when interaction finished', async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone], + password: true, + verify: true, + }); + // Create a reset password hook + const resetPasswordHook = await authedAdminApi + .post('hooks', { + json: getHookCreationPayload( + InteractionHookEvent.PostResetPassword, + 'http://localhost:9999' + ), + }) + .json(); + const logKey: LogKey = 'TriggerHook.PostResetPassword'; + + const { user, userProfile } = await generateNewUser({ + primaryPhone: true, + primaryEmail: true, + password: true, + }); + // Reset Password by Email + await resetPassword({ email: userProfile.primaryEmail }, generatePassword()); + // Reset Password by Phone + await resetPassword({ phone: userProfile.primaryPhone }, generatePassword()); + // Wait for the hook to be trigged + await waitFor(1000); + + const relatedLogs = await getWebhookRecentLogs( + resetPasswordHook.id, + new URLSearchParams({ logKey, page_size: '100' }) + ); + const succeedLogs = relatedLogs.filter( + ({ payload: { result } }) => result === LogResult.Success + ); + + expect(succeedLogs).toHaveLength(2); + + await authedAdminApi.delete(`hooks/${resetPasswordHook.id}`); + await deleteUser(user.id); + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts index fe66b572a6ee..94e7007c37b5 100644 --- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts @@ -1,24 +1,16 @@ -import { - InteractionEvent, - ConnectorType, - SignInIdentifier, - UsersPasswordEncryptionMethod, -} from '@logto/schemas'; +import { ConnectorType, SignInIdentifier, UsersPasswordEncryptionMethod } from '@logto/schemas'; -import { - putInteraction, - sendVerificationCode, - patchInteractionIdentifiers, - putInteractionProfile, - deleteUser, -} from '#src/api/index.js'; -import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { deleteUser } from '#src/api/index.js'; import { clearConnectorsByTypes, - setSmsConnector, setEmailConnector, + setSmsConnector, } from '#src/helpers/connector.js'; -import { readConnectorMessage, expectRejects, createUserByAdmin } from '#src/helpers/index.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { + signInWithPassword, + signInWithUsernamePasswordAndUpdateEmailOrPhone, +} from '#src/helpers/interactions.js'; import { enableAllPasswordSignInMethods, enableAllVerificationCodeSignInMethods, @@ -40,20 +32,8 @@ describe('Sign-in flow using password identifiers', () => { it('sign-in with username and password', async () => { const { userProfile, user } = await generateNewUser({ username: true, password: true }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - username: userProfile.username, - password: userProfile.password, - }, - }); - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); + await signInWithPassword({ username: userProfile.username, password: userProfile.password }); await deleteUser(user.id); }); @@ -61,81 +41,31 @@ describe('Sign-in flow using password identifiers', () => { it('sign-in with username and password twice to test algorithm transition', async () => { const username = generateUsername(); const password = 'password'; + const user = await createUserByAdmin({ username, passwordDigest: '5f4dcc3b5aa765d61d8327deb882cf99', passwordAlgorithm: UsersPasswordEncryptionMethod.MD5, }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - username, - password, - }, - }); - const { redirectTo } = await client.submitInteraction(); + await signInWithPassword({ username, password }); - await processSession(client, redirectTo); - await logoutClient(client); - - const client2 = await initClient(); - - await client2.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - username, - password, - }, - }); - - const { redirectTo: redirectTo2 } = await client2.submitInteraction(); - - await processSession(client2, redirectTo2); - await logoutClient(client2); + await signInWithPassword({ username, password }); await deleteUser(user.id); }); it('sign-in with email and password', async () => { const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - email: userProfile.primaryEmail, - password: userProfile.password, - }, - }); - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); + await signInWithPassword({ email: userProfile.primaryEmail, password: userProfile.password }); await deleteUser(user.id); }); it('sign-in with phone and password', async () => { const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - phone: userProfile.primaryPhone, - password: userProfile.password, - }, - }); - - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); - + await signInWithPassword({ phone: userProfile.primaryPhone, password: userProfile.password }); await deleteUser(user.id); }); @@ -149,54 +79,16 @@ describe('Sign-in flow using password identifiers', () => { const { userProfile, user } = await generateNewUser({ username: true, password: true }); const { primaryEmail } = generateNewUserProfile({ primaryEmail: true }); - const client = await initClient(); - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - username: userProfile.username, - password: userProfile.password, - }, - }); - - await expectRejects(client.submitInteraction(), { - code: 'user.missing_profile', - status: 422, - }); - - await client.successSend(sendVerificationCode, { - email: primaryEmail, - }); - - const { code } = await readConnectorMessage('Email'); - - await client.successSend(patchInteractionIdentifiers, { - email: primaryEmail, - verificationCode: code, - }); - - await client.successSend(putInteractionProfile, { - email: primaryEmail, - }); - - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); - - // SignIn with email and password - await client.initSession(); - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { + await signInWithUsernamePasswordAndUpdateEmailOrPhone( + userProfile.username, + userProfile.password, + { email: primaryEmail, - password: userProfile.password, - }, - }); + } + ); - const { redirectTo: redirectTo2 } = await client.submitInteraction(); - await processSession(client, redirectTo2); - await logoutClient(client); + await signInWithPassword({ email: primaryEmail, password: userProfile.password }); await deleteUser(user.id); }); @@ -211,54 +103,14 @@ describe('Sign-in flow using password identifiers', () => { const { userProfile, user } = await generateNewUser({ username: true, password: true }); const { primaryPhone } = generateNewUserProfile({ primaryPhone: true }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - username: userProfile.username, - password: userProfile.password, - }, - }); - await expectRejects(client.submitInteraction(), { - code: 'user.missing_profile', - status: 422, - }); - - await client.successSend(sendVerificationCode, { - phone: primaryPhone, - }); - - const { code } = await readConnectorMessage('Sms'); - - await client.successSend(patchInteractionIdentifiers, { - phone: primaryPhone, - verificationCode: code, - }); - - await client.successSend(putInteractionProfile, { - phone: primaryPhone, - }); - - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); - - // SignIn with new phone and password - await client.initSession(); - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { + await signInWithUsernamePasswordAndUpdateEmailOrPhone( + userProfile.username, + userProfile.password, + { phone: primaryPhone, - password: userProfile.password, - }, - }); - - const { redirectTo: redirectTo2 } = await client.submitInteraction(); - await processSession(client, redirectTo2); - await logoutClient(client); + } + ); await deleteUser(user.id); });