0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

test: add integration tests for interaction hooks

This commit is contained in:
Gao Sun 2022-12-24 20:42:18 +08:00
parent 5825620b9b
commit f993da8795
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
10 changed files with 237 additions and 34 deletions

View file

@ -1,4 +1,3 @@
import { InteractionEvent } from '@logto/schemas';
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
import { mockEsm, mockEsmDefault } from '@logto/shared/esm';
import type { InferModelType } from '@withtyped/server';
@ -47,7 +46,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
});
it('should return if no user ID found', async () => {
await triggerInteractionHooksIfNeeded();
await triggerInteractionHooksIfNeeded(Event.SignIn);
expect(queryFunction).not.toBeCalled();
});
@ -56,14 +55,11 @@ describe('triggerInteractionHooksIfNeeded()', () => {
jest.useFakeTimers().setSystemTime(100_000);
await triggerInteractionHooksIfNeeded(
Event.SignIn,
// @ts-expect-error for testing
{
jti: 'some_jti',
result: {
login: { accountId: '123' },
event: InteractionEvent.SignIn,
identifier: { connectorId: 'bar' },
},
result: { login: { accountId: '123' } },
params: { client_id: 'some_client' },
} as Interaction
);

View file

@ -1,14 +1,23 @@
import { InteractionEvent, userInfoSelectFields } from '@logto/schemas';
import { generateStandardId } from '@logto/core-kit';
import { InteractionEvent, LogResult, userInfoSelectFields } from '@logto/schemas';
import { HookEventPayload, HookEvent } from '@logto/schemas/models';
import { trySafe } from '@logto/shared';
import { conditional, pick } from '@silverhand/essentials';
import { got } from 'got';
import type { Response } from 'got';
import { got, HTTPError } from 'got';
import type { Provider } from 'oidc-provider';
import { LogEntry } from '#src/middleware/koa-audit-log.js';
import modelRouters from '#src/model-routers/index.js';
import { findApplicationById } from '#src/queries/application.js';
import { insertLog } from '#src/queries/log.js';
import { findUserById } from '#src/queries/user.js';
import { getInteractionStorage } from '#src/routes/interaction/utils/interaction.js';
const parseResponse = ({ statusCode, body }: Response) => ({
statusCode,
// eslint-disable-next-line no-restricted-syntax
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
});
const eventToHook: Record<InteractionEvent, HookEvent> = {
[InteractionEvent.Register]: HookEvent.PostRegister,
@ -19,6 +28,7 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export const triggerInteractionHooksIfNeeded = async (
event: Event,
details?: Interaction,
userAgent?: string
) => {
@ -30,9 +40,6 @@ export const triggerInteractionHooksIfNeeded = async (
return;
}
const interactionPayload = getInteractionStorage(details.result);
const { event } = interactionPayload;
const hookEvent = eventToHook[event];
const { rows } = await modelRouters.hook.client.readAll();
@ -58,7 +65,13 @@ export const triggerInteractionHooksIfNeeded = async (
rows
.filter(({ event }) => event === hookEvent)
.map(async ({ config: { url, headers, retries }, id }) => {
console.log(`\tTriggering hook ${id} due to ${hookEvent} event`);
const json: HookEventPayload = { hookId: id, ...payload };
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
logEntry.append({ json, hookId: id });
// Trigger web hook and log response
await got
.post(url, {
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
@ -66,7 +79,27 @@ export const triggerInteractionHooksIfNeeded = async (
retry: { limit: retries },
timeout: { request: 10_000 },
})
.json();
.then(async (response) => {
logEntry.append({
response: parseResponse(response),
});
})
.catch(async (error) => {
logEntry.append({
result: LogResult.Error,
response: error instanceof HTTPError ? parseResponse(error.response) : String(error),
});
});
console.log(
`\tHook ${id} ${logEntry.payload.result === LogResult.Success ? 'succeeded' : 'failed'}`
);
await insertLog({
id: generateStandardId(),
key: logEntry.key,
payload: logEntry.payload,
});
})
);
};

View file

@ -1,9 +1,9 @@
import { generateStandardId } from '@logto/core-kit';
import type { LogContextPayload, LogKey } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import { pick } from '@silverhand/essentials';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { nanoid } from 'nanoid';
import RequestError from '#src/errors/RequestError/index.js';
import { insertLog } from '#src/queries/log.js';
@ -138,7 +138,7 @@ export default function koaAuditLog<
await Promise.all(
entries.map(async ({ payload }) => {
return insertLog({
id: nanoid(),
id: generateStandardId(),
key: payload.key,
payload: { ip, userAgent, ...payload },
});

View file

@ -214,7 +214,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router.post(
`${interactionPrefix}/submit`,
koaInteractionSie(),
koaInteractionHooks(),
koaInteractionHooks(provider),
async (ctx, next) => {
const { interactionDetails } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);

View file

@ -1,18 +1,26 @@
import { trySafe } from '@logto/shared';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import type { Provider } from 'oidc-provider';
import { triggerInteractionHooksIfNeeded } from '#src/libraries/hook.js';
import { getInteractionStorage } from '../utils/interaction.js';
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
export default function koaInteractionHooks<
StateT,
ContextT extends WithInteractionDetailsContext<IRouterParamContext>,
ResponseT
>(): MiddlewareType<StateT, ContextT, ResponseT> {
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseT> {
return async (ctx, next) => {
const { event } = getInteractionStorage(ctx.interactionDetails.result);
await next();
void triggerInteractionHooksIfNeeded(ctx.interactionDetails, ctx.header['user-agent']);
// Get up-to-date interaction details
const details = await trySafe(provider.interactionDetails(ctx.req, ctx.res));
// Hooks should not crash the app
void trySafe(triggerInteractionHooksIfNeeded(event, details, ctx.header['user-agent']));
};
}

View file

@ -1,4 +1,5 @@
import fs from 'fs/promises';
import { createServer } from 'http';
import path from 'path';
import type { User, SignIn, SignInIdentifier } from '@logto/schemas';
@ -188,3 +189,26 @@ export const expectRequestError = (error: unknown, code: string, messageIncludes
expect(body.message.includes(messageIncludes)).toBeTruthy();
}
};
export const createMockServer = (port: number) => {
const server = createServer((request, response) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
response.statusCode = 204;
response.end();
});
return {
listen: async () =>
new Promise((resolve) => {
server.listen(port, () => {
resolve(true);
});
}),
close: async () =>
new Promise((resolve) => {
server.close(() => {
resolve(true);
});
}),
};
};

View file

@ -28,7 +28,7 @@ describe('audit logs for interaction', () => {
// Expect interaction create log
const createLogs = await getLogs(
new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.Create}` })
new URLSearchParams({ logKey: `${interaction.prefix}.${interaction.Action.Create}` })
);
expect(createLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();
@ -45,7 +45,7 @@ describe('audit logs for interaction', () => {
// Expect interaction end log
const endLogs = await getLogs(
new URLSearchParams({ logType: `${interaction.prefix}.${interaction.Action.End}` })
new URLSearchParams({ logKey: `${interaction.prefix}.${interaction.Action.End}` })
);
expect(endLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();

View file

@ -1,20 +1,29 @@
import type { LogKey } from '@logto/schemas';
import { LogResult, Event } from '@logto/schemas';
import type { Hooks } from '@logto/schemas/models';
import { HookEvent } from '@logto/schemas/models';
import type { InferModelType } from '@withtyped/server';
import { authedAdminApi } from '#src/api/index.js';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
import { createMockServer } from '#src/helpers.js';
describe('hooks', () => {
it('should be able to create, query, and delete a hook', async () => {
type Hook = InferModelType<typeof Hooks>;
import { initClient, processSession } from './interaction/utils/client.js';
import { generateNewUser, generateNewUserProfile } from './interaction/utils/user.js';
const payload = {
event: 'PostRegister',
type Hook = InferModelType<typeof Hooks>;
const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> => ({
event,
config: {
url: 'https://foo.bar',
url,
headers: { foo: 'bar' },
retries: 3,
},
};
});
describe('hooks', () => {
it('should be able to create, query, and delete a hook', async () => {
const payload = createPayload(HookEvent.PostRegister);
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
expect(payload.event).toEqual(created.event);
@ -28,4 +37,84 @@ describe('hooks', () => {
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: Event.SignIn,
identifier: {
username,
password,
},
});
await client.submitInteraction();
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === createdHook.id && result === LogResult.Error
)
).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] = await Promise.all([
authedAdminApi.post('hooks', { json: createPayload(HookEvent.PostRegister) }).json<Hook>(),
authedAdminApi
.post('hooks', { json: createPayload(HookEvent.PostRegister, 'http://localhost:9999') })
.json<Hook>(),
]);
const logKey: LogKey = 'TriggerHook.PostRegister';
const { listen, close } = createMockServer(9999);
await listen(); // Start mock server
// Init session and submit
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initClient();
await client.send(putInteraction, {
event: Event.Register,
profile: {
username,
password,
},
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await close(); // Stop mock server early
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook1.id && result === LogResult.Error
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook2.id && result === LogResult.Success
)
).toBeTruthy();
// Clean up
await Promise.all([
authedAdminApi.delete(`hooks/${hook1.id}`),
authedAdminApi.delete(`hooks/${hook2.id}`),
]);
await deleteUser(id);
});
});

View file

@ -0,0 +1,35 @@
import { trySafe } from './function.js';
describe('trySafe()', () => {
it('should directly execute and return if the function is not a Promise', () => {
expect(trySafe(() => 'foo')).toStrictEqual('foo');
expect(
trySafe(() => {
throw new Error('Test');
})
// eslint-disable-next-line unicorn/no-useless-undefined
).toStrictEqual(undefined);
});
it('should execute or unwrap a Promise and catch the error', async () => {
expect(
await trySafe(
new Promise((resolve) => {
setTimeout(() => {
resolve('bar');
}, 0);
})
)
).toStrictEqual('bar');
expect(
await trySafe(
async () =>
new Promise((resolve, reject) => {
reject();
})
)
// eslint-disable-next-line unicorn/no-useless-undefined
).toStrictEqual(undefined);
});
});

View file

@ -15,9 +15,27 @@ export const tryThat = async <T, E extends Error>(
}
};
export const trySafe = async <T>(exec: Promise<T> | (() => Promise<T>)): Promise<T | undefined> => {
export const isPromise = (value: unknown): value is Promise<unknown> =>
value !== null &&
(typeof value === 'object' || typeof value === 'function') &&
'then' in value &&
typeof value.then === 'function';
export type TrySafe = {
<T>(exec: Promise<T> | (() => Promise<T>)): Promise<T | undefined>;
<T>(exec: () => T): T | undefined;
};
export const trySafe: TrySafe = (exec) => {
try {
return await (typeof exec === 'function' ? exec() : exec);
const unwrapped = typeof exec === 'function' ? exec() : exec;
return isPromise(unwrapped)
? // eslint-disable-next-line promise/prefer-await-to-then
unwrapped.catch((error: unknown) => {
console.error('trySafe() caught error', error);
})
: unwrapped;
} catch (error: unknown) {
console.error('trySafe() caught error', error);
}