fix: audit submissions disabled after bind
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user