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:
parent
f1730db70b
commit
b92508db3a
6 changed files with 147 additions and 5 deletions
|
@ -13,10 +13,12 @@ export type LogCondition = {
|
||||||
logKey?: string;
|
logKey?: string;
|
||||||
applicationId?: string;
|
applicationId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
hookId?: string;
|
||||||
|
startTimeExclusive?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildLogConditionSql = (logCondition: LogCondition) =>
|
const buildLogConditionSql = (logCondition: LogCondition) =>
|
||||||
conditionalSql(logCondition, ({ logKey, applicationId, userId }) => {
|
conditionalSql(logCondition, ({ logKey, applicationId, userId, hookId, startTimeExclusive }) => {
|
||||||
const subConditions = [
|
const subConditions = [
|
||||||
conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`),
|
conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`),
|
||||||
conditionalSql(userId, (userId) => sql`${fields.payload}->>'userId'=${userId}`),
|
conditionalSql(userId, (userId) => sql`${fields.payload}->>'userId'=${userId}`),
|
||||||
|
@ -24,6 +26,12 @@ const buildLogConditionSql = (logCondition: LogCondition) =>
|
||||||
applicationId,
|
applicationId,
|
||||||
(applicationId) => sql`${fields.payload}->>'applicationId'=${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);
|
].filter(({ sql }) => sql);
|
||||||
|
|
||||||
return subConditions.length > 0 ? sql`where ${sql.join(subConditions, sql` and `)}` : sql``;
|
return subConditions.length > 0 ? sql`where ${sql.join(subConditions, sql` and `)}` : sql``;
|
||||||
|
|
|
@ -4,8 +4,11 @@ import {
|
||||||
type HookEvents,
|
type HookEvents,
|
||||||
type HookConfig,
|
type HookConfig,
|
||||||
type CreateHook,
|
type CreateHook,
|
||||||
|
LogResult,
|
||||||
|
type Log,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { pickDefault } from '@logto/shared/esm';
|
import { pickDefault } from '@logto/shared/esm';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mockCreatedAtForHook,
|
mockCreatedAtForHook,
|
||||||
|
@ -42,13 +45,34 @@ const hooks = {
|
||||||
deleteHookById: jest.fn(),
|
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'));
|
const hookRoutes = await pickDefault(import('./hook.js'));
|
||||||
|
|
||||||
describe('hook routes', () => {
|
describe('hook routes', () => {
|
||||||
const hookRequest = createRequester({ authedRoutes: hookRoutes, tenantContext });
|
const hookRequest = createRequester({ authedRoutes: hookRoutes, tenantContext });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('GET /hooks', async () => {
|
it('GET /hooks', async () => {
|
||||||
const response = await hookRequest.get('/hooks');
|
const response = await hookRequest.get('/hooks');
|
||||||
expect(response.status).toEqual(200);
|
expect(response.status).toEqual(200);
|
||||||
|
@ -63,6 +87,25 @@ describe('hook routes', () => {
|
||||||
expect(response.body.id).toBe(hookIdInMockList);
|
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 () => {
|
it('POST /hooks', async () => {
|
||||||
const name = 'fooName';
|
const name = 'fooName';
|
||||||
const events: HookEvents = [HookEvent.PostRegister];
|
const events: HookEvents = [HookEvent.PostRegister];
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { Hooks, hookEventsGuard } from '@logto/schemas';
|
import { Hooks, Logs, hookEventsGuard } from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { conditional, deduplicate } from '@silverhand/essentials';
|
import { conditional, deduplicate } from '@silverhand/essentials';
|
||||||
|
import { subDays } from 'date-fns';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.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 assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||||
|
@ -16,7 +18,10 @@ const nonemptyUniqueHookEventsGuard = hookEventsGuard
|
||||||
export default function hookRoutes<T extends AuthedRouter>(
|
export default function hookRoutes<T extends AuthedRouter>(
|
||||||
...[router, { queries }]: RouterInitArgs<T>
|
...[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(
|
router.get(
|
||||||
'/hooks',
|
'/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(
|
router.post(
|
||||||
'/hooks',
|
'/hooks',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
|
|
|
@ -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 { HookEvent, SignInIdentifier, LogResult, InteractionEvent } from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
|
import { authedAdminApi, deleteUser, getLogs, putInteraction } from '#src/api/index.js';
|
||||||
|
@ -225,4 +225,38 @@ describe('hooks', () => {
|
||||||
]);
|
]);
|
||||||
await deleteUser(id);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
|
@ -19,3 +19,6 @@ create index logs__user_id
|
||||||
|
|
||||||
create index logs__application_id
|
create index logs__application_id
|
||||||
on logs (tenant_id, (payload->>'applicationId'));
|
on logs (tenant_id, (payload->>'applicationId'));
|
||||||
|
|
||||||
|
create index logs__hook_id
|
||||||
|
on logs (tenant_id, (payload->>'hookId'));
|
||||||
|
|
Loading…
Reference in a new issue