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
@@ -21,6 +21,7 @@ export type DownstreamDeliveryQueueRequest = {
receiptDedupeKey?: string;
queueHttpWebhook?: boolean;
queueCmppDelivery?: boolean;
allowBusinessRejectionCmppDelivery?: boolean;
propagateHttpQueueError?: boolean;
};
@@ -80,6 +81,7 @@ export async function queueFinalReceiptDeliveries(
message: FinalReceiptMessage;
payload: Record<string, unknown>;
segmentPayloads?: Record<number, Record<string, unknown>>;
allowBusinessRejectionCmppDelivery?: boolean;
propagateHttpQueueError?: boolean;
},
) {
@@ -125,6 +127,7 @@ export async function queueFinalReceiptDeliveries(
: `receipt:${message.id}:segment:${target.segmentIndex}`,
queueHttpWebhook: false,
queueCmppDelivery: true,
allowBusinessRejectionCmppDelivery: data.allowBusinessRejectionCmppDelivery,
});
}
return { queued: true, cmppTargetCount: targets.length };
+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 () => {
@@ -210,6 +210,7 @@ export class SendDownstreamDeliveryService {
},
});
const deliveryAllowed = application?.status === 'active' || application?.status === 'disabling';
const cmppDeliveryAllowed = deliveryAllowed || data.allowBusinessRejectionCmppDelivery === true;
if (deliveryAllowed && data.queueHttpWebhook !== false) {
try {
await this.openApi?.queueWebhookEvent({
@@ -229,7 +230,9 @@ export class SendDownstreamDeliveryService {
if (data.queueCmppDelivery === false) {
return null;
}
if (application?.interfaceEnabled !== true) {
if (!application?.cmppAccount || (
application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true
)) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
@@ -249,11 +252,11 @@ export class SendDownstreamDeliveryService {
dedupeKey,
deliveryType: data.deliveryType,
payload,
retryEnabled: deliveryAllowed && (data.deliveryType === 'uplink'
retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink'
? application?.downstreamUplinkRetryEnabled ?? true
: application?.downstreamReceiptRetryEnabled ?? true),
status: deliveryAllowed ? 'pending' : 'abandoned',
lastError: deliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
},
});
} catch (error) {
@@ -278,7 +281,7 @@ export class SendDownstreamDeliveryService {
}
throw error;
}
if (!deliveryAllowed) {
if (!cmppDeliveryAllowed) {
return delivery;
}
const claimId = `api-direct:${process.pid}:${randomUUID()}`;
@@ -478,6 +481,7 @@ export class SendDownstreamDeliveryService {
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`,
@@ -233,9 +233,6 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (application.status !== 'active' || application.tenant.status !== 'active' || !application.interfaceEnabled) {
throw new BadRequestException('CMPP account is disabled for new submissions');
}
if (data.longMessage) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
@@ -360,10 +357,10 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
const remoteIp = data.remoteIp?.replace(/^::ffff:/, '').trim() || null;
const submittedSrcId = data.srcId?.trim() ?? '';
// One indexed statement must both validate the current application state and create the
// durable Inbox row. Keeping those operations in one database snapshot prevents a disable
// racing between a separate SELECT and INSERT, while the unique request key remains the
// authoritative idempotency boundary.
// One indexed statement validates protocol-level constraints and creates the durable Inbox
// row. Application/tenant/interface state is deliberately evaluated by the workflow worker:
// an already-authenticated connection must receive a successful SubmitResp first, followed
// by an auditable REJECTD receipt if the business resource was disabled after bind.
const rows = await this.prisma.$queryRaw<PersistedInboundWorkflowRow[]>(Prisma.sql`
WITH application AS (
SELECT app.id,
@@ -383,10 +380,6 @@ async submitInboundMessage(data: GatewayInboundSubmitDto) {
), validation AS (
SELECT application.*,
CASE
WHEN application.status <> 'active'
OR application."tenantStatus" <> 'active'
OR NOT application."interfaceEnabled"
THEN 'CMPP account is disabled for new submissions'
WHEN ${remoteIp}::text IS NOT NULL
AND EXISTS (
SELECT 1 FROM "SmsApplicationIpAllowlist" allowlist