0
Fork 0
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:
Gao Sun 2022-12-26 19:59:33 +08:00
parent f993da8795
commit 84014b37d2
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
9 changed files with 85 additions and 30 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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