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

Merge pull request #2891 from logto-io/gao-log-5124-core-library-factory-hook

refactor(core): migrate hook library to factory mode
This commit is contained in:
Gao Sun 2023-01-10 14:50:59 +08:00 committed by GitHub
commit 1f293292b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 180 additions and 139 deletions

View file

@ -21,7 +21,7 @@
"start": "NODE_ENV=production node build/index.js",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build:test && pnpm test:only",
"test:ci": "pnpm test:only --coverage --silent --maxWorkers=50%",
"test:ci": "pnpm test:only --coverage --silent --maxWorkers=75%",
"test:report": "codecov -F core"
},
"dependencies": {

View file

@ -4,54 +4,64 @@ import { createMockUtils } from '@logto/shared/esm';
import type { InferModelType } from '@withtyped/server';
import { got } from 'got';
import modelRouters from '#src/model-routers/index.js';
import { MockQueryClient } from '#src/test-utils/query-client.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import type { Interaction } from './hook.js';
const { jest } = import.meta;
const { mockEsm, mockEsmDefault } = createMockUtils(jest);
const { mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
const nanoIdMock = 'mockId';
await mockEsmWithActual('@logto/core-kit', () => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
buildIdGenerator: () => () => nanoIdMock,
generateStandardId: () => nanoIdMock,
}));
const { createModelRouters } = await import('#src/model-routers/index.js');
const { MockQueryClient } = await import('#src/test-utils/query-client.js');
const { MockQueries } = await import('#src/test-utils/tenant.js');
const queryClient = new MockQueryClient();
const queryFunction = jest.fn();
const url = 'https://logto.gg';
const hook: InferModelType<typeof modelRouters.hook.model> = {
const hook: InferModelType<ModelRouters['hook']['model']> = {
id: 'foo',
event: HookEvent.PostSignIn,
config: { headers: { bar: 'baz' }, url, retries: 3 },
createdAt: new Date(),
};
const readAll = jest
.spyOn(modelRouters.hook.client, 'readAll')
.mockResolvedValue({ rows: [hook], rowCount: 1 });
const post = jest
.spyOn(got, 'post')
// @ts-expect-error for testing
.mockImplementation(jest.fn(async () => ({ statusCode: 200, body: '{"message":"ok"}' })));
const nanoIdMock = 'mockId';
mockEsm('@logto/core-kit', () => ({
generateStandardId: () => nanoIdMock,
}));
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
mockEsm('#src/queries/user.js', () => ({
findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }),
}));
mockEsm('#src/queries/application.js', () => ({
findApplicationById: () => ({ id: 'app_id', extraField: 'not_ok' }),
}));
const insertLog = jest.fn();
// eslint-disable-next-line unicorn/consistent-function-scoping
mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient);
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
const { triggerInteractionHooksIfNeeded } = await import('./hook.js');
const { createHookLibrary } = await import('./hook.js');
const modelRouters = createModelRouters(new MockQueryClient());
const { triggerInteractionHooksIfNeeded } = createHookLibrary(
new MockQueries({
// @ts-expect-error
users: { findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }) },
applications: {
// @ts-expect-error
findApplicationById: async () => ({ id: 'app_id', extraField: 'not_ok' }),
},
logs: { insertLog },
}),
modelRouters
);
const readAll = jest
.spyOn(modelRouters.hook.client, 'readAll')
.mockResolvedValue({ rows: [hook], rowCount: 1 });
describe('triggerInteractionHooksIfNeeded()', () => {
afterEach(() => {

View file

@ -8,10 +8,8 @@ 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 type { ModelRouters } from '#src/model-routers/index.js';
import type Queries from '#src/tenants/Queries.js';
const parseResponse = ({ statusCode, body }: Response) => ({
statusCode,
@ -27,80 +25,90 @@ const eventToHook: Record<InteractionEvent, HookEvent> = {
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export const triggerInteractionHooksIfNeeded = async (
event: InteractionEvent,
details?: Interaction,
userAgent?: string
) => {
const userId = details?.result?.login?.accountId;
const sessionId = details?.jti;
const applicationId = details?.params.client_id;
export const createHookLibrary = (queries: Queries, { hook }: ModelRouters) => {
const {
applications: { findApplicationById },
logs: { insertLog },
users: { findUserById },
} = queries;
if (!userId) {
return;
}
const triggerInteractionHooksIfNeeded = async (
event: InteractionEvent,
details?: Interaction,
userAgent?: string
) => {
const userId = details?.result?.login?.accountId;
const sessionId = details?.jti;
const applicationId = details?.params.client_id;
const hookEvent = eventToHook[event];
const { rows } = await modelRouters.hook.client.readAll();
if (!userId) {
return;
}
const [user, application] = await Promise.all([
trySafe(findUserById(userId)),
trySafe(async () =>
conditional(typeof applicationId === 'string' && (await findApplicationById(applicationId)))
),
]);
const hookEvent = eventToHook[event];
const { rows } = await hook.client.readAll();
const payload = {
event: hookEvent,
interactionEvent: event,
createdAt: new Date().toISOString(),
sessionId,
userAgent,
userId,
user: user && pick(user, ...userInfoSelectFields),
application: application && pick(application, 'id', 'type', 'name', 'description'),
} satisfies Omit<HookEventPayload, 'hookId'>;
const [user, application] = await Promise.all([
trySafe(findUserById(userId)),
trySafe(async () =>
conditional(typeof applicationId === 'string' && (await findApplicationById(applicationId)))
),
]);
await Promise.all(
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}`);
const payload = {
event: hookEvent,
interactionEvent: event,
createdAt: new Date().toISOString(),
sessionId,
userAgent,
userId,
user: user && pick(user, ...userInfoSelectFields),
application: application && pick(application, 'id', 'type', 'name', 'description'),
} satisfies Omit<HookEventPayload, 'hookId'>;
logEntry.append({ json, hookId: id });
await Promise.all(
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}`);
// Trigger web hook and log response
await got
.post(url, {
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
json,
retry: { limit: retries },
timeout: { request: 10_000 },
})
.then(async (response) => {
logEntry.append({
response: parseResponse(response),
});
})
.catch(async (error) => {
logEntry.append({
result: LogResult.Error,
response: conditional(error instanceof HTTPError && parseResponse(error.response)),
error: conditional(error instanceof Error && String(error)),
logEntry.append({ json, hookId: id });
// Trigger web hook and log response
await got
.post(url, {
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
json,
retry: { limit: retries },
timeout: { request: 10_000 },
})
.then(async (response) => {
logEntry.append({
response: parseResponse(response),
});
})
.catch(async (error) => {
logEntry.append({
result: LogResult.Error,
response: conditional(error instanceof HTTPError && parseResponse(error.response)),
error: conditional(error instanceof Error && String(error)),
});
});
console.log(
`\tHook ${id} ${logEntry.payload.result === LogResult.Success ? 'succeeded' : 'failed'}`
);
await insertLog({
id: generateStandardId(),
key: logEntry.key,
payload: logEntry.payload,
});
})
);
};
console.log(
`\tHook ${id} ${logEntry.payload.result === LogResult.Success ? 'succeeded' : 'failed'}`
);
await insertLog({
id: generateStandardId(),
key: logEntry.key,
payload: logEntry.payload,
});
})
);
return { triggerInteractionHooksIfNeeded };
};

View file

@ -3,24 +3,26 @@ import { LogResult } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import i18next from 'i18next';
import RequestError from '#src/errors/RequestError/index.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithLogContext, LogPayload } from './koa-audit-log.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
insertLog: jest.fn(),
}));
const { mockEsmWithActual } = createMockUtils(jest);
const nanoIdMock = 'mockId';
mockEsm('@logto/core-kit', () => ({
await mockEsmWithActual('@logto/core-kit', () => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
buildIdGenerator: () => () => nanoIdMock,
generateStandardId: () => nanoIdMock,
}));
const { default: RequestError } = await import('#src/errors/RequestError/index.js');
const { MockQueries } = await import('#src/test-utils/tenant.js');
const { createContextWithRouteParameters } = await import('#src/utils/test-utils.js');
const insertLog = jest.fn();
const queries = new MockQueries({ logs: { insertLog } });
const koaLog = await pickDefault(import('./koa-audit-log.js'));
describe('koaAuditLog middleware', () => {
@ -51,7 +53,7 @@ describe('koaAuditLog middleware', () => {
log.append(mockPayload);
log.append(additionalMockPayload);
};
await koaLog()(ctx, next);
await koaLog(queries)(ctx, next);
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
@ -82,7 +84,7 @@ describe('koaAuditLog middleware', () => {
const log2 = ctx.createLog(logKey);
log2.append(mockPayload);
};
await koaLog()(ctx, next);
await koaLog(queries)(ctx, next);
const basePayload = {
...mockPayload,
@ -116,7 +118,7 @@ describe('koaAuditLog middleware', () => {
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function
const next = async () => {};
await koaLog()(ctx, next);
await koaLog(queries)(ctx, next);
expect(insertLog).not.toBeCalled();
});
@ -136,7 +138,7 @@ describe('koaAuditLog middleware', () => {
log.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
await expect(koaLog(queries)(ctx, next)).rejects.toMatchError(error);
expect(insertLog).toBeCalledWith({
id: nanoIdMock,
@ -172,7 +174,7 @@ describe('koaAuditLog middleware', () => {
log2.append(mockPayload);
throw error;
};
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
await expect(koaLog(queries)(ctx, next)).rejects.toMatchError(error);
expect(insertLog).toHaveBeenCalledTimes(2);
expect(insertLog).toBeCalledWith({

View file

@ -6,7 +6,7 @@ import type { Context, MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import RequestError from '#src/errors/RequestError/index.js';
import { insertLog } from '#src/queries/log.js';
import type Queries from '#src/tenants/Queries.js';
const removeUndefinedKeys = (object: Record<string, unknown>) =>
Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
@ -93,11 +93,9 @@ export type WithLogContext<ContextT extends IRouterParamContext = IRouterParamCo
* @see {@link LogKey} for all available log keys, and {@link LogResult} for result enums.
* @see {@link LogContextPayload} for the basic type suggestion of log data.
*/
export default function koaAuditLog<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
export default function koaAuditLog<StateT, ContextT extends IRouterParamContext, ResponseBodyT>({
logs: { insertLog },
}: Queries): MiddlewareType<StateT, WithLogContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const entries: LogEntry[] = [];

View file

@ -1,10 +1,9 @@
import { Hooks } from '@logto/schemas/models';
import { createModelRouter } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import envSet from '#src/env-set/index.js';
export type ModelRouters = ReturnType<typeof createModelRouters>;
const modelRouters = {
hook: createModelRouter(Hooks, envSet.queryClient).withCrud(),
};
export default modelRouters;
export const createModelRouters = (queryClient: QueryClient) => ({
hook: createModelRouter(Hooks, queryClient).withCrud(),
});

View file

@ -1,7 +1,9 @@
import { MockQueries } from '#src/test-utils/tenant.js';
import initOidc from './init.js';
describe('oidc provider init', () => {
it('init should not throw', async () => {
expect(() => initOidc()).not.toThrow();
expect(() => initOidc(new MockQueries())).not.toThrow();
});
});

View file

@ -13,10 +13,8 @@ import { addOidcEventListeners } from '#src/event-listeners/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import postgresAdapter from '#src/oidc/adapter.js';
import { isOriginAllowed, validateCustomClientMetadata } from '#src/oidc/utils.js';
import { findApplicationById } from '#src/queries/application.js';
import { findResourceByIndicator } from '#src/queries/resource.js';
import { findUserById } from '#src/queries/user.js';
import { routes } from '#src/routes/consts.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { claimToUserKey, getUserClaims } from './scope.js';
@ -24,7 +22,12 @@ import { claimToUserKey, getUserClaims } from './scope.js';
// Temporarily removed 'EdDSA' since it's not supported by browser yet
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);
export default function initOidc(): Provider {
export default function initOidc(queries: Queries): Provider {
const {
applications: { findApplicationById },
resources: { findResourceByIndicator },
users: { findUserById },
} = queries;
const {
issuer,
cookieKeys,
@ -208,7 +211,7 @@ export default function initOidc(): Provider {
addOidcEventListeners(oidc);
// Provide audit log context for event listeners
oidc.use(koaAuditLog());
oidc.use(koaAuditLog(queries));
return oidc;
}

View file

@ -3,7 +3,6 @@ import type { MiddlewareType } from 'koa';
import koaBody from 'koa-body';
import LogtoRequestError from '#src/errors/RequestError/index.js';
import modelRouters from '#src/model-routers/index.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -23,6 +22,8 @@ const errorHandler: MiddlewareType = async (_, next) => {
}
};
export default function hookRoutes<T extends AuthedRouter>(...[router]: RouterInitArgs<T>) {
export default function hookRoutes<T extends AuthedRouter>(
...[router, { modelRouters }]: RouterInitArgs<T>
) {
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouters.hook.routes()));
}

View file

@ -45,12 +45,12 @@ export type RouterContext<T> = T extends Router<unknown, infer Context> ? Contex
export default function interactionRoutes<T extends AnonymousRouter>(
...[anonymousRouter, tenant]: RouterInitArgs<T>
) {
const { provider } = tenant;
const { provider, queries } = tenant;
const router =
// @ts-expect-error for good koa types
// eslint-disable-next-line no-restricted-syntax
(anonymousRouter as Router<unknown, WithInteractionDetailsContext<RouterContext<T>>>).use(
koaAuditLog(),
koaAuditLog(queries),
koaInteractionDetails(provider)
);
@ -280,7 +280,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router.post(
`${interactionPrefix}/submit`,
koaInteractionSie(),
koaInteractionHooks(provider),
koaInteractionHooks(tenant),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);

View file

@ -1,9 +1,8 @@
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 type TenantContext from '#src/tenants/TenantContext.js';
import { getInteractionStorage } from '../utils/interaction.js';
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
@ -12,7 +11,12 @@ export default function koaInteractionHooks<
StateT,
ContextT extends WithInteractionDetailsContext<IRouterParamContext>,
ResponseT
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseT> {
>({
provider,
libraries: {
hooks: { triggerInteractionHooksIfNeeded },
},
}: TenantContext): MiddlewareType<StateT, ContextT, ResponseT> {
return async (ctx, next) => {
const { event } = getInteractionStorage(ctx.interactionDetails.result);

View file

@ -1,8 +1,10 @@
import { createConnectorLibrary } from '#src/libraries/connector.js';
import { createHookLibrary } from '#src/libraries/hook.js';
import { createPhraseLibrary } from '#src/libraries/phrase.js';
import { createResourceLibrary } from '#src/libraries/resource.js';
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
import { createUserLibrary } from '#src/libraries/user.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import type Queries from './Queries.js';
@ -12,6 +14,7 @@ export default class Libraries {
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
phrases = createPhraseLibrary(this.queries);
resources = createResourceLibrary(this.queries);
hooks = createHookLibrary(this.queries, this.modelRouters);
constructor(public readonly queries: Queries) {}
constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {}
}

View file

@ -16,6 +16,8 @@ import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import { createModelRouters } from '#src/model-routers/index.js';
import initOidc from '#src/oidc/init.js';
import initRouter from '#src/routes/init.js';
@ -27,6 +29,7 @@ export default class Tenant implements TenantContext {
public readonly provider: Provider;
public readonly queries: Queries;
public readonly libraries: Libraries;
public readonly modelRouters: ModelRouters;
public readonly app: Koa;
@ -35,16 +38,18 @@ export default class Tenant implements TenantContext {
}
constructor(public id: string) {
const modelRouters = createModelRouters(envSet.queryClient);
const queries = new Queries(envSet.pool);
const libraries = new Libraries(queries);
const libraries = new Libraries(queries, modelRouters);
this.modelRouters = modelRouters;
this.queries = queries;
this.libraries = libraries;
// Init app
const app = new Koa();
const provider = initOidc();
const provider = initOidc(queries);
app.use(mount('/oidc', provider.app));
app.use(koaLogger());
@ -54,7 +59,7 @@ export default class Tenant implements TenantContext {
app.use(koaConnectorErrorHandler());
app.use(koaI18next());
const apisApp = initRouter({ provider, queries, libraries });
const apisApp = initRouter({ provider, queries, libraries, modelRouters });
app.use(mount('/api', apisApp));
app.use(mount('/', koaRootProxy()));

View file

@ -1,5 +1,7 @@
import type Provider from 'oidc-provider';
import type { ModelRouters } from '#src/model-routers/index.js';
import type Libraries from './Libraries.js';
import type Queries from './Queries.js';
@ -7,4 +9,5 @@ export default abstract class TenantContext {
public abstract readonly provider: Provider;
public abstract readonly queries: Queries;
public abstract readonly libraries: Libraries;
public abstract readonly modelRouters: ModelRouters;
}

View file

@ -1,11 +1,13 @@
import { createMockPool, createMockQueryResult } from 'slonik';
import { createModelRouters } from '#src/model-routers/index.js';
import Libraries from '#src/tenants/Libraries.js';
import Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import type { GrantMock } from './oidc-provider.js';
import { createMockProvider } from './oidc-provider.js';
import { MockQueryClient } from './query-client.js';
export class MockQueries extends Queries {
constructor(queriesOverride?: Partial2<Queries>) {
@ -44,6 +46,7 @@ export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
export class MockTenant implements TenantContext {
public queries: Queries;
public libraries: Libraries;
public modelRouters = createModelRouters(new MockQueryClient());
constructor(
public provider = createMockProvider(),
@ -51,7 +54,7 @@ export class MockTenant implements TenantContext {
librariesOverride?: Partial2<Libraries>
) {
this.queries = new MockQueries(queriesOverride);
this.libraries = new Libraries(this.queries);
this.libraries = new Libraries(this.queries, this.modelRouters);
this.setPartial('libraries', librariesOverride);
}