Skip to content

Commit

Permalink
Two skipping pharses of the response's validation whose are located i…
Browse files Browse the repository at this point in the history
…n the mainland.

One special Rsa::verify onto the downloading API which is located in the overseas.

Close wechatpay-apiv3#94
  • Loading branch information
TheNorthMemory committed May 21, 2022
1 parent 0ddd126 commit 3e15430
Show file tree
Hide file tree
Showing 3 changed files with 222 additions and 1 deletion.
41 changes: 40 additions & 1 deletion src/ClientJsonTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
use function sprintf;
use function array_key_exists;
use function array_keys;
use function strcasecmp;
use function strncasecmp;
use function stripos;

use GuzzleHttp\Client;
use GuzzleHttp\Middleware;
Expand All @@ -30,6 +33,7 @@
const WechatpaySerial = 'Wechatpay-Serial';
const WechatpaySignature = 'Wechatpay-Signature';
const WechatpayTimestamp = 'Wechatpay-Timestamp';
const WechatpayStatementSha1 = 'Wechatpay-Statement-Sha1';

/**
* JSON based Client interface for sending HTTP requests.
Expand Down Expand Up @@ -88,6 +92,13 @@ public static function signer(string $mchid, string $serial, $privateKey): calla
protected static function assertSuccessfulResponse(array &$certs): callable
{
return static function (ResponseInterface $response, RequestInterface $request) use(&$certs): ResponseInterface {
if (
0 === strcasecmp($url = $request->getUri()->getPath(), '/v3/billdownload/file')
|| (0 === strncasecmp($url, '/v3/merchant-service/images/', 28) && false === stripos($url, 'upload', 28))
) {
return $response;
}

if (!($response->hasHeader(WechatpayNonce) && $response->hasHeader(WechatpaySerial)
&& $response->hasHeader(WechatpaySignature) && $response->hasHeader(WechatpayTimestamp))) {
throw new RequestException(sprintf(
Expand Down Expand Up @@ -117,10 +128,18 @@ protected static function assertSuccessfulResponse(array &$certs): callable
), $request, $response);
}

$isOverseas = 0 === strcasecmp($url, '/hk/v3/statements') && $response->hasHeader(WechatpayStatementSha1);

$verified = false;
try {
$verified = Crypto\Rsa::verify(
Formatter::response($timestamp, $nonce, static::body($response)),
Formatter::response(
$timestamp,
$nonce,
$isOverseas
? static::digestBody($response->getHeader(WechatpayStatementSha1)[0])
: static::body($response)
),
$signature, $certs[$serial]
);
} catch (\Exception $exception) {}
Expand All @@ -135,6 +154,26 @@ protected static function assertSuccessfulResponse(array &$certs): callable
};
}

/**
* Downloading the reconciliation was required the client to format the `WechatpayStatementSha1` digest string as `JSON`.
*
* There was also sugguestion that to validate the response streaming's `SHA1` digest whether or nor equals to `WechatpayStatementSha1`.
* Here may contains with or without `gzip` parameter. Both of them are validating the plain `CSV` stream.
* Keep the same logic with the mainland's one(without `SHA1` validation).
* If someone needs this feature built-in, contrubiting is welcome.
*
* @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/ch/fusion_wallet_ch/QuickPay/chapter8_5.shtml
* @see https://pay.weixin.qq.com/wiki/doc/api/wxpay/en/fusion_wallet/QuickPay/chapter8_5.shtml
*
* @param string $thing - The digest value
*
* @return string - The JSON string
*/
protected static function digestBody(string $thing): string
{
return sprintf('{"sha1":"%s"}', $thing);
}

/**
* APIv3's verifier middleware stack
*
Expand Down
45 changes: 45 additions & 0 deletions tests/OpenAPI/V3/MerchantService/Images/DownloadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ public function testGet(array $config, string $slot, ResponseInterface $respondo
$this->mock->reset();
$this->mock->append($respondor);
$this->mock->append($respondor);
$this->mock->append($respondor);

$response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([
'media_slot_url' => $slot,
]);
self::responseAssertion($response);

$response = $endpoint->chain('v3/merchant-service/images/{media_slot_url}')->get([
'handler' => $stack,
Expand Down Expand Up @@ -143,6 +149,13 @@ public function testGetAsync(array $config, string $slot, ResponseInterface $res
$this->mock->reset();
$this->mock->append($respondor);
$this->mock->append($respondor);
$this->mock->append($respondor);

$endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([
'media_slot_url' => $slot,
])->then(static function (ResponseInterface $response) {
self::responseAssertion($response);
})->wait();

$endpoint->chain('v3/merchant-service/images/{media_slot_url}')->getAsync([
'handler' => $stack,
Expand Down Expand Up @@ -177,19 +190,37 @@ public function testUseStandardGuzzleHttpClient(array $config, string $slot, Res

$this->mock->reset();

$this->mock->append($respondor);
$response = $apiv3Client->request('GET', $relativeUrl);
self::responseAssertion($response);

$this->mock->append($respondor);
$response = $apiv3Client->request('GET', $relativeUrl, ['handler' => $stack]);
self::responseAssertion($response);

$this->mock->append($respondor);
$response = $apiv3Client->request('GET', $fullUri);
self::responseAssertion($response);

$this->mock->append($respondor);
$response = $apiv3Client->request('GET', $fullUri, ['handler' => $stack]);
self::responseAssertion($response);

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */
$response = $apiv3Client->get($relativeUrl);
self::responseAssertion($response);

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */
$response = $apiv3Client->get($relativeUrl, ['handler' => $stack]);
self::responseAssertion($response);

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */
$response = $apiv3Client->get($fullUri);
self::responseAssertion($response);

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `get` method signature */
$response = $apiv3Client->get($fullUri, ['handler' => $stack]);
Expand All @@ -199,17 +230,31 @@ public function testUseStandardGuzzleHttpClient(array $config, string $slot, Res
self::responseAssertion($response);
};

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */
$response = $apiv3Client->getAsync($fullUri)->then($asyncAssertion)->wait();

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */
$response = $apiv3Client->getAsync($fullUri, ['handler' => $stack])->then($asyncAssertion)->wait();

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */
$response = $apiv3Client->getAsync($relativeUrl)->then($asyncAssertion)->wait();

$this->mock->append($respondor);
/** @phpstan-ignore-next-line because of \GuzzleHttp\ClientInterface no `getAsync` method signature */
$response = $apiv3Client->getAsync($relativeUrl, ['handler' => $stack])->then($asyncAssertion)->wait();

$this->mock->append($respondor);
$response = $apiv3Client->requestAsync('GET', $relativeUrl)->then($asyncAssertion)->wait();

$this->mock->append($respondor);
$response = $apiv3Client->requestAsync('GET', $relativeUrl, ['handler' => $stack])->then($asyncAssertion)->wait();

$this->mock->append($respondor);
$response = $apiv3Client->requestAsync('GET', $fullUri)->then($asyncAssertion)->wait();

$this->mock->append($respondor);
$response = $apiv3Client->requestAsync('GET', $fullUri, ['handler' => $stack])->then($asyncAssertion)->wait();
}
Expand Down
137 changes: 137 additions & 0 deletions tests/OpenAPI/V3/StatementsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php declare(strict_types=1);

namespace WeChatPay\Tests\OpenAPI\V3;

use function rtrim;
use function file_get_contents;
use function sprintf;
use function strlen;

use WeChatPay\Builder;
use WeChatPay\Formatter;
use WeChatPay\Crypto\Rsa;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\LazyOpenStream;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\ResponseInterface;
use PHPUnit\Framework\TestCase;

class StatementsTest extends TestCase
{
private const FIXTURES = 'file://' . __DIR__ . '/../../fixtures/%s';

/** @var MockHandler $mock */
private $mock;

private function guzzleMockStack(): HandlerStack
{
$this->mock = new MockHandler();

return HandlerStack::create($this->mock);
}

/**
* @param array<string,mixed> $config
* @return array{\WeChatPay\BuilderChainable}
*/
private function newInstance(array $config): array
{
$instance = Builder::factory($config + ['handler' => $this->guzzleMockStack(),]);

return [$instance];
}

/**
* @return array<string,array<mixed>>
*/
public function mockDataProvider(): array
{
$mchid = '1230000109';
$mchSerial = rtrim(file_get_contents(sprintf(self::FIXTURES, 'mock.serial.txt')) ?: '');
$mchPrivateKey = Rsa::from(sprintf(self::FIXTURES, 'mock.pkcs8.key'));
$platSerial = Formatter::nonce(40);
$platPublicKey = Rsa::from(sprintf(self::FIXTURES, 'mock.spki.pem'), Rsa::KEY_TYPE_PUBLIC);

$stream = new LazyOpenStream(sprintf(self::FIXTURES, 'bill.ALL.csv'), 'rb');

$platPrivateKey = Rsa::from(sprintf(self::FIXTURES, 'mock.pkcs1.key'));
$signature = Rsa::sign(Formatter::response(
$timestamp = (string)Formatter::timestamp(),
$nonce = Formatter::nonce(),
sprintf('{"sha1":"%s"}', $digest = Utils::hash($stream, 'SHA1'))
), $platPrivateKey);

return [
'configuration with base_uri' => [
[
'base_uri' => 'https://api.mch.weixin.qq.com/hk/',
'mchid' => $mchid,
'serial' => $mchSerial,
'privateKey' => $mchPrivateKey,
'certs' => [$platSerial => $platPublicKey]
],
new Response(200, [
'Content-Type' => 'text/plain',
'Wechatpay-Timestamp' => $timestamp,
'Wechatpay-Nonce' => $nonce,
'Wechatpay-Signature' => $signature,
'Wechatpay-Serial' => $platSerial,
'Wechatpay-Statement-Sha1' => $digest,
], $stream),
],
];
}

/**
* @dataProvider mockDataProvider
* @param array<string,mixed> $config
* @param ResponseInterface $respondor
*/
public function testGet(array $config, ResponseInterface $respondor): void
{
[$endpoint] = $this->newInstance($config);

$this->mock->reset();
$this->mock->append($respondor);

$response = $endpoint->chain('v3/statements')->get([
'date' => '20180103',
]);
self::responseAssertion($response);
}

/**
* @param ResponseInterface $response
*/
private static function responseAssertion(ResponseInterface $response): void
{
self::assertTrue($response->hasHeader('Content-Type'));
self::assertStringStartsWith('text/plain', $response->getHeaderLine('Content-Type'));
self::assertTrue($response->hasHeader('Wechatpay-Statement-Sha1'));
self::assertNotEmpty($digest = $response->getHeaderLine('Wechatpay-Statement-Sha1'));
self::assertTrue(strlen($digest) === 40);
self::assertEquals($digest, Utils::hash($response->getBody(), 'SHA1'));
}

/**
* @dataProvider mockDataProvider
* @param array<string,mixed> $config
* @param ResponseInterface $respondor
*/
public function testGetAsync(array $config, ResponseInterface $respondor): void
{
[$endpoint] = $this->newInstance($config);

$this->mock->reset();
$this->mock->append($respondor);

$endpoint->chain('v3/statements')->getAsync([
'date' => '20180103',
])->then(static function (ResponseInterface $response) {
self::responseAssertion($response);
$response->getBody()->close();// cleanup the opening file handler
})->wait();
}
}

0 comments on commit 3e15430

Please sign in to comment.