0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(test): split hook integration tests into several files (#3891)

This commit is contained in:
Xiao Yijun 2023-05-24 15:01:32 +08:00 committed by GitHub
parent 2d8a226970
commit 37c3f5952d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 393 additions and 382 deletions

View file

@ -0,0 +1,17 @@
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
type HookCreationPayload = Pick<Hook, 'name' | 'events'> & {
config: HookConfig;
};
export const getHookCreationPayload = (
event: HookEvent,
url = 'not_work_url'
): HookCreationPayload => ({
name: 'hook_name',
events: [event],
config: {
url,
headers: { foo: 'bar' },
},
});

View file

@ -30,8 +30,9 @@ export const registerNewUser = async (username: string, password: string) => {
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
const userId = await processSession(client, redirectTo);
await logoutClient(client);
return userId;
};
export const signInWithPassword = async (

View file

@ -0,0 +1,91 @@
import {
type Hook,
HookEvent,
type HookResponse,
type Log,
LogResult,
SignInIdentifier,
} from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { authedAdminApi } from '#src/api/api.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
import { createMockServer } from '#src/helpers/index.js';
import { registerNewUser } from '#src/helpers/interactions.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUserProfile } from '#src/helpers/user.js';
describe('hook logs', () => {
const { listen, close } = createMockServer(9999);
beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
await listen();
});
afterAll(async () => {
await close();
});
it('should get recent hook logs correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUser(username, password);
const logs = await authedAdminApi
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
.json<Log[]>();
expect(
logs.some(
({ payload: { hookId, result } }) =>
hookId === createdHook.id && result === LogResult.Success
)
).toBeTruthy();
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(userId);
});
it('should get hook execution stats correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
const hooksWithExecutionStats = await authedAdminApi
.get('hooks?includeExecutionStats=true')
.json<HookResponse[]>();
for (const hook of hooksWithExecutionStats) {
expect(hook.executionStats).toBeTruthy();
}
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUser(username, password);
const hookWithExecutionStats = await authedAdminApi
.get(`hooks/${createdHook.id}?includeExecutionStats=true`)
.json<HookResponse>();
const { executionStats } = hookWithExecutionStats;
expect(executionStats).toBeTruthy();
expect(executionStats.requestCount).toBe(1);
expect(executionStats.successCount).toBe(1);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(userId);
});
});

View file

@ -0,0 +1,98 @@
import type { Hook } from '@logto/schemas';
import { HookEvent } from '@logto/schemas';
import { authedAdminApi } from '#src/api/index.js';
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
describe('hooks', () => {
it('should be able to create, query, update, and delete a hook', async () => {
const payload = getHookCreationPayload(HookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(created).toMatchObject(payload);
expect(await authedAdminApi.get('hooks').json<Hook[]>()).toContainEqual(created);
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { events: [HookEvent.PostSignIn] } })
.json<Hook>()
).toMatchObject({ ...created, events: [HookEvent.PostSignIn] });
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('statusCode', 204);
await expect(authedAdminApi.get(`hooks/${created.id}`)).rejects.toHaveProperty(
'response.statusCode',
404
);
});
it('should be able to create, query, update, and delete a hook by the original API', async () => {
const payload = {
event: HookEvent.PostRegister,
config: {
url: 'not_work_url',
retries: 2,
},
};
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(created).toMatchObject(payload);
expect(await authedAdminApi.get('hooks').json<Hook[]>()).toContainEqual(created);
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { event: HookEvent.PostSignIn } })
.json<Hook>()
).toMatchObject({
...created,
event: HookEvent.PostSignIn,
});
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('statusCode', 204);
await expect(authedAdminApi.get(`hooks/${created.id}`)).rejects.toHaveProperty(
'response.statusCode',
404
);
});
it('should throw error when creating a hook with an empty hook name', async () => {
const payload = {
name: '',
events: [HookEvent.PostRegister],
config: {
url: 'not_work_url',
},
};
await expect(authedAdminApi.post('hooks', { json: payload })).rejects.toMatchObject(
createResponseWithCode(400)
);
});
it('should throw error when no event is provided when creating a hook', async () => {
const payload = {
name: 'hook_name',
config: {
url: 'not_work_url',
},
};
await expect(authedAdminApi.post('hooks', { json: payload })).rejects.toMatchObject(
createResponseWithCode(400)
);
});
it('should throw error if update a hook with a invalid hook id', async () => {
const payload = {
name: 'new_hook_name',
};
await expect(authedAdminApi.patch('hooks/invalid_id', { json: payload })).rejects.toMatchObject(
createResponseWithCode(404)
);
});
it('should throw error if regenerate a hook signing key with a invalid hook id', async () => {
await expect(authedAdminApi.patch('hooks/invalid_id/signing-key')).rejects.toMatchObject(
createResponseWithCode(404)
);
});
});

View file

@ -0,0 +1,185 @@
import { createHmac } from 'node:crypto';
import { type RequestListener } from 'node:http';
import {
type Hook,
HookEvent,
type LogKey,
LogResult,
SignInIdentifier,
type Log,
} from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { authedAdminApi } from '#src/api/api.js';
import { getLogs } from '#src/api/logs.js';
import { getHookCreationPayload } from '#src/helpers/hook.js';
import { createMockServer } from '#src/helpers/index.js';
import { registerNewUser, signInWithPassword } from '#src/helpers/interactions.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
type HookSecureData = {
signature: string;
payload: string;
};
// Note: return hook payload and signature for webhook security testing
const hookServerRequestListener: RequestListener = (request, response) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
response.statusCode = 204;
const data: Buffer[] = [];
request.on('data', (chunk: Buffer) => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
data.push(chunk);
});
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' });
const payload = Buffer.concat(data).toString();
response.end(
JSON.stringify({
signature: request.headers['logto-signature-sha-256'] as string,
payload,
} satisfies HookSecureData)
);
});
};
describe('trigger hooks', () => {
const { listen, close } = createMockServer(9999, hookServerRequestListener);
beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
await listen();
});
afterAll(async () => {
await close();
});
it('should trigger sign-in hook and record error when interaction finished', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: getHookCreationPayload(HookEvent.PostSignIn) })
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostSignIn';
const {
userProfile: { username, password },
user,
} = await generateNewUser({ username: true, password: true });
await signInWithPassword({ username, password });
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === createdHook.id &&
result === LogResult.Error &&
error === 'RequestError: Invalid URL'
)
).toBeTruthy();
// Clean up
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(user.id);
});
it('should trigger multiple register hooks and record properly when interaction finished', async () => {
const [hook1, hook2, hook3] = await Promise.all([
authedAdminApi
.post('hooks', { json: getHookCreationPayload(HookEvent.PostRegister) })
.json<Hook>(),
authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>(),
// Using the old API to create a hook
authedAdminApi
.post('hooks', {
json: {
event: HookEvent.PostRegister,
config: { url: 'http://localhost:9999', retries: 2 },
},
})
.json<Hook>(),
]);
const logKey: LogKey = 'TriggerHook.PostRegister';
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUser(username, password);
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL'
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook2.id && result === LogResult.Success
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook3.id && result === LogResult.Success
)
).toBeTruthy();
// Clean up
await Promise.all([
authedAdminApi.delete(`hooks/${hook1.id}`),
authedAdminApi.delete(`hooks/${hook2.id}`),
authedAdminApi.delete(`hooks/${hook3.id}`),
]);
await deleteUser(userId);
});
it('should secure webhook payload data successfully', async () => {
const createdHook = await authedAdminApi
.post('hooks', {
json: getHookCreationPayload(HookEvent.PostRegister, 'http://localhost:9999'),
})
.json<Hook>();
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUser(username, password);
const logs = await authedAdminApi
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
.json<Log[]>();
const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
expect(log).toBeTruthy();
const response = log?.payload.response;
expect(response).toBeTruthy();
const {
body: { signature, payload },
} = response as { body: HookSecureData };
expect(signature).toBeTruthy();
expect(payload).toBeTruthy();
const calculateSignature = createHmac('sha256', createdHook.signingKey)
.update(payload)
.digest('hex');
expect(calculateSignature).toEqual(signature);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(userId);
});
});

View file

@ -1,381 +0,0 @@
import { createHmac } from 'node:crypto';
import { type RequestListener } from 'node:http';
import type { Hook, HookConfig, HookResponse, Log, LogKey } from '@logto/schemas';
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
import { initClient, processSession } from '#src/helpers/client.js';
import { createMockServer } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
import { waitFor } from '#src/utils.js';
type CreateHookPayload = Pick<Hook, 'name' | 'events'> & {
config: HookConfig;
};
const createPayload = (event: HookEvent, url = 'not_work_url'): CreateHookPayload => ({
name: 'hook_name',
events: [event],
config: {
url,
headers: { foo: 'bar' },
},
});
type HookSecureData = {
signature: string;
payload: string;
};
// Note: return hook payload and signature for webhook security testing
const hookServerRequestListener: RequestListener = (request, response) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
response.statusCode = 204;
const data: Buffer[] = [];
request.on('data', (chunk: Buffer) => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
data.push(chunk);
});
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' });
const payload = Buffer.concat(data).toString();
response.end(
JSON.stringify({
signature: request.headers['logto-signature-sha-256'] as string,
payload,
} satisfies HookSecureData)
);
});
};
describe('hooks', () => {
const { listen, close } = createMockServer(9999, hookServerRequestListener);
beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
await listen();
});
afterAll(async () => {
await close();
});
it('should be able to create, query, update, and delete a hook', async () => {
const payload = createPayload(HookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(created).toMatchObject(payload);
expect(await authedAdminApi.get('hooks').json<Hook[]>()).toContainEqual(created);
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { events: [HookEvent.PostSignIn] } })
.json<Hook>()
).toMatchObject({ ...created, events: [HookEvent.PostSignIn] });
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('statusCode', 204);
await expect(authedAdminApi.get(`hooks/${created.id}`)).rejects.toHaveProperty(
'response.statusCode',
404
);
});
it('should be able to create, query, update, and delete a hook by the original API', async () => {
const payload = {
event: HookEvent.PostRegister,
config: {
url: 'not_work_url',
retries: 2,
},
};
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(created).toMatchObject(payload);
expect(await authedAdminApi.get('hooks').json<Hook[]>()).toContainEqual(created);
expect(await authedAdminApi.get(`hooks/${created.id}`).json<Hook>()).toEqual(created);
expect(
await authedAdminApi
.patch(`hooks/${created.id}`, { json: { event: HookEvent.PostSignIn } })
.json<Hook>()
).toMatchObject({
...created,
event: HookEvent.PostSignIn,
});
expect(await authedAdminApi.delete(`hooks/${created.id}`)).toHaveProperty('statusCode', 204);
await expect(authedAdminApi.get(`hooks/${created.id}`)).rejects.toHaveProperty(
'response.statusCode',
404
);
});
it('should throw error when creating a hook with an empty hook name', async () => {
const payload = {
name: '',
events: [HookEvent.PostRegister],
config: {
url: 'not_work_url',
},
};
await expect(authedAdminApi.post('hooks', { json: payload })).rejects.toMatchObject(
createResponseWithCode(400)
);
});
it('should throw error when no event is provided when creating a hook', async () => {
const payload = {
name: 'hook_name',
config: {
url: 'not_work_url',
},
};
await expect(authedAdminApi.post('hooks', { json: payload })).rejects.toMatchObject(
createResponseWithCode(400)
);
});
it('should throw error if update a hook with a invalid hook id', async () => {
const payload = {
name: 'new_hook_name',
};
await expect(authedAdminApi.patch('hooks/invalid_id', { json: payload })).rejects.toMatchObject(
createResponseWithCode(404)
);
});
it('should throw error if regenerate a hook signing key with a invalid hook id', async () => {
await expect(authedAdminApi.patch('hooks/invalid_id/signing-key')).rejects.toMatchObject(
createResponseWithCode(404)
);
});
it('should trigger sign-in hook and record error when interaction finished', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostSignIn) })
.json<Hook>();
const logKey: LogKey = 'TriggerHook.PostSignIn';
// Init session and submit
const {
userProfile: { username, password },
user,
} = await generateNewUser({ username: true, password: true });
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});
await client.submitInteraction();
await waitFor(500); // Wait for hooks execution
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === createdHook.id &&
result === LogResult.Error &&
error === 'RequestError: Invalid URL'
)
).toBeTruthy();
// Clean up
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(user.id);
});
it('should trigger multiple register hooks and record properly when interaction finished', async () => {
const [hook1, hook2, hook3] = await Promise.all([
authedAdminApi.post('hooks', { json: createPayload(HookEvent.PostRegister) }).json<Hook>(),
authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>(),
// Using the old API to create a hook
authedAdminApi
.post('hooks', {
json: {
event: HookEvent.PostRegister,
config: { url: 'http://localhost:9999', retries: 2 },
},
})
.json<Hook>(),
]);
const logKey: LogKey = 'TriggerHook.PostRegister';
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await waitFor(500); // Wait for hooks execution
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL'
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook2.id && result === LogResult.Success
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook3.id && result === LogResult.Success
)
).toBeTruthy();
// Clean up
await Promise.all([
authedAdminApi.delete(`hooks/${hook1.id}`),
authedAdminApi.delete(`hooks/${hook2.id}`),
authedAdminApi.delete(`hooks/${hook3.id}`),
]);
await deleteUser(id);
});
it('should get recent hook logs correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>();
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await waitFor(500); // Wait for hooks execution
const logs = await authedAdminApi
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
.json<Log[]>();
expect(
logs.some(
({ payload: { hookId, result } }) =>
hookId === createdHook.id && result === LogResult.Success
)
).toBeTruthy();
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(id);
});
it('should secure webhook payload data successfully', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>();
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await waitFor(500); // Wait for hooks execution
const logs = await authedAdminApi
.get(`hooks/${createdHook.id}/recent-logs?page_size=100`)
.json<Log[]>();
const log = logs.find(({ payload: { hookId } }) => hookId === createdHook.id);
expect(log).toBeTruthy();
const response = log?.payload.response;
expect(response).toBeTruthy();
const {
body: { signature, payload },
} = response as { body: HookSecureData };
expect(signature).toBeTruthy();
expect(payload).toBeTruthy();
const calculateSignature = createHmac('sha256', createdHook.signingKey)
.update(payload)
.digest('hex');
expect(calculateSignature).toEqual(signature);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(id);
});
it('should get hook execution stats correctly', async () => {
const createdHook = await authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>();
const hooksWithExecutionStats = await authedAdminApi
.get('hooks?includeExecutionStats=true')
.json<HookResponse[]>();
for (const hook of hooksWithExecutionStats) {
expect(hook.executionStats).toBeTruthy();
}
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: InteractionEvent.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await waitFor(500); // Wait for hooks execution
const hookWithExecutionStats = await authedAdminApi
.get(`hooks/${createdHook.id}?includeExecutionStats=true`)
.json<HookResponse>();
const { executionStats } = hookWithExecutionStats;
expect(executionStats).toBeTruthy();
expect(executionStats.requestCount).toBe(1);
expect(executionStats.successCount).toBe(1);
await authedAdminApi.delete(`hooks/${createdHook.id}`);
await deleteUser(id);
});
});