feat: enforce signature-scoped drainage authorization before SMS submission
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
// Only a separately created QA database is accepted. No send entry, worker,
|
||||
// Gateway transport, balance mutation or remote business configuration is used.
|
||||
const require = createRequire(resolve('api/package.json'));
|
||||
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
|
||||
const name = process.env.CMPP_DRAINAGE_QA_DATABASE;
|
||||
assert.match(name ?? '', /^cmpp_qa_drainage_\d+$/);
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
assert(['localhost', '127.0.0.1'].includes(url.hostname));
|
||||
url.pathname = `/${name}`;
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.CMPP_PROCESS_ROLE = 'api';
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service.js');
|
||||
const { evaluateMessageDrainage } = require('./dist/send-chain/drainage-authorization.js');
|
||||
const { DrainageSubmitGuardController } = require('./dist/send-chain/drainage-submit-guard.controller.js');
|
||||
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
|
||||
const { ReportsService } = require('./dist/reports/reports.service.js');
|
||||
const { Module } = require('@nestjs/common');
|
||||
const { NestFactory } = require('@nestjs/core');
|
||||
const db = new PrismaService();
|
||||
const checks = [];
|
||||
const prefix = `qa-drainage-${randomUUID()}`;
|
||||
let app;
|
||||
try {
|
||||
const application = await db.smsApplication.findFirstOrThrow({ where: { status: { not: 'deleted' } } });
|
||||
const channels = await db.smsChannel.findMany({ take: 2 });
|
||||
assert.equal(channels.length, 2);
|
||||
const signature = await db.smsSignature.create({
|
||||
data: {
|
||||
id: prefix,
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
name: prefix,
|
||||
auditStatus: 'approved',
|
||||
},
|
||||
});
|
||||
const common = { tenantId: application.tenantId, applicationId: application.id, signatureId: signature.id };
|
||||
const materials = [];
|
||||
for (const [index, target] of ['lisglo.cn', '02177882277'].entries()) {
|
||||
const material = await db.smsDrainageInfo.create({
|
||||
data: { ...common, siteName: prefix, url: target, auditStatus: 'approved' },
|
||||
});
|
||||
materials.push(material);
|
||||
for (const channel of channels.slice(index))
|
||||
await db.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
signatureId: signature.id,
|
||||
channelId: channel.id,
|
||||
carrier: 'mobile',
|
||||
approvalScope: 'carrier',
|
||||
reportType: 'drainage',
|
||||
drainageItemId: material.id,
|
||||
status: 'approved',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const channel of channels)
|
||||
await db.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: application.tenantId,
|
||||
signatureId: signature.id,
|
||||
channelId: channel.id,
|
||||
carrier: 'mobile',
|
||||
approvalScope: 'carrier',
|
||||
reportType: 'signature',
|
||||
status: 'approved',
|
||||
},
|
||||
});
|
||||
const original = `【${prefix}】访问 https://sms.lisglo.cn/path?x=1 或联系 021-77882277`;
|
||||
const message = await db.smsMessageRecord.create({
|
||||
data: { ...common, messageId: prefix, content: original, phoneNumber: '13800000000', carrier: 'mobile' },
|
||||
});
|
||||
const gate = await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
|
||||
assert.deepEqual(gate.allowedChannelIds, [channels[1].id]);
|
||||
assert.equal(gate.targets.length, 2);
|
||||
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, original);
|
||||
assert.equal(await db.smsDrainageDecision.count({ where: { messageRecordId: message.id } }), 1);
|
||||
checks.push(
|
||||
'real rules, NFKC phone, two targets, channel intersection, original content preserved, durable decision',
|
||||
);
|
||||
const submit = await db.smsSubmitRecord.create({
|
||||
data: { messageRecordId: message.id, channelId: channels[1].id, submitId: `${prefix}-submit` },
|
||||
});
|
||||
class QAOnlyModule {}
|
||||
Module({ controllers: [DrainageSubmitGuardController], providers: [{ provide: PrismaService, useValue: db }] })(
|
||||
QAOnlyModule,
|
||||
);
|
||||
app = await NestFactory.create(QAOnlyModule, { logger: false });
|
||||
await app.listen(0, '127.0.0.1');
|
||||
const base = await app.getUrl();
|
||||
const body = {
|
||||
submitId: submit.submitId,
|
||||
channelId: channels[1].id,
|
||||
contentHash: createHash('sha256').update(original).digest('hex'),
|
||||
};
|
||||
const check = async (extra = {}) => {
|
||||
const response = await fetch(`${base}/gateway/events/authorize-drainage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...extra },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
return response.json();
|
||||
};
|
||||
assert.equal((await check()).allowed, true);
|
||||
await db.channelSignatureReportTask.updateMany({
|
||||
where: { drainageItemId: materials[1].id },
|
||||
data: { status: 'rejected' },
|
||||
});
|
||||
assert.equal((await check()).allowed, false);
|
||||
checks.push('real HTTP final permission changes immediately after carrier report revocation');
|
||||
await db.channelSignatureReportTask.updateMany({
|
||||
where: { drainageItemId: materials[1].id },
|
||||
data: { status: 'approved' },
|
||||
});
|
||||
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
|
||||
assert.equal((await check()).allowed, false);
|
||||
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
|
||||
const denied = await fetch(`${base}/gateway/events/authorize-drainage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '198.51.100.1' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
assert.equal(denied.status, 403);
|
||||
checks.push('platform audit required; proxy-origin requests forbidden');
|
||||
let acquired;
|
||||
let release;
|
||||
const locked = new Promise((resolve) => {
|
||||
acquired = resolve;
|
||||
});
|
||||
const unlock = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const writer = db.$transaction(
|
||||
async (tx) => {
|
||||
await tx.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
|
||||
acquired();
|
||||
await unlock;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await locked;
|
||||
let finished = false;
|
||||
const concurrent = check().then((result) => {
|
||||
finished = true;
|
||||
return result;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const waitedForWriter = !finished;
|
||||
release();
|
||||
await writer;
|
||||
assert(waitedForWriter);
|
||||
assert.equal((await concurrent).allowed, false);
|
||||
checks.push('PostgreSQL writer lock blocks concurrent permission and committed revocation is observed');
|
||||
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
|
||||
for (const content of [
|
||||
'https://lisglo.cn.evil.com/p',
|
||||
'https://evil.com/?next=lisglo.cn',
|
||||
'https://lisglo.cn@evil.com/p',
|
||||
]) {
|
||||
const rejected = await db.smsMessageRecord.create({
|
||||
data: {
|
||||
...common,
|
||||
messageId: `${prefix}-${randomUUID()}`,
|
||||
content,
|
||||
phoneNumber: '13800000000',
|
||||
carrier: 'mobile',
|
||||
},
|
||||
});
|
||||
await assert.rejects(evaluateMessageDrainage(db, rejected, 'mobile', undefined, true), /未在当前签名下添加/);
|
||||
}
|
||||
checks.push('real configured detectors reject suffix spoof, query spoof and URL userInfo spoof');
|
||||
await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
|
||||
await new ReportsService(db).refreshRollingWindow(new Date(Date.now() + 86400000));
|
||||
const quality = await db.dailyQualityReport.findMany({
|
||||
where: { dimensionType: 'drainage', drainageInfoId: { in: materials.map((item) => item.id) } },
|
||||
});
|
||||
assert.equal(quality.length, 2);
|
||||
assert(quality.every((row) => row.submittedUnits === 1));
|
||||
const today = new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00+08:00');
|
||||
const customerUnits = await db.smsMessageRecord.aggregate({
|
||||
where: { applicationId: application.id, queuedAt: { gte: today, lt: new Date(today.getTime() + 86400000) } },
|
||||
_sum: { billingUnits: true },
|
||||
});
|
||||
const applicationQuality = await db.dailyQualityReport.findFirstOrThrow({
|
||||
where: { dimensionType: 'application', dimensionId: application.id },
|
||||
orderBy: { reportDate: 'desc' },
|
||||
});
|
||||
assert.equal(applicationQuality.submittedUnits, customerUnits._sum.billingUnits);
|
||||
// Synthetic rejected metadata only, no supplier transmission is performed.
|
||||
await db.smsSubmitRecord.update({ where: { id: submit.id }, data: { submitStatus: 'rejected', errorCode: 'DRN' } });
|
||||
const reports = await new ChannelReportingService(db).listReportTasks(
|
||||
application.tenantId,
|
||||
undefined,
|
||||
channels[1].id,
|
||||
'drainage',
|
||||
);
|
||||
const ownReports = reports.filter((row) => row.signatureId === signature.id);
|
||||
assert.equal(ownReports.length, 2);
|
||||
assert(ownReports.every((row) => row.deliveryStats.total === 0));
|
||||
checks.push(
|
||||
'real quality SQL attributes one message to both targets without multiplying customer units; no-wire rejection counts zero channel attempts',
|
||||
);
|
||||
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id } }), 0);
|
||||
assert.equal(await db.smsReceiptRecord.count({ where: { messageRecordId: message.id } }), 0);
|
||||
checks.push('no send intent or customer receipt was created by qualification checks');
|
||||
console.log(JSON.stringify({ success: true, database: name, fixturePrefix: prefix, checks }));
|
||||
} finally {
|
||||
if (app) await app.close();
|
||||
await db.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user