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:
parent
f247ce5f86
commit
81f76d122d
7 changed files with 171 additions and 46 deletions
5
.changeset-staged/nice-vans-kneel.md
Normal file
5
.changeset-staged/nice-vans-kneel.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/cloud": minor
|
||||
---
|
||||
|
||||
Add send sms service
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
);
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
27
packages/cloud/src/utils/connector/seed.ts
Normal file
27
packages/cloud/src/utils/connector/seed.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
Loading…
Add table
Reference in a new issue