mirror of
https://gitee.com/zoujingli/WeChatDeveloper.git
synced 2026-09-04 15:04:32 +08:00
377 lines
14 KiB
PHP
377 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace We\Tests;
|
|
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Handler\MockHandler;
|
|
use GuzzleHttp\HandlerStack;
|
|
use GuzzleHttp\Middleware;
|
|
use GuzzleHttp\Psr7\Request;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use PHPUnit\Framework\Attributes\CoversClass;
|
|
use PHPUnit\Framework\TestCase;
|
|
use We\Config\WechatPaymentConfig;
|
|
use We\Exception\ApiException;
|
|
use We\Exception\SignatureException;
|
|
use We\Exception\TransportException;
|
|
use We\Exception\WechatException;
|
|
use We\Platform\Wechat\PaymentClient as WechatPaymentClient;
|
|
use We\Support\Signature;
|
|
|
|
/**
|
|
* 微信支付 APIv3 通知验签与解密测试用例。
|
|
* @internal
|
|
*/
|
|
#[CoversClass(WechatPaymentClient::class)]
|
|
final class PaymentClientTest extends TestCase
|
|
{
|
|
public function testRequestReturnsOnlySignedPlatformResponse(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$body = "{\n \"prepay_id\": \"wx-prepay-1\"\n}";
|
|
$response = WechatPaymentResponse::signed(200, $body, $platformPrivateKey);
|
|
$client = self::paymentClient($response, $platformPublicKey);
|
|
|
|
$data = $client->post('v3/pay/transactions/jsapi', ['description' => 'signed response']);
|
|
|
|
self::assertSame('wx-prepay-1', $data['prepay_id']);
|
|
}
|
|
|
|
public function testRequestRejectsUnsignedPlatformResponse(): void
|
|
{
|
|
$client = self::paymentClient(
|
|
new Response(200, [], '{"prepay_id":"unsigned"}'),
|
|
TestKeys::platformKeyPair()[1],
|
|
);
|
|
|
|
$this->expectException(SignatureException::class);
|
|
$this->expectExceptionMessage('响应');
|
|
|
|
$client->post('v3/pay/transactions/jsapi', ['description' => 'unsigned response']);
|
|
}
|
|
|
|
public function testRequestRejectsInvalidPlatformSignature(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$response = WechatPaymentResponse::signed(200, '{"prepay_id":"tampered"}', $platformPrivateKey)
|
|
->withHeader('Wechatpay-Signature', base64_encode('invalid-signature'));
|
|
$client = self::paymentClient($response, $platformPublicKey);
|
|
|
|
$this->expectException(SignatureException::class);
|
|
|
|
$client->post('v3/pay/transactions/jsapi');
|
|
}
|
|
|
|
public function testRequestRejectsSignedNonJsonResponse(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$client = self::paymentClient(
|
|
WechatPaymentResponse::signed(200, 'not-json', $platformPrivateKey),
|
|
$platformPublicKey,
|
|
);
|
|
|
|
$this->expectException(ApiException::class);
|
|
$this->expectExceptionMessage('JSON');
|
|
|
|
$client->get('v3/certificates');
|
|
}
|
|
|
|
public function testRequestExposesTransportFailureType(): void
|
|
{
|
|
$failure = new ConnectException(
|
|
'connection failed',
|
|
new Request('GET', 'https://api.mch.weixin.qq.com/v3/certificates'),
|
|
);
|
|
$client = self::paymentClient($failure, TestKeys::platformKeyPair()[1]);
|
|
|
|
$this->expectException(TransportException::class);
|
|
|
|
$client->get('v3/certificates');
|
|
}
|
|
|
|
public function testNotificationRejectsTimestampOutsideDefaultWindow(): void
|
|
{
|
|
[$client, $headers, $body] = self::notificationFixture(time() - 301);
|
|
|
|
$this->expectException(SignatureException::class);
|
|
$this->expectExceptionMessage('时间戳');
|
|
|
|
$client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]);
|
|
}
|
|
|
|
public function testNotificationRejectsFutureTimestampOutsideDefaultWindow(): void
|
|
{
|
|
[$client, $headers, $body] = self::notificationFixture(time() + 301);
|
|
|
|
$this->expectException(SignatureException::class);
|
|
$this->expectExceptionMessage('时间戳');
|
|
|
|
$client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]);
|
|
}
|
|
|
|
public function testNotificationAcceptsConfiguredFreshnessWindow(): void
|
|
{
|
|
[$client, $headers, $body] = self::notificationFixture(time() - 600, 900);
|
|
|
|
$data = $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]);
|
|
|
|
self::assertSame('T-FRESHNESS', $data['out_trade_no']);
|
|
}
|
|
|
|
public function testNotificationFreshnessCanBeExplicitlyDisabled(): void
|
|
{
|
|
[$client, $headers, $body] = self::notificationFixture(1, 0);
|
|
|
|
$data = $client->post('decrypt_notification', [], ['headers' => $headers, 'raw_body' => $body]);
|
|
|
|
self::assertSame('T-FRESHNESS', $data['out_trade_no']);
|
|
}
|
|
|
|
public function testDownloadBillCompletesVerifiedTwoStageFlow(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$metadataBody = '{"download_url":"https://download.example.com/bill.csv?token=signed"}';
|
|
$history = [];
|
|
$stack = HandlerStack::create(new MockHandler([
|
|
WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey),
|
|
new Response(200, ['Content-Type' => 'text/csv'], "trade_no,amount\nT1,1\n"),
|
|
]));
|
|
$stack->push(Middleware::history($history));
|
|
$http = new Client(['handler' => $stack, 'base_uri' => 'https://api.mch.weixin.qq.com/']);
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
str_repeat('k', 32),
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
platformPublicKey: $platformPublicKey,
|
|
platformSerial: 'platform-serial',
|
|
), $http);
|
|
|
|
$response = $client->downloadBill('v3/bill/tradebill', ['bill_date' => '2026-08-10']);
|
|
|
|
self::assertSame("trade_no,amount\nT1,1\n", (string)$response->getBody());
|
|
self::assertCount(2, $history);
|
|
self::assertSame('api.mch.weixin.qq.com', $history[0]['request']->getUri()->getHost());
|
|
self::assertSame('download.example.com', $history[1]['request']->getUri()->getHost());
|
|
}
|
|
|
|
public function testDownloadBillRejectsNonHttpsDownloadUrl(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$metadataBody = '{"download_url":"http://127.0.0.1/internal"}';
|
|
$client = self::paymentClient(
|
|
WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey),
|
|
$platformPublicKey,
|
|
);
|
|
|
|
$this->expectException(WechatException::class);
|
|
$this->expectExceptionMessage('HTTPS');
|
|
|
|
$client->downloadBill('v3/bill/tradebill', ['bill_date' => '2026-08-10']);
|
|
}
|
|
|
|
public function testDownloadBillDoesNotFollowRedirects(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$metadataBody = '{"download_url":"https://download.example.com/bill.csv"}';
|
|
$history = [];
|
|
$stack = HandlerStack::create(new MockHandler([
|
|
WechatPaymentResponse::signed(200, $metadataBody, $platformPrivateKey),
|
|
new Response(302, ['Location' => 'http://127.0.0.1/internal']),
|
|
new Response(200, [], 'unsafe redirect result'),
|
|
]));
|
|
$stack->push(Middleware::history($history));
|
|
$http = new Client(['handler' => $stack, 'base_uri' => 'https://api.mch.weixin.qq.com/']);
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
str_repeat('k', 32),
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
platformPublicKey: $platformPublicKey,
|
|
platformSerial: 'platform-serial',
|
|
), $http);
|
|
|
|
try {
|
|
$client->downloadBill('v3/bill/tradebill');
|
|
self::fail('Expected redirect response to be rejected.');
|
|
} catch (ApiException $exception) {
|
|
self::assertSame(302, $exception->getCode());
|
|
self::assertCount(2, $history);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 测试微信支付回调验签使用原始请求体。
|
|
*/
|
|
public function testDecryptNotificationUsesRawBodyForSignature(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$apiV3Key = str_repeat('k', 32);
|
|
$nonce = '123456789012';
|
|
$aad = 'transaction';
|
|
$plain = '{"out_trade_no":"T202605040001","trade_state":"SUCCESS"}';
|
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
|
|
self::assertIsString($cipher);
|
|
|
|
$rawBody = "{\n \"id\": \"notify-id\",\n \"resource\": {\n \"ciphertext\": \"" . base64_encode($cipher . $tag) . "\",\n \"nonce\": \"{$nonce}\",\n \"associated_data\": \"{$aad}\"\n }\n}";
|
|
$timestamp = (string)time();
|
|
$notifyNonce = 'notify-nonce';
|
|
$headers = [
|
|
'Wechatpay-Timestamp' => $timestamp,
|
|
'Wechatpay-Nonce' => $notifyNonce,
|
|
'Wechatpay-Serial' => 'platform-serial',
|
|
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
|
];
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
$apiV3Key,
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
'',
|
|
$platformPublicKey,
|
|
'platform-serial',
|
|
));
|
|
|
|
$data = $client->post('decrypt_notification', [], [
|
|
'headers' => $headers,
|
|
'raw_body' => $rawBody,
|
|
]);
|
|
|
|
self::assertSame('T202605040001', $data['out_trade_no']);
|
|
self::assertSame('SUCCESS', $data['trade_state']);
|
|
}
|
|
|
|
/**
|
|
* 测试微信支付回调平台序列号不匹配时拒绝处理。
|
|
*/
|
|
public function testDecryptNotificationRejectsPlatformSerialMismatch(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$rawBody = '{"resource":{"ciphertext":"invalid","nonce":"nonce"}}';
|
|
$timestamp = (string)time();
|
|
$notifyNonce = 'notify-nonce';
|
|
$headers = [
|
|
'Wechatpay-Timestamp' => $timestamp,
|
|
'Wechatpay-Nonce' => $notifyNonce,
|
|
'Wechatpay-Serial' => 'other-serial',
|
|
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
|
];
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
str_repeat('k', 32),
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
'',
|
|
$platformPublicKey,
|
|
'platform-serial',
|
|
));
|
|
|
|
$this->expectException(SignatureException::class);
|
|
$this->expectExceptionMessage('序列号');
|
|
|
|
$client->post('decrypt_notification', [], [
|
|
'headers' => $headers,
|
|
'raw_body' => $rawBody,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 测试微信支付回调 resource 结构异常时抛出 SDK 异常而不是 TypeError。
|
|
*/
|
|
public function testDecryptNotificationRejectsInvalidResourceShape(): void
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$rawBody = '{"resource":"invalid"}';
|
|
$timestamp = (string)time();
|
|
$notifyNonce = 'notify-nonce';
|
|
$headers = [
|
|
'Wechatpay-Timestamp' => $timestamp,
|
|
'Wechatpay-Nonce' => $notifyNonce,
|
|
'Wechatpay-Serial' => 'platform-serial',
|
|
'Wechatpay-Signature' => Signature::paymentV3Sign($platformPrivateKey, "{$timestamp}\n{$notifyNonce}\n{$rawBody}\n"),
|
|
];
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
str_repeat('k', 32),
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
'',
|
|
$platformPublicKey,
|
|
'platform-serial',
|
|
));
|
|
|
|
$this->expectException(WechatException::class);
|
|
$this->expectExceptionMessage('resource');
|
|
|
|
$client->post('decrypt_notification', [], [
|
|
'headers' => $headers,
|
|
'raw_body' => $rawBody,
|
|
]);
|
|
}
|
|
|
|
private static function paymentClient(Response|\Throwable $result, string $platformPublicKey): WechatPaymentClient
|
|
{
|
|
$http = new Client([
|
|
'handler' => HandlerStack::create(new MockHandler([$result])),
|
|
'base_uri' => 'https://api.mch.weixin.qq.com/',
|
|
]);
|
|
|
|
return new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
str_repeat('k', 32),
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
platformPublicKey: $platformPublicKey,
|
|
platformSerial: 'platform-serial',
|
|
), $http);
|
|
}
|
|
|
|
/**
|
|
* @return array{0:WechatPaymentClient,1:array<string,string>,2:string}
|
|
*/
|
|
private static function notificationFixture(int $timestamp, int $freshnessWindow = 300): array
|
|
{
|
|
[$platformPrivateKey, $platformPublicKey] = TestKeys::platformKeyPair();
|
|
$apiV3Key = str_repeat('k', 32);
|
|
$nonce = '123456789012';
|
|
$aad = 'transaction';
|
|
$plain = '{"out_trade_no":"T-FRESHNESS"}';
|
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad);
|
|
self::assertIsString($cipher);
|
|
$body = '{"resource":{"ciphertext":"' . base64_encode($cipher . $tag)
|
|
. '","nonce":"' . $nonce . '","associated_data":"' . $aad . '"}}';
|
|
$timestampValue = (string)$timestamp;
|
|
$notifyNonce = 'freshness-nonce';
|
|
$headers = [
|
|
'Wechatpay-Timestamp' => $timestampValue,
|
|
'Wechatpay-Nonce' => $notifyNonce,
|
|
'Wechatpay-Serial' => 'platform-serial',
|
|
'Wechatpay-Signature' => Signature::paymentV3Sign(
|
|
$platformPrivateKey,
|
|
"{$timestampValue}\n{$notifyNonce}\n{$body}\n",
|
|
),
|
|
];
|
|
$client = new WechatPaymentClient(new WechatPaymentConfig(
|
|
'wx_app',
|
|
'mch_id',
|
|
$apiV3Key,
|
|
'merchant-serial',
|
|
TestKeys::privateKey(),
|
|
platformPublicKey: $platformPublicKey,
|
|
platformSerial: 'platform-serial',
|
|
notificationToleranceSeconds: $freshnessWindow,
|
|
));
|
|
|
|
return [$client, $headers, $body];
|
|
}
|
|
}
|