fix: harden alipay direct pay flow

This commit is contained in:
daguimu
2026-03-10 11:52:37 +08:00
parent 2492031e13
commit 8b10bc3bd5
30 changed files with 1893 additions and 437 deletions

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
}),
}));
const { mockGenerateSign } = vi.hoisted(() => ({
mockGenerateSign: vi.fn(() => 'signed-value'),
}));
vi.mock('@/lib/alipay/sign', () => ({
generateSign: mockGenerateSign,
}));
import { execute, pageExecute } from '@/lib/alipay/client';
describe('alipay client helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('pageExecute includes notify_url and return_url by default', () => {
const url = new URL(
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
);
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
expect(url.searchParams.get('sign')).toBe('signed-value');
});
it('pageExecute omits return_url when explicitly disabled', () => {
const url = new URL(
pageExecute(
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
),
);
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
expect(url.searchParams.get('return_url')).toBeNull();
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
});
it('execute posts form data and returns the named response payload', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '10000',
msg: 'Success',
trade_status: 'TRADE_SUCCESS',
},
sign: 'server-sign',
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://openapi.alipay.com/gateway.do');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
expect(String(init.body)).toContain('method=alipay.trade.query');
});
it('execute throws when alipay response code is not successful', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '40004',
msg: 'Business Failed',
sub_code: 'ACQ.TRADE_NOT_EXIST',
sub_msg: 'trade not exist',
},
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
'[ACQ.TRADE_NOT_EXIST] trade not exist',
);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
describe('Alipay codec', () => {
it('should normalize plus signs in notify sign parameter', () => {
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(params.sign).toBe('abc+def');
expect(params.trade_no).toBe('1');
});
it('should decode payload charset from content-type header', () => {
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
const decoded = decodeAlipayPayload(body, {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
});
it('should fallback to body charset hint when header is missing', () => {
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
const decoded = decodeAlipayPayload(body);
expect(decoded).toContain('trade_no=202603090001');
});
});

View File

@@ -8,6 +8,7 @@ vi.mock('@/lib/config', () => ({
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
PRODUCT_NAME: 'Sub2API Balance Recharge',
}),
}));
@@ -25,7 +26,7 @@ vi.mock('@/lib/alipay/sign', () => ({
verifySign: (...args: unknown[]) => mockVerifySign(...args),
}));
import { AlipayProvider } from '@/lib/alipay/provider';
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
describe('AlipayProvider', () => {
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
});
describe('createPayment', () => {
it('should call pageExecute and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
it('should return service short link as desktop qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-001',
amount: 100,
paymentType: 'alipay',
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 100.00 CNY',
clientIp: '127.0.0.1',
};
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-001');
expect(result.qrCode).toBe('https://pay.example.com/pay/order-001');
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
expect(mockExecute).not.toHaveBeenCalled();
expect(mockPageExecute).not.toHaveBeenCalled();
});
it('should build short link from app url', () => {
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
});
it('should call pageExecute for mobile and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
const request: CreatePaymentRequest = {
orderId: 'order-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 50.00 CNY',
clientIp: '127.0.0.1',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-002');
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
expect(mockPageExecute).toHaveBeenCalledWith(
{
out_trade_no: 'order-001',
product_code: 'FAST_INSTANT_TRADE_PAY',
total_amount: '100.00',
subject: 'Sub2API Balance Recharge 100.00 CNY',
out_trade_no: 'order-002',
product_code: 'QUICK_WAP_WAY',
total_amount: '50.00',
subject: 'Sub2API Balance Recharge 50.00 CNY',
},
expect.objectContaining({}),
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
expect(mockExecute).not.toHaveBeenCalled();
});
});
@@ -140,6 +165,15 @@ describe('AlipayProvider', () => {
const result = await provider.queryOrder('order-004');
expect(result.status).toBe('failed');
});
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
const result = await provider.queryOrder('order-005');
expect(result.tradeNo).toBe('order-005');
expect(result.status).toBe('pending');
expect(result.amount).toBe(0);
});
});
describe('verifyNotification', () => {
@@ -188,7 +222,7 @@ describe('AlipayProvider', () => {
trade_no: '2026030500003',
out_trade_no: 'order-003',
trade_status: 'TRADE_CLOSED',
total_amount: '30.00',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
@@ -198,80 +232,98 @@ describe('AlipayProvider', () => {
expect(result.status).toBe('failed');
});
it('should throw on invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
it('should reject unsupported sign_type', async () => {
const body = new URLSearchParams({
trade_no: '2026030500004',
out_trade_no: 'order-004',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
});
it('should reject invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
const body = new URLSearchParams({
trade_no: '2026030500005',
out_trade_no: 'order-005',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'bad_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
'Alipay notification signature verification failed',
);
});
it('should reject app_id mismatch', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500006',
out_trade_no: 'order-006',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000009999',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
});
});
describe('refund', () => {
it('should call alipay.trade.refund and return success', async () => {
it('should request refund and map success status', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
trade_no: 'refund-trade-no',
fund_change: 'Y',
});
const request: RefundRequest = {
tradeNo: '2026030500001',
orderId: 'order-001',
amount: 100,
reason: 'customer request',
tradeNo: 'trade-no',
orderId: 'order-refund',
amount: 12.34,
reason: 'test refund',
};
const result = await provider.refund(request);
expect(result.refundId).toBe('2026030500001');
expect(result.status).toBe('success');
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
out_trade_no: 'order-001',
refund_amount: '100.00',
refund_reason: 'customer request',
out_request_no: 'order-001-refund',
out_trade_no: 'order-refund',
refund_amount: '12.34',
refund_reason: 'test refund',
out_request_no: 'order-refund-refund',
});
});
it('should return pending when fund_change is N', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500002',
fund_change: 'N',
});
const result = await provider.refund({
tradeNo: '2026030500002',
orderId: 'order-002',
amount: 50,
});
expect(result.status).toBe('pending');
});
});
describe('cancelPayment', () => {
it('should call alipay.trade.close', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
});
it('should close payment by out_trade_no', async () => {
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
await provider.cancelPayment('order-close');
await provider.cancelPayment('order-001');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
out_trade_no: 'order-001',
out_trade_no: 'order-close',
});
});
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
});
});
});

View File

@@ -34,7 +34,6 @@ describe('Alipay RSA2 Sign', () => {
const sign = generateSign(testParams, privateKey);
expect(sign).toBeTruthy();
expect(typeof sign).toBe('string');
// base64 格式
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
});
@@ -44,16 +43,15 @@ describe('Alipay RSA2 Sign', () => {
expect(sign1).toBe(sign2);
});
it('should filter out sign and sign_type fields', () => {
it('should filter out sign field but keep sign_type in request signing', () => {
const paramsWithSign = { ...testParams, sign: 'old_sign' };
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(paramsWithSign, privateKey);
expect(sign1).toBe(sign2);
// sign_type should also be excluded from signing (per Alipay spec)
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
const sign3 = generateSign(paramsWithSignType, privateKey);
expect(sign3).toBe(sign1);
expect(sign3).not.toBe(sign1);
});
it('should filter out empty values', () => {
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
it('should work with private key using literal \\n escapes', () => {
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, escapedPrivateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should work with public key using literal \\n escapes', () => {
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, escapedPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with CRLF-formatted PEM keys', () => {
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
const sign = generateSign(testParams, crlfPrivateKey);
const valid = verifySign(testParams, crlfPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with literal \\r\\n escapes in PEM keys', () => {
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
const sign = generateSign(testParams, escapedCrlfPrivateKey);
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
expect(valid).toBe(true);
});
});
});