fix: audit submissions disabled after bind

This commit is contained in:
hectorzhao
2026-08-25 19:54:41 +08:00
parent 2a181777ad
commit 4a17df78b8
6 changed files with 105 additions and 31 deletions
+80 -15
View File
@@ -1205,7 +1205,7 @@ describe('SendChainService', () => {
});
});
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
it('accepts Submit after bind and emits an auditable REJECTD receipt when the interface was disabled', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -1222,6 +1222,7 @@ describe('SendChainService', () => {
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'active',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
@@ -1232,12 +1233,34 @@ describe('SendChainService', () => {
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
sequenceId: 701,
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP account is disabled for new submissions');
})).resolves.toEqual(expect.objectContaining({
accepted: true,
messageRecordId: 'record-1',
status: 'accepted',
}));
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.cmppDownstreamDelivery.create).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'record-1' },
data: expect.objectContaining({
status: 'failed',
receiptStatus: 'undelivered',
receiptRawStatus: 'REJECTD',
errorCode: 'INTERFACE',
}),
}));
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }),
}));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }),
}));
expect(service['postGatewayControl']).toHaveBeenCalledWith(
'/downstream/receipt',
expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'INTERFACE' }),
);
});
it('queues an HTTP webhook but not CMPP delivery for an HTTP-only application', async () => {
@@ -2376,23 +2399,44 @@ describe('SendChainService', () => {
}
});
it('rejects a disabled application before the merged Inbox statement can insert', async () => {
it('persists fast-path Submit before evaluating application or tenant business state', async () => {
const previous = process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
process.env.CMPP_INBOUND_FAST_PATH_ENABLED = 'true';
try {
const { service, prisma } = createService();
prisma.$queryRaw.mockResolvedValueOnce([{
validationError: 'CMPP account is disabled for new submissions',
payloadHash: null,
response: null,
}]);
prisma.$queryRaw.mockImplementationOnce((query) => {
const payloadHash = query.values.find((value: unknown) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value));
return Promise.resolve([{
validationError: null,
payloadHash,
response: {
accepted: true,
tenantId: 'tenant-1',
applicationId: 'app-1',
taskId: '',
messageId: 'MSG-disabled-after-bind',
messageRecordId: '',
status: 'accepted_pending',
phoneCount: 1,
messages: [],
},
}]);
});
await expect(service.submitInboundMessage({
requestId: 'cmpp-inbound:disabled',
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
})).rejects.toThrow('CMPP account is disabled for new submissions');
})).resolves.toEqual(expect.objectContaining({
accepted: true,
status: 'accepted_pending',
}));
const sql = prisma.$queryRaw.mock.calls[0][0].strings.join(' ');
expect(sql).not.toContain("application.status <> 'active'");
expect(sql).not.toContain('NOT application."interfaceEnabled"');
expect(sql).toContain('CMPP source IP is not in application allowlist');
expect(sql).toContain('CMPP Src_Id must equal the access number assigned to this application');
expect(prisma.cmppInboundSubmissionInbox.findUnique).not.toHaveBeenCalled();
} finally {
if (previous == null) delete process.env.CMPP_INBOUND_FAST_PATH_ENABLED;
@@ -3928,7 +3972,7 @@ describe('SendChainService', () => {
}));
});
it('allows a disabling application to reconnect for receipt draining but rejects new submissions', async () => {
it('allows a disabling application to reconnect for receipt draining and audits later Submit as REJECTD', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue({
id: 'app-1',
@@ -3943,6 +3987,16 @@ describe('SendChainService', () => {
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
});
prisma.smsApplication.findUnique.mockResolvedValue({
id: 'app-1',
tenantId: 'tenant-1',
cmppAccount: '100001',
status: 'deleted',
interfaceEnabled: false,
downstreamReceiptRetryEnabled: true,
downstreamUplinkRetryEnabled: true,
httpConfig: { enabled: false },
});
await expect(service.authenticateInboundApplication({
account: '100001',
@@ -3955,9 +4009,20 @@ describe('SendChainService', () => {
account: '100001',
phoneNumber: '13800000001',
content: 'hello',
sequenceId: 702,
remoteIp: '127.0.0.1',
})).rejects.toThrow('disabled for new submissions');
expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled();
})).resolves.toEqual(expect.objectContaining({ accepted: true, status: 'accepted' }));
expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(1);
expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }),
}));
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: 'pending', deliveryType: 'receipt' }),
}));
expect(service['postGatewayControl']).toHaveBeenCalledWith(
'/downstream/receipt',
expect.objectContaining({ rawStatus: 'REJECTD', errorCode: 'ACCOUNT' }),
);
});
it('lets Gateway read historical pending receipts after an application or enterprise is disabled', async () => {