From 1ea7dec855fb9592377e7cb3c38cd5632edb4b16 Mon Sep 17 00:00:00 2001 From: Maciej Kwiatkowski Date: Tue, 10 Sep 2024 17:26:29 +0200 Subject: [PATCH] SP-965 Validating incoming webhooks --- .phpunit.result.cache | 1 - BitPayLib/class-bitpaycreateorder.php | 26 ++ BitPayLib/class-bitpayipnprocess.php | 37 ++- BitPayLib/class-bitpaypluginsetup.php | 12 +- BitPayLib/class-bitpaywebhookverifier.php | 21 +- composer.json | 3 + tests/.gitignore | 3 +- .../Unit/BitPayLib/test-bitpayipnprocess.php | 225 ++++++++++++++---- .../BitPayLib/test-bitpaywebhookverifier.php | 95 ++++++++ 9 files changed, 348 insertions(+), 75 deletions(-) delete mode 100644 .phpunit.result.cache create mode 100644 BitPayLib/class-bitpaycreateorder.php create mode 100644 tests/Unit/BitPayLib/test-bitpaywebhookverifier.php diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index 93fd01c..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defects":{"Unit\\BitPayLib\\BitPayInProcessTest::it_should_do_not_allow_to_process_non_bitpay_orders":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_do_not_allow_to_process_with_wrong_transaction":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_process_ipn_request":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_confirm_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_decline_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_fail_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_expire_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_refund_order":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_for_wcorder_wccomplete_status_and_wccompleted_for_complete_action_in_admin":3,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_for_wcorder_wcprocessing_status_and_wccompleted_for_complete_action_in_admin":3},"times":{"Unit\\BitPayLib\\BitPayInProcessTest::it_should_do_not_allow_to_process_non_bitpay_orders":0.038,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_do_not_allow_to_process_with_wrong_transaction":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_process_ipn_request":0.002,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_confirm_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_decline_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_fail_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_expire_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_refund_order":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_for_wcorder_wccomplete_status_and_wccompleted_for_complete_action_in_admin":0.001,"Unit\\BitPayLib\\BitPayInProcessTest::it_should_complete_for_wcorder_wcprocessing_status_and_wccompleted_for_complete_action_in_admin":0.001,"Unit\\BitPayLib\\BitPayInvoiceFactoryTest::it_should_create_bitpay_invoice_by_wc_order_id":0.005,"Unit\\BitPayLib\\BitPayInvoiceFactoryTest::it_should_use_custom_checkout_page_for_redirect_url":0.002}} \ No newline at end of file diff --git a/BitPayLib/class-bitpaycreateorder.php b/BitPayLib/class-bitpaycreateorder.php new file mode 100644 index 0000000..1d73fbd --- /dev/null +++ b/BitPayLib/class-bitpaycreateorder.php @@ -0,0 +1,26 @@ +bitpay_payment_settings = $bitpay_payment_settings; + } + + public function execute( int $order_id ): void { + $token = $this->bitpay_payment_settings->get_bitpay_token(); + $order = new \WC_Order( $order_id ); + + $order->update_meta_data( self::BITPAY_TOKEN_ORDER_METADATA_KEY, $token ); + $order->save_meta_data(); + } +} diff --git a/BitPayLib/class-bitpayipnprocess.php b/BitPayLib/class-bitpayipnprocess.php index 1275448..4e8de53 100644 --- a/BitPayLib/class-bitpayipnprocess.php +++ b/BitPayLib/class-bitpayipnprocess.php @@ -24,23 +24,23 @@ class BitPayIpnProcess { private BitPayLogger $logger; private BitPayClientFactory $factory; private BitPayWordpressHelper $bitpay_wordpress_helper; - private BitPayWebhookVerifier $bitpay_webhook_verifier; - private BitPayPaymentSettings $bitpay_payment_settings; + private BitPayWebhookVerifier $bitpay_webhook_verifier; + private BitPayPaymentSettings $bitpay_payment_settings; public function __construct( BitPayCheckoutTransactions $bitpay_checkout_transactions, BitPayClientFactory $factory, BitPayWordpressHelper $bitpay_wordpress_helper, BitPayLogger $logger, - BitPayWebhookVerifier $bitpay_webhook_verifier, - BitPayPaymentSettings $bitpay_payment_settings + BitPayWebhookVerifier $bitpay_webhook_verifier, + BitPayPaymentSettings $bitpay_payment_settings, ) { $this->bitpay_checkout_transactions = $bitpay_checkout_transactions; $this->logger = $logger; $this->factory = $factory; $this->bitpay_wordpress_helper = $bitpay_wordpress_helper; - $this->bitpay_webhook_verifier = $bitpay_webhook_verifier; - $this->bitpay_payment_settings = $bitpay_payment_settings; + $this->bitpay_webhook_verifier = $bitpay_webhook_verifier; + $this->bitpay_payment_settings = $bitpay_payment_settings; } public function execute( WP_REST_Request $request ): void { @@ -51,16 +51,11 @@ public function execute( WP_REST_Request $request ): void { $data['event'] = $event; $data['requestDate'] = date( 'Y-m-d H:i:s' ); $invoice_id = $data['id'] ?? null; + $x_signature = $request->get_header( 'x-signature' ); $this->logger->execute( $data, 'INCOMING IPN', true ); - $is_webhook_verified = $this->bitpay_webhook_verifier->verify( - $this->bitpay_payment_settings->get_bitpay_token(), // token used to create resource - $request->get_header('x-signature'), - $request->get_body() - ); - - if ( ! $event || ! $data || ! $invoice_id || ! $is_webhook_verified ) { + if ( ! $event || ! $data || ! $invoice_id || ! $x_signature ) { $this->logger->execute( 'Wrong IPN request', 'INCOMING IPN ERROR', false, true ); return; } @@ -70,6 +65,8 @@ public function execute( WP_REST_Request $request ): void { do_action( 'bitpay_checkout_woocoomerce_after_get_invoice', $bitpay_invoice ); $order = $this->bitpay_wordpress_helper->get_order( $bitpay_invoice->getOrderId() ); $this->validate_order( $order, $invoice_id ); + $this->validate_webhook( $x_signature, $request->get_body(), $order ); + $this->process( $bitpay_invoice, $order, $event['name'] ); } catch ( BitPayInvalidOrder $e ) { // phpcs:ignore // do nothing. @@ -331,4 +328,18 @@ private function should_process_refund(): bool { $should_process_refund_status = $this->get_wc_order_statuses()['bitpay_checkout_order_process_refund'] ?? '1'; return '1' === $should_process_refund_status; } + + private function validate_webhook( string $x_signature, string $webhook_body, WC_Order $order ): void { + $order_bitpay_token = $order->get_meta( BitPayCreateOrder::BITPAY_TOKEN_ORDER_METADATA_KEY ); + + if ( $order_bitpay_token && + ! $this->bitpay_webhook_verifier->verify( + $this->bitpay_payment_settings->get_bitpay_token(), + $x_signature, + $webhook_body + ) + ) { + throw new \Exception( 'IPN Request failed HMAC validation' ); + } + } } diff --git a/BitPayLib/class-bitpaypluginsetup.php b/BitPayLib/class-bitpaypluginsetup.php index 627c7e2..9215e4c 100644 --- a/BitPayLib/class-bitpaypluginsetup.php +++ b/BitPayLib/class-bitpaypluginsetup.php @@ -25,6 +25,7 @@ class BitPayPluginSetup { private BitPayPaymentSettings $bitpay_payment_settings; private BitPayInvoiceCreate $bitpay_invoice_create; private BitPayCheckoutTransactions $bitpay_checkout_transactions; + private BitPayCreateOrder $bitpay_create_order; public function __construct() { $this->bitpay_payment_settings = new BitPayPaymentSettings(); @@ -32,7 +33,7 @@ public function __construct() { $cart = new BitPayCart(); $logger = new BitPayLogger(); $wordpress_helper = new BitPayWordpressHelper(); - $webhook_verifier = new BitPayWebhookVerifier(); + $webhook_verifier = new BitPayWebhookVerifier(); $this->bitpay_checkout_transactions = new BitPayCheckoutTransactions( $wordpress_helper ); $this->bitpay_ipn_process = new BitPayIpnProcess( $this->bitpay_checkout_transactions, $factory, $wordpress_helper, $logger, $webhook_verifier, $this->bitpay_payment_settings ); $this->bitpay_cancel_order = new BitPayCancelOrder( $cart, $this->bitpay_checkout_transactions, $logger ); @@ -43,6 +44,9 @@ public function __construct() { $wordpress_helper, $logger ); + $this->bitpay_create_order = new BitPayCreateOrder( + $this->bitpay_payment_settings + ); } public function execute(): void { @@ -59,6 +63,8 @@ public function execute(): void { add_filter( 'woocommerce_payment_gateways', array( $this, 'wc_bitpay_checkout_add_to_gateways' ) ); add_filter( 'woocommerce_order_button_html', array( $this, 'bitpay_checkout_replace_order_button_html' ), 10, 2 ); add_action( 'woocommerce_blocks_loaded', array( $this, 'register_payment_block' ) ); + add_action( 'woocommerce_new_order', array( $this, 'bitpay_create_order' ) ); + add_action( 'woocommerce_update_order', array( $this, 'bitpay_create_order' ) ); // http:///wp-json/bitpay/ipn/status url. // http:///wp-json/bitpay/cartfix/restore url. @@ -222,4 +228,8 @@ function () { 5 ); } + + public function bitpay_create_order( int $order_id ): void { + $this->bitpay_create_order->execute( $order_id ); + } } diff --git a/BitPayLib/class-bitpaywebhookverifier.php b/BitPayLib/class-bitpaywebhookverifier.php index 8eae163..f99849f 100644 --- a/BitPayLib/class-bitpaywebhookverifier.php +++ b/BitPayLib/class-bitpaywebhookverifier.php @@ -5,14 +5,17 @@ namespace BitPayLib; class BitPayWebhookVerifier { - public function verify($signing_key, $sig_header, $webhook_body): bool { - $hmac = base64_encode( hash_hmac( - 'sha256', - $webhook_body, - $signing_key, - true - ) ); + public function verify( string $signing_key, string $sig_header, string $webhook_body ): bool { + // phpcs:ignore + $hmac = base64_encode( + hash_hmac( + 'sha256', + $webhook_body, + $signing_key, + true + ) + ); - return $sig_header !== $hmac; - } + return $sig_header === $hmac; + } } diff --git a/composer.json b/composer.json index 2e3aef0..f8764df 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,9 @@ ], "post-update-cmd": [ "composer add-prefix" + ], + "phpcbf": [ + "./vendor/bin/phpcbf BitPayLib" ] }, "config": { diff --git a/tests/.gitignore b/tests/.gitignore index 2eea525..884428f 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +.phpunit.result.cache \ No newline at end of file diff --git a/tests/Unit/BitPayLib/test-bitpayipnprocess.php b/tests/Unit/BitPayLib/test-bitpayipnprocess.php index cdd87ce..1fc01e6 100644 --- a/tests/Unit/BitPayLib/test-bitpayipnprocess.php +++ b/tests/Unit/BitPayLib/test-bitpayipnprocess.php @@ -6,6 +6,7 @@ use BitPayLib\BitPayCheckoutTransactions; use BitPayLib\BitPayClientFactory; +use BitPayLib\BitPayCreateOrder; use BitPayLib\BitPayIpnProcess; use BitPayLib\BitPayLogger; use BitPayLib\BitPayPaymentSettings; @@ -14,7 +15,7 @@ use PHPUnit\Framework\TestCase; -class BitPayInProcessTest extends TestCase { +class BitPayIpnProcessTest extends TestCase { private const WC_ORDER_ID = 'someWcId'; private const BITPAY_INVOICE_ID = 'someId'; @@ -41,6 +42,7 @@ public function it_should_do_not_allow_to_process_non_bitpay_orders() { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_paid_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -55,8 +57,8 @@ public function it_should_do_not_allow_to_process_non_bitpay_orders() { $bitpay_client_factory, $bitpay_checkout_transactions, $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); // then @@ -99,6 +101,7 @@ public function it_should_do_not_allow_to_process_with_wrong_transaction() { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_paid_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -111,9 +114,9 @@ public function it_should_do_not_allow_to_process_with_wrong_transaction() { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $logger->expects(self::exactly(2))->method('execute')->with( @@ -135,8 +138,10 @@ public function it_should_do_not_allow_to_process_with_wrong_transaction() { /** * @test */ - public function it_should_process_ipn_request() { + public function it_should_process_ipn_request_without_verification_when_order_has_no_saved_token() { // given + $webhook_verifier = $this->get_bitpay_webhook_verifier(); + $webhook_verifier->expects(self::never())->method('verify'); $wordpress_helper = $this->get_wordpress_helper(); $request = $this->getMockBuilder(\WP_REST_Request::class)->getMock(); $transactions = $this->get_checkout_transactions(); @@ -156,6 +161,8 @@ public function it_should_process_ipn_request() { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_paid_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); + $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -168,10 +175,120 @@ public function it_should_process_ipn_request() { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() - ); + $logger, + $webhook_verifier, + $this->get_bitpay_payment_settings() + ); + + // then + $wc_order + ->expects(self::once()) + ->method('add_order_note') + ->with('BitPay Invoice ID: someId is paid and awaiting confirmation.'); + $transactions->expects(self::once())->method('update_transaction_status'); + + // when + $testedClass->execute($request); + } + + /** + * @test + */ + public function it_should_not_process_failed_verification_ipn_request() { + // given + $webhook_verifier = $this->get_bitpay_webhook_verifier(); + $webhook_verifier->expects(self::once())->method('verify')->willReturn(false); + $wordpress_helper = $this->get_wordpress_helper(); + $request = $this->getMockBuilder(\WP_REST_Request::class)->getMock(); + $transactions = $this->get_checkout_transactions(); + $bitpay_invoice = $this->getMockBuilder(\BitPaySDK\Model\Invoice\Invoice::class)->getMock(); + $bitpay_client = $this->getMockBuilder(\BitPaySDK\Client::class) + ->disableOriginalConstructor() + ->getMock(); + $logger = $this->get_bitpay_logger(); + $bitpay_client_factory = $this->getMockBuilder(BitPayClientFactory::class) + ->disableOriginalConstructor()->getMock(); + $wc_order = $this->get_wc_order(); + $bitpay_payment_settings = $this->get_bitpay_payment_settings(); + + $transactions->method('count_transaction_id')->willReturn(1); + $bitpay_invoice->method('getStatus')->willReturn('paid'); + $bitpay_invoice->method('getId')->willReturn(self::BITPAY_INVOICE_ID); + $request->method('get_body') + ->willReturn( + file_get_contents(__DIR__ . '/json/bitpay_paid_ipn_webhook.json') + ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); + $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); + $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); + $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) + ->willReturn($bitpay_invoice); + $wordpress_helper->method('get_order') + ->with(self::BITPAY_INVOICE_ID) + ->willReturn($wc_order); + $wc_order->method('get_meta')->with(BitPayCreateOrder::BITPAY_TOKEN_ORDER_METADATA_KEY)->willReturn('secret_token'); + $bitpay_payment_settings->expects(self::once())->method('get_bitpay_token')->willReturn('different_token'); + + $testedClass = $this->getTestedClass( + $wordpress_helper, + $bitpay_client_factory, + $transactions, + $logger, + $webhook_verifier, + $bitpay_payment_settings + ); + + // then + $wc_order + ->expects(self::never()) + ->method('add_order_note'); + $transactions->expects(self::never())->method('update_transaction_status'); + + // when + $testedClass->execute($request); + } + + public function it_should_verify_order_with_saved_token_ipn_request_and_process_correctly_verified_ipn() { + // given + $wordpress_helper = $this->get_wordpress_helper(); + $request = $this->getMockBuilder(\WP_REST_Request::class)->getMock(); + $transactions = $this->get_checkout_transactions(); + $webhook_verifier = $this->get_bitpay_webhook_verifier(); + $webhook_verifier->method('verify')->willReturn(true); + $bitpay_invoice = $this->getMockBuilder(\BitPaySDK\Model\Invoice\Invoice::class)->getMock(); + $bitpay_client = $this->getMockBuilder(\BitPaySDK\Client::class) + ->disableOriginalConstructor() + ->getMock(); + $logger = $this->get_bitpay_logger(); + $bitpay_client_factory = $this->getMockBuilder(BitPayClientFactory::class) + ->disableOriginalConstructor()->getMock(); + $wc_order = $this->get_wc_order(); + $wc_order->method('get_meta')->with(BitPayCreateOrder::BITPAY_TOKEN_ORDER_METADATA_KEY)->willReturn('secret_token'); + + $transactions->method('count_transaction_id')->willReturn(1); + $bitpay_invoice->method('getStatus')->willReturn('paid'); + $bitpay_invoice->method('getId')->willReturn(self::BITPAY_INVOICE_ID); + $request->method('get_body') + ->willReturn( + file_get_contents(__DIR__ . '/json/bitpay_paid_ipn_webhook.json') + ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); + $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); + $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); + $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) + ->willReturn($bitpay_invoice); + $wordpress_helper->method('get_order') + ->with(self::BITPAY_INVOICE_ID) + ->willReturn($wc_order); + + $testedClass = $this->getTestedClass( + $wordpress_helper, + $bitpay_client_factory, + $transactions, + $logger, + $webhook_verifier, + $this->get_bitpay_payment_settings() + ); // then $wc_order @@ -208,6 +325,7 @@ public function it_should_confirm_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_confirmed_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -220,10 +338,10 @@ public function it_should_confirm_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() - ); + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() + ); $wc_order ->expects(self::exactly(2)) @@ -264,6 +382,7 @@ public function it_should_complete_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_completed_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -276,9 +395,9 @@ public function it_should_complete_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -319,6 +438,7 @@ public function it_should_decline_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_declined_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -331,9 +451,9 @@ public function it_should_decline_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -371,6 +491,7 @@ public function it_should_fail_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_invalid_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -383,9 +504,9 @@ public function it_should_fail_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -423,6 +544,7 @@ public function it_should_expire_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_expired_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -435,9 +557,9 @@ public function it_should_expire_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -474,6 +596,7 @@ public function it_should_refund_order(): void { ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_refunded_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -486,9 +609,9 @@ public function it_should_refund_order(): void { $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -527,6 +650,7 @@ public function it_should_complete_for_wcorder_wccomplete_status_and_wccompleted ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_completed_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -539,9 +663,9 @@ public function it_should_complete_for_wcorder_wccomplete_status_and_wccompleted $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -583,6 +707,7 @@ public function it_should_complete_for_wcorder_wcprocessing_status_and_wccomplet ->willReturn( file_get_contents(__DIR__ . '/json/bitpay_completed_ipn_webhook.json') ); + $request->expects(self::once())->method('get_header')->with('x-signature')->willReturn('x-signature-header-value'); $bitpay_invoice->method('getOrderId')->willReturn( self::BITPAY_INVOICE_ID ); $bitpay_client_factory->method('create')->willReturn( $bitpay_client ); $bitpay_client->method('getInvoice')->with( self::BITPAY_INVOICE_ID, \BitPaySDK\Model\Facade::POS, false ) @@ -595,9 +720,9 @@ public function it_should_complete_for_wcorder_wcprocessing_status_and_wccomplet $wordpress_helper, $bitpay_client_factory, $transactions, - $logger, - $this->get_bitpay_webhook_verifier(), - $this->get_bitpay_payment_settings() + $logger, + $this->get_bitpay_webhook_verifier(), + $this->get_bitpay_payment_settings() ); $wc_order @@ -621,19 +746,19 @@ private function get_bitpay_logger() { return $this->getMockBuilder(BitPayLogger::class)->disableOriginalConstructor()->getMock(); } - /** - * @return (BitPayWebhookVerifier|\PHPUnit\Framework\MockObject\MockObject) - */ - private function get_bitpay_webhook_verifier() { - return $this->getMockBuilder(BitPayWebhookVerifier::class)->disableOriginalConstructor()->getMock(); - } + /** + * @return (BitPayWebhookVerifier|\PHPUnit\Framework\MockObject\MockObject) + */ + private function get_bitpay_webhook_verifier() { + return $this->getMockBuilder(BitPayWebhookVerifier::class)->disableOriginalConstructor()->getMock(); + } - /** - * @return (BitPayPaymentSettings|\PHPUnit\Framework\MockObject\MockObject) - */ - private function get_bitpay_payment_settings() { - return $this->getMockBuilder(BitPayPaymentSettings::class)->disableOriginalConstructor()->getMock(); - } + /** + * @return (BitPayPaymentSettings|\PHPUnit\Framework\MockObject\MockObject) + */ + private function get_bitpay_payment_settings() { + return $this->getMockBuilder(BitPayPaymentSettings::class)->disableOriginalConstructor()->getMock(); + } /** * @return (BitPayCheckoutTransactions&\PHPUnit\Framework\MockObject\MockObject) @@ -683,8 +808,8 @@ private function getTestedClass( BitPayClientFactory $bitpay_client_factory, BitPayCheckoutTransactions $bitpay_checkout_transactions, BitPayLogger $logger, - BitPayWebhookVerifier $webhook_verifier, - BitPayPaymentSettings $payment_settings + BitPayWebhookVerifier $webhook_verifier, + BitPayPaymentSettings $payment_settings ): BitPayIpnProcess { return new BitPayIpnProcess($bitpay_checkout_transactions, $bitpay_client_factory, $wordpress_helper, $logger, $webhook_verifier, $payment_settings); } diff --git a/tests/Unit/BitPayLib/test-bitpaywebhookverifier.php b/tests/Unit/BitPayLib/test-bitpaywebhookverifier.php new file mode 100644 index 0000000..c57ad37 --- /dev/null +++ b/tests/Unit/BitPayLib/test-bitpaywebhookverifier.php @@ -0,0 +1,95 @@ +fail("Test webhook body file does not exist."); + } + return file_get_contents($file); + } + + /** + * @test + */ + public function it_should_return_true_if_the_signature_matches() + { + $verifier = $this->getTestedClass(); + $signingKey = 'my_secret_key'; + + // bitpay_paid_ipn_webhook request body signed with the signing key: my_secret_key + $expected_signature_header = 'G3AN1yIRVFPahcmKXK0qg9UksH9WlK3Llvvs7APZOzc='; + + $this->assertTrue( + $verifier->verify($signingKey, $expected_signature_header, $this->get_test_webhook_body()) + ); + } + + /** + * @test + */ + public function it_should_return_false_if_the_signature_doesnt_match() + { + $verifier = $this->getTestedClass(); + + // key different from the one used to test sign + $signingKey = 'different_key'; + + // bitpay_paid_ipn_webhook request body signed with the signing key: my_secret_key + $expected_signature_header = 'G3AN1yIRVFPahcmKXK0qg9UksH9WlK3Llvvs7APZOzc='; + + $this->assertFalse( + $verifier->verify($signingKey, $expected_signature_header, $this->get_test_webhook_body()) + ); + } + + /** + * @test + */ + public function it_should_return_false_for_empty_signature() + { + $verifier = $this->getTestedClass(); + $signingKey = 'my_secret_key'; + + $this->assertFalse( + $verifier->verify($signingKey, '', $this->get_test_webhook_body()) + ); + } + + /** + * @test + */ + public function it_should_return_false_if_the_request_body_is_tampered_with() + { + $verifier = $this->getTestedClass(); + $signingKey = 'my_secret_key'; + + $originalWebhookBody = $this->get_test_webhook_body(); +// $expected_signature_header = base64_encode( +// hash_hmac('sha256', $originalWebhookBody, $signingKey, true) +// ); + + // bitpay_paid_ipn_webhook request body signed with the signing key: my_secret_key + $expected_signature_header = 'G3AN1yIRVFPahcmKXK0qg9UksH9WlK3Llvvs7APZOzc='; + + // Tamper with the request body (e.g., change a character) + $tamperedWebhookBody = str_replace('payment', 'tampered', $originalWebhookBody); + + $this->assertFalse( + $verifier->verify($signingKey, $expected_signature_header, $tamperedWebhookBody), + "Tampered webhook body should result in a failed verification" + ); + } + + private function getTestedClass(): BitPayWebhookVerifier { + return new BitPayWebhookVerifier(); + } +}