0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat: remove enabled from table connectors (#2513)

This commit is contained in:
Darcy Ye 2022-11-28 13:51:46 +08:00
parent 23d2a3fe80
commit 7ba40a7782
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
28 changed files with 199 additions and 512 deletions

View file

@ -77,7 +77,6 @@ export const mockMetadata6: ConnectorMetadata = {
export const mockConnector0: Connector = {
id: 'id0',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -87,7 +86,6 @@ export const mockConnector0: Connector = {
export const mockConnector1: Connector = {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_234,
syncProfile: false,
@ -97,7 +95,6 @@ export const mockConnector1: Connector = {
export const mockConnector2: Connector = {
id: 'id2',
enabled: true,
config: {},
createdAt: 1_234_567_890_345,
syncProfile: false,
@ -107,7 +104,6 @@ export const mockConnector2: Connector = {
export const mockConnector3: Connector = {
id: 'id3',
enabled: true,
config: {},
createdAt: 1_234_567_890_456,
syncProfile: false,
@ -117,7 +113,6 @@ export const mockConnector3: Connector = {
export const mockConnector4: Connector = {
id: 'id4',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,
@ -127,7 +122,6 @@ export const mockConnector4: Connector = {
export const mockConnector5: Connector = {
id: 'id5',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,
@ -137,7 +131,6 @@ export const mockConnector5: Connector = {
export const mockConnector6: Connector = {
id: 'id6',
enabled: true,
config: {},
createdAt: 1_234_567_890_567,
syncProfile: false,

View file

@ -33,7 +33,6 @@ export {
export const mockConnector: Connector = {
id: 'id',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -205,7 +204,6 @@ export const mockGoogleConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'google',
enabled: false,
},
metadata: {
...mockMetadata,
@ -227,15 +225,13 @@ export const mockLogtoConnectors = [
mockWechatNativeConnector,
];
export const disabledSocialTarget01 = 'disableSocialTarget-id01';
export const disabledSocialTarget02 = 'disableSocialTarget-id02';
export const enabledSocialTarget01 = 'enabledSocialTarget-id01';
export const socialTarget01 = 'socialTarget-id01';
export const socialTarget02 = 'socialTarget-id02';
export const mockSocialConnectors: LogtoConnector[] = [
{
dbEntry: {
id: 'id0',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -244,7 +240,7 @@ export const mockSocialConnectors: LogtoConnector[] = [
},
metadata: {
...mockMetadata,
target: disabledSocialTarget01,
target: socialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
@ -252,7 +248,6 @@ export const mockSocialConnectors: LogtoConnector[] = [
{
dbEntry: {
id: 'id1',
enabled: true,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
@ -261,24 +256,7 @@ export const mockSocialConnectors: LogtoConnector[] = [
},
metadata: {
...mockMetadata,
target: enabledSocialTarget01,
},
type: ConnectorType.Social,
...mockLogtoConnector,
},
{
dbEntry: {
id: 'id2',
enabled: false,
config: {},
createdAt: 1_234_567_890_123,
syncProfile: false,
metadata: {},
connectorId: 'id2',
},
metadata: {
...mockMetadata,
target: disabledSocialTarget02,
target: socialTarget02,
},
type: ConnectorType.Social,
...mockLogtoConnector,

View file

@ -10,7 +10,7 @@ import { findPackage } from '@logto/shared';
import chalk from 'chalk';
import RequestError from '#src/errors/RequestError/index.js';
import { findAllConnectors, insertConnector } from '#src/queries/connector.js';
import { findAllConnectors } from '#src/queries/connector.js';
import { defaultConnectorMethods } from './consts.js';
import { metaUrl } from './meta-url.js';
@ -156,29 +156,3 @@ export const getLogtoConnectorById = async (id: string): Promise<LogtoConnector>
return pickedConnector;
};
export const initConnectors = async () => {
const connectors = await findAllConnectors();
const existingConnectors = new Map(
connectors.map((connector) => [connector.connectorId, connector])
);
const allConnectors = await loadConnectorFactories();
const newConnectors = allConnectors.filter(({ metadata: { id } }) => {
const connector = existingConnectors.get(id);
if (!connector) {
return true;
}
return connector.config === JSON.stringify({});
});
await Promise.all(
newConnectors.map(async ({ metadata: { id } }) => {
await insertConnector({
id,
connectorId: id,
});
})
);
};

View file

@ -7,7 +7,6 @@ import { getConnectorConfig } from './index.js';
const connectors: Connector[] = [
{
id: 'id',
enabled: true,
config: { foo: 'bar' },
createdAt: 0,
syncProfile: false,

View file

@ -1,7 +1,6 @@
import Koa from 'koa';
import initApp from './app/init.js';
import { initConnectors } from './connectors/index.js';
import { configDotEnv } from './env-set/dot-env.js';
import envSet from './env-set/index.js';
import initI18n from './i18n/init.js';
@ -15,7 +14,6 @@ import initI18n from './i18n/init.js';
const app = new Koa({
proxy: envSet.values.trustProxyHeader,
});
await initConnectors();
await initI18n();
await initApp(app);
} catch (error: unknown) {

View file

@ -53,7 +53,7 @@ export const sendPasscode = async (passcode: Passcode) => {
const connector = connectors.find(
(connector): connector is LogtoConnector<SmsConnector | EmailConnector> =>
connector.dbEntry.enabled && connector.type === expectType
connector.type === expectType
);
assertThat(

View file

@ -4,9 +4,8 @@ import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import { BrandingStyle } from '@logto/schemas';
import {
disabledSocialTarget01,
disabledSocialTarget02,
enabledSocialTarget01,
socialTarget01,
socialTarget02,
mockBranding,
mockSignInExperience,
mockSocialConnectors,
@ -165,14 +164,10 @@ describe('remove unavailable social connector targets', () => {
socialSignInConnectorTargets: mockSocialConnectorTargets,
});
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockSocialConnectors);
expect(mockSocialConnectorTargets).toEqual([
disabledSocialTarget01,
enabledSocialTarget01,
disabledSocialTarget02,
]);
expect(mockSocialConnectorTargets).toEqual([socialTarget01, socialTarget02]);
await removeUnavailableSocialConnectorTargets();
expect(updateDefaultSignInExperience).toBeCalledWith({
socialSignInConnectorTargets: [enabledSocialTarget01],
socialSignInConnectorTargets: [socialTarget01, socialTarget02],
});
});
});

View file

@ -52,7 +52,7 @@ export const removeUnavailableSocialConnectorTargets = async () => {
const connectors = await getLogtoConnectors();
const availableSocialConnectorTargets = new Set(
connectors
.filter(({ type, dbEntry: { enabled } }) => enabled && type === ConnectorType.Social)
.filter(({ type }) => type === ConnectorType.Social)
.map(({ metadata: { target } }) => target)
);

View file

@ -65,8 +65,8 @@ describe('validate sign-in', () => {
});
});
describe('There must be at least one enabled connector for the specific identifier.', () => {
it('throws when there is no enabled email connector and identifiers includes email with verification code checked', () => {
describe('There must be at least one connector for the specific identifier.', () => {
it('throws when there is no email connector and identifiers includes email with verification code checked', () => {
expect(() => {
validateSignIn(
{
@ -89,7 +89,7 @@ describe('validate sign-in', () => {
);
});
it('throws when there is no enabled sms connector and identifiers includes phone with verification code checked', () => {
it('throws when there is no sms connector and identifiers includes phone with verification code checked', () => {
expect(() => {
validateSignIn(
{

View file

@ -13,8 +13,8 @@ jest.mock('#src/lib/session.js', () => ({
}));
describe('validate sign-up', () => {
describe('There must be at least one enabled connector for the specific identifier.', () => {
test('should throw when there is no enabled email connector and identifier is email', async () => {
describe('There must be at least one connector for the specific identifier.', () => {
test('should throw when there is no email connector and identifier is email', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Email }, []);
}).toMatchError(
@ -25,7 +25,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled email connector and identifier is email or phone', async () => {
test('should throw when there is no email connector and identifier is email or phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, []);
}).toMatchError(
@ -36,7 +36,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled sms connector and identifier is phone', async () => {
test('should throw when there is no sms connector and identifier is phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.Sms }, []);
}).toMatchError(
@ -47,7 +47,7 @@ describe('validate sign-up', () => {
);
});
test('should throw when there is no enabled email connector and identifier is email or phone', async () => {
test('should throw when there is no email connector and identifier is email or phone', async () => {
expect(() => {
validateSignUp({ ...mockSignUp, identifier: SignUpIdentifier.EmailOrSms }, [
mockAliyunDmConnector,

View file

@ -36,7 +36,7 @@ describe('connector queries', () => {
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by ${fields.enabled} desc, ${fields.id} asc
order by ${fields.id} asc
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
@ -172,8 +172,8 @@ describe('connector queries', () => {
};
const expectSql = `
insert into "connectors" ("id", "enabled", "sync_profile", "connector_id", "config", "metadata")
values ($1, $2, $3, $4, $5, $6)
insert into "connectors" ("id", "sync_profile", "connector_id", "config", "metadata")
values ($1, $2, $3, $4, $5)
returning *
`;
@ -182,7 +182,6 @@ describe('connector queries', () => {
expect(values).toEqual([
connector.id,
connector.enabled,
connector.syncProfile,
connector.connectorId,
connector.config,
@ -197,27 +196,27 @@ describe('connector queries', () => {
it('updateConnector (with id)', async () => {
const id = 'foo';
const enabled = false;
const syncProfile = false;
const expectSql = sql`
update ${table}
set ${fields.enabled}=$1
set ${fields.syncProfile}=$1
where ${fields.id}=$2
returning *
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([enabled, id]);
expect(values).toEqual([syncProfile, id]);
return createMockQueryResult([{ id, enabled }]);
return createMockQueryResult([{ id, syncProfile }]);
});
await expect(
updateConnector({ where: { id }, set: { enabled }, jsonbMode: 'merge' })
updateConnector({ where: { id }, set: { syncProfile }, jsonbMode: 'merge' })
).resolves.toEqual({
id,
enabled,
syncProfile,
});
});
});

View file

@ -15,7 +15,7 @@ export const findAllConnectors = async () =>
envSet.pool.query<Connector>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
order by ${fields.enabled} desc, ${fields.id} asc
order by ${fields.id} asc
`)
);

View file

@ -3,17 +3,6 @@ import { MessageTypes } from '@logto/connector-kit';
import { ConnectorType } from '@logto/schemas';
import { any } from 'zod';
import {
mockMetadata,
mockMetadata0,
mockMetadata1,
mockMetadata2,
mockMetadata3,
mockConnector,
mockConnectorFactory,
mockLogtoConnectorList,
mockLogtoConnector,
} from '#src/__mocks__/index.js';
import { defaultConnectorMethods } from '#src/connectors/consts.js';
import type { ConnectorFactory, LogtoConnector } from '#src/connectors/types.js';
import RequestError from '#src/errors/RequestError/index.js';
@ -29,6 +18,18 @@ import { createRequester } from '#src/utils/test-utils.js';
import connectorRoutes from './connector.js';
import {
mockMetadata,
mockMetadata0,
mockMetadata1,
mockMetadata2,
mockMetadata3,
mockConnector,
mockConnectorFactory,
mockLogtoConnectorList,
mockLogtoConnector,
} from '#src/__mocks__/index.js';
const loadConnectorFactoriesPlaceHolder = jest.fn() as jest.MockedFunction<
() => Promise<ConnectorFactory[]>
>;
@ -75,13 +76,13 @@ describe('connector route', () => {
jest.clearAllMocks();
});
it('throws if more than one email connector is enabled', async () => {
it('throws if more than one email connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(mockLogtoConnectorList);
const response = await connectorRequest.get('/connectors').send({});
expect(response).toHaveProperty('statusCode', 400);
});
it('throws if more than one SMS connector is enabled', async () => {
it('throws if more than one SMS connector exists', async () => {
getLogtoConnectorsPlaceHolder.mockResolvedValueOnce(
mockLogtoConnectorList.filter((connector) => connector.type !== ConnectorType.Email)
);

View file

@ -51,15 +51,11 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
const connectors = await getLogtoConnectors();
assertThat(
connectors.filter(
(connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Email
).length <= 1,
connectors.filter((connector) => connector.type === ConnectorType.Email).length <= 1,
'connector.more_than_one_email'
);
assertThat(
connectors.filter(
(connector) => connector.dbEntry.enabled && connector.type === ConnectorType.Sms
).length <= 1,
connectors.filter((connector) => connector.type === ConnectorType.Sms).length <= 1,
'connector.more_than_one_sms'
);
@ -152,68 +148,16 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
type === connectorFactory.type && id !== insertConnectorId
)
.map(({ dbEntry: { id } }) => id);
await deleteConnectorByIds(conflictingConnectorIds);
if (conflictingConnectorIds.length > 0) {
await deleteConnectorByIds(conflictingConnectorIds);
}
}
return next();
}
);
router.patch(
'/connectors/:id/enabled',
koaGuard({
params: object({ id: string().min(1) }),
body: Connectors.createGuard.pick({ enabled: true }),
}),
async (ctx, next) => {
const {
params: { id },
body: { enabled },
} = ctx.guard;
const {
type,
dbEntry: { config },
metadata,
validateConfig,
} = await getLogtoConnectorById(id);
if (enabled) {
validateConfig(config);
}
// Only allow one enabled connector for SMS and Email.
// disable other connectors before enable this one.
if (enabled && (type === ConnectorType.Sms || type === ConnectorType.Email)) {
const connectors = await getLogtoConnectors();
await Promise.all(
connectors
.filter(
({ dbEntry: { enabled }, type: currentType }) => type === currentType && enabled
)
.map(async ({ dbEntry: { id } }) =>
updateConnector({ set: { enabled: false }, where: { id }, jsonbMode: 'merge' })
)
);
}
const connector = await updateConnector({
set: { enabled },
where: { id },
jsonbMode: 'merge',
});
// Delete the social connector in the sign-in experience if it is disabled.
if (!enabled && type === ConnectorType.Social) {
await removeUnavailableSocialConnectorTargets();
}
ctx.body = { ...connector, metadata, type };
return next();
}
);
router.patch(
'/connectors/:id',
koaGuard({
@ -269,11 +213,10 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
} = ctx.guard;
const { phone, email, config } = body;
const logtoConnectors = await getLogtoConnectors();
const subject = phone ?? email;
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
const connector = logtoConnectors.find(({ metadata: { id: currentId } }) => currentId === id);
const connector = await getLogtoConnectorById(id);
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
assertThat(

View file

@ -55,189 +55,6 @@ jest.mock('#src/lib/sign-in-experience.js', () => ({
describe('connector PATCH routes', () => {
const connectorRequest = createRequester({ authedRoutes: connectorRoutes });
describe('PATCH /connectors/:id/enabled', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('throws if connector can not be found (locally)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList.slice(1));
const response = await connectorRequest
.patch('/connectors/findConnector/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('throws if connector can not be found (remotely)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([]);
const response = await connectorRequest
.patch('/connectors/id0/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 404);
});
it('enables one of the social connectors (with valid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: true },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
type: ConnectorType.Social,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the social connectors (with invalid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the social connectors', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Social,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
it('enables one of the email/sms connectors (with valid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce(mockLogtoConnectorList);
const mockedMetadata = {
...mockMetadata,
id: 'id1',
};
const mockedConnector = {
...mockConnector,
id: 'id1',
};
getLogtoConnectorByIdPlaceholder.mockResolvedValueOnce({
dbEntry: mockedConnector,
metadata: mockedMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
});
const response = await connectorRequest
.patch('/connectors/id1/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 200);
expect(updateConnector).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
where: { id: 'id5' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(updateConnector).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
where: { id: 'id1' },
set: { enabled: true },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockedMetadata,
});
});
it('enables one of the email/sms connectors (with invalid config)', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
validateConfig: () => {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
},
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: true });
expect(response).toHaveProperty('statusCode', 500);
});
it('disables one of the email/sms connectors', async () => {
getLogtoConnectorsPlaceholder.mockResolvedValueOnce([
{
dbEntry: mockConnector,
metadata: mockMetadata,
type: ConnectorType.Sms,
...mockLogtoConnector,
},
]);
const response = await connectorRequest
.patch('/connectors/id/enabled')
.send({ enabled: false });
expect(updateConnector).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'id' },
set: { enabled: false },
jsonbMode: 'merge',
})
);
expect(response.body).toMatchObject({
metadata: mockMetadata,
});
expect(response).toHaveProperty('statusCode', 200);
});
});
describe('PATCH /connectors/:id', () => {
afterEach(() => {
jest.clearAllMocks();

View file

@ -170,15 +170,6 @@ describe('session -> socialRoutes', () => {
expect(response.statusCode).toEqual(400);
});
it('throw error when connector is disabled', async () => {
const response = await sessionRequest.post(`${signInRoute}`).send({
connectorId: 'social_disabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(`${signInRoute}`).send({
connectorId: 'others',

View file

@ -46,7 +46,6 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
const { connectorId, state, redirectUri } = ctx.guard.body;
assertThat(state && redirectUri, 'session.insufficient_info');
const connector = await getLogtoConnectorById(connectorId);
assertThat(connector.dbEntry.enabled, 'connector.not_enabled');
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
const redirectTo = await connector.getAuthorizationUri({ state, redirectUri });
ctx.body = { redirectTo };

View file

@ -94,7 +94,7 @@ describe('PATCH /sign-in-exp', () => {
status: 200,
body: {
...mockSignInExperience,
socialSignInConnectorTargets: ['github', 'facebook'],
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
},
});
});
@ -118,18 +118,12 @@ describe('PATCH /sign-in-exp', () => {
signUp: mockSignUp,
signIn: mockSignIn,
});
const connectors = [
mockFacebookConnector,
mockGithubConnector,
mockWechatConnector,
mockAliyunSmsConnector,
];
expect(validateBranding).toHaveBeenCalledWith(mockBranding);
expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo);
expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse);
expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, connectors);
expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, connectors);
expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, logtoConnectors);
expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, logtoConnectors);
expect(response).toMatchObject({
status: 200,

View file

@ -50,25 +50,24 @@ export default function signInExperiencesRoutes<T extends AuthedRouter>(router:
}
const connectors = await getLogtoConnectors();
const enabledConnectors = connectors.filter(({ dbEntry: { enabled } }) => enabled);
// Remove unavailable connectors
const filteredSocialSignInConnectorTargets = socialSignInConnectorTargets?.filter((target) =>
enabledConnectors.some(
connectors.some(
(connector) =>
connector.metadata.target === target && connector.type === ConnectorType.Social
)
);
if (signUp) {
validateSignUp(signUp, enabledConnectors);
validateSignUp(signUp, connectors);
}
if (signIn && signUp) {
validateSignIn(signIn, signUp, enabledConnectors);
validateSignIn(signIn, signUp, connectors);
} else if (signIn) {
const signInExperience = await findDefaultSignInExperience();
validateSignIn(signIn, signInExperience.signUp, enabledConnectors);
validateSignIn(signIn, signInExperience.signUp, connectors);
}
ctx.body = await updateDefaultSignInExperience(
filteredSocialSignInConnectorTargets

View file

@ -22,12 +22,8 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
]);
const forgotPassword = {
sms: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Sms && enabled
),
email: logtoConnectors.some(
({ type, dbEntry: { enabled } }) => type === ConnectorType.Email && enabled
),
sms: logtoConnectors.some(({ type }) => type === ConnectorType.Sms),
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
};
const socialConnectors =
@ -37,8 +33,7 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(router: T, pr
Array<ConnectorMetadata & { id: string }>
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target }, dbEntry: { enabled } }) =>
target === connectorTarget && enabled
({ metadata: { target } }) => target === connectorTarget
);
return [

View file

@ -1,4 +1,4 @@
import type { ConnectorResponse } from '@logto/schemas';
import type { Connector, ConnectorResponse } from '@logto/schemas';
import { authedAdminApi } from './api';
@ -15,7 +15,10 @@ export const postConnector = async (connectorId: string) =>
url: `connectors`,
json: { connectorId },
})
.json();
.json<Connector>();
export const deleteConnectorById = async (id: string) =>
authedAdminApi.delete({ url: `connectors/${id}` }).json();
export const updateConnectorConfig = async (connectorId: string, config: Record<string, unknown>) =>
authedAdminApi
@ -25,20 +28,6 @@ export const updateConnectorConfig = async (connectorId: string, config: Record<
})
.json<ConnectorResponse>();
export const enableConnector = async (connectorId: string) =>
updateConnectorEnabledProperty(connectorId, true);
export const disableConnector = async (connectorId: string) =>
updateConnectorEnabledProperty(connectorId, false);
const updateConnectorEnabledProperty = (connectorId: string, enabled: boolean) =>
authedAdminApi
.patch({
url: `connectors/${connectorId}/enabled`,
json: { enabled },
})
.json<ConnectorResponse>();
export const sendSmsTestMessage = async (
connectorId: string,
phone: string,

View file

@ -9,19 +9,14 @@ import {
createUser,
registerUserWithUsernameAndPassword,
signInWithPassword,
updateConnectorConfig,
enableConnector,
bindWithSocial,
getAuthWithSocial,
postConnector,
signInWithSocial,
updateSignInExperience,
} from '@/api';
import MockClient from '@/client';
import { generateUsername, generatePassword } from '@/utils';
import { mockSocialConnectorId } from './__mocks__/connectors-mock';
export const createUserByAdmin = (username?: string, password?: string, primaryEmail?: string) => {
return createUser({
username: username ?? generateUsername(),
@ -72,17 +67,6 @@ export const signIn = async ({ username, email, password }: SignInHelper) => {
assert(client.isAuthenticated, new Error('Sign in failed'));
};
export const setUpConnector = async (connectorId: string, config: Record<string, unknown>) => {
try {
await updateConnectorConfig(connectorId, config);
} catch {
await postConnector(connectorId);
await updateConnectorConfig(connectorId, config);
}
const connector = await enableConnector(connectorId);
assert(connector.enabled, new Error('Connector Setup Failed'));
};
export const setSignUpIdentifier = async (
identifier: SignUpIdentifier,
password = true,
@ -115,7 +99,7 @@ export const readPasscode = async (): Promise<PasscodeRecord> => {
return JSON.parse(content) as PasscodeRecord;
};
export const bindSocialToNewCreatedUser = async () => {
export const bindSocialToNewCreatedUser = async (connectorId: string) => {
const username = generateUsername();
const password = generatePassword();
@ -130,13 +114,10 @@ export const bindSocialToNewCreatedUser = async () => {
await client.initSession();
assert(client.interactionCookie, new Error('Session not found'));
await signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
client.interactionCookie
);
await signInWithSocial({ state, connectorId, redirectUri }, client.interactionCookie);
const response = await getAuthWithSocial(
{ connectorId: mockSocialConnectorId, data: { state, redirectUri, code } },
{ connectorId, data: { state, redirectUri, code } },
client.interactionCookie
).catch((error: unknown) => error);
@ -152,7 +133,7 @@ export const bindSocialToNewCreatedUser = async () => {
interactionCookie: client.interactionCookie,
});
await bindWithSocial(mockSocialConnectorId, client.interactionCookie);
await bindWithSocial(connectorId, client.interactionCookie);
await client.processSession(redirectTo);

View file

@ -12,8 +12,10 @@ import {
deleteUser,
updateUserPassword,
deleteUserIdentity,
postConnector,
updateConnectorConfig,
} from '@/api';
import { createUserByAdmin, bindSocialToNewCreatedUser, setUpConnector } from '@/helpers';
import { createUserByAdmin, bindSocialToNewCreatedUser } from '@/helpers';
describe('admin console user management', () => {
it('should create user successfully', async () => {
@ -66,9 +68,10 @@ describe('admin console user management', () => {
});
it('should delete user identities successfully', async () => {
await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig);
const { id } = await postConnector(mockSocialConnectorId);
await updateConnectorConfig(id, mockSocialConnectorConfig);
const createdUserId = await bindSocialToNewCreatedUser();
const createdUserId = await bindSocialToNewCreatedUser(id);
const userInfo = await getUser(createdUserId);
expect(userInfo.identities).toHaveProperty(mockSocialConnectorTarget);

View file

@ -1,4 +1,3 @@
import { ConnectorType } from '@logto/schemas';
import { HTTPError } from 'got';
import {
@ -10,8 +9,7 @@ import {
mockSocialConnectorId,
} from '@/__mocks__/connectors-mock';
import {
disableConnector,
enableConnector,
deleteConnectorById,
getConnector,
listConnectors,
postConnector,
@ -20,6 +18,8 @@ import {
updateConnectorConfig,
} from '@/api/connector';
const connectorIdMap = new Map();
/*
* We'd better only use mock connectors in integration tests.
* Since we will refactor connectors soon, keep using some real connectors
@ -31,18 +31,17 @@ test('connector set-up flow', async () => {
*/
await Promise.all(
[
{ id: mockSmsConnectorId, config: mockSmsConnectorConfig },
{ id: mockEmailConnectorId, config: mockEmailConnectorConfig },
{ id: mockSocialConnectorId, config: mockSocialConnectorConfig },
].map(async ({ id, config }) => {
{ connectorId: mockSmsConnectorId, config: mockSmsConnectorConfig },
{ connectorId: mockEmailConnectorId, config: mockEmailConnectorConfig },
{ connectorId: mockSocialConnectorId, config: mockSocialConnectorConfig },
].map(async ({ connectorId, config }) => {
const { id } = await postConnector(connectorId);
connectorIdMap.set(connectorId, id);
const updatedConnector = await updateConnectorConfig(id, config);
expect(updatedConnector.config).toEqual(config);
const enabledConnector = await enableConnector(id);
expect(enabledConnector.enabled).toBeTruthy();
// The result of getting a connector should be same as the result of updating a connector above.
const connector = await getConnector(id);
expect(connector.enabled).toBeTruthy();
expect(connector.config).toEqual(config);
})
);
@ -52,52 +51,25 @@ test('connector set-up flow', async () => {
* We will test updating to the invalid connector config, that is the case not covered above.
*/
await expect(
updateConnectorConfig(mockSocialConnectorId, mockSmsConnectorConfig)
updateConnectorConfig(connectorIdMap.get(mockSocialConnectorId), mockSmsConnectorConfig)
).rejects.toThrow(HTTPError);
// To confirm the failed updating request above did not modify the original config,
// we check: the mock connector config should stay the same.
const mockSocialConnector = await getConnector(mockSocialConnectorId);
const mockSocialConnector = await getConnector(connectorIdMap.get(mockSocialConnectorId));
expect(mockSocialConnector.config).toEqual(mockSocialConnectorConfig);
/*
* Change to another SMS/Email connector
*/
await Promise.all(
[
{ id: mockSmsConnectorId, config: mockSmsConnectorConfig, type: ConnectorType.Sms },
{ id: mockEmailConnectorId, config: mockEmailConnectorConfig, type: ConnectorType.Email },
].map(async ({ id, config, type }) => {
// FIXME @Darcy: fix use of `id` and `connectorId`
try {
await getConnector(id);
} catch {
await postConnector(id);
}
const updatedConnector = await updateConnectorConfig(id, config);
expect(updatedConnector.config).toEqual(config);
const enabledConnector = await enableConnector(id);
expect(enabledConnector.enabled).toBeTruthy();
// There should be exactly one enabled SMS/email connector after changing to another SMS/email connector.
const connectorsAfterChanging = await listConnectors();
const enabledConnectors = connectorsAfterChanging.filter(
(connector) => connector.type === type && connector.enabled
);
expect(enabledConnectors.length).toEqual(1);
expect(enabledConnectors[0]?.id).toEqual(id);
})
);
// FIXME @Darcy [LOG-4750,4751]: complete this IT after add another mock sms/email connector (or other current existing connector could be affected)
/*
* Delete (i.e. disable) a connector
*
* We have not provided the API to delete a connector for now.
* Deleting a connector using Admin Console means disabling a connector using Management API.
*/
const disabledMockEmailConnector = await disableConnector(mockEmailConnectorId);
expect(disabledMockEmailConnector.enabled).toBeFalsy();
const mockEmailConnector = await getConnector(mockEmailConnectorId);
expect(mockEmailConnector.enabled).toBeFalsy();
await expect(
deleteConnectorById(connectorIdMap.get(mockEmailConnectorId))
).resolves.not.toThrow();
connectorIdMap.delete(mockEmailConnectorId);
/**
* List connectors after manually setting up connectors.
@ -106,39 +78,48 @@ test('connector set-up flow', async () => {
expect(await listConnectors()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: mockEmailConnectorId,
config: mockEmailConnectorConfig,
enabled: false,
}),
expect.objectContaining({
id: mockSmsConnectorId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: connectorIdMap.get(mockSmsConnectorId),
connectorId: mockSmsConnectorId,
config: mockSmsConnectorConfig,
enabled: true,
}),
expect.objectContaining({
id: mockSocialConnectorId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
id: connectorIdMap.get(mockSocialConnectorId),
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
enabled: true,
}),
])
);
});
describe('send SMS/email test message', () => {
test('send SMS/email test message', async () => {
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
await Promise.all(
[{ connectorId: mockSmsConnectorId }, { connectorId: mockEmailConnectorId }].map(
async ({ connectorId }) => {
const { id } = await postConnector(connectorId);
connectorIdMap.set(connectorId, id);
}
)
);
const phone = '8612345678901';
const email = 'test@example.com';
it('should send the test message successfully when the config is valid', async () => {
await expect(
sendSmsTestMessage(mockSmsConnectorId, phone, mockSmsConnectorConfig)
).resolves.not.toThrow();
await expect(
sendEmailTestMessage(mockEmailConnectorId, email, mockEmailConnectorConfig)
).resolves.not.toThrow();
});
it('should fail to send the test message when the config is invalid', async () => {
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);
});
await expect(
sendSmsTestMessage(connectorIdMap.get(mockSmsConnectorId), phone, mockSmsConnectorConfig)
).resolves.not.toThrow();
await expect(
sendEmailTestMessage(connectorIdMap.get(mockEmailConnectorId), email, mockEmailConnectorConfig)
).resolves.not.toThrow();
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);
});

View file

@ -17,15 +17,17 @@ import {
verifyRegisterUserWithSmsPasscode,
sendSignInUserWithSmsPasscode,
verifySignInUserWithSmsPasscode,
disableConnector,
signInWithPassword,
createUser,
listConnectors,
deleteConnectorById,
postConnector,
updateConnectorConfig,
} from '@/api';
import MockClient from '@/client';
import {
registerNewUser,
signIn,
setUpConnector,
readPasscode,
createUserByAdmin,
setSignUpIdentifier,
@ -33,6 +35,8 @@ import {
} from '@/helpers';
import { generateUsername, generatePassword, generateEmail, generatePhone } from '@/utils';
const connectorIdMap = new Map();
describe('username and password flow', () => {
const username = generateUsername();
const password = generatePassword();
@ -63,7 +67,10 @@ describe('email and password flow', () => {
assert(localPart && domain, new Error('Email address local part or domain is empty'));
beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig);
const { id } = await postConnector(mockEmailConnectorId);
await updateConnectorConfig(id, mockEmailConnectorConfig);
connectorIdMap.set(mockEmailConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Email, true);
await setSignInMethod([
{
@ -93,7 +100,18 @@ describe('email and password flow', () => {
describe('email passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig);
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
const { id } = await postConnector(mockEmailConnectorId);
await updateConnectorConfig(id, mockEmailConnectorConfig);
connectorIdMap.set(mockEmailConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Email, false);
await setSignInMethod([
{
@ -175,13 +193,24 @@ describe('email passwordless flow', () => {
});
afterAll(async () => {
void disableConnector(mockEmailConnectorId);
await deleteConnectorById(connectorIdMap.get(mockEmailConnectorId));
});
});
describe('sms passwordless flow', () => {
beforeAll(async () => {
await setUpConnector(mockSmsConnectorId, mockSmsConnectorConfig);
const connectors = await listConnectors();
await Promise.all(
connectors.map(async ({ id }) => {
await deleteConnectorById(id);
})
);
connectorIdMap.clear();
const { id } = await postConnector(mockSmsConnectorId);
await updateConnectorConfig(id, mockSmsConnectorConfig);
connectorIdMap.set(mockSmsConnectorId, id);
await setSignUpIdentifier(SignUpIdentifier.Sms, false);
await setSignInMethod([
{
@ -263,7 +292,7 @@ describe('sms passwordless flow', () => {
});
afterAll(async () => {
void disableConnector(mockSmsConnectorId);
await deleteConnectorById(connectorIdMap.get(mockSmsConnectorId));
});
});

View file

@ -14,20 +14,27 @@ import {
bindWithSocial,
signInWithPassword,
getUser,
postConnector,
updateConnectorConfig,
} from '@/api';
import MockClient from '@/client';
import { setUpConnector, createUserByAdmin, setSignUpIdentifier } from '@/helpers';
import { createUserByAdmin, setSignUpIdentifier } from '@/helpers';
import { generateUsername, generatePassword } from '@/utils';
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
const code = 'auth_code_foo';
const connectorIdMap = new Map<string, string>();
describe('social sign-in and register', () => {
const socialUserId = crypto.randomUUID();
beforeAll(async () => {
await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig);
const { id } = await postConnector(mockSocialConnectorId);
connectorIdMap.set(mockSocialConnectorId, id);
await updateConnectorConfig(id, mockSocialConnectorConfig);
await setSignUpIdentifier(SignUpIdentifier.None, false);
});
@ -39,14 +46,14 @@ describe('social sign-in and register', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const response = await getAuthWithSocial(
{
connectorId: mockSocialConnectorId,
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code, userId: socialUserId },
},
client.interactionCookie
@ -57,7 +64,7 @@ describe('social sign-in and register', () => {
// Register with social
const { redirectTo } = await registerWithSocial(
mockSocialConnectorId,
connectorIdMap.get(mockSocialConnectorId) ?? '',
client.interactionCookie
);
@ -78,14 +85,14 @@ describe('social sign-in and register', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const { redirectTo } = await getAuthWithSocial(
{
connectorId: mockSocialConnectorId,
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code, userId: socialUserId },
},
client.interactionCookie
@ -113,13 +120,16 @@ describe('social bind account', () => {
await expect(
signInWithSocial(
{ state, connectorId: mockSocialConnectorId, redirectUri },
{ state, connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '', redirectUri },
client.interactionCookie
)
).resolves.toBeTruthy();
const response = await getAuthWithSocial(
{ connectorId: mockSocialConnectorId, data: { state, redirectUri, code } },
{
connectorId: connectorIdMap.get(mockSocialConnectorId) ?? '',
data: { state, redirectUri, code },
},
client.interactionCookie
).catch((error: unknown) => error);
@ -133,7 +143,7 @@ describe('social bind account', () => {
});
await expect(
bindWithSocial(mockSocialConnectorId, client.interactionCookie)
bindWithSocial(connectorIdMap.get(mockSocialConnectorId) ?? '', client.interactionCookie)
).resolves.not.toThrow();
await client.processSession(redirectTo);

View file

@ -0,0 +1,20 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
DELETE FROM connectors WHERE enabled = false;
ALTER TABLE connectors DROP COLUMN enabled;
`);
},
down: async (pool) => {
await pool.query(sql`
ALTER TABLE connectors ADD COLUMN enabled boolean NOT NULL DEFAULT false;
UPDATE connectors SET enabled = true;
`);
},
};
export default alteration;

View file

@ -1,6 +1,5 @@
create table connectors (
id varchar(128) not null,
enabled boolean not null default FALSE,
sync_profile boolean not null default FALSE,
connector_id varchar(128) not null,
config jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,