Files
lislgosms/tools/testing/verify-http-signature.mjs
2026-09-15 14:58:31 +08:00

247 lines
10 KiB
JavaScript

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();
}