mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): test webhook (#3889)
This commit is contained in:
parent
afd2257e55
commit
d3d63b1562
8 changed files with 333 additions and 43 deletions
|
@ -1,11 +1,12 @@
|
|||
import type { Hook } from '@logto/schemas';
|
||||
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { got } from 'got';
|
||||
|
||||
import { mockHook } from '#src/__mocks__/hook.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
||||
import type { Interaction } from './hook.js';
|
||||
import type { Interaction } from './index.js';
|
||||
import { generateHookTestPayload, parseResponse } from './utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
||||
|
@ -22,6 +23,12 @@ mockEsm('#src/utils/sign.js', () => ({
|
|||
sign: () => mockSignature,
|
||||
}));
|
||||
|
||||
const { sendWebhookRequest } = mockEsm('./utils.js', () => ({
|
||||
sendWebhookRequest: jest.fn().mockResolvedValue({ statusCode: 200, body: '{"message":"ok"}' }),
|
||||
generateHookTestPayload,
|
||||
parseResponse,
|
||||
}));
|
||||
|
||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||
|
||||
const url = 'https://logto.gg';
|
||||
|
@ -37,18 +44,14 @@ const hook: Hook = {
|
|||
createdAt: Date.now() / 1000,
|
||||
};
|
||||
|
||||
const post = jest
|
||||
.spyOn(got, 'post')
|
||||
// @ts-expect-error
|
||||
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
|
||||
|
||||
const insertLog = jest.fn();
|
||||
const mockHookState = { requestCount: 100, successCount: 10 };
|
||||
const getHookExecutionStatsByHookId = jest.fn().mockResolvedValue(mockHookState);
|
||||
const findAllHooks = jest.fn().mockResolvedValue([hook]);
|
||||
const findHookById = jest.fn().mockResolvedValue(hook);
|
||||
|
||||
const { createHookLibrary } = await import('./hook.js');
|
||||
const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook } = createHookLibrary(
|
||||
const { createHookLibrary } = await import('./index.js');
|
||||
const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook, testHook } = createHookLibrary(
|
||||
new MockQueries({
|
||||
// @ts-expect-error
|
||||
users: { findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }) },
|
||||
|
@ -57,7 +60,7 @@ const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook } = createHo
|
|||
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
|
||||
},
|
||||
logs: { insertLog, getHookExecutionStatsByHookId },
|
||||
hooks: { findAllHooks },
|
||||
hooks: { findAllHooks, findHookById },
|
||||
})
|
||||
);
|
||||
|
||||
|
@ -86,13 +89,9 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
);
|
||||
|
||||
expect(findAllHooks).toHaveBeenCalled();
|
||||
expect(post).toHaveBeenCalledWith(url, {
|
||||
headers: {
|
||||
'user-agent': 'Logto (https://logto.io/)',
|
||||
bar: 'baz',
|
||||
'logto-signature-sha-256': mockSignature,
|
||||
},
|
||||
json: {
|
||||
expect(sendWebhookRequest).toHaveBeenCalledWith({
|
||||
hookConfig: hook.config,
|
||||
payload: {
|
||||
hookId: 'foo',
|
||||
event: 'PostSignIn',
|
||||
interactionEvent: 'SignIn',
|
||||
|
@ -102,8 +101,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
application: { id: 'app_id' },
|
||||
createdAt: new Date(100_000).toISOString(),
|
||||
},
|
||||
retry: { limit: 3 },
|
||||
timeout: { request: 10_000 },
|
||||
signingKey: hook.signingKey,
|
||||
});
|
||||
|
||||
const calledPayload: unknown = insertLog.mock.calls[0][0];
|
||||
|
@ -127,3 +125,35 @@ describe('attachExecutionStatsToHook', () => {
|
|||
expect(result).toEqual({ ...mockHook, executionStats: mockHookState });
|
||||
});
|
||||
});
|
||||
|
||||
describe('testHook', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call sendWebhookRequest with correct values', async () => {
|
||||
await testHook(hook.id, [HookEvent.PostSignIn], hook.config);
|
||||
const testHookPayload = generateHookTestPayload(hook.id, HookEvent.PostSignIn);
|
||||
expect(sendWebhookRequest).toHaveBeenCalledWith({
|
||||
hookConfig: hook.config,
|
||||
payload: testHookPayload,
|
||||
signingKey: hook.signingKey,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call sendWebhookRequest with correct times if multiple events are provided', async () => {
|
||||
await testHook(hook.id, [HookEvent.PostSignIn, HookEvent.PostResetPassword], hook.config);
|
||||
expect(sendWebhookRequest).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw send test payload failed error if sendWebhookRequest fails', async () => {
|
||||
sendWebhookRequest.mockRejectedValueOnce(new Error('test error'));
|
||||
await expect(testHook(hook.id, [HookEvent.PostSignIn], hook.config)).rejects.toThrowError(
|
||||
new RequestError({
|
||||
code: 'hook.send_test_payload_failed',
|
||||
message: 'test error',
|
||||
status: 500,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -6,23 +6,19 @@ import {
|
|||
userInfoSelectFields,
|
||||
type Hook,
|
||||
type HookResponse,
|
||||
type HookConfig,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
||||
import { got, HTTPError } from 'got';
|
||||
import { type Response } from 'got';
|
||||
import { HTTPError } from 'got';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { consoleLog } from '#src/utils/console.js';
|
||||
import { sign } from '#src/utils/sign.js';
|
||||
|
||||
const parseResponse = ({ statusCode, body }: Response) => ({
|
||||
statusCode,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
|
||||
});
|
||||
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
|
||||
|
||||
const eventToHook: Record<InteractionEvent, HookEvent> = {
|
||||
[InteractionEvent.Register]: HookEvent.PostRegister,
|
||||
|
@ -38,7 +34,7 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
logs: { insertLog, getHookExecutionStatsByHookId },
|
||||
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
|
||||
users: { findUserById },
|
||||
hooks: { findAllHooks },
|
||||
hooks: { findAllHooks, findHookById },
|
||||
} = queries;
|
||||
|
||||
const triggerInteractionHooksIfNeeded = async (
|
||||
|
@ -84,7 +80,7 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||
|
||||
await Promise.all(
|
||||
rows.map(async ({ config: { url, headers, retries }, id, signingKey }) => {
|
||||
rows.map(async ({ id, config, signingKey }) => {
|
||||
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
|
||||
const json: HookEventPayload = { hookId: id, ...payload };
|
||||
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
||||
|
@ -92,17 +88,11 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
logEntry.append({ json, hookId: id });
|
||||
|
||||
// Trigger web hook and log response
|
||||
await got
|
||||
.post(url, {
|
||||
headers: {
|
||||
'user-agent': 'Logto (https://logto.io/)',
|
||||
...headers,
|
||||
...conditional(signingKey && { 'logto-signature-sha-256': sign(signingKey, json) }),
|
||||
},
|
||||
json,
|
||||
retry: { limit: retries ?? 3 },
|
||||
timeout: { request: 10_000 },
|
||||
})
|
||||
await sendWebhookRequest({
|
||||
hookConfig: config,
|
||||
payload: json,
|
||||
signingKey,
|
||||
})
|
||||
.then(async (response) => {
|
||||
logEntry.append({
|
||||
response: parseResponse(response),
|
||||
|
@ -129,6 +119,36 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
);
|
||||
};
|
||||
|
||||
const testHook = async (hookId: string, events: HookEvent[], config: HookConfig) => {
|
||||
const { signingKey } = await findHookById(hookId);
|
||||
try {
|
||||
await Promise.all(
|
||||
events.map(async (event) => {
|
||||
const testPayload = generateHookTestPayload(hookId, event);
|
||||
await sendWebhookRequest({
|
||||
hookConfig: config,
|
||||
payload: testPayload,
|
||||
signingKey,
|
||||
});
|
||||
})
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
/**
|
||||
* Note: We only care about whether the test payload is sent to the endpoint of the webhook,
|
||||
* so we don't care about http errors returned by the endpoint.
|
||||
*/
|
||||
if (error instanceof HTTPError) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new RequestError({
|
||||
code: 'hook.send_test_payload_failed',
|
||||
message: conditional(error instanceof Error && String(error)) ?? 'Unknown error',
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const attachExecutionStatsToHook = async (hook: Hook): Promise<HookResponse> => ({
|
||||
...hook,
|
||||
executionStats: await getHookExecutionStatsByHookId(hook.id),
|
||||
|
@ -137,5 +157,6 @@ export const createHookLibrary = (queries: Queries) => {
|
|||
return {
|
||||
triggerInteractionHooksIfNeeded,
|
||||
attachExecutionStatsToHook,
|
||||
testHook,
|
||||
};
|
||||
};
|
50
packages/core/src/libraries/hook/utils.test.ts
Normal file
50
packages/core/src/libraries/hook/utils.test.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { HookEvent } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { got } from 'got';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
const post = jest
|
||||
.spyOn(got, 'post')
|
||||
// @ts-expect-error
|
||||
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
|
||||
|
||||
const mockSignature = 'mockSignature';
|
||||
mockEsm('#src/utils/sign.js', () => ({
|
||||
sign: () => mockSignature,
|
||||
}));
|
||||
|
||||
const { generateHookTestPayload, sendWebhookRequest } = await import('./utils.js');
|
||||
|
||||
describe('sendWebhookRequest', () => {
|
||||
it('should call got.post with correct values', async () => {
|
||||
const mockHookId = 'mockHookId';
|
||||
const mockEvent: HookEvent = HookEvent.PostSignIn;
|
||||
const testPayload = generateHookTestPayload(mockHookId, mockEvent);
|
||||
|
||||
const mockUrl = 'https://logto.gg';
|
||||
const mockSigningKey = 'mockSigningKey';
|
||||
|
||||
await sendWebhookRequest({
|
||||
hookConfig: {
|
||||
url: mockUrl,
|
||||
headers: { foo: 'bar' },
|
||||
},
|
||||
payload: testPayload,
|
||||
signingKey: mockSigningKey,
|
||||
});
|
||||
|
||||
expect(post).toBeCalledWith(mockUrl, {
|
||||
headers: {
|
||||
'user-agent': 'Logto (https://logto.io/)',
|
||||
foo: 'bar',
|
||||
'logto-signature-sha-256': mockSignature,
|
||||
},
|
||||
json: testPayload,
|
||||
retry: { limit: 3 },
|
||||
timeout: { request: 10_000 },
|
||||
});
|
||||
});
|
||||
});
|
79
packages/core/src/libraries/hook/utils.ts
Normal file
79
packages/core/src/libraries/hook/utils.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
type HookEvent,
|
||||
type HookEventPayload,
|
||||
ApplicationType,
|
||||
type HookConfig,
|
||||
} from '@logto/schemas';
|
||||
import { conditional, trySafe } from '@silverhand/essentials';
|
||||
import { got, type Response } from 'got';
|
||||
|
||||
import { sign } from '#src/utils/sign.js';
|
||||
|
||||
export const parseResponse = ({ statusCode, body }: Response) => ({
|
||||
statusCode,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
|
||||
});
|
||||
|
||||
type SendWebhookRequest = {
|
||||
hookConfig: HookConfig;
|
||||
payload: HookEventPayload;
|
||||
signingKey: string;
|
||||
};
|
||||
|
||||
export const sendWebhookRequest = async ({
|
||||
hookConfig,
|
||||
payload,
|
||||
signingKey,
|
||||
}: SendWebhookRequest) => {
|
||||
const { url, headers, retries } = hookConfig;
|
||||
|
||||
return got.post(url, {
|
||||
headers: {
|
||||
'user-agent': 'Logto (https://logto.io/)',
|
||||
...headers,
|
||||
...conditional(signingKey && { 'logto-signature-sha-256': sign(signingKey, payload) }),
|
||||
},
|
||||
json: payload,
|
||||
retry: { limit: retries ?? 3 },
|
||||
timeout: { request: 10_000 },
|
||||
});
|
||||
};
|
||||
|
||||
export const generateHookTestPayload = (hookId: string, event: HookEvent): HookEventPayload => {
|
||||
const fakeUserId = 'fake-user-id';
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
hookId,
|
||||
event,
|
||||
createdAt: now.toISOString(),
|
||||
sessionId: 'fake-session-id',
|
||||
userAgent: 'fake-user-agent',
|
||||
userId: fakeUserId,
|
||||
user: {
|
||||
id: fakeUserId,
|
||||
username: 'fake-user',
|
||||
primaryEmail: 'fake-user@fake-service.com',
|
||||
primaryPhone: '1234567890',
|
||||
name: 'Fake User',
|
||||
avatar: 'https://fake-service.com/avatars/fake-user.png',
|
||||
customData: { theme: 'light' },
|
||||
identities: {
|
||||
google: {
|
||||
userId: 'fake-google-user-id',
|
||||
},
|
||||
},
|
||||
applicationId: 'fake-application-id',
|
||||
isSuspended: false,
|
||||
lastSignInAt: now.getTime(),
|
||||
createdAt: now.getTime(),
|
||||
},
|
||||
application: {
|
||||
id: 'fake-spa-application-id',
|
||||
type: ApplicationType.SPA,
|
||||
name: 'Fake Application',
|
||||
description: 'Fake application data for testing',
|
||||
},
|
||||
};
|
||||
};
|
|
@ -77,9 +77,12 @@ const attachExecutionStatsToHook = jest.fn().mockImplementation((hook) => ({
|
|||
executionStats: mockExecutionStats,
|
||||
}));
|
||||
|
||||
const testHook = jest.fn();
|
||||
|
||||
const mockLibraries = {
|
||||
hooks: {
|
||||
attachExecutionStatsToHook,
|
||||
testHook,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -225,6 +228,15 @@ describe('hook routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('POST /hooks/:id/test should return 204 if test is successful', async () => {
|
||||
const targetMockHook = mockHookList[0] ?? mockHook;
|
||||
const response = await hookRequest.post(`/hooks/${targetMockHook.id}/test`).send({
|
||||
events: [HookEvent.PostRegister],
|
||||
config: { url: 'https://example.com' },
|
||||
});
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
||||
it('PATCH /hooks/:id', async () => {
|
||||
const targetMockHook = mockHookList[0] ?? mockHook;
|
||||
const name = 'newName';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Hooks, Logs, hookEventsGuard, hookResponseGuard } from '@logto/schemas';
|
||||
import { Hooks, Logs, hookConfigGuard, hookEventsGuard, hookResponseGuard } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { conditional, deduplicate, yes } from '@silverhand/essentials';
|
||||
import { subDays } from 'date-fns';
|
||||
|
@ -24,7 +24,7 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
} = queries;
|
||||
|
||||
const {
|
||||
hooks: { attachExecutionStatsToHook },
|
||||
hooks: { attachExecutionStatsToHook, testHook },
|
||||
} = libraries;
|
||||
|
||||
router.get(
|
||||
|
@ -130,6 +130,27 @@ export default function hookRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/hooks/:id/test',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string() }),
|
||||
body: z.object({ events: nonemptyUniqueHookEventsGuard, config: hookConfigGuard }),
|
||||
status: [204, 404, 500],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
body: { events, config },
|
||||
} = ctx.guard;
|
||||
|
||||
await testHook(id, events, config);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/hooks/:id',
|
||||
koaGuard({
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { createApplicationLibrary } from '#src/libraries/application.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import { createHookLibrary } from '#src/libraries/hook.js';
|
||||
import { createHookLibrary } from '#src/libraries/hook/index.js';
|
||||
import { createPasscodeLibrary } from '#src/libraries/passcode.js';
|
||||
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
||||
import { createResourceLibrary } from '#src/libraries/resource.js';
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import { HookEvent, type Hook } from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from '#src/api/api.js';
|
||||
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
|
||||
import { getHookCreationPayload } from '#src/helpers/hook.js';
|
||||
import { createMockServer } from '#src/helpers/index.js';
|
||||
|
||||
const responseSuccessPort = 9999;
|
||||
const responseSuccessEndpoint = `http://localhost:${responseSuccessPort}`;
|
||||
|
||||
const responseErrorPort = 9998;
|
||||
const responseErrorEndpoint = `http://localhost:${responseErrorPort}`;
|
||||
|
||||
describe('hook testing', () => {
|
||||
const { listen, close } = createMockServer(responseSuccessPort);
|
||||
const { listen: listenError, close: closeError } = createMockServer(
|
||||
responseErrorPort,
|
||||
(request, response) => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
response.statusCode = 500;
|
||||
response.end();
|
||||
}
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
await listen();
|
||||
await listenError();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await close();
|
||||
await closeError();
|
||||
});
|
||||
|
||||
it('should return 204 if test hook successfully', async () => {
|
||||
const payload = getHookCreationPayload(HookEvent.PostRegister, responseSuccessEndpoint);
|
||||
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
|
||||
const response = await authedAdminApi.post(`hooks/${created.id}/test`, {
|
||||
json: { events: [HookEvent.PostSignIn], config: { url: responseSuccessEndpoint } },
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
|
||||
// Clean Up
|
||||
await authedAdminApi.delete(`hooks/${created.id}`);
|
||||
});
|
||||
|
||||
it('should return 404 if the hook to test does not exist', async () => {
|
||||
const invalidHookId = 'invalid_id';
|
||||
await expect(
|
||||
authedAdminApi.post(`hooks/${invalidHookId}/test`, {
|
||||
json: { events: [HookEvent.PostSignIn], config: { url: responseSuccessEndpoint } },
|
||||
})
|
||||
).rejects.toMatchObject(createResponseWithCode(404));
|
||||
});
|
||||
|
||||
it('should return 500 if the hook endpoint is not working', async () => {
|
||||
const payload = getHookCreationPayload(HookEvent.PostRegister);
|
||||
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
|
||||
await expect(
|
||||
authedAdminApi.post(`hooks/${created.id}/test`, {
|
||||
json: { events: [HookEvent.PostSignIn], config: { url: 'not_work_url' } },
|
||||
})
|
||||
).rejects.toMatchObject(createResponseWithCode(500));
|
||||
|
||||
// Clean Up
|
||||
await authedAdminApi.delete(`hooks/${created.id}`);
|
||||
});
|
||||
|
||||
it('should return 204 if the hook endpoint return 500', async () => {
|
||||
const payload = getHookCreationPayload(HookEvent.PostRegister);
|
||||
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
|
||||
const response = await authedAdminApi.post(`hooks/${created.id}/test`, {
|
||||
json: { events: [HookEvent.PostSignIn], config: { url: responseErrorEndpoint } },
|
||||
});
|
||||
expect(response.statusCode).toBe(204);
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue