trustbase-seller-backend 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payments/wechat/profitsharing.js +4 -5
- package/routes/onboard.html +43 -7
- package/routes/sp-applyment.js +23 -5
- package/test-onboard-applyment.js +454 -448
- package/test-sp-applyment.js +1 -1
package/package.json
CHANGED
|
@@ -107,16 +107,15 @@ async function addReceivers(receivers) {
|
|
|
107
107
|
return results.length === 1 ? results[0] : results;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
/** 接收方名称加密: 微信支付公钥
|
|
111
|
-
* ⚠️ 2026-09-26
|
|
112
|
-
*
|
|
113
|
-
* 必须用 openssl 子进程分开指定 (rsa_oaep_md:sha256 + rsa_mgf1_md:sha1)。 */
|
|
110
|
+
/** 接收方名称加密: 微信支付公钥 RSAES-OAEP → base64。
|
|
111
|
+
* ⚠️ 2026-09-26 实测枚举锁定: 微信的 RSAES-OAEP = OAEP-SHA1 + MGF1-SHA1 (全线 SHA-1)。
|
|
112
|
+
* sha256/sha256、sha256/sha1 都被微信拒 ("平台私钥解密失败"), sha1/sha1 通过。openssl 子进程实现。 */
|
|
114
113
|
function encryptReceiverName(name) {
|
|
115
114
|
const p = process.env.WXPAY_SP_PUBLIC_KEY_PATH || process.env.WXPAY_PUBLIC_KEY_PATH;
|
|
116
115
|
if (!p) throw new Error("WXPAY_(SP_)PUBLIC_KEY_PATH 未配置, 接收方名称无法加密");
|
|
117
116
|
const { execSync } = require("child_process");
|
|
118
117
|
const b64 = execSync(
|
|
119
|
-
`openssl pkeyutl -encrypt -pubin -inkey ${JSON.stringify(p)} -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:
|
|
118
|
+
`openssl pkeyutl -encrypt -pubin -inkey ${JSON.stringify(p)} -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha1 -pkeyopt rsa_mgf1_md:sha1 | base64 -w0`,
|
|
120
119
|
{ input: Buffer.from(name, "utf8"), maxBuffer: 1024 * 1024 }
|
|
121
120
|
).toString("utf8").trim();
|
|
122
121
|
return b64;
|
package/routes/onboard.html
CHANGED
|
@@ -196,12 +196,13 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
|
|
|
196
196
|
<div class="afield"><label for="a_merchant_name">营业执照名称</label><input id="a_merchant_name" placeholder="全称, 与执照一致"></div>
|
|
197
197
|
<div class="afield"><label for="a_license_number">统一社会信用代码</label><input id="a_license_number" placeholder="18 位, 9 开头"></div>
|
|
198
198
|
<div class="afield"><label for="a_service_phone">客服电话</label><input id="a_service_phone" placeholder="交易记录向买家展示"></div>
|
|
199
|
-
<div class="afield"><label for="a_settlement_id"
|
|
199
|
+
<div class="afield"><label for="a_settlement_id">经营类目 (微信行业编码 MCC)</label>
|
|
200
|
+
<input id="a_settlement_filter" placeholder="搜索: 编码或名称 (如 5732 / 电子)" style="margin-bottom:6px">
|
|
200
201
|
<select id="a_settlement_id">
|
|
201
|
-
<option value="
|
|
202
|
-
<option value="
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
<option value="">请选择经营类目</option>
|
|
203
|
+
<option value="__MANUAL__">手动输入编码</option>
|
|
204
|
+
</select>
|
|
205
|
+
<input id="a_settlement_manual" class="amanual" placeholder="4 位 MCC 编码, 如: 5311" style="display:none"></div>
|
|
205
206
|
<div class="afield afield-full"><label for="a_qualification_type">类目资质描述 <span class="aopt">通用类目留空</span></label><input id="a_qualification_type" placeholder="通用零售/餐饮留空即可;特殊行业(医疗/教育/食品)需对应资质"></div>
|
|
206
207
|
</div>
|
|
207
208
|
</div>
|
|
@@ -932,12 +933,47 @@ async function restoreIdentity(){
|
|
|
932
933
|
(证件缩略图预览已于二重框修复时并入主脚本 mountApplyUpload 槽位双态) ===== */
|
|
933
934
|
(function(){
|
|
934
935
|
// ⓪ 编码字段下拉 (2026-10-03 xiaomu: 手填编码易被微信秒拒 → 下拉)
|
|
935
|
-
// 经营类目 ↔ 主体类型联动 (
|
|
936
|
+
// 经营类目 ↔ 主体类型联动 — MCC 真实编码 (V3 进件 settlement_id 用《商户行业编码》4 位 MCC,
|
|
937
|
+
// 之前 716/719/727 是 V2 结算规则 ID, 被微信拒 "所属行业不存在"。编码主体通用,
|
|
938
|
+
// 联动只影响默认选中: 企业→5311 百货, 个体户→5331 杂货便利店)
|
|
939
|
+
var MCC=[['5311','百货商店'],['5331','各类杂货店便利店'],['5411','大型仓储式超级市场'],
|
|
940
|
+
['5611','男装店'],['5621','妇女成衣店'],['5641','婴儿儿童服装店'],['5651','家庭服装商店'],
|
|
941
|
+
['5655','运动服饰商店'],['5661','鞋店'],['5691','成人成衣店'],['5699','各类服装及饰物店'],
|
|
942
|
+
['5712','家具家用设备零售商'],['5722','家用电器商店'],['5732','电子设备商店'],['5734','计算机软件商店'],
|
|
943
|
+
['5921','瓶装酒零售店'],['5942','书店'],['5943','文具办公用品商店'],['5944','珠宝钟表银器店'],
|
|
944
|
+
['5945','玩具游戏店'],['5948','箱包皮具店'],['5949','纺织品针织品零售'],['5970','工艺美术商店'],
|
|
945
|
+
['5977','化妆品商店'],['5995','宠物商店及用品'],['5999','其他专门零售店'],
|
|
946
|
+
['5812','就餐场所和餐馆'],['5814','快餐店'],['5499','各类食品店'],['5441','糖果坚果商店'],
|
|
947
|
+
['5451','乳制品冷饮店'],['5462','面包房糕点商店'],['5422','各类肉类零售商'],['5912','药店药房'],
|
|
948
|
+
['4816','计算机网络信息服务'],['7011','住宿服务'],['4722','旅行社'],['4733','票务'],
|
|
949
|
+
['7230','美容理发店'],['7298','保健美容SPA'],['7295','家政服务'],['7699','各类维修店'],
|
|
950
|
+
['8249','贸易职业学校(职业技能培训)'],['8299','其他学校和教育服务']];
|
|
936
951
|
var subj=document.getElementById('a_subject_type');
|
|
937
952
|
var stl=document.getElementById('a_settlement_id');
|
|
953
|
+
if(stl){
|
|
954
|
+
// 填充 MCC 选项 (手动输入项保持在最后)
|
|
955
|
+
var manualOpt=stl.querySelector('option[value="__MANUAL__"]');
|
|
956
|
+
MCC.forEach(function(c){
|
|
957
|
+
var o=document.createElement('option');
|
|
958
|
+
o.value=c[0];o.textContent=c[0]+' · '+c[1];
|
|
959
|
+
stl.insertBefore(o,manualOpt);
|
|
960
|
+
});
|
|
961
|
+
stl.value='5311'; // 默认 百货商店 (综合电商最高频)
|
|
962
|
+
// 搜索过滤 (45 个选项手翻太长)
|
|
963
|
+
var filt=document.getElementById('a_settlement_filter');
|
|
964
|
+
if(filt) filt.addEventListener('input',function(){
|
|
965
|
+
var q=filt.value.trim().toLowerCase();
|
|
966
|
+
Array.prototype.forEach.call(stl.options,function(o){
|
|
967
|
+
if(o.value===''||o.value==='__MANUAL__') return;
|
|
968
|
+
o.hidden = !!(q && o.textContent.toLowerCase().indexOf(q)<0);
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
// 手动输入接管 (沿用 id 移交逻辑)
|
|
972
|
+
linkSelectManual('a_settlement_id','a_settlement_manual','__MANUAL__');
|
|
973
|
+
}
|
|
938
974
|
if(subj&&stl){
|
|
939
975
|
subj.addEventListener('change',function(){
|
|
940
|
-
stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '
|
|
976
|
+
stl.value = subj.value==='SUBJECT_TYPE_ENTERPRISE' ? '5311' : '5331';
|
|
941
977
|
// F4: 企业主体显示身份证居住地址(必填) + UBO 说明; 个体户隐藏
|
|
942
978
|
var ent = subj.value==='SUBJECT_TYPE_ENTERPRISE';
|
|
943
979
|
var ia=document.getElementById('afield_idaddr'); if(ia) ia.style.display=ent?'':'none';
|
package/routes/sp-applyment.js
CHANGED
|
@@ -110,12 +110,30 @@ function wxPubKeyPem() {
|
|
|
110
110
|
throw new HttpError(500, "wx_pubkey_missing", "WXPAY_SP_PUBLIC_KEY_PATH 未配置, 敏感字段无法加密");
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** 敏感字段加密:
|
|
113
|
+
/** 敏感字段加密: 微信支付公钥 RSAES-OAEP → base64。
|
|
114
|
+
* ⚠️ 2026-09-26 实测枚举锁定: 微信 RSAES-OAEP = OAEP-SHA1 + MGF1-SHA1 (全线 SHA-1),
|
|
115
|
+
* sha256 系全部"平台私钥解密失败"。openssl 子进程, pem 参数保留兼容旧调用签名。 */
|
|
114
116
|
function encryptSensitive(plain, pem) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
const { execSync } = require("child_process");
|
|
118
|
+
const fs = require("fs");
|
|
119
|
+
const os = require("os");
|
|
120
|
+
const path = require("path");
|
|
121
|
+
// openssl 需要文件路径: pem 已是路径直接用; 是内容则写临时文件
|
|
122
|
+
let keyPath = pem;
|
|
123
|
+
let tmp = null;
|
|
124
|
+
if (String(pem).includes("BEGIN")) {
|
|
125
|
+
tmp = path.join(os.tmpdir(), `wxpk-${process.pid}-${Date.now()}.pem`);
|
|
126
|
+
fs.writeFileSync(tmp, pem, { mode: 0o600 });
|
|
127
|
+
keyPath = tmp;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return execSync(
|
|
131
|
+
`openssl pkeyutl -encrypt -pubin -inkey ${JSON.stringify(keyPath)} -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha1 -pkeyopt rsa_mgf1_md:sha1 | base64 -w0`,
|
|
132
|
+
{ input: Buffer.from(String(plain), "utf8"), maxBuffer: 1024 * 1024 }
|
|
133
|
+
).toString("utf8").trim();
|
|
134
|
+
} finally {
|
|
135
|
+
if (tmp) { try { fs.unlinkSync(tmp); } catch (_) {} }
|
|
136
|
+
}
|
|
119
137
|
}
|
|
120
138
|
|
|
121
139
|
/** 业务申请编号: TB{毫秒}{6位随机} (微信要求仅数字/字母/下划线) */
|
|
@@ -1,451 +1,457 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* test-onboard-applyment.js — 卖家本机进件链路 离线单测 (2026-10-03 架构纠偏)
|
|
4
|
-
* node --test test-onboard-applyment.js
|
|
5
|
-
*
|
|
6
|
-
* 数据流: 卖家页面 → 卖家本机 seller-backend (敏感字段本地 RSA-OAEP 加密)
|
|
7
|
-
* → 假 SP 网关 (本测试起的 mock HTTP 服务, 捕获转发体断言无明文) → (真环境再到微信)
|
|
8
|
-
* SP 不可达: SP_GATEWAY_URL 指到关闭端口, 断言 503 友好提示 + 台账 PENDING_PUSH 可重试。
|
|
9
|
-
*/
|
|
10
|
-
const test = require("node:test");
|
|
11
|
-
const assert = require("node:assert");
|
|
12
|
-
const crypto = require("crypto");
|
|
13
|
-
const fs = require("fs");
|
|
14
|
-
const os = require("os");
|
|
15
|
-
const path = require("path");
|
|
16
|
-
const express = require("express");
|
|
17
|
-
const Database = require("better-sqlite3");
|
|
18
|
-
|
|
19
|
-
const MERCHANT_TOKEN = "test-sp-merchant-token";
|
|
20
|
-
|
|
21
|
-
// 测试 SP 微信支付公钥对 (卖家本机加密用; 私钥断言密文可还原)
|
|
22
|
-
const wxPub = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
23
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "onboard-apply-test-"));
|
|
24
|
-
const pubKeyPath = path.join(tmpDir, "wx_sp_pub.pem");
|
|
25
|
-
fs.writeFileSync(pubKeyPath, wxPub.publicKey.export({ type: "spki", format: "pem" }));
|
|
26
|
-
|
|
27
|
-
process.env.WXPAY_SP_PUBLIC_KEY_PATH = pubKeyPath;
|
|
28
|
-
process.env.TB_BASE_DIR = tmpDir; // M2: applyment submit 会读本机 seller 身份 (tb_address 贯通), 隔离到临时目录
|
|
29
|
-
process.env.SP_MERCHANT_TOKEN = MERCHANT_TOKEN;
|
|
30
|
-
|
|
31
|
-
const onboard = require("./routes/onboard");
|
|
32
|
-
const spApplymentLib = require("./routes/sp-applyment"); // buildApplymentBody 场景结构断言用
|
|
33
|
-
|
|
34
|
-
// ---------- 假 SP 网关 (捕获请求体) ----------
|
|
35
|
-
const spCapture = { uploads: [], submits: [], statusQueries: [] };
|
|
36
|
-
let spSrv = null;
|
|
37
|
-
let spBase = "";
|
|
38
|
-
let submitCounter = 0;
|
|
39
|
-
|
|
40
|
-
async function startFakeSp() {
|
|
41
|
-
const app = express();
|
|
42
|
-
app.use(express.json());
|
|
43
|
-
// 凭证校验 (卖家带 x-sp-admin-key = SP_MERCHANT_TOKEN)
|
|
44
|
-
app.use((req, res, next) => {
|
|
45
|
-
if (req.path === "/api/sp/info") return next();
|
|
46
|
-
if (req.headers["x-sp-admin-key"] !== MERCHANT_TOKEN) {
|
|
47
|
-
return res.status(401).json({ error: "unauthorized", message: "x-sp-admin-key 无效" });
|
|
48
|
-
}
|
|
49
|
-
next();
|
|
50
|
-
});
|
|
51
|
-
app.get("/api/sp/info", (req, res) => {
|
|
52
|
-
spCapture.infoQueries = (spCapture.infoQueries || 0) + 1;
|
|
53
|
-
res.json({ name: "无锡小播鼠网络科技", desc: "TrustBase 官方支付服务商", commission_rate_bp: 100, rate_pct: "1.00%", gateway: "fake-sp" });
|
|
54
|
-
});
|
|
55
|
-
app.post("/api/sp/applyment/upload", (req, res) => {
|
|
56
|
-
spCapture.uploads.push(req.body);
|
|
57
|
-
res.json({ media_id: "FAKE_MEDIA_" + crypto.randomBytes(6).toString("hex") });
|
|
58
|
-
});
|
|
59
|
-
app.post("/api/sp/applyment/submit", (req, res) => {
|
|
60
|
-
spCapture.submits.push(req.body);
|
|
61
|
-
submitCounter++;
|
|
62
|
-
// F3: 卖家侧生成 business_code, SP 回显同号 (不带号才兜底生成)
|
|
63
|
-
res.json({ applyment_id: "200000212477" + String(5690 + submitCounter), business_code: req.body.business_code || ("TBFAKE" + String(submitCounter).padStart(6, "0")) });
|
|
64
|
-
});
|
|
65
|
-
app.get("/api/sp/applyment/status/:bc", (req, res) => {
|
|
66
|
-
spCapture.statusQueries.push(req.params.bc);
|
|
67
|
-
const ov = spCapture.statusOverride || {};
|
|
68
|
-
res.json({
|
|
69
|
-
business_code: req.params.bc, status: ov.status || "APPLYMENT_STATE_FINISHED", state_msg: ov.state_msg || "已完成",
|
|
70
|
-
sign_url: "https://pay.weixin.qq.com/public/apply4ec_sign/s?fake=1", sub_mchid: "1600000888",
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
spSrv = app.listen(0);
|
|
74
|
-
await new Promise((r) => spSrv.once("listening", r));
|
|
75
|
-
spBase = `http://127.0.0.1:${spSrv.address().port}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 起被测卖家后端 (新内存库) */
|
|
79
|
-
async function startSeller() {
|
|
80
|
-
const db = new Database(":memory:");
|
|
81
|
-
const app = express();
|
|
82
|
-
app.use(express.json());
|
|
83
|
-
const router = onboard.createOnboardRouter({ db });
|
|
84
|
-
app.use("/api/onboard", router);
|
|
85
|
-
app.use("/onboard", router);
|
|
86
|
-
const srv = app.listen(0);
|
|
87
|
-
await new Promise((r) => srv.once("listening", r));
|
|
88
|
-
return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const PLAINTEXT = { id: "440300199001011234", card: "6225880212345678", mobile: "13800001111" };
|
|
92
|
-
|
|
93
|
-
/** 合法表单 (5 张图先走 upload 代理拿 media_id) */
|
|
94
|
-
async function fillForm(base) {
|
|
95
|
-
const media = {};
|
|
96
|
-
for (const k of ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id", "store_entrance_media_id", "store_indoor_media_id"]) {
|
|
97
|
-
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
98
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
99
|
-
body: JSON.stringify({ filename: k + ".jpg", content_b64: Buffer.from("fake-jpeg-" + k).toString("base64") }),
|
|
100
|
-
});
|
|
101
|
-
const j = await r.json();
|
|
102
|
-
assert.strictEqual(r.status, 200, "upload " + k + ": " + JSON.stringify(j));
|
|
103
|
-
media[k] = j.media_id;
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
subject_type: "SUBJECT_TYPE_INDIVIDUAL",
|
|
107
|
-
contact_name: "张三", contact_mobile: PLAINTEXT.mobile, contact_email: "zs@example.com",
|
|
108
|
-
merchant_shortname: "张三餐饮店", merchant_name: "深圳市张三餐饮店", license_number: "92440300MA5F00000X",
|
|
109
|
-
legal_name: "张三", legal_id_number: PLAINTEXT.id,
|
|
110
|
-
legal_id_period_begin: "2015-01-01", legal_id_period_end: "2035-01-01",
|
|
111
|
-
account_type: "BANK_ACCOUNT_TYPE_PERSONAL",
|
|
112
|
-
account_name: "张三", account_number: PLAINTEXT.card, account_bank: "招商银行",
|
|
113
|
-
store_name: "张三餐饮店南山店", store_address_code: "440300", store_address: "南山区xx路1号",
|
|
114
|
-
service_phone: "0755-12345678", settlement_id: "719", qualification_type: "零售",
|
|
115
|
-
...media,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
test.before(async () => {
|
|
120
|
-
await startFakeSp();
|
|
121
|
-
process.env.SP_GATEWAY_URL = spBase;
|
|
122
|
-
});
|
|
123
|
-
test.after(() => { if (spSrv) spSrv.close(); });
|
|
124
|
-
|
|
125
|
-
test("upload 代理: 转发假 SP 拿 media_id, 凭证头正确", async () => {
|
|
126
|
-
const { srv, base } = await startSeller();
|
|
127
|
-
try {
|
|
128
|
-
const before = spCapture.uploads.length;
|
|
129
|
-
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
130
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
131
|
-
body: JSON.stringify({ filename: "id_front.jpg", content_b64: Buffer.from("fake-jpeg").toString("base64") }),
|
|
132
|
-
});
|
|
133
|
-
const j = await r.json();
|
|
134
|
-
assert.strictEqual(r.status, 200);
|
|
135
|
-
assert.match(j.media_id, /^FAKE_MEDIA_/);
|
|
136
|
-
assert.strictEqual(spCapture.uploads.length, before + 1);
|
|
137
|
-
assert.strictEqual(spCapture.uploads[before].filename, "id_front.jpg");
|
|
138
|
-
} finally { srv.close(); }
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("submit: 本地加密转发 (转发体无明文, 密文可还原) + 台账落行 + 幂等", async () => {
|
|
142
|
-
const { db, srv, base } = await startSeller();
|
|
143
|
-
try {
|
|
144
|
-
const form = await fillForm(base);
|
|
145
|
-
const submitsBefore = spCapture.submits.length;
|
|
146
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
147
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
148
|
-
});
|
|
149
|
-
const j = await r.json();
|
|
150
|
-
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
151
|
-
assert.match(j.business_code, /^TB\d{19}$/, "business_code 卖家侧本地生成 (F3)");
|
|
152
|
-
assert.ok(j.applyment_id);
|
|
153
|
-
|
|
154
|
-
// 转发体: already_encrypted + 无明文敏感
|
|
155
|
-
assert.strictEqual(spCapture.submits.length, submitsBefore + 1);
|
|
156
|
-
const fwd = spCapture.submits[submitsBefore];
|
|
157
|
-
assert.strictEqual(fwd.already_encrypted, true);
|
|
158
|
-
assert.match(fwd.tb_address || "", /^tct1[a-z0-9]{20,60}$/, "M2: payload 带商户链上地址 (引导金 recipient)");
|
|
159
|
-
const fwdStr = JSON.stringify(fwd);
|
|
160
|
-
assert.ok(!fwdStr.includes(PLAINTEXT.id), "转发体不含明文身份证");
|
|
161
|
-
assert.ok(!fwdStr.includes(PLAINTEXT.card), "转发体不含明文卡号");
|
|
162
|
-
assert.ok(!fwdStr.includes(PLAINTEXT.mobile), "转发体不含明文手机");
|
|
163
|
-
// 密文可用测试私钥还原 (证明用的是 SP 公钥 RSA-OAEP-SHA256)
|
|
164
|
-
const dec = crypto.privateDecrypt(
|
|
165
|
-
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "
|
|
166
|
-
Buffer.from(fwd.legal_id_number, "base64")
|
|
167
|
-
).toString("utf8");
|
|
168
|
-
assert.strictEqual(dec, PLAINTEXT.id);
|
|
169
|
-
// media_id 透传
|
|
170
|
-
assert.strictEqual(fwd.license_media_id, form.license_media_id);
|
|
171
|
-
|
|
172
|
-
// 台账: business_code 落行, 快照无明文, 暂存 payload 是密文
|
|
173
|
-
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
174
|
-
assert.strictEqual(row.business_code, j.business_code);
|
|
175
|
-
assert.strictEqual(row.status, "SUBMITTED");
|
|
176
|
-
assert.ok(!row.form_snapshot.includes(PLAINTEXT.id), "快照无明文身份证");
|
|
177
|
-
assert.ok(!row.encrypted_payload.includes(PLAINTEXT.id), "暂存 payload 无 plaintext");
|
|
178
|
-
|
|
179
|
-
// 幂等: 同表单再提交 → 同 business_code, 不重复调 SP
|
|
180
|
-
const r2 = await fetch(base + "/api/onboard/applyment", {
|
|
181
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
182
|
-
});
|
|
183
|
-
const j2 = await r2.json();
|
|
184
|
-
assert.strictEqual(j2.idempotent, true);
|
|
185
|
-
assert.strictEqual(j2.business_code, j.business_code);
|
|
186
|
-
assert.strictEqual(spCapture.submits.length, submitsBefore + 1, "SP 未被重复调用");
|
|
187
|
-
} finally { srv.close(); }
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("status 代理: FINISHED + sign_url + sub_mchid 回页面, 台账同步", async () => {
|
|
191
|
-
const { db, srv, base } = await startSeller();
|
|
192
|
-
try {
|
|
193
|
-
const form = await fillForm(base);
|
|
194
|
-
const sr = await fetch(base + "/api/onboard/applyment", {
|
|
195
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
196
|
-
});
|
|
197
|
-
const { business_code } = await sr.json();
|
|
198
|
-
const r = await fetch(base + "/api/onboard/applyment/status");
|
|
199
|
-
const j = await r.json();
|
|
200
|
-
assert.strictEqual(r.status, 200);
|
|
201
|
-
assert.strictEqual(j.status, "APPLYMENT_STATE_FINISHED");
|
|
202
|
-
assert.ok(j.sign_url.includes("pay.weixin.qq.com"));
|
|
203
|
-
assert.strictEqual(j.sub_mchid, "1600000888");
|
|
204
|
-
assert.ok(spCapture.statusQueries.includes(business_code), "按 business_code 查 SP");
|
|
205
|
-
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
206
|
-
assert.strictEqual(row.sub_mchid, "1600000888");
|
|
207
|
-
assert.ok(row.sign_url.includes("pay.weixin.qq.com"));
|
|
208
|
-
} finally { srv.close(); }
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("SP 不可达: 503 友好提示 + 台账 PENDING_PUSH, 恢复后空 body 重试成功", async () => {
|
|
212
|
-
const { db, srv, base } = await startSeller();
|
|
213
|
-
try {
|
|
214
|
-
// 先上传 (SP 还在线)
|
|
215
|
-
const form = await fillForm(base);
|
|
216
|
-
// 掐断 SP
|
|
217
|
-
process.env.SP_GATEWAY_URL = "http://127.0.0.1:1";
|
|
218
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
219
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
220
|
-
});
|
|
221
|
-
const j = await r.json();
|
|
222
|
-
assert.strictEqual(r.status, 503);
|
|
223
|
-
assert.strictEqual(j.error, "sp_unavailable");
|
|
224
|
-
assert.ok(j.message.includes("服务商暂时不可用"), j.message);
|
|
225
|
-
assert.strictEqual(j.pending, true);
|
|
226
|
-
// 台账 PENDING_PUSH + 密文暂存
|
|
227
|
-
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
228
|
-
assert.strictEqual(row.status, "PENDING_PUSH");
|
|
229
|
-
assert.ok(!row.encrypted_payload.includes(PLAINTEXT.id), "暂存的是密文");
|
|
230
|
-
// 状态查询如实回 PENDING_PUSH
|
|
231
|
-
const st = await (await fetch(base + "/api/onboard/applyment/status")).json();
|
|
232
|
-
assert.strictEqual(st.status, "PENDING_PUSH");
|
|
233
|
-
// 恢复 SP → 空 body 重试
|
|
234
|
-
process.env.SP_GATEWAY_URL = spBase;
|
|
235
|
-
const submitsBefore = spCapture.submits.length;
|
|
236
|
-
const r2 = await fetch(base + "/api/onboard/applyment", {
|
|
237
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
238
|
-
});
|
|
239
|
-
const j2 = await r2.json();
|
|
240
|
-
assert.strictEqual(r2.status, 200, JSON.stringify(j2));
|
|
241
|
-
assert.match(j2.business_code, /^TB\d{19}$/);
|
|
242
|
-
assert.strictEqual(spCapture.submits.length, submitsBefore + 1, "用暂存密文重试转发了一次");
|
|
243
|
-
const row2 = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
244
|
-
assert.strictEqual(row2.status, "SUBMITTED");
|
|
245
|
-
} finally { srv.close(); }
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("缺必填字段 → 400; 缺 media_id → 400 指明哪张图", async () => {
|
|
249
|
-
const { srv, base } = await startSeller();
|
|
250
|
-
try {
|
|
251
|
-
// 空表单
|
|
252
|
-
let r = await fetch(base + "/api/onboard/applyment", {
|
|
253
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
254
|
-
});
|
|
255
|
-
assert.strictEqual(r.status, 400);
|
|
256
|
-
// 缺一张图
|
|
257
|
-
const form = await fillForm(base);
|
|
258
|
-
delete form.store_indoor_media_id;
|
|
259
|
-
r = await fetch(base + "/api/onboard/applyment", {
|
|
260
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
261
|
-
});
|
|
262
|
-
const j = await r.json();
|
|
263
|
-
assert.strictEqual(r.status, 400);
|
|
264
|
-
assert.strictEqual(j.error, "media_missing");
|
|
265
|
-
assert.ok(j.message.includes("门店内部照片"), j.message);
|
|
266
|
-
} finally { srv.close(); }
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元素齐", async () => {
|
|
270
|
-
const { srv, base } = await startSeller();
|
|
271
|
-
try {
|
|
272
|
-
const r = await fetch(base + "/onboard/");
|
|
273
|
-
const html = await r.text();
|
|
274
|
-
assert.strictEqual(r.status, 200);
|
|
275
|
-
assert.ok(html.includes("btn-apply-submit"), "进件提交按钮");
|
|
276
|
-
assert.ok(html.includes("apply-status"), "状态框");
|
|
277
|
-
assert.ok(html.includes("/api/onboard/applyment"), "页面打新端点");
|
|
278
|
-
// 2026-10-03: 三场景选择器 + 错误红卡
|
|
279
|
-
assert.ok(html.includes('name="sales_scene"'), "场景单选");
|
|
280
|
-
assert.ok(html.includes("SALES_SCENES_WEB") && html.includes("SALES_SCENES_MP"), "WEB/MP 场景");
|
|
281
|
-
assert.ok(html.includes("apply-err"), "错误红卡容器");
|
|
282
|
-
// 2026-10-03 二重框修复: 上传槽双态 (替换/删除按钮 + _aSlots 注册 + 删除清 media_id)
|
|
283
|
-
assert.ok(html.includes("upops") && html.includes("updel"), "替换/删除按钮结构");
|
|
284
|
-
assert.ok(html.includes("替换") && html.includes("删除"), "替换/删除文案");
|
|
285
|
-
assert.ok(html.includes("_aSlots"), "槽位双态注册表");
|
|
286
|
-
assert.ok(html.includes("delete _aMedia[field]"), "删除清 media_id");
|
|
287
|
-
assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* test-onboard-applyment.js — 卖家本机进件链路 离线单测 (2026-10-03 架构纠偏)
|
|
4
|
+
* node --test test-onboard-applyment.js
|
|
5
|
+
*
|
|
6
|
+
* 数据流: 卖家页面 → 卖家本机 seller-backend (敏感字段本地 RSA-OAEP 加密)
|
|
7
|
+
* → 假 SP 网关 (本测试起的 mock HTTP 服务, 捕获转发体断言无明文) → (真环境再到微信)
|
|
8
|
+
* SP 不可达: SP_GATEWAY_URL 指到关闭端口, 断言 503 友好提示 + 台账 PENDING_PUSH 可重试。
|
|
9
|
+
*/
|
|
10
|
+
const test = require("node:test");
|
|
11
|
+
const assert = require("node:assert");
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const express = require("express");
|
|
17
|
+
const Database = require("better-sqlite3");
|
|
18
|
+
|
|
19
|
+
const MERCHANT_TOKEN = "test-sp-merchant-token";
|
|
20
|
+
|
|
21
|
+
// 测试 SP 微信支付公钥对 (卖家本机加密用; 私钥断言密文可还原)
|
|
22
|
+
const wxPub = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
23
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "onboard-apply-test-"));
|
|
24
|
+
const pubKeyPath = path.join(tmpDir, "wx_sp_pub.pem");
|
|
25
|
+
fs.writeFileSync(pubKeyPath, wxPub.publicKey.export({ type: "spki", format: "pem" }));
|
|
26
|
+
|
|
27
|
+
process.env.WXPAY_SP_PUBLIC_KEY_PATH = pubKeyPath;
|
|
28
|
+
process.env.TB_BASE_DIR = tmpDir; // M2: applyment submit 会读本机 seller 身份 (tb_address 贯通), 隔离到临时目录
|
|
29
|
+
process.env.SP_MERCHANT_TOKEN = MERCHANT_TOKEN;
|
|
30
|
+
|
|
31
|
+
const onboard = require("./routes/onboard");
|
|
32
|
+
const spApplymentLib = require("./routes/sp-applyment"); // buildApplymentBody 场景结构断言用
|
|
33
|
+
|
|
34
|
+
// ---------- 假 SP 网关 (捕获请求体) ----------
|
|
35
|
+
const spCapture = { uploads: [], submits: [], statusQueries: [] };
|
|
36
|
+
let spSrv = null;
|
|
37
|
+
let spBase = "";
|
|
38
|
+
let submitCounter = 0;
|
|
39
|
+
|
|
40
|
+
async function startFakeSp() {
|
|
41
|
+
const app = express();
|
|
42
|
+
app.use(express.json());
|
|
43
|
+
// 凭证校验 (卖家带 x-sp-admin-key = SP_MERCHANT_TOKEN)
|
|
44
|
+
app.use((req, res, next) => {
|
|
45
|
+
if (req.path === "/api/sp/info") return next();
|
|
46
|
+
if (req.headers["x-sp-admin-key"] !== MERCHANT_TOKEN) {
|
|
47
|
+
return res.status(401).json({ error: "unauthorized", message: "x-sp-admin-key 无效" });
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
app.get("/api/sp/info", (req, res) => {
|
|
52
|
+
spCapture.infoQueries = (spCapture.infoQueries || 0) + 1;
|
|
53
|
+
res.json({ name: "无锡小播鼠网络科技", desc: "TrustBase 官方支付服务商", commission_rate_bp: 100, rate_pct: "1.00%", gateway: "fake-sp" });
|
|
54
|
+
});
|
|
55
|
+
app.post("/api/sp/applyment/upload", (req, res) => {
|
|
56
|
+
spCapture.uploads.push(req.body);
|
|
57
|
+
res.json({ media_id: "FAKE_MEDIA_" + crypto.randomBytes(6).toString("hex") });
|
|
58
|
+
});
|
|
59
|
+
app.post("/api/sp/applyment/submit", (req, res) => {
|
|
60
|
+
spCapture.submits.push(req.body);
|
|
61
|
+
submitCounter++;
|
|
62
|
+
// F3: 卖家侧生成 business_code, SP 回显同号 (不带号才兜底生成)
|
|
63
|
+
res.json({ applyment_id: "200000212477" + String(5690 + submitCounter), business_code: req.body.business_code || ("TBFAKE" + String(submitCounter).padStart(6, "0")) });
|
|
64
|
+
});
|
|
65
|
+
app.get("/api/sp/applyment/status/:bc", (req, res) => {
|
|
66
|
+
spCapture.statusQueries.push(req.params.bc);
|
|
67
|
+
const ov = spCapture.statusOverride || {};
|
|
68
|
+
res.json({
|
|
69
|
+
business_code: req.params.bc, status: ov.status || "APPLYMENT_STATE_FINISHED", state_msg: ov.state_msg || "已完成",
|
|
70
|
+
sign_url: "https://pay.weixin.qq.com/public/apply4ec_sign/s?fake=1", sub_mchid: "1600000888",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
spSrv = app.listen(0);
|
|
74
|
+
await new Promise((r) => spSrv.once("listening", r));
|
|
75
|
+
spBase = `http://127.0.0.1:${spSrv.address().port}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 起被测卖家后端 (新内存库) */
|
|
79
|
+
async function startSeller() {
|
|
80
|
+
const db = new Database(":memory:");
|
|
81
|
+
const app = express();
|
|
82
|
+
app.use(express.json());
|
|
83
|
+
const router = onboard.createOnboardRouter({ db });
|
|
84
|
+
app.use("/api/onboard", router);
|
|
85
|
+
app.use("/onboard", router);
|
|
86
|
+
const srv = app.listen(0);
|
|
87
|
+
await new Promise((r) => srv.once("listening", r));
|
|
88
|
+
return { db, srv, base: `http://127.0.0.1:${srv.address().port}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const PLAINTEXT = { id: "440300199001011234", card: "6225880212345678", mobile: "13800001111" };
|
|
92
|
+
|
|
93
|
+
/** 合法表单 (5 张图先走 upload 代理拿 media_id) */
|
|
94
|
+
async function fillForm(base) {
|
|
95
|
+
const media = {};
|
|
96
|
+
for (const k of ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id", "store_entrance_media_id", "store_indoor_media_id"]) {
|
|
97
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
98
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ filename: k + ".jpg", content_b64: Buffer.from("fake-jpeg-" + k).toString("base64") }),
|
|
100
|
+
});
|
|
101
|
+
const j = await r.json();
|
|
102
|
+
assert.strictEqual(r.status, 200, "upload " + k + ": " + JSON.stringify(j));
|
|
103
|
+
media[k] = j.media_id;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
subject_type: "SUBJECT_TYPE_INDIVIDUAL",
|
|
107
|
+
contact_name: "张三", contact_mobile: PLAINTEXT.mobile, contact_email: "zs@example.com",
|
|
108
|
+
merchant_shortname: "张三餐饮店", merchant_name: "深圳市张三餐饮店", license_number: "92440300MA5F00000X",
|
|
109
|
+
legal_name: "张三", legal_id_number: PLAINTEXT.id,
|
|
110
|
+
legal_id_period_begin: "2015-01-01", legal_id_period_end: "2035-01-01",
|
|
111
|
+
account_type: "BANK_ACCOUNT_TYPE_PERSONAL",
|
|
112
|
+
account_name: "张三", account_number: PLAINTEXT.card, account_bank: "招商银行",
|
|
113
|
+
store_name: "张三餐饮店南山店", store_address_code: "440300", store_address: "南山区xx路1号",
|
|
114
|
+
service_phone: "0755-12345678", settlement_id: "719", qualification_type: "零售",
|
|
115
|
+
...media,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test.before(async () => {
|
|
120
|
+
await startFakeSp();
|
|
121
|
+
process.env.SP_GATEWAY_URL = spBase;
|
|
122
|
+
});
|
|
123
|
+
test.after(() => { if (spSrv) spSrv.close(); });
|
|
124
|
+
|
|
125
|
+
test("upload 代理: 转发假 SP 拿 media_id, 凭证头正确", async () => {
|
|
126
|
+
const { srv, base } = await startSeller();
|
|
127
|
+
try {
|
|
128
|
+
const before = spCapture.uploads.length;
|
|
129
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
130
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ filename: "id_front.jpg", content_b64: Buffer.from("fake-jpeg").toString("base64") }),
|
|
132
|
+
});
|
|
133
|
+
const j = await r.json();
|
|
134
|
+
assert.strictEqual(r.status, 200);
|
|
135
|
+
assert.match(j.media_id, /^FAKE_MEDIA_/);
|
|
136
|
+
assert.strictEqual(spCapture.uploads.length, before + 1);
|
|
137
|
+
assert.strictEqual(spCapture.uploads[before].filename, "id_front.jpg");
|
|
138
|
+
} finally { srv.close(); }
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("submit: 本地加密转发 (转发体无明文, 密文可还原) + 台账落行 + 幂等", async () => {
|
|
142
|
+
const { db, srv, base } = await startSeller();
|
|
143
|
+
try {
|
|
144
|
+
const form = await fillForm(base);
|
|
145
|
+
const submitsBefore = spCapture.submits.length;
|
|
146
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
147
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
148
|
+
});
|
|
149
|
+
const j = await r.json();
|
|
150
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
151
|
+
assert.match(j.business_code, /^TB\d{19}$/, "business_code 卖家侧本地生成 (F3)");
|
|
152
|
+
assert.ok(j.applyment_id);
|
|
153
|
+
|
|
154
|
+
// 转发体: already_encrypted + 无明文敏感
|
|
155
|
+
assert.strictEqual(spCapture.submits.length, submitsBefore + 1);
|
|
156
|
+
const fwd = spCapture.submits[submitsBefore];
|
|
157
|
+
assert.strictEqual(fwd.already_encrypted, true);
|
|
158
|
+
assert.match(fwd.tb_address || "", /^tct1[a-z0-9]{20,60}$/, "M2: payload 带商户链上地址 (引导金 recipient)");
|
|
159
|
+
const fwdStr = JSON.stringify(fwd);
|
|
160
|
+
assert.ok(!fwdStr.includes(PLAINTEXT.id), "转发体不含明文身份证");
|
|
161
|
+
assert.ok(!fwdStr.includes(PLAINTEXT.card), "转发体不含明文卡号");
|
|
162
|
+
assert.ok(!fwdStr.includes(PLAINTEXT.mobile), "转发体不含明文手机");
|
|
163
|
+
// 密文可用测试私钥还原 (证明用的是 SP 公钥 RSA-OAEP-SHA256)
|
|
164
|
+
const dec = crypto.privateDecrypt(
|
|
165
|
+
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" },
|
|
166
|
+
Buffer.from(fwd.legal_id_number, "base64")
|
|
167
|
+
).toString("utf8");
|
|
168
|
+
assert.strictEqual(dec, PLAINTEXT.id);
|
|
169
|
+
// media_id 透传
|
|
170
|
+
assert.strictEqual(fwd.license_media_id, form.license_media_id);
|
|
171
|
+
|
|
172
|
+
// 台账: business_code 落行, 快照无明文, 暂存 payload 是密文
|
|
173
|
+
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
174
|
+
assert.strictEqual(row.business_code, j.business_code);
|
|
175
|
+
assert.strictEqual(row.status, "SUBMITTED");
|
|
176
|
+
assert.ok(!row.form_snapshot.includes(PLAINTEXT.id), "快照无明文身份证");
|
|
177
|
+
assert.ok(!row.encrypted_payload.includes(PLAINTEXT.id), "暂存 payload 无 plaintext");
|
|
178
|
+
|
|
179
|
+
// 幂等: 同表单再提交 → 同 business_code, 不重复调 SP
|
|
180
|
+
const r2 = await fetch(base + "/api/onboard/applyment", {
|
|
181
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
182
|
+
});
|
|
183
|
+
const j2 = await r2.json();
|
|
184
|
+
assert.strictEqual(j2.idempotent, true);
|
|
185
|
+
assert.strictEqual(j2.business_code, j.business_code);
|
|
186
|
+
assert.strictEqual(spCapture.submits.length, submitsBefore + 1, "SP 未被重复调用");
|
|
187
|
+
} finally { srv.close(); }
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("status 代理: FINISHED + sign_url + sub_mchid 回页面, 台账同步", async () => {
|
|
191
|
+
const { db, srv, base } = await startSeller();
|
|
192
|
+
try {
|
|
193
|
+
const form = await fillForm(base);
|
|
194
|
+
const sr = await fetch(base + "/api/onboard/applyment", {
|
|
195
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
196
|
+
});
|
|
197
|
+
const { business_code } = await sr.json();
|
|
198
|
+
const r = await fetch(base + "/api/onboard/applyment/status");
|
|
199
|
+
const j = await r.json();
|
|
200
|
+
assert.strictEqual(r.status, 200);
|
|
201
|
+
assert.strictEqual(j.status, "APPLYMENT_STATE_FINISHED");
|
|
202
|
+
assert.ok(j.sign_url.includes("pay.weixin.qq.com"));
|
|
203
|
+
assert.strictEqual(j.sub_mchid, "1600000888");
|
|
204
|
+
assert.ok(spCapture.statusQueries.includes(business_code), "按 business_code 查 SP");
|
|
205
|
+
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
206
|
+
assert.strictEqual(row.sub_mchid, "1600000888");
|
|
207
|
+
assert.ok(row.sign_url.includes("pay.weixin.qq.com"));
|
|
208
|
+
} finally { srv.close(); }
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("SP 不可达: 503 友好提示 + 台账 PENDING_PUSH, 恢复后空 body 重试成功", async () => {
|
|
212
|
+
const { db, srv, base } = await startSeller();
|
|
213
|
+
try {
|
|
214
|
+
// 先上传 (SP 还在线)
|
|
215
|
+
const form = await fillForm(base);
|
|
216
|
+
// 掐断 SP
|
|
217
|
+
process.env.SP_GATEWAY_URL = "http://127.0.0.1:1";
|
|
218
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
219
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
220
|
+
});
|
|
221
|
+
const j = await r.json();
|
|
222
|
+
assert.strictEqual(r.status, 503);
|
|
223
|
+
assert.strictEqual(j.error, "sp_unavailable");
|
|
224
|
+
assert.ok(j.message.includes("服务商暂时不可用"), j.message);
|
|
225
|
+
assert.strictEqual(j.pending, true);
|
|
226
|
+
// 台账 PENDING_PUSH + 密文暂存
|
|
227
|
+
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
228
|
+
assert.strictEqual(row.status, "PENDING_PUSH");
|
|
229
|
+
assert.ok(!row.encrypted_payload.includes(PLAINTEXT.id), "暂存的是密文");
|
|
230
|
+
// 状态查询如实回 PENDING_PUSH
|
|
231
|
+
const st = await (await fetch(base + "/api/onboard/applyment/status")).json();
|
|
232
|
+
assert.strictEqual(st.status, "PENDING_PUSH");
|
|
233
|
+
// 恢复 SP → 空 body 重试
|
|
234
|
+
process.env.SP_GATEWAY_URL = spBase;
|
|
235
|
+
const submitsBefore = spCapture.submits.length;
|
|
236
|
+
const r2 = await fetch(base + "/api/onboard/applyment", {
|
|
237
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
238
|
+
});
|
|
239
|
+
const j2 = await r2.json();
|
|
240
|
+
assert.strictEqual(r2.status, 200, JSON.stringify(j2));
|
|
241
|
+
assert.match(j2.business_code, /^TB\d{19}$/);
|
|
242
|
+
assert.strictEqual(spCapture.submits.length, submitsBefore + 1, "用暂存密文重试转发了一次");
|
|
243
|
+
const row2 = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
244
|
+
assert.strictEqual(row2.status, "SUBMITTED");
|
|
245
|
+
} finally { srv.close(); }
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("缺必填字段 → 400; 缺 media_id → 400 指明哪张图", async () => {
|
|
249
|
+
const { srv, base } = await startSeller();
|
|
250
|
+
try {
|
|
251
|
+
// 空表单
|
|
252
|
+
let r = await fetch(base + "/api/onboard/applyment", {
|
|
253
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
254
|
+
});
|
|
255
|
+
assert.strictEqual(r.status, 400);
|
|
256
|
+
// 缺一张图
|
|
257
|
+
const form = await fillForm(base);
|
|
258
|
+
delete form.store_indoor_media_id;
|
|
259
|
+
r = await fetch(base + "/api/onboard/applyment", {
|
|
260
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
261
|
+
});
|
|
262
|
+
const j = await r.json();
|
|
263
|
+
assert.strictEqual(r.status, 400);
|
|
264
|
+
assert.strictEqual(j.error, "media_missing");
|
|
265
|
+
assert.ok(j.message.includes("门店内部照片"), j.message);
|
|
266
|
+
} finally { srv.close(); }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元素齐", async () => {
|
|
270
|
+
const { srv, base } = await startSeller();
|
|
271
|
+
try {
|
|
272
|
+
const r = await fetch(base + "/onboard/");
|
|
273
|
+
const html = await r.text();
|
|
274
|
+
assert.strictEqual(r.status, 200);
|
|
275
|
+
assert.ok(html.includes("btn-apply-submit"), "进件提交按钮");
|
|
276
|
+
assert.ok(html.includes("apply-status"), "状态框");
|
|
277
|
+
assert.ok(html.includes("/api/onboard/applyment"), "页面打新端点");
|
|
278
|
+
// 2026-10-03: 三场景选择器 + 错误红卡
|
|
279
|
+
assert.ok(html.includes('name="sales_scene"'), "场景单选");
|
|
280
|
+
assert.ok(html.includes("SALES_SCENES_WEB") && html.includes("SALES_SCENES_MP"), "WEB/MP 场景");
|
|
281
|
+
assert.ok(html.includes("apply-err"), "错误红卡容器");
|
|
282
|
+
// 2026-10-03 二重框修复: 上传槽双态 (替换/删除按钮 + _aSlots 注册 + 删除清 media_id)
|
|
283
|
+
assert.ok(html.includes("upops") && html.includes("updel"), "替换/删除按钮结构");
|
|
284
|
+
assert.ok(html.includes("替换") && html.includes("删除"), "替换/删除文案");
|
|
285
|
+
assert.ok(html.includes("_aSlots"), "槽位双态注册表");
|
|
286
|
+
assert.ok(html.includes("delete _aMedia[field]"), "删除清 media_id");
|
|
287
|
+
assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
|
|
288
288
|
assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
|
|
289
|
-
|
|
290
|
-
assert.ok(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
form.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
assert.strictEqual(
|
|
371
|
-
assert.
|
|
372
|
-
assert.ok(!
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
assert.strictEqual(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
289
|
+
// 2026-10-03 MCC 修正: 经营类目下拉是真实 MCC 编码 (716/719/727 是 V2 结算规则 ID, 被微信拒)
|
|
290
|
+
assert.ok(html.includes("5311") && html.includes("5732"), "MCC 编码选项");
|
|
291
|
+
assert.ok(html.includes("百货商店") && html.includes("就餐场所和餐馆"), "MCC 名称");
|
|
292
|
+
assert.ok(!html.includes('value="719"') && !html.includes('value="716"') && !html.includes('value="727"'), "V2 错误编码已清除");
|
|
293
|
+
assert.ok(html.includes("a_settlement_filter"), "类目搜索过滤");
|
|
294
|
+
assert.ok(html.includes("a_settlement_manual") && html.includes("__MANUAL__"), "手动输入接管保留");
|
|
295
|
+
assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
|
|
296
|
+
assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
|
|
297
|
+
} finally { srv.close(); }
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/** 场景表单变体: 去掉门店字段, 换场景专属字段+照片 */
|
|
301
|
+
async function fillSceneForm(base, scene) {
|
|
302
|
+
const form = await fillForm(base);
|
|
303
|
+
delete form.store_name; delete form.store_address_code; delete form.store_address;
|
|
304
|
+
delete form.store_entrance_media_id; delete form.store_indoor_media_id;
|
|
305
|
+
form.sales_scene = scene;
|
|
306
|
+
if (scene === "SALES_SCENES_WEB") {
|
|
307
|
+
form.website_url = "https://shop.example.com";
|
|
308
|
+
for (const k of ["web_home_media_id", "web_product_media_id"]) {
|
|
309
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
310
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ filename: k + ".jpg", content_b64: Buffer.from("fake-" + k).toString("base64") }),
|
|
312
|
+
});
|
|
313
|
+
form[k] = (await r.json()).media_id;
|
|
314
|
+
}
|
|
315
|
+
} else if (scene === "SALES_SCENES_MP") {
|
|
316
|
+
form.appid = "wx1234567890abcdef";
|
|
317
|
+
form.appid_type = "mp";
|
|
318
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
319
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
320
|
+
body: JSON.stringify({ filename: "mp.jpg", content_b64: Buffer.from("fake-mp").toString("base64") }),
|
|
321
|
+
});
|
|
322
|
+
form.mp_media_id = (await r.json()).media_id;
|
|
323
|
+
}
|
|
324
|
+
return form;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
test("错误提示: 缺 store_address → 中文 message + field 定位 (2026-10-03 实测 fix)", async () => {
|
|
328
|
+
const { srv, base } = await startSeller();
|
|
329
|
+
try {
|
|
330
|
+
const form = await fillForm(base);
|
|
331
|
+
delete form.store_address;
|
|
332
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
333
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
334
|
+
});
|
|
335
|
+
const j = await r.json();
|
|
336
|
+
assert.strictEqual(r.status, 400);
|
|
337
|
+
assert.strictEqual(j.error, "invalid_param");
|
|
338
|
+
assert.strictEqual(j.field, "store_address", "field 回传供前端高亮");
|
|
339
|
+
assert.ok(j.message.includes("门店详细地址"), "中文文案: " + j.message);
|
|
340
|
+
assert.ok(j.message.includes("(store_address)"), "带字段名: " + j.message);
|
|
341
|
+
} finally { srv.close(); }
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("qualification_type 留空可提交 (后端默认 '无', 2026-10-03 实测秒拒 fix)", async () => {
|
|
345
|
+
const { srv, base } = await startSeller();
|
|
346
|
+
try {
|
|
347
|
+
const form = await fillForm(base);
|
|
348
|
+
delete form.qualification_type;
|
|
349
|
+
const before = spCapture.submits.length;
|
|
350
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
351
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
352
|
+
});
|
|
353
|
+
const j = await r.json();
|
|
354
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
355
|
+
assert.strictEqual(spCapture.submits[before].qualification_type, "无", "留空默认 '无'");
|
|
356
|
+
} finally { srv.close(); }
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("WEB 场景: 不带门店字段可提交, SP 侧组装 web_info", async () => {
|
|
360
|
+
const { srv, base } = await startSeller();
|
|
361
|
+
try {
|
|
362
|
+
const form = await fillSceneForm(base, "SALES_SCENES_WEB");
|
|
363
|
+
const before = spCapture.submits.length;
|
|
364
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
365
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
366
|
+
});
|
|
367
|
+
const j = await r.json();
|
|
368
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
369
|
+
const fwd = spCapture.submits[before];
|
|
370
|
+
assert.strictEqual(fwd.sales_scene, "SALES_SCENES_WEB");
|
|
371
|
+
assert.strictEqual(fwd.website_url, "https://shop.example.com");
|
|
372
|
+
assert.ok(!fwd.store_name, "不带门店字段");
|
|
373
|
+
// SP 侧结构: sales_info.web_info.domain (F2: 微信官方字段是 domain, 无 web_pics)
|
|
374
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
375
|
+
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_WEB"]);
|
|
376
|
+
assert.strictEqual(wxBody.business_info.sales_info.web_info.domain, "https://shop.example.com");
|
|
377
|
+
assert.ok(!("web_pics" in wxBody.business_info.sales_info.web_info), "微信 web_info 无 pics 字段");
|
|
378
|
+
assert.ok(!wxBody.business_info.sales_info.biz_store_info, "无门店结构");
|
|
379
|
+
// 网址不带协议 → SP 拒
|
|
380
|
+
assert.throws(() => spApplymentLib.buildApplymentBody({ ...fwd, website_url: "shop.example.com" }, (s) => s, { alreadyEncrypted: true }), /http/);
|
|
381
|
+
} finally { srv.close(); }
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("MP 场景: mp_media_id 必传 + appid_type=mini 走 mini_program_info", async () => {
|
|
385
|
+
const { srv, base } = await startSeller();
|
|
386
|
+
try {
|
|
387
|
+
// 缺公众号截图 → 400 中文提示
|
|
388
|
+
const form = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
389
|
+
delete form.mp_media_id;
|
|
390
|
+
let r = await fetch(base + "/api/onboard/applyment", {
|
|
391
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
392
|
+
});
|
|
393
|
+
let j = await r.json();
|
|
394
|
+
assert.strictEqual(r.status, 400);
|
|
395
|
+
assert.strictEqual(j.error, "media_missing");
|
|
396
|
+
assert.strictEqual(j.field, "mp_media_id");
|
|
397
|
+
assert.ok(j.message.includes("公众号/小程序页面截图"), j.message);
|
|
398
|
+
// 补齐 → 200; mini 类型 → mini_program_info
|
|
399
|
+
const form2 = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
400
|
+
form2.appid_type = "mini";
|
|
401
|
+
r = await fetch(base + "/api/onboard/applyment", {
|
|
402
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form2),
|
|
403
|
+
});
|
|
404
|
+
j = await r.json();
|
|
405
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
406
|
+
const fwd = spCapture.submits[spCapture.submits.length - 1];
|
|
407
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
408
|
+
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_MINI_PROGRAM"]);
|
|
409
|
+
assert.strictEqual(wxBody.business_info.sales_info.mini_program_info.mini_program_sub_appid, "wx1234567890abcdef");
|
|
410
|
+
// 公众号类型 → mp_info.mp_sub_appid
|
|
411
|
+
const wxBody2 = spApplymentLib.buildApplymentBody({ ...fwd, appid_type: "mp", business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
412
|
+
assert.strictEqual(wxBody2.business_info.sales_info.mp_info.mp_sub_appid, "wx1234567890abcdef");
|
|
413
|
+
} finally { srv.close(); }
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("端到端兼容: 卖家本机 → 真 sp-applyment 路由 (mock 微信), already_encrypted 直通成功", async () => {
|
|
417
|
+
// 起真 SP 网关代码 (微信调用 mock), admin key = 商户 token
|
|
418
|
+
process.env.SP_APPLYMENT_MOCK = "1";
|
|
419
|
+
process.env.SP_ADMIN_API_KEY = MERCHANT_TOKEN;
|
|
420
|
+
const spApplyment = require("./routes/sp-applyment");
|
|
421
|
+
const spDb = new Database(":memory:");
|
|
422
|
+
const spApp = express();
|
|
423
|
+
spApp.use(express.json());
|
|
424
|
+
spApp.use("/api/sp", spApplyment.createSpApplymentRouter({ db: spDb }));
|
|
425
|
+
const realSp = spApp.listen(0);
|
|
426
|
+
await new Promise((r) => realSp.once("listening", r));
|
|
427
|
+
const { db, srv, base } = await startSeller();
|
|
428
|
+
try {
|
|
429
|
+
process.env.SP_GATEWAY_URL = `http://127.0.0.1:${realSp.address().port}`;
|
|
430
|
+
const form = await fillForm(base);
|
|
431
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
432
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
433
|
+
});
|
|
434
|
+
const j = await r.json();
|
|
435
|
+
assert.strictEqual(r.status, 200, "真 SP 路由应接受密文直通: " + JSON.stringify(j));
|
|
436
|
+
assert.match(j.business_code, /^TB\d{19}$/, "business_code 由真 SP 生成");
|
|
437
|
+
assert.strictEqual(j.applyment_id, "2000002124775691", "mock applyment_id");
|
|
438
|
+
// SP 侧台账落行
|
|
439
|
+
const spRow = spDb.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
440
|
+
assert.ok(spRow, "SP 台账落行");
|
|
441
|
+
// 状态端到端: 卖家 → 真 SP (mock FINISHED)
|
|
442
|
+
const st = await (await fetch(base + "/api/onboard/applyment/status")).json();
|
|
443
|
+
assert.strictEqual(st.status, "APPLYMENT_STATE_FINISHED");
|
|
444
|
+
assert.ok(st.sign_url);
|
|
445
|
+
// 本地台账同步 sub_mchid
|
|
446
|
+
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
447
|
+
assert.strictEqual(row.sub_mchid, "1600000999");
|
|
448
|
+
} finally {
|
|
449
|
+
srv.close(); realSp.close();
|
|
450
|
+
delete process.env.SP_APPLYMENT_MOCK;
|
|
451
|
+
delete process.env.SP_ADMIN_API_KEY;
|
|
452
|
+
process.env.SP_GATEWAY_URL = spBase;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
449
455
|
|
|
450
456
|
test("F1: REJECTED 后可同 business_code 覆盖重提 (卖家侧不幂等拦截)", async () => {
|
|
451
457
|
const { db, srv, base } = await startSeller();
|
|
@@ -523,7 +529,7 @@ test("F4: 企业主体 id_card_address 必填 + 本机加密 + SP 组装进 id_c
|
|
|
523
529
|
assert.notStrictEqual(fwd.legal_id_address, form.legal_id_address, "居住地址不是明文");
|
|
524
530
|
assert.ok(!JSON.stringify(fwd).includes("xx路1号101室"), "转发体无明文居住地址");
|
|
525
531
|
const dec = crypto.privateDecrypt(
|
|
526
|
-
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "
|
|
532
|
+
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" },
|
|
527
533
|
Buffer.from(fwd.legal_id_address, "base64")
|
|
528
534
|
).toString("utf8");
|
|
529
535
|
assert.strictEqual(dec, form.legal_id_address);
|
package/test-sp-applyment.js
CHANGED
|
@@ -163,7 +163,7 @@ test("submit mock: 落台账 + 敏感字段加密 (非明文且可还原) + 重
|
|
|
163
163
|
const encId = wxBody.subject_info.identity_info.id_card_info.id_card_number;
|
|
164
164
|
assert.notStrictEqual(encId, form.legal_id_number, "身份证号不是明文");
|
|
165
165
|
const dec = crypto.privateDecrypt(
|
|
166
|
-
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "
|
|
166
|
+
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" },
|
|
167
167
|
Buffer.from(encId, "base64")
|
|
168
168
|
).toString("utf8");
|
|
169
169
|
assert.strictEqual(dec, form.legal_id_number, "密文可还原");
|