import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { createHmac, createHash, randomUUID } from 'node:crypto'; import { request } from 'node:http'; import fs from 'node:fs'; const require = createRequire(new URL('../../api/package.json', import.meta.url)); const dbUrl = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || ''); const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || ''); assert(['127.0.0.1', 'localhost'].includes(dbUrl.hostname) && dbUrl.pathname.startsWith('/cmpp_qa_')); assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname) && Number(redisUrl.port) > 10000); process.env.DATABASE_URL = dbUrl.toString(); process.env.REDIS_URL = redisUrl.toString(); process.env.NODE_ENV = 'test'; process.env.HTTP_API_MASTER_KEY = randomUUID(); require('reflect-metadata'); const { Module } = require('@nestjs/common'); const { NestFactory } = require('@nestjs/core'); const { PrismaService } = require('./dist/prisma/prisma.service'); const { OpenApiService } = require('./dist/open-api/open-api.service'); const { OpenApiAuthGuard } = require('./dist/open-api/open-api-auth.guard'); const { OpenApiController } = require('./dist/open-api/open-api.controller'); const { OpenApiDocsController } = require('./dist/open-api/open-api-docs.controller'); const { OpenApiTraceInterceptor } = require('./dist/open-api/open-api-trace.interceptor'); const { SecurityDetectionService } = require('./dist/security-detection/security-detection.service'); const { encryptSecret } = require('./dist/open-api/open-api.crypto'); const { configureHttpBodyParsers } = require('./dist/http-body-limits'); const db = new PrismaService(); const service = new OpenApiService(db, undefined); const checks = []; let app; const ok = (name) => { checks.push(name); console.log('PASS', name); }; try { assert.equal(await db.smsMessageRecord.count(), 0, 'Dedicated empty QA database required'); const tenant = await db.tenant.create({ data: { name: '签名隔离验收', code: randomUUID() } }); const createApp = () => db.smsApplication.create({ data: { tenantId: tenant.id, name: '签名隔离应用', cmppAccount: randomUUID(), cmppEnterpriseCode: '000001', secretHash: 'not-login', interfaceEnabled: false, httpConfig: { create: { enabled: true, sendEnabled: true, qpsLimit: 100 } }, }, }); const own = await createApp(); const other = await createApp(); const secret = randomUUID(); const accessKey = randomUUID(); const credential = await db.httpApiCredential.create({ data: { applicationId: own.id, name: '签名测试', accessKey, secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4), }, }); const channel = await db.smsChannel.create({ data: { name: '隔离占位', code: randomUUID(), gatewayHost: '127.0.0.1', gatewayPort: 1, account: 'none', passwordCipher: 'none', srcId: 'none', status: 'disabled', carriers: ['mobile'], }, }); const uplink = await db.smsUplinkMessage.create({ data: { tenantId: tenant.id, applicationId: own.id, channelId: channel.id, phoneNumber: '13800138000', destId: '10690000', content: '签名验收', receivedAt: new Date(), matchStatus: 'matched', }, }); const foreign = await db.smsUplinkMessage.create({ data: { tenantId: tenant.id, applicationId: other.id, channelId: channel.id, phoneNumber: '13800138000', destId: '10690000', content: '其他应用', receivedAt: new Date(), matchStatus: 'matched', }, }); class Harness {} Module({ controllers: [OpenApiController, OpenApiDocsController], providers: [ { provide: PrismaService, useValue: db }, { provide: OpenApiService, useValue: service }, { provide: SecurityDetectionService, useValue: { recordEvent: async () => {} } }, OpenApiAuthGuard, OpenApiTraceInterceptor, ], })(Harness); app = await NestFactory.create(Harness, { logger: false, rawBody: true, bodyParser: false }); app.setGlobalPrefix('api'); configureHttpBodyParsers(app); await app.listen(16426, '127.0.0.1'); const invoke = async (path, options = {}) => { const method = options.method || 'GET'; const timestamp = options.timestamp || String(Math.floor(Date.now() / 1000)); const nonce = options.nonce || randomUUID(); const body = options.body; const fields = [method, path.split('?')[0], timestamp, nonce]; let bytes = Buffer.from(fields.join(options.separator || '\n')); if (options.legacy) bytes = Buffer.from( [ ...fields, createHash('sha256') .update(body || '{}') .digest('hex'), ].join('\n'), ); else if (method === 'POST') bytes = Buffer.concat([bytes, Buffer.from('\n'), Buffer.from(options.signedBody ?? body ?? '')]); else if (options.trailing) bytes = Buffer.concat([bytes, Buffer.from('\n')]); const signature = createHmac('sha256', secret).update(bytes).digest('hex'); const headers = { 'X-App-Key': options.key || accessKey, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, ...options.headers, }; if (body !== undefined) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(body)); } return new Promise((resolve, reject) => { const req = request('http://127.0.0.1:16426' + path, { method, headers, timeout: 10000 }, (res) => { let data = ''; res.on('data', (x) => (data += x)); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) })); }); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end(body); }); }; const list = '/api/openapi/v1/sms/uplinks'; let r = await invoke(list); assert.equal(r.status, 200); assert.deepEqual( r.body.items.map((x) => x.id), [uplink.id], ); ok('new GET authenticates and queries actual PostgreSQL with app isolation'); r = await invoke(list + '/' + uplink.id); assert.equal(r.body.content, uplink.content); assert(!('channelId' in r.body)); ok('detail matches database and hides channel fields'); assert.equal((await invoke(list + '/' + foreign.id)).status, 404); ok('foreign application detail excluded'); for (const options of [{ legacy: true }, { trailing: true }, { separator: '\r\n' }, { separator: '\\n' }]) { r = await invoke(list, options); assert.equal(r.status, 401); assert.equal(r.body.code, 'SIGNATURE_INVALID'); } ok('legacy GET, trailing LF, CRLF and literal escape rejected'); const nonce = randomUUID(); assert.equal((await invoke(list, { nonce })).status, 200); assert.equal((await invoke(list, { nonce })).body.code, 'NONCE_REPLAYED'); ok('real Redis atomic nonce replay rejection'); assert.equal((await invoke(list, { timestamp: '100' })).body.code, 'TIMESTAMP_EXPIRED'); ok('expired timestamp rejected'); assert.equal((await invoke(list, { key: 'unknown' })).body.code, 'CREDENTIAL_INVALID'); ok('unknown credential rejected'); assert.equal((await invoke(list + '?limit=1.5')).body.code, 'LIMIT_INVALID'); ok('signed invalid query reaches parameter validation'); assert.equal((await invoke(list, { body: '{}' })).status, 400); ok('nonempty GET body rejected'); const post = '/api/openapi/v1/sms/messages'; const raw = '{ "mobile": "invalid", "content": "中文测试" }'; r = await invoke(post, { method: 'POST', body: raw }); assert.equal(r.body.code, 'PARAMETER_INVALID'); ok('new POST original UTF8 body authenticates before safe validation rejection'); for (const options of [ { legacy: true }, { signedBody: raw.trim() + '\n' }, { signedBody: JSON.stringify(JSON.parse(raw)) }, ]) { r = await invoke(post, { method: 'POST', body: raw, ...options }); assert.equal(r.body.code, 'SIGNATURE_INVALID'); } ok('legacy POST, changed whitespace and trailing newline rejected'); r = await invoke(post, { method: 'POST' }); assert.equal(r.status, 400); ok('missing raw POST body rejected'); // Stored uncertain request proves internal idempotency remains; never create an SMS. const valid = '{"mobile":"13800138000","content":"仅幂等核验"}', idem = 'qa-' + randomUUID(); await db.openApiRequest.create({ data: { applicationId: own.id, tenantId: tenant.id, requestId: randomUUID(), idempotencyKey: idem, credentialId: credential.id, bodyHash: createHash('sha256').update(valid).digest('hex'), status: 'requires_review', }, }); r = await invoke(post, { method: 'POST', body: valid, headers: { 'Idempotency-Key': idem } }); assert.equal(r.body.code, 'REQUEST_REQUIRES_REVIEW'); r = await invoke(post, { method: 'POST', body: valid + ' ', headers: { 'Idempotency-Key': idem } }); assert.equal(r.body.code, 'IDEMPOTENCY_CONFLICT'); ok('existing idempotency fingerprint preserved without requeue'); const last = await db.httpApiCredential.findUnique({ where: { id: credential.id } }); assert(last.lastUsedAt); ok('actual credential usage persisted'); assert.equal(await db.smsMessageRecord.count(), 0); assert.equal(await db.smsBatchTask.count(), 0); ok('zero SMS records or batches created'); const md = await (await fetch('http://127.0.0.1:16426/api/client-docs?format=md')).text(); assert.equal(md, fs.readFileSync(new URL('../../docs/client-http-api-guide.md', import.meta.url), 'utf8')); ok('actual docs download equals authoritative guide'); if (process.env.SIGNATURE_TEST_KEEP_OPEN === '1') { console.log('READY_BROWSER'); await new Promise((resolve) => { process.on('SIGINT', resolve); process.on('SIGTERM', resolve); }); } } finally { if (process.env.SIGNATURE_TEST_REPORT) fs.writeFileSync(process.env.SIGNATURE_TEST_REPORT, JSON.stringify({ checks }, null, 2)); await app?.close(); await db.$disconnect(); }