mirror of
https://github.com/woocommerce/woocommerce-paypal-payments.git
synced 2026-07-29 02:07:27 +08:00
286 lines
11 KiB
PHP
286 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace WooCommerce\PayPalCommerce\Axo\Gateway;
|
|
|
|
use Mockery;
|
|
use Psr\Log\LoggerInterface;
|
|
use WC_Order;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
|
|
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
|
|
use WooCommerce\PayPalCommerce\Session\SessionHandler;
|
|
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
|
|
use WooCommerce\PayPalCommerce\TestCase;
|
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\TransactionUrlProvider;
|
|
use WooCommerce\PayPalCommerce\WcGateway\Helper\CardPaymentsConfiguration;
|
|
use WooCommerce\PayPalCommerce\WcGateway\Helper\Environment;
|
|
use WooCommerce\PayPalCommerce\WcGateway\Processor\OrderProcessor;
|
|
use function Brain\Monkey\Functions\when;
|
|
|
|
class AxoGatewayTestable extends AxoGateway
|
|
{
|
|
public function update_option( $key, $value = '' ): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public function process_3ds_return_exposed( WC_Order $wc_order, string $token ): array
|
|
{
|
|
return $this->process_3ds_return( $wc_order, $token );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway
|
|
*/
|
|
class AxoGatewayTest extends TestCase
|
|
{
|
|
private CardPaymentsConfiguration $dcc_configuration;
|
|
private OrderEndpoint $order_endpoint;
|
|
private OrderProcessor $order_processor;
|
|
private LoggerInterface $logger;
|
|
private AxoGatewayTestable $sut;
|
|
|
|
public function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->dcc_configuration = Mockery::mock( CardPaymentsConfiguration::class );
|
|
$this->dcc_configuration->shouldReceive( 'gateway_title' )->andReturn( 'Fastlane Cards' );
|
|
$this->dcc_configuration->shouldReceive( 'use_fastlane' )->andReturn( false );
|
|
|
|
$this->order_endpoint = Mockery::mock( OrderEndpoint::class );
|
|
$this->order_processor = Mockery::mock( OrderProcessor::class );
|
|
$this->logger = Mockery::mock( LoggerInterface::class );
|
|
|
|
$woocommerce = Mockery::mock( 'WooCommerce' );
|
|
$cart = Mockery::mock( 'stdClass' );
|
|
$cart->shouldReceive( 'empty_cart' );
|
|
when( 'WC' )->justReturn( $woocommerce );
|
|
$woocommerce->cart = $cart;
|
|
|
|
$this->sut = new AxoGatewayTestable(
|
|
$this->dcc_configuration,
|
|
Mockery::mock( SessionHandler::class ),
|
|
$this->order_processor,
|
|
[],
|
|
$this->order_endpoint,
|
|
Mockery::mock( PurchaseUnitFactory::class ),
|
|
Mockery::mock( ShippingPreferenceFactory::class ),
|
|
Mockery::mock( TransactionUrlProvider::class ),
|
|
Mockery::mock( Environment::class ),
|
|
$this->logger,
|
|
Mockery::mock( ExperienceContextBuilder::class ),
|
|
Mockery::mock( SettingsModel::class )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param string $custom_id
|
|
* @param string $amount_value
|
|
* @param string $currency_code
|
|
* @return Order
|
|
*/
|
|
private function create_completed_paypal_order(
|
|
string $custom_id,
|
|
string $amount_value,
|
|
string $currency_code
|
|
): Order {
|
|
$order_status = Mockery::mock( OrderStatus::class );
|
|
$order_status->shouldReceive( 'is' )
|
|
->with( OrderStatus::COMPLETED )
|
|
->andReturn( true );
|
|
|
|
$amount = Mockery::mock( Amount::class );
|
|
$amount->shouldReceive( 'value_str' )->andReturn( $amount_value );
|
|
$amount->shouldReceive( 'currency_code' )->andReturn( $currency_code );
|
|
|
|
$purchase_unit = Mockery::mock( PurchaseUnit::class );
|
|
$purchase_unit->shouldReceive( 'custom_id' )->andReturn( $custom_id );
|
|
$purchase_unit->shouldReceive( 'amount' )->andReturn( $amount );
|
|
|
|
$paypal_order = Mockery::mock( Order::class );
|
|
$paypal_order->shouldReceive( 'status' )->andReturn( $order_status );
|
|
$paypal_order->shouldReceive( 'purchase_units' )->andReturn( [ $purchase_unit ] );
|
|
|
|
return $paypal_order;
|
|
}
|
|
|
|
/**
|
|
* @scenario When process_3ds_return() is called with a valid PayPal token whose
|
|
* custom_id does NOT match the WC order id, the method returns a WP_Error
|
|
* and the WC order remains unpaid.
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway::process_3ds_return
|
|
*/
|
|
public function test_returns_failure_when_custom_id_does_not_match_wc_order_id(): void
|
|
{
|
|
// Arrange
|
|
$wc_order = Mockery::mock( WC_Order::class );
|
|
$wc_order->shouldReceive( 'get_id' )->andReturn( 1 );
|
|
$wc_order->shouldReceive( 'get_total' )->andReturn( '100.00' );
|
|
$wc_order->shouldReceive( 'get_currency' )->andReturn( 'USD' );
|
|
|
|
$paypal_order = $this->create_completed_paypal_order( '999', '100.00', 'USD' );
|
|
$this->order_endpoint->shouldReceive( 'order' )->with( 'token' )->andReturn( $paypal_order );
|
|
$this->order_processor->shouldReceive( 'process_captured_and_authorized' )->never();
|
|
$this->logger->shouldReceive( 'error' )->once();
|
|
|
|
// When
|
|
$result = $this->sut->process_3ds_return_exposed( $wc_order, 'token' );
|
|
|
|
// Then
|
|
$this->assertSame( 'failure', $result['result'] );
|
|
}
|
|
|
|
/**
|
|
* @scenario When process_3ds_return() is called with a PayPal token whose custom_id
|
|
* matches the WC order id but whose captured amount is significantly
|
|
* different from the WC order total, the order is still marked paid.
|
|
* Amount comparison was deliberately removed: once custom_id matches the
|
|
* PayPal order is proven to belong to this WC order, and amount differences
|
|
* (e.g. inclusive-tax rounding, promotional adjustments) must not block
|
|
* payment. This test guards against re-introducing an amount check.
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway::process_3ds_return
|
|
*/
|
|
public function test_allows_payment_when_paypal_amount_differs_from_wc_total(): void
|
|
{
|
|
// Arrange — WC total 50.00, PayPal captured 10.00 (large gap); custom_id matches
|
|
$wc_order = Mockery::mock( WC_Order::class );
|
|
$wc_order->shouldReceive( 'get_id' )->andReturn( 1 );
|
|
|
|
$paypal_order = $this->create_completed_paypal_order( '1', '10.00', 'USD' );
|
|
$this->order_endpoint->shouldReceive( 'order' )->with( 'valid-token' )->andReturn( $paypal_order );
|
|
$this->order_processor
|
|
->shouldReceive( 'process_captured_and_authorized' )
|
|
->once()
|
|
->with( $wc_order, $paypal_order );
|
|
|
|
when( 'apply_filters' )->justReturn( true );
|
|
|
|
// When
|
|
$result = $this->sut->process_3ds_return_exposed( $wc_order, 'valid-token' );
|
|
|
|
// Then
|
|
$this->assertSame( 'success', $result['result'] );
|
|
}
|
|
|
|
/**
|
|
* @scenario When process_3ds_return() is called with a PayPal token whose custom_id
|
|
* matches the WC order id, the order is marked paid via
|
|
* OrderProcessor::process_captured_and_authorized(). The custom_id binding
|
|
* is the sole validation criterion; amount and currency are not checked.
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway::process_3ds_return
|
|
*/
|
|
public function test_calls_order_processor_when_custom_id_matches(): void
|
|
{
|
|
// Arrange
|
|
$wc_order = Mockery::mock( WC_Order::class );
|
|
$wc_order->shouldReceive( 'get_id' )->andReturn( 1 );
|
|
$wc_order->shouldReceive( 'get_total' )->andReturn( '100.00' );
|
|
$wc_order->shouldReceive( 'get_currency' )->andReturn( 'USD' );
|
|
|
|
$paypal_order = $this->create_completed_paypal_order( '1', '100.00', 'USD' );
|
|
$this->order_endpoint->shouldReceive( 'order' )->with( 'valid-token' )->andReturn( $paypal_order );
|
|
$this->order_processor
|
|
->shouldReceive( 'process_captured_and_authorized' )
|
|
->once()
|
|
->with( $wc_order, $paypal_order );
|
|
|
|
when( 'apply_filters' )->justReturn( true );
|
|
|
|
// When
|
|
$result = $this->sut->process_3ds_return_exposed( $wc_order, 'valid-token' );
|
|
|
|
// Then
|
|
$this->assertSame( 'success', $result['result'] );
|
|
}
|
|
|
|
/**
|
|
* @return Order
|
|
*/
|
|
private function create_completed_paypal_order_without_purchase_units(): Order {
|
|
$order_status = Mockery::mock( OrderStatus::class );
|
|
$order_status->shouldReceive( 'is' )
|
|
->with( OrderStatus::COMPLETED )
|
|
->andReturn( true );
|
|
|
|
$paypal_order = Mockery::mock( Order::class );
|
|
$paypal_order->shouldReceive( 'status' )->andReturn( $order_status );
|
|
$paypal_order->shouldReceive( 'purchase_units' )->andReturn( [] );
|
|
|
|
return $paypal_order;
|
|
}
|
|
|
|
/**
|
|
* GIVEN a PayPal order that is COMPLETED but has no purchase units
|
|
* WHEN process_3ds_return() is called
|
|
* THEN the method returns a failure result
|
|
* AND the order processor is never called
|
|
* AND an error is logged containing the WC order ID
|
|
*
|
|
* @scenario When process_3ds_return() is called and the COMPLETED PayPal order
|
|
* contains no purchase units, the method must return a failure result
|
|
* without invoking the order processor, and must log an error.
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway::process_3ds_return
|
|
*/
|
|
public function test_returns_failure_when_paypal_order_has_no_purchase_units(): void {
|
|
// Arrange
|
|
$wc_order = Mockery::mock( WC_Order::class );
|
|
$wc_order->shouldReceive( 'get_id' )->andReturn( 42 );
|
|
|
|
$paypal_order = $this->create_completed_paypal_order_without_purchase_units();
|
|
$this->order_endpoint->shouldReceive( 'order' )->with( 'some-token' )->andReturn( $paypal_order );
|
|
$this->order_processor->shouldReceive( 'process_captured_and_authorized' )->never();
|
|
$this->logger->shouldReceive( 'error' )->once();
|
|
|
|
// When
|
|
$result = $this->sut->process_3ds_return_exposed( $wc_order, 'some-token' );
|
|
|
|
// Then
|
|
$this->assertSame( 'failure', $result['result'] );
|
|
}
|
|
|
|
/**
|
|
* @scenario All validation rejections are logged with details sufficient for an
|
|
* auditor to identify the attacker's token and the attempted fraud.
|
|
* @covers \WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway::process_3ds_return
|
|
*/
|
|
public function test_logs_rejection_details_when_validation_fails(): void
|
|
{
|
|
// Arrange
|
|
$wc_order = Mockery::mock( WC_Order::class );
|
|
$wc_order->shouldReceive( 'get_id' )->andReturn( 1 );
|
|
$wc_order->shouldReceive( 'get_total' )->andReturn( '100.00' );
|
|
$wc_order->shouldReceive( 'get_currency' )->andReturn( 'USD' );
|
|
|
|
$paypal_order = $this->create_completed_paypal_order( '999', '100.00', 'USD' );
|
|
$this->order_endpoint->shouldReceive( 'order' )->with( 'token' )->andReturn( $paypal_order );
|
|
$this->order_processor->shouldReceive( 'process_captured_and_authorized' )->never();
|
|
|
|
$this->logger
|
|
->shouldReceive( 'error' )
|
|
->once()
|
|
->with(
|
|
Mockery::on(
|
|
static function ( string $message ): bool {
|
|
return strpos( $message, '999' ) !== false
|
|
&& strpos( $message, '1' ) !== false
|
|
&& strpos( $message, 'token' ) !== false;
|
|
}
|
|
)
|
|
);
|
|
|
|
// When
|
|
$this->sut->process_3ds_return_exposed( $wc_order, 'token' );
|
|
|
|
// Then — Mockery verifies logger->error() call in tearDown(); count it as an assertion
|
|
$this->addToAssertionCount( 1 );
|
|
}
|
|
}
|