0
Fork 0
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:
Xiao Yijun 2023-05-25 12:16:29 +08:00 committed by GitHub
parent afd2257e55
commit d3d63b1562
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 333 additions and 43 deletions

View file

@ -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,
})
);
});
});

View file

@ -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,
};
};

View 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 },
});
});
});

View 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',
},
};
};

View file

@ -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';

View file

@ -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({

View file

@ -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';

View file

@ -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);
});
});