fix: enforce drainage uniqueness and carrier-specific reporting
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||
const url = new URL(process.env.DRAINAGE_TEST_DATABASE_URL || '');
|
||||
assert(
|
||||
['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_'),
|
||||
'Dedicated loopback QA database required',
|
||||
);
|
||||
process.env.DATABASE_URL = url.toString();
|
||||
process.env.NODE_ENV = 'test';
|
||||
const { PrismaService } = require('./dist/prisma/prisma.service.js');
|
||||
const { SmsConfigService } = require('./dist/sms-config/sms-config.service.js');
|
||||
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
|
||||
const { OpenApiService } = require('./dist/open-api/open-api.service.js');
|
||||
const { ReportBatchGenerationService } = require('./dist/report-materials/batch-generation.service.js');
|
||||
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
|
||||
const { assessDrainage } = require('./dist/send-chain/drainage-authorization.js');
|
||||
const express = require('express');
|
||||
const db = new PrismaService();
|
||||
const sms = new SmsConfigService(db);
|
||||
const reporting = new ChannelReportingService(db);
|
||||
// No onModuleInit: transport, workers, reconciliation and authentication are outside this loopback service harness.
|
||||
const httpConfig = new OpenApiService(db, undefined);
|
||||
const batch = new ReportBatchGenerationService(db, undefined, sms, undefined, undefined);
|
||||
const messages = new OperationsMessageQueries(db);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('json replacer', (_, value) => (typeof value === 'bigint' ? value.toString() : value));
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
try {
|
||||
res.json(await fn(req));
|
||||
} catch (e) {
|
||||
res.status(e.getStatus?.() || 500).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
'/api/admin/enterprise-signatures/:id',
|
||||
wrap((r) => sms.getSignature(r.params.id)),
|
||||
);
|
||||
app.post(
|
||||
'/api/admin/enterprise-signatures/:id/drainage-infos',
|
||||
wrap((r) => sms.createDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
|
||||
);
|
||||
app.put(
|
||||
'/api/admin/drainage-infos/:id',
|
||||
wrap((r) => sms.updateDrainageInfo(r.params.id, r.body, { initialAuditStatus: 'approved' })),
|
||||
);
|
||||
app.get(
|
||||
'/api/admin/enterprise-applications/:id/report-fields',
|
||||
wrap((r) => sms.getApplicationReportFields(r.params.id)),
|
||||
);
|
||||
app.post(
|
||||
'/api/admin/drainage-infos/:id/status',
|
||||
wrap((r) => sms.changeDrainageInfoStatus(r.params.id, r.body)),
|
||||
);
|
||||
app.get(
|
||||
'/api/admin/drainage-infos/:id/report-targets',
|
||||
wrap((r) => sms.getDrainageReportTargets(r.params.id)),
|
||||
);
|
||||
app.post(
|
||||
'/api/admin/report-tasks/status-change',
|
||||
wrap((r) => reporting.changeReportTaskStatuses(r.body)),
|
||||
);
|
||||
app.get(
|
||||
'/api/admin/enterprise-applications/:id/http-api',
|
||||
wrap((r) => httpConfig.getConfig(r.params.id)),
|
||||
);
|
||||
app.put(
|
||||
'/api/admin/enterprise-applications/:id/http-api',
|
||||
wrap((r) => httpConfig.updateConfig(r.params.id, r.body)),
|
||||
);
|
||||
app.get(
|
||||
'/api/admin/messages/:id',
|
||||
wrap((r) => messages.getMessage(r.params.id)),
|
||||
);
|
||||
const port = Number(process.env.DRAINAGE_TEST_PORT || 16416);
|
||||
const server = await new Promise((resolve) => {
|
||||
const listener = app.listen(port, '127.0.0.1', () => resolve(listener));
|
||||
});
|
||||
const base = 'http://127.0.0.1:' + port;
|
||||
const checks = [];
|
||||
const check = (name, value) => {
|
||||
assert(value, name);
|
||||
checks.push(name);
|
||||
};
|
||||
const request = async (method, path, body) => {
|
||||
const r = await fetch(base + '/api/admin/' + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, data: await r.json() };
|
||||
};
|
||||
try {
|
||||
const tag = randomUUID().slice(0, 8);
|
||||
const { Client } = require('pg');
|
||||
const migrationDb = new Client({ connectionString: url.toString() });
|
||||
await migrationDb.connect();
|
||||
try {
|
||||
await migrationDb.query('CREATE SCHEMA qa_migration_' + tag);
|
||||
await migrationDb.query('SET search_path TO qa_migration_' + tag);
|
||||
await migrationDb.query(
|
||||
'CREATE TABLE "ChannelSignatureReportTask" ("id" text, "signatureId" text, "drainageItemId" text, "channelId" text, "carrier" text, "reportType" text, "status" text)',
|
||||
);
|
||||
await migrationDb.query(
|
||||
`CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key" ON "ChannelSignatureReportTask" ("signatureId", "drainageItemId", "channelId") WHERE "reportType"='drainage' AND "drainageItemId" IS NOT NULL`,
|
||||
);
|
||||
await migrationDb.query(
|
||||
`INSERT INTO "ChannelSignatureReportTask" VALUES ('old','s','d','c',NULL,'drainage','approved')`,
|
||||
);
|
||||
const oldRow = (await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows;
|
||||
await migrationDb.query(
|
||||
fs.readFileSync(
|
||||
new URL('../../api/prisma/migrations/20260914093000_drainage_carrier_reports/migration.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await migrationDb.query('SELECT * FROM "ChannelSignatureReportTask"')).rows, oldRow);
|
||||
check('migration preserves legacy approval exactly', true);
|
||||
for (const carrier of ['mobile', 'unicom', 'telecom'])
|
||||
await migrationDb.query(
|
||||
`INSERT INTO "ChannelSignatureReportTask" VALUES ($1,'s','d','c',$1,'drainage','pending')`,
|
||||
[carrier],
|
||||
);
|
||||
for (const carrier of ['mobile', null])
|
||||
await assert.rejects(
|
||||
migrationDb.query(
|
||||
`INSERT INTO "ChannelSignatureReportTask" VALUES ('dup','s','d','c',$1,'drainage','pending')`,
|
||||
[carrier],
|
||||
),
|
||||
{ code: '23505' },
|
||||
);
|
||||
check('migration permits independent carriers and rejects carrier and legacy duplicates', true);
|
||||
} finally {
|
||||
await migrationDb.end();
|
||||
}
|
||||
const tenant = await db.tenant.create({ data: { name: '隔离引流验收', code: 'QA-' + tag } });
|
||||
const application = await db.smsApplication.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
name: '隔离引流应用',
|
||||
cmppAccount: 'qa-' + tag,
|
||||
cmppEnterpriseCode: 'QA',
|
||||
secretHash: randomUUID(),
|
||||
interfaceEnabled: false,
|
||||
},
|
||||
});
|
||||
const signature = await db.smsSignature.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, name: '【引流三网验收】', auditStatus: 'approved' },
|
||||
});
|
||||
const second = await db.smsSignature.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, name: '【其他签名】', auditStatus: 'approved' },
|
||||
});
|
||||
const channel = await db.smsChannel.create({
|
||||
data: {
|
||||
code: 'QA-' + tag,
|
||||
name: '隔离三网通道',
|
||||
carriers: ['mobile', 'unicom', 'telecom'],
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 1,
|
||||
account: 'unused',
|
||||
passwordCipher: 'unused',
|
||||
srcId: '1069',
|
||||
},
|
||||
});
|
||||
for (const carrier of ['mobile', 'unicom', 'telecom']) {
|
||||
const group = await db.smsChannelGroup.create({
|
||||
data: {
|
||||
code: 'QA-' + tag + '-' + carrier,
|
||||
name: carrier,
|
||||
carrier,
|
||||
items: { create: { channelId: channel.id, carrier } },
|
||||
},
|
||||
});
|
||||
await db.channelRouteRule.create({
|
||||
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier },
|
||||
});
|
||||
}
|
||||
const field = await db.drainageField.create({ data: { code: 'qa_' + tag, name: '引流信息', fieldType: 'string' } });
|
||||
await db.channelReportField.create({
|
||||
data: {
|
||||
channelId: channel.id,
|
||||
drainageFieldId: field.id,
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
reportType: 'drainage',
|
||||
exportName: field.name,
|
||||
fieldType: 'string',
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
const created = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||
url: 'example.com',
|
||||
});
|
||||
if (created.status !== 200) console.error('create response', created);
|
||||
check('create', created.status === 200);
|
||||
const drainage = created.data;
|
||||
const duplicate = await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||
url: ' example.com ',
|
||||
});
|
||||
check('duplicate trimmed value returns 400', duplicate.status === 400);
|
||||
const other = await request('POST', 'enterprise-signatures/' + second.id + '/drainage-infos', { url: 'example.com' });
|
||||
check('same value in another signature allowed', other.status === 200);
|
||||
const parallel = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'parallel.example.com' }),
|
||||
),
|
||||
);
|
||||
check(
|
||||
'five concurrent creates commit once',
|
||||
parallel.filter((x) => x.status === 200).length === 1 && parallel.filter((x) => x.status === 400).length === 4,
|
||||
);
|
||||
const parallelItem = parallel.find((x) => x.status === 200).data;
|
||||
const conflicting = await request('PUT', 'drainage-infos/' + parallelItem.id, { url: 'example.com' });
|
||||
check('edit cannot collide', conflicting.status === 400);
|
||||
const editA = (
|
||||
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'a.example.net' })
|
||||
).data;
|
||||
const editB = (
|
||||
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', { url: 'b.example.net' })
|
||||
).data;
|
||||
const racingEdits = await Promise.all(
|
||||
[editA, editB].map((x) => request('PUT', 'drainage-infos/' + x.id, { url: 'race.example.net' })),
|
||||
);
|
||||
check(
|
||||
'concurrent edits commit one target',
|
||||
racingEdits.filter((x) => x.status === 200).length === 1 &&
|
||||
racingEdits.filter((x) => x.status === 400).length === 1,
|
||||
);
|
||||
await db.smsDrainageInfo.update({ where: { id: parallelItem.id }, data: { auditStatus: 'deleted' } });
|
||||
check(
|
||||
'deleted target can be reused',
|
||||
(
|
||||
await request('POST', 'enterprise-signatures/' + signature.id + '/drainage-infos', {
|
||||
url: 'parallel.example.com',
|
||||
})
|
||||
).status === 200,
|
||||
);
|
||||
const unchanged = await request('PUT', 'drainage-infos/' + drainage.id, {
|
||||
url: 'example.com',
|
||||
remark: 'unchanged value',
|
||||
});
|
||||
check('edit self allowed', unchanged.status === 200);
|
||||
check(
|
||||
'restoration cannot bypass uniqueness',
|
||||
(await request('POST', `drainage-infos/${parallelItem.id}/status`, { status: 'approved' })).status === 400,
|
||||
);
|
||||
const targets = (await request('GET', 'drainage-infos/' + drainage.id + '/report-targets')).data;
|
||||
check('one channel has three targets', targets.length === 3 && new Set(targets.map((x) => x.carrier)).size === 3);
|
||||
const states = { mobile: 'approved', unicom: 'failed', telecom: 'pending' };
|
||||
const body = {
|
||||
items: targets.map((t) => ({
|
||||
signatureId: signature.id,
|
||||
drainageItemId: drainage.id,
|
||||
reportType: 'drainage',
|
||||
channelId: channel.id,
|
||||
carrier: t.carrier,
|
||||
status: states[t.carrier],
|
||||
})),
|
||||
sourceEntry: 'enterprise_signature',
|
||||
};
|
||||
check('save three carrier states', (await request('POST', 'report-tasks/status-change', body)).status === 200);
|
||||
const rows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
|
||||
const target = {
|
||||
key: 'url:example.com',
|
||||
category: 'url',
|
||||
value: 'example.com',
|
||||
text: 'example.com',
|
||||
start: 0,
|
||||
end: 11,
|
||||
};
|
||||
check('mobile route approved', assessDrainage([target], rows, 'mobile').allowedChannelIds.includes(channel.id));
|
||||
check('unicom route denied', assessDrainage([target], rows, 'unicom').allowedChannelIds.length === 0);
|
||||
check('telecom pending denied', assessDrainage([target], rows, 'telecom').allowedChannelIds.length === 0);
|
||||
await db.channelSignatureReportTask.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
signatureId: signature.id,
|
||||
drainageItemId: drainage.id,
|
||||
channelId: channel.id,
|
||||
reportType: 'drainage',
|
||||
carrier: null,
|
||||
status: 'approved',
|
||||
},
|
||||
});
|
||||
const legacyRows = await db.smsDrainageInfo.findMany({ where: { id: drainage.id }, include: { reportTasks: true } });
|
||||
check(
|
||||
'legacy approval cannot override explicit rejection',
|
||||
assessDrainage([target], legacyRows, 'unicom').allowedChannelIds.length === 0,
|
||||
);
|
||||
const view = (await request('GET', 'enterprise-signatures/' + signature.id)).data;
|
||||
check(
|
||||
'summary follows carrier',
|
||||
view.drainageCarrierReportSummary[drainage.id].mobile.approved === 1 &&
|
||||
view.drainageCarrierReportSummary[drainage.id].unicom.approved === 0,
|
||||
);
|
||||
const preview = await batch.inspectBatchItem({
|
||||
reportType: 'drainage',
|
||||
signatureId: signature.id,
|
||||
drainageItemId: drainage.id,
|
||||
});
|
||||
check(
|
||||
'batch targets preserve carriers',
|
||||
preview.targets.length === 3 && preview.targets.every((t) => t.carrier !== 'all'),
|
||||
);
|
||||
const details = await reporting.listReportDetailsPage({
|
||||
reportType: 'drainage',
|
||||
channelId: channel.id,
|
||||
signatureId: signature.id,
|
||||
pageSize: 100,
|
||||
});
|
||||
check(
|
||||
'report details unique carrier rows',
|
||||
details.items.filter((t) => t.drainageItemId === drainage.id).length === 3,
|
||||
);
|
||||
check(
|
||||
'material update',
|
||||
(await request('PUT', 'drainage-infos/' + drainage.id, { url: 'example.com', remark: 'invalidate reports' }))
|
||||
.status === 200,
|
||||
);
|
||||
const reset = await db.channelSignatureReportTask.findMany({ where: { drainageItemId: drainage.id } });
|
||||
check(
|
||||
'material update invalidates all approvals',
|
||||
reset.every((t) => t.status !== 'approved'),
|
||||
);
|
||||
check(
|
||||
'material update resets three carrier tasks',
|
||||
reset.filter((t) => t.carrier && t.status === 'pending').length === 3,
|
||||
);
|
||||
const whitelist = ['203.0.113.1', '203.0.113.0/24', '2001:db8::1'];
|
||||
check(
|
||||
'HTTP whitelist persists',
|
||||
(
|
||||
await request('PUT', 'enterprise-applications/' + application.id + '/http-api', {
|
||||
enabled: false,
|
||||
ipAllowlist: whitelist,
|
||||
})
|
||||
).status === 200,
|
||||
);
|
||||
const config = (await request('GET', 'enterprise-applications/' + application.id + '/http-api')).data;
|
||||
check(
|
||||
'HTTP whitelist readback',
|
||||
JSON.stringify([...config.ipAllowlist].sort()) === JSON.stringify([...whitelist].sort()),
|
||||
);
|
||||
const message = await db.smsMessageRecord.create({
|
||||
data: {
|
||||
messageId: 'QA-' + tag,
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
phoneNumber: '13800001000',
|
||||
content: '【引流三网验收】只读展示',
|
||||
status: 'failed',
|
||||
},
|
||||
});
|
||||
await db.smsChannelSensitiveDecision.create({
|
||||
data: {
|
||||
messageRecordId: message.id,
|
||||
routeAttemptId: randomUUID(),
|
||||
snapshot: { hits: [], reason: null, candidateChannelIds: [], selectedChannelId: null },
|
||||
},
|
||||
});
|
||||
const fixture = {
|
||||
tag,
|
||||
tenantId: tenant.id,
|
||||
applicationId: application.id,
|
||||
signatureId: signature.id,
|
||||
drainageId: drainage.id,
|
||||
channelId: channel.id,
|
||||
messageId: message.id,
|
||||
base,
|
||||
checks,
|
||||
};
|
||||
if (process.env.DRAINAGE_TEST_EVIDENCE)
|
||||
fs.writeFileSync(process.env.DRAINAGE_TEST_EVIDENCE, JSON.stringify(fixture, null, 2));
|
||||
console.log(JSON.stringify({ passed: checks.length, checks, fixture }));
|
||||
if (process.env.DRAINAGE_TEST_KEEP_SERVER !== 'true') {
|
||||
await new Promise((r) => server.close(r));
|
||||
await db.$disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await new Promise((r) => server.close(r));
|
||||
await db.$disconnect();
|
||||
process.exitCode = 1;
|
||||
}
|
||||
process.on('SIGINT', async () => {
|
||||
await new Promise((r) => server.close(r));
|
||||
await db.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user