mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(schemas): define data hook events (#5828)
* feat(schemas): define data hook events define data hook events * fix(schemas,core): fix the type error fix the type error * fix(core): fix unit test fix unit test * feat(test): add integration tests for DataHooks add integration tests for DataHooks * fix(test): fix ut of management api hook middleware fix ut of the management api hook middleware * refactor(test,core,schemas): refactor some DataHook definiations refactor some DataHook definitations * chore(test): remove upper scope describe wrap remove upper scope describe wrap * fix(test): fix tests fix tests * refactor(schemas): rename the info.update events rename the info.update events * refactor(schemas): rename rename * refactor(core,schemas): refactor DataHook code refactor DataHook code to address some code review comments * fix(test): fix ut fix ut * fix(schemas): update DataHookEventPayload type update DataHookEventPayload type * chore(schemas): update comments update comments
This commit is contained in:
parent
cad032a22a
commit
e7a642028d
11 changed files with 720 additions and 18 deletions
|
@ -65,15 +65,19 @@ describe('koaManagementApiHooks', () => {
|
||||||
|
|
||||||
it.each(events)('should append hook context for %s', async (key, event) => {
|
it.each(events)('should append hook context for %s', async (key, event) => {
|
||||||
const [method, route] = key.split(' ') as [string, string];
|
const [method, route] = key.split(' ') as [string, string];
|
||||||
|
const ctxParams = createContextWithRouteParameters();
|
||||||
|
|
||||||
const ctx: ParameterizedContext<unknown, WithHookContext> = {
|
const ctx: ParameterizedContext<unknown, WithHookContext> = {
|
||||||
...createContextWithRouteParameters(),
|
...ctxParams,
|
||||||
header: {},
|
header: {},
|
||||||
appendDataHookContext: notToBeCalled,
|
appendDataHookContext: notToBeCalled,
|
||||||
method,
|
method,
|
||||||
_matchedRoute: route,
|
_matchedRoute: route,
|
||||||
path: route,
|
path: route,
|
||||||
body: { key },
|
response: {
|
||||||
|
...ctxParams.response,
|
||||||
|
body: { key },
|
||||||
|
},
|
||||||
status: 200,
|
status: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -85,7 +89,16 @@ describe('koaManagementApiHooks', () => {
|
||||||
contextArray: [
|
contextArray: [
|
||||||
{
|
{
|
||||||
event,
|
event,
|
||||||
data: { path: route, method, body: { key }, status: 200 },
|
data: {
|
||||||
|
path: route,
|
||||||
|
method,
|
||||||
|
response: {
|
||||||
|
body: { key },
|
||||||
|
},
|
||||||
|
params: ctxParams.params,
|
||||||
|
matchedRoute: route,
|
||||||
|
status: 200,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -49,13 +49,24 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
|
||||||
await next();
|
await next();
|
||||||
|
|
||||||
// Auto append pre-registered management API hooks if any
|
// Auto append pre-registered management API hooks if any
|
||||||
const { path, method, body, status, _matchedRoute } = ctx;
|
const {
|
||||||
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, _matchedRoute);
|
path,
|
||||||
|
method,
|
||||||
|
status,
|
||||||
|
_matchedRoute: matchedRoute,
|
||||||
|
params,
|
||||||
|
response: { body },
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
|
const hookRegistrationKey = buildManagementApiDataHookRegistrationKey(method, matchedRoute);
|
||||||
|
|
||||||
// TODO: @simeng-li do we need to insert the request body to the hook context?
|
|
||||||
if (hasRegisteredDataHookEvent(hookRegistrationKey)) {
|
if (hasRegisteredDataHookEvent(hookRegistrationKey)) {
|
||||||
const event = managementApiHooksRegistration[hookRegistrationKey];
|
const event = managementApiHooksRegistration[hookRegistrationKey];
|
||||||
dataHooks.appendContext({ event, data: { path, method, body, status } });
|
|
||||||
|
dataHooks.appendContext({
|
||||||
|
event,
|
||||||
|
data: { path, method, response: { body }, status, params, matchedRoute },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataHooks.contextArray.length > 0) {
|
if (dataHooks.contextArray.length > 0) {
|
||||||
|
|
|
@ -4,13 +4,13 @@ import type {
|
||||||
PrimitiveValueExpression,
|
PrimitiveValueExpression,
|
||||||
TaggedTemplateLiteralInvocation,
|
TaggedTemplateLiteralInvocation,
|
||||||
} from '@silverhand/slonik/dist/src/types.js';
|
} from '@silverhand/slonik/dist/src/types.js';
|
||||||
import type { MiddlewareType, Context, Middleware } from 'koa';
|
import type { Context, Middleware, MiddlewareType } from 'koa';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import type { IRouterParamContext } from 'koa-router';
|
import type { IRouterParamContext } from 'koa-router';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
import type { ManagementApiRouter, AnonymousRouter } from '#src/routes/types.js';
|
import type { AnonymousRouter, ManagementApiRouter } from '#src/routes/types.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
import type { Options } from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||||
|
|
|
@ -48,7 +48,8 @@
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"puppeteer": "^22.6.5",
|
"puppeteer": "^22.6.5",
|
||||||
"text-encoder": "^0.0.4",
|
"text-encoder": "^0.0.4",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.9.0"
|
"node": "^20.9.0"
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createServer, type RequestListener, type Server } from 'node:http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mock server that listens for incoming requests and responds with the request body.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const server = new WebhookMockServer(3000);
|
||||||
|
* await server.listen();
|
||||||
|
*/
|
||||||
|
class WebhookMockServer {
|
||||||
|
public readonly endpoint = `http://localhost:${this.port}`;
|
||||||
|
private readonly server: Server;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
/** The port number to listen on. */
|
||||||
|
private readonly port: number,
|
||||||
|
/** A callback that is called with the request body when a request is received. */
|
||||||
|
requestCallback?: (body: string) => void
|
||||||
|
) {
|
||||||
|
const requestListener: RequestListener = (request, response) => {
|
||||||
|
const data: Uint8Array[] = [];
|
||||||
|
|
||||||
|
request.on('data', (chunk: Uint8Array) => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||||
|
data.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('end', () => {
|
||||||
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|
||||||
|
const payload: unknown = JSON.parse(Buffer.concat(data).toString());
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
signature: request.headers['logto-signature-sha-256'],
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestCallback?.(body);
|
||||||
|
|
||||||
|
response.end(body);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.server = createServer(requestListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listen() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.server.listen(this.port, () => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async close() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.server.close(() => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebhookMockServer;
|
|
@ -0,0 +1,342 @@
|
||||||
|
import {
|
||||||
|
RoleType,
|
||||||
|
hookEventGuard,
|
||||||
|
hookEvents,
|
||||||
|
jsonGuard,
|
||||||
|
managementApiHooksRegistration,
|
||||||
|
type Hook,
|
||||||
|
type Role,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { authedAdminApi } from '#src/api/api.js';
|
||||||
|
import { createResource } from '#src/api/resource.js';
|
||||||
|
import { createScope } from '#src/api/scope.js';
|
||||||
|
import {
|
||||||
|
OrganizationApiTest,
|
||||||
|
OrganizationRoleApiTest,
|
||||||
|
OrganizationScopeApiTest,
|
||||||
|
} from '#src/helpers/organization.js';
|
||||||
|
import { UserApiTest, generateNewUser } from '#src/helpers/user.js';
|
||||||
|
import { generateName, waitFor } from '#src/utils.js';
|
||||||
|
|
||||||
|
import WebhookMockServer from './WebhookMockServer.js';
|
||||||
|
import {
|
||||||
|
organizationDataHookTestCases,
|
||||||
|
organizationRoleDataHookTestCases,
|
||||||
|
organizationScopeDataHookTestCases,
|
||||||
|
roleDataHookTestCases,
|
||||||
|
scopesDataHookTestCases,
|
||||||
|
userDataHookTestCases,
|
||||||
|
} from './test-cases.js';
|
||||||
|
|
||||||
|
const mockHookResponseGuard = z.object({
|
||||||
|
signature: z.string(),
|
||||||
|
payload: z.object({
|
||||||
|
event: hookEventGuard,
|
||||||
|
createdAt: z.string(),
|
||||||
|
hookId: z.string(),
|
||||||
|
body: jsonGuard.optional(),
|
||||||
|
method: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value?.toUpperCase()),
|
||||||
|
matchedRoute: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type MockHookResponse = z.infer<typeof mockHookResponseGuard>;
|
||||||
|
|
||||||
|
const hookName = 'management-api-hook';
|
||||||
|
const webhooks = new Map<string, Hook>();
|
||||||
|
const webhookResults = new Map<string, MockHookResponse>();
|
||||||
|
|
||||||
|
// Record the hook response to the webhookResults map.
|
||||||
|
// Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered.
|
||||||
|
const webhookResponseHandler = (response: string) => {
|
||||||
|
const result = mockHookResponseGuard.parse(JSON.parse(response));
|
||||||
|
const { payload } = result;
|
||||||
|
|
||||||
|
// Use matchedRoute as the key
|
||||||
|
if (payload.matchedRoute) {
|
||||||
|
webhookResults.set(`${payload.method} ${payload.matchedRoute}`, result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the webhook result by the key.
|
||||||
|
*
|
||||||
|
* @remark Since the webhook request is async, we need to wait for a while
|
||||||
|
* to ensure the webhook response is received.
|
||||||
|
*/
|
||||||
|
const getWebhookResult = async (key: string) => {
|
||||||
|
await waitFor(100);
|
||||||
|
|
||||||
|
return webhookResults.get(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhookServer = new WebhookMockServer(9999, webhookResponseHandler);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await webhookServer.listen();
|
||||||
|
|
||||||
|
const webhookInstance = await authedAdminApi
|
||||||
|
.post('hooks', {
|
||||||
|
json: {
|
||||||
|
name: hookName,
|
||||||
|
events: [...hookEvents],
|
||||||
|
config: {
|
||||||
|
url: webhookServer.endpoint,
|
||||||
|
headers: { foo: 'bar' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.json<Hook>();
|
||||||
|
|
||||||
|
webhooks.set(hookName, webhookInstance);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`))
|
||||||
|
);
|
||||||
|
|
||||||
|
await webhookServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user data hook events', () => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-let
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a user to trigger the User.Created event.
|
||||||
|
const { user } = await generateNewUser({ username: true, password: true });
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
userId = user.id;
|
||||||
|
const userCreateHook = await getWebhookResult('POST /users');
|
||||||
|
expect(userCreateHook?.payload.event).toBe('User.Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(userDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](endpoint.replace('{userId}', userId), { json: payload });
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role data hook events', () => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let roleId: string;
|
||||||
|
let scopeId: string;
|
||||||
|
let resourceId: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a role to trigger the Role.Created event.
|
||||||
|
const role = await authedAdminApi
|
||||||
|
.post('roles', {
|
||||||
|
json: { name: generateName(), description: 'data-hook-role', type: RoleType.User },
|
||||||
|
})
|
||||||
|
.json<Role>();
|
||||||
|
|
||||||
|
const roleCreateHook = await getWebhookResult('POST /roles');
|
||||||
|
expect(roleCreateHook?.payload.event).toBe('Role.Created');
|
||||||
|
|
||||||
|
// Prepare the role and scope id for the Role.Scopes.Updated event.
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
roleId = role.id;
|
||||||
|
const resource = await createResource();
|
||||||
|
const scope = await createScope(resource.id);
|
||||||
|
|
||||||
|
scopeId = scope.id;
|
||||||
|
resourceId = resource.id;
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await authedAdminApi.delete(`resources/${resourceId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(roleDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](
|
||||||
|
endpoint.replace('{roleId}', roleId).replace('{scopeId}', scopeId),
|
||||||
|
// Replace all the scopeId placeholder in the payload
|
||||||
|
{ json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) }
|
||||||
|
);
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scope data hook events', () => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let resourceId: string;
|
||||||
|
let scopeId: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const resource = await createResource();
|
||||||
|
const scope = await createScope(resource.id);
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
resourceId = resource.id;
|
||||||
|
scopeId = scope.id;
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
|
const scopesCreateHook = await getWebhookResult('POST /resources/:resourceId/scopes');
|
||||||
|
expect(scopesCreateHook?.payload.event).toBe('Scope.Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await authedAdminApi.delete(`resources/${resourceId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(scopesDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](
|
||||||
|
endpoint.replace('{resourceId}', resourceId).replace('{scopeId}', scopeId),
|
||||||
|
{ json: payload }
|
||||||
|
);
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organization data hook events', () => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let organizationId: string;
|
||||||
|
let userId: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
const organizationApi = new OrganizationApiTest();
|
||||||
|
const userApi = new UserApiTest();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const organization = await organizationApi.create({
|
||||||
|
name: generateName(),
|
||||||
|
description: 'organization data hook test organization.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await userApi.create({ name: generateName() });
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
organizationId = organization.id;
|
||||||
|
userId = user.id;
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
|
const organizationCreateHook = await getWebhookResult('POST /organizations');
|
||||||
|
expect(organizationCreateHook?.payload.event).toBe('Organization.Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await userApi.cleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(organizationDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](
|
||||||
|
endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId),
|
||||||
|
{ json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) }
|
||||||
|
);
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organization scope data hook events', () => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let scopeId: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
const organizationScopeApi = new OrganizationScopeApiTest();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const scope = await organizationScopeApi.create({
|
||||||
|
name: generateName(),
|
||||||
|
description: 'organization scope data hook test scope.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
scopeId = scope.id;
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
|
const organizationScopeCreateHook = await getWebhookResult('POST /organization-scopes');
|
||||||
|
expect(organizationScopeCreateHook?.payload.event).toBe('OrganizationScope.Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(organizationScopeDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](endpoint.replace('{organizationScopeId}', scopeId), {
|
||||||
|
json: payload,
|
||||||
|
});
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organization role data hook events', () => {
|
||||||
|
/* eslint-disable @silverhand/fp/no-let */
|
||||||
|
let roleId: string;
|
||||||
|
let scopeId: string;
|
||||||
|
/* eslint-enable @silverhand/fp/no-let */
|
||||||
|
|
||||||
|
const organizationScopeApi = new OrganizationScopeApiTest();
|
||||||
|
const roleApi = new OrganizationRoleApiTest();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const role = await roleApi.create({
|
||||||
|
name: generateName(),
|
||||||
|
description: 'organization role data hook test role.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = await organizationScopeApi.create({
|
||||||
|
name: generateName(),
|
||||||
|
description: 'organization role data hook test scope.',
|
||||||
|
});
|
||||||
|
|
||||||
|
/* eslint-disable @silverhand/fp/no-mutation */
|
||||||
|
roleId = role.id;
|
||||||
|
scopeId = scope.id;
|
||||||
|
/* eslint-enable @silverhand/fp/no-mutation */
|
||||||
|
|
||||||
|
const organizationRoleCreateHook = await getWebhookResult('POST /organization-roles');
|
||||||
|
expect(organizationRoleCreateHook?.payload.event).toBe('OrganizationRole.Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await organizationScopeApi.cleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(organizationRoleDataHookTestCases)(
|
||||||
|
'test case %#: %p',
|
||||||
|
async ({ route, event, method, endpoint, payload }) => {
|
||||||
|
await authedAdminApi[method](
|
||||||
|
endpoint.replace('{organizationRoleId}', roleId).replace('{scopeId}', scopeId),
|
||||||
|
{ json: JSON.parse(JSON.stringify(payload).replace('{scopeId}', scopeId)) }
|
||||||
|
);
|
||||||
|
const hook = await getWebhookResult(route);
|
||||||
|
expect(hook?.payload.event).toBe(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data hook events coverage', () => {
|
||||||
|
const keys = Object.keys(managementApiHooksRegistration);
|
||||||
|
it.each(keys)('should have test case for %s', async (key) => {
|
||||||
|
const webhookResult = await getWebhookResult(key);
|
||||||
|
expect(webhookResult).toBeDefined();
|
||||||
|
expect(webhookResult?.signature).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
195
packages/integration-tests/src/tests/api/hook/test-cases.ts
Normal file
195
packages/integration-tests/src/tests/api/hook/test-cases.ts
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
import { generateName } from '#src/utils.js';
|
||||||
|
|
||||||
|
type TestCase = {
|
||||||
|
route: string;
|
||||||
|
event: string;
|
||||||
|
method: 'patch' | 'post' | 'delete' | 'put';
|
||||||
|
endpoint: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /users/:userId',
|
||||||
|
event: 'User.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `users/{userId}`,
|
||||||
|
payload: { name: 'new name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PATCH /users/:userId/custom-data',
|
||||||
|
event: 'User.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `users/{userId}/custom-data`,
|
||||||
|
payload: { customData: { foo: 'bar' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PATCH /users/:userId/profile',
|
||||||
|
event: 'User.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `users/{userId}/profile`,
|
||||||
|
payload: { profile: { nickname: 'darcy' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PATCH /users/:userId/password',
|
||||||
|
event: 'User.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `users/{userId}/password`,
|
||||||
|
payload: { password: 'new-password' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PATCH /users/:userId/is-suspended',
|
||||||
|
event: 'User.SuspensionStatus.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `users/{userId}/is-suspended`,
|
||||||
|
payload: { isSuspended: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /users/:userId',
|
||||||
|
event: 'User.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `users/{userId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const roleDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /roles/:id',
|
||||||
|
event: 'Role.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `roles/{roleId}`,
|
||||||
|
payload: { name: 'new name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'POST /roles/:id/scopes',
|
||||||
|
event: 'Role.Scopes.Updated',
|
||||||
|
method: 'post',
|
||||||
|
endpoint: `roles/{roleId}/scopes`,
|
||||||
|
payload: { scopeIds: ['{scopeId}'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /roles/:id/scopes/:scopeId',
|
||||||
|
event: 'Role.Scopes.Updated',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `roles/{roleId}/scopes/{scopeId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /roles/:id',
|
||||||
|
event: 'Role.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `roles/{roleId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const scopesDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /resources/:resourceId/scopes/:scopeId',
|
||||||
|
event: 'Scope.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `resources/{resourceId}/scopes/{scopeId}`,
|
||||||
|
payload: { name: generateName() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /resources/:resourceId/scopes/:scopeId',
|
||||||
|
event: 'Scope.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `resources/{resourceId}/scopes/{scopeId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const organizationDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /organizations/:id',
|
||||||
|
event: 'Organization.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `organizations/{organizationId}`,
|
||||||
|
payload: { description: 'new org description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'POST /organizations/:id/users',
|
||||||
|
event: 'Organization.Membership.Updated',
|
||||||
|
method: 'post',
|
||||||
|
endpoint: `organizations/{organizationId}/users`,
|
||||||
|
payload: { userIds: ['{userId}'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PUT /organizations/:id/users',
|
||||||
|
event: 'Organization.Membership.Updated',
|
||||||
|
method: 'put',
|
||||||
|
endpoint: `organizations/{organizationId}/users`,
|
||||||
|
payload: { userIds: ['{userId}'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /organizations/:id/users/:userId',
|
||||||
|
event: 'Organization.Membership.Updated',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `organizations/{organizationId}/users/{userId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /organizations/:id',
|
||||||
|
event: 'Organization.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `organizations/{organizationId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const organizationScopeDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /organization-scopes/:id',
|
||||||
|
event: 'OrganizationScope.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `organization-scopes/{organizationScopeId}`,
|
||||||
|
payload: { description: 'new org scope description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /organization-scopes/:id',
|
||||||
|
event: 'OrganizationScope.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `organization-scopes/{organizationScopeId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const organizationRoleDataHookTestCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
route: 'PATCH /organization-roles/:id',
|
||||||
|
event: 'OrganizationRole.Updated',
|
||||||
|
method: 'patch',
|
||||||
|
endpoint: `organization-roles/{organizationRoleId}`,
|
||||||
|
payload: { name: generateName() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'POST /organization-roles/:id/scopes',
|
||||||
|
event: 'OrganizationRole.Scopes.Updated',
|
||||||
|
method: 'post',
|
||||||
|
endpoint: `organization-roles/{organizationRoleId}/scopes`,
|
||||||
|
payload: { organizationScopeIds: ['{scopeId}'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'PUT /organization-roles/:id/scopes',
|
||||||
|
event: 'OrganizationRole.Scopes.Updated',
|
||||||
|
method: 'put',
|
||||||
|
endpoint: `organization-roles/{organizationRoleId}/scopes`,
|
||||||
|
payload: { organizationScopeIds: ['{scopeId}'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /organization-roles/:id/scopes/:organizationScopeId',
|
||||||
|
event: 'OrganizationRole.Scopes.Updated',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `organization-roles/{organizationRoleId}/scopes/{scopeId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
route: 'DELETE /organization-roles/:id',
|
||||||
|
event: 'OrganizationRole.Deleted',
|
||||||
|
method: 'delete',
|
||||||
|
endpoint: `organization-roles/{organizationRoleId}`,
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
];
|
|
@ -15,26 +15,62 @@ export enum InteractionHookEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataHookEvent
|
// DataHookEvent
|
||||||
// TODO: @simeng-li implement more data hook events
|
enum DataHookSchema {
|
||||||
enum DataHookMutableSchema {
|
User = 'User',
|
||||||
Role = 'Role',
|
Role = 'Role',
|
||||||
|
Scope = 'Scope',
|
||||||
|
Organization = 'Organization',
|
||||||
|
OrganizationRole = 'OrganizationRole',
|
||||||
|
OrganizationScope = 'OrganizationScope',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DataHookMutationType {
|
enum DataHookBasicMutationType {
|
||||||
Created = 'Created',
|
Created = 'Created',
|
||||||
Updated = 'Updated',
|
|
||||||
Deleted = 'Deleted',
|
Deleted = 'Deleted',
|
||||||
|
Updated = 'Updated',
|
||||||
}
|
}
|
||||||
export type DataHookEvent = `${DataHookMutableSchema}.${DataHookMutationType}`;
|
|
||||||
|
type BasicDataHookEvent = `${DataHookSchema}.${DataHookBasicMutationType}`;
|
||||||
|
|
||||||
|
// Custom DataHook mutable schemas
|
||||||
|
type CustomDataHookMutableSchema =
|
||||||
|
| `${DataHookSchema.User}.SuspensionStatus`
|
||||||
|
| `${DataHookSchema.Role}.Scopes`
|
||||||
|
| `${DataHookSchema.Organization}.Membership`
|
||||||
|
| `${DataHookSchema.OrganizationRole}.Scopes`;
|
||||||
|
|
||||||
|
type DataHookPropertyUpdateEvent =
|
||||||
|
`${CustomDataHookMutableSchema}.${DataHookBasicMutationType.Updated}`;
|
||||||
|
|
||||||
|
export type DataHookEvent = BasicDataHookEvent | DataHookPropertyUpdateEvent;
|
||||||
|
|
||||||
/** The hook event values that can be registered. */
|
/** The hook event values that can be registered. */
|
||||||
export const hookEvents = Object.freeze([
|
export const hookEvents = Object.freeze([
|
||||||
InteractionHookEvent.PostRegister,
|
InteractionHookEvent.PostRegister,
|
||||||
InteractionHookEvent.PostSignIn,
|
InteractionHookEvent.PostSignIn,
|
||||||
InteractionHookEvent.PostResetPassword,
|
InteractionHookEvent.PostResetPassword,
|
||||||
|
'User.Created',
|
||||||
|
'User.Deleted',
|
||||||
|
'User.Updated',
|
||||||
|
'User.SuspensionStatus.Updated',
|
||||||
'Role.Created',
|
'Role.Created',
|
||||||
'Role.Updated',
|
|
||||||
'Role.Deleted',
|
'Role.Deleted',
|
||||||
|
'Role.Updated',
|
||||||
|
'Role.Scopes.Updated',
|
||||||
|
'Scope.Created',
|
||||||
|
'Scope.Deleted',
|
||||||
|
'Scope.Updated',
|
||||||
|
'Organization.Created',
|
||||||
|
'Organization.Deleted',
|
||||||
|
'Organization.Updated',
|
||||||
|
'Organization.Membership.Updated',
|
||||||
|
'OrganizationRole.Created',
|
||||||
|
'OrganizationRole.Deleted',
|
||||||
|
'OrganizationRole.Updated',
|
||||||
|
'OrganizationRole.Scopes.Updated',
|
||||||
|
'OrganizationScope.Created',
|
||||||
|
'OrganizationScope.Deleted',
|
||||||
|
'OrganizationScope.Updated',
|
||||||
] as const satisfies Array<InteractionHookEvent | DataHookEvent>);
|
] as const satisfies Array<InteractionHookEvent | DataHookEvent>);
|
||||||
|
|
||||||
/** The type of hook event values that can be registered. */
|
/** The type of hook event values that can be registered. */
|
||||||
|
@ -74,7 +110,34 @@ type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
* Define the hook event that should be triggered when the management API is called.
|
* Define the hook event that should be triggered when the management API is called.
|
||||||
*/
|
*/
|
||||||
export const managementApiHooksRegistration = Object.freeze({
|
export const managementApiHooksRegistration = Object.freeze({
|
||||||
|
'POST /users': 'User.Created',
|
||||||
|
'DELETE /users/:userId': 'User.Deleted',
|
||||||
|
'PATCH /users/:userId': 'User.Updated',
|
||||||
|
'PATCH /users/:userId/custom-data': 'User.Updated',
|
||||||
|
'PATCH /users/:userId/profile': 'User.Updated',
|
||||||
|
'PATCH /users/:userId/password': 'User.Updated',
|
||||||
|
'PATCH /users/:userId/is-suspended': 'User.SuspensionStatus.Updated',
|
||||||
'POST /roles': 'Role.Created',
|
'POST /roles': 'Role.Created',
|
||||||
'PATCH /roles/:id': 'Role.Updated',
|
|
||||||
'DELETE /roles/:id': 'Role.Deleted',
|
'DELETE /roles/:id': 'Role.Deleted',
|
||||||
|
'PATCH /roles/:id': 'Role.Updated',
|
||||||
|
'POST /roles/:id/scopes': 'Role.Scopes.Updated',
|
||||||
|
'DELETE /roles/:id/scopes/:scopeId': 'Role.Scopes.Updated',
|
||||||
|
'POST /resources/:resourceId/scopes': 'Scope.Created',
|
||||||
|
'DELETE /resources/:resourceId/scopes/:scopeId': 'Scope.Deleted',
|
||||||
|
'PATCH /resources/:resourceId/scopes/:scopeId': 'Scope.Updated',
|
||||||
|
'POST /organizations': 'Organization.Created',
|
||||||
|
'DELETE /organizations/:id': 'Organization.Deleted',
|
||||||
|
'PATCH /organizations/:id': 'Organization.Updated',
|
||||||
|
'PUT /organizations/:id/users': 'Organization.Membership.Updated',
|
||||||
|
'POST /organizations/:id/users': 'Organization.Membership.Updated',
|
||||||
|
'DELETE /organizations/:id/users/:userId': 'Organization.Membership.Updated',
|
||||||
|
'POST /organization-roles': 'OrganizationRole.Created',
|
||||||
|
'DELETE /organization-roles/:id': 'OrganizationRole.Deleted',
|
||||||
|
'PATCH /organization-roles/:id': 'OrganizationRole.Updated',
|
||||||
|
'POST /organization-scopes': 'OrganizationScope.Created',
|
||||||
|
'DELETE /organization-scopes/:id': 'OrganizationScope.Deleted',
|
||||||
|
'PATCH /organization-scopes/:id': 'OrganizationScope.Updated',
|
||||||
|
'PUT /organization-roles/:id/scopes': 'OrganizationRole.Scopes.Updated',
|
||||||
|
'POST /organization-roles/:id/scopes': 'OrganizationRole.Scopes.Updated',
|
||||||
|
'DELETE /organization-roles/:id/scopes/:organizationScopeId': 'OrganizationRole.Scopes.Updated',
|
||||||
} satisfies Record<`${ApiMethod} ${string}`, DataHookEvent>);
|
} satisfies Record<`${ApiMethod} ${string}`, DataHookEvent>);
|
||||||
|
|
|
@ -23,9 +23,19 @@ export type DataHookEventPayload = {
|
||||||
hookId: string;
|
hookId: string;
|
||||||
ip?: string;
|
ip?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
body?: Record<string, unknown>;
|
/** An object that contains response data. */
|
||||||
|
response?: {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
/** Request route params. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
/** Request route path. */
|
||||||
path?: string;
|
path?: string;
|
||||||
|
/** Matched route used as the identifier to trigger the hook. */
|
||||||
|
matchedRoute?: string;
|
||||||
|
/** Response status code. */
|
||||||
status?: number;
|
status?: number;
|
||||||
|
/** Request method. */
|
||||||
method?: string;
|
method?: string;
|
||||||
} & Record<string, unknown>;
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
|
|
@ -3767,6 +3767,9 @@ importers:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.3
|
specifier: ^5.3.3
|
||||||
version: 5.3.3
|
version: 5.3.3
|
||||||
|
zod:
|
||||||
|
specifier: ^3.22.4
|
||||||
|
version: 3.22.4
|
||||||
|
|
||||||
packages/phrases:
|
packages/phrases:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
Loading…
Reference in a new issue