0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

fix(console,core,toolkit): passwordless connector sender tester (#3492)

This commit is contained in:
Darcy Ye 2023-03-19 22:35:38 +08:00 committed by GitHub
parent 839ee874da
commit 0bfd8509fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 79 additions and 72 deletions

View file

@ -7,7 +7,7 @@ export type ConnectorFactory<T extends AllConnector = AllConnector> = Pick<
T,
'type' | 'metadata'
> & {
createConnector: CreateConnector<AllConnector>;
createConnector: CreateConnector<T>;
path: string;
};

View file

@ -2,7 +2,12 @@ import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import path from 'path';
import type { AllConnector, BaseConnector, GetConnectorConfig } from '@logto/connector-kit';
import type {
AllConnector,
BaseConnector,
ConnectorMetadata,
GetConnectorConfig,
} from '@logto/connector-kit';
import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit';
import { notImplemented } from './consts.js';
@ -64,10 +69,10 @@ export const parseMetadata = async (
};
};
export const buildRawConnector = async (
connectorFactory: ConnectorFactory,
export const buildRawConnector = async <T extends AllConnector = AllConnector>(
connectorFactory: ConnectorFactory<T>,
getConnectorConfig?: GetConnectorConfig
) => {
): Promise<{ rawConnector: T; rawMetadata: ConnectorMetadata }> => {
const { createConnector, path: packagePath } = connectorFactory;
const rawConnector = await createConnector({
getConfig: getConnectorConfig ?? notImplemented,

View file

@ -14,8 +14,8 @@ import useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import BasicForm from '@/pages/Connectors/components/ConnectorForm/BasicForm';
import ConfigForm from '@/pages/Connectors/components/ConnectorForm/ConfigForm';
import { useConfigParser } from '@/pages/Connectors/components/ConnectorForm/hooks';
import { initFormData, parseFormConfig } from '@/pages/Connectors/components/ConnectorForm/utils';
import { useConnectorFormConfigParser } from '@/pages/Connectors/components/ConnectorForm/hooks';
import { initFormData } from '@/pages/Connectors/components/ConnectorForm/utils';
import type { ConnectorFormType } from '@/pages/Connectors/types';
import { SyncProfileMode } from '@/pages/Connectors/types';
@ -38,7 +38,6 @@ const getConnectorTarget = (connectorData: ConnectorResponse): Optional<string>
const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { getDocumentationUrl } = useDocumentationUrl();
const parseJsonConfig = useConfigParser();
const api = useApi();
const methods = useForm<ConnectorFormType>({
reValidateMode: 'onBlur',
@ -70,9 +69,11 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
});
}, [connectorData, reset]);
const configParser = useConnectorFormConfigParser();
const onSubmit = handleSubmit(async (data) => {
const { formItems, isStandard, id } = connectorData;
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const payload = isSocialConnector
@ -136,9 +137,9 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
{connectorData.type !== ConnectorType.Social && (
<FormCard title="connector_details.test_connection">
<SenderTester
connectorId={connectorData.id}
connectorFactoryId={connectorData.connectorId}
connectorType={connectorData.type}
config={watch('config')}
parse={() => configParser(watch(), connectorData.formItems)}
/>
</FormCard>
)}

View file

@ -3,7 +3,6 @@ import { ConnectorType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
@ -12,22 +11,21 @@ import TextInput from '@/components/TextInput';
import { Tooltip } from '@/components/Tip';
import useApi from '@/hooks/use-api';
import { onKeyDownHandler } from '@/utils/a11y';
import { safeParseJson } from '@/utils/json';
import * as styles from './index.module.scss';
type Props = {
connectorId: string;
connectorFactoryId: string;
connectorType: Exclude<ConnectorType, ConnectorType.Social>;
config: string;
className?: string;
parse: () => unknown;
};
type FormData = {
sendTo: string;
};
const SenderTester = ({ connectorId, connectorType, config, className }: Props) => {
const SenderTester = ({ connectorFactoryId, connectorType, className, parse }: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const {
handleSubmit,
@ -58,23 +56,15 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props)
const onSubmit = handleSubmit(async (formData) => {
const { sendTo } = formData;
const result = safeParseJson(config);
if (!result.success) {
toast.error(result.error);
return;
}
const data = {
config: result.data,
config: parse(),
...(isSms
? { phone: sendTo.replace(/[ ()-]/g, '').replace(/\+/g, '00') }
: { email: sendTo }),
};
try {
await api.post(`api/connectors/${connectorId}/test`, { json: data }).json();
await api.post(`api/connectors/${connectorFactoryId}/test`, { json: data }).json();
setShowTooltip(true);
} catch (error: unknown) {
console.error(error);

View file

@ -1,9 +1,12 @@
import type { ConnectorResponse } from '@logto/schemas';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { parseFormConfig } from '@/pages/Connectors/components/ConnectorForm/utils';
import type { ConnectorFormType } from '@/pages/Connectors/types';
import { safeParseJson } from '@/utils/json';
export const useConfigParser = () => {
export const useJsonStringConfigParser = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (config: string) => {
@ -24,3 +27,11 @@ export const useConfigParser = () => {
return result.data;
};
};
export const useConnectorFormConfigParser = () => {
const parseJsonConfig = useJsonStringConfigParser();
return (data: ConnectorFormType, formItems: ConnectorResponse['formItems']) => {
return formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
};
};

View file

@ -29,8 +29,8 @@ import { SyncProfileMode } from '../../types';
import { splitMarkdownByTitle } from '../../utils';
import BasicForm from '../ConnectorForm/BasicForm';
import ConfigForm from '../ConnectorForm/ConfigForm';
import { useConfigParser } from '../ConnectorForm/hooks';
import { initFormData, parseFormConfig } from '../ConnectorForm/utils';
import { useConnectorFormConfigParser } from '../ConnectorForm/hooks';
import { initFormData } from '../ConnectorForm/utils';
import * as styles from './index.module.scss';
const targetErrorCode = 'connector.multiple_target_with_same_platform';
@ -45,7 +45,6 @@ const Guide = ({ connector, onClose }: Props) => {
const navigate = useNavigate();
const callbackConnectorId = useRef(generateStandardId());
const { updateConfigs } = useConfigs();
const parseJsonConfig = useConfigParser();
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { type: connectorType, formItems, target, isStandard, configTemplate } = connector ?? {};
@ -74,6 +73,8 @@ const Guide = ({ connector, onClose }: Props) => {
});
}, [formItems, reset, configTemplate, target, isSocialConnector, isStandard]);
const configParser = useConnectorFormConfigParser();
if (!connector) {
return null;
}
@ -90,7 +91,8 @@ const Guide = ({ connector, onClose }: Props) => {
// Recover error state
setConflictConnectorName(undefined);
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
const config = configParser(data, formItems);
const { syncProfile, name, logo, logoDark, target } = data;
const basePayload = {
@ -213,9 +215,9 @@ const Guide = ({ connector, onClose }: Props) => {
<div>{t('connectors.guide.test_connection')}</div>
</div>
<SenderTester
connectorId={connectorId}
connectorFactoryId={connectorId}
connectorType={connectorType}
config={watch('config')}
parse={() => configParser(watch(), formItems)}
/>
</div>
)}

View file

@ -1,11 +1,6 @@
/* eslint-disable max-lines */
import { defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
import {
ConnectorError,
ConnectorErrorCodes,
ConnectorPlatform,
VerificationCodeType,
} from '@logto/connector-kit';
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import { ConnectorPlatform, VerificationCodeType } from '@logto/connector-kit';
import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
import type { Connector } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
@ -224,10 +219,6 @@ describe('connector route', () => {
...mockLogtoConnector,
},
]);
validateConfig.mockImplementationOnce((config: unknown) => {
throw new ConnectorError(ConnectorErrorCodes.General);
});
buildRawConnector.mockResolvedValueOnce({ rawConnector: { configGuard: any() } });
const response = await connectorRequest.post('/connectors').send({
connectorId: 'connectorId',
config: { cliend_id: 'client_id', client_secret: 'client_secret' },
@ -384,8 +375,6 @@ describe('connector route', () => {
...mockLogtoConnector,
},
]);
validateConfig.mockReturnValueOnce(null);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { configGuard: any() } });
const response = await connectorRequest.post('/connectors').send({
connectorId: 'id0',
metadata: { target: 'target' },
@ -432,19 +421,15 @@ describe('connector route', () => {
});
it('should get SMS connector and send test message', async () => {
const mockedMetadata = {
...mockMetadata,
};
const sendMessage = jest.fn();
const mockedSmsConnector: LogtoConnector<SmsConnector> = {
dbEntry: mockConnector,
metadata: mockedMetadata,
const mockedSmsConnectorFactory: ConnectorFactory<SmsConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Sms,
configGuard: any(),
...defaultConnectorMethods,
sendMessage,
createConnector: jest.fn(),
};
getLogtoConnectors.mockResolvedValueOnce([mockedSmsConnector]);
loadConnectorFactories.mockResolvedValueOnce([mockedSmsConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ phone: '12345678901', config: { test: 123 } });
@ -464,15 +449,14 @@ describe('connector route', () => {
it('should get email connector and send test message', async () => {
const sendMessage = jest.fn();
const mockedEmailConnector: LogtoConnector<EmailConnector> = {
dbEntry: mockConnector,
const mockedEmailConnectorFactory: ConnectorFactory<EmailConnector> = {
...mockConnectorFactory,
metadata: mockMetadata,
type: ConnectorType.Email,
configGuard: any(),
...defaultConnectorMethods,
sendMessage,
createConnector: jest.fn(),
};
getLogtoConnectors.mockResolvedValueOnce([mockedEmailConnector]);
loadConnectorFactories.mockResolvedValueOnce([mockedEmailConnectorFactory]);
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
const response = await connectorRequest
.post('/connectors/id/test')
.send({ email: 'test@email.com', config: { test: 123 } });

View file

@ -1,4 +1,7 @@
/* eslint-disable max-lines */
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import type { SmsConnector, EmailConnector } from '@logto/connector-kit';
import { VerificationCodeType, validateConfig } from '@logto/connector-kit';
import { emailRegEx, phoneRegEx, buildIdGenerator } from '@logto/core-kit';
import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas';
@ -281,9 +284,9 @@ export default function connectorRoutes<T extends AuthedRouter>(
);
router.post(
'/connectors/:id/test',
'/connectors/:factoryId/test',
koaGuard({
params: object({ id: string().min(1) }),
params: object({ factoryId: string().min(1) }),
body: object({
phone: string().regex(phoneRegEx).optional(),
email: string().regex(emailRegEx).optional(),
@ -292,7 +295,7 @@ export default function connectorRoutes<T extends AuthedRouter>(
}),
async (ctx, next) => {
const {
params: { id },
params: { factoryId },
body,
} = ctx.guard;
const { phone, email, config } = body;
@ -300,19 +303,29 @@ export default function connectorRoutes<T extends AuthedRouter>(
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connector = await getLogtoConnectorById(id);
const connectorFactories = await loadConnectorFactories();
const connectorFactory = connectorFactories
.filter(
(factory): factory is ConnectorFactory<SmsConnector> | ConnectorFactory<EmailConnector> =>
factory.type === ConnectorType.Email || factory.type === ConnectorType.Sms
)
.find(({ metadata: { id } }) => id === factoryId && !demoConnectorIds.includes(id));
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
assertThat(
connector,
connectorFactory,
new RequestError({
code: 'connector.not_found',
type: expectType,
factoryId,
})
);
assertThat(connector.type === expectType, 'connector.unexpected_type');
const { sendMessage } = connector;
assertThat(connectorFactory.type === expectType, 'connector.unexpected_type');
const {
rawConnector: { sendMessage },
} = await buildRawConnector<SmsConnector | EmailConnector>(connectorFactory);
await sendMessage(
{
@ -358,3 +371,4 @@ export default function connectorRoutes<T extends AuthedRouter>(
}
);
}
/* eslint-enable max-lines */

View file

@ -142,10 +142,10 @@ test('send SMS/email test message', async () => {
const email = 'test@example.com';
await expect(
sendSmsTestMessage(connectorIdMap.get(mockSmsConnectorId), phone, mockSmsConnectorConfig)
sendSmsTestMessage(mockSmsConnectorId, phone, mockSmsConnectorConfig)
).resolves.not.toThrow();
await expect(
sendEmailTestMessage(connectorIdMap.get(mockEmailConnectorId), email, mockEmailConnectorConfig)
sendEmailTestMessage(mockEmailConnectorId, email, mockEmailConnectorConfig)
).resolves.not.toThrow();
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);

View file

@ -190,7 +190,7 @@ export type BaseConnector<Type extends ConnectorType> = {
configGuard: ZodType;
};
export type CreateConnector<T extends BaseConnector<ConnectorType>> = (options: {
export type CreateConnector<T extends AllConnector> = (options: {
getConfig: GetConnectorConfig;
}) => Promise<T>;