trustbase-seller-backend 0.2.8 → 0.2.9
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/sp-applyment.js +23 -5
- package/test-onboard-applyment.js +449 -449
- 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/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,451 @@
|
|
|
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"), "旧增强脚本预览补丁已删 (二重框根因)");
|
|
288
|
-
assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
|
|
289
|
-
assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
|
|
290
|
-
assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
|
|
291
|
-
} finally { srv.close(); }
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
/** 场景表单变体: 去掉门店字段, 换场景专属字段+照片 */
|
|
295
|
-
async function fillSceneForm(base, scene) {
|
|
296
|
-
const form = await fillForm(base);
|
|
297
|
-
delete form.store_name; delete form.store_address_code; delete form.store_address;
|
|
298
|
-
delete form.store_entrance_media_id; delete form.store_indoor_media_id;
|
|
299
|
-
form.sales_scene = scene;
|
|
300
|
-
if (scene === "SALES_SCENES_WEB") {
|
|
301
|
-
form.website_url = "https://shop.example.com";
|
|
302
|
-
for (const k of ["web_home_media_id", "web_product_media_id"]) {
|
|
303
|
-
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
304
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
305
|
-
body: JSON.stringify({ filename: k + ".jpg", content_b64: Buffer.from("fake-" + k).toString("base64") }),
|
|
306
|
-
});
|
|
307
|
-
form[k] = (await r.json()).media_id;
|
|
308
|
-
}
|
|
309
|
-
} else if (scene === "SALES_SCENES_MP") {
|
|
310
|
-
form.appid = "wx1234567890abcdef";
|
|
311
|
-
form.appid_type = "mp";
|
|
312
|
-
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
313
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
314
|
-
body: JSON.stringify({ filename: "mp.jpg", content_b64: Buffer.from("fake-mp").toString("base64") }),
|
|
315
|
-
});
|
|
316
|
-
form.mp_media_id = (await r.json()).media_id;
|
|
317
|
-
}
|
|
318
|
-
return form;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
test("错误提示: 缺 store_address → 中文 message + field 定位 (2026-10-03 实测 fix)", async () => {
|
|
322
|
-
const { srv, base } = await startSeller();
|
|
323
|
-
try {
|
|
324
|
-
const form = await fillForm(base);
|
|
325
|
-
delete form.store_address;
|
|
326
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
327
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
328
|
-
});
|
|
329
|
-
const j = await r.json();
|
|
330
|
-
assert.strictEqual(r.status, 400);
|
|
331
|
-
assert.strictEqual(j.error, "invalid_param");
|
|
332
|
-
assert.strictEqual(j.field, "store_address", "field 回传供前端高亮");
|
|
333
|
-
assert.ok(j.message.includes("门店详细地址"), "中文文案: " + j.message);
|
|
334
|
-
assert.ok(j.message.includes("(store_address)"), "带字段名: " + j.message);
|
|
335
|
-
} finally { srv.close(); }
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test("qualification_type 留空可提交 (后端默认 '无', 2026-10-03 实测秒拒 fix)", async () => {
|
|
339
|
-
const { srv, base } = await startSeller();
|
|
340
|
-
try {
|
|
341
|
-
const form = await fillForm(base);
|
|
342
|
-
delete form.qualification_type;
|
|
343
|
-
const before = spCapture.submits.length;
|
|
344
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
345
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
346
|
-
});
|
|
347
|
-
const j = await r.json();
|
|
348
|
-
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
349
|
-
assert.strictEqual(spCapture.submits[before].qualification_type, "无", "留空默认 '无'");
|
|
350
|
-
} finally { srv.close(); }
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("WEB 场景: 不带门店字段可提交, SP 侧组装 web_info", async () => {
|
|
354
|
-
const { srv, base } = await startSeller();
|
|
355
|
-
try {
|
|
356
|
-
const form = await fillSceneForm(base, "SALES_SCENES_WEB");
|
|
357
|
-
const before = spCapture.submits.length;
|
|
358
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
359
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
360
|
-
});
|
|
361
|
-
const j = await r.json();
|
|
362
|
-
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
363
|
-
const fwd = spCapture.submits[before];
|
|
364
|
-
assert.strictEqual(fwd.sales_scene, "SALES_SCENES_WEB");
|
|
365
|
-
assert.strictEqual(fwd.website_url, "https://shop.example.com");
|
|
366
|
-
assert.ok(!fwd.store_name, "不带门店字段");
|
|
367
|
-
// SP 侧结构: sales_info.web_info.domain (F2: 微信官方字段是 domain, 无 web_pics)
|
|
368
|
-
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
369
|
-
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_WEB"]);
|
|
370
|
-
assert.strictEqual(wxBody.business_info.sales_info.web_info.domain, "https://shop.example.com");
|
|
371
|
-
assert.ok(!("web_pics" in wxBody.business_info.sales_info.web_info), "微信 web_info 无 pics 字段");
|
|
372
|
-
assert.ok(!wxBody.business_info.sales_info.biz_store_info, "无门店结构");
|
|
373
|
-
// 网址不带协议 → SP 拒
|
|
374
|
-
assert.throws(() => spApplymentLib.buildApplymentBody({ ...fwd, website_url: "shop.example.com" }, (s) => s, { alreadyEncrypted: true }), /http/);
|
|
375
|
-
} finally { srv.close(); }
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
test("MP 场景: mp_media_id 必传 + appid_type=mini 走 mini_program_info", async () => {
|
|
379
|
-
const { srv, base } = await startSeller();
|
|
380
|
-
try {
|
|
381
|
-
// 缺公众号截图 → 400 中文提示
|
|
382
|
-
const form = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
383
|
-
delete form.mp_media_id;
|
|
384
|
-
let r = await fetch(base + "/api/onboard/applyment", {
|
|
385
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
386
|
-
});
|
|
387
|
-
let j = await r.json();
|
|
388
|
-
assert.strictEqual(r.status, 400);
|
|
389
|
-
assert.strictEqual(j.error, "media_missing");
|
|
390
|
-
assert.strictEqual(j.field, "mp_media_id");
|
|
391
|
-
assert.ok(j.message.includes("公众号/小程序页面截图"), j.message);
|
|
392
|
-
// 补齐 → 200; mini 类型 → mini_program_info
|
|
393
|
-
const form2 = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
394
|
-
form2.appid_type = "mini";
|
|
395
|
-
r = await fetch(base + "/api/onboard/applyment", {
|
|
396
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form2),
|
|
397
|
-
});
|
|
398
|
-
j = await r.json();
|
|
399
|
-
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
400
|
-
const fwd = spCapture.submits[spCapture.submits.length - 1];
|
|
401
|
-
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
402
|
-
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_MINI_PROGRAM"]);
|
|
403
|
-
assert.strictEqual(wxBody.business_info.sales_info.mini_program_info.mini_program_sub_appid, "wx1234567890abcdef");
|
|
404
|
-
// 公众号类型 → mp_info.mp_sub_appid
|
|
405
|
-
const wxBody2 = spApplymentLib.buildApplymentBody({ ...fwd, appid_type: "mp", business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
406
|
-
assert.strictEqual(wxBody2.business_info.sales_info.mp_info.mp_sub_appid, "wx1234567890abcdef");
|
|
407
|
-
} finally { srv.close(); }
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
test("端到端兼容: 卖家本机 → 真 sp-applyment 路由 (mock 微信), already_encrypted 直通成功", async () => {
|
|
411
|
-
// 起真 SP 网关代码 (微信调用 mock), admin key = 商户 token
|
|
412
|
-
process.env.SP_APPLYMENT_MOCK = "1";
|
|
413
|
-
process.env.SP_ADMIN_API_KEY = MERCHANT_TOKEN;
|
|
414
|
-
const spApplyment = require("./routes/sp-applyment");
|
|
415
|
-
const spDb = new Database(":memory:");
|
|
416
|
-
const spApp = express();
|
|
417
|
-
spApp.use(express.json());
|
|
418
|
-
spApp.use("/api/sp", spApplyment.createSpApplymentRouter({ db: spDb }));
|
|
419
|
-
const realSp = spApp.listen(0);
|
|
420
|
-
await new Promise((r) => realSp.once("listening", r));
|
|
421
|
-
const { db, srv, base } = await startSeller();
|
|
422
|
-
try {
|
|
423
|
-
process.env.SP_GATEWAY_URL = `http://127.0.0.1:${realSp.address().port}`;
|
|
424
|
-
const form = await fillForm(base);
|
|
425
|
-
const r = await fetch(base + "/api/onboard/applyment", {
|
|
426
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
427
|
-
});
|
|
428
|
-
const j = await r.json();
|
|
429
|
-
assert.strictEqual(r.status, 200, "真 SP 路由应接受密文直通: " + JSON.stringify(j));
|
|
430
|
-
assert.match(j.business_code, /^TB\d{19}$/, "business_code 由真 SP 生成");
|
|
431
|
-
assert.strictEqual(j.applyment_id, "2000002124775691", "mock applyment_id");
|
|
432
|
-
// SP 侧台账落行
|
|
433
|
-
const spRow = spDb.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
434
|
-
assert.ok(spRow, "SP 台账落行");
|
|
435
|
-
// 状态端到端: 卖家 → 真 SP (mock FINISHED)
|
|
436
|
-
const st = await (await fetch(base + "/api/onboard/applyment/status")).json();
|
|
437
|
-
assert.strictEqual(st.status, "APPLYMENT_STATE_FINISHED");
|
|
438
|
-
assert.ok(st.sign_url);
|
|
439
|
-
// 本地台账同步 sub_mchid
|
|
440
|
-
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
441
|
-
assert.strictEqual(row.sub_mchid, "1600000999");
|
|
442
|
-
} finally {
|
|
443
|
-
srv.close(); realSp.close();
|
|
444
|
-
delete process.env.SP_APPLYMENT_MOCK;
|
|
445
|
-
delete process.env.SP_ADMIN_API_KEY;
|
|
446
|
-
process.env.SP_GATEWAY_URL = spBase;
|
|
447
|
-
}
|
|
448
|
-
});
|
|
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
|
+
assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
|
|
289
|
+
assert.ok(html.includes("maybeAutoKyc"), "M2: 进件受理后自动 KYC 提交");
|
|
290
|
+
assert.ok(!html.includes("btn-verify"), "手动实名按钮已删 (自动触发)");
|
|
291
|
+
} finally { srv.close(); }
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/** 场景表单变体: 去掉门店字段, 换场景专属字段+照片 */
|
|
295
|
+
async function fillSceneForm(base, scene) {
|
|
296
|
+
const form = await fillForm(base);
|
|
297
|
+
delete form.store_name; delete form.store_address_code; delete form.store_address;
|
|
298
|
+
delete form.store_entrance_media_id; delete form.store_indoor_media_id;
|
|
299
|
+
form.sales_scene = scene;
|
|
300
|
+
if (scene === "SALES_SCENES_WEB") {
|
|
301
|
+
form.website_url = "https://shop.example.com";
|
|
302
|
+
for (const k of ["web_home_media_id", "web_product_media_id"]) {
|
|
303
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
304
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ filename: k + ".jpg", content_b64: Buffer.from("fake-" + k).toString("base64") }),
|
|
306
|
+
});
|
|
307
|
+
form[k] = (await r.json()).media_id;
|
|
308
|
+
}
|
|
309
|
+
} else if (scene === "SALES_SCENES_MP") {
|
|
310
|
+
form.appid = "wx1234567890abcdef";
|
|
311
|
+
form.appid_type = "mp";
|
|
312
|
+
const r = await fetch(base + "/api/onboard/applyment/upload", {
|
|
313
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
314
|
+
body: JSON.stringify({ filename: "mp.jpg", content_b64: Buffer.from("fake-mp").toString("base64") }),
|
|
315
|
+
});
|
|
316
|
+
form.mp_media_id = (await r.json()).media_id;
|
|
317
|
+
}
|
|
318
|
+
return form;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
test("错误提示: 缺 store_address → 中文 message + field 定位 (2026-10-03 实测 fix)", async () => {
|
|
322
|
+
const { srv, base } = await startSeller();
|
|
323
|
+
try {
|
|
324
|
+
const form = await fillForm(base);
|
|
325
|
+
delete form.store_address;
|
|
326
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
327
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
328
|
+
});
|
|
329
|
+
const j = await r.json();
|
|
330
|
+
assert.strictEqual(r.status, 400);
|
|
331
|
+
assert.strictEqual(j.error, "invalid_param");
|
|
332
|
+
assert.strictEqual(j.field, "store_address", "field 回传供前端高亮");
|
|
333
|
+
assert.ok(j.message.includes("门店详细地址"), "中文文案: " + j.message);
|
|
334
|
+
assert.ok(j.message.includes("(store_address)"), "带字段名: " + j.message);
|
|
335
|
+
} finally { srv.close(); }
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("qualification_type 留空可提交 (后端默认 '无', 2026-10-03 实测秒拒 fix)", async () => {
|
|
339
|
+
const { srv, base } = await startSeller();
|
|
340
|
+
try {
|
|
341
|
+
const form = await fillForm(base);
|
|
342
|
+
delete form.qualification_type;
|
|
343
|
+
const before = spCapture.submits.length;
|
|
344
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
345
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
346
|
+
});
|
|
347
|
+
const j = await r.json();
|
|
348
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
349
|
+
assert.strictEqual(spCapture.submits[before].qualification_type, "无", "留空默认 '无'");
|
|
350
|
+
} finally { srv.close(); }
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("WEB 场景: 不带门店字段可提交, SP 侧组装 web_info", async () => {
|
|
354
|
+
const { srv, base } = await startSeller();
|
|
355
|
+
try {
|
|
356
|
+
const form = await fillSceneForm(base, "SALES_SCENES_WEB");
|
|
357
|
+
const before = spCapture.submits.length;
|
|
358
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
359
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
360
|
+
});
|
|
361
|
+
const j = await r.json();
|
|
362
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
363
|
+
const fwd = spCapture.submits[before];
|
|
364
|
+
assert.strictEqual(fwd.sales_scene, "SALES_SCENES_WEB");
|
|
365
|
+
assert.strictEqual(fwd.website_url, "https://shop.example.com");
|
|
366
|
+
assert.ok(!fwd.store_name, "不带门店字段");
|
|
367
|
+
// SP 侧结构: sales_info.web_info.domain (F2: 微信官方字段是 domain, 无 web_pics)
|
|
368
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
369
|
+
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_WEB"]);
|
|
370
|
+
assert.strictEqual(wxBody.business_info.sales_info.web_info.domain, "https://shop.example.com");
|
|
371
|
+
assert.ok(!("web_pics" in wxBody.business_info.sales_info.web_info), "微信 web_info 无 pics 字段");
|
|
372
|
+
assert.ok(!wxBody.business_info.sales_info.biz_store_info, "无门店结构");
|
|
373
|
+
// 网址不带协议 → SP 拒
|
|
374
|
+
assert.throws(() => spApplymentLib.buildApplymentBody({ ...fwd, website_url: "shop.example.com" }, (s) => s, { alreadyEncrypted: true }), /http/);
|
|
375
|
+
} finally { srv.close(); }
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("MP 场景: mp_media_id 必传 + appid_type=mini 走 mini_program_info", async () => {
|
|
379
|
+
const { srv, base } = await startSeller();
|
|
380
|
+
try {
|
|
381
|
+
// 缺公众号截图 → 400 中文提示
|
|
382
|
+
const form = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
383
|
+
delete form.mp_media_id;
|
|
384
|
+
let r = await fetch(base + "/api/onboard/applyment", {
|
|
385
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
386
|
+
});
|
|
387
|
+
let j = await r.json();
|
|
388
|
+
assert.strictEqual(r.status, 400);
|
|
389
|
+
assert.strictEqual(j.error, "media_missing");
|
|
390
|
+
assert.strictEqual(j.field, "mp_media_id");
|
|
391
|
+
assert.ok(j.message.includes("公众号/小程序页面截图"), j.message);
|
|
392
|
+
// 补齐 → 200; mini 类型 → mini_program_info
|
|
393
|
+
const form2 = await fillSceneForm(base, "SALES_SCENES_MP");
|
|
394
|
+
form2.appid_type = "mini";
|
|
395
|
+
r = await fetch(base + "/api/onboard/applyment", {
|
|
396
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form2),
|
|
397
|
+
});
|
|
398
|
+
j = await r.json();
|
|
399
|
+
assert.strictEqual(r.status, 200, JSON.stringify(j));
|
|
400
|
+
const fwd = spCapture.submits[spCapture.submits.length - 1];
|
|
401
|
+
const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
402
|
+
assert.deepStrictEqual(wxBody.business_info.sales_info.sales_scenes_type, ["SALES_SCENES_MINI_PROGRAM"]);
|
|
403
|
+
assert.strictEqual(wxBody.business_info.sales_info.mini_program_info.mini_program_sub_appid, "wx1234567890abcdef");
|
|
404
|
+
// 公众号类型 → mp_info.mp_sub_appid
|
|
405
|
+
const wxBody2 = spApplymentLib.buildApplymentBody({ ...fwd, appid_type: "mp", business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
|
|
406
|
+
assert.strictEqual(wxBody2.business_info.sales_info.mp_info.mp_sub_appid, "wx1234567890abcdef");
|
|
407
|
+
} finally { srv.close(); }
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("端到端兼容: 卖家本机 → 真 sp-applyment 路由 (mock 微信), already_encrypted 直通成功", async () => {
|
|
411
|
+
// 起真 SP 网关代码 (微信调用 mock), admin key = 商户 token
|
|
412
|
+
process.env.SP_APPLYMENT_MOCK = "1";
|
|
413
|
+
process.env.SP_ADMIN_API_KEY = MERCHANT_TOKEN;
|
|
414
|
+
const spApplyment = require("./routes/sp-applyment");
|
|
415
|
+
const spDb = new Database(":memory:");
|
|
416
|
+
const spApp = express();
|
|
417
|
+
spApp.use(express.json());
|
|
418
|
+
spApp.use("/api/sp", spApplyment.createSpApplymentRouter({ db: spDb }));
|
|
419
|
+
const realSp = spApp.listen(0);
|
|
420
|
+
await new Promise((r) => realSp.once("listening", r));
|
|
421
|
+
const { db, srv, base } = await startSeller();
|
|
422
|
+
try {
|
|
423
|
+
process.env.SP_GATEWAY_URL = `http://127.0.0.1:${realSp.address().port}`;
|
|
424
|
+
const form = await fillForm(base);
|
|
425
|
+
const r = await fetch(base + "/api/onboard/applyment", {
|
|
426
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
|
|
427
|
+
});
|
|
428
|
+
const j = await r.json();
|
|
429
|
+
assert.strictEqual(r.status, 200, "真 SP 路由应接受密文直通: " + JSON.stringify(j));
|
|
430
|
+
assert.match(j.business_code, /^TB\d{19}$/, "business_code 由真 SP 生成");
|
|
431
|
+
assert.strictEqual(j.applyment_id, "2000002124775691", "mock applyment_id");
|
|
432
|
+
// SP 侧台账落行
|
|
433
|
+
const spRow = spDb.prepare("SELECT * FROM sp_applyments WHERE business_code = ?").get(j.business_code);
|
|
434
|
+
assert.ok(spRow, "SP 台账落行");
|
|
435
|
+
// 状态端到端: 卖家 → 真 SP (mock FINISHED)
|
|
436
|
+
const st = await (await fetch(base + "/api/onboard/applyment/status")).json();
|
|
437
|
+
assert.strictEqual(st.status, "APPLYMENT_STATE_FINISHED");
|
|
438
|
+
assert.ok(st.sign_url);
|
|
439
|
+
// 本地台账同步 sub_mchid
|
|
440
|
+
const row = db.prepare("SELECT * FROM onboard_applyments ORDER BY id DESC LIMIT 1").get();
|
|
441
|
+
assert.strictEqual(row.sub_mchid, "1600000999");
|
|
442
|
+
} finally {
|
|
443
|
+
srv.close(); realSp.close();
|
|
444
|
+
delete process.env.SP_APPLYMENT_MOCK;
|
|
445
|
+
delete process.env.SP_ADMIN_API_KEY;
|
|
446
|
+
process.env.SP_GATEWAY_URL = spBase;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
449
|
|
|
450
450
|
test("F1: REJECTED 后可同 business_code 覆盖重提 (卖家侧不幂等拦截)", async () => {
|
|
451
451
|
const { db, srv, base } = await startSeller();
|
|
@@ -523,7 +523,7 @@ test("F4: 企业主体 id_card_address 必填 + 本机加密 + SP 组装进 id_c
|
|
|
523
523
|
assert.notStrictEqual(fwd.legal_id_address, form.legal_id_address, "居住地址不是明文");
|
|
524
524
|
assert.ok(!JSON.stringify(fwd).includes("xx路1号101室"), "转发体无明文居住地址");
|
|
525
525
|
const dec = crypto.privateDecrypt(
|
|
526
|
-
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "
|
|
526
|
+
{ key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" },
|
|
527
527
|
Buffer.from(fwd.legal_id_address, "base64")
|
|
528
528
|
).toString("utf8");
|
|
529
529
|
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, "密文可还原");
|