mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(core): add suctom scopes.updated hook events (#5880)
* feat(core): add suctom scopes.updated hook events add scopes.updated hook event to role creation api * chore(core): add dev feature guard add dev feature gurad * feat(core): fetch scopes details and return to the hook fetch scopes details and return to the hook * refactor(core): mark deprecated body of roles/:id/scopes api mark deprecated body of roles/:id/scopes api * fix(test): fix unit test fix unit test
This commit is contained in:
parent
a1091aee20
commit
88f568f3c5
14 changed files with 280 additions and 106 deletions
|
@ -1,11 +1,22 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
InteractionHookEvent,
|
||||
managementApiHooksRegistration,
|
||||
type DataHookEvent,
|
||||
type InteractionApiMetadata,
|
||||
type ManagementApiContext,
|
||||
} from '@logto/schemas';
|
||||
import { type Optional } from '@silverhand/essentials';
|
||||
import { type Context } from 'koa';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
|
||||
import {
|
||||
buildManagementApiContext,
|
||||
buildManagementApiDataHookRegistrationKey,
|
||||
hasRegisteredDataHookEvent,
|
||||
} from './utils.js';
|
||||
|
||||
type ManagementApiHooksRegistrationKey = keyof typeof managementApiHooksRegistration;
|
||||
|
||||
type DataHookMetadata = {
|
||||
userAgent?: string;
|
||||
|
@ -16,13 +27,32 @@ type DataHookContext = {
|
|||
event: DataHookEvent;
|
||||
/** Data details */
|
||||
data?: unknown;
|
||||
} & Partial<ManagementApiContext>;
|
||||
} & Partial<ManagementApiContext> &
|
||||
Record<string, unknown>;
|
||||
|
||||
export class DataHookContextManager {
|
||||
contextArray: DataHookContext[] = [];
|
||||
|
||||
constructor(public metadata: DataHookMetadata) {}
|
||||
|
||||
getRegisteredDataHookEventContext(
|
||||
ctx: IRouterParamContext & Context
|
||||
): DataHookContext | undefined {
|
||||
const { method, _matchedRoute: matchedRoute } = ctx;
|
||||
|
||||
const key = buildManagementApiDataHookRegistrationKey(method, matchedRoute);
|
||||
|
||||
if (!hasRegisteredDataHookEvent(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
event: managementApiHooksRegistration[key],
|
||||
...buildManagementApiContext(ctx),
|
||||
data: ctx.response.body,
|
||||
};
|
||||
}
|
||||
|
||||
appendContext(context: DataHookContext) {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
this.contextArray.push(context);
|
||||
|
|
|
@ -9,7 +9,7 @@ import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js';
|
|||
import { generateHookTestPayload, parseResponse } from './utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
await mockIdGenerators();
|
||||
|
||||
|
@ -18,7 +18,7 @@ mockEsm('#src/utils/sign.js', () => ({
|
|||
sign: () => mockSignature,
|
||||
}));
|
||||
|
||||
const { sendWebhookRequest } = mockEsm('./utils.js', () => ({
|
||||
const { sendWebhookRequest } = await mockEsmWithActual('./utils.js', () => ({
|
||||
sendWebhookRequest: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 200, text: async () => '{"message":"ok"}' }),
|
||||
|
|
|
@ -4,8 +4,10 @@ import {
|
|||
type HookConfig,
|
||||
type HookEvent,
|
||||
type HookEventPayload,
|
||||
type ManagementApiContext,
|
||||
} from '@logto/schemas';
|
||||
import { conditional, trySafe } from '@silverhand/essentials';
|
||||
import { type Context } from 'koa';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
import ky, { type KyResponse } from 'ky';
|
||||
|
||||
|
@ -94,3 +96,17 @@ export const buildManagementApiDataHookRegistrationKey = (
|
|||
export const hasRegisteredDataHookEvent = (
|
||||
key: string
|
||||
): key is keyof typeof managementApiHooksRegistration => key in managementApiHooksRegistration;
|
||||
|
||||
export const buildManagementApiContext = (
|
||||
ctx: IRouterParamContext & Context
|
||||
): ManagementApiContext => {
|
||||
const { path, method, status, _matchedRoute: matchedRoute, params } = ctx;
|
||||
|
||||
return {
|
||||
path,
|
||||
method,
|
||||
status,
|
||||
params,
|
||||
matchedRoute: matchedRoute && String(matchedRoute),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,14 +1,9 @@
|
|||
import { managementApiHooksRegistration } from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { type MiddlewareType } from 'koa';
|
||||
import { type IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { DataHookContextManager } from '#src/libraries/hook/context-manager.js';
|
||||
import {
|
||||
buildManagementApiDataHookRegistrationKey,
|
||||
hasRegisteredDataHookEvent,
|
||||
} from '#src/libraries/hook/utils.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||
|
||||
|
@ -48,33 +43,12 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
|
|||
|
||||
await next();
|
||||
|
||||
const {
|
||||
path,
|
||||
method,
|
||||
status,
|
||||
_matchedRoute: matchedRoute,
|
||||
params,
|
||||
response: { body },
|
||||
} = ctx;
|
||||
|
||||
const hookRegistrationMatchedRouteKey = buildManagementApiDataHookRegistrationKey(
|
||||
method,
|
||||
matchedRoute
|
||||
);
|
||||
|
||||
// Auto append pre-registered management API hooks if any
|
||||
if (hasRegisteredDataHookEvent(hookRegistrationMatchedRouteKey)) {
|
||||
const event = managementApiHooksRegistration[hookRegistrationMatchedRouteKey];
|
||||
const registeredHookEventContext =
|
||||
dataHooksContextManager.getRegisteredDataHookEventContext(ctx);
|
||||
|
||||
dataHooksContextManager.appendContext({
|
||||
event,
|
||||
path,
|
||||
method,
|
||||
status,
|
||||
params,
|
||||
matchedRoute: matchedRoute && String(matchedRoute),
|
||||
data: body,
|
||||
});
|
||||
if (registeredHookEventContext) {
|
||||
dataHooksContextManager.appendContext(registeredHookEventContext);
|
||||
}
|
||||
|
||||
// Trigger data hooks
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { dateRegex } from '@logto/core-kit';
|
||||
import { getNewUsersResponseGuard, getActiveUsersResponseGuard } from '@logto/schemas';
|
||||
import { getActiveUsersResponseGuard, getNewUsersResponseGuard } from '@logto/schemas';
|
||||
import { endOfDay, format, subDays } from 'date-fns';
|
||||
import { number, object, string } from 'zod';
|
||||
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
import {
|
||||
type CreateOrganizationRole,
|
||||
OrganizationRoles,
|
||||
organizationRoleWithScopesGuard,
|
||||
type CreateOrganizationRole,
|
||||
type OrganizationRole,
|
||||
type OrganizationRoleKeys,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
|
||||
|
@ -13,7 +17,11 @@ import { organizationRoleSearchKeys } from '#src/queries/organization/index.js';
|
|||
import SchemaRouter from '#src/utils/SchemaRouter.js';
|
||||
import { parseSearchOptions } from '#src/utils/search.js';
|
||||
|
||||
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||
import {
|
||||
type ManagementApiRouter,
|
||||
type ManagementApiRouterContext,
|
||||
type RouterInitArgs,
|
||||
} from '../types.js';
|
||||
|
||||
import { errorHandler } from './utils.js';
|
||||
|
||||
|
@ -31,7 +39,13 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
|
|||
},
|
||||
]: RouterInitArgs<T>
|
||||
) {
|
||||
const router = new SchemaRouter(OrganizationRoles, roles, {
|
||||
const router = new SchemaRouter<
|
||||
OrganizationRoleKeys,
|
||||
CreateOrganizationRole,
|
||||
OrganizationRole,
|
||||
unknown,
|
||||
ManagementApiRouterContext
|
||||
>(OrganizationRoles, roles, {
|
||||
middlewares: [koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })],
|
||||
disabled: { get: true, post: true },
|
||||
errorHandler,
|
||||
|
@ -98,8 +112,24 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
|
|||
);
|
||||
}
|
||||
|
||||
const { isDevFeaturesEnabled } = EnvSet.values;
|
||||
|
||||
ctx.body = role;
|
||||
ctx.status = 201;
|
||||
|
||||
// Trigger `OrganizationRole.Scope.Updated` event if organizationScopeIds or resourceScopeIds are provided.
|
||||
// TODO: remove dev feature guard
|
||||
if (
|
||||
isDevFeaturesEnabled &&
|
||||
(organizationScopeIds.length > 0 || resourceScopeIds.length > 0)
|
||||
) {
|
||||
ctx.appendDataHookContext({
|
||||
event: 'OrganizationRole.Scopes.Updated',
|
||||
...buildManagementApiContext(ctx),
|
||||
organizationRoleId: role.id,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -3,8 +3,8 @@ import { pickDefault } from '@logto/shared/esm';
|
|||
|
||||
import {
|
||||
mockAdminUserRole,
|
||||
mockScope,
|
||||
mockResource,
|
||||
mockScope,
|
||||
mockScopeWithResource,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js';
|
||||
|
@ -105,7 +105,7 @@ describe('role scope routes', () => {
|
|||
const response = await roleRequester.post(`/roles/${mockAdminUserRole.id}/scopes`).send({
|
||||
scopeIds: [mockScope.id],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.status).toEqual(201);
|
||||
expect(validateRoleScopeAssignment).toHaveBeenCalledWith([mockScope.id], mockAdminUserRole.id);
|
||||
expect(insertRolesScopes).toHaveBeenCalledWith([
|
||||
{ id: mockId, roleId: mockAdminUserRole.id, scopeId: mockScope.id },
|
||||
|
|
|
@ -85,7 +85,7 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
|
|||
params: object({ id: string().min(1) }),
|
||||
body: object({ scopeIds: string().min(1).array().nonempty() }),
|
||||
response: Scopes.guard.array(),
|
||||
status: [200, 400, 404, 422],
|
||||
status: [201, 400, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
@ -100,9 +100,15 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
|
|||
scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: id, scopeId }))
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Align with organization role scopes assignment.
|
||||
* All relation assignment should not return the entity details at the body
|
||||
**/
|
||||
const newRolesScopes = await findRolesScopesByRoleId(id);
|
||||
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
|
||||
ctx.body = scopes;
|
||||
ctx.status = 201;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import type { RoleResponse } from '@logto/schemas';
|
||||
import { RoleType, Roles, featuredApplicationGuard, featuredUserGuard } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { pickState, tryThat } from '@silverhand/essentials';
|
||||
import { object, string, z, number } from 'zod';
|
||||
import { pickState, trySafe, tryThat } from '@silverhand/essentials';
|
||||
import { number, object, string, z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaPagination from '#src/middleware/koa-pagination.js';
|
||||
import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
|
||||
|
@ -21,6 +23,7 @@ export default function roleRoutes<T extends ManagementApiRouter>(
|
|||
const { queries, libraries } = tenant;
|
||||
const {
|
||||
rolesScopes: { insertRolesScopes },
|
||||
scopes: { findScopesByIds },
|
||||
roles: {
|
||||
countRoles,
|
||||
deleteRoleById,
|
||||
|
@ -172,6 +175,24 @@ export default function roleRoutes<T extends ManagementApiRouter>(
|
|||
await insertRolesScopes(
|
||||
scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: role.id, scopeId }))
|
||||
);
|
||||
|
||||
const { isDevFeaturesEnabled } = EnvSet.values;
|
||||
|
||||
// TODO: Remove dev feature guard
|
||||
if (isDevFeaturesEnabled) {
|
||||
// Trigger the `Role.Scopes.Updated` event if scopeIds are provided. Should not break the request
|
||||
await trySafe(async () => {
|
||||
// Align the response type with POST /roles/:id/scopes
|
||||
const newRolesScopes = await findScopesByIds(scopeIds);
|
||||
|
||||
ctx.appendDataHookContext({
|
||||
event: 'Role.Scopes.Updated',
|
||||
...buildManagementApiContext(ctx),
|
||||
roleId: role.id,
|
||||
data: newRolesScopes,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = role;
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
ssoConnectorWithProviderConfigGuard,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardShortId } from '@logto/shared';
|
||||
import { conditional, assert } from '@silverhand/essentials';
|
||||
import { assert, conditional } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -13,18 +13,18 @@ import koaPagination from '#src/middleware/koa-pagination.js';
|
|||
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
|
||||
import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js';
|
||||
import { ssoConnectorFactories } from '#src/sso/index.js';
|
||||
import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js';
|
||||
import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js';
|
||||
import { tableToPathname } from '#src/utils/SchemaRouter.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
|
||||
|
||||
import {
|
||||
parseFactoryDetail,
|
||||
parseConnectorConfig,
|
||||
fetchConnectorProviderDetails,
|
||||
validateConnectorDomains,
|
||||
parseConnectorConfig,
|
||||
parseFactoryDetail,
|
||||
validateConnectorConfigConnectionStatus,
|
||||
validateConnectorDomains,
|
||||
} from './utils.js';
|
||||
|
||||
export default function singleSignOnConnectorsRoutes<T extends ManagementApiRouter>(
|
||||
|
|
|
@ -9,7 +9,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
|
|||
|
||||
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
||||
|
||||
type ManagementApiRouterContext = WithAuthContext &
|
||||
export type ManagementApiRouterContext = WithAuthContext &
|
||||
WithLogContext &
|
||||
WithI18nContext &
|
||||
WithHookContext &
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import { hookEvents } from '@logto/schemas';
|
||||
|
||||
import { OrganizationRoleApi } from '#src/api/organization-role.js';
|
||||
import { OrganizationScopeApi } from '#src/api/organization-scope.js';
|
||||
import { createResource, deleteResource } from '#src/api/resource.js';
|
||||
import { createRole } from '#src/api/role.js';
|
||||
import { createScope } from '#src/api/scope.js';
|
||||
import { WebHookApiTest } from '#src/helpers/hook.js';
|
||||
import { generateName, generateRoleName } from '#src/utils.js';
|
||||
|
||||
import WebhookMockServer from './WebhookMockServer.js';
|
||||
import { assertHookLogResult } from './utils.js';
|
||||
|
||||
describe('trigger custom data hook events', () => {
|
||||
const webbHookMockServer = new WebhookMockServer(9999);
|
||||
const webHookApi = new WebHookApiTest();
|
||||
const hookName = 'customDataHookEventListener';
|
||||
|
||||
beforeAll(async () => {
|
||||
await webbHookMockServer.listen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await webbHookMockServer.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await webHookApi.create({
|
||||
name: hookName,
|
||||
events: [...hookEvents],
|
||||
config: { url: webbHookMockServer.endpoint },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await webHookApi.cleanUp();
|
||||
});
|
||||
|
||||
it('create roles with scopeIds should trigger Roles.Scopes.Updated event', async () => {
|
||||
const resource = await createResource();
|
||||
const scope = await createScope(resource.id);
|
||||
const hook = webHookApi.hooks.get(hookName)!;
|
||||
|
||||
const role = await createRole({ scopeIds: [scope.id] });
|
||||
|
||||
await assertHookLogResult(hook, 'Role.Created', {
|
||||
hookPayload: {
|
||||
event: 'Role.Created',
|
||||
path: '/roles',
|
||||
data: role,
|
||||
},
|
||||
});
|
||||
|
||||
await assertHookLogResult(hook, 'Role.Scopes.Updated', {
|
||||
hookPayload: {
|
||||
event: 'Role.Scopes.Updated',
|
||||
path: '/roles',
|
||||
roleId: role.id,
|
||||
data: [scope],
|
||||
},
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await deleteResource(resource.id);
|
||||
});
|
||||
|
||||
it('create organizationRoles with organizationScopeIds should trigger OrganizationRole.Scopes.Updated event', async () => {
|
||||
const roleApi = new OrganizationRoleApi();
|
||||
const organizationScopeApi = new OrganizationScopeApi();
|
||||
const scope = await organizationScopeApi.create({ name: generateName() });
|
||||
const hook = webHookApi.hooks.get(hookName)!;
|
||||
|
||||
const organizationRole = await roleApi.create({
|
||||
name: generateRoleName(),
|
||||
organizationScopeIds: [scope.id],
|
||||
});
|
||||
|
||||
await assertHookLogResult(hook, 'OrganizationRole.Created', {
|
||||
hookPayload: {
|
||||
event: 'OrganizationRole.Created',
|
||||
path: '/organization-roles',
|
||||
data: organizationRole,
|
||||
},
|
||||
});
|
||||
|
||||
await assertHookLogResult(hook, 'OrganizationRole.Scopes.Updated', {
|
||||
hookPayload: {
|
||||
event: 'OrganizationRole.Scopes.Updated',
|
||||
path: '/organization-roles',
|
||||
organizationRoleId: organizationRole.id,
|
||||
},
|
||||
});
|
||||
|
||||
await roleApi.delete(organizationRole.id);
|
||||
await organizationScopeApi.delete(scope.id);
|
||||
});
|
||||
});
|
|
@ -1,16 +1,11 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
InteractionHookEvent,
|
||||
LogResult,
|
||||
SignInIdentifier,
|
||||
hookEvents,
|
||||
type Hook,
|
||||
type HookEvent,
|
||||
} from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
|
||||
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 {
|
||||
|
@ -24,9 +19,10 @@ import {
|
|||
enableAllVerificationCodeSignInMethods,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js';
|
||||
import { generateEmail, generatePassword, waitFor } from '#src/utils.js';
|
||||
import { generateEmail, generatePassword } from '#src/utils.js';
|
||||
|
||||
import WebhookMockServer, { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js';
|
||||
import WebhookMockServer from './WebhookMockServer.js';
|
||||
import { assertHookLogResult } from './utils.js';
|
||||
|
||||
const webbHookMockServer = new WebhookMockServer(9999);
|
||||
const userNamePrefix = 'hookTriggerTestUser';
|
||||
|
@ -38,57 +34,6 @@ const email = generateEmail();
|
|||
const userApi = new UserApiTest();
|
||||
const webHookApi = new WebHookApiTest();
|
||||
|
||||
const assertHookLogResult = async (
|
||||
{ id: hookId, signingKey }: Hook,
|
||||
event: HookEvent,
|
||||
assertions: {
|
||||
errorMessage?: string;
|
||||
toBeUndefined?: boolean;
|
||||
hookPayload?: Record<string, unknown>;
|
||||
}
|
||||
) => {
|
||||
// Since the webhook request is async, we need to wait for a while to ensure the webhook response is received.
|
||||
await waitFor(50);
|
||||
|
||||
const logs = await getWebhookRecentLogs(
|
||||
hookId,
|
||||
new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' })
|
||||
);
|
||||
|
||||
const logEntry = logs[0];
|
||||
|
||||
if (assertions.toBeUndefined) {
|
||||
expect(logEntry).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
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(),
|
||||
|
|
55
packages/integration-tests/src/tests/api/hook/utils.ts
Normal file
55
packages/integration-tests/src/tests/api/hook/utils.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { LogResult, type Hook, type HookEvent } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
|
||||
import { getWebhookRecentLogs } from '#src/api/logs.js';
|
||||
import { waitFor } from '#src/utils.js';
|
||||
|
||||
import { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js';
|
||||
|
||||
export const assertHookLogResult = async (
|
||||
{ id: hookId, signingKey }: Hook,
|
||||
event: HookEvent,
|
||||
assertions: {
|
||||
errorMessage?: string;
|
||||
toBeUndefined?: boolean;
|
||||
hookPayload?: Record<string, unknown>;
|
||||
}
|
||||
) => {
|
||||
// Since the webhook request is async, we need to wait for a while to ensure the webhook response is received.
|
||||
await waitFor(100);
|
||||
|
||||
const logs = await getWebhookRecentLogs(
|
||||
hookId,
|
||||
new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' })
|
||||
);
|
||||
|
||||
const logEntry = logs[0];
|
||||
|
||||
if (assertions.toBeUndefined) {
|
||||
expect(logEntry).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
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 (assertions.hookPayload) {
|
||||
expect(result).toEqual(LogResult.Success);
|
||||
expect(payload.response).toBeTruthy();
|
||||
|
||||
const { body } = mockHookResponseGuard.parse(payload.response);
|
||||
expect(verifySignature(body.rawPayload, signingKey, body.signature)).toBeTruthy();
|
||||
expect(body.payload).toEqual(expect.objectContaining(assertions.hookPayload));
|
||||
}
|
||||
|
||||
if (assertions.errorMessage) {
|
||||
expect(result).toEqual(LogResult.Error);
|
||||
expect(error).toContain(assertions.errorMessage);
|
||||
}
|
||||
};
|
Loading…
Add table
Reference in a new issue