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 type { Hook } from '@logto/schemas';
|
||||||
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
|
||||||
import { createMockUtils } from '@logto/shared/esm';
|
import { createMockUtils } from '@logto/shared/esm';
|
||||||
import { got } from 'got';
|
|
||||||
|
|
||||||
import { mockHook } from '#src/__mocks__/hook.js';
|
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 { jest } = import.meta;
|
||||||
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
||||||
|
@ -22,6 +23,12 @@ mockEsm('#src/utils/sign.js', () => ({
|
||||||
sign: () => mockSignature,
|
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 { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||||
|
|
||||||
const url = 'https://logto.gg';
|
const url = 'https://logto.gg';
|
||||||
|
@ -37,18 +44,14 @@ const hook: Hook = {
|
||||||
createdAt: Date.now() / 1000,
|
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 insertLog = jest.fn();
|
||||||
const mockHookState = { requestCount: 100, successCount: 10 };
|
const mockHookState = { requestCount: 100, successCount: 10 };
|
||||||
const getHookExecutionStatsByHookId = jest.fn().mockResolvedValue(mockHookState);
|
const getHookExecutionStatsByHookId = jest.fn().mockResolvedValue(mockHookState);
|
||||||
const findAllHooks = jest.fn().mockResolvedValue([hook]);
|
const findAllHooks = jest.fn().mockResolvedValue([hook]);
|
||||||
|
const findHookById = jest.fn().mockResolvedValue(hook);
|
||||||
|
|
||||||
const { createHookLibrary } = await import('./hook.js');
|
const { createHookLibrary } = await import('./index.js');
|
||||||
const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook } = createHookLibrary(
|
const { triggerInteractionHooksIfNeeded, attachExecutionStatsToHook, testHook } = createHookLibrary(
|
||||||
new MockQueries({
|
new MockQueries({
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
users: { findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }) },
|
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' }),
|
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
|
||||||
},
|
},
|
||||||
logs: { insertLog, getHookExecutionStatsByHookId },
|
logs: { insertLog, getHookExecutionStatsByHookId },
|
||||||
hooks: { findAllHooks },
|
hooks: { findAllHooks, findHookById },
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -86,13 +89,9 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(findAllHooks).toHaveBeenCalled();
|
expect(findAllHooks).toHaveBeenCalled();
|
||||||
expect(post).toHaveBeenCalledWith(url, {
|
expect(sendWebhookRequest).toHaveBeenCalledWith({
|
||||||
headers: {
|
hookConfig: hook.config,
|
||||||
'user-agent': 'Logto (https://logto.io/)',
|
payload: {
|
||||||
bar: 'baz',
|
|
||||||
'logto-signature-sha-256': mockSignature,
|
|
||||||
},
|
|
||||||
json: {
|
|
||||||
hookId: 'foo',
|
hookId: 'foo',
|
||||||
event: 'PostSignIn',
|
event: 'PostSignIn',
|
||||||
interactionEvent: 'SignIn',
|
interactionEvent: 'SignIn',
|
||||||
|
@ -102,8 +101,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
application: { id: 'app_id' },
|
application: { id: 'app_id' },
|
||||||
createdAt: new Date(100_000).toISOString(),
|
createdAt: new Date(100_000).toISOString(),
|
||||||
},
|
},
|
||||||
retry: { limit: 3 },
|
signingKey: hook.signingKey,
|
||||||
timeout: { request: 10_000 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const calledPayload: unknown = insertLog.mock.calls[0][0];
|
const calledPayload: unknown = insertLog.mock.calls[0][0];
|
||||||
|
@ -127,3 +125,35 @@ describe('attachExecutionStatsToHook', () => {
|
||||||
expect(result).toEqual({ ...mockHook, executionStats: mockHookState });
|
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,
|
userInfoSelectFields,
|
||||||
type Hook,
|
type Hook,
|
||||||
type HookResponse,
|
type HookResponse,
|
||||||
|
type HookConfig,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
import { conditional, pick, trySafe } from '@silverhand/essentials';
|
||||||
import { got, HTTPError } from 'got';
|
import { HTTPError } from 'got';
|
||||||
import { type Response } from 'got';
|
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
import { LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import { consoleLog } from '#src/utils/console.js';
|
import { consoleLog } from '#src/utils/console.js';
|
||||||
import { sign } from '#src/utils/sign.js';
|
|
||||||
|
|
||||||
const parseResponse = ({ statusCode, body }: Response) => ({
|
import { generateHookTestPayload, parseResponse, sendWebhookRequest } from './utils.js';
|
||||||
statusCode,
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventToHook: Record<InteractionEvent, HookEvent> = {
|
const eventToHook: Record<InteractionEvent, HookEvent> = {
|
||||||
[InteractionEvent.Register]: HookEvent.PostRegister,
|
[InteractionEvent.Register]: HookEvent.PostRegister,
|
||||||
|
@ -38,7 +34,7 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
logs: { insertLog, getHookExecutionStatsByHookId },
|
logs: { insertLog, getHookExecutionStatsByHookId },
|
||||||
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
|
// TODO: @gao should we use the library function thus we can pass full userinfo to the payload?
|
||||||
users: { findUserById },
|
users: { findUserById },
|
||||||
hooks: { findAllHooks },
|
hooks: { findAllHooks, findHookById },
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
const triggerInteractionHooksIfNeeded = async (
|
const triggerInteractionHooksIfNeeded = async (
|
||||||
|
@ -84,7 +80,7 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
} satisfies Omit<HookEventPayload, 'hookId'>;
|
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||||
|
|
||||||
await Promise.all(
|
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`);
|
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
|
||||||
const json: HookEventPayload = { hookId: id, ...payload };
|
const json: HookEventPayload = { hookId: id, ...payload };
|
||||||
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
|
||||||
|
@ -92,17 +88,11 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
logEntry.append({ json, hookId: id });
|
logEntry.append({ json, hookId: id });
|
||||||
|
|
||||||
// Trigger web hook and log response
|
// Trigger web hook and log response
|
||||||
await got
|
await sendWebhookRequest({
|
||||||
.post(url, {
|
hookConfig: config,
|
||||||
headers: {
|
payload: json,
|
||||||
'user-agent': 'Logto (https://logto.io/)',
|
signingKey,
|
||||||
...headers,
|
})
|
||||||
...conditional(signingKey && { 'logto-signature-sha-256': sign(signingKey, json) }),
|
|
||||||
},
|
|
||||||
json,
|
|
||||||
retry: { limit: retries ?? 3 },
|
|
||||||
timeout: { request: 10_000 },
|
|
||||||
})
|
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
logEntry.append({
|
logEntry.append({
|
||||||
response: parseResponse(response),
|
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> => ({
|
const attachExecutionStatsToHook = async (hook: Hook): Promise<HookResponse> => ({
|
||||||
...hook,
|
...hook,
|
||||||
executionStats: await getHookExecutionStatsByHookId(hook.id),
|
executionStats: await getHookExecutionStatsByHookId(hook.id),
|
||||||
|
@ -137,5 +157,6 @@ export const createHookLibrary = (queries: Queries) => {
|
||||||
return {
|
return {
|
||||||
triggerInteractionHooksIfNeeded,
|
triggerInteractionHooksIfNeeded,
|
||||||
attachExecutionStatsToHook,
|
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,
|
executionStats: mockExecutionStats,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const testHook = jest.fn();
|
||||||
|
|
||||||
const mockLibraries = {
|
const mockLibraries = {
|
||||||
hooks: {
|
hooks: {
|
||||||
attachExecutionStatsToHook,
|
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 () => {
|
it('PATCH /hooks/:id', async () => {
|
||||||
const targetMockHook = mockHookList[0] ?? mockHook;
|
const targetMockHook = mockHookList[0] ?? mockHook;
|
||||||
const name = 'newName';
|
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 { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, deduplicate, yes } from '@silverhand/essentials';
|
import { conditional, deduplicate, yes } from '@silverhand/essentials';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
|
@ -24,7 +24,7 @@ export default function hookRoutes<T extends AuthedRouter>(
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hooks: { attachExecutionStatsToHook },
|
hooks: { attachExecutionStatsToHook, testHook },
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
|
||||||
router.get(
|
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(
|
router.patch(
|
||||||
'/hooks/:id',
|
'/hooks/:id',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { createApplicationLibrary } from '#src/libraries/application.js';
|
import { createApplicationLibrary } from '#src/libraries/application.js';
|
||||||
import type { ConnectorLibrary } from '#src/libraries/connector.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 { createPasscodeLibrary } from '#src/libraries/passcode.js';
|
||||||
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
import { createPhraseLibrary } from '#src/libraries/phrase.js';
|
||||||
import { createResourceLibrary } from '#src/libraries/resource.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