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:
@@ -58,7 +58,8 @@ export function renderHttpGuide(markdown: string, origin: string) {
|
||||
continue;
|
||||
}
|
||||
closeTable();
|
||||
if (line.trim().endsWith(':') && line.trim().length < 70) sampleTitle = line.trim().replace(/:$/, '');
|
||||
const caption = line.trim().replace(/^\*\*(.+)\*\*$/, '$1');
|
||||
if (caption.endsWith(':') && caption.length < 70) sampleTitle = caption.replace(/:$/, '');
|
||||
if (line.trim() && !/^---+$/.test(line)) current.body.push(`<p>${inline(line.replace(/^>\s?/, '').replace(/^- /, '• '))}</p>`);
|
||||
}
|
||||
closeTable();
|
||||
|
||||
@@ -15,7 +15,7 @@ import IORedis from 'ioredis';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { decryptSecret } from './open-api.crypto';
|
||||
import type { OpenApiRequestLike } from './open-api.types';
|
||||
import { openApiBodyHash, openApiSignature, publicOpenApiFailure } from './open-api.protocol';
|
||||
import { openApiSignature, publicOpenApiFailure } from './open-api.protocol';
|
||||
import { ProtocolLogsService } from '../protocol-logs/protocol-logs.service';
|
||||
import { SecurityDetectionService } from '../security-detection/security-detection.service';
|
||||
|
||||
@@ -97,14 +97,13 @@ export class OpenApiAuthGuard implements CanActivate, OnModuleDestroy {
|
||||
throw new ForbiddenException({ code: 'IP_NOT_ALLOWED', message: '当前IP不在HTTP接口白名单中' });
|
||||
}
|
||||
const path = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
||||
const bodyHash = openApiBodyHash(request.rawBody, request.body);
|
||||
const expected = openApiSignature(
|
||||
decryptSecret(credential.secretEncrypted),
|
||||
request.method,
|
||||
path,
|
||||
timestampText,
|
||||
nonce,
|
||||
bodyHash,
|
||||
request.rawBody,
|
||||
);
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
const suppliedBuffer = /^[0-9a-f]{64}$/i.test(suppliedSignature)
|
||||
|
||||
@@ -20,37 +20,23 @@ describe('HTTP API remediation boundaries', () => {
|
||||
'/api/openapi/v1/sms/uplinks',
|
||||
'1789344000',
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
openApiBodyHash(undefined, undefined),
|
||||
undefined,
|
||||
),
|
||||
).toBe('f551ad48ea2a16762b0144f0f0d6e9110c1732adc003fcb94658e5333116eb65');
|
||||
).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
|
||||
});
|
||||
it('keeps GET absent-body compatibility and signs exact POST UTF8 bytes', () => {
|
||||
it('preserves internal idempotency hashes but signs exact POST UTF8 bytes', () => {
|
||||
expect(openApiBodyHash(undefined, undefined)).toBe(
|
||||
'44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a',
|
||||
);
|
||||
const raw = Buffer.from('{ "content": "中文\\n正文" }');
|
||||
expect(openApiBodyHash(raw, {})).toBe(createHash('sha256').update(raw).digest('hex'));
|
||||
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', openApiBodyHash(raw, {})].join('\n');
|
||||
const source = ['POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw.toString('utf8')].join('\n');
|
||||
expect(
|
||||
openApiSignature(
|
||||
'offline-secret',
|
||||
'post',
|
||||
'/api/openapi/v1/sms/messages?ignored=1',
|
||||
'123',
|
||||
'nonce-0001',
|
||||
openApiBodyHash(raw, {}),
|
||||
),
|
||||
openApiSignature('offline-secret', 'post', '/api/openapi/v1/sms/messages?ignored=1', '123', 'nonce-0001', raw),
|
||||
).toBe(createHmac('sha256', 'offline-secret').update(source).digest('hex'));
|
||||
for (const separator of ['\r\n', '\\n'])
|
||||
expect(createHmac('sha256', 'offline-secret').update(source.split('\n').join(separator)).digest('hex')).not.toBe(
|
||||
openApiSignature(
|
||||
'offline-secret',
|
||||
'POST',
|
||||
'/api/openapi/v1/sms/messages',
|
||||
'123',
|
||||
'nonce-0001',
|
||||
openApiBodyHash(raw, {}),
|
||||
),
|
||||
openApiSignature('offline-secret', 'POST', '/api/openapi/v1/sms/messages', '123', 'nonce-0001', raw),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,11 +155,11 @@ describe('HTTP API remediation boundaries', () => {
|
||||
});
|
||||
it('keeps named code examples beside their source paragraphs', () => {
|
||||
const html = renderHttpGuide(
|
||||
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n五行原文:\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
|
||||
'**接口版本:v1 · 2026-09-14**\n## 鉴权\n### 签名原文\n**签名原文:**\n```text\nMETHOD\nPATH\n```\n后续说明\n### 回执\n```json\n{}\n```',
|
||||
'',
|
||||
);
|
||||
expect(html.indexOf('sample-1')).toBeLessThan(html.indexOf('后续说明'));
|
||||
expect(html).toContain('五行原文 · text');
|
||||
expect(html).toContain('签名原文 · text');
|
||||
expect(html).not.toContain('data-show-sample');
|
||||
expect(html).not.toContain('<aside');
|
||||
expect(html).not.toMatch(/>示例 \d+</);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { openApiSignature } from './open-api.protocol';
|
||||
|
||||
describe('raw-body request signing contract', () => {
|
||||
const path = '/api/openapi/v1/sms/messages';
|
||||
const nonce = '7921b5d1-3b99-48d4-a068-ea7cf0c998db';
|
||||
const body = Buffer.from(
|
||||
'{"mobile":"13800138000","content":"【示例签名】您的验证码是123456,5分钟内有效。","clientMessageId":"doc-example-20260914-0001"}',
|
||||
);
|
||||
const secret = 'DEMO_SECRET_NOT_A_REAL_CREDENTIAL';
|
||||
const sign = (raw: Buffer) => openApiSignature(secret, 'POST', path, '1789355443', nonce, raw);
|
||||
|
||||
it('matches the independently computed published POST vector', () => {
|
||||
expect(sign(body)).toBe('a951451624d37d3e9df24045dc65d94557551dcbeada26988e25b6d49e945124');
|
||||
});
|
||||
it('does not accept legacy body digests or changed body bytes', () => {
|
||||
const legacy = createHmac('sha256', secret)
|
||||
.update(['POST', path, '1789355443', nonce, createHash('sha256').update(body).digest('hex')].join('\n'))
|
||||
.digest('hex');
|
||||
expect(sign(body)).not.toBe(legacy);
|
||||
for (const changed of [
|
||||
Buffer.concat([body, Buffer.from('\n')]),
|
||||
Buffer.from(JSON.stringify(JSON.parse(body.toString()), null, 2)),
|
||||
Buffer.from(body.toString().replace('123456', '654321')),
|
||||
]) {
|
||||
expect(sign(changed)).not.toBe(sign(body));
|
||||
}
|
||||
});
|
||||
it('rejects missing POST raw bytes instead of reconstructing JSON', () => {
|
||||
expect(() => openApiSignature(secret, 'POST', path, '123', nonce)).toThrow('缺少原始请求体');
|
||||
});
|
||||
it('rejects nonempty GET bodies and distinguishes a trailing LF', () => {
|
||||
const fields = ['GET', '/api/openapi/v1/sms/uplinks', '123', nonce];
|
||||
const actual = openApiSignature(secret, fields[0], fields[1], fields[2], fields[3]);
|
||||
expect(actual).toBe(createHmac('sha256', secret).update(fields.join('\n')).digest('hex'));
|
||||
expect(actual).not.toBe(
|
||||
createHmac('sha256', secret)
|
||||
.update(fields.join('\n') + '\n')
|
||||
.digest('hex'),
|
||||
);
|
||||
expect(() => openApiSignature(secret, 'GET', path, '123', nonce, Buffer.from('{}'))).toThrow('GET请求不得携带正文');
|
||||
});
|
||||
it('executes both handbook examples and verifies every complete request packet', () => {
|
||||
const guide = readFileSync(resolve(__dirname, '../../../docs/client-http-api-guide.md'), 'utf8').replace(
|
||||
/\r\n/g,
|
||||
'\n',
|
||||
);
|
||||
expect(guide).toContain('### 1.4 怎样使用后面的 cURL 示例');
|
||||
expect(guide).not.toContain('### 2.4');
|
||||
const scripts = [...guide.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
|
||||
expect(scripts).toHaveLength(2);
|
||||
for (const script of scripts) {
|
||||
const outputs: string[] = [];
|
||||
runInNewContext(script[1], {
|
||||
Buffer,
|
||||
require: () => ({ createHmac }),
|
||||
console: { log: (value: string) => outputs.push(value) },
|
||||
});
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(guide).toContain(outputs[0]);
|
||||
}
|
||||
const packets = [...guide.matchAll(/```http\n((?:GET|POST) \/api\/openapi\/[\s\S]*?)\n```/g)];
|
||||
expect(packets).toHaveLength(5);
|
||||
for (const [, packet] of packets) {
|
||||
const split = packet.indexOf('\n\n');
|
||||
const headers = packet.slice(0, split);
|
||||
const [method, url] = headers.split('\n')[0].split(' ');
|
||||
const header = (name: string) => headers.match(new RegExp('^' + name + ': (.+)$', 'm'))![1];
|
||||
const raw = method === 'POST' ? Buffer.from(packet.slice(split + 2)) : undefined;
|
||||
if (raw) expect(raw.length).toBe(Number(header('Content-Length')));
|
||||
expect(openApiSignature(secret, method, url, header('X-Timestamp'), header('X-Nonce'), raw)).toBe(
|
||||
header('X-Signature'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,12 @@ import {
|
||||
@ApiHeader({ name: 'X-App-Key', required: true })
|
||||
@ApiHeader({ name: 'X-Timestamp', required: true })
|
||||
@ApiHeader({ name: 'X-Nonce', required: true })
|
||||
@ApiHeader({ name: 'X-Signature', required: true })
|
||||
@ApiHeader({
|
||||
name: 'X-Signature',
|
||||
required: true,
|
||||
description:
|
||||
'HMAC-SHA256小写十六进制。方法、路径(不含query)、时间戳、nonce以LF分隔;GET末尾无LF,POST追加LF及原始UTF-8正文,不计算正文摘要。',
|
||||
})
|
||||
@ApiResponse({ status: 400, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 401, type: OpenApiProblemDto })
|
||||
@ApiResponse({ status: 403, type: OpenApiProblemDto })
|
||||
|
||||
@@ -64,7 +64,7 @@ export function sendOpenApiProblem(
|
||||
});
|
||||
}
|
||||
|
||||
/** v1 compatibility: an absent parsed body hashes as {}, never try alternate hashes. */
|
||||
/** Internal idempotency fingerprint; this digest is not part of request authentication. */
|
||||
export function openApiBodyHash(rawBody: Buffer | undefined, body: unknown) {
|
||||
return createHash('sha256')
|
||||
.update(rawBody ?? Buffer.from(JSON.stringify(body ?? {})))
|
||||
@@ -77,11 +77,21 @@ export function openApiSignature(
|
||||
path: string,
|
||||
timestamp: string,
|
||||
nonce: string,
|
||||
bodyHash: string,
|
||||
rawBody?: Buffer,
|
||||
) {
|
||||
return createHmac('sha256', secret)
|
||||
.update([method.toUpperCase(), path.split('?')[0], timestamp, nonce, bodyHash].join('\n'))
|
||||
.digest('hex');
|
||||
const verb = method.toUpperCase();
|
||||
if (verb === 'GET' && rawBody?.length) {
|
||||
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: 'GET请求不得携带正文' });
|
||||
}
|
||||
if (verb !== 'GET' && !rawBody) {
|
||||
throw new BadRequestException({ code: 'PARAMETER_INVALID', message: '缺少原始请求体' });
|
||||
}
|
||||
const signature = createHmac('sha256', secret).update(
|
||||
[verb, path.split('?')[0], timestamp, nonce].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
if (verb !== 'GET') signature.update('\n').update(rawBody!);
|
||||
return signature.digest('hex');
|
||||
}
|
||||
|
||||
export function publicOpenApiFailure(error: unknown) {
|
||||
|
||||
+605
-819
File diff suppressed because it is too large
Load Diff
@@ -2307,3 +2307,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
||||
## 2026-09-14 客户端页面与文档调整
|
||||
|
||||
模板正文统一高度并纵向滚动,添加按钮按内容宽度,删除采用公共风险确认按钮;表单顺序为应用、模板名称、签名、内容,不再展示模板分类,历史分类不清空。接口文档使用独立客户端页面,所有示例紧随对应说明并按用途命名;工作台最近批次显示短信正文和统一中文状态;发送详情必须返回并显示真实回执状态与北京时间,未收到回执不伪造数据。设计与兼容边界见 [客户端整改设计](client-ui-remediation-20260914.md)。
|
||||
|
||||
|
||||
## 2026-09-15 HTTP 客户手册与签名简化
|
||||
|
||||
按用户确认的 B6 手册实施,原2.4移为1.4。POST直接签原始UTF-8正文,GET四项LF分隔且末尾无LF;不兼容尝试旧摘要签名。保持凭据、权限、时间窗、nonce去重、内部发送幂等及Webhook规则。公开页、客户端独立文档页与MD下载共用权威手册,Node示例和完整HTTP报文按新规则验证。新规则替代此前三步正文摘要签名需求,适用本次代码版本;仅授权测试部署,预生产不变。设计及兼容影响见[HTTP整改方案](http-api-assessment-20260910.md)的2026-09-15章节。
|
||||
|
||||
@@ -305,3 +305,15 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
||||
截至 2026-09-14T07:49:14.156Z,测试环境精确版本97d133442350b9725422ed4e55386f47e37004fd。HTTP-FULL-B01至B04已修复、提交、推送、标准发布并真实复验;74套809项精确候选测试、格式、Lint、类型、构建及安全门禁通过。235项真实请求/断言中229项通过,另6条原始非通过记录已分类并有复测,不删除失败历史。20条短信19送达/1预期失败退款,23个模拟CMPP Submit,21成功计费单位,净扣6825,余额1848101→1841276。7条上行3歧义隐藏/4匹配且ACK完成;24个Webhook事件22送达/2预设终止,31次真实HTTPS收件,签名、密钥轮换、状态码、退避、超时和人工重试均核验。三个Redis Stream pending/lag均0;本轮待办排空、三个应用停用/凭据撤销、receiver及隧道关闭、hosts原字节恢复。未操作预生产、真实运营商或其他客户配置。
|
||||
|
||||
完整矩阵、根因、发布恢复资产/容量与未执行项见 [HTTP全量验收报告](http-api-full-acceptance-20260914.md)。本段更新此前阶段性“待修/待授权/阻塞”状态,不将其当当前状态。
|
||||
|
||||
|
||||
## 2026-09-15 已确认的新请求签名规则(实施中)
|
||||
|
||||
本节按用户最终 B6 手册及本轮实施授权,替代前述 R01/3.2 的摘要签名保留要求;历史日期的实现证据仍保留。本轮公开手册采用 B6 内容,将原 2.4 cURL 使用说明移到 1.4;公开阅读器、客户端 iframe 和 MD 下载继续共用 docs/client-http-api-guide.md。
|
||||
|
||||
- POST:UTF-8 编码的方法、路径、timestamp、nonce 以 LF 分隔,nonce 后一个 LF,再原样追加 rawBody 字节,直接 HMAC-SHA256。禁止对解析后 JSON 重建正文;POST 缺失 rawBody 时拒绝,避免签署与收到的字节不一致。
|
||||
- GET:仅四项以 LF 分隔,末尾无 LF,无正文;携带非空正文拒绝。query 仍不参与签名,路径仍不含域名及 query,不新增编码/排序协议。
|
||||
- 使用原四个鉴权头、路径和凭据;不自动尝试旧摘要算法,不引入隐式双算法。旧签名返回 SIGNATURE_INVALID,既有接入须同步升级。测试环境本轮发布,预生产不发布;回退应用同时恢复原手册,客户须恢复匹配算法。
|
||||
- 时间戳窗口、nonce Redis 原子去重、租户/应用隔离、白名单、QPS 和日志脱敏保持。发送幂等使用的内部 bodyHash 保持,已有请求快照/计费/消息/队列均不迁移、不补投。Webhook 验签不变。
|
||||
- 数据模型、基础设施配置和 Gateway 无改动。历史查询 startTime/endTime 能力保留;手册简化介绍不代表删除后台兼容参数。
|
||||
- 验收:固定 POST/GET 向量、旧签名拒绝、末尾 LF/CRLF/字面反斜杠/正文空格与字段顺序篡改、缺失 rawBody、非空 GET、重放、权限与隔离;真实 HTTP/PG/Redis 验证以隔离数据和无发送请求完成,线上不创建短信或更改客户配置。两段 Node 示例从权威文档提取运行并与全部 HTTP 报文复算;公开页面三尺寸、目录/检索/下载及客户端共享入口验收。按精确提交标准 validate/preflight/prepare/deploy/verify。
|
||||
|
||||
@@ -5526,3 +5526,18 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
||||
### CLIENT-0914 系列执行结果(2026-09-14 23:25 CST)
|
||||
|
||||
CLIENT-0914-01~07 的模板样式/顺序、文档归属与检索、中文状态/短信正文、回执聚合字段与时区已在独立真实 PostgreSQL/服务组件环境验证;测试部署应用为 4665079。在线文档三尺寸、接口内容和真实数据库查询回执已复核通过;HTTP 环境剪贴板自动复制受限时选中示例并提示手动复制。线上登录后模板/工作台/短信详情全流程因未取得客户端账号仍未执行,不以本地隔离组件或公开文档页面替代。详细日志、首轮 worker 超时与完整复测结果见 [整改记录](client-ui-remediation-20260914.md)。
|
||||
|
||||
|
||||
## 2026-09-15 HTTP 签名简化验收
|
||||
|
||||
本组替代现行版本的旧GET{}摘要/三步签名断言,历史执行记录不改写。
|
||||
|
||||
| 用例 | 验收内容 |
|
||||
| --- | --- |
|
||||
| HTTP-SIGN-0915-01 | 手册2.2 POST原始字节HMAC与固定向量一致;2.3 GET四项以LF分隔、末尾无LF;两段Node示例实际运行,五个完整HTTP报文签名复算一致。 |
|
||||
| HTTP-SIGN-0915-02 | 真实HTTP鉴权拒绝旧摘要签名、GET末尾LF、CRLF和字面反斜杠n;POST正文空格/末尾换行/内容篡改拒绝。 |
|
||||
| HTTP-SIGN-0915-03 | POST无rawBody、GET非空正文返回400;不从解析后JSON重建签名字节。 |
|
||||
| HTTP-SIGN-0915-04 | 真实PG查询仅当前应用资料,其他应用详情404;Redis nonce重放401、过期timestamp及无效凭据401。 |
|
||||
| HTTP-SIGN-0915-05 | 同幂等键/同正文仍读取既有requires_review;改正文409冲突;无消息、批次或队列新增,不恢复未知发送。 |
|
||||
| HTTP-SIGN-0915-06 | 原2.4移到1.4并修正引用;MD下载与源一致,三尺寸目录/刷新/检索/空结果/复制正常,加粗标题成为具名示例;不执行示例请求。 |
|
||||
| HTTP-SIGN-0915-07 | 精确提交测试发布后核对真实文档及新签名查询;旧算法拒绝。账号/凭据缺失时不恢复或新建线上配置,明确未执行范围。 |
|
||||
|
||||
@@ -5018,3 +5018,14 @@ HTTP-FULL-B02:去除URL IPv6方括号后区分IP字面量与DNS,DNS失败转
|
||||
三条真实测试消息的数据库回执保持不变,已部署客户端分页查询服务已由 null 恢复返回 delivered 和真实时间。线上文档三尺寸/43 内嵌示例/检索通过;HTTP 剪贴板受限时实际提供选中后手动复制。当前版本、服务、迁移、队列和日志检查通过无警告。完整登录后客户端页面仍待可用账号;连接器失败不等于用户未登录,本地真实 PG/服务组件验收不替代线上登录验收。
|
||||
|
||||
计划 20260914T151450-4665079ca3d2-874a39f8,备份 attempt-1789399125391792291。远端准备 61.7 秒、备份 34.5 秒、停止开始至启动完成约 13.4 秒。系统盘净增约 1.46 GiB、可用约 17.21 GiB,历史资产未清理。设计、详细证据及未验证项见 [客户端页面与接口文档整改](client-ui-remediation-20260914.md)。预生产未部署;未发送短信或修改业务配置。
|
||||
|
||||
|
||||
## 2026-09-15 HTTP 签名简化实施与测试发布(进行中)
|
||||
|
||||
起点main/实际远端bcb278be29857b73ec11e04650923f473875ca9b,暂存空,53个已有文件保护摘要在.local-data/http-signature-20260915/protected-hashes.json。测试机SSH初期超时、Tailscale relay可达,后续恢复并回读线上4665079ca3d2f985c85c5c1d08f8fe173047e9bd;sudo仍需掩码输入。
|
||||
|
||||
按B6实施POST原始正文HMAC、GET末尾无LF,保留内部幂等摘要和Webhook规则;旧签名不回退尝试。原2.4移1.4、公开/客户端共用MD,补OpenAPI签名描述。页面只读复现加粗示例标题未识别,reader支持加粗标题。相关设计先更新于http-api-assessment-20260910.md。
|
||||
|
||||
定向31项、初轮API全量75套817项及构建通过。独立本地PG16424/cmpp_qa_http_signature(104迁移)、Redis16425、真实Nest16426验收16组通过,包含查询、隔离、篡改、旧算法、重放、幂等及MD下载,短信/批次数均0。仅安全辅助日志隔离为空实现,不将日志持久化标为验收。未启动发送或回调Worker;Redis5.0.14提示建议6.2+,实际nonce命令通过。样本初轮漏carriers、参数错误码断言与真实DTO不一致、幂等样本漏credentialId,修正并保留失败日志;不视为业务缺陷。另一本地PG16安装缺dict_snowball,使用既有完整pgsql安装启动本轮隔离实例。
|
||||
|
||||
CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页面横向溢出;1.4/2.2/2.3顺序、36个具名示例、目录跳转、复制、刷新、错误码检索和空结果通过,控制台无warn/error。当前仅本地页面,线上验收另记。证据目录.local-data/http-signature-20260915;最终精确候选门禁/提交/推送/发布后补。无预生产或业务配置修改、无短信发送。
|
||||
|
||||
@@ -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