feat: strengthen risk controls and review workflows
This commit is contained in:
@@ -402,6 +402,73 @@ describe('SendChainService', () => {
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('marks invalid and blacklisted client numbers as submit failures while sending valid numbers', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002', reason: '平台拒收' }]);
|
||||
(billing.estimateSmsCost as jest.Mock).mockReturnValue({
|
||||
billingUnitsPerMessage: 1,
|
||||
totalBillingUnits: 1,
|
||||
unitPrice: 3,
|
||||
amountCents: 3,
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '23800000002', '13800000002'],
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ phoneNumber: '13800000001', status: 'queued', amountCents: 3 }),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '23800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'INVALID_PHONE',
|
||||
amountCents: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800000002',
|
||||
status: 'submit_failed',
|
||||
submitStatus: 'rejected',
|
||||
errorCode: 'GLOBAL_BLACKLIST',
|
||||
amountCents: 0,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3 }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('persists the review task id on every message waiting for manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
(riskReview.evaluateTask as jest.Mock).mockResolvedValue({
|
||||
status: 'pending_review',
|
||||
reason: '命中人工审核规则',
|
||||
task: { id: 'review-task-1' },
|
||||
});
|
||||
|
||||
await service.createBatchTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001'],
|
||||
});
|
||||
|
||||
expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({
|
||||
data: [expect.objectContaining({
|
||||
phoneNumber: '13800000001',
|
||||
status: 'pending_review',
|
||||
reviewTaskId: 'review-task-1',
|
||||
})],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the whole batch atomically when the application daily send limit would be exceeded', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.$queryRaw.mockResolvedValueOnce([{ dailyLimit: 1, usedCount: null }]);
|
||||
@@ -807,7 +874,7 @@ describe('SendChainService', () => {
|
||||
})).rejects.toThrow('CMPP interface is disabled for this application');
|
||||
});
|
||||
|
||||
it('does not create a CMPP downstream delivery when the application interface was disabled after bind', async () => {
|
||||
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
@@ -835,12 +902,10 @@ describe('SendChainService', () => {
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||
})).rejects.toThrow('CMPP account is disabled for new submissions');
|
||||
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'validating' }) });
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageRecordId: 'record-1', receiptStatus: 'undelivered', errorCode: 'INTERFACE' }),
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -852,6 +917,7 @@ describe('SendChainService', () => {
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'active',
|
||||
interfaceEnabled: false,
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
@@ -882,7 +948,7 @@ describe('SendChainService', () => {
|
||||
cmppAccount: '100001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'active',
|
||||
interfaceEnabled: false,
|
||||
interfaceEnabled: true,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
@@ -919,8 +985,8 @@ describe('SendChainService', () => {
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) });
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists inbound CMPP long-message fragments and creates one complete main record after reassembly', async () => {
|
||||
@@ -1292,19 +1358,37 @@ describe('SendChainService', () => {
|
||||
expect(billing.freeze).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => {
|
||||
it('returns a failure receipt for an invalid destination while other CMPP destinations continue', async () => {
|
||||
const { service, prisma } = createService();
|
||||
let messageIndex = 0;
|
||||
prisma.smsMessageRecord.create.mockImplementation(({ data }) => Promise.resolve({
|
||||
id: `record-${++messageIndex}`,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumbers: ['13800000001', 'invalid'],
|
||||
content: 'hello',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP submit phone number is invalid');
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, phoneCount: 2 }));
|
||||
|
||||
expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-2' },
|
||||
data: expect.objectContaining({
|
||||
status: 'failed',
|
||||
receiptStatus: 'undelivered',
|
||||
errorCode: 'INVALID_PHONE',
|
||||
}),
|
||||
});
|
||||
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
messageRecordId: 'record-2',
|
||||
receiptStatus: 'undelivered',
|
||||
errorCode: 'INVALID_PHONE',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts only the filled client Src_Id and snapshots the real application extension', async () => {
|
||||
@@ -1571,7 +1655,8 @@ describe('SendChainService', () => {
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsSendTask.findUnique.mockResolvedValue({
|
||||
id: 'review-task-1',
|
||||
messageRecords: [{
|
||||
});
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([{
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
@@ -1581,8 +1666,7 @@ describe('SendChainService', () => {
|
||||
amountCents: 3,
|
||||
billingUnits: 1,
|
||||
batchTask: { id: 'task-1', sourceType: 'cmpp' },
|
||||
}],
|
||||
});
|
||||
}]);
|
||||
|
||||
await expect(service.handleReviewDecision('review-task-1', 'approved', '审核通过')).resolves.toEqual({
|
||||
reviewTaskId: 'review-task-1',
|
||||
@@ -2697,6 +2781,52 @@ describe('SendChainService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
cmppEnterpriseCode: 'SP0001',
|
||||
secretHash: 'secret-hash',
|
||||
status: 'disabling',
|
||||
interfaceEnabled: true,
|
||||
cmppMaxConnections: 2,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
|
||||
await expect(service.authenticateInboundApplication({
|
||||
account: '100001',
|
||||
password: 'secret-hash',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('disabled for new submissions');
|
||||
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'deleted',
|
||||
interfaceEnabled: true,
|
||||
tenant: { id: 'tenant-1', status: 'deleted', certificationStatus: 'approved' },
|
||||
});
|
||||
prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.listPendingDownstreamDeliveries({ account: '100001', limit: 100 }))
|
||||
.resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('persists each upstream SubmitResp segment before the aggregate result arrives', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user