mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor: add and fix tests
This commit is contained in:
parent
f993da8795
commit
84014b37d2
9 changed files with 85 additions and 30 deletions
|
@ -1,4 +1,4 @@
|
||||||
import { mockEsmDefault, pickDefault } from '@logto/shared/esm';
|
import { mockEsm, pickDefault } from '@logto/shared/esm';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
|
|
||||||
import { emptyMiddleware } from '#src/utils/test-utils.js';
|
import { emptyMiddleware } from '#src/utils/test-utils.js';
|
||||||
|
@ -14,7 +14,10 @@ const middlewareList = [
|
||||||
'spa-proxy',
|
'spa-proxy',
|
||||||
].map((name) => {
|
].map((name) => {
|
||||||
const mock = jest.fn(() => emptyMiddleware);
|
const mock = jest.fn(() => emptyMiddleware);
|
||||||
mockEsmDefault(`#src/middleware/koa-${name}.js`, () => mock);
|
mockEsm(`#src/middleware/koa-${name}.js`, () => ({
|
||||||
|
default: mock,
|
||||||
|
...(name === 'audit-log' && { LogEntry: jest.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
return mock;
|
return mock;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { InteractionEvent, LogResult } from '@logto/schemas';
|
||||||
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
|
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
|
||||||
import { mockEsm, mockEsmDefault } from '@logto/shared/esm';
|
import { mockEsm, mockEsmDefault } from '@logto/shared/esm';
|
||||||
import type { InferModelType } from '@withtyped/server';
|
import type { InferModelType } from '@withtyped/server';
|
||||||
|
@ -24,8 +25,20 @@ const readAll = jest
|
||||||
.spyOn(modelRouters.hook.client, 'readAll')
|
.spyOn(modelRouters.hook.client, 'readAll')
|
||||||
.mockResolvedValue({ rows: [hook], rowCount: 1 });
|
.mockResolvedValue({ rows: [hook], rowCount: 1 });
|
||||||
|
|
||||||
// @ts-expect-error for testing
|
const post = jest
|
||||||
const post = jest.spyOn(got, 'post').mockImplementation(jest.fn(() => ({ json: jest.fn() })));
|
.spyOn(got, 'post')
|
||||||
|
// @ts-expect-error for testing
|
||||||
|
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
|
||||||
|
|
||||||
|
// TODO: @Gao fix `mockEsm()` import issue
|
||||||
|
const nanoIdMock = 'mockId';
|
||||||
|
jest.unstable_mockModule('@logto/core-kit', () => ({
|
||||||
|
generateStandardId: () => nanoIdMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
|
||||||
|
insertLog: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
mockEsm('#src/queries/user.js', () => ({
|
mockEsm('#src/queries/user.js', () => ({
|
||||||
findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }),
|
findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }),
|
||||||
|
@ -46,7 +59,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return if no user ID found', async () => {
|
it('should return if no user ID found', async () => {
|
||||||
await triggerInteractionHooksIfNeeded(Event.SignIn);
|
await triggerInteractionHooksIfNeeded(InteractionEvent.SignIn);
|
||||||
|
|
||||||
expect(queryFunction).not.toBeCalled();
|
expect(queryFunction).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
@ -55,7 +68,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
jest.useFakeTimers().setSystemTime(100_000);
|
jest.useFakeTimers().setSystemTime(100_000);
|
||||||
|
|
||||||
await triggerInteractionHooksIfNeeded(
|
await triggerInteractionHooksIfNeeded(
|
||||||
Event.SignIn,
|
InteractionEvent.SignIn,
|
||||||
// @ts-expect-error for testing
|
// @ts-expect-error for testing
|
||||||
{
|
{
|
||||||
jti: 'some_jti',
|
jti: 'some_jti',
|
||||||
|
@ -80,6 +93,18 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
retry: { limit: 3 },
|
retry: { limit: 3 },
|
||||||
timeout: { request: 10_000 },
|
timeout: { request: 10_000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const calledPayload: unknown = insertLog.mock.calls[0][0];
|
||||||
|
expect(calledPayload).toHaveProperty('id', nanoIdMock);
|
||||||
|
expect(calledPayload).toHaveProperty('key', 'TriggerHook.' + HookEvent.PostSignIn);
|
||||||
|
expect(calledPayload).toHaveProperty('payload.result', LogResult.Success);
|
||||||
|
expect(calledPayload).toHaveProperty('payload.hookId', 'foo');
|
||||||
|
expect(calledPayload).toHaveProperty('payload.json.event', HookEvent.PostSignIn);
|
||||||
|
expect(calledPayload).toHaveProperty('payload.json.interactionEvent', InteractionEvent.SignIn);
|
||||||
|
expect(calledPayload).toHaveProperty('payload.json.hookId', 'foo');
|
||||||
|
expect(calledPayload).toHaveProperty('payload.json.userId', '123');
|
||||||
|
expect(calledPayload).toHaveProperty('payload.response.statusCode', 200);
|
||||||
|
expect(calledPayload).toHaveProperty('payload.response.body.message', 'ok');
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -28,7 +28,7 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
|
||||||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||||
|
|
||||||
export const triggerInteractionHooksIfNeeded = async (
|
export const triggerInteractionHooksIfNeeded = async (
|
||||||
event: Event,
|
event: InteractionEvent,
|
||||||
details?: Interaction,
|
details?: Interaction,
|
||||||
userAgent?: string
|
userAgent?: string
|
||||||
) => {
|
) => {
|
||||||
|
@ -87,7 +87,8 @@ export const triggerInteractionHooksIfNeeded = async (
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
logEntry.append({
|
logEntry.append({
|
||||||
result: LogResult.Error,
|
result: LogResult.Error,
|
||||||
response: error instanceof HTTPError ? parseResponse(error.response) : String(error),
|
response: conditional(error instanceof HTTPError && parseResponse(error.response)),
|
||||||
|
error: conditional(error instanceof Error && String(error)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -10,14 +10,14 @@ import type { WithLogContext, LogPayload } from './koa-audit-log.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
|
|
||||||
const nanoIdMock = 'mockId';
|
|
||||||
|
|
||||||
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
|
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
|
||||||
insertLog: jest.fn(),
|
insertLog: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mockEsm('nanoid', () => ({
|
// TODO: @Gao fix `mockEsm()` import issue
|
||||||
nanoid: () => nanoIdMock,
|
const nanoIdMock = 'mockId';
|
||||||
|
jest.unstable_mockModule('@logto/core-kit', () => ({
|
||||||
|
generateStandardId: () => nanoIdMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const koaLog = await pickDefault(import('./koa-audit-log.js'));
|
const koaLog = await pickDefault(import('./koa-audit-log.js'));
|
||||||
|
|
|
@ -92,15 +92,17 @@ const { sendPasscodeToIdentifier } = await mockEsmWithActual(
|
||||||
|
|
||||||
const { createLog, prependAllLogEntries } = createMockLogContext();
|
const { createLog, prependAllLogEntries } = createMockLogContext();
|
||||||
|
|
||||||
mockEsmDefault(
|
await mockEsmWithActual(
|
||||||
'#src/middleware/koa-audit-log.js',
|
'#src/middleware/koa-audit-log.js',
|
||||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
(): { default: typeof koaAuditLog } => ({
|
||||||
(): typeof koaAuditLog => () => async (ctx, next) => {
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
ctx.createLog = createLog;
|
default: () => async (ctx, next) => {
|
||||||
ctx.prependAllLogEntries = prependAllLogEntries;
|
ctx.createLog = createLog;
|
||||||
|
ctx.prependAllLogEntries = prependAllLogEntries;
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -272,7 +274,7 @@ describe('session -> interactionRoutes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
||||||
getInteractionStorage.mockReturnValueOnce({
|
getInteractionStorage.mockReturnValue({
|
||||||
event: InteractionEvent.ForgotPassword,
|
event: InteractionEvent.ForgotPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -218,7 +218,6 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { interactionDetails } = ctx;
|
const { interactionDetails } = ctx;
|
||||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
const { event } = interactionStorage;
|
const { event } = interactionStorage;
|
||||||
|
|
||||||
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
|
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
|
||||||
|
|
|
@ -16,4 +16,4 @@ global.TextDecoder = TextDecoder;
|
||||||
global.TextEncoder = TextEncoder;
|
global.TextEncoder = TextEncoder;
|
||||||
/* eslint-enable @silverhand/fp/no-mutation */
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
jest.setTimeout(10_000);
|
jest.setTimeout(15_000);
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import type { LogKey } from '@logto/schemas';
|
import type { LogKey } from '@logto/schemas';
|
||||||
import { LogResult, Event } from '@logto/schemas';
|
import { SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
|
||||||
import type { Hooks } from '@logto/schemas/models';
|
import type { Hooks } from '@logto/schemas/models';
|
||||||
import { HookEvent } from '@logto/schemas/models';
|
import { HookEvent } from '@logto/schemas/models';
|
||||||
import type { InferModelType } from '@withtyped/server';
|
import type { InferModelType } from '@withtyped/server';
|
||||||
|
|
||||||
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
|
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
|
||||||
import { createMockServer } from '#src/helpers.js';
|
import { createMockServer } from '#src/helpers.js';
|
||||||
|
import { waitFor } from '#src/utils.js';
|
||||||
|
|
||||||
import { initClient, processSession } from './interaction/utils/client.js';
|
import { initClient, processSession } from './interaction/utils/client.js';
|
||||||
|
import { enableAllPasswordSignInMethods } from './interaction/utils/sign-in-experience.js';
|
||||||
import { generateNewUser, generateNewUserProfile } from './interaction/utils/user.js';
|
import { generateNewUser, generateNewUserProfile } from './interaction/utils/user.js';
|
||||||
|
|
||||||
type Hook = InferModelType<typeof Hooks>;
|
type Hook = InferModelType<typeof Hooks>;
|
||||||
|
@ -22,6 +24,21 @@ const createPayload = (event: HookEvent, url = 'not_work_url'): Partial<Hook> =>
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hooks', () => {
|
describe('hooks', () => {
|
||||||
|
const { listen, close } = createMockServer(9999);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await enableAllPasswordSignInMethods({
|
||||||
|
identifiers: [SignInIdentifier.Username],
|
||||||
|
password: true,
|
||||||
|
verify: false,
|
||||||
|
});
|
||||||
|
await listen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await close();
|
||||||
|
});
|
||||||
|
|
||||||
it('should be able to create, query, and delete a hook', async () => {
|
it('should be able to create, query, and delete a hook', async () => {
|
||||||
const payload = createPayload(HookEvent.PostRegister);
|
const payload = createPayload(HookEvent.PostRegister);
|
||||||
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
|
const created = await authedAdminApi.post('hooks', { json: payload }).json<Hook>();
|
||||||
|
@ -51,19 +68,23 @@ describe('hooks', () => {
|
||||||
} = await generateNewUser({ username: true, password: true });
|
} = await generateNewUser({ username: true, password: true });
|
||||||
const client = await initClient();
|
const client = await initClient();
|
||||||
await client.successSend(putInteraction, {
|
await client.successSend(putInteraction, {
|
||||||
event: Event.SignIn,
|
event: InteractionEvent.SignIn,
|
||||||
identifier: {
|
identifier: {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await client.submitInteraction();
|
await client.submitInteraction();
|
||||||
|
await waitFor(500); // Wait for hooks execution
|
||||||
|
|
||||||
// Check hook trigger log
|
// Check hook trigger log
|
||||||
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
|
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
|
||||||
expect(
|
expect(
|
||||||
logs.some(
|
logs.some(
|
||||||
({ payload: { hookId, result } }) => hookId === createdHook.id && result === LogResult.Error
|
({ payload: { hookId, result, error } }) =>
|
||||||
|
hookId === createdHook.id &&
|
||||||
|
result === LogResult.Error &&
|
||||||
|
error === 'RequestError: Invalid URL'
|
||||||
)
|
)
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
|
@ -80,14 +101,12 @@ describe('hooks', () => {
|
||||||
.json<Hook>(),
|
.json<Hook>(),
|
||||||
]);
|
]);
|
||||||
const logKey: LogKey = 'TriggerHook.PostRegister';
|
const logKey: LogKey = 'TriggerHook.PostRegister';
|
||||||
const { listen, close } = createMockServer(9999);
|
|
||||||
await listen(); // Start mock server
|
|
||||||
|
|
||||||
// Init session and submit
|
// Init session and submit
|
||||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
const client = await initClient();
|
const client = await initClient();
|
||||||
await client.send(putInteraction, {
|
await client.send(putInteraction, {
|
||||||
event: Event.Register,
|
event: InteractionEvent.Register,
|
||||||
profile: {
|
profile: {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
|
@ -95,13 +114,14 @@ describe('hooks', () => {
|
||||||
});
|
});
|
||||||
const { redirectTo } = await client.submitInteraction();
|
const { redirectTo } = await client.submitInteraction();
|
||||||
const id = await processSession(client, redirectTo);
|
const id = await processSession(client, redirectTo);
|
||||||
await close(); // Stop mock server early
|
await waitFor(500); // Wait for hooks execution
|
||||||
|
|
||||||
// Check hook trigger log
|
// Check hook trigger log
|
||||||
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
|
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
|
||||||
expect(
|
expect(
|
||||||
logs.some(
|
logs.some(
|
||||||
({ payload: { hookId, result } }) => hookId === hook1.id && result === LogResult.Error
|
({ payload: { hookId, result, error } }) =>
|
||||||
|
hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL'
|
||||||
)
|
)
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
|
|
|
@ -12,3 +12,8 @@ export const generatePhone = () => {
|
||||||
|
|
||||||
return crypto.getRandomValues(array).join('');
|
return crypto.getRandomValues(array).join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const waitFor = async (ms: number) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue