0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(cloud): send sms (#3452)

This commit is contained in:
wangsijie 2023-03-17 12:49:58 +08:00 committed by GitHub
parent f247ce5f86
commit 81f76d122d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 171 additions and 46 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/cloud": minor
---
Add send sms service

View file

@ -1,6 +1,12 @@
import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
import type { AllConnector, EmailConnector, SendMessagePayload } from '@logto/connector-kit';
import { ConnectorType, validateConfig } from '@logto/connector-kit';
import type {
AllConnector,
EmailConnector,
SendMessagePayload,
ConnectorType,
SmsConnector,
} from '@logto/connector-kit';
import { validateConfig } from '@logto/connector-kit';
import { generateStandardId } from '@logto/core-kit';
import type { ServiceLogType } from '@logto/schemas';
import { adminTenantId } from '@logto/schemas';
@ -84,16 +90,16 @@ export class ServicesLibrary {
);
}
async sendEmail(data: SendMessagePayload) {
async sendMessage(type: ConnectorType.Email | ConnectorType.Sms, data: SendMessagePayload) {
const connectors = await this.getAdminTenantLogtoConnectors();
const connector = connectors.find(
(connector): connector is LogtoConnector<EmailConnector> =>
connector.type === ConnectorType.Email
(connector): connector is LogtoConnector<EmailConnector | SmsConnector> =>
connector.type === type
);
if (!connector) {
throw new RequestError('Unable to find email connector', 500);
throw new RequestError(`Unable to find ${type} connector`, 500);
}
const { sendMessage } = connector;

View file

@ -5,7 +5,6 @@ import {
import { generateStandardId } from '@logto/core-kit';
import type { LogtoOidcConfigType, TenantInfo, TenantModel } from '@logto/schemas';
import {
cloudApiIndicator,
createAdminTenantApplicationRole,
AdminTenantRole,
createTenantMachineToMachineApplication,
@ -30,6 +29,7 @@ import { createSystemsQuery } from '#src/queries/system.js';
import { createTenantsQueries } from '#src/queries/tenants.js';
import { createUsersQueries } from '#src/queries/users.js';
import { getDatabaseName } from '#src/queries/utils.js';
import { createCloudServiceConnector } from '#src/utils/connector/seed.js';
import { insertInto } from '#src/utils/query.js';
import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js';
@ -117,20 +117,20 @@ export class TenantsLibrary {
// Create demo connectors
const globalValues = new GlobalValues();
const { cloudUrlSet, adminUrlSet } = globalValues;
const { cloudUrlSet } = globalValues;
await connectors.insertConnector({
id: generateStandardId(),
tenantId,
connectorId: DemoConnector.Email,
config: {
appId: m2mApplication.id,
appSecret: m2mApplication.secret,
tokenEndpoint: `${adminUrlSet.endpoint.toString()}oidc/token`,
endpoint: `${cloudUrlSet.endpoint.toString()}api`,
resource: cloudApiIndicator,
},
});
await Promise.all(
[DemoConnector.Email, DemoConnector.Sms].map(async (connectorId) => {
return connectors.insertConnector(
createCloudServiceConnector({
tenantId,
connectorId,
appId: m2mApplication.id,
appSecret: m2mApplication.secret,
})
);
})
);
// Create demo social connectors
const presetSocialConnectors = await systems.getDemoSocialValue();

View file

@ -1,4 +1,4 @@
import { CloudScope, ServiceLogType } from '@logto/schemas';
import { CloudScope, ConnectorType, ServiceLogType } from '@logto/schemas';
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
import { noop } from '#src/test-utils/function.js';
@ -64,10 +64,70 @@ describe('POST /api/services/send-email', () => {
})([CloudScope.SendEmail]),
async ({ status }) => {
expect(status).toBe(201);
expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload);
expect(library.sendMessage).toBeCalledWith(ConnectorType.Email, mockSendMessagePayload);
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendEmail);
},
createHttpContext()
);
});
});
describe('POST /api/services/send-sms', () => {
const library = new MockServicesLibrary();
const router = servicesRoutes(library);
it('should throw 403 when lack of permission', async () => {
await expect(
router.routes()(
buildRequestAuthContext('POST /services/send-sms', {
body: { data: mockSendMessagePayload },
})(),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should throw 403 when tenant id not found', async () => {
await expect(
router.routes()(
buildRequestAuthContext('POST /services/send-sms', {
body: { data: mockSendMessagePayload },
})([CloudScope.SendSms]),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should throw 403 when insufficient funds', async () => {
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
library.getTenantBalanceForType.mockResolvedValueOnce(0);
await expect(
router.routes()(
buildRequestAuthContext('POST /services/send-sms', {
body: { data: mockSendMessagePayload },
})([CloudScope.SendSms]),
noop,
createHttpContext()
)
).rejects.toMatchObject({ status: 403 });
});
it('should return 201', async () => {
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
await router.routes()(
buildRequestAuthContext('POST /services/send-sms', {
body: { data: mockSendMessagePayload },
})([CloudScope.SendSms]),
async ({ status }) => {
expect(status).toBe(201);
expect(library.sendMessage).toBeCalledWith(ConnectorType.Sms, mockSendMessagePayload);
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendSms);
},
createHttpContext()
);
});
});

View file

@ -1,4 +1,4 @@
import { sendMessagePayloadGuard } from '@logto/connector-kit';
import { ConnectorType, sendMessagePayloadGuard } from '@logto/connector-kit';
import { CloudScope, ServiceLogType } from '@logto/schemas';
import { createRouter, RequestError } from '@withtyped/server';
import { z } from 'zod';
@ -7,29 +7,56 @@ import type { ServicesLibrary } from '#src/libraries/services.js';
import type { WithAuthContext } from '#src/middleware/with-auth.js';
export const servicesRoutes = (library: ServicesLibrary) =>
createRouter<WithAuthContext, '/services'>('/services').post(
'/send-email',
{ body: z.object({ data: sendMessagePayloadGuard }) },
async (context, next) => {
if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) {
throw new RequestError('Forbidden due to lack of permission.', 403);
createRouter<WithAuthContext, '/services'>('/services')
.post(
'/send-email',
{ body: z.object({ data: sendMessagePayloadGuard }) },
async (context, next) => {
if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) {
throw new RequestError('Forbidden due to lack of permission.', 403);
}
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
if (!tenantId) {
throw new RequestError('Unable to find tenant id.', 403);
}
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendEmail);
if (!balance) {
throw new RequestError('Service usage limit reached.', 403);
}
await library.sendMessage(ConnectorType.Email, context.guarded.body.data);
await library.addLog(tenantId, ServiceLogType.SendEmail);
return next({ ...context, status: 201 });
}
)
.post(
'/send-sms',
{ body: z.object({ data: sendMessagePayloadGuard }) },
async (context, next) => {
if (![CloudScope.SendSms].some((scope) => context.auth.scopes.includes(scope))) {
throw new RequestError('Forbidden due to lack of permission.', 403);
}
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
if (!tenantId) {
throw new RequestError('Unable to find tenant id.', 403);
if (!tenantId) {
throw new RequestError('Unable to find tenant id.', 403);
}
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendSms);
if (!balance) {
throw new RequestError('Service usage limit reached.', 403);
}
await library.sendMessage(ConnectorType.Sms, context.guarded.body.data);
await library.addLog(tenantId, ServiceLogType.SendSms);
return next({ ...context, status: 201 });
}
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendEmail);
if (!balance) {
throw new RequestError('Service usage limit reached.', 403);
}
await library.sendEmail(context.guarded.body.data);
await library.addLog(tenantId, ServiceLogType.SendEmail);
return next({ ...context, status: 201 });
}
);
);

View file

@ -22,7 +22,7 @@ export class MockServicesLibrary implements ServicesLibrary {
public getTenantIdFromApplicationId = jest.fn<Promise<string>, [string]>();
public sendEmail = jest.fn();
public sendMessage = jest.fn();
public getAdminTenantLogtoConnectors = jest.fn();

View file

@ -0,0 +1,27 @@
import { generateStandardId } from '@logto/core-kit';
import { cloudApiIndicator } from '@logto/schemas';
import { GlobalValues } from '@logto/shared';
export const createCloudServiceConnector = (data: {
tenantId: string;
connectorId: string;
appId: string;
appSecret: string;
}) => {
const globalValues = new GlobalValues();
const { cloudUrlSet, adminUrlSet } = globalValues;
const { tenantId, connectorId, appId, appSecret } = data;
return {
id: generateStandardId(),
tenantId,
connectorId,
config: {
appId,
appSecret,
tokenEndpoint: `${adminUrlSet.endpoint.toString()}oidc/token`,
endpoint: `${cloudUrlSet.endpoint.toString()}api`,
resource: cloudApiIndicator,
},
};
};