mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(cloud): limit control for send email (#3418)
This commit is contained in:
parent
63938d6d4b
commit
dc175580b9
8 changed files with 101 additions and 5 deletions
|
@ -1,6 +1,8 @@
|
|||
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 { generateStandardId } from '@logto/core-kit';
|
||||
import type { ServiceLogType } from '@logto/schemas';
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import { trySafe } from '@logto/shared';
|
||||
import { RequestError } from '@withtyped/server';
|
||||
|
@ -9,6 +11,8 @@ import type { Queries } from '#src/queries/index.js';
|
|||
import type { LogtoConnector } from '#src/utils/connector/index.js';
|
||||
import { loadConnectorFactories } from '#src/utils/connector/index.js';
|
||||
|
||||
export const serviceCountLimitForTenant = 100;
|
||||
|
||||
export class ServicesLibrary {
|
||||
constructor(public readonly queries: Queries) {}
|
||||
|
||||
|
@ -96,4 +100,18 @@ export class ServicesLibrary {
|
|||
|
||||
return sendMessage(data);
|
||||
}
|
||||
|
||||
async addLog(tenantId: string, type: ServiceLogType) {
|
||||
return this.queries.serviceLogs.insertLog({
|
||||
id: generateStandardId(),
|
||||
type,
|
||||
tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
async getTenantBalanceForType(tenantId: string, type: ServiceLogType) {
|
||||
const usedCount = await this.queries.serviceLogs.countTenantLogs(tenantId, type);
|
||||
|
||||
return serviceCountLimitForTenant - usedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import { parseDsn } from '#src/utils/postgres.js';
|
|||
|
||||
import { createApplicationsQueries } from './application.js';
|
||||
import { createConnectorsQuery } from './connector.js';
|
||||
import { createServiceLogsQueries } from './service-logs.js';
|
||||
import { createTenantsQueries } from './tenants.js';
|
||||
import { createUsersQueries } from './users.js';
|
||||
|
||||
|
@ -16,4 +17,5 @@ export class Queries {
|
|||
public readonly users = createUsersQueries(this.client);
|
||||
public readonly applications = createApplicationsQueries(this.client);
|
||||
public readonly connectors = createConnectorsQuery(this.client);
|
||||
public readonly serviceLogs = createServiceLogsQueries(this.client);
|
||||
}
|
||||
|
|
29
packages/cloud/src/queries/service-logs.ts
Normal file
29
packages/cloud/src/queries/service-logs.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { CreateServiceLog, ServiceLogType } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type ServiceLogsQueries = ReturnType<typeof createServiceLogsQueries>;
|
||||
|
||||
export const createServiceLogsQueries = (client: Queryable<PostgreSql>) => {
|
||||
const insertLog = async (data: Omit<CreateServiceLog, 'payload'>) =>
|
||||
client.query(insertInto(data, 'service_logs'));
|
||||
|
||||
const countTenantLogs = async (tenantId: string, type: ServiceLogType) => {
|
||||
const { rows } = await client.query<{ count: number }>(sql`
|
||||
select count(id) as count
|
||||
from service_logs
|
||||
where tenantId = ${tenantId}
|
||||
and type = ${type}
|
||||
`);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
return {
|
||||
insertLog,
|
||||
countTenantLogs,
|
||||
};
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import { CloudScope } from '@logto/schemas';
|
||||
import { CloudScope, ServiceLogType } from '@logto/schemas';
|
||||
|
||||
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
|
||||
import { noop } from '#src/test-utils/function.js';
|
||||
|
@ -28,6 +28,33 @@ describe('POST /api/services/send-email', () => {
|
|||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when tenant id not found', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendEmail]),
|
||||
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-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendEmail]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should return 201', async () => {
|
||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||
|
||||
|
@ -38,6 +65,7 @@ describe('POST /api/services/send-email', () => {
|
|||
async ({ status }) => {
|
||||
expect(status).toBe(201);
|
||||
expect(library.sendEmail).toBeCalledWith(mockSendMessagePayload);
|
||||
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendEmail);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { sendMessagePayloadGuard } from '@logto/connector-kit';
|
||||
import { CloudScope } from '@logto/schemas';
|
||||
import { CloudScope, ServiceLogType } from '@logto/schemas';
|
||||
import { createRouter, RequestError } from '@withtyped/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -17,10 +17,18 @@ export const servicesRoutes = (library: ServicesLibrary) =>
|
|||
|
||||
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
|
||||
|
||||
// TODO limitation control
|
||||
console.log(tenantId);
|
||||
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.sendEmail(context.guarded.body.data);
|
||||
await library.addLog(tenantId, ServiceLogType.SendEmail);
|
||||
|
||||
return next({ ...context, status: 201 });
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { TenantInfo } from '@logto/schemas';
|
||||
import type { ServiceLogType, TenantInfo } from '@logto/schemas';
|
||||
|
||||
import type { ServicesLibrary } from '#src/libraries/services.js';
|
||||
import type { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||
|
@ -25,4 +25,10 @@ export class MockServicesLibrary implements ServicesLibrary {
|
|||
public sendEmail = jest.fn();
|
||||
|
||||
public getAdminTenantLogtoConnectors = jest.fn();
|
||||
|
||||
public addLog = jest.fn();
|
||||
|
||||
public getTenantBalanceForType = jest
|
||||
.fn<Promise<number>, [string, ServiceLogType]>()
|
||||
.mockResolvedValue(100);
|
||||
}
|
||||
|
|
|
@ -14,3 +14,4 @@ export * from './system.js';
|
|||
export * from './tenant.js';
|
||||
export * from './user-assets.js';
|
||||
export * from './hook.js';
|
||||
export * from './service-log.js';
|
||||
|
|
4
packages/schemas/src/types/service-log.ts
Normal file
4
packages/schemas/src/types/service-log.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export enum ServiceLogType {
|
||||
SendEmail = 'sendEmail',
|
||||
SendSms = 'sendSms',
|
||||
}
|
Loading…
Add table
Reference in a new issue