diff --git a/.changeset-staged/nice-vans-kneel.md b/.changeset-staged/nice-vans-kneel.md new file mode 100644 index 000000000..362a153bf --- /dev/null +++ b/.changeset-staged/nice-vans-kneel.md @@ -0,0 +1,5 @@ +--- +"@logto/cloud": minor +--- + +Add send sms service diff --git a/packages/cloud/src/libraries/services.ts b/packages/cloud/src/libraries/services.ts index 4937273b6..44d5524fc 100644 --- a/packages/cloud/src/libraries/services.ts +++ b/packages/cloud/src/libraries/services.ts @@ -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 => - connector.type === ConnectorType.Email + (connector): connector is LogtoConnector => + 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; diff --git a/packages/cloud/src/libraries/tenants.ts b/packages/cloud/src/libraries/tenants.ts index 2fd2522a5..b43ddbde3 100644 --- a/packages/cloud/src/libraries/tenants.ts +++ b/packages/cloud/src/libraries/tenants.ts @@ -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(); diff --git a/packages/cloud/src/routes/service.test.ts b/packages/cloud/src/routes/service.test.ts index 2db621c0b..7530ba202 100644 --- a/packages/cloud/src/routes/service.test.ts +++ b/packages/cloud/src/routes/service.test.ts @@ -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() + ); + }); +}); diff --git a/packages/cloud/src/routes/services.ts b/packages/cloud/src/routes/services.ts index 01a008b8e..51c719358 100644 --- a/packages/cloud/src/routes/services.ts +++ b/packages/cloud/src/routes/services.ts @@ -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('/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('/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 }); - } - ); + ); diff --git a/packages/cloud/src/test-utils/libraries.ts b/packages/cloud/src/test-utils/libraries.ts index c5d6d33f9..3ddf9f365 100644 --- a/packages/cloud/src/test-utils/libraries.ts +++ b/packages/cloud/src/test-utils/libraries.ts @@ -22,7 +22,7 @@ export class MockServicesLibrary implements ServicesLibrary { public getTenantIdFromApplicationId = jest.fn, [string]>(); - public sendEmail = jest.fn(); + public sendMessage = jest.fn(); public getAdminTenantLogtoConnectors = jest.fn(); diff --git a/packages/cloud/src/utils/connector/seed.ts b/packages/cloud/src/utils/connector/seed.ts new file mode 100644 index 000000000..bd8f7eea9 --- /dev/null +++ b/packages/cloud/src/utils/connector/seed.ts @@ -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, + }, + }; +};