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

feat(core,schemas): support get hook recent logs (#3859)

This commit is contained in:
Xiao Yijun 2023-05-22 19:01:54 +08:00 committed by GitHub
parent f1730db70b
commit b92508db3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 147 additions and 5 deletions

View file

@ -13,10 +13,12 @@ export type LogCondition = {
logKey?: string;
applicationId?: string;
userId?: string;
hookId?: string;
startTimeExclusive?: number;
};
const buildLogConditionSql = (logCondition: LogCondition) =>
conditionalSql(logCondition, ({ logKey, applicationId, userId }) => {
conditionalSql(logCondition, ({ logKey, applicationId, userId, hookId, startTimeExclusive }) => {
const subConditions = [
conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`),
conditionalSql(userId, (userId) => sql`${fields.payload}->>'userId'=${userId}`),
@ -24,6 +26,12 @@ const buildLogConditionSql = (logCondition: LogCondition) =>
applicationId,
(applicationId) => sql`${fields.payload}->>'applicationId'=${applicationId}`
),
conditionalSql(hookId, (hookId) => sql`${fields.payload}->>'hookId'=${hookId}`),
conditionalSql(
startTimeExclusive,
(startTimeExclusive) =>
sql`${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)`
),
].filter(({ sql }) => sql);
return subConditions.length > 0 ? sql`where ${sql.join(subConditions, sql` and `)}` : sql``;

View file

@ -4,8 +4,11 @@ import {
type HookEvents,
type HookConfig,
type CreateHook,
LogResult,
type Log,
} from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { subDays } from 'date-fns';
import {
mockCreatedAtForHook,
@ -42,13 +45,34 @@ const hooks = {
deleteHookById: jest.fn(),
};
const tenantContext = new MockTenant(undefined, { hooks });
const mockLog: Log = {
tenantId: 'fake_tenant',
id: '1',
key: 'a',
payload: { key: 'a', result: LogResult.Success },
createdAt: 123,
};
const logs = {
countLogs: jest.fn().mockResolvedValue({
count: 1,
}),
findLogs: jest.fn().mockResolvedValue([mockLog]),
};
const { countLogs, findLogs } = logs;
const tenantContext = new MockTenant(undefined, { hooks, logs });
const hookRoutes = await pickDefault(import('./hook.js'));
describe('hook routes', () => {
const hookRequest = createRequester({ authedRoutes: hookRoutes, tenantContext });
afterEach(() => {
jest.clearAllMocks();
});
it('GET /hooks', async () => {
const response = await hookRequest.get('/hooks');
expect(response.status).toEqual(200);
@ -63,6 +87,25 @@ describe('hook routes', () => {
expect(response.body.id).toBe(hookIdInMockList);
});
it('GET /hooks/:id/recent-logs should call countLogs and findLogs with correct parameters', async () => {
jest.useFakeTimers().setSystemTime(100_000);
const hookId = 'foo';
const logKey = 'TriggerHook.PostSignIn';
const page = 1;
const pageSize = 5;
const startTimeExclusive = subDays(new Date(100_000), 1).getTime();
await hookRequest.get(
`/hooks/${hookId}/recent-logs?logKey=${logKey}&page=${page}&page_size=${pageSize}`
);
expect(countLogs).toHaveBeenCalledWith({ hookId, logKey, startTimeExclusive });
expect(findLogs).toHaveBeenCalledWith(5, 0, { hookId, logKey, startTimeExclusive });
jest.useRealTimers();
});
it('POST /hooks', async () => {
const name = 'fooName';
const events: HookEvents = [HookEvent.PostRegister];

View file

@ -1,10 +1,12 @@
import { Hooks, hookEventsGuard } from '@logto/schemas';
import { Hooks, Logs, hookEventsGuard } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, deduplicate } from '@silverhand/essentials';
import { subDays } from 'date-fns';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -16,7 +18,10 @@ const nonemptyUniqueHookEventsGuard = hookEventsGuard
export default function hookRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById } = queries.hooks;
const {
hooks: { findAllHooks, findHookById, insertHook, updateHookById, deleteHookById },
logs: { countLogs, findLogs },
} = queries;
router.get(
'/hooks',
@ -46,6 +51,37 @@ export default function hookRoutes<T extends AuthedRouter>(
}
);
router.get(
'/hooks/:id/recent-logs',
koaPagination(),
koaGuard({
params: z.object({ id: z.string() }),
query: z.object({ logKey: z.string().optional() }),
response: Logs.guard.omit({ tenantId: true }).array(),
status: 200,
}),
async (ctx, next) => {
const { limit, offset } = ctx.pagination;
const {
params: { id },
query: { logKey },
} = ctx.guard;
const startTimeExclusive = subDays(new Date(), 1).getTime();
const [{ count }, logs] = await Promise.all([
countLogs({ logKey, hookId: id, startTimeExclusive }),
findLogs(limit, offset, { logKey, hookId: id, startTimeExclusive }),
]);
ctx.pagination.totalCount = count;
ctx.body = logs;
return next();
}
);
router.post(
'/hooks',
koaGuard({

View file

@ -1,4 +1,4 @@
import type { Hook, HookConfig, LogKey } from '@logto/schemas';
import type { Hook, HookConfig, Log, LogKey } from '@logto/schemas';
import { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
@ -225,4 +225,38 @@ describe('hooks', () => {
]);
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);
});
});

View file

@ -0,0 +1,18 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create index logs__hook_id on logs (tenant_id, (payload->>'hookId'));
`);
},
down: async (pool) => {
await pool.query(sql`
drop index logs__hook_id;
`);
},
};
export default alteration;

View file

@ -19,3 +19,6 @@ create index logs__user_id
create index logs__application_id
on logs (tenant_id, (payload->>'applicationId'));
create index logs__hook_id
on logs (tenant_id, (payload->>'hookId'));