diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7f8c40c81..6c5a4b901 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,11 +4,9 @@ name: Deploy # Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the 6.x branch + # Triggers the workflow on push event but only for the 6.x branch(required the secrets environment) push: branches: [ 6.x ] - pull_request: - branches: [ 6.x ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/src/Pay/Client.php b/src/Pay/Client.php index f134bd5fb..4942d46ed 100644 --- a/src/Pay/Client.php +++ b/src/Pay/Client.php @@ -30,8 +30,10 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; +use function array_key_exists; use function is_array; use function is_string; +use function ltrim; use function str_starts_with; use function strcasecmp; @@ -164,9 +166,11 @@ public function request(string $method, string $url, array $options = []): Respo failureJudge: $this->isV3Request($url) ? null : function (Response $response) use ($url): bool { $arr = $response->toArray(); + if ($url === self::V2_URI_OVER_GETS[0]) { + return ! (array_key_exists('retcode', $arr) && $arr['retcode'] === 0); + } + return ! ( - $url === self::V2_URI_OVER_GETS[0] && array_key_exists('retcode', $arr) && $arr['retcode'] === 0 - ) || ! ( // protocol code, most similar to the HTTP status code in APIv3 array_key_exists('return_code', $arr) && $arr['return_code'] === 'SUCCESS' ) || ( @@ -180,7 +184,7 @@ public function request(string $method, string $url, array $options = []): Respo protected function isV3Request(string $url): bool { - $uri = (new Uri($url))->getPath(); + $uri = '/'.ltrim((new Uri($url))->getPath(), '/'); foreach (self::V3_URI_PREFIXES as $prefix) { if (str_starts_with($uri, $prefix)) { diff --git a/src/Pay/Server.php b/src/Pay/Server.php index 3f7726712..998dcfaff 100644 --- a/src/Pay/Server.php +++ b/src/Pay/Server.php @@ -18,7 +18,9 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use function array_key_exists; use function is_array; +use function is_string; use function json_decode; use function json_encode; use function str_contains; @@ -141,6 +143,20 @@ protected function decodeXmlMessage(string $contents): array $attributes = Xml::parse(AesEcb::decrypt($attributes['req_info'], md5($key), iv: '')); } + if ( + is_array($attributes) + && array_key_exists('event_ciphertext', $attributes) && is_string($attributes['event_ciphertext']) + && array_key_exists('event_nonce', $attributes) && is_string($attributes['event_nonce']) + && array_key_exists('event_associated_data', $attributes) && is_string($attributes['event_associated_data']) + ) { + $attributes += Xml::parse(AesGcm::decrypt( + $attributes['event_ciphertext'], + $this->merchant->getSecretKey(), + $attributes['event_nonce'], + $attributes['event_associated_data'] // maybe empty string + )); + } + if (! is_array($attributes)) { throw new RuntimeException('Failed to decrypt request message.'); } diff --git a/tests/Pay/ClientTest.php b/tests/Pay/ClientTest.php index b6f1666c0..a26ed48e5 100644 --- a/tests/Pay/ClientTest.php +++ b/tests/Pay/ClientTest.php @@ -126,6 +126,23 @@ public function test_v2_request_with_xml_string_as_body() $this->assertSame(Xml::build(['foo' => 'bar']), $client->getRequestOptions()['body']); } + public function test_v2_request_appauth_getaccesstoken() + { + $client = Client::mock('{"retcode":-1,"access_token":"mock-token"}', 200, ['Content-Type' => 'application/json']); + $client->shouldReceive('createSignature')->never(); + $client->shouldReceive('isV3Request')->andReturn(false); + $client->shouldReceive('attachLegacySignature')->with([ + 'foo' => 'bar', + ])->andReturn(['foo' => 'bar', 'sign' => 'mock-signature']); + + $response = $client->get('/appauth/getaccesstoken', ['query' => ['foo' => 'bar']]); + + $this->assertSame('GET', $client->getRequestMethod()); + $this->assertEquals(['foo' => 'bar', 'sign' => 'mock-signature'], $client->getRequestOptions()['query']); + $this->assertSame('https://api.mch.weixin.qq.com/appauth/getaccesstoken?foo=bar&sign=mock-signature', $client->getRequestUrl()); + $this->assertContains('Content-Type: text/xml', $client->getRequestOptions()['headers']); + } + public function test_v3_upload_media() { $client = Client::mock(); diff --git a/tests/Pay/ServerTest.php b/tests/Pay/ServerTest.php index 266316c3a..0f21532c3 100644 --- a/tests/Pay/ServerTest.php +++ b/tests/Pay/ServerTest.php @@ -4,10 +4,22 @@ namespace EasyWeChat\Tests\Pay; +use EasyWeChat\Kernel\Support\AesEcb; +use EasyWeChat\Kernel\Support\AesGcm; +use EasyWeChat\Kernel\Support\Xml; use EasyWeChat\Pay\Contracts\Merchant; +use EasyWeChat\Pay\Message; use EasyWeChat\Pay\Server; use EasyWeChat\Tests\TestCase; +use Mockery\LegacyMockInterface; +use Nyholm\Psr7\Response; use Nyholm\Psr7\ServerRequest; +use Psr\Http\Message\ResponseInterface; + +use function bin2hex; +use function fopen; +use function md5; +use function random_bytes; class ServerTest extends TestCase { @@ -16,10 +28,13 @@ public function test_it_will_handle_validation_request() $request = (new ServerRequest( 'POST', 'http://easywechat.com/', - [], + [ + 'Content-Type' => 'application/json', + ], fopen(__DIR__.'/../fixtures/files/pay_demo.json', 'r') )); + /** @var Merchant&LegacyMockInterface $merchant */ $merchant = \Mockery::mock(Merchant::class); $merchant->shouldReceive('getSecretKey')->andReturn('key'); @@ -28,4 +43,106 @@ public function test_it_will_handle_validation_request() $response = $server->serve(); $this->assertSame('{"code":"SUCCESS","message":"成功"}', \strval($response->getBody())); } + + public function test_legacy_encryped_by_aesecb_refund_request() + { + /** @var Merchant&LegacyMockInterface $merchant */ + $merchant = \Mockery::mock(Merchant::class); + $merchant->shouldReceive(['getV2SecretKey' => random_bytes(32)]); + $symmtricKey = $merchant->getV2SecretKey(); + + $server = new Server($merchant, new ServerRequest( + 'POST', + 'http://easywechat.com/sample-webhook-handler', + [ + 'Content-Type' => 'text/xml', + ], + Xml::build([ + 'return_code' => 'SUCCESS', + 'req_info' => AesEcb::encrypt(Xml::build([ + 'refund_id' => '50000408942018111907145868882', + 'transaction_id' => '4200000215201811190261405420', + ]), md5($symmtricKey), ''), + ]) + )); + + $response = $server->with(function (Message $message): ResponseInterface { + $source = $message->getOriginalContents(); + $parsed = $message->toArray(); + + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringNotContainsString('', $source); + $this->assertStringNotContainsString('', $source); + $this->assertArrayNotHasKey('return_code', $parsed); + $this->assertArrayNotHasKey('req_info', $parsed); + $this->assertArrayHasKey('refund_id', $parsed); + $this->assertArrayHasKey('transaction_id', $parsed); + + return new Response( + 200, + ['Content-Type' => 'text/xml'], + 'SUCCESS' + ); + })->serve(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertSame('SUCCESS', \strval($response->getBody())); + } + + public function test_legacy_encryped_by_aesgcm_notification_request() + { + /** @var Merchant&LegacyMockInterface $merchant */ + $merchant = \Mockery::mock(Merchant::class); + $merchant->shouldReceive(['getSecretKey' => random_bytes(32)]); + $symmtricKey = $merchant->getSecretKey(); + + $server = new Server($merchant, new ServerRequest( + 'POST', + 'http://easywechat.com/sample-webhook-handler', + [ + 'Content-Type' => 'text/xml', + ], + Xml::build([ + 'event_type' => 'TRANSACTION.SUCCESS', + 'event_algorithm' => 'AEAD_AES_256_GCM', + 'event_nonce' => $nonce = bin2hex(random_bytes(6)), + 'event_associated_data' => $aad = '', + 'event_ciphertext' => AesGcm::encrypt(Xml::build([ + 'state' => 'USER_PAID', + 'service_id' => '1234352342', + ]), $symmtricKey, iv: $nonce, aad: $aad), + ]) + )); + + $response = $server->with(function (Message $message): ResponseInterface { + $source = $message->getOriginalContents(); + $parsed = $message->toArray(); + + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringContainsString('', $source); + $this->assertStringNotContainsString('', $source); + $this->assertStringNotContainsString('', $source); + $this->assertArrayHasKey('event_type', $parsed); + $this->assertArrayHasKey('event_algorithm', $parsed); + $this->assertArrayHasKey('event_nonce', $parsed); + $this->assertArrayHasKey('event_associated_data', $parsed); + $this->assertArrayHasKey('event_ciphertext', $parsed); + $this->assertArrayHasKey('state', $parsed); + $this->assertArrayHasKey('service_id', $parsed); + + return new Response( + 500, + ['Content-Type' => 'text/xml'], + 'ERROR_NAMEERROR_DESCRIPTION' + ); + })->serve(); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertSame('ERROR_NAMEERROR_DESCRIPTION', \strval($response->getBody())); + } }