Skip to content

Commit

Permalink
test(core): add integration tests
Browse files Browse the repository at this point in the history
add integration tests for interaction hooks
  • Loading branch information
simeng-li committed May 13, 2024
1 parent 7991f26 commit 0c759a4
Show file tree
Hide file tree
Showing 9 changed files with 674 additions and 427 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ export default function koaInteractionHooks<
});

// Assign user and event data to the data hook context
const assignDataHookContext: AssignDataHookContext = ({ event, user, data }) => {
const assignDataHookContext: AssignDataHookContext = ({ event, user, data: extraData }) => {
dataHookContext.appendContext({
event,
data: {
// Only return the selected user fields
...conditional(user && { user: pick(user, ...userInfoSelectFields) }),
...data,
...conditional(user && pick(user, ...userInfoSelectFields)),
...extraData,
},
});
};
Expand Down
11 changes: 8 additions & 3 deletions packages/integration-tests/src/helpers/connector.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { ConnectorType } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';

import {
mockEmailConnectorConfig,
mockEmailConnectorId,
mockSmsConnectorConfig,
mockSmsConnectorId,
mockSocialConnectorId,
mockSocialConnectorConfig,
mockSocialConnectorId,
} from '#src/__mocks__/connectors-mock.js';
import { listConnectors, deleteConnectorById, postConnector } from '#src/api/index.js';
import { deleteConnectorById, listConnectors, postConnector } from '#src/api/index.js';
import { deleteSsoConnectorById, getSsoConnectors } from '#src/api/sso-connector.js';

export const clearConnectorsByTypes = async (types: ConnectorType[]) => {
Expand Down Expand Up @@ -41,3 +41,8 @@ export const setSocialConnector = async () =>
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
});

export const resetPasswordlessConnectors = async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await Promise.all([setEmailConnector(), setSmsConnector()]);
};
32 changes: 31 additions & 1 deletion packages/integration-tests/src/helpers/hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Hook, type HookConfig, type HookEvent } from '@logto/schemas';
import { type CreateHook, type Hook, type HookConfig, type HookEvent } from '@logto/schemas';

import { authedAdminApi } from '#src/api/api.js';

type HookCreationPayload = Pick<Hook, 'name' | 'events'> & {
config: HookConfig;
Expand All @@ -15,3 +17,31 @@ export const getHookCreationPayload = (
headers: { foo: 'bar' },
},
});

export class WebHookApiTest {
readonly #hooks = new Map<string, Hook>();

get hooks(): Map<string, Hook> {
return this.#hooks;
}

async create(json: Omit<CreateHook, 'id'>): Promise<Hook> {
const hook = await authedAdminApi.post('hooks', { json }).json<Hook>();
this.#hooks.set(hook.name, hook);

return hook;
}

async delete(name: string): Promise<void> {
const hook = this.#hooks.get(name);

if (hook) {
await authedAdminApi.delete(`hooks/${hook.id}`);
this.#hooks.delete(name);
}
}

async cleanUp(): Promise<void> {
await Promise.all(Array.from(this.#hooks.keys()).map(async (name) => this.delete(name)));
}
}
43 changes: 40 additions & 3 deletions packages/integration-tests/src/helpers/interactions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type {
UsernamePasswordPayload,
EmailPasswordPayload,
PhonePasswordPayload,
UsernamePasswordPayload,
} from '@logto/schemas';
import { InteractionEvent } from '@logto/schemas';

import {
putInteraction,
createSocialAuthorizationUri,
patchInteractionIdentifiers,
putInteraction,
putInteractionProfile,
sendVerificationCode,
} from '#src/api/index.js';
import { generateUserId } from '#src/utils.js';

import { initClient, processSession, logoutClient } from './client.js';
import { initClient, logoutClient, processSession } from './client.js';
import { expectRejects, readConnectorMessage } from './index.js';
import { enableAllPasswordSignInMethods } from './sign-in-experience.js';
import { generateNewUser } from './user.js';
Expand Down Expand Up @@ -90,6 +90,43 @@ export const createNewSocialUserWithUsernameAndPassword = async (connectorId: st
return processSession(client, redirectTo);
};

export const signInWithUsernamePasswordAndUpdateEmailOrPhone = async (
username: string,
password: string,
profile: { email: string } | { phone: string }
) => {
const client = await initClient();

await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: {
username,
password,
},
});

await expectRejects(client.submitInteraction(), {
code: 'user.missing_profile',
status: 422,
});

await client.successSend(sendVerificationCode, profile);

const { code } = await readConnectorMessage('email' in profile ? 'Email' : 'Sms');

await client.successSend(patchInteractionIdentifiers, {
...profile,
verificationCode: code,
});

await client.successSend(putInteractionProfile, profile);

const { redirectTo } = await client.submitInteraction();

await processSession(client, redirectTo);
await logoutClient(client);
};

export const resetPassword = async (
profile: { email: string } | { phone: string },
newPassword: string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createHmac } from 'node:crypto';
import { createServer, type RequestListener, type Server } from 'node:http';

import { hookEventGuard } from '@logto/schemas';
import { z } from 'zod';

/**
* A mock server that listens for incoming requests and responds with the request body.
*
Expand Down Expand Up @@ -28,11 +32,14 @@ class WebhookMockServer {
request.on('end', () => {
response.writeHead(200, { 'Content-Type': 'application/json' });

const payload: unknown = JSON.parse(Buffer.concat(data).toString());
// Keep the raw payload for signature verification
const rawPayload = Buffer.concat(data).toString();
const payload: unknown = JSON.parse(rawPayload);

const body = JSON.stringify({
signature: request.headers['logto-signature-sha-256'],
payload,
rawPayload,
});

requestCallback?.(body);
Expand Down Expand Up @@ -61,4 +68,24 @@ class WebhookMockServer {
}
}

export const mockHookResponseGuard = z.object({
body: z.object({
signature: z.string(),
payload: z
.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
})
.catchall(z.any()),
// Use the raw payload for signature verification
rawPayload: z.string(),
}),
});

export default WebhookMockServer;

export const verifySignature = (payload: string, secret: string, signature: string) => {
const calculatedSignature = createHmac('sha256', secret).update(payload).digest('hex');
return calculatedSignature === signature;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import {
hookEvents,
jsonGuard,
managementApiHooksRegistration,
type Hook,
type Role,
} from '@logto/schemas';
import { assert } from '@silverhand/essentials';
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 { WebHookApiTest } from '#src/helpers/hook.js';
import {
OrganizationApiTest,
OrganizationRoleApiTest,
Expand All @@ -20,7 +21,7 @@ import {
import { UserApiTest, generateNewUser } from '#src/helpers/user.js';
import { generateName, waitFor } from '#src/utils.js';

import WebhookMockServer from './WebhookMockServer.js';
import WebhookMockServer, { verifySignature } from './WebhookMockServer.js';
import {
organizationDataHookTestCases,
organizationRoleDataHookTestCases,
Expand All @@ -32,24 +33,28 @@ import {

const mockHookResponseGuard = z.object({
signature: z.string(),
payload: z.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
data: jsonGuard.optional(),
method: z
.string()
.optional()
.transform((value) => value?.toUpperCase()),
matchedRoute: z.string().optional(),
}),
payload: z
.object({
event: hookEventGuard,
createdAt: z.string(),
hookId: z.string(),
data: jsonGuard.optional(),
method: z
.string()
.optional()
.transform((value) => value?.toUpperCase()),
matchedRoute: z.string().optional(),
})
.catchall(z.any()),
// Keep the raw payload for signature verification
rawPayload: z.string(),
});

type MockHookResponse = z.infer<typeof mockHookResponseGuard>;

const hookName = 'management-api-hook';
const webhooks = new Map<string, Hook>();
const webhookResults = new Map<string, MockHookResponse>();
const webHookApi = new WebHookApiTest();

// Record the hook response to the webhookResults map.
// Compare the webhookResults map with the managementApiHooksRegistration to verify all hook is triggered.
Expand Down Expand Up @@ -80,27 +85,17 @@ 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);
await webHookApi.create({
name: hookName,
events: [...hookEvents],
config: {
url: webhookServer.endpoint,
},
});
});

afterAll(async () => {
await Promise.all(
Array.from(webhooks.values()).map(async (hook) => authedAdminApi.delete(`hooks/${hook.id}`))
);

await webHookApi.cleanUp();
await webhookServer.close();
});

Expand Down Expand Up @@ -332,11 +327,17 @@ describe('organization role data hook events', () => {
);
});

describe('data hook events coverage', () => {
describe('data hook events coverage and signature verification', () => {
const keys = Object.keys(managementApiHooksRegistration);

it.each(keys)('should have test case for %s', async (key) => {
const webhook = webHookApi.hooks.get(hookName)!;

const webhookResult = await getWebhookResult(key);
expect(webhookResult).toBeDefined();
expect(webhookResult?.signature).toBeDefined();
assert(webhookResult, new Error('webhookResult is undefined'));

const { signature, rawPayload } = webhookResult;
expect(verifySignature(rawPayload, webhook.signingKey, signature)).toBeTruthy();
});
});
Loading

0 comments on commit 0c759a4

Please sign in to comment.