feat: simplify HTTP request signing and publish revised client guide
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user