diff --git a/packages/core/src/libraries/hook/context-manager.ts b/packages/core/src/libraries/hook/context-manager.ts index 91b842fe8..45fb16fa1 100644 --- a/packages/core/src/libraries/hook/context-manager.ts +++ b/packages/core/src/libraries/hook/context-manager.ts @@ -43,6 +43,9 @@ type UserContext = { user: User; }; +/** + * A map of data hook event to its context type for better type hinting. + */ export type DataHookContextMap = { 'Organization.Membership.Updated': { organizationId: string }; 'User.Created': UserContext; diff --git a/packages/core/src/libraries/hook/index.test.ts b/packages/core/src/libraries/hook/index.test.ts index ef5e827a4..79b3e0a87 100644 --- a/packages/core/src/libraries/hook/index.test.ts +++ b/packages/core/src/libraries/hook/index.test.ts @@ -198,10 +198,7 @@ describe('triggerDataHooks()', () => { const hookData = { path: '/test', method: 'POST', data: { success: true } }; const hooksManager = new DataHookContextManager(metadata); - hooksManager.appendContext({ - event: 'Role.Created', - ...hookData, - }); + hooksManager.appendContext('Role.Created', hookData); await triggerDataHooks(new ConsoleLog(), hooksManager); @@ -257,8 +254,7 @@ describe('triggerDataHooks()', () => { const hooksManager = new DataHookContextManager(metadata); - hooksManager.appendContext({ - event: 'Role.Created', + hooksManager.appendContext('Role.Created', { data: { id: 'user_id', username: 'user' }, }); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index f70a5044e..043ec64ea 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -70,6 +70,14 @@ const converBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => { }; }; +export type InsertUserResult = [ + User, + { + /** The organization IDs that the user has been provisioned into. */ + organizationIds: readonly string[]; + }, +]; + export type UserLibrary = ReturnType; export const createUserLibrary = (queries: Queries) => { @@ -107,14 +115,6 @@ export const createUserLibrary = (queries: Queries) => { { retries, factor: 0 } // No need for exponential backoff ); - type InsertUserResult = [ - User, - { - /** The organization IDs that the user has been provisioned into. */ - organizationsIds: readonly string[]; - }, - ]; - const insertUser = async ( data: OmitAutoSetFields, additionalRoleNames: string[] @@ -166,7 +166,7 @@ export const createUserLibrary = (queries: Queries) => { return []; }; - return [user, { organizationsIds: await provisionOrganizations() }]; + return [user, { organizationIds: await provisionOrganizations() }]; }); }; diff --git a/packages/core/src/routes/admin-user/basics.test.ts b/packages/core/src/routes/admin-user/basics.test.ts index 99c272e35..4de3d6e5a 100644 --- a/packages/core/src/routes/admin-user/basics.test.ts +++ b/packages/core/src/routes/admin-user/basics.test.ts @@ -5,6 +5,7 @@ import { removeUndefinedKeys } from '@silverhand/essentials'; import { mockUser, mockUserResponse } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; @@ -81,10 +82,13 @@ const signOutUser = jest.fn(); const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...removeUndefinedKeys(user), // No undefined values will be returned from database - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), verifyUserPassword, signOutUser, diff --git a/packages/core/src/routes/admin-user/basics.ts b/packages/core/src/routes/admin-user/basics.ts index 062e275b7..330c47ff1 100644 --- a/packages/core/src/routes/admin-user/basics.ts +++ b/packages/core/src/routes/admin-user/basics.ts @@ -200,7 +200,7 @@ export default function adminUserBasicsRoutes( const id = await generateUserId(); - const [user, { organizationsIds }] = await insertUser( + const [user, { organizationIds }] = await insertUser( { id, primaryEmail, @@ -221,7 +221,7 @@ export default function adminUserBasicsRoutes( [] ); - for (const organizationId of organizationsIds) { + for (const organizationId of organizationIds) { ctx.appendDataHookContext('Organization.Membership.Updated', { ...buildManagementApiContext(ctx), organizationId, diff --git a/packages/core/src/routes/admin-user/mfa-verifications.test.ts b/packages/core/src/routes/admin-user/mfa-verifications.test.ts index 4e0ac5811..84c85a2f6 100644 --- a/packages/core/src/routes/admin-user/mfa-verifications.test.ts +++ b/packages/core/src/routes/admin-user/mfa-verifications.test.ts @@ -8,6 +8,7 @@ import { mockUserTotpMfaVerification, mockUserWithMfaVerifications, } from '#src/__mocks__/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -49,10 +50,13 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () => const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...removeUndefinedKeys(user), // No undefined values will be returned from database - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/admin-user/search.test.ts b/packages/core/src/routes/admin-user/search.test.ts index 8ff623024..83d7daf8e 100644 --- a/packages/core/src/routes/admin-user/search.test.ts +++ b/packages/core/src/routes/admin-user/search.test.ts @@ -1,9 +1,10 @@ import type { CreateUser, Role, User } from '@logto/schemas'; import { userInfoSelectFields, RoleType } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; -import { pick } from '@silverhand/essentials'; +import { pick, removeUndefinedKeys } from '@silverhand/essentials'; import { mockUser, mockUserList, mockUserListResponse } from '#src/__mocks__/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -52,10 +53,13 @@ const mockedQueries = { const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...user, - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/admin-user/social.test.ts b/packages/core/src/routes/admin-user/social.test.ts index 0d6e4677a..c6dfd41f2 100644 --- a/packages/core/src/routes/admin-user/social.test.ts +++ b/packages/core/src/routes/admin-user/social.test.ts @@ -1,5 +1,6 @@ import { ConnectorType, type CreateUser, type User } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; +import { removeUndefinedKeys } from '@silverhand/essentials'; import { mockConnector0, @@ -9,6 +10,7 @@ import { } from '#src/__mocks__/index.js'; import { mockUser } from '#src/__mocks__/user.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -47,10 +49,13 @@ const mockedQueries = { const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...user, - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts index e45458c13..c02d179e4 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts @@ -52,7 +52,10 @@ const userQueries = { const { hasActiveUsers, updateUserById } = userQueries; -const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() }; +const userLibraries = { + generateUserId: jest.fn().mockResolvedValue('uid'), + insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]), +}; const { generateUserId, insertUser } = userLibraries; const submitInteraction = await pickDefault(import('./submit-interaction.js')); @@ -73,7 +76,7 @@ describe('submit action', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { params: {} } as Awaited>, assignInteractionHookResult: jest.fn(), - assignDataHookContext: jest.fn(), + appendDataHookContext: jest.fn(), }; const profile = { username: 'username', diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 4c49dc5da..90f6a384f 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -9,6 +9,7 @@ import { import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; +import { type InsertUserResult } from '#src/libraries/user.js'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -62,7 +63,9 @@ const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = u const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), - insertUser: jest.fn(async (user: CreateUser) => user as User), + insertUser: jest.fn( + async (user: CreateUser): Promise => [user as User, { organizationIds: [] }] + ), }; const { generateUserId, insertUser } = userLibraries; @@ -84,7 +87,7 @@ describe('submit action', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { params: {} } as Awaited>, assignInteractionHookResult: jest.fn(), - assignDataHookContext: jest.fn(), + appendDataHookContext: jest.fn(), }; const profile = { username: 'username', @@ -153,8 +156,7 @@ describe('submit action', () => { login: { accountId: 'uid' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Created', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Created', { user: { id: 'uid', ...upsertProfile, @@ -188,8 +190,7 @@ describe('submit action', () => { login: { accountId: 'pending-account-id' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Created', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Created', { user: { id: 'pending-account-id', ...upsertProfile, @@ -336,7 +337,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).not.toBeCalled(); + expect(ctx.appendDataHookContext).not.toBeCalled(); }); it('sign-in with new profile', async () => { @@ -371,8 +372,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: updateProfile, }); }); @@ -432,8 +432,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: { primaryEmail: 'email', name: userInfo.name, @@ -458,8 +457,7 @@ describe('submit action', () => { passwordEncryptionMethod: 'plain', }); expect(assignInteractionResults).not.toBeCalled(); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: { passwordEncrypted: 'passwordEncrypted', passwordEncryptionMethod: 'plain', diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 77cb43140..36f428f42 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -135,7 +135,7 @@ async function handleSubmitRegister( (invitation) => invitation.status === OrganizationInvitationStatus.Pending ); - const [user, { organizationsIds }] = await insertUser( + const [user, { organizationIds }] = await insertUser( { id, ...userProfile, @@ -190,7 +190,7 @@ async function handleSubmitRegister( ctx.assignInteractionHookResult({ userId: id }); ctx.appendDataHookContext('User.Created', { user }); - for (const organizationId of organizationsIds) { + for (const organizationId of organizationIds) { ctx.appendDataHookContext('Organization.Membership.Updated', { organizationId, }); diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 831dd90f4..eca0d1ad7 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -13,6 +13,7 @@ import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; +import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); @@ -25,7 +26,7 @@ const updateUserSsoIdentityMock = jest.fn(); const insertUserSsoIdentityMock = jest.fn(); const updateUserMock = jest.fn(); const findUserByEmailMock = jest.fn(); -const insertUserMock = jest.fn(); +const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizationIds: [] }]); const generateUserIdMock = jest.fn().mockResolvedValue('foo'); const getAvailableSsoConnectorsMock = jest.fn(); @@ -59,12 +60,14 @@ const { } = await import('./single-sign-on.js'); describe('Single sign on util methods tests', () => { - const mockContext: WithLogContext & WithInteractionDetailsContext = { + const mockContext = { ...createContextWithRouteParameters(), ...createMockLogContext(), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { jti: 'foo' } as Awaited>, - }; + assignInteractionHookResult: jest.fn(), + appendDataHookContext: jest.fn(), + } satisfies WithInteractionHooksContext>; const mockProvider = createMockProvider(); @@ -288,7 +291,7 @@ describe('Single sign on util methods tests', () => { describe('registerWithSsoAuthentication tests', () => { it('should register if no related user account found', async () => { - insertUserMock.mockResolvedValueOnce({ id: 'foo' }); + insertUserMock.mockResolvedValueOnce([{ id: 'foo' }, { organizationIds: [] }]); const { id } = await registerWithSsoAuthentication(mockContext, tenant, { connectorId: wellConfiguredSsoConnector.id, diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index d8a5fddd1..e67514a3b 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -310,7 +310,7 @@ export const registerWithSsoAuthentication = async ( }; // Insert new user - const [user, { organizationsIds }] = await usersLibrary.insertUser( + const [user, { organizationIds }] = await usersLibrary.insertUser( { id: await usersLibrary.generateUserId(), ...syncingProfile, @@ -318,7 +318,7 @@ export const registerWithSsoAuthentication = async ( }, [] ); - for (const organizationId of organizationsIds) { + for (const organizationId of organizationIds) { ctx.appendDataHookContext('Organization.Membership.Updated', { organizationId, }); diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts index 9d8d6ce21..b64c83aba 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts @@ -1,8 +1,3 @@ -/** - * @fileoverview - * Tests for manual-triggered data hook events. - */ - import { SignInIdentifier, hookEvents, userInfoSelectFields } from '@logto/schemas'; import { pick } from '@silverhand/essentials'; @@ -10,6 +5,7 @@ import { deleteUser } from '#src/api/admin-user.js'; import { createResource, deleteResource } from '#src/api/resource.js'; import { createRole } from '#src/api/role.js'; import { createScope } from '#src/api/scope.js'; +import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js'; import { WebHookApiTest } from '#src/helpers/hook.js'; import { registerWithEmail } from '#src/helpers/interactions.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; @@ -133,7 +129,11 @@ describe('manual data hook tests', () => { await assertOrganizationMembershipUpdated(organization.id); }); + // TODO: Add user deletion test case + it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => { + await setEmailConnector(); + await setSmsConnector(); await enableAllVerificationCodeSignInMethods({ identifiers: [SignInIdentifier.Email], password: false, 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 e4801d3d1..f6071e3ea 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 @@ -1,8 +1,3 @@ -/** - * @fileoverview - * Tests for automatic data hook events triggered by the Management API. - */ - import { RoleType, hookEventGuard,