Files
lislgosms/tools/testing/verify-signature-uniqueness.mjs
T

251 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(
['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_unique_'),
'isolated local database required',
);
const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname), 'isolated local Redis required');
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redisUrl.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
});
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SmsConfigService } = require('./dist/sms-config/sms-config.service');
const { SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service');
const { ReportImportReviewService } = require('./dist/report-materials/import-review.service');
const { FilesService } = require('./dist/files/files.service');
const { ReportImportParserService } = require('./dist/report-materials/import-parser.service');
const { SignatureNameConflict } = require('./dist/sms-config/signature-uniqueness');
const { Client } = require('pg');
const pass = (name) => console.log('PASS', name);
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
try {
const db = app.get(PrismaService),
sms = app.get(SmsConfigService);
assert.equal(await db.smsSignature.count(), 0, 'fresh database required; do not delete existing data');
await app.listen(0, '127.0.0.1');
const base = (await app.getUrl()) + '/api';
const stamp = randomUUID().slice(0, 8);
const tenant = await db.tenant.create({ data: { name: '签名唯一性隔离企业', code: stamp } });
const other = await db.tenant.create({ data: { name: '另一个隔离企业', code: stamp + 'b' } });
const makeApplication = (n) =>
db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '隔离应用' + n,
cmppAccount: stamp + n,
cmppEnterpriseCode: 'test',
secretHash: 'unused',
interfaceEnabled: false,
},
});
const a = await makeApplication('a'),
b = await makeApplication('b');
const users = app.get(UsersService),
sessions = app.get(SessionService);
const admin = await users.create({
username: stamp,
email: stamp + '@example.invalid',
displayName: '隔离审核员',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const client = await users.create({
username: stamp + 'c',
email: stamp + 'c@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
async function headersFor(user, portal) {
const session = await sessions.create(user.id, portal, 0);
return { 'content-type': 'application/json', cookie: `${sessions.cookieName(portal)}=${session.token}` };
}
const adminHeaders = await headersFor(admin, 'admin'),
clientHeaders = await headersFor(client, 'client');
const body = { tenantId: tenant.id, applicationId: a.id, name: '【唯一验证】' };
const request = (path, data, headers = adminHeaders, method = 'POST') =>
fetch(base + path, { method, headers, body: JSON.stringify(data) });
const create = (data, headers) => request('/admin/enterprise-signatures', data, headers);
assert.equal((await create(body, {})).status, 401);
const first = await create(body);
assert.equal(first.status, 201);
const signature = await first.json();
const duplicate = await create(body);
assert.equal(duplicate.status, 409);
assert.match((await duplicate.json()).message, /同名有效签名/);
const clientBody = { applicationId: a.id, name: body.name };
assert.equal((await request('/client/signatures', clientBody, clientHeaders)).status, 409);
assert.equal(
(await request('/client/signatures', clientBody, { ...clientHeaders, 'x-tenant-id': other.id })).status,
403,
);
assert.equal((await request('/client/signatures', { name: '无括号' }, clientHeaders)).status, 400);
pass('admin/client HTTP duplicate 409, authentication, tenant header isolation and format validation');
const unbound = await sms.createSignature({ tenantId: tenant.id, name: body.name });
await assert.rejects(() => sms.createSignature({ tenantId: tenant.id, name: body.name }), SignatureNameConflict);
const secondApp = await sms.createSignature({ ...body, applicationId: b.id });
await sms.createSignature({ tenantId: other.id, name: body.name });
await sms.updateSignature(signature.id, { name: body.name, purpose: '保留用途' });
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: a.id }), SignatureNameConflict);
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: null }), SignatureNameConflict);
const renamed = await sms.createSignature({ ...body, name: '【另一个名称】' });
assert.equal(
(await request('/admin/enterprise-signatures/' + renamed.id, { name: body.name }, adminHeaders, 'PUT')).status,
409,
);
await assert.rejects(
() => sms.updateSignature(signature.id, { purpose: '禁止跨租户' }, other.id),
/Signature not found/,
);
pass('self edit, rename, application change, null application scope and cross-tenant separation');
await sms.changeSignatureStatus(unbound.id, { status: 'disabled' });
const replacement = await sms.createSignature({ tenantId: tenant.id, name: body.name });
for (const restore of [
() => sms.changeSignatureStatus(unbound.id, { status: 'approved' }),
() => sms.approveSignature(unbound.id, { reviewerId: admin.id }),
() => sms.submitSignature(unbound.id),
]) {
await assert.rejects(restore, SignatureNameConflict);
}
await sms.changeSignatureStatus(replacement.id, { status: 'deleted' });
await sms.changeSignatureStatus(unbound.id, { status: 'approved' });
pass('disable/delete release names; status restoration, review and submit cannot bypass uniqueness');
const races = await Promise.all(Array.from({ length: 12 }, () => create({ ...body, name: '【并发名称】' })));
assert.equal(races.filter((r) => r.status === 201).length, 1);
assert.equal(races.filter((r) => r.status === 409).length, 11);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发名称】' } }),
1,
);
for (const applicationId of [a.id, null]) {
await assert.rejects(
() => db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name: body.name } }),
(e) => e.code === 'P2002',
);
}
pass('12 concurrent real HTTP creates produce one row; both database indexes reject direct writes');
const files = app.get(FilesService),
importer = new ReportImportReviewService(db, files, sms, new ReportImportParserService(db, files, sms));
await sms.updateSignature(signature.id, {
drainageInfo: {
signatureReportValues: { old: '必须保留', changed: '旧值' },
links: [{ url: 'https://example.invalid' }],
},
});
const mappings = [
{
sourceHeader: '签名',
sourceColumnIndex: 0,
targetFieldCode: 'name',
targetKind: 'signatureName',
fieldType: 'string',
},
{
sourceHeader: '新资料',
sourceColumnIndex: 1,
targetFieldCode: 'changed',
targetKind: 'dynamic',
fieldType: 'string',
},
];
const staged = await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name, changed: '新值' });
assert.equal(staged.operation, 'update');
assert.equal(staged.targetId, signature.id);
const batch = { tenantId: tenant.id, applicationId: a.id, reportType: 'signature' };
const item = { reportType: 'signature', targetId: staged.targetId, payload: staged.payload };
assert.equal(await importer.applyImportItem(batch, item, admin.id), signature.id);
const saved = await db.smsSignature.findUniqueOrThrow({ where: { id: signature.id } });
assert.equal(saved.purpose, '保留用途');
assert.equal(saved.drainageInfo.signatureReportValues.old, '必须保留');
assert.equal(saved.drainageInfo.signatureReportValues.changed, '新值');
assert.equal(saved.drainageInfo.links.length, 1);
assert.equal(await importer.applyImportItem(batch, { ...item, targetId: null }, admin.id), signature.id);
// A stopped historical duplicate must not be chosen over the effective signature.
await db.smsSignature.create({ data: { ...body, auditStatus: 'disabled' } });
assert.equal(
(await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name })).targetId,
signature.id,
);
pass('batch staging/apply preserves ID, unmapped materials, purpose and links; delayed create becomes update');
const concurrentItem = { ...item, targetId: null, payload: { ...item.payload, name: '【并发导入】' } };
const importedIds = await Promise.all(
Array.from({ length: 3 }, () => importer.applyImportItem(batch, concurrentItem, admin.id)),
);
assert.equal(new Set(importedIds).size, 1);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发导入】' } }),
1,
);
const { writeUniqueSignature } = require('./dist/sms-config/signature-uniqueness');
const raceIdentity = { ...body, name: '【数据库竞态】' };
await assert.rejects(
() =>
writeUniqueSignature(db, raceIdentity, async () => {
await db.smsSignature.create({ data: raceIdentity });
return db.smsSignature.create({ data: raceIdentity });
}),
SignatureNameConflict,
);
pass('concurrent batch imports converge to one signature and real P2002 maps to HTTP conflict');
// Exercise migration failure on a session-local shadow table, never modify application history.
const pg = new Client({ connectionString: url.toString() });
await pg.connect();
try {
await pg.query(
'CREATE TEMP TABLE "SmsSignature" ("tenantId" text, "applicationId" text, "name" text, "auditStatus" text)',
);
await pg.query(
`INSERT INTO "SmsSignature" VALUES ('history', NULL, 'duplicate', 'approved'), ('history', NULL, 'duplicate', 'pending')`,
);
const sql = readFileSync(
new URL('../../api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql', import.meta.url),
'utf8',
);
await assert.rejects(() => pg.query(sql), /duplicate active signatures exist/);
await pg.query('ROLLBACK');
assert.equal((await pg.query('SELECT count(*) FROM "SmsSignature"')).rows[0].count, '2');
assert.equal(
(await pg.query("SELECT count(*) FROM pg_indexes WHERE schemaname LIKE 'pg_temp_%' AND tablename='SmsSignature'"))
.rows[0].count,
'0',
);
} finally {
await pg.end();
}
pass('historical duplicates block migration and roll back without modifying records');
console.log('Signature uniqueness acceptance complete; no SMS, external notifications or live environment writes.');
} finally {
await app.close();
}