forked from wangziqi/gongxue-base
feat: add payment provider webhooks
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
@@ -32,12 +33,31 @@ const ids = {
|
||||
scorelineSchool: '00000000-0000-0000-0000-000000000831',
|
||||
};
|
||||
|
||||
const paymentFixture = (() => {
|
||||
const wechatMerchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const wechatPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const alipayApp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const alipayPlatform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
return {
|
||||
wechatMerchantPrivateKey: wechatMerchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
wechatMerchantPublicKey: wechatMerchant.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
wechatPlatformPrivateKey: wechatPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
wechatPlatformPublicKey: wechatPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
alipayAppPrivateKey: alipayApp.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
alipayAppPublicKey: alipayApp.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
alipayPlatformPrivateKey: alipayPlatform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
||||
alipayPlatformPublicKey: alipayPlatform.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
||||
wechatApiV3Key: '12345678901234567890123456789012',
|
||||
};
|
||||
})();
|
||||
|
||||
let apiBase = process.env.API_BASE || 'http://127.0.0.1:8787';
|
||||
let serverProcess = null;
|
||||
let serverLogs = '';
|
||||
let legacyDisabledServer = null;
|
||||
let legacyDisabledServerLogs = '';
|
||||
let fakeWechatServer = null;
|
||||
let fakeWechatPayServer = null;
|
||||
|
||||
function buildUrl(path, query = {}) {
|
||||
return buildUrlAt(apiBase, path, query);
|
||||
@@ -252,6 +272,76 @@ async function startFakeWechatServer() {
|
||||
};
|
||||
}
|
||||
|
||||
async function startFakeWechatPayServer() {
|
||||
const port = await getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const requests = [];
|
||||
fakeWechatPayServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
let raw = '';
|
||||
req.on('data', chunk => {
|
||||
raw += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
requests.push({
|
||||
method: req.method,
|
||||
pathname: url.pathname,
|
||||
headers: req.headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (url.pathname !== '/v3/pay/transactions/jsapi') {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 'NOT_FOUND' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ prepay_id: `prepay-${body.out_trade_no || 'unknown'}` }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeWechatPayServer.once('error', reject);
|
||||
fakeWechatPayServer.listen(port, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
endpoint: `${baseUrl}/v3/pay/transactions/jsapi`,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
function encryptWechatResource(plain) {
|
||||
const nonce = crypto.randomBytes(12).toString('base64url');
|
||||
const aad = 'transaction';
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(paymentFixture.wechatApiV3Key, 'utf8'), Buffer.from(nonce, 'utf8'));
|
||||
cipher.setAAD(Buffer.from(aad, 'utf8'));
|
||||
const encrypted = Buffer.concat([cipher.update(JSON.stringify(plain), 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return {
|
||||
algorithm: 'AEAD_AES_256_GCM',
|
||||
nonce,
|
||||
associated_data: aad,
|
||||
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function signWechatNotification(rawBody, timestamp, nonce) {
|
||||
const message = `${timestamp}\n${nonce}\n${rawBody}\n`;
|
||||
return crypto.createSign('RSA-SHA256').update(message).sign(paymentFixture.wechatPlatformPrivateKey, 'base64');
|
||||
}
|
||||
|
||||
function signAlipayParams(params) {
|
||||
const canonical = Object.keys(params)
|
||||
.filter(key => !['sign', 'sign_type'].includes(key) && params[key] !== undefined && params[key] !== null && params[key] !== '')
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
|
||||
}
|
||||
|
||||
async function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -422,10 +512,14 @@ function stopServer() {
|
||||
fakeWechatServer.close();
|
||||
fakeWechatServer = null;
|
||||
}
|
||||
if (fakeWechatPayServer) {
|
||||
fakeWechatPayServer.close();
|
||||
fakeWechatPayServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testCatalogAndLearning() {
|
||||
const questions = await request('/api/catalog/questions', { query: { limit: 20 } });
|
||||
const questions = await request('/api/catalog/questions', { query: { limit: 500 } });
|
||||
const question = questions.items?.find(item => item.id === ids.question);
|
||||
assert.ok(question, 'main tenant should return smoke question');
|
||||
assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker');
|
||||
@@ -597,6 +691,182 @@ async function testCommerce() {
|
||||
assert.ok(Array.isArray(entitlements.items), 'entitlements should return a list');
|
||||
assert.ok(entitlements.summary && typeof entitlements.summary.isSvip === 'boolean', 'entitlements should include summary');
|
||||
assert.equal(entitlements.summary.isSvip, true, 'redeemed activation code should make smoke user SVIP');
|
||||
|
||||
const fakeWechatPay = await startFakeWechatPayServer();
|
||||
const wechatAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
provider: 'wechat_pay',
|
||||
mode: 'tenant_collect',
|
||||
displayName: '集成测试微信支付',
|
||||
status: 'active',
|
||||
configPublic: {
|
||||
appId: 'wx-pay-smoke-appid',
|
||||
merchantId: '1900000001',
|
||||
merchantSerialNo: 'serial-smoke',
|
||||
notifyUrl: 'https://pay.example.test/wechat/notify',
|
||||
endpoint: fakeWechatPay.endpoint,
|
||||
wechatpayPublicKey: paymentFixture.wechatPlatformPublicKey,
|
||||
},
|
||||
secret: {
|
||||
secretJson: {
|
||||
privateKey: paymentFixture.wechatMerchantPrivateKey,
|
||||
apiV3Key: paymentFixture.wechatApiV3Key,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(wechatAccount.item?.provider, 'wechat_pay', 'tenant admin should configure active WeChat Pay account');
|
||||
assert.ok(!JSON.stringify(wechatAccount).includes(paymentFixture.wechatApiV3Key), 'payment account response must not leak apiV3Key');
|
||||
|
||||
const wechatOrder = await request('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
planId: '00000000-0000-0000-0000-000000000201',
|
||||
payProvider: 'wechat_pay',
|
||||
payMethod: 'jsapi',
|
||||
regionId: ids.region,
|
||||
},
|
||||
});
|
||||
assert.ok(wechatOrder.item?.orderNo, 'student should create a WeChat Pay order');
|
||||
|
||||
const wechatPayment = await request('/api/commerce/payments/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: wechatOrder.item.orderNo,
|
||||
provider: 'wechat_pay',
|
||||
openId: 'openid-pay-smoke',
|
||||
},
|
||||
});
|
||||
assert.equal(wechatPayment.item?.provider, 'wechat_pay', 'payment create should use WeChat Pay provider');
|
||||
assert.ok(wechatPayment.item?.paymentParams?.paySign, 'WeChat Pay create should return signed JSAPI params');
|
||||
assert.equal(fakeWechatPay.requests.at(-1)?.body?.out_trade_no, wechatOrder.item.orderNo, 'WeChat Pay provider should call transaction endpoint with order number');
|
||||
|
||||
const wechatNotificationBody = {
|
||||
id: `evt-${wechatOrder.item.orderNo}`,
|
||||
create_time: '2026-06-28T00:00:00+08:00',
|
||||
event_type: 'TRANSACTION.SUCCESS',
|
||||
resource_type: 'encrypt-resource',
|
||||
resource: encryptWechatResource({
|
||||
appid: 'wx-pay-smoke-appid',
|
||||
mchid: '1900000001',
|
||||
out_trade_no: wechatOrder.item.orderNo,
|
||||
transaction_id: `wx-trade-${wechatOrder.item.orderNo}`,
|
||||
trade_state: 'SUCCESS',
|
||||
success_time: '2026-06-28T00:00:00+08:00',
|
||||
amount: { total: wechatOrder.item.amountCents, currency: 'CNY' },
|
||||
}),
|
||||
};
|
||||
const wechatRaw = JSON.stringify(wechatNotificationBody);
|
||||
const wechatTimestamp = String(Math.floor(Date.now() / 1000));
|
||||
const wechatNonce = 'nonce-pay-smoke';
|
||||
const wechatNotify = await request('/api/commerce/payments/notify/wechat_pay', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
query: { tenantId: MAIN_TENANT_ID },
|
||||
headers: {
|
||||
'wechatpay-timestamp': wechatTimestamp,
|
||||
'wechatpay-nonce': wechatNonce,
|
||||
'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce),
|
||||
'wechatpay-serial': 'platform-serial-smoke',
|
||||
},
|
||||
body: wechatNotificationBody,
|
||||
});
|
||||
assert.equal(wechatNotify.item?.status, 'paid', 'WeChat Pay notify should mark order paid');
|
||||
assert.ok(wechatNotify.item?.entitlement?.id, 'WeChat Pay notify should grant entitlement');
|
||||
|
||||
const wechatNotifyAgain = await request('/api/commerce/payments/notify/wechat_pay', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
query: { tenantId: MAIN_TENANT_ID },
|
||||
headers: {
|
||||
'wechatpay-timestamp': wechatTimestamp,
|
||||
'wechatpay-nonce': wechatNonce,
|
||||
'wechatpay-signature': signWechatNotification(wechatRaw, wechatTimestamp, wechatNonce),
|
||||
'wechatpay-serial': 'platform-serial-smoke',
|
||||
},
|
||||
body: wechatNotificationBody,
|
||||
});
|
||||
assert.equal(wechatNotifyAgain.item?.idempotent, true, 'duplicate WeChat Pay notify should be idempotent');
|
||||
|
||||
const alipayAccount = await request('/api/tenant-admin/payment-accounts', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
provider: 'alipay',
|
||||
mode: 'tenant_collect',
|
||||
displayName: '集成测试支付宝',
|
||||
status: 'active',
|
||||
configPublic: {
|
||||
appId: 'alipay-smoke-appid',
|
||||
notifyUrl: 'https://pay.example.test/alipay/notify',
|
||||
returnUrl: 'https://app.example.test/pay/success',
|
||||
},
|
||||
secret: {
|
||||
secretJson: {
|
||||
privateKey: paymentFixture.alipayAppPrivateKey,
|
||||
alipayPublicKey: paymentFixture.alipayPlatformPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(alipayAccount.item?.provider, 'alipay', 'tenant admin should configure active Alipay account');
|
||||
assert.ok(!JSON.stringify(alipayAccount).includes('PRIVATE KEY'), 'Alipay account response must not leak private key');
|
||||
|
||||
const alipayOrder = await request('/api/commerce/orders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
planId: '00000000-0000-0000-0000-000000000201',
|
||||
payProvider: 'alipay',
|
||||
payMethod: 'wap',
|
||||
regionId: ids.region,
|
||||
},
|
||||
});
|
||||
const alipayPayment = await request('/api/commerce/payments/create', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
orderNo: alipayOrder.item.orderNo,
|
||||
provider: 'alipay',
|
||||
},
|
||||
});
|
||||
assert.equal(alipayPayment.item?.provider, 'alipay', 'payment create should use Alipay provider');
|
||||
assert.ok(alipayPayment.item?.paymentParams?.url?.includes('alipay.trade.wap.pay'), 'Alipay create should return WAP URL');
|
||||
|
||||
const alipayNotifyBody = {
|
||||
notify_id: `alipay-notify-${alipayOrder.item.orderNo}`,
|
||||
notify_time: '2026-06-28 00:00:00',
|
||||
app_id: 'alipay-smoke-appid',
|
||||
trade_no: `ali-trade-${alipayOrder.item.orderNo}`,
|
||||
out_trade_no: alipayOrder.item.orderNo,
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: (alipayOrder.item.amountCents / 100).toFixed(2),
|
||||
receipt_amount: (alipayOrder.item.amountCents / 100).toFixed(2),
|
||||
charset: 'utf-8',
|
||||
version: '1.0',
|
||||
};
|
||||
alipayNotifyBody.sign_type = 'RSA2';
|
||||
alipayNotifyBody.sign = signAlipayParams(alipayNotifyBody);
|
||||
const alipayNotify = await request('/api/commerce/payments/notify/alipay', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
query: { tenantId: MAIN_TENANT_ID },
|
||||
body: alipayNotifyBody,
|
||||
});
|
||||
assert.equal(alipayNotify.item?.status, 'paid', 'Alipay notify should mark order paid');
|
||||
assert.ok(alipayNotify.item?.entitlement?.id, 'Alipay notify should grant entitlement');
|
||||
|
||||
const tamperedAlipayNotify = await request('/api/commerce/payments/notify/alipay', {
|
||||
userId: false,
|
||||
method: 'POST',
|
||||
query: { tenantId: MAIN_TENANT_ID },
|
||||
body: {
|
||||
...alipayNotifyBody,
|
||||
total_amount: '0.01',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(tamperedAlipayNotify.code, 'PAYMENT_SIGNATURE_INVALID', 'tampered Alipay notify must fail signature verification');
|
||||
}
|
||||
|
||||
async function testTenantIsolation() {
|
||||
|
||||
Reference in New Issue
Block a user