diff --git a/app/code/Magento/Authorization/Model/CompositeUserContext.php b/app/code/Magento/Authorization/Model/CompositeUserContext.php index 7678b7e639e13..9a6be4d96ef1c 100644 --- a/app/code/Magento/Authorization/Model/CompositeUserContext.php +++ b/app/code/Magento/Authorization/Model/CompositeUserContext.php @@ -56,7 +56,7 @@ protected function add(UserContextInterface $userContext) } /** - * {@inheritdoc} + * @inheritDoc */ public function getUserId() { @@ -64,7 +64,7 @@ public function getUserId() } /** - * {@inheritdoc} + * @inheritDoc */ public function getUserType() { @@ -78,7 +78,7 @@ public function getUserType() */ protected function getUserContext() { - if ($this->chosenUserContext === null) { + if (!$this->chosenUserContext) { /** @var UserContextInterface $userContext */ foreach ($this->userContexts as $userContext) { if ($userContext->getUserType() && $userContext->getUserId() !== null) { diff --git a/app/code/Magento/Captcha/Model/DefaultModel.php b/app/code/Magento/Captcha/Model/DefaultModel.php index bbbbfb0a36e08..0e63c3a665e2d 100644 --- a/app/code/Magento/Captcha/Model/DefaultModel.php +++ b/app/code/Magento/Captcha/Model/DefaultModel.php @@ -7,7 +7,9 @@ namespace Magento\Captcha\Model; +use Magento\Authorization\Model\UserContextInterface; use Magento\Captcha\Helper\Data; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Math\Random; /** @@ -93,12 +95,18 @@ class DefaultModel extends \Zend\Captcha\Image implements \Magento\Captcha\Model */ private $randomMath; + /** + * @var UserContextInterface + */ + private $userContext; + /** * @param \Magento\Framework\Session\SessionManagerInterface $session * @param \Magento\Captcha\Helper\Data $captchaData * @param ResourceModel\LogFactory $resLogFactory * @param string $formId - * @param Random $randomMath + * @param Random|null $randomMath + * @param UserContextInterface|null $userContext * @throws \Zend\Captcha\Exception\ExtensionNotLoadedException */ public function __construct( @@ -106,14 +114,16 @@ public function __construct( \Magento\Captcha\Helper\Data $captchaData, \Magento\Captcha\Model\ResourceModel\LogFactory $resLogFactory, $formId, - Random $randomMath = null + Random $randomMath = null, + ?UserContextInterface $userContext = null ) { parent::__construct(); $this->session = $session; $this->captchaData = $captchaData; $this->resLogFactory = $resLogFactory; $this->formId = $formId; - $this->randomMath = $randomMath ?? \Magento\Framework\App\ObjectManager::getInstance()->get(Random::class); + $this->randomMath = $randomMath ?? ObjectManager::getInstance()->get(Random::class); + $this->userContext = $userContext ?? ObjectManager::getInstance()->get(UserContextInterface::class); } /** @@ -152,6 +162,7 @@ public function isRequired($login = null) $this->formId, $this->getTargetForms() ) + || $this->userContext->getUserType() === UserContextInterface::USER_TYPE_INTEGRATION ) { return false; } @@ -241,7 +252,7 @@ private function isOverLimitLoginAttempts($login) */ private function isUserAuth() { - return $this->session->isLoggedIn(); + return $this->session->isLoggedIn() || $this->userContext->getUserId(); } /** @@ -427,7 +438,7 @@ public function getWordLen() $to = self::DEFAULT_WORD_LENGTH_TO; } - return \Magento\Framework\Math\Random::getRandomNumber($from, $to); + return Random::getRandomNumber($from, $to); } /** @@ -544,7 +555,7 @@ private function clearWord() */ protected function randomSize() { - return \Magento\Framework\Math\Random::getRandomNumber(280, 300) / 100; + return Random::getRandomNumber(280, 300) / 100; } /** diff --git a/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php b/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php index d83abc7a6c7d1..059d395f6cf73 100644 --- a/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php +++ b/app/code/Magento/Captcha/Observer/CaptchaStringResolver.php @@ -3,10 +3,14 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); + namespace Magento\Captcha\Observer; use Magento\Framework\App\RequestInterface; use Magento\Framework\App\Request\Http as HttpRequest; +use Magento\Captcha\Helper\Data as CaptchaHelper; /** * Extract given captcha word. @@ -22,12 +26,13 @@ class CaptchaStringResolver */ public function resolve(RequestInterface $request, $formId) { - $captchaParams = $request->getPost(\Magento\Captcha\Helper\Data::INPUT_NAME_FIELD_VALUE); + $value = ''; + $captchaParams = $request->getPost(CaptchaHelper::INPUT_NAME_FIELD_VALUE); if (!empty($captchaParams) && !empty($captchaParams[$formId])) { $value = $captchaParams[$formId]; - } else { - //For Web APIs - $value = $request->getHeader('X-Captcha'); + } elseif ($headerValue = $request->getHeader('X-Captcha')) { + //CAPTCHA was provided via header for this XHR/web API request. + $value = $headerValue; } return $value; diff --git a/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php b/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php index b569803078457..c03254978b0af 100644 --- a/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php +++ b/app/code/Magento/Captcha/Test/Unit/Model/DefaultTest.php @@ -7,12 +7,25 @@ namespace Magento\Captcha\Test\Unit\Model; +use Magento\Authorization\Model\UserContextInterface; +use Magento\Captcha\Block\Captcha\DefaultCaptcha; +use Magento\Captcha\Helper\Data; +use Magento\Captcha\Model\DefaultModel; +use Magento\Captcha\Model\ResourceModel\Log; +use Magento\Captcha\Model\ResourceModel\LogFactory; +use Magento\Customer\Model\Session; use Magento\Framework\Math\Random; +use Magento\Framework\ObjectManagerInterface; +use Magento\Framework\Session\Storage; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManager; +use PHPUnit\Framework\TestCase; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class DefaultTest extends \PHPUnit\Framework\TestCase +class DefaultTest extends TestCase { /** * Expiration frame @@ -60,7 +73,7 @@ class DefaultTest extends \PHPUnit\Framework\TestCase ]; /** - * @var \Magento\Captcha\Model\DefaultModel + * @var DefaultModel */ protected $_object; @@ -80,10 +93,15 @@ class DefaultTest extends \PHPUnit\Framework\TestCase protected $session; /** - * @var \PHPUnit_Framework_MockObject_MockObject + * @var \PHPUnit_Framework_MockObject_MockObject|LogFactory */ protected $_resLogFactory; + /** + * @var UserContextInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $userContextMock; + /** * Sets up the fixture, for example, opens a network connection. * This method is called before a test is executed. @@ -92,47 +110,52 @@ protected function setUp() { $this->session = $this->_getSessionStub(); - $this->_storeManager = $this->createPartialMock(\Magento\Store\Model\StoreManager::class, ['getStore']); + $this->_storeManager = $this->createPartialMock(StoreManager::class, ['getStore']); $this->_storeManager->expects( $this->any() )->method( 'getStore' - )->will( - $this->returnValue($this->_getStoreStub()) + )->willReturn( + $this->_getStoreStub() ); // \Magento\Customer\Model\Session - $this->_objectManager = $this->createMock(\Magento\Framework\ObjectManagerInterface::class); + $this->_objectManager = $this->getMockForAbstractClass(ObjectManagerInterface::class); $this->_objectManager->expects( $this->any() )->method( 'get' - )->will( - $this->returnValueMap( - [ - \Magento\Captcha\Helper\Data::class => $this->_getHelperStub(), - \Magento\Customer\Model\Session::class => $this->session, - ] - ) + )->willReturnMap( + [ + Data::class => $this->_getHelperStub(), + Session::class => $this->session, + ] ); $this->_resLogFactory = $this->createPartialMock( - \Magento\Captcha\Model\ResourceModel\LogFactory::class, + LogFactory::class, ['create'] ); $this->_resLogFactory->expects( $this->any() )->method( 'create' - )->will( - $this->returnValue($this->_getResourceModelStub()) + )->willReturn( + $this->_getResourceModelStub() ); - $this->_object = new \Magento\Captcha\Model\DefaultModel( + $randomMock = $this->createMock(Random::class); + $randomMock->method('getRandomString')->willReturn('random-string'); + + $this->userContextMock = $this->getMockForAbstractClass(UserContextInterface::class); + + $this->_object = new DefaultModel( $this->session, $this->_getHelperStub(), $this->_resLogFactory, - 'user_create' + 'user_create', + $randomMock, + $this->userContextMock ); } @@ -141,7 +164,7 @@ protected function setUp() */ public function testGetBlockName() { - $this->assertEquals($this->_object->getBlockName(), \Magento\Captcha\Block\Captcha\DefaultCaptcha::class); + $this->assertEquals($this->_object->getBlockName(), DefaultCaptcha::class); } /** @@ -152,6 +175,19 @@ public function testIsRequired() $this->assertTrue($this->_object->isRequired()); } + /** + * Validate that CAPTCHA is disabled for integrations. + * + * @return void + */ + public function testIsRequiredForIntegration(): void + { + $this->userContextMock->method('getUserType')->willReturn(UserContextInterface::USER_TYPE_INTEGRATION); + $this->userContextMock->method('getUserId')->willReturn(1); + + $this->assertFalse($this->_object->isRequired()); + } + /** * @covers \Magento\Captcha\Model\DefaultModel::isCaseSensitive */ @@ -215,7 +251,7 @@ public function testGetImgSrc() */ public function testLogAttempt() { - $captcha = new \Magento\Captcha\Model\DefaultModel( + $captcha = new DefaultModel( $this->session, $this->_getHelperStub(), $this->_resLogFactory, @@ -246,16 +282,16 @@ public function testGetWord() */ protected function _getSessionStub() { - $helper = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $helper = new ObjectManager($this); $sessionArgs = $helper->getConstructArguments( - \Magento\Customer\Model\Session::class, - ['storage' => new \Magento\Framework\Session\Storage()] + Session::class, + ['storage' => new Storage()] ); - $session = $this->getMockBuilder(\Magento\Customer\Model\Session::class) + $session = $this->getMockBuilder(Session::class) ->setMethods(['isLoggedIn', 'getUserCreateWord']) ->setConstructorArgs($sessionArgs) ->getMock(); - $session->expects($this->any())->method('isLoggedIn')->will($this->returnValue(false)); + $session->expects($this->any())->method('isLoggedIn')->willReturn(false); $session->setData( [ @@ -271,34 +307,35 @@ protected function _getSessionStub() /** * Create helper stub - * @return \Magento\Captcha\Helper\Data + * @return Data */ protected function _getHelperStub() { $helper = $this->getMockBuilder( - \Magento\Captcha\Helper\Data::class - )->disableOriginalConstructor()->setMethods( - ['getConfig', 'getFonts', '_getWebsiteCode', 'getImgUrl'] - )->getMock(); + Data::class + )->disableOriginalConstructor() + ->setMethods( + ['getConfig', 'getFonts', '_getWebsiteCode', 'getImgUrl'] + )->getMock(); $helper->expects( $this->any() )->method( 'getConfig' - )->will( - $this->returnCallback('Magento\Captcha\Test\Unit\Model\DefaultTest::getConfigNodeStub') + )->willReturnCallback( + 'Magento\Captcha\Test\Unit\Model\DefaultTest::getConfigNodeStub' ); - $helper->expects($this->any())->method('getFonts')->will($this->returnValue($this->_fontPath)); + $helper->expects($this->any())->method('getFonts')->willReturn($this->_fontPath); - $helper->expects($this->any())->method('_getWebsiteCode')->will($this->returnValue('base')); + $helper->expects($this->any())->method('_getWebsiteCode')->willReturn('base'); $helper->expects( $this->any() )->method( 'getImgUrl' - )->will( - $this->returnValue('http://localhost/pub/media/captcha/base/') + )->willReturn( + 'http://localhost/pub/media/captcha/base/' ); return $helper; @@ -306,20 +343,20 @@ protected function _getHelperStub() /** * Get stub for resource model - * @return \Magento\Captcha\Model\ResourceModel\Log + * @return Log */ protected function _getResourceModelStub() { $resourceModel = $this->createPartialMock( - \Magento\Captcha\Model\ResourceModel\Log::class, + Log::class, ['countAttemptsByRemoteAddress', 'countAttemptsByUserLogin', 'logAttempt', '__wakeup'] ); $resourceModel->expects($this->any())->method('logAttempt'); - $resourceModel->expects($this->any())->method('countAttemptsByRemoteAddress')->will($this->returnValue(0)); + $resourceModel->expects($this->any())->method('countAttemptsByRemoteAddress')->willReturn(0); - $resourceModel->expects($this->any())->method('countAttemptsByUserLogin')->will($this->returnValue(3)); + $resourceModel->expects($this->any())->method('countAttemptsByUserLogin')->willReturn(3); return $resourceModel; } @@ -344,13 +381,13 @@ public static function getConfigNodeStub() /** * Create store stub * - * @return \Magento\Store\Model\Store + * @return Store */ protected function _getStoreStub() { - $store = $this->createPartialMock(\Magento\Store\Model\Store::class, ['isAdmin', 'getBaseUrl']); - $store->expects($this->any())->method('getBaseUrl')->will($this->returnValue('http://localhost/pub/media/')); - $store->expects($this->any())->method('isAdmin')->will($this->returnValue(false)); + $store = $this->createPartialMock(Store::class, ['isAdmin', 'getBaseUrl']); + $store->expects($this->any())->method('getBaseUrl')->willReturn('http://localhost/pub/media/'); + $store->expects($this->any())->method('isAdmin')->willReturn(false); return $store; } @@ -361,7 +398,7 @@ protected function _getStoreStub() */ public function testIsShownToLoggedInUser($expectedResult, $formId) { - $captcha = new \Magento\Captcha\Model\DefaultModel( + $captcha = new DefaultModel( $this->session, $this->_getHelperStub(), $this->_resLogFactory, @@ -392,8 +429,8 @@ public function testGenerateWord($string) $randomMock = $this->createMock(Random::class); $randomMock->expects($this->once()) ->method('getRandomString') - ->will($this->returnValue($string)); - $captcha = new \Magento\Captcha\Model\DefaultModel( + ->willReturn($string); + $captcha = new DefaultModel( $this->session, $this->_getHelperStub(), $this->_resLogFactory, diff --git a/app/code/Magento/Captcha/composer.json b/app/code/Magento/Captcha/composer.json index c6bbcc8e91c3e..bb56f359b4a08 100644 --- a/app/code/Magento/Captcha/composer.json +++ b/app/code/Magento/Captcha/composer.json @@ -11,6 +11,7 @@ "magento/module-checkout": "*", "magento/module-customer": "*", "magento/module-store": "*", + "magento/module-authorization": "*", "laminas/laminas-captcha": "^2.7.1", "laminas/laminas-db": "^2.8.2", "laminas/laminas-session": "^2.7.3" diff --git a/app/code/Magento/Captcha/i18n/en_US.csv b/app/code/Magento/Captcha/i18n/en_US.csv index 480107df8adfe..ac6a7cf9d57e7 100644 --- a/app/code/Magento/Captcha/i18n/en_US.csv +++ b/app/code/Magento/Captcha/i18n/en_US.csv @@ -9,6 +9,7 @@ Always,Always "Reload captcha","Reload captcha" "Please type the letters and numbers below","Please type the letters and numbers below" "Attention: Captcha is case sensitive.","Attention: Captcha is case sensitive." +"Please provide CAPTCHA code and try again","Please provide CAPTCHA code and try again" CAPTCHA,CAPTCHA "Enable CAPTCHA in Admin","Enable CAPTCHA in Admin" Font,Font diff --git a/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php b/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php new file mode 100644 index 0000000000000..e398bf400391b --- /dev/null +++ b/app/code/Magento/Checkout/Api/Exception/PaymentProcessingRateLimitExceededException.php @@ -0,0 +1,19 @@ +userContext = $userContext; + $this->customerRepo = $customerRepo; + $this->captchaHelper = $captchaHelper; + $this->request = $request; + $this->captchaResolver = $captchaResolver; + } + + /** + * @inheritDoc + */ + public function limit(): void + { + if ($this->userContext->getUserType() !== UserContextInterface::USER_TYPE_GUEST + && $this->userContext->getUserType() !== UserContextInterface::USER_TYPE_CUSTOMER + && $this->userContext->getUserType() !== null + ) { + return; + } + + $login = $this->retrieveLogin(); + /** @var Captcha $captcha */ + $captcha = $this->captchaHelper->getCaptcha(self::CAPTCHA_FORM); + /** @var PaymentProcessingRateLimitExceededException|null $exception */ + $exception = null; + if ($captcha->isRequired($login)) { + $value = $this->captchaResolver->resolve($this->request, self::CAPTCHA_FORM); + if ($value && !$captcha->isCorrect($value)) { + $exception = new PaymentProcessingRateLimitExceededException(__('Incorrect CAPTCHA')); + } elseif (!$value) { + $exception = new PaymentProcessingRateLimitExceededException( + __('Please provide CAPTCHA code and try again') + ); + } + } + + $captcha->logAttempt($login); + if ($exception) { + throw $exception; + } + } + + /** + * Retrieve current user login. + * + * @return string|null + */ + private function retrieveLogin(): ?string + { + $login = null; + if ($this->userContext->getUserId()) { + $login = $this->customerRepo->getById($this->userContext->getUserId())->getEmail(); + } + + return $login; + } +} diff --git a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php index 8b8d2602fbfc7..2b2824213df79 100644 --- a/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/GuestPaymentInformationManagement.php @@ -7,8 +7,8 @@ namespace Magento\Checkout\Model; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; use Magento\Framework\App\ObjectManager; -use Magento\Framework\App\ResourceConnection; use Magento\Quote\Api\CartRepositoryInterface; use Magento\Framework\Exception\CouldNotSaveException; use Magento\Quote\Model\Quote; @@ -56,6 +56,11 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa */ private $logger; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentsRateLimiter; + /** * @param \Magento\Quote\Api\GuestBillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\GuestPaymentMethodManagementInterface $paymentMethodManagement @@ -63,6 +68,7 @@ class GuestPaymentInformationManagement implements \Magento\Checkout\Api\GuestPa * @param \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement * @param \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory * @param CartRepositoryInterface $cartRepository + * @param PaymentProcessingRateLimiterInterface|null $paymentsRateLimiter * @codeCoverageIgnore */ public function __construct( @@ -71,7 +77,8 @@ public function __construct( \Magento\Quote\Api\GuestCartManagementInterface $cartManagement, \Magento\Checkout\Api\PaymentInformationManagementInterface $paymentInformationManagement, \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory, - CartRepositoryInterface $cartRepository + CartRepositoryInterface $cartRepository, + ?PaymentProcessingRateLimiterInterface $paymentsRateLimiter = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; @@ -79,6 +86,8 @@ public function __construct( $this->paymentInformationManagement = $paymentInformationManagement; $this->quoteIdMaskFactory = $quoteIdMaskFactory; $this->cartRepository = $cartRepository; + $this->paymentsRateLimiter = $paymentsRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); } /** @@ -121,6 +130,8 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $this->paymentsRateLimiter->limit(); + $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id'); /** @var Quote $quote */ $quote = $this->cartRepository->getActive($quoteIdMask->getQuoteId()); diff --git a/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php b/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php new file mode 100644 index 0000000000000..268e765571205 --- /dev/null +++ b/app/code/Magento/Checkout/Model/PaymentCaptchaConfigProvider.php @@ -0,0 +1,88 @@ +storeManager = $storeManager; + $this->captchaData = $captchaData; + $this->customerSession = $customerSession; + } + + /** + * @inheritDoc + */ + public function getConfig() + { + /** @var Store $store */ + $store = $this->storeManager->getStore(); + /** @var DefaultModel $captchaModel */ + $captchaModel = $this->captchaData->getCaptcha(CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM); + $login = null; + if ($this->customerSession->isLoggedIn()) { + $login = $this->customerSession->getCustomerData()->getEmail(); + } + $required = $captchaModel->isRequired($login); + if ($required) { + $captchaModel->generate(); + $imageSrc = $captchaModel->getImgSrc(); + } else { + $imageSrc = ''; + } + + return [ + 'captcha' => [ + CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM => [ + 'isCaseSensitive' => (bool)$captchaModel->isCaseSensitive(), + 'imageHeight' => $captchaModel->getHeight(), + 'imageSrc' => $imageSrc, + 'refreshUrl' => $store->getUrl('captcha/refresh', ['_secure' => $store->isCurrentlySecure()]), + 'isRequired' => $required, + 'timestamp' => time() + ] + ] + ]; + } +} diff --git a/app/code/Magento/Checkout/Model/PaymentInformationManagement.php b/app/code/Magento/Checkout/Model/PaymentInformationManagement.php index 2f68aba5ec6ae..a6e448ecdb87e 100644 --- a/app/code/Magento/Checkout/Model/PaymentInformationManagement.php +++ b/app/code/Magento/Checkout/Model/PaymentInformationManagement.php @@ -6,6 +6,8 @@ namespace Magento\Checkout\Model; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Exception\CouldNotSaveException; /** @@ -51,12 +53,18 @@ class PaymentInformationManagement implements \Magento\Checkout\Api\PaymentInfor */ private $cartRepository; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentRateLimiter; + /** * @param \Magento\Quote\Api\BillingAddressManagementInterface $billingAddressManagement * @param \Magento\Quote\Api\PaymentMethodManagementInterface $paymentMethodManagement * @param \Magento\Quote\Api\CartManagementInterface $cartManagement * @param PaymentDetailsFactory $paymentDetailsFactory * @param \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository + * @param PaymentProcessingRateLimiterInterface|null $paymentRateLimiter * @codeCoverageIgnore */ public function __construct( @@ -64,13 +72,16 @@ public function __construct( \Magento\Quote\Api\PaymentMethodManagementInterface $paymentMethodManagement, \Magento\Quote\Api\CartManagementInterface $cartManagement, \Magento\Checkout\Model\PaymentDetailsFactory $paymentDetailsFactory, - \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository + \Magento\Quote\Api\CartTotalRepositoryInterface $cartTotalsRepository, + ?PaymentProcessingRateLimiterInterface $paymentRateLimiter = null ) { $this->billingAddressManagement = $billingAddressManagement; $this->paymentMethodManagement = $paymentMethodManagement; $this->cartManagement = $cartManagement; $this->paymentDetailsFactory = $paymentDetailsFactory; $this->cartTotalsRepository = $cartTotalsRepository; + $this->paymentRateLimiter = $paymentRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); } /** @@ -110,6 +121,8 @@ public function savePaymentInformation( \Magento\Quote\Api\Data\PaymentInterface $paymentMethod, \Magento\Quote\Api\Data\AddressInterface $billingAddress = null ) { + $this->paymentRateLimiter->limit(); + if ($billingAddress) { /** @var \Magento\Quote\Api\CartRepositoryInterface $quoteRepository */ $quoteRepository = $this->getCartRepository(); @@ -157,7 +170,7 @@ public function getPaymentInformation($cartId) private function getLogger() { if (!$this->logger) { - $this->logger = \Magento\Framework\App\ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class); + $this->logger = ObjectManager::getInstance()->get(\Psr\Log\LoggerInterface::class); } return $this->logger; } @@ -171,7 +184,7 @@ private function getLogger() private function getCartRepository() { if (!$this->cartRepository) { - $this->cartRepository = \Magento\Framework\App\ObjectManager::getInstance() + $this->cartRepository = ObjectManager::getInstance() ->get(\Magento\Quote\Api\CartRepositoryInterface::class); } return $this->cartRepository; diff --git a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php index e3843991a181f..baf31beb271a9 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/GuestPaymentInformationManagementTest.php @@ -7,14 +7,31 @@ namespace Magento\Checkout\Test\Unit\Model; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Checkout\Model\GuestPaymentInformationManagement; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Phrase; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\AddressInterface; +use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Quote\Api\GuestBillingAddressManagementInterface; +use Magento\Quote\Api\GuestCartManagementInterface; +use Magento\Quote\Api\GuestPaymentMethodManagementInterface; use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\Quote\Address\Rate; use Magento\Quote\Model\QuoteIdMask; +use Magento\Quote\Model\QuoteIdMaskFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase +class GuestPaymentInformationManagementTest extends TestCase { /** * @var \PHPUnit_Framework_MockObject_MockObject @@ -42,7 +59,7 @@ class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase protected $quoteIdMaskFactoryMock; /** - * @var \Magento\Checkout\Model\GuestPaymentInformationManagement + * @var GuestPaymentInformationManagement */ protected $model; @@ -51,31 +68,38 @@ class GuestPaymentInformationManagementTest extends \PHPUnit\Framework\TestCase */ private $loggerMock; + /** + * @var PaymentProcessingRateLimiterInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private $limiterMock; + protected function setUp() { - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $objectManager = new ObjectManager($this); $this->billingAddressManagementMock = $this->createMock( - \Magento\Quote\Api\GuestBillingAddressManagementInterface::class + GuestBillingAddressManagementInterface::class ); $this->paymentMethodManagementMock = $this->createMock( - \Magento\Quote\Api\GuestPaymentMethodManagementInterface::class + GuestPaymentMethodManagementInterface::class ); - $this->cartManagementMock = $this->createMock(\Magento\Quote\Api\GuestCartManagementInterface::class); - $this->cartRepositoryMock = $this->createMock(\Magento\Quote\Api\CartRepositoryInterface::class); + $this->cartManagementMock = $this->getMockForAbstractClass(GuestCartManagementInterface::class); + $this->cartRepositoryMock = $this->getMockForAbstractClass(CartRepositoryInterface::class); $this->quoteIdMaskFactoryMock = $this->createPartialMock( - \Magento\Quote\Model\QuoteIdMaskFactory::class, + QuoteIdMaskFactory::class, ['create'] ); - $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); + $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + $this->limiterMock = $this->getMockForAbstractClass(PaymentProcessingRateLimiterInterface::class); $this->model = $objectManager->getObject( - \Magento\Checkout\Model\GuestPaymentInformationManagement::class, + GuestPaymentInformationManagement::class, [ 'billingAddressManagement' => $this->billingAddressManagementMock, 'paymentMethodManagement' => $this->paymentMethodManagementMock, 'cartManagement' => $this->cartManagementMock, 'cartRepository' => $this->cartRepositoryMock, - 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock + 'quoteIdMaskFactory' => $this->quoteIdMaskFactoryMock, + 'paymentsRateLimiter' => $this->limiterMock ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -83,22 +107,21 @@ protected function setUp() public function testSavePaymentInformationAndPlaceOrder() { - $cartId = 100; $orderId = 200; - $email = 'email@magento.com'; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - - $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); - - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); + $this->assertEquals($orderId, $this->placeOrder($orderId)); + } - $this->assertEquals( - $orderId, - $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock) - ); + /** + * Validate that "testSavePaymentInformationAndPlaceOrderLimited" calls are limited. + * + * @return void + */ + public function testSavePaymentInformationAndPlaceOrderLimited(): void + { + $this->expectException(PaymentProcessingRateLimitExceededException::class); + $this->limiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->placeOrder(); } /** @@ -108,14 +131,14 @@ public function testSavePaymentInformationAndPlaceOrderException() { $cartId = 100; $email = 'email@magento.com'; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $exception = new \Magento\Framework\Exception\CouldNotSaveException(__('DB exception')); + $exception = new CouldNotSaveException(__('DB exception')); $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); @@ -127,24 +150,29 @@ public function testSavePaymentInformationAndPlaceOrderException() public function testSavePaymentInformation() { - $cartId = 100; - $email = 'email@magento.com'; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); + $this->assertTrue($this->savePayment()); + } - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + /** + * Validate that this method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationLimited(): void + { + $this->expectException(PaymentProcessingRateLimitExceededException::class); + $this->limiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); - $this->assertTrue($this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock)); + $this->savePayment(); } public function testSavePaymentInformationWithoutBillingAddress() { $cartId = 100; $email = 'email@magento.com'; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $quoteMock = $this->createMock(Quote::class); $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); @@ -169,8 +197,8 @@ public function testSavePaymentInformationAndPlaceOrderWithLocalizedException() { $cartId = 100; $email = 'email@magento.com'; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $quoteMock = $this->createMock(Quote::class); $quoteMock->method('getBillingAddress')->willReturn($billingAddressMock); @@ -184,8 +212,8 @@ public function testSavePaymentInformationAndPlaceOrderWithLocalizedException() $billingAddressMock->expects($this->once())->method('setEmail')->with($email)->willReturnSelf(); $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $phrase = new \Magento\Framework\Phrase(__('DB exception')); - $exception = new \Magento\Framework\Exception\LocalizedException($phrase); + $phrase = new Phrase(__('DB exception')); + $exception = new LocalizedException($phrase); $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); @@ -212,7 +240,7 @@ private function getMockForAssignBillingAddress( $billingAddressId = 1; $quote = $this->createMock(Quote::class); $quoteBillingAddress = $this->createMock(Address::class); - $shippingRate = $this->createPartialMock(\Magento\Quote\Model\Quote\Address\Rate::class, []); + $shippingRate = $this->createPartialMock(Rate::class, []); $shippingRate->setCarrier('flatrate'); $quoteShippingAddress = $this->createPartialMock( Address::class, @@ -221,31 +249,75 @@ private function getMockForAssignBillingAddress( $this->cartRepositoryMock->method('getActive') ->with($cartId) ->willReturn($quote); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('getBillingAddress') ->willReturn($quoteBillingAddress); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('getShippingAddress') ->willReturn($quoteShippingAddress); - $quoteBillingAddress->expects($this->once()) + $quoteBillingAddress->expects($this->any()) ->method('getId') ->willReturn($billingAddressId); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('removeAddress') ->with($billingAddressId); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('setBillingAddress') ->with($billingAddressMock); $quoteShippingAddress->expects($this->any()) ->method('getShippingRateByCode') ->willReturn($shippingRate); - $quote->expects($this->once()) + $quote->expects($this->any()) ->method('setDataChanges') ->willReturnSelf(); $quoteShippingAddress->method('getShippingMethod') ->willReturn('flatrate_flatrate'); - $quoteShippingAddress->expects($this->once()) + $quoteShippingAddress->expects($this->any()) ->method('setLimitCarrier') ->with('flatrate'); } + + /** + * Place order. + * + * @param int $orderId + * @return mixed Method call result. + */ + private function placeOrder(?int $orderId = 200) + { + $cartId = 100; + $email = 'email@magento.com'; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + + $billingAddressMock->expects($this->any())->method('setEmail')->with($email)->willReturnSelf(); + + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any()) + ->method('placeOrder') + ->with($cartId) + ->willReturn($orderId); + + return $this->model->savePaymentInformationAndPlaceOrder($cartId, $email, $paymentMock, $billingAddressMock); + } + + /** + * Save payment information. + * + * @return mixed Call result. + */ + private function savePayment() + { + $cartId = 100; + $email = 'email@magento.com'; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $billingAddressMock->expects($this->any())->method('setEmail')->with($email)->willReturnSelf(); + + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + + return $this->model->savePaymentInformation($cartId, $email, $paymentMock, $billingAddressMock); + } } diff --git a/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php b/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php index ece395e3131f9..7b1860c16aa7d 100644 --- a/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php +++ b/app/code/Magento/Checkout/Test/Unit/Model/PaymentInformationManagementTest.php @@ -3,13 +3,32 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ +declare(strict_types=1); namespace Magento\Checkout\Test\Unit\Model; +use Magento\Checkout\Api\Exception\PaymentProcessingRateLimitExceededException; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Checkout\Model\PaymentInformationManagement; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Phrase; +use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; +use Magento\Quote\Api\BillingAddressManagementInterface; +use Magento\Quote\Api\CartManagementInterface; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\AddressInterface; +use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Quote\Api\PaymentMethodManagementInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use Magento\Quote\Model\Quote\Address\Rate; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class PaymentInformationManagementTest extends \PHPUnit\Framework\TestCase +class PaymentInformationManagementTest extends TestCase { /** * @var \PHPUnit_Framework_MockObject_MockObject @@ -27,7 +46,7 @@ class PaymentInformationManagementTest extends \PHPUnit\Framework\TestCase protected $cartManagementMock; /** - * @var \Magento\Checkout\Model\PaymentInformationManagement + * @var PaymentInformationManagement */ protected $model; @@ -41,25 +60,33 @@ class PaymentInformationManagementTest extends \PHPUnit\Framework\TestCase */ private $cartRepositoryMock; + /** + * @var PaymentProcessingRateLimiterInterface|MockObject + */ + private $rateLimiterMock; + protected function setUp() { - $objectManager = new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); + $objectManager = new ObjectManager($this); $this->billingAddressManagementMock = $this->createMock( - \Magento\Quote\Api\BillingAddressManagementInterface::class + BillingAddressManagementInterface::class ); $this->paymentMethodManagementMock = $this->createMock( - \Magento\Quote\Api\PaymentMethodManagementInterface::class + PaymentMethodManagementInterface::class ); - $this->cartManagementMock = $this->createMock(\Magento\Quote\Api\CartManagementInterface::class); + $this->cartManagementMock = $this->getMockForAbstractClass(CartManagementInterface::class); - $this->loggerMock = $this->createMock(\Psr\Log\LoggerInterface::class); - $this->cartRepositoryMock = $this->getMockBuilder(\Magento\Quote\Api\CartRepositoryInterface::class)->getMock(); + $this->loggerMock = $this->getMockForAbstractClass(LoggerInterface::class); + $this->cartRepositoryMock = $this->getMockBuilder(CartRepositoryInterface::class) + ->getMock(); + $this->rateLimiterMock = $this->getMockForAbstractClass(PaymentProcessingRateLimiterInterface::class); $this->model = $objectManager->getObject( - \Magento\Checkout\Model\PaymentInformationManagement::class, + PaymentInformationManagement::class, [ 'billingAddressManagement' => $this->billingAddressManagementMock, 'paymentMethodManagement' => $this->paymentMethodManagementMock, - 'cartManagement' => $this->cartManagementMock + 'cartManagement' => $this->cartManagementMock, + 'paymentRateLimiter' => $this->rateLimiterMock ] ); $objectManager->setBackwardCompatibleProperty($this->model, 'logger', $this->loggerMock); @@ -68,35 +95,41 @@ protected function setUp() public function testSavePaymentInformationAndPlaceOrder() { - $cartId = 100; $orderId = 200; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); - $this->assertEquals( $orderId, - $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock) + $this->placeOrder($orderId) ); } + /** + * Valdiate that the method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationAndPlaceOrderLimited(): void + { + $this->rateLimiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->expectException(PaymentProcessingRateLimitExceededException::class); + + $this->placeOrder(); + } + /** * @expectedException \Magento\Framework\Exception\CouldNotSaveException */ public function testSavePaymentInformationAndPlaceOrderException() { $cartId = 100; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $exception = new \Exception(__('DB exception')); - $this->loggerMock->expects($this->once())->method('critical'); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $exception = new \Exception('DB exception'); + $this->loggerMock->expects($this->any())->method('critical'); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); @@ -109,10 +142,10 @@ public function testSavePaymentInformationAndPlaceOrderIfBillingAddressNotExist( { $cartId = 100; $orderId = 200; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->with($cartId)->willReturn($orderId); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->with($cartId)->willReturn($orderId); $this->assertEquals( $orderId, @@ -122,22 +155,29 @@ public function testSavePaymentInformationAndPlaceOrderIfBillingAddressNotExist( public function testSavePaymentInformation() { - $cartId = 100; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $this->assertTrue($this->savePayment()); + } - $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + /** + * Validate that the method is rate-limited. + * + * @return void + */ + public function testSavePaymentInformationLimited(): void + { + $this->rateLimiterMock->method('limit') + ->willThrowException(new PaymentProcessingRateLimitExceededException(__('Error'))); + $this->expectException(PaymentProcessingRateLimitExceededException::class); - $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock)); + $this->savePayment(); } public function testSavePaymentInformationWithoutBillingAddress() { $cartId = 100; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock)); } @@ -149,15 +189,15 @@ public function testSavePaymentInformationWithoutBillingAddress() public function testSavePaymentInformationAndPlaceOrderWithLocolizedException() { $cartId = 100; - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $phrase = new \Magento\Framework\Phrase(__('DB exception')); - $exception = new \Magento\Framework\Exception\LocalizedException($phrase); - $this->cartManagementMock->expects($this->once())->method('placeOrder')->willThrowException($exception); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $phrase = new Phrase(__('DB exception')); + $exception = new LocalizedException($phrase); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->willThrowException($exception); $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); } @@ -172,18 +212,18 @@ public function testSavePaymentInformationAndPlaceOrderWithNewBillingAddress(): $cartId = 100; $quoteBillingAddressId = 1; $customerId = 1; - $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); - $quoteBillingAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $billingAddressMock = $this->createMock(\Magento\Quote\Api\Data\AddressInterface::class); - $paymentMock = $this->createMock(\Magento\Quote\Api\Data\PaymentInterface::class); + $quoteMock = $this->createMock(Quote::class); + $quoteBillingAddress = $this->createMock(Address::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); $quoteBillingAddress->method('getCustomerId')->willReturn($customerId); $quoteMock->method('getBillingAddress')->willReturn($quoteBillingAddress); $quoteBillingAddress->method('getId')->willReturn($quoteBillingAddressId); $this->cartRepositoryMock->method('getActive')->with($cartId)->willReturn($quoteMock); - - $this->paymentMethodManagementMock->expects($this->once())->method('set')->with($cartId, $paymentMock); - $billingAddressMock->expects($this->once())->method('setCustomerId')->with($customerId); + + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $billingAddressMock->expects($this->any())->method('setCustomerId')->with($customerId); $this->assertTrue($this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock)); } @@ -194,24 +234,60 @@ public function testSavePaymentInformationAndPlaceOrderWithNewBillingAddress(): private function getMockForAssignBillingAddress($cartId, $billingAddressMock) { $billingAddressId = 1; - $quoteMock = $this->createMock(\Magento\Quote\Model\Quote::class); - $quoteBillingAddress = $this->createMock(\Magento\Quote\Model\Quote\Address::class); - $shippingRate = $this->createPartialMock(\Magento\Quote\Model\Quote\Address\Rate::class, []); + $quoteMock = $this->createMock(Quote::class); + $quoteBillingAddress = $this->createMock(Address::class); + $shippingRate = $this->createPartialMock(Rate::class, []); $shippingRate->setCarrier('flatrate'); $quoteShippingAddress = $this->createPartialMock( - \Magento\Quote\Model\Quote\Address::class, + Address::class, ['setLimitCarrier', 'getShippingMethod', 'getShippingRateByCode'] ); $this->cartRepositoryMock->expects($this->any())->method('getActive')->with($cartId)->willReturn($quoteMock); $quoteMock->method('getBillingAddress')->willReturn($quoteBillingAddress); - $quoteMock->expects($this->once())->method('getShippingAddress')->willReturn($quoteShippingAddress); - $quoteBillingAddress->expects($this->once())->method('getId')->willReturn($billingAddressId); - $quoteBillingAddress->expects($this->once())->method('getId')->willReturn($billingAddressId); - $quoteMock->expects($this->once())->method('removeAddress')->with($billingAddressId); - $quoteMock->expects($this->once())->method('setBillingAddress')->with($billingAddressMock); - $quoteMock->expects($this->once())->method('setDataChanges')->willReturnSelf(); + $quoteMock->expects($this->any())->method('getShippingAddress')->willReturn($quoteShippingAddress); + $quoteBillingAddress->expects($this->any())->method('getId')->willReturn($billingAddressId); + $quoteBillingAddress->expects($this->any())->method('getId')->willReturn($billingAddressId); + $quoteMock->expects($this->any())->method('removeAddress')->with($billingAddressId); + $quoteMock->expects($this->any())->method('setBillingAddress')->with($billingAddressMock); + $quoteMock->expects($this->any())->method('setDataChanges')->willReturnSelf(); $quoteShippingAddress->expects($this->any())->method('getShippingRateByCode')->willReturn($shippingRate); $quoteShippingAddress->expects($this->any())->method('getShippingMethod')->willReturn('flatrate_flatrate'); - $quoteShippingAddress->expects($this->once())->method('setLimitCarrier')->with('flatrate')->willReturnSelf(); + $quoteShippingAddress->expects($this->any())->method('setLimitCarrier')->with('flatrate')->willReturnSelf(); + } + + /** + * Save payment information. + * + * @return mixed + */ + private function savePayment() + { + $cartId = 100; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + + return $this->model->savePaymentInformation($cartId, $paymentMock, $billingAddressMock); + } + + /** + * Call `place order`. + * + * @param int|null $orderId + * @return mixed + */ + private function placeOrder(?int $orderId = 200) + { + $cartId = 100; + $paymentMock = $this->getMockForAbstractClass(PaymentInterface::class); + $billingAddressMock = $this->getMockForAbstractClass(AddressInterface::class); + + $this->getMockForAssignBillingAddress($cartId, $billingAddressMock); + $this->paymentMethodManagementMock->expects($this->any())->method('set')->with($cartId, $paymentMock); + $this->cartManagementMock->expects($this->any())->method('placeOrder')->with($cartId)->willReturn($orderId); + + return $this->model->savePaymentInformationAndPlaceOrder($cartId, $paymentMock, $billingAddressMock); } } diff --git a/app/code/Magento/Checkout/composer.json b/app/code/Magento/Checkout/composer.json index cce1dd466de9a..b5c1f007002c0 100644 --- a/app/code/Magento/Checkout/composer.json +++ b/app/code/Magento/Checkout/composer.json @@ -24,7 +24,8 @@ "magento/module-tax": "*", "magento/module-theme": "*", "magento/module-ui": "*", - "magento/module-captcha": "*" + "magento/module-captcha": "*", + "magento/module-authorization": "*" }, "suggest": { "magento/module-cookie": "*" diff --git a/app/code/Magento/Checkout/etc/config.xml b/app/code/Magento/Checkout/etc/config.xml index f8c2e7ebcb503..b8dc5874a973d 100644 --- a/app/code/Magento/Checkout/etc/config.xml +++ b/app/code/Magento/Checkout/etc/config.xml @@ -39,6 +39,9 @@ + + + @@ -46,6 +49,7 @@ 1 + 1 diff --git a/app/code/Magento/Checkout/etc/di.xml b/app/code/Magento/Checkout/etc/di.xml index 4ebd594a28562..0c1d866dfc2fb 100644 --- a/app/code/Magento/Checkout/etc/di.xml +++ b/app/code/Magento/Checkout/etc/di.xml @@ -49,4 +49,6 @@ + diff --git a/app/code/Magento/Checkout/etc/frontend/di.xml b/app/code/Magento/Checkout/etc/frontend/di.xml index 8f35fe9f37abf..1d50ec14c0bba 100644 --- a/app/code/Magento/Checkout/etc/frontend/di.xml +++ b/app/code/Magento/Checkout/etc/frontend/di.xml @@ -49,6 +49,7 @@ Magento\Checkout\Model\DefaultConfigProvider Magento\Checkout\Model\Cart\CheckoutSummaryConfigProvider + Magento\Checkout\Model\PaymentCaptchaConfigProvider @@ -99,4 +100,11 @@ + + + + payment_processing_request + + + diff --git a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml index fed0c951eff9f..685e4db9955f1 100644 --- a/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml +++ b/app/code/Magento/Checkout/view/frontend/layout/checkout_index_index.xml @@ -287,6 +287,12 @@ + + Magento_Checkout/js/view/checkout/placeOrderCaptcha + place-order-captcha + payment_processing_request + checkoutConfig + uiComponent beforeMethods diff --git a/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js b/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js index 9de8a93905c99..ae5b0914e83a6 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/action/set-payment-information-extended.js @@ -14,8 +14,9 @@ define([ 'Magento_Customer/js/model/customer', 'Magento_Checkout/js/action/get-totals', 'Magento_Checkout/js/model/full-screen-loader', - 'underscore' -], function (quote, urlBuilder, storage, errorProcessor, customer, getTotalsAction, fullScreenLoader, _) { + 'underscore', + 'Magento_Checkout/js/model/payment/set-payment-hooks' +], function (quote, urlBuilder, storage, errorProcessor, customer, getTotalsAction, fullScreenLoader, _, hooks) { 'use strict'; /** @@ -37,7 +38,8 @@ define([ return function (messageContainer, paymentData, skipBilling) { var serviceUrl, - payload; + payload, + headers = {}; paymentData = filterTemplateData(paymentData); skipBilling = skipBilling || false; @@ -64,8 +66,12 @@ define([ fullScreenLoader.startLoader(); + _.each(hooks.requestModifiers, function (modifier) { + modifier(headers, payload); + }); + return storage.post( - serviceUrl, JSON.stringify(payload) + serviceUrl, JSON.stringify(payload), true, 'application/json', headers ).fail( function (response) { errorProcessor.process(response, messageContainer); @@ -73,6 +79,9 @@ define([ ).always( function () { fullScreenLoader.stopLoader(); + _.each(hooks.afterRequestListeners, function (listener) { + listener(); + }); } ); }; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js new file mode 100644 index 0000000000000..5cd31d85c9a29 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/place-order-hooks.js @@ -0,0 +1,13 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([], function () { + 'use strict'; + + return { + requestModifiers: [], + afterRequestListeners: [] + }; +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js new file mode 100644 index 0000000000000..5cd31d85c9a29 --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/payment/set-payment-hooks.js @@ -0,0 +1,13 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([], function () { + 'use strict'; + + return { + requestModifiers: [], + afterRequestListeners: [] + }; +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js index c07878fcaea92..701c31944939b 100644 --- a/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js +++ b/app/code/Magento/Checkout/view/frontend/web/js/model/place-order.js @@ -10,16 +10,23 @@ define( 'mage/storage', 'Magento_Checkout/js/model/error-processor', 'Magento_Checkout/js/model/full-screen-loader', - 'Magento_Customer/js/customer-data' + 'Magento_Customer/js/customer-data', + 'Magento_Checkout/js/model/payment/place-order-hooks', + 'underscore' ], - function (storage, errorProcessor, fullScreenLoader, customerData) { + function (storage, errorProcessor, fullScreenLoader, customerData, hooks, _) { 'use strict'; return function (serviceUrl, payload, messageContainer) { + var headers = {}; + fullScreenLoader.startLoader(); + _.each(hooks.requestModifiers, function (modifier) { + modifier(headers, payload); + }); return storage.post( - serviceUrl, JSON.stringify(payload) + serviceUrl, JSON.stringify(payload), true, 'application/json', headers ).fail( function (response) { errorProcessor.process(response, messageContainer); @@ -44,6 +51,9 @@ define( ).always( function () { fullScreenLoader.stopLoader(); + _.each(hooks.afterRequestListeners, function (listener) { + listener(); + }); } ); }; diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js new file mode 100644 index 0000000000000..d0e27ad8e0abb --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/placeOrderCaptcha.js @@ -0,0 +1,38 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Captcha/js/view/checkout/defaultCaptcha', + 'Magento_Captcha/js/model/captchaList', + 'underscore', + 'Magento_Checkout/js/model/payment/place-order-hooks' +], +function (defaultCaptcha, captchaList, _, placeOrderHooks) { + 'use strict'; + + return defaultCaptcha.extend({ + /** @inheritdoc */ + initialize: function () { + var self = this, + currentCaptcha; + + this._super(); + currentCaptcha = captchaList.getCaptchaByFormId(this.formId); + + if (currentCaptcha != null) { + currentCaptcha.setIsVisible(true); + this.setCurrentCaptcha(currentCaptcha); + placeOrderHooks.requestModifiers.push(function (headers) { + if (self.isRequired()) { + headers['X-Captcha'] = self.captchaValue()(); + } + }); + placeOrderHooks.afterRequestListeners.push(function () { + self.refresh(); + }); + } + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js new file mode 100644 index 0000000000000..93f3bb8b2a45c --- /dev/null +++ b/app/code/Magento/Checkout/view/frontend/web/js/view/checkout/setPaymentCaptcha.js @@ -0,0 +1,38 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'Magento_Captcha/js/view/checkout/defaultCaptcha', + 'Magento_Captcha/js/model/captchaList', + 'underscore', + 'Magento_Checkout/js/model/payment/set-payment-hooks' +], +function (defaultCaptcha, captchaList, _, setPaymentHooks) { + 'use strict'; + + return defaultCaptcha.extend({ + /** @inheritdoc */ + initialize: function () { + var self = this, + currentCaptcha; + + this._super(); + currentCaptcha = captchaList.getCaptchaByFormId(this.formId); + + if (currentCaptcha != null) { + currentCaptcha.setIsVisible(true); + this.setCurrentCaptcha(currentCaptcha); + setPaymentHooks.requestModifiers.push(function (headers) { + if (self.isRequired()) { + headers['X-Captcha'] = self.captchaValue()(); + } + }); + setPaymentHooks.afterRequestListeners.push(function () { + self.refresh(); + }); + } + } + }); +}); diff --git a/app/code/Magento/Checkout/view/frontend/web/template/payment.html b/app/code/Magento/Checkout/view/frontend/web/template/payment.html index a3e1a0f7aca90..1e3d3fed3876f 100644 --- a/app/code/Magento/Checkout/view/frontend/web/template/payment.html +++ b/app/code/Magento/Checkout/view/frontend/web/template/payment.html @@ -21,6 +21,10 @@
+ + + +
diff --git a/app/code/Magento/Multishipping/Block/Checkout/Overview.php b/app/code/Magento/Multishipping/Block/Checkout/Overview.php index d078785ff7966..05da219b67b92 100644 --- a/app/code/Magento/Multishipping/Block/Checkout/Overview.php +++ b/app/code/Magento/Multishipping/Block/Checkout/Overview.php @@ -6,6 +6,8 @@ namespace Magento\Multishipping\Block\Checkout; +use Magento\Captcha\Block\Captcha; +use Magento\Checkout\Model\CaptchaPaymentProcessingRateLimiter; use Magento\Framework\Pricing\PriceCurrencyInterface; use Magento\Quote\Model\Quote\Address; @@ -15,6 +17,7 @@ * @api * @author Magento Core Team * @since 100.0.2 + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Overview extends \Magento\Sales\Block\Items\AbstractItems { @@ -116,6 +119,20 @@ protected function _prepareLayout() $this->pageConfig->getTitle()->set( __('Review Order - %1', $this->pageConfig->getTitle()->getDefault()) ); + if (!$this->getChildBlock('captcha')) { + $this->addChild( + 'captcha', + Captcha::class, + [ + 'cacheable' => false, + 'after' => '-', + 'form_id' => CaptchaPaymentProcessingRateLimiter::CAPTCHA_FORM, + 'image_width' => 230, + 'image_height' => 230 + ] + ); + } + return parent::_prepareLayout(); } diff --git a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php index f05a7f43b8118..b3333d828a094 100644 --- a/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php +++ b/app/code/Magento/Multishipping/Controller/Checkout/OverviewPost.php @@ -5,6 +5,9 @@ */ namespace Magento\Multishipping\Controller\Checkout; +use Magento\Checkout\Api\PaymentProcessingRateLimiterInterface; +use Magento\Framework\App\Action\HttpPostActionInterface; +use Magento\Framework\App\ObjectManager; use Magento\Multishipping\Model\Checkout\Type\Multishipping\State; use Magento\Customer\Api\AccountManagementInterface; use Magento\Customer\Api\CustomerRepositoryInterface; @@ -12,14 +15,15 @@ use Magento\Framework\Session\SessionManagerInterface; /** - * Class OverviewPost + * Placing orders. * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ -class OverviewPost extends \Magento\Multishipping\Controller\Checkout +class OverviewPost extends \Magento\Multishipping\Controller\Checkout implements HttpPostActionInterface { /** * @var \Magento\Framework\Data\Form\FormKey\Validator + * @deprecated Form key validation is handled on the framework level. */ protected $formKeyValidator; @@ -38,6 +42,11 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout */ private $session; + /** + * @var PaymentProcessingRateLimiterInterface + */ + private $paymentRateLimiter; + /** * @param \Magento\Framework\App\Action\Context $context * @param \Magento\Customer\Model\Session $customerSession @@ -47,6 +56,7 @@ class OverviewPost extends \Magento\Multishipping\Controller\Checkout * @param \Psr\Log\LoggerInterface $logger * @param \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator * @param SessionManagerInterface $session + * @param PaymentProcessingRateLimiterInterface|null $paymentRateLimiter */ public function __construct( \Magento\Framework\App\Action\Context $context, @@ -56,12 +66,15 @@ public function __construct( \Magento\Framework\Data\Form\FormKey\Validator $formKeyValidator, \Psr\Log\LoggerInterface $logger, \Magento\Checkout\Api\AgreementsValidatorInterface $agreementValidator, - SessionManagerInterface $session + SessionManagerInterface $session, + ?PaymentProcessingRateLimiterInterface $paymentRateLimiter = null ) { $this->formKeyValidator = $formKeyValidator; $this->logger = $logger; $this->agreementsValidator = $agreementValidator; $this->session = $session; + $this->paymentRateLimiter = $paymentRateLimiter + ?? ObjectManager::getInstance()->get(PaymentProcessingRateLimiterInterface::class); parent::__construct( $context, @@ -79,15 +92,12 @@ public function __construct( */ public function execute() { - if (!$this->formKeyValidator->validate($this->getRequest())) { - $this->_forward('backToAddresses'); - return; - } - if (!$this->_validateMinimumAmount()) { - return; - } - try { + $this->paymentRateLimiter->limit(); + if (!$this->_validateMinimumAmount()) { + return; + } + if (!$this->agreementsValidator->isValid(array_keys($this->getRequest()->getPost('agreement', [])))) { $this->messageManager->addError( __('Please agree to all Terms and Conditions before placing the order.') diff --git a/app/code/Magento/Multishipping/composer.json b/app/code/Magento/Multishipping/composer.json index 61f4aafe567db..e9e680fc4c880 100644 --- a/app/code/Magento/Multishipping/composer.json +++ b/app/code/Magento/Multishipping/composer.json @@ -15,7 +15,8 @@ "magento/module-sales": "*", "magento/module-store": "*", "magento/module-tax": "*", - "magento/module-theme": "*" + "magento/module-theme": "*", + "magento/module-captcha": "*" }, "type": "magento2-module", "license": [ diff --git a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml index da31d11892cb0..8c50f54fab69c 100644 --- a/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml +++ b/app/code/Magento/Multishipping/view/frontend/templates/checkout/overview.phtml @@ -9,7 +9,7 @@ /** @var \Magento\Multishipping\Block\Checkout\Overview $block */ ?> getCheckoutData()->getAddressErrors(); ?> - $error) : ?> + $error): ?>
escapeHtml($error); ?> escapeHtml(__('Please see')); ?> @@ -60,7 +60,7 @@
escapeHtml(__('Shipping Information')); ?>
helper(Magento\Tax\Helper\Data::class)->displayCartBothPrices() ? 2 : 1); ?> - getShippingAddresses() as $index => $address) : ?> + getShippingAddresses() as $index => $address): ?>
@@ -72,7 +72,7 @@
- getCheckoutData()->getAddressError($address)) : ?> + getCheckoutData()->getAddressError($address)): ?>
escapeHtml($error); ?>
@@ -93,7 +93,7 @@ escapeHtml(__('Change')); ?> - getShippingAddressRate($address)) : ?> + getShippingAddressRate($address)): ?>
escapeHtml($_rate->getCarrierTitle()) ?> (escapeHtml($_rate->getMethodTitle()) ?>) @@ -103,7 +103,7 @@ $displayBothPrices = $this->helper(Magento\Tax\Helper\Data::class) ->displayShippingBothPrices() && $inclTax !== $exclTax; ?> - + @@ -112,7 +112,7 @@ data-label="escapeHtmlAttr(__('Excl. Tax')); ?>"> - +
@@ -138,7 +138,7 @@ - getShippingAddressItems($address) as $item) : ?> + getShippingAddressItems($address) as $item): ?> getRowItemHtml($item) ?> @@ -155,13 +155,13 @@
- getQuote()->hasVirtualItems()) : ?> + getQuote()->hasVirtualItems()): ?>
getQuote()->getBillingAddress(); ?>
escapeHtml(__('Other items in your order')); ?>
- getCheckoutData()->getAddressError($billingAddress)) :?> + getCheckoutData()->getAddressError($billingAddress)): ?>
escapeHtml($error); ?>
@@ -183,7 +183,7 @@ - getVirtualItems() as $_item) : ?> + getVirtualItems() as $_item): ?> getRowItemHtml($_item) ?> @@ -209,6 +209,7 @@
+ getChildHtml('captcha') ?>