diff --git a/patches.json b/patches.json index 34208a3..09e3449 100644 --- a/patches.json +++ b/patches.json @@ -205,6 +205,35 @@ "Fix pagebuilder module": { "2.3.1": "MDVA-22979__fix_pagebuilder_module__2.3.1.patch", "2.3.2": "MDVA-22979__fix_pagebuilder_module__2.3.2.patch" + }, + "Fix XSS in order history": { + "2.2.0 - 2.2.6": "PRODSECBUG-2233__fix_xss_in_order_history__2.2.0.patch", + "2.2.7 - 2.2.8": "PRODSECBUG-2233__fix_xss_in_order_history__2.2.7.patch", + "2.3.0 - 2.3.1": "PRODSECBUG-2233__fix_xss_in_order_history__2.3.0.patch" + }, + "Pass Store View scope in the Async/Bulk Web API": { + "2.3.1": "MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.1.patch", + ">=2.3.2 <2.3.3": "MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.2.patch" + }, + "Admin path disclosure bug": { + "2.1.4 - 2.1.17": "PRODSECBUG-2432__admin_path_disclosure_bug__2.1.4.patch", + "2.2.0 - 2.2.8": "PRODSECBUG-2432__admin_path_disclosure_bug__2.2.0.patch", + "2.3.0 - 2.3.1": "PRODSECBUG-2432__admin_path_disclosure_bug__2.3.0.patch" + }, + "Transaction MD5 hash field is removed by Authorize.net": { + "2.2.0 - 2.2.7": "MAGETWO-98129__transaction_field_is_removed_by_authorize_net__2.2.0.patch" + }, + "Customer attributes issue": { + "2.2.6": "MAGETWO-95591__customer_attributes_issue__2.2.6.patch" + }, + "Optimize retrieving product attributes": { + "2.2.5": "MAGETWO-93083__optimize_retrieving_product_attributes__2.2.5.patch" + }, + "Cannot change the applied theme": { + "2.2.5": "MAGETWO-93036__cannot_change_the_applied_theme__2.2.5.patch" + }, + "Fix for multi-site configuration issue": { + "2.2.4": "MAGETWO-92926__fix_for_multi-site_configuration_issue__2.2.4.patch" } }, "monolog/monolog": { diff --git a/patches/MAGETWO-92926__fix_for_multi-site_configuration_issue__2.2.4.patch b/patches/MAGETWO-92926__fix_for_multi-site_configuration_issue__2.2.4.patch new file mode 100644 index 0000000..4a47ef5 --- /dev/null +++ b/patches/MAGETWO-92926__fix_for_multi-site_configuration_issue__2.2.4.patch @@ -0,0 +1,15 @@ +diff -Nuar a/app/etc/di.xml b/app/etc/di.xml +--- a/app/etc/di.xml ++++ b/app/etc/di.xml +@@ -232,6 +233,11 @@ + Magento\Backend\App\Request\PathInfoProcessor\Proxy + + ++ ++ ++ Magento\Framework\Session\Config\ConfigInterface\Proxy ++ ++ + + + diff --git a/patches/MAGETWO-93036__cannot_change_the_applied_theme__2.2.5.patch b/patches/MAGETWO-93036__cannot_change_the_applied_theme__2.2.5.patch new file mode 100644 index 0000000..cb5fdb4 --- /dev/null +++ b/patches/MAGETWO-93036__cannot_change_the_applied_theme__2.2.5.patch @@ -0,0 +1,16 @@ +diff -Nuar a/vendor/magento/module-email/Model/AbstractTemplate.php b/vendor/magento/module-email/Model/AbstractTemplate.php +--- a/vendor/magento/module-email/Model/AbstractTemplate.php ++++ b/vendor/magento/module-email/Model/AbstractTemplate.php +@@ -534,10 +534,9 @@ protected function cancelDesignConfig() + */ + public function setForcedArea($templateId) + { +- if ($this->area) { +- throw new \LogicException(__('Area is already set')); ++ if (!isset($this->area)) { ++ $this->area = $this->emailConfig->getTemplateArea($templateId); + } +- $this->area = $this->emailConfig->getTemplateArea($templateId); + return $this; + } + diff --git a/patches/MAGETWO-93083__optimize_retrieving_product_attributes__2.2.5.patch b/patches/MAGETWO-93083__optimize_retrieving_product_attributes__2.2.5.patch new file mode 100644 index 0000000..d35cf4e --- /dev/null +++ b/patches/MAGETWO-93083__optimize_retrieving_product_attributes__2.2.5.patch @@ -0,0 +1,181 @@ +diff -Nuar a/vendor/magento/module-catalog/Model/Product.php b/vendor/magento/module-catalog/Model/Product.php +--- a/vendor/magento/module-catalog/Model/Product.php ++++ b/vendor/magento/module-catalog/Model/Product.php +@@ -12,6 +12,7 @@ + use Magento\Catalog\Model\Product\Attribute\Backend\Media\EntryConverterPool; + use Magento\Framework\Api\AttributeValueFactory; + use Magento\Framework\App\Filesystem\DirectoryList; ++use Magento\Framework\App\ObjectManager; + use Magento\Framework\DataObject\IdentityInterface; + use Magento\Framework\Pricing\SaleableInterface; + +@@ -270,6 +271,7 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements + + /** + * @var \Magento\Catalog\Api\ProductAttributeRepositoryInterface ++ * @deprecated Not used anymore due to performance issue (loaded all product attributes) + */ + protected $metadataService; + +@@ -346,6 +348,11 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements + */ + protected $linkTypeProvider; + ++ /** ++ * @var \Magento\Eav\Model\Config ++ */ ++ private $eavConfig; ++ + /** + * Product constructor. + * @param \Magento\Framework\Model\Context $context +@@ -383,7 +390,7 @@ class Product extends \Magento\Catalog\Model\AbstractModel implements + * @param \Magento\Framework\Api\DataObjectHelper $dataObjectHelper + * @param \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $joinProcessor + * @param array $data +- * ++ * @param \Magento\Eav\Model\Config|null $config + * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ +@@ -422,7 +429,8 @@ public function __construct( + EntryConverterPool $mediaGalleryEntryConverterPool, + \Magento\Framework\Api\DataObjectHelper $dataObjectHelper, + \Magento\Framework\Api\ExtensionAttribute\JoinProcessorInterface $joinProcessor, +- array $data = [] ++ array $data = [], ++ \Magento\Eav\Model\Config $config = null + ) { + $this->metadataService = $metadataService; + $this->_itemOptionFactory = $itemOptionFactory; +@@ -461,6 +469,7 @@ public function __construct( + $resourceCollection, + $data + ); ++ $this->eavConfig = $config ?? ObjectManager::getInstance()->get(\Magento\Eav\Model\Config::class); + } + + /** +@@ -474,12 +483,18 @@ protected function _construct() + } + + /** +- * {@inheritdoc} ++ * Get a list of custom attribute codes that belongs to product attribute set. If attribute set not specified for ++ * product will return all attribute codes ++ * ++ * @return string[] + */ + protected function getCustomAttributesCodes() + { + if ($this->customAttributesCodes === null) { +- $this->customAttributesCodes = $this->getEavAttributesCodes($this->metadataService); ++ $this->customAttributesCodes = array_keys($this->eavConfig->getEntityAttributes( ++ self::ENTITY, ++ $this ++ )); + $this->customAttributesCodes = array_diff($this->customAttributesCodes, $this->interfaceAttributes); + } + return $this->customAttributesCodes; +diff -Nuar a/vendor/magento/module-catalog/Plugin/Model/ResourceModel/ReadSnapshotPlugin.php b/vendor/magento/module-catalog/Plugin/Model/ResourceModel/ReadSnapshotPlugin.php +--- a/vendor/magento/module-catalog/Plugin/Model/ResourceModel/ReadSnapshotPlugin.php ++++ b/vendor/magento/module-catalog/Plugin/Model/ResourceModel/ReadSnapshotPlugin.php +@@ -58,7 +58,9 @@ public function afterExecute(ReadSnapshot $subject, array $entityData, $entityTy + $globalAttributes = []; + $attributesMap = []; + $eavEntityType = $metadata->getEavEntityType(); +- $attributes = (null === $eavEntityType) ? [] : $this->config->getEntityAttributes($eavEntityType); ++ $attributes = null === $eavEntityType ++ ? [] ++ : $this->config->getEntityAttributes($eavEntityType, new \Magento\Framework\DataObject($entityData)); + + /** @var \Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute */ + foreach ($attributes as $attribute) { +diff -Nuar a/vendor/magento/module-eav/Model/ResourceModel/ReadHandler.php b/vendor/magento/module-eav/Model/ResourceModel/ReadHandler.php +--- a/vendor/magento/module-eav/Model/ResourceModel/ReadHandler.php ++++ b/vendor/magento/module-eav/Model/ResourceModel/ReadHandler.php +@@ -5,6 +5,7 @@ + */ + namespace Magento\Eav\Model\ResourceModel; + ++use Magento\Framework\DataObject; + use Magento\Framework\EntityManager\MetadataPool; + use Magento\Framework\EntityManager\Operation\AttributeInterface; + use Magento\Framework\Model\Entity\ScopeInterface; +@@ -59,13 +60,29 @@ public function __construct( + * @param string $entityType + * @return \Magento\Eav\Api\Data\AttributeInterface[] + * @throws \Exception if for unknown entity type ++ * @deprecated Not used anymore ++ * @see ReadHandler::getEntityAttributes + */ + protected function getAttributes($entityType) + { + $metadata = $this->metadataPool->getMetadata($entityType); + $eavEntityType = $metadata->getEavEntityType(); +- $attributes = (null === $eavEntityType) ? [] : $this->config->getAttributes($eavEntityType); +- return $attributes; ++ return null === $eavEntityType ? [] : $this->config->getEntityAttributes($eavEntityType); ++ } ++ ++ /** ++ * Get attribute of given entity type ++ * ++ * @param string $entityType ++ * @param DataObject $entity ++ * @return \Magento\Eav\Api\Data\AttributeInterface[] ++ * @throws \Exception if for unknown entity type ++ */ ++ private function getEntityAttributes(string $entityType, DataObject $entity): array ++ { ++ $metadata = $this->metadataPool->getMetadata($entityType); ++ $eavEntityType = $metadata->getEavEntityType(); ++ return null === $eavEntityType ? [] : $this->config->getEntityAttributes($eavEntityType, $entity); + } + + /** +@@ -105,7 +122,7 @@ public function execute($entityType, $entityData, $arguments = []) + $selects = []; + + /** @var \Magento\Eav\Model\Entity\Attribute\AbstractAttribute $attribute */ +- foreach ($this->getAttributes($entityType) as $attribute) { ++ foreach ($this->getEntityAttributes($entityType, new DataObject($entityData)) as $attribute) { + if (!$attribute->isStatic()) { + $attributeTables[$attribute->getBackend()->getTable()][] = $attribute->getAttributeId(); + $attributesMap[$attribute->getAttributeId()] = $attribute->getAttributeCode(); +diff -Nuar a/vendor/magento/module-swatches/Model/Plugin/ProductImage.php b/vendor/magento/module-swatches/Model/Plugin/ProductImage.php +--- a/vendor/magento/module-swatches/Model/Plugin/ProductImage.php ++++ b/vendor/magento/module-swatches/Model/Plugin/ProductImage.php +@@ -69,7 +69,7 @@ public function beforeGetImage( + && ($location == self::CATEGORY_PAGE_GRID_LOCATION || $location == self::CATEGORY_PAGE_LIST_LOCATION)) { + $request = $this->request->getParams(); + if (is_array($request)) { +- $filterArray = $this->getFilterArray($request); ++ $filterArray = $this->getFilterArray($request, $product); + if (!empty($filterArray)) { + $product = $this->loadSimpleVariation($product, $filterArray); + } +@@ -99,16 +99,18 @@ protected function loadSimpleVariation(\Magento\Catalog\Model\Product $parentPro + * Get filters from request + * + * @param array $request ++ * @param \Magento\Catalog\Model\Product $product + * @return array + */ +- protected function getFilterArray(array $request) ++ private function getFilterArray(array $request, \Magento\Catalog\Model\Product $product) + { + $filterArray = []; +- $attributeCodes = $this->eavConfig->getEntityAttributeCodes(\Magento\Catalog\Model\Product::ENTITY); ++ $attributes = $this->eavConfig->getEntityAttributes(\Magento\Catalog\Model\Product::ENTITY, $product); ++ + foreach ($request as $code => $value) { +- if (in_array($code, $attributeCodes)) { +- $attribute = $this->eavConfig->getAttribute(\Magento\Catalog\Model\Product::ENTITY, $code); +- if ($attribute->getId() && $this->canReplaceImageWithSwatch($attribute)) { ++ if (isset($attributes[$code])) { ++ $attribute = $attributes[$code]; ++ if ($this->canReplaceImageWithSwatch($attribute)) { + $filterArray[$code] = $value; + } + } diff --git a/patches/MAGETWO-95591__customer_attributes_issue__2.2.6.patch b/patches/MAGETWO-95591__customer_attributes_issue__2.2.6.patch new file mode 100644 index 0000000..4f963b8 --- /dev/null +++ b/patches/MAGETWO-95591__customer_attributes_issue__2.2.6.patch @@ -0,0 +1,1419 @@ +diff -Nuar a/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Save.php b/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Save.php +--- a/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Save.php ++++ b/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Save.php +@@ -18,6 +18,8 @@ + use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\Validator; + use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory; + use Magento\Eav\Model\ResourceModel\Entity\Attribute\Group\CollectionFactory; ++use Magento\Framework\App\ObjectManager; ++use Magento\Framework\Serialize\Serializer\FormData; + use Magento\Framework\Cache\FrontendInterface; + use Magento\Framework\Controller\ResultFactory; + use Magento\Framework\Controller\Result\Json; +@@ -68,6 +70,11 @@ class Save extends Attribute + */ + private $layoutFactory; + ++ /** ++ * @var FormData ++ */ ++ private $formDataSerializer; ++ + /** + * @param Context $context + * @param FrontendInterface $attributeLabelCache +@@ -80,6 +87,7 @@ class Save extends Attribute + * @param FilterManager $filterManager + * @param Product $productHelper + * @param LayoutFactory $layoutFactory ++ * @param FormData|null $formDataSerializer + * @SuppressWarnings(PHPMD.ExcessiveParameterList) + */ + public function __construct( +@@ -93,7 +101,8 @@ public function __construct( + CollectionFactory $groupCollectionFactory, + FilterManager $filterManager, + Product $productHelper, +- LayoutFactory $layoutFactory ++ LayoutFactory $layoutFactory, ++ FormData $formDataSerializer = null + ) { + parent::__construct($context, $attributeLabelCache, $coreRegistry, $resultPageFactory); + $this->buildFactory = $buildFactory; +@@ -103,19 +112,37 @@ public function __construct( + $this->validatorFactory = $validatorFactory; + $this->groupCollectionFactory = $groupCollectionFactory; + $this->layoutFactory = $layoutFactory; ++ $this->formDataSerializer = $formDataSerializer ?? ObjectManager::getInstance()->get(FormData::class); + } + + /** +- * @return Redirect ++ * @inheritdoc ++ * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function execute() + { ++ try { ++ $optionData = $this->formDataSerializer->unserialize( ++ $this->getRequest()->getParam('serialized_options', '[]') ++ ); ++ } catch (\InvalidArgumentException $e) { ++ $message = __("The attribute couldn't be saved due to an error. Verify your information and try again. " ++ . "If the error persists, please try again later."); ++ $this->messageManager->addErrorMessage($message); ++ ++ return $this->returnResult('catalog/*/edit', ['_current' => true], ['error' => true]); ++ } ++ + $data = $this->getRequest()->getPostValue(); ++ $data = array_replace_recursive( ++ $data, ++ $optionData ++ ); ++ + if ($data) { +- $this->preprocessOptionsData($data); + $setId = $this->getRequest()->getParam('set'); + + $attributeSet = null; +@@ -124,7 +151,7 @@ public function execute() + $name = trim($name); + + try { +- /** @var $attributeSet Set */ ++ /** @var Set $attributeSet */ + $attributeSet = $this->buildFactory->create() + ->setEntityTypeId($this->_entityTypeId) + ->setSkeletonId($setId) +@@ -147,7 +174,7 @@ public function execute() + + $attributeId = $this->getRequest()->getParam('attribute_id'); + +- /** @var $model ProductAttributeInterface */ ++ /** @var ProductAttributeInterface $model */ + $model = $this->attributeFactory->create(); + if ($attributeId) { + $model->load($attributeId); +@@ -180,7 +207,7 @@ public function execute() + + //validate frontend_input + if (isset($data['frontend_input'])) { +- /** @var $inputType Validator */ ++ /** @var Validator $inputType */ + $inputType = $this->validatorFactory->create(); + if (!$inputType->isValid($data['frontend_input'])) { + foreach ($inputType->getMessages() as $message) { +@@ -313,28 +340,8 @@ public function execute() + } + + /** +- * Extract options data from serialized options field and append to data array. +- * +- * This logic is required to overcome max_input_vars php limit +- * that may vary and/or be inaccessible to change on different instances. ++ * Provides an initialized Result object. + * +- * @param array $data +- * @return void +- */ +- private function preprocessOptionsData(&$data) +- { +- if (isset($data['serialized_options'])) { +- $serializedOptions = json_decode($data['serialized_options'], JSON_OBJECT_AS_ARRAY); +- foreach ($serializedOptions as $serializedOption) { +- $option = []; +- parse_str($serializedOption, $option); +- $data = array_replace_recursive($data, $option); +- } +- } +- unset($data['serialized_options']); +- } +- +- /** + * @param string $path + * @param array $params + * @param array $response +diff -Nuar a/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Validate.php b/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Validate.php +--- a/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Validate.php ++++ b/vendor/magento/module-catalog/Controller/Adminhtml/Product/Attribute/Validate.php +@@ -6,8 +6,15 @@ + */ + namespace Magento\Catalog\Controller\Adminhtml\Product\Attribute; + ++use Magento\Framework\Serialize\Serializer\FormData; ++use Magento\Framework\App\ObjectManager; + use Magento\Framework\DataObject; + ++/** ++ * Product attribute validate controller. ++ * ++ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) ++ */ + class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute + { + const DEFAULT_MESSAGE_KEY = 'message'; +@@ -27,6 +34,11 @@ class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute + */ + private $multipleAttributeList; + ++ /** ++ * @var FormData ++ */ ++ private $formDataSerializer; ++ + /** + * Constructor + * +@@ -37,6 +49,7 @@ class Validate extends \Magento\Catalog\Controller\Adminhtml\Product\Attribute + * @param \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory + * @param \Magento\Framework\View\LayoutFactory $layoutFactory + * @param array $multipleAttributeList ++ * @param FormData|null $formDataSerializer + */ + public function __construct( + \Magento\Backend\App\Action\Context $context, +@@ -45,16 +58,19 @@ public function __construct( + \Magento\Framework\View\Result\PageFactory $resultPageFactory, + \Magento\Framework\Controller\Result\JsonFactory $resultJsonFactory, + \Magento\Framework\View\LayoutFactory $layoutFactory, +- array $multipleAttributeList = [] ++ array $multipleAttributeList = [], ++ FormData $formDataSerializer = null + ) { + parent::__construct($context, $attributeLabelCache, $coreRegistry, $resultPageFactory); + $this->resultJsonFactory = $resultJsonFactory; + $this->layoutFactory = $layoutFactory; + $this->multipleAttributeList = $multipleAttributeList; ++ $this->formDataSerializer = $formDataSerializer ?? ObjectManager::getInstance()->get(FormData::class); + } + + /** +- * @return \Magento\Framework\Controller\ResultInterface ++ * @inheritdoc ++ * + * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + */ +@@ -62,6 +78,16 @@ public function execute() + { + $response = new DataObject(); + $response->setError(false); ++ try { ++ $optionsData = $this->formDataSerializer->unserialize( ++ $this->getRequest()->getParam('serialized_options', '[]') ++ ); ++ } catch (\InvalidArgumentException $e) { ++ $message = __("The attribute couldn't be validated due to an error. Verify your information and try again. " ++ . "If the error persists, please try again later."); ++ $this->setMessageToResponse($response, [$message]); ++ $response->setError(true); ++ } + + $attributeCode = $this->getRequest()->getParam('attribute_code'); + $frontendLabel = $this->getRequest()->getParam('frontend_label'); +@@ -101,10 +127,10 @@ public function execute() + } + + $multipleOption = $this->getRequest()->getParam("frontend_input"); +- $multipleOption = null == $multipleOption ? 'select' : $multipleOption; ++ $multipleOption = (null === $multipleOption) ? 'select' : $multipleOption; + + if (isset($this->multipleAttributeList[$multipleOption]) && !(null == ($multipleOption))) { +- $options = $this->getRequest()->getParam($this->multipleAttributeList[$multipleOption]); ++ $options = $optionsData[$this->multipleAttributeList[$multipleOption]] ?? null; + $this->checkUniqueOption( + $response, + $options +@@ -122,7 +148,8 @@ public function execute() + } + + /** +- * Throws Exception if not unique values into options ++ * Throws Exception if not unique values into options. ++ * + * @param array $optionsValues + * @param array $deletedOptions + * @return bool +@@ -156,6 +183,8 @@ private function setMessageToResponse($response, $messages) + } + + /** ++ * Performs checking the uniqueness of the attribute options. ++ * + * @param DataObject $response + * @param array|null $options + * @return $this +diff -Nuar a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php +--- a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php ++++ b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/SaveTest.php +@@ -3,8 +3,10 @@ + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ ++ + namespace Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\Attribute; + ++use Magento\Catalog\Api\Data\ProductAttributeInterface; + use Magento\Catalog\Controller\Adminhtml\Product\Attribute\Save; + use Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\AttributeTest; + use Magento\Catalog\Model\Product\AttributeSet\BuildFactory; +@@ -13,11 +15,16 @@ + use Magento\Eav\Api\Data\AttributeSetInterface; + use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\ValidatorFactory; + use Magento\Eav\Model\ResourceModel\Entity\Attribute\Group\CollectionFactory; ++use Magento\Framework\Message\ManagerInterface; ++use Magento\Framework\Serialize\Serializer\FormData; ++use Magento\Framework\Controller\ResultFactory; + use Magento\Framework\Filter\FilterManager; + use Magento\Catalog\Helper\Product as ProductHelper; ++use Magento\Framework\View\Element\Messages; + use Magento\Framework\View\LayoutFactory; + use Magento\Backend\Model\View\Result\Redirect as ResultRedirect; + use Magento\Eav\Model\Adminhtml\System\Config\Source\Inputtype\Validator as InputTypeValidator; ++use Magento\Framework\View\LayoutInterface; + + /** + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) +@@ -79,6 +86,21 @@ class SaveTest extends AttributeTest + */ + protected $inputTypeValidatorMock; + ++ /** ++ * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject ++ */ ++ private $messageManagerMock; ++ ++ /** ++ * @var FormData|\PHPUnit_Framework_MockObject_MockObject ++ */ ++ private $formDataSerializerMock; ++ ++ /** ++ * @var ProductAttributeInterface|\PHPUnit_Framework_MockObject_MockObject ++ */ ++ private $productAttributeMock; ++ + protected function setUp() + { + parent::setUp(); +@@ -108,6 +130,7 @@ protected function setUp() + ->disableOriginalConstructor() + ->getMock(); + $this->redirectMock = $this->getMockBuilder(ResultRedirect::class) ++ ->setMethods(['setData', 'setPath']) + ->disableOriginalConstructor() + ->getMock(); + $this->attributeSetMock = $this->getMockBuilder(AttributeSetInterface::class) +@@ -119,6 +142,15 @@ protected function setUp() + $this->inputTypeValidatorMock = $this->getMockBuilder(InputTypeValidator::class) + ->disableOriginalConstructor() + ->getMock(); ++ $this->messageManagerMock = $this->getMockBuilder(\Magento\Framework\Message\ManagerInterface::class) ++ ->disableOriginalConstructor() ++ ->getMockForAbstractClass(); ++ $this->formDataSerializerMock = $this->getMockBuilder(FormData::class) ++ ->disableOriginalConstructor() ++ ->getMock(); ++ $this->productAttributeMock = $this->getMockBuilder(ProductAttributeInterface::class) ++ ->setMethods(['getId', 'get']) ++ ->getMockForAbstractClass(); + + $this->buildFactoryMock->expects($this->any()) + ->method('create') +@@ -126,6 +158,9 @@ protected function setUp() + $this->validatorFactoryMock->expects($this->any()) + ->method('create') + ->willReturn($this->inputTypeValidatorMock); ++ $this->attributeFactoryMock ++ ->method('create') ++ ->willReturn($this->productAttributeMock); + } + + /** +@@ -135,6 +170,7 @@ protected function getModel() + { + return $this->objectManager->getObject(Save::class, [ + 'context' => $this->contextMock, ++ 'messageManager' => $this->messageManagerMock, + 'attributeLabelCache' => $this->attributeLabelCacheMock, + 'coreRegistry' => $this->coreRegistryMock, + 'resultPageFactory' => $this->resultPageFactoryMock, +@@ -145,11 +181,22 @@ protected function getModel() + 'validatorFactory' => $this->validatorFactoryMock, + 'groupCollectionFactory' => $this->groupCollectionFactoryMock, + 'layoutFactory' => $this->layoutFactoryMock, ++ 'formDataSerializer' => $this->formDataSerializerMock, + ]); + } + + public function testExecuteWithEmptyData() + { ++ $this->requestMock->expects($this->any()) ++ ->method('getParam') ++ ->willReturnMap([ ++ ['isAjax', null, null], ++ ['serialized_options', '[]', ''], ++ ]); ++ $this->formDataSerializerMock->expects($this->once()) ++ ->method('unserialize') ++ ->with('') ++ ->willReturn([]); + $this->requestMock->expects($this->once()) + ->method('getPostValue') + ->willReturn([]); +@@ -170,6 +217,22 @@ public function testExecute() + 'frontend_input' => 'test_frontend_input', + ]; + ++ $this->requestMock->expects($this->any()) ++ ->method('getParam') ++ ->willReturnMap([ ++ ['isAjax', null, null], ++ ['serialized_options', '[]', ''], ++ ]); ++ $this->formDataSerializerMock->expects($this->once()) ++ ->method('unserialize') ++ ->with('') ++ ->willReturn([]); ++ $this->productAttributeMock->expects($this->once()) ++ ->method('getId') ++ ->willReturn(1); ++ $this->productAttributeMock->expects($this->once()) ++ ->method('getAttributeCode') ++ ->willReturn('test_code'); + $this->requestMock->expects($this->once()) + ->method('getPostValue') + ->willReturn($data); +@@ -203,4 +266,74 @@ public function testExecute() + + $this->assertInstanceOf(ResultRedirect::class, $this->getModel()->execute()); + } ++ ++ /** ++ * @return void ++ * @throws \Magento\Framework\Exception\NotFoundException ++ */ ++ public function testExecuteWithOptionsDataError() ++ { ++ $serializedOptions = '{"key":"value"}'; ++ $message = "The attribute couldn't be saved due to an error. Verify your information and try again. " ++ . "If the error persists, please try again later."; ++ ++ $this->requestMock->expects($this->any()) ++ ->method('getParam') ++ ->willReturnMap([ ++ ['isAjax', null, true], ++ ['serialized_options', '[]', $serializedOptions], ++ ]); ++ $this->formDataSerializerMock->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedOptions) ++ ->willThrowException(new \InvalidArgumentException('Some exception')); ++ $this->messageManagerMock->expects($this->once()) ++ ->method('addErrorMessage') ++ ->with($message); ++ $this->addReturnResultConditions('catalog/*/edit', ['_current' => true], ['error' => true]); ++ ++ $this->getModel()->execute(); ++ } ++ ++ /** ++ * @param string $path ++ * @param array $params ++ * @param array $response ++ * @return void ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ private function addReturnResultConditions(string $path = '', array $params = [], array $response = []) ++ { ++ $layoutMock = $this->getMockBuilder(LayoutInterface::class) ++ ->setMethods(['initMessages', 'getMessagesBlock']) ++ ->getMockForAbstractClass(); ++ $this->layoutFactoryMock->expects($this->once()) ++ ->method('create') ++ ->with() ++ ->willReturn($layoutMock); ++ $layoutMock->expects($this->once()) ++ ->method('initMessages') ++ ->with(); ++ $messageBlockMock = $this->getMockBuilder(Messages::class) ++ ->disableOriginalConstructor() ++ ->getMock(); ++ $layoutMock->expects($this->once()) ++ ->method('getMessagesBlock') ++ ->willReturn($messageBlockMock); ++ $messageBlockMock->expects($this->once()) ++ ->method('getGroupedHtml') ++ ->willReturn('message1'); ++ $this->resultFactoryMock->expects($this->once()) ++ ->method('create') ++ ->with(ResultFactory::TYPE_JSON) ++ ->willReturn($this->redirectMock); ++ $response = array_merge($response, [ ++ 'messages' => ['message1'], ++ 'params' => $params, ++ ]); ++ $this->redirectMock->expects($this->once()) ++ ->method('setData') ++ ->with($response) ++ ->willReturnSelf(); ++ } + } +diff -Nuar a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php +--- a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php ++++ b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/Attribute/ValidateTest.php +@@ -9,6 +9,7 @@ + use Magento\Catalog\Model\ResourceModel\Eav\Attribute; + use Magento\Catalog\Test\Unit\Controller\Adminhtml\Product\AttributeTest; + use Magento\Eav\Model\Entity\Attribute\Set as AttributeSet; ++use Magento\Framework\Serialize\Serializer\FormData; + use Magento\Framework\Controller\Result\Json as ResultJson; + use Magento\Framework\Controller\Result\JsonFactory as ResultJsonFactory; + use Magento\Framework\Escaper; +@@ -61,6 +62,11 @@ class ValidateTest extends AttributeTest + */ + protected $layoutMock; + ++ /** ++ * @var FormData|\PHPUnit_Framework_MockObject_MockObject ++ */ ++ private $formDataSerializer; ++ + protected function setUp() + { + parent::setUp(); +@@ -86,6 +92,9 @@ protected function setUp() + ->getMock(); + $this->layoutMock = $this->getMockBuilder(LayoutInterface::class) + ->getMockForAbstractClass(); ++ $this->formDataSerializer = $this->getMockBuilder(FormData::class) ++ ->disableOriginalConstructor() ++ ->getMock(); + + $this->contextMock->expects($this->any()) + ->method('getObjectManager') +@@ -100,25 +109,28 @@ protected function getModel() + return $this->objectManager->getObject( + Validate::class, + [ +- 'context' => $this->contextMock, +- 'attributeLabelCache' => $this->attributeLabelCacheMock, +- 'coreRegistry' => $this->coreRegistryMock, +- 'resultPageFactory' => $this->resultPageFactoryMock, +- 'resultJsonFactory' => $this->resultJsonFactoryMock, +- 'layoutFactory' => $this->layoutFactoryMock, +- 'multipleAttributeList' => ['select' => 'option'] ++ 'context' => $this->contextMock, ++ 'attributeLabelCache' => $this->attributeLabelCacheMock, ++ 'coreRegistry' => $this->coreRegistryMock, ++ 'resultPageFactory' => $this->resultPageFactoryMock, ++ 'resultJsonFactory' => $this->resultJsonFactoryMock, ++ 'layoutFactory' => $this->layoutFactoryMock, ++ 'multipleAttributeList' => ['select' => 'option'], ++ 'formDataSerializer' => $this->formDataSerializer, + ] + ); + } + + public function testExecute() + { ++ $serializedOptions = '{"key":"value"}'; + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ + ['frontend_label', null, 'test_frontend_label'], + ['attribute_code', null, 'test_attribute_code'], + ['new_attribute_set_name', null, 'test_attribute_set_name'], ++ ['serialized_options', '[]', $serializedOptions], + ]); + $this->objectManagerMock->expects($this->exactly(2)) + ->method('create') +@@ -160,6 +172,7 @@ public function testExecute() + */ + public function testUniqueValidation(array $options, $isError) + { ++ $serializedOptions = '{"key":"value"}'; + $countFunctionCalls = ($isError) ? 6 : 5; + $this->requestMock->expects($this->exactly($countFunctionCalls)) + ->method('getParam') +@@ -167,10 +180,15 @@ public function testUniqueValidation(array $options, $isError) + ['frontend_label', null, null], + ['attribute_code', null, "test_attribute_code"], + ['new_attribute_set_name', null, 'test_attribute_set_name'], +- ['option', null, $options], +- ['message_key', null, Validate::DEFAULT_MESSAGE_KEY] ++ ['message_key', null, Validate::DEFAULT_MESSAGE_KEY], ++ ['serialized_options', '[]', $serializedOptions], + ]); + ++ $this->formDataSerializer->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedOptions) ++ ->willReturn($options); ++ + $this->objectManagerMock->expects($this->once()) + ->method('create') + ->willReturn($this->attributeMock); +@@ -203,68 +221,84 @@ public function provideUniqueData() + return [ + 'no values' => [ + [ +- 'delete' => [ +- "option_0" => "", +- "option_1" => "", +- "option_2" => "", +- ] +- ], false ++ 'option' => [ ++ 'delete' => [ ++ "option_0" => "", ++ "option_1" => "", ++ "option_2" => "", ++ ], ++ ], ++ ++ ], ++ false, + ], + 'valid options' => [ + [ +- 'value' => [ +- "option_0" => [1, 0], +- "option_1" => [2, 0], +- "option_2" => [3, 0], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [1, 0], ++ "option_1" => [2, 0], ++ "option_2" => [3, 0], ++ ], ++ 'delete' => [ ++ "option_0" => "", ++ "option_1" => "", ++ "option_2" => "", ++ ], + ], +- 'delete' => [ +- "option_0" => "", +- "option_1" => "", +- "option_2" => "", +- ] +- ], false ++ ], ++ false, + ], + 'duplicate options' => [ + [ +- 'value' => [ +- "option_0" => [1, 0], +- "option_1" => [1, 0], +- "option_2" => [3, 0], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [1, 0], ++ "option_1" => [1, 0], ++ "option_2" => [3, 0], ++ ], ++ 'delete' => [ ++ "option_0" => "", ++ "option_1" => "", ++ "option_2" => "", ++ ], + ], +- 'delete' => [ +- "option_0" => "", +- "option_1" => "", +- "option_2" => "", +- ] +- ], true ++ ], ++ true, + ], + 'duplicate and deleted' => [ + [ +- 'value' => [ +- "option_0" => [1, 0], +- "option_1" => [1, 0], +- "option_2" => [3, 0], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [1, 0], ++ "option_1" => [1, 0], ++ "option_2" => [3, 0], ++ ], ++ 'delete' => [ ++ "option_0" => "", ++ "option_1" => "1", ++ "option_2" => "", ++ ], + ], +- 'delete' => [ +- "option_0" => "", +- "option_1" => "1", +- "option_2" => "", +- ] +- ], false ++ ], ++ false, + ], + 'empty and deleted' => [ + [ +- 'value' => [ +- "option_0" => [1, 0], +- "option_1" => [2, 0], +- "option_2" => ["", ""], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [1, 0], ++ "option_1" => [2, 0], ++ "option_2" => ["", ""], ++ ], ++ 'delete' => [ ++ "option_0" => "", ++ "option_1" => "", ++ "option_2" => "1", ++ ], + ], +- 'delete' => [ +- "option_0" => "", +- "option_1" => "", +- "option_2" => "1", +- ] +- ], false ++ ], ++ false, + ], + ]; + } +@@ -278,6 +312,7 @@ public function provideUniqueData() + */ + public function testEmptyOption(array $options, $result) + { ++ $serializedOptions = '{"key":"value"}'; + $this->requestMock->expects($this->any()) + ->method('getParam') + ->willReturnMap([ +@@ -285,10 +320,15 @@ public function testEmptyOption(array $options, $result) + ['frontend_input', 'select', 'multipleselect'], + ['attribute_code', null, "test_attribute_code"], + ['new_attribute_set_name', null, 'test_attribute_set_name'], +- ['option', null, $options], + ['message_key', Validate::DEFAULT_MESSAGE_KEY, 'message'], ++ ['serialized_options', '[]', $serializedOptions], + ]); + ++ $this->formDataSerializer->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedOptions) ++ ->willReturn($options); ++ + $this->objectManagerMock->expects($this->once()) + ->method('create') + ->willReturn($this->attributeMock); +@@ -320,32 +360,38 @@ public function provideEmptyOption() + return [ + 'empty admin scope options' => [ + [ +- 'value' => [ +- "option_0" => [''], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [''], ++ ], + ], + ], + (object) [ + 'error' => true, + 'message' => 'The value of Admin scope can\'t be empty.', +- ] ++ ], + ], + 'not empty admin scope options' => [ + [ +- 'value' => [ +- "option_0" => ['asdads'], ++ 'option' => [ ++ 'value' => [ ++ "option_0" => ['asdads'], ++ ], + ], + ], + (object) [ + 'error' => false, +- ] ++ ], + ], + 'empty admin scope options and deleted' => [ + [ +- 'value' => [ +- "option_0" => [''], +- ], +- 'delete' => [ +- 'option_0' => '1', ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [''], ++ ], ++ 'delete' => [ ++ 'option_0' => '1', ++ ], + ], + ], + (object) [ +@@ -354,11 +400,13 @@ public function provideEmptyOption() + ], + 'empty admin scope options and not deleted' => [ + [ +- 'value' => [ +- "option_0" => [''], +- ], +- 'delete' => [ +- 'option_0' => '0', ++ 'option' => [ ++ 'value' => [ ++ "option_0" => [''], ++ ], ++ 'delete' => [ ++ 'option_0' => '0', ++ ], + ], + ], + (object) [ +@@ -368,4 +416,55 @@ public function provideEmptyOption() + ], + ]; + } ++ ++ /** ++ * @return void ++ * @throws \Magento\Framework\Exception\NotFoundException ++ */ ++ public function testExecuteWithOptionsDataError() ++ { ++ $serializedOptions = '{"key":"value"}'; ++ $message = "The attribute couldn't be validated due to an error. Verify your information and try again. " ++ . "If the error persists, please try again later."; ++ $this->requestMock->expects($this->any()) ++ ->method('getParam') ++ ->willReturnMap([ ++ ['frontend_label', null, 'test_frontend_label'], ++ ['attribute_code', null, 'test_attribute_code'], ++ ['new_attribute_set_name', null, 'test_attribute_set_name'], ++ ['message_key', Validate::DEFAULT_MESSAGE_KEY, 'message'], ++ ['serialized_options', '[]', $serializedOptions], ++ ]); ++ ++ $this->formDataSerializer->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedOptions) ++ ->willThrowException(new \InvalidArgumentException('Some exception')); ++ ++ $this->objectManagerMock->expects($this->once()) ++ ->method('create') ++ ->willReturnMap([ ++ [\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class, [], $this->attributeMock], ++ [\Magento\Eav\Model\Entity\Attribute\Set::class, [], $this->attributeSetMock] ++ ]); ++ ++ $this->attributeMock->expects($this->once()) ++ ->method('loadByCode') ++ ->willReturnSelf(); ++ $this->attributeSetMock->expects($this->never()) ++ ->method('setEntityTypeId') ++ ->willReturnSelf(); ++ $this->resultJsonFactoryMock->expects($this->once()) ++ ->method('create') ++ ->willReturn($this->resultJson); ++ $this->resultJson->expects($this->once()) ++ ->method('setJsonData') ++ ->with(json_encode([ ++ 'error' => true, ++ 'message' => $message, ++ ])) ++ ->willReturnSelf(); ++ ++ $this->getModel()->execute(); ++ } + } +diff -Nuar a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php +--- a/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php ++++ b/vendor/magento/module-catalog/Test/Unit/Controller/Adminhtml/Product/AttributeTest.php +@@ -9,8 +9,9 @@ + use Magento\Catalog\Controller\Adminhtml\Product\Attribute; + use Magento\Framework\App\RequestInterface; + use Magento\Framework\Cache\FrontendInterface; ++use Magento\Framework\Message\ManagerInterface; + use Magento\Framework\Registry; +-use Magento\Framework\TestFramework\Unit\Helper\ObjectManager; ++use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; + use Magento\Framework\View\Result\PageFactory; + use Magento\Framework\Controller\ResultFactory; + +@@ -20,7 +21,7 @@ + class AttributeTest extends \PHPUnit\Framework\TestCase + { + /** +- * @var ObjectManager ++ * @var ObjectManagerHelper + */ + protected $objectManager; + +@@ -54,9 +55,14 @@ class AttributeTest extends \PHPUnit\Framework\TestCase + */ + protected $resultFactoryMock; + ++ /** ++ * @var ManagerInterface|\PHPUnit_Framework_MockObject_MockObject ++ */ ++ private $messageManager; ++ + protected function setUp() + { +- $this->objectManager = new ObjectManager($this); ++ $this->objectManager = new ObjectManagerHelper($this); + $this->contextMock = $this->getMockBuilder(Context::class) + ->disableOriginalConstructor() + ->getMock(); +@@ -74,6 +80,9 @@ protected function setUp() + $this->resultFactoryMock = $this->getMockBuilder(ResultFactory::class) + ->disableOriginalConstructor() + ->getMock(); ++ $this->messageManager = $this->getMockBuilder(ManagerInterface::class) ++ ->disableOriginalConstructor() ++ ->getMock(); + + $this->contextMock->expects($this->any()) + ->method('getRequest') +@@ -81,6 +90,9 @@ protected function setUp() + $this->contextMock->expects($this->any()) + ->method('getResultFactory') + ->willReturn($this->resultFactoryMock); ++ $this->contextMock->expects($this->once()) ++ ->method('getMessageManager') ++ ->willReturn($this->messageManager); + } + + /** +diff -Nuar a/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml b/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml +--- a/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml ++++ b/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/js.phtml +@@ -40,13 +40,16 @@ function getFrontTab() { + + function checkOptionsPanelVisibility(){ + if($('manage-options-panel')){ +- var panel = $('manage-options-panel').up('.fieldset'); ++ var panel = $('manage-options-panel').up('.fieldset'), ++ activePanelClass = 'selected-type-options'; + + if($('frontend_input') && ($('frontend_input').value=='select' || $('frontend_input').value=='multiselect')){ + panel.show(); ++ panel.addClass(activePanelClass); + } + else { + panel.hide(); ++ panel.removeClass(activePanelClass); + } + } + } +diff -Nuar a/vendor/magento/module-catalog/view/adminhtml/web/js/options.js b/vendor/magento/module-catalog/view/adminhtml/web/js/options.js +--- a/vendor/magento/module-catalog/view/adminhtml/web/js/options.js ++++ b/vendor/magento/module-catalog/view/adminhtml/web/js/options.js +@@ -20,7 +20,6 @@ define([ + + return function (config) { + var optionPanel = jQuery('#manage-options-panel'), +- optionsValues = [], + editForm = jQuery('#edit_form'), + attributeOption = { + table: $('attribute-options-table'), +@@ -145,7 +144,9 @@ define([ + + return optionDefaultInputType; + } +- }; ++ }, ++ tableBody = jQuery(), ++ activePanelClass = 'selected-type-options'; + + if ($('add_new_option_button')) { + Event.observe('add_new_option_button', 'click', attributeOption.add.bind(attributeOption, {}, true)); +@@ -180,30 +181,32 @@ define([ + }); + }); + } +- editForm.on('submit', function () { +- optionPanel.find('input') +- .each(function () { +- if (this.disabled) { +- return; ++ editForm.on('beforeSubmit', function () { ++ var optionContainer = optionPanel.find('table tbody'), ++ optionsValues; ++ ++ if (optionPanel.hasClass(activePanelClass)) { ++ optionsValues = jQuery.map( ++ optionContainer.find('tr'), ++ function (row) { ++ return jQuery(row).find('input, select, textarea').serialize(); + } +- +- if (this.type === 'checkbox' || this.type === 'radio') { +- if (this.checked) { +- optionsValues.push(this.name + '=' + jQuery(this).val()); +- } +- } else { +- optionsValues.push(this.name + '=' + jQuery(this).val()); +- } +- }); +- jQuery('') +- .attr({ +- type: 'hidden', +- name: 'serialized_options' +- }) +- .val(JSON.stringify(optionsValues)) +- .prependTo(editForm); +- optionPanel.find('table') +- .replaceWith(jQuery('
').text(jQuery.mage.__('Sending attribute values as package.'))); ++ ); ++ jQuery('') ++ .attr({ ++ type: 'hidden', ++ name: 'serialized_options' ++ }) ++ .val(JSON.stringify(optionsValues)) ++ .prependTo(editForm); ++ } ++ tableBody = optionContainer.detach(); ++ }); ++ editForm.on('afterValidate.error highlight.validate', function () { ++ if (optionPanel.hasClass(activePanelClass)) { ++ optionPanel.find('table').append(tableBody); ++ jQuery('input[name="serialized_options"]').remove(); ++ } + }); + window.attributeOption = attributeOption; + window.optionDefaultInputType = attributeOption.getOptionInputType(); +diff -Nuar a/vendor/magento/module-swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php b/vendor/magento/module-swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php +--- a/vendor/magento/module-swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php ++++ b/vendor/magento/module-swatches/Controller/Adminhtml/Product/Attribute/Plugin/Save.php +@@ -16,6 +16,8 @@ + class Save + { + /** ++ * Performs the conversion of the frontend input value. ++ * + * @param Attribute\Save $subject + * @param RequestInterface $request + * @return array +@@ -26,15 +28,6 @@ public function beforeDispatch(Attribute\Save $subject, RequestInterface $reques + $data = $request->getPostValue(); + + if (isset($data['frontend_input'])) { +- //Data is serialized to overcome issues caused by max_input_vars value if it's modification is unavailable. +- //See subject controller code and comments for more info. +- if (isset($data['serialized_swatch_values']) +- && in_array($data['frontend_input'], ['swatch_visual', 'swatch_text']) +- ) { +- $data['serialized_options'] = $data['serialized_swatch_values']; +- unset($data['serialized_swatch_values']); +- } +- + switch ($data['frontend_input']) { + case 'swatch_visual': + $data[Swatch::SWATCH_INPUT_TYPE_KEY] = Swatch::SWATCH_INPUT_TYPE_VISUAL; +diff -Nuar a/vendor/magento/module-swatches/view/adminhtml/web/js/product-attributes.js b/vendor/magento/module-swatches/view/adminhtml/web/js/product-attributes.js +--- a/vendor/magento/module-swatches/view/adminhtml/web/js/product-attributes.js ++++ b/vendor/magento/module-swatches/view/adminhtml/web/js/product-attributes.js +@@ -16,7 +16,8 @@ define([ + 'use strict'; + + return function (optionConfig) { +- var swatchProductAttributes = { ++ var activePanelClass = 'selected-type-options', ++ swatchProductAttributes = { + frontendInput: $('#frontend_input'), + isFilterable: $('#is_filterable'), + isFilterableInSearch: $('#is_filterable_in_search'), +@@ -337,6 +338,7 @@ define([ + */ + _showPanel: function (el) { + el.closest('.fieldset').show(); ++ el.addClass(activePanelClass); + this._render(el.attr('id')); + }, + +@@ -346,6 +348,7 @@ define([ + */ + _hidePanel: function (el) { + el.closest('.fieldset').hide(); ++ el.removeClass(activePanelClass); + }, + + /** +@@ -413,7 +416,11 @@ define([ + }; + + $(function () { +- var editForm = $('#edit_form'); ++ var editForm = $('#edit_form'), ++ swatchVisualPanel = $('#swatch-visual-options-panel'), ++ swatchTextPanel = $('#swatch-text-options-panel'), ++ tableBody = $(), ++ activePanel = $(); + + $('#frontend_input').bind('change', function () { + swatchProductAttributes.bindAttributeInputType(); +@@ -429,30 +436,35 @@ define([ + .collapsable() + .collapse('hide'); + +- editForm.on('submit', function () { +- var activePanel, +- swatchValues = [], +- swatchVisualPanel = $('#swatch-visual-options-panel'), +- swatchTextPanel = $('#swatch-text-options-panel'); ++ editForm.on('beforeSubmit', function () { ++ var optionContainer, optionsValues; + +- activePanel = swatchTextPanel.is(':visible') ? swatchTextPanel : swatchVisualPanel; ++ activePanel = swatchTextPanel.hasClass(activePanelClass) ? swatchTextPanel : swatchVisualPanel; ++ optionContainer = activePanel.find('table tbody'); + +- activePanel.find('table input') +- .each(function () { +- swatchValues.push(this.name + '=' + $(this).val()); +- }); ++ if (activePanel.hasClass(activePanelClass)) { ++ optionsValues = $.map( ++ optionContainer.find('tr'), ++ function (row) { ++ return $(row).find('input, select, textarea').serialize(); ++ } ++ ); ++ $('') ++ .attr({ ++ type: 'hidden', ++ name: 'serialized_options' ++ }) ++ .val(JSON.stringify(optionsValues)) ++ .prependTo(editForm); ++ } + +- $('').attr({ +- type: 'hidden', +- name: 'serialized_swatch_values' +- }) +- .val(JSON.stringify(swatchValues)) +- .prependTo(editForm); +- +- [swatchVisualPanel, swatchTextPanel].forEach(function (el) { +- $(el).find('table') +- .replaceWith($('
').text($.mage.__('Sending swatch values as package.'))); +- }); ++ tableBody = optionContainer.detach(); ++ }); ++ editForm.on('afterValidate.error highlight.validate', function () { ++ if (activePanel.hasClass(activePanelClass)) { ++ activePanel.find('table').append(tableBody); ++ $('input[name="serialized_options"]').remove(); ++ } + }); + }); + +diff -Nuar a/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php b/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php +--- a/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php ++++ b/dev/tests/functional/tests/app/Magento/Catalog/Test/Handler/CatalogProductAttribute/Curl.php +@@ -91,15 +91,18 @@ public function persist(FixtureInterface $fixture = null) + $data['frontend_label'] = [0 => $data['frontend_label']]; + + if (isset($data['options'])) { ++ $optionsData = []; + foreach ($data['options'] as $key => $values) { ++ $optionRowData = []; + $index = 'option_' . $key; + if ($values['is_default'] == 'Yes') { +- $data['default'][] = $index; ++ $optionRowData['default'][] = $index; + } +- $data['option']['value'][$index] = [$values['admin'], $values['view']]; +- $data['option']['order'][$index] = $key; ++ $optionRowData['option']['value'][$index] = [$values['admin'], $values['view']]; ++ $optionRowData['option']['order'][$index] = $key; ++ $optionsData[] = $optionRowData; + } +- unset($data['options']); ++ $data['options'] = $optionsData; + } + + $data = $this->changeStructureOfTheData($data); +@@ -134,11 +137,39 @@ public function persist(FixtureInterface $fixture = null) + } + + /** ++ * Additional data handling. ++ * + * @param array $data + * @return array + */ +- protected function changeStructureOfTheData(array $data) ++ protected function changeStructureOfTheData(array $data): array + { ++ if (!isset($data['options'])) { ++ return $data; ++ } ++ ++ $serializedOptions = $this->getSerializeOptions($data['options']); ++ if ($serializedOptions) { ++ $data['serialized_options'] = $serializedOptions; ++ unset($data['options']); ++ } ++ + return $data; + } ++ ++ /** ++ * Provides serialized product attribute options. ++ * ++ * @param array $data ++ * @return string ++ */ ++ protected function getSerializeOptions(array $data): string ++ { ++ $options = []; ++ foreach ($data as $optionRowData) { ++ $options[] = http_build_query($optionRowData); ++ } ++ ++ return json_encode($options); ++ } + } +diff -Nuar a/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php b/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php +--- a/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php ++++ b/dev/tests/functional/tests/app/Magento/Swatches/Test/Handler/SwatchProductAttribute/Curl.php +@@ -29,21 +29,32 @@ public function __construct(DataInterface $configuration, EventManagerInterface + ]; + } + ++ /** ++ * @inheritdoc ++ */ ++ protected function changeStructureOfTheData(array $data): array ++ { ++ return parent::changeStructureOfTheData($data); ++ } ++ + /** + * Re-map options from default options structure to swatches structure, + * as swatches was initially created with name convention differ from other attributes. + * +- * @param array $data +- * @return array ++ * @inheritdoc + */ +- protected function changeStructureOfTheData(array $data) ++ protected function getSerializeOptions(array $data): string + { +- $data = parent::changeStructureOfTheData($data); +- $data['optiontext'] = $data['option']; +- $data['swatchtext'] = [ +- 'value' => $data['option']['value'] +- ]; +- unset($data['option']); +- return $data; ++ $options = []; ++ foreach ($data as $optionRowData) { ++ $optionRowData['optiontext'] = $optionRowData['option']; ++ $optionRowData['swatchtext'] = [ ++ 'value' => $optionRowData['option']['value'] ++ ]; ++ unset($optionRowData['option']); ++ $options[] = http_build_query($optionRowData); ++ } ++ ++ return json_encode($options); + } + } +diff -Nuar a/vendor/magento/framework/Serialize/Serializer/FormData.php b/vendor/magento/framework/Serialize/Serializer/FormData.php +--- /dev/null ++++ b/vendor/magento/framework/Serialize/Serializer/FormData.php +@@ -0,0 +1,55 @@ ++serializer = $serializer; ++ } ++ ++ /** ++ * Provides form data from the serialized data. ++ * ++ * @param string $serializedData ++ * @return array ++ * @throws \InvalidArgumentException ++ */ ++ public function unserialize(string $serializedData): array ++ { ++ $encodedFields = $this->serializer->unserialize($serializedData); ++ ++ if (!is_array($encodedFields)) { ++ throw new \InvalidArgumentException('Unable to unserialize value.'); ++ } ++ ++ $formData = []; ++ foreach ($encodedFields as $item) { ++ $decodedFieldData = []; ++ parse_str($item, $decodedFieldData); ++ $formData = array_replace_recursive($formData, $decodedFieldData); ++ } ++ ++ return $formData; ++ } ++} +diff -Nuar a/vendor/magento/framework/Serialize/Test/Unit/Serializer/FormDataTest.php b/vendor/magento/framework/Serialize/Test/Unit/Serializer/FormDataTest.php +--- /dev/null ++++ b/vendor/magento/framework/Serialize/Test/Unit/Serializer/FormDataTest.php +@@ -0,0 +1,106 @@ ++jsonSerializerMock = $this->createMock(Json::class); ++ $this->formDataSerializer = new FormData($this->jsonSerializerMock); ++ } ++ ++ /** ++ * @param string $serializedData ++ * @param array $encodedFields ++ * @param array $expectedFormData ++ * @return void ++ * @dataProvider unserializeDataProvider ++ */ ++ public function testUnserialize(string $serializedData, array $encodedFields, array $expectedFormData) ++ { ++ $this->jsonSerializerMock->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedData) ++ ->willReturn($encodedFields); ++ ++ $this->assertEquals($expectedFormData, $this->formDataSerializer->unserialize($serializedData)); ++ } ++ ++ /** ++ * @return array ++ */ ++ public function unserializeDataProvider(): array ++ { ++ return [ ++ [ ++ 'serializedData' => ++ '["option[order][option_0]=1","option[value][option_0]=1","option[delete][option_0]="]', ++ 'encodedFields' => [ ++ 'option[order][option_0]=1', ++ 'option[value][option_0]=1', ++ 'option[delete][option_0]=', ++ ], ++ 'expectedFormData' => [ ++ 'option' => [ ++ 'order' => [ ++ 'option_0' => '1', ++ ], ++ 'value' => [ ++ 'option_0' => '1', ++ ], ++ 'delete' => [ ++ 'option_0' => '', ++ ], ++ ], ++ ], ++ ], ++ [ ++ 'serializedData' => '[]', ++ 'encodedFields' => [], ++ 'expectedFormData' => [], ++ ], ++ ]; ++ } ++ ++ /** ++ * @return void ++ * @expectedException InvalidArgumentException ++ * @expectedExceptionMessage Unable to unserialize value. ++ */ ++ public function testUnserializeWithWrongSerializedData() ++ { ++ $serializedData = 'test'; ++ ++ $this->jsonSerializerMock->expects($this->once()) ++ ->method('unserialize') ++ ->with($serializedData) ++ ->willReturn('test'); ++ ++ $this->formDataSerializer->unserialize($serializedData); ++ } ++} +diff -Nuar a/lib/web/mage/backend/validation.js b/lib/web/mage/backend/validation.js +--- a/lib/web/mage/backend/validation.js ++++ b/lib/web/mage/backend/validation.js +@@ -171,6 +171,7 @@ + this._submit(); + } else { + this._showErrors(response); ++ $(this.element[0]).trigger('afterValidate.error'); + $('body').trigger('processStop'); + } + }, diff --git a/patches/MAGETWO-98129__transaction_field_is_removed_by_authorize_net__2.2.0.patch b/patches/MAGETWO-98129__transaction_field_is_removed_by_authorize_net__2.2.0.patch new file mode 100644 index 0000000..be518ee --- /dev/null +++ b/patches/MAGETWO-98129__transaction_field_is_removed_by_authorize_net__2.2.0.patch @@ -0,0 +1,322 @@ +diff -Nuar a/vendor/magento/module-authorizenet/Model/Directpost.php b/vendor/magento/module-authorizenet/Model/Directpost.php +--- a/vendor/magento/module-authorizenet/Model/Directpost.php ++++ b/vendor/magento/module-authorizenet/Model/Directpost.php +@@ -543,15 +543,16 @@ + public function validateResponse() + { + $response = $this->getResponse(); +- //md5 check +- if (!$this->getConfigData('trans_md5') +- || !$this->getConfigData('login') +- || !$response->isValidHash($this->getConfigData('trans_md5'), $this->getConfigData('login')) ++ $hashConfigKey = !empty($response->getData('x_SHA2_Hash')) ? 'signature_key' : 'trans_md5'; ++ ++ //hash check ++ if (!$response->isValidHash($this->getConfigData($hashConfigKey), $this->getConfigData('login')) + ) { + throw new \Magento\Framework\Exception\LocalizedException( + __('The transaction was declined because the response hash validation failed.') + ); + } ++ + return true; + } + +diff -Nuar a/vendor/magento/module-authorizenet/Model/Directpost/Request.php b/vendor/magento/module-authorizenet/Model/Directpost/Request.php +--- a/vendor/magento/module-authorizenet/Model/Directpost/Request.php ++++ b/vendor/magento/module-authorizenet/Model/Directpost/Request.php +@@ -7,6 +7,7 @@ + namespace Magento\Authorizenet\Model\Directpost; + + use Magento\Authorizenet\Model\Request as AuthorizenetRequest; ++use Magento\Framework\Intl\DateTimeFactory; + + /** + * Authorize.net request model for DirectPost model +@@ -18,9 +19,33 @@ + */ + protected $_transKey = null; + ++ /** ++ * Hexadecimal signature key. ++ * ++ * @var string ++ */ ++ private $signatureKey = ''; ++ ++ /** ++ * @var DateTimeFactory ++ */ ++ private $dateTimeFactory; ++ ++ /** ++ * @param DateTimeFactory $dateTimeFactory ++ * @param array $data ++ */ ++ public function __construct( ++ DateTimeFactory $dateTimeFactory, ++ array $data = [] ++ ) { ++ $this->dateTimeFactory = $dateTimeFactory; ++ parent::__construct($data); ++ } ++ + /** + * Return merchant transaction key. +- * Needed to generate sign. ++ * Needed to generate MD5 sign. + * + * @return string + */ +@@ -31,7 +56,7 @@ + + /** + * Set merchant transaction key. +- * Needed to generate sign. ++ * Needed to generate MD5 sign. + * + * @param string $transKey + * @return $this +@@ -43,7 +68,7 @@ + } + + /** +- * Generates the fingerprint for request. ++ * Generates the MD5 fingerprint for request. + * + * @param string $merchantApiLoginId + * @param string $merchantTransactionKey +@@ -63,7 +88,7 @@ + ) { + return hash_hmac( + "md5", +- $merchantApiLoginId . "^" . $fpSequence . "^" . $fpTimestamp . "^" . $amount . "^" . $currencyCode, ++ $merchantApiLoginId . '^' . $fpSequence . '^' . $fpTimestamp . '^' . $amount . '^' . $currencyCode, + $merchantTransactionKey + ); + } +@@ -85,6 +110,7 @@ + ->setXRelayUrl($paymentMethod->getRelayUrl()); + + $this->_setTransactionKey($paymentMethod->getConfigData('trans_key')); ++ $this->setSignatureKey($paymentMethod->getConfigData('signature_key')); + return $this; + } + +@@ -168,17 +194,81 @@ + */ + public function signRequestData() + { +- $fpTimestamp = time(); +- $hash = $this->generateRequestSign( +- $this->getXLogin(), +- $this->_getTransactionKey(), +- $this->getXAmount(), +- $this->getXCurrencyCode(), +- $this->getXFpSequence(), +- $fpTimestamp +- ); ++ $fpDate = $this->dateTimeFactory->create('now', new \DateTimeZone('UTC')); ++ $fpTimestamp = $fpDate->getTimestamp(); ++ ++ if (!empty($this->getSignatureKey())) { ++ $hash = $this->generateSha2RequestSign( ++ $this->getXLogin(), ++ $this->getSignatureKey(), ++ $this->getXAmount(), ++ $this->getXCurrencyCode(), ++ $this->getXFpSequence(), ++ $fpTimestamp ++ ); ++ } else { ++ $hash = $this->generateRequestSign( ++ $this->getXLogin(), ++ $this->_getTransactionKey(), ++ $this->getXAmount(), ++ $this->getXCurrencyCode(), ++ $this->getXFpSequence(), ++ $fpTimestamp ++ ); ++ } ++ + $this->setXFpTimestamp($fpTimestamp); + $this->setXFpHash($hash); ++ + return $this; + } ++ ++ /** ++ * Generates the SHA2 fingerprint for request. ++ * ++ * @param string $merchantApiLoginId ++ * @param string $merchantSignatureKey ++ * @param string $amount ++ * @param string $currencyCode ++ * @param string $fpSequence An invoice number or random number. ++ * @param string $fpTimestamp ++ * @return string The fingerprint. ++ */ ++ private function generateSha2RequestSign( ++ $merchantApiLoginId, ++ $merchantSignatureKey, ++ $amount, ++ $currencyCode, ++ $fpSequence, ++ $fpTimestamp ++ ): string { ++ $message = $merchantApiLoginId . '^' . $fpSequence . '^' . $fpTimestamp . '^' . $amount . '^' . $currencyCode; ++ ++ return strtoupper(hash_hmac('sha512', $message, pack('H*', $merchantSignatureKey))); ++ } ++ ++ /** ++ * Return merchant hexadecimal signature key. ++ * ++ * Needed to generate SHA2 sign. ++ * ++ * @return string ++ */ ++ private function getSignatureKey(): string ++ { ++ return $this->signatureKey; ++ } ++ ++ /** ++ * Set merchant hexadecimal signature key. ++ * ++ * Needed to generate SHA2 sign. ++ * ++ * @param string $signatureKey ++ * @return void ++ */ ++ private function setSignatureKey(string $signatureKey) ++ { ++ $this->signatureKey = $signatureKey; ++ } + } +diff -Nuar a/vendor/magento/module-authorizenet/Model/Directpost/Response.php b/vendor/magento/module-authorizenet/Model/Directpost/Response.php +--- a/vendor/magento/module-authorizenet/Model/Directpost/Response.php ++++ b/vendor/magento/module-authorizenet/Model/Directpost/Response.php +@@ -24,27 +24,33 @@ + */ + public function generateHash($merchantMd5, $merchantApiLogin, $amount, $transactionId) + { +- if (!$amount) { +- $amount = '0.00'; +- } +- + return strtoupper(md5($merchantMd5 . $merchantApiLogin . $transactionId . $amount)); + } + + /** + * Return if is valid order id. + * +- * @param string $merchantMd5 ++ * @param string $storedHash + * @param string $merchantApiLogin + * @return bool + */ +- public function isValidHash($merchantMd5, $merchantApiLogin) ++ public function isValidHash($storedHash, $merchantApiLogin) + { +- $hash = $this->generateHash($merchantMd5, $merchantApiLogin, $this->getXAmount(), $this->getXTransId()); ++ if (empty($this->getData('x_amount'))) { ++ $this->setData('x_amount', '0.00'); ++ } + +- return Security::compareStrings($hash, $this->getData('x_MD5_Hash')); +- } ++ if (!empty($this->getData('x_SHA2_Hash'))) { ++ $hash = $this->generateSha2Hash($storedHash); ++ return Security::compareStrings($hash, $this->getData('x_SHA2_Hash')); ++ } elseif (!empty($this->getData('x_MD5_Hash'))) { ++ $hash = $this->generateHash($storedHash, $merchantApiLogin, $this->getXAmount(), $this->getXTransId()); ++ return Security::compareStrings($hash, $this->getData('x_MD5_Hash')); ++ } + ++ return false; ++ } ++ + /** + * Return if this is approved response from Authorize.net auth request. + * +@@ -54,4 +60,54 @@ + { + return $this->getXResponseCode() == \Magento\Authorizenet\Model\Directpost::RESPONSE_CODE_APPROVED; + } ++ ++ /** ++ * Generates an SHA2 hash to compare against AuthNet's. ++ * ++ * @param string $signatureKey ++ * @return string ++ * @see https://support.authorize.net/s/article/MD5-Hash-End-of-Life-Signature-Key-Replacement ++ */ ++ private function generateSha2Hash(string $signatureKey): string ++ { ++ $hashFields = [ ++ 'x_trans_id', ++ 'x_test_request', ++ 'x_response_code', ++ 'x_auth_code', ++ 'x_cvv2_resp_code', ++ 'x_cavv_response', ++ 'x_avs_code', ++ 'x_method', ++ 'x_account_number', ++ 'x_amount', ++ 'x_company', ++ 'x_first_name', ++ 'x_last_name', ++ 'x_address', ++ 'x_city', ++ 'x_state', ++ 'x_zip', ++ 'x_country', ++ 'x_phone', ++ 'x_fax', ++ 'x_email', ++ 'x_ship_to_company', ++ 'x_ship_to_first_name', ++ 'x_ship_to_last_name', ++ 'x_ship_to_address', ++ 'x_ship_to_city', ++ 'x_ship_to_state', ++ 'x_ship_to_zip', ++ 'x_ship_to_country', ++ 'x_invoice_num', ++ ]; ++ ++ $message = '^'; ++ foreach ($hashFields as $field) { ++ $message .= ($this->getData($field) ?? '') . '^'; ++ } ++ ++ return strtoupper(hash_hmac('sha512', $message, pack('H*', $signatureKey))); ++ } + } +diff -Nuar a/vendor/magento/module-authorizenet/etc/adminhtml/system.xml b/vendor/magento/module-authorizenet/etc/adminhtml/system.xml +--- a/vendor/magento/module-authorizenet/etc/adminhtml/system.xml ++++ b/vendor/magento/module-authorizenet/etc/adminhtml/system.xml +@@ -29,6 +29,10 @@ + + Magento\Config\Model\Config\Backend\Encrypted + ++ ++ ++ Magento\Config\Model\Config\Backend\Encrypted ++ + + + Magento\Config\Model\Config\Backend\Encrypted +diff -Nuar a/vendor/magento/module-authorizenet/etc/config.xml b/vendor/magento/module-authorizenet/etc/config.xml +--- a/vendor/magento/module-authorizenet/etc/config.xml ++++ b/vendor/magento/module-authorizenet/etc/config.xml +@@ -22,6 +22,7 @@ + Credit Card Direct Post (Authorize.net) + + ++ + 0 + USD + 1 diff --git a/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.1.patch b/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.1.patch new file mode 100644 index 0000000..feebf9d --- /dev/null +++ b/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.1.patch @@ -0,0 +1,542 @@ +diff -Nuar a/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php b/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php +--- /dev/null ++++ b/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php +@@ -0,0 +1,102 @@ ++storeManager = $storeManager; ++ $this->envelopeFactory = $envelopeFactory; ++ $this->logger = $logger; ++ } ++ ++ /** ++ * Check if amqpProperties['application_headers'] have 'store_id' and use it to setCurrentStore ++ * Restore original store value in consumer process after execution. ++ * Reject queue messages because of wrong store_id. ++ * ++ * @param SubjectMassConsumerEnvelopeCallback $subject ++ * @param callable $proceed ++ * @param EnvelopeInterface $message ++ * @return void ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function aroundExecute( ++ SubjectMassConsumerEnvelopeCallback $subject, ++ callable $proceed, ++ EnvelopeInterface $message ++ ): void { ++ $amqpProperties = $message->getProperties(); ++ if (isset($amqpProperties['application_headers'])) { ++ $headers = $amqpProperties['application_headers']; ++ if ($headers instanceof AMQPTable) { ++ $headers = $headers->getNativeData(); ++ } ++ if (isset($headers['store_id'])) { ++ $storeId = $headers['store_id']; ++ try { ++ $currentStoreId = $this->storeManager->getStore()->getId(); ++ } catch (NoSuchEntityException $e) { ++ $this->logger->error( ++ sprintf( ++ "Can't set currentStoreId during processing queue. Message rejected. Error %s.", ++ $e->getMessage() ++ ) ++ ); ++ $subject->getQueue()->reject($message, false, $e->getMessage()); ++ ++ return; ++ } ++ if ($storeId !== $currentStoreId) { ++ $this->storeManager->setCurrentStore($storeId); ++ } ++ } ++ } ++ $proceed($message); ++ if (isset($storeId, $currentStoreId) && $storeId !== $currentStoreId) { ++ $this->storeManager->setCurrentStore($currentStoreId);//restore original store value ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php b/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php +--- /dev/null ++++ b/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php +@@ -0,0 +1,115 @@ ++storeManager = $storeManager; ++ $this->envelopeFactory = $envelopeFactory; ++ $this->logger = $logger; ++ } ++ ++ /** ++ * Set current store_id in amqpProperties['application_headers'] ++ * so consumer may check store_id and execute operation in correct store scope. ++ * Prevent publishing inconsistent messages because of store_id not defined or wrong. ++ * ++ * @param SubjectExchange $subject ++ * @param string $topic ++ * @param EnvelopeInterface[] $envelopes ++ * @return array ++ * @throws AMQPInvalidArgumentException ++ * @throws \LogicException ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeEnqueue(SubjectExchange $subject, $topic, array $envelopes): array ++ { ++ try { ++ $storeId = $this->storeManager->getStore()->getId(); ++ } catch (NoSuchEntityException $e) { ++ $errorMessage = sprintf( ++ "Can't get current storeId and inject to amqp message. Error %s.", ++ $e->getMessage() ++ ); ++ $this->logger->error($errorMessage); ++ throw new \LogicException($errorMessage); ++ } ++ ++ $updatedEnvelopes = []; ++ foreach ($envelopes as $envelope) { ++ $properties = $envelope->getProperties(); ++ if (empty($properties)) { ++ $properties = []; ++ } ++ if (isset($properties['application_headers'])) { ++ $headers = $properties['application_headers']; ++ if ($headers instanceof AMQPTable) { ++ try { ++ $headers->set('store_id', $storeId); ++ // phpcs:ignore Magento2.Exceptions.ThrowCatch ++ } catch (AMQPInvalidArgumentException $ea) { ++ $errorMessage = sprintf("Can't set storeId to amqp message. Error %s.", $ea->getMessage()); ++ $this->logger->error($errorMessage); ++ throw new AMQPInvalidArgumentException($errorMessage); ++ } ++ } ++ } else { ++ $properties['application_headers'] = new AMQPTable(['store_id' => $storeId]); ++ } ++ $updatedEnvelopes[] = $this->envelopeFactory->create( ++ [ ++ 'body' => $envelope->getBody(), ++ 'properties' => $properties, ++ ] ++ ); ++ } ++ if (!empty($updatedEnvelopes)) { ++ $envelopes = $updatedEnvelopes; ++ } ++ ++ return [$topic, $envelopes]; ++ } ++} +diff -Nuar a/vendor/magento/module-amqp/composer.json b/vendor/magento/module-amqp/composer.json +--- a/vendor/magento/module-amqp/composer.json ++++ b/vendor/magento/module-amqp/composer.json +@@ -8,6 +11,10 @@ + "magento/framework": "102.0.*", + "magento/framework-amqp": "100.3.*", + "magento/framework-message-queue": "100.3.*", ++ "magento/module-store": "101.0.*", + "php": "~7.1.3||~7.2.0" + }, ++ "suggest": { ++ "magento/module-asynchronous-operations": "*", ++ }, + "type": "magento2-module", + "license": [ + "OSL-3.0", +diff -Nuar a/vendor/magento/module-amqp/etc/di.xml b/vendor/magento/module-amqp/etc/di.xml +--- a/vendor/magento/module-amqp/etc/di.xml ++++ b/vendor/magento/module-amqp/etc/di.xml +@@ -72,4 +72,10 @@ + \Magento\Framework\Amqp\Bulk\Exchange + + ++ ++ ++ ++ ++ ++ + +diff -Nuar a/vendor/magento/module-amqp/etc/module.xml b/vendor/magento/module-amqp/etc/module.xml +--- a/vendor/magento/module-amqp/etc/module.xml ++++ b/vendor/magento/module-amqp/etc/module.xml +@@ -6,5 +6,9 @@ + */ + --> + +- ++ ++ ++ ++ ++ + +diff -Nuar a/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php b/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php +--- a/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php ++++ b/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php +@@ -8,17 +8,10 @@ declare(strict_types=1); + + namespace Magento\AsynchronousOperations\Model; + +-use Magento\Framework\App\ResourceConnection; +-use Psr\Log\LoggerInterface; +-use Magento\Framework\MessageQueue\MessageLockException; +-use Magento\Framework\MessageQueue\ConnectionLostException; +-use Magento\Framework\Exception\NotFoundException; + use Magento\Framework\MessageQueue\CallbackInvoker; + use Magento\Framework\MessageQueue\ConsumerConfigurationInterface; + use Magento\Framework\MessageQueue\EnvelopeInterface; + use Magento\Framework\MessageQueue\QueueInterface; +-use Magento\Framework\MessageQueue\LockInterface; +-use Magento\Framework\MessageQueue\MessageController; + use Magento\Framework\MessageQueue\ConsumerInterface; + + /** +@@ -33,57 +27,31 @@ class MassConsumer implements ConsumerInterface + */ + private $invoker; + +- /** +- * @var \Magento\Framework\App\ResourceConnection +- */ +- private $resource; +- + /** + * @var \Magento\Framework\MessageQueue\ConsumerConfigurationInterface + */ + private $configuration; +- +- /** +- * @var \Magento\Framework\MessageQueue\MessageController +- */ +- private $messageController; +- +- /** +- * @var LoggerInterface +- */ +- private $logger; + + /** +- * @var OperationProcessor ++ * @var MassConsumerEnvelopeCallbackFactory + */ +- private $operationProcessor; ++ private $massConsumerEnvelopeCallback; + + /** + * Initialize dependencies. + * + * @param CallbackInvoker $invoker +- * @param ResourceConnection $resource +- * @param MessageController $messageController + * @param ConsumerConfigurationInterface $configuration +- * @param OperationProcessorFactory $operationProcessorFactory +- * @param LoggerInterface $logger ++ * @param MassConsumerEnvelopeCallbackFactory $massConsumerEnvelopeCallback + */ + public function __construct( + CallbackInvoker $invoker, +- ResourceConnection $resource, +- MessageController $messageController, + ConsumerConfigurationInterface $configuration, +- OperationProcessorFactory $operationProcessorFactory, +- LoggerInterface $logger ++ MassConsumerEnvelopeCallbackFactory $massConsumerEnvelopeCallback + ) { + $this->invoker = $invoker; +- $this->resource = $resource; +- $this->messageController = $messageController; + $this->configuration = $configuration; +- $this->operationProcessor = $operationProcessorFactory->create([ +- 'configuration' => $configuration +- ]); +- $this->logger = $logger; ++ $this->massConsumerEnvelopeCallback = $massConsumerEnvelopeCallback; + } + + /** +@@ -108,38 +84,15 @@ class MassConsumer implements ConsumerInterface + */ + private function getTransactionCallback(QueueInterface $queue) + { +- return function (EnvelopeInterface $message) use ($queue) { +- /** @var LockInterface $lock */ +- $lock = null; +- try { +- $topicName = $message->getProperties()['topic_name']; +- $lock = $this->messageController->lock($message, $this->configuration->getConsumerName()); ++ $callbackInstance = $this->massConsumerEnvelopeCallback->create( ++ [ ++ 'configuration' => $this->configuration, ++ 'queue' => $queue, ++ ] ++ ); + +- $allowedTopics = $this->configuration->getTopicNames(); +- if (in_array($topicName, $allowedTopics)) { +- $this->operationProcessor->process($message->getBody()); +- } else { +- $queue->reject($message); +- return; +- } +- $queue->acknowledge($message); +- } catch (MessageLockException $exception) { +- $queue->acknowledge($message); +- } catch (ConnectionLostException $e) { +- if ($lock) { +- $this->resource->getConnection() +- ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); +- } +- } catch (NotFoundException $e) { +- $queue->acknowledge($message); +- $this->logger->warning($e->getMessage()); +- } catch (\Exception $e) { +- $queue->reject($message, false, $e->getMessage()); +- if ($lock) { +- $this->resource->getConnection() +- ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); +- } +- } ++ return function (EnvelopeInterface $message) use ($callbackInstance) { ++ $callbackInstance->execute($message); + }; + } + } +diff -Nuar a/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php b/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php +--- /dev/null ++++ b/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php +@@ -0,0 +1,138 @@ ++resource = $resource; ++ $this->messageController = $messageController; ++ $this->configuration = $configuration; ++ $this->operationProcessor = $operationProcessorFactory->create( ++ [ ++ 'configuration' => $configuration, ++ ] ++ ); ++ $this->logger = $logger; ++ $this->queue = $queue; ++ } ++ ++ /** ++ * Get transaction callback. This handles the case of async. ++ * ++ * @param EnvelopeInterface $message ++ * @return void ++ */ ++ public function execute(EnvelopeInterface $message): void ++ { ++ $queue = $this->queue; ++ /** @var LockInterface $lock */ ++ $lock = null; ++ try { ++ $topicName = $message->getProperties()['topic_name']; ++ $lock = $this->messageController->lock($message, $this->configuration->getConsumerName()); ++ ++ $allowedTopics = $this->configuration->getTopicNames(); ++ if (in_array($topicName, $allowedTopics)) { ++ $this->operationProcessor->process($message->getBody()); ++ } else { ++ $queue->reject($message); ++ ++ return; ++ } ++ $queue->acknowledge($message); ++ } catch (MessageLockException $exception) { ++ $queue->acknowledge($message); ++ } catch (ConnectionLostException $e) { ++ if ($lock) { ++ $this->resource->getConnection() ++ ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); ++ } ++ } catch (NotFoundException $e) { ++ $queue->acknowledge($message); ++ $this->logger->warning($e->getMessage()); ++ } catch (\Exception $e) { ++ $queue->reject($message, false, $e->getMessage()); ++ if ($lock) { ++ $this->resource->getConnection() ++ ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); ++ } ++ } ++ } ++ ++ /** ++ * Get message queue. ++ * ++ * @return QueueInterface ++ */ ++ public function getQueue(): QueueInterface ++ { ++ return $this->queue; ++ } ++} diff --git a/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.2.patch b/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.2.patch new file mode 100644 index 0000000..99257df --- /dev/null +++ b/patches/MAGETWO-99902__pass_store_view_scope_in_async_web_api__2.3.2.patch @@ -0,0 +1,556 @@ +diff -Nuar a/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php b/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php +--- /dev/null ++++ b/vendor/magento/module-amqp/Plugin/AsynchronousOperations/MassConsumerEnvelopeCallback.php +@@ -0,0 +1,102 @@ ++storeManager = $storeManager; ++ $this->envelopeFactory = $envelopeFactory; ++ $this->logger = $logger; ++ } ++ ++ /** ++ * Check if amqpProperties['application_headers'] have 'store_id' and use it to setCurrentStore ++ * Restore original store value in consumer process after execution. ++ * Reject queue messages because of wrong store_id. ++ * ++ * @param SubjectMassConsumerEnvelopeCallback $subject ++ * @param callable $proceed ++ * @param EnvelopeInterface $message ++ * @return void ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function aroundExecute( ++ SubjectMassConsumerEnvelopeCallback $subject, ++ callable $proceed, ++ EnvelopeInterface $message ++ ): void { ++ $amqpProperties = $message->getProperties(); ++ if (isset($amqpProperties['application_headers'])) { ++ $headers = $amqpProperties['application_headers']; ++ if ($headers instanceof AMQPTable) { ++ $headers = $headers->getNativeData(); ++ } ++ if (isset($headers['store_id'])) { ++ $storeId = $headers['store_id']; ++ try { ++ $currentStoreId = $this->storeManager->getStore()->getId(); ++ } catch (NoSuchEntityException $e) { ++ $this->logger->error( ++ sprintf( ++ "Can't set currentStoreId during processing queue. Message rejected. Error %s.", ++ $e->getMessage() ++ ) ++ ); ++ $subject->getQueue()->reject($message, false, $e->getMessage()); ++ ++ return; ++ } ++ if ($storeId !== $currentStoreId) { ++ $this->storeManager->setCurrentStore($storeId); ++ } ++ } ++ } ++ $proceed($message); ++ if (isset($storeId, $currentStoreId) && $storeId !== $currentStoreId) { ++ $this->storeManager->setCurrentStore($currentStoreId);//restore original store value ++ } ++ } ++} +diff -Nuar a/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php b/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php +--- /dev/null ++++ b/vendor/magento/module-amqp/Plugin/Framework/Amqp/Bulk/Exchange.php +@@ -0,0 +1,115 @@ ++storeManager = $storeManager; ++ $this->envelopeFactory = $envelopeFactory; ++ $this->logger = $logger; ++ } ++ ++ /** ++ * Set current store_id in amqpProperties['application_headers'] ++ * so consumer may check store_id and execute operation in correct store scope. ++ * Prevent publishing inconsistent messages because of store_id not defined or wrong. ++ * ++ * @param SubjectExchange $subject ++ * @param string $topic ++ * @param EnvelopeInterface[] $envelopes ++ * @return array ++ * @throws AMQPInvalidArgumentException ++ * @throws \LogicException ++ * @SuppressWarnings(PHPMD.UnusedFormalParameter) ++ */ ++ public function beforeEnqueue(SubjectExchange $subject, $topic, array $envelopes): array ++ { ++ try { ++ $storeId = $this->storeManager->getStore()->getId(); ++ } catch (NoSuchEntityException $e) { ++ $errorMessage = sprintf( ++ "Can't get current storeId and inject to amqp message. Error %s.", ++ $e->getMessage() ++ ); ++ $this->logger->error($errorMessage); ++ throw new \LogicException($errorMessage); ++ } ++ ++ $updatedEnvelopes = []; ++ foreach ($envelopes as $envelope) { ++ $properties = $envelope->getProperties(); ++ if (empty($properties)) { ++ $properties = []; ++ } ++ if (isset($properties['application_headers'])) { ++ $headers = $properties['application_headers']; ++ if ($headers instanceof AMQPTable) { ++ try { ++ $headers->set('store_id', $storeId); ++ // phpcs:ignore Magento2.Exceptions.ThrowCatch ++ } catch (AMQPInvalidArgumentException $ea) { ++ $errorMessage = sprintf("Can't set storeId to amqp message. Error %s.", $ea->getMessage()); ++ $this->logger->error($errorMessage); ++ throw new AMQPInvalidArgumentException($errorMessage); ++ } ++ } ++ } else { ++ $properties['application_headers'] = new AMQPTable(['store_id' => $storeId]); ++ } ++ $updatedEnvelopes[] = $this->envelopeFactory->create( ++ [ ++ 'body' => $envelope->getBody(), ++ 'properties' => $properties, ++ ] ++ ); ++ } ++ if (!empty($updatedEnvelopes)) { ++ $envelopes = $updatedEnvelopes; ++ } ++ ++ return [$topic, $envelopes]; ++ } ++} +diff -Nuar a/vendor/magento/module-amqp/composer.json b/vendor/magento/module-amqp/composer.json +--- a/vendor/magento/module-amqp/composer.json ++++ b/vendor/magento/module-amqp/composer.json +@@ -8,6 +11,10 @@ + "magento/framework": "102.0.*", + "magento/framework-amqp": "100.3.*", + "magento/framework-message-queue": "100.3.*", ++ "magento/module-store": "101.0.*", + "php": "~7.1.3||~7.2.0" + }, ++ "suggest": { ++ "magento/module-asynchronous-operations": "*", ++ }, + "type": "magento2-module", + "license": [ + "OSL-3.0", +diff -Nuar a/vendor/magento/module-amqp/etc/di.xml b/vendor/magento/module-amqp/etc/di.xml +--- a/vendor/magento/module-amqp/etc/di.xml ++++ b/vendor/magento/module-amqp/etc/di.xml +@@ -72,4 +72,10 @@ + \Magento\Framework\Amqp\Bulk\Exchange + + ++ ++ ++ ++ ++ ++ + +diff -Nuar a/vendor/magento/module-amqp/etc/module.xml b/vendor/magento/module-amqp/etc/module.xml +--- a/vendor/magento/module-amqp/etc/module.xml ++++ b/vendor/magento/module-amqp/etc/module.xml +@@ -6,5 +6,9 @@ + */ + --> + +- ++ ++ ++ ++ ++ + +diff -Nuar a/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php b/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php +--- a/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php ++++ b/vendor/magento/module-asynchronous-operations/Model/MassConsumer.php +@@ -8,18 +8,11 @@ declare(strict_types=1); + + namespace Magento\AsynchronousOperations\Model; + +-use Magento\Framework\App\ResourceConnection; + use Magento\Framework\Registry; +-use Psr\Log\LoggerInterface; +-use Magento\Framework\MessageQueue\MessageLockException; +-use Magento\Framework\MessageQueue\ConnectionLostException; +-use Magento\Framework\Exception\NotFoundException; + use Magento\Framework\MessageQueue\CallbackInvokerInterface; + use Magento\Framework\MessageQueue\ConsumerConfigurationInterface; + use Magento\Framework\MessageQueue\EnvelopeInterface; + use Magento\Framework\MessageQueue\QueueInterface; +-use Magento\Framework\MessageQueue\LockInterface; +-use Magento\Framework\MessageQueue\MessageController; + use Magento\Framework\MessageQueue\ConsumerInterface; + + /** +@@ -34,66 +27,39 @@ class MassConsumer implements ConsumerInterface + */ + private $invoker; + +- /** +- * @var \Magento\Framework\App\ResourceConnection +- */ +- private $resource; +- + /** + * @var \Magento\Framework\MessageQueue\ConsumerConfigurationInterface + */ + private $configuration; + + /** +- * @var \Magento\Framework\MessageQueue\MessageController +- */ +- private $messageController; +- +- /** +- * @var LoggerInterface +- */ +- private $logger; +- +- /** +- * @var OperationProcessor ++ * @var Registry + */ +- private $operationProcessor; ++ private $registry; + + /** +- * @var Registry ++ * @var MassConsumerEnvelopeCallbackFactory + */ +- private $registry; ++ private $massConsumerEnvelopeCallback; + + /** + * Initialize dependencies. + * + * @param CallbackInvokerInterface $invoker +- * @param ResourceConnection $resource +- * @param MessageController $messageController + * @param ConsumerConfigurationInterface $configuration +- * @param OperationProcessorFactory $operationProcessorFactory +- * @param LoggerInterface $logger ++ * @param MassConsumerEnvelopeCallbackFactory $massConsumerEnvelopeCallback + * @param Registry $registry + */ + public function __construct( + CallbackInvokerInterface $invoker, +- ResourceConnection $resource, +- MessageController $messageController, + ConsumerConfigurationInterface $configuration, +- OperationProcessorFactory $operationProcessorFactory, +- LoggerInterface $logger, ++ MassConsumerEnvelopeCallbackFactory $massConsumerEnvelopeCallback, + Registry $registry = null + ) { + $this->invoker = $invoker; +- $this->resource = $resource; +- $this->messageController = $messageController; + $this->configuration = $configuration; +- $this->operationProcessor = $operationProcessorFactory->create([ +- 'configuration' => $configuration +- ]); +- $this->logger = $logger; +- $this->registry = $registry ?? \Magento\Framework\App\ObjectManager::getInstance() +- ->get(Registry::class); ++ $this->massConsumerEnvelopeCallback = $massConsumerEnvelopeCallback; ++ $this->registry = $registry ?? \Magento\Framework\App\ObjectManager::getInstance()->get(Registry::class); + } + + /** +@@ -122,38 +88,15 @@ class MassConsumer implements ConsumerInterface + */ + private function getTransactionCallback(QueueInterface $queue) + { +- return function (EnvelopeInterface $message) use ($queue) { +- /** @var LockInterface $lock */ +- $lock = null; +- try { +- $topicName = $message->getProperties()['topic_name']; +- $lock = $this->messageController->lock($message, $this->configuration->getConsumerName()); +- +- $allowedTopics = $this->configuration->getTopicNames(); +- if (in_array($topicName, $allowedTopics)) { +- $this->operationProcessor->process($message->getBody()); +- } else { +- $queue->reject($message); +- return; +- } +- $queue->acknowledge($message); +- } catch (MessageLockException $exception) { +- $queue->acknowledge($message); +- } catch (ConnectionLostException $e) { +- if ($lock) { +- $this->resource->getConnection() +- ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); +- } +- } catch (NotFoundException $e) { +- $queue->acknowledge($message); +- $this->logger->warning($e->getMessage()); +- } catch (\Exception $e) { +- $queue->reject($message, false, $e->getMessage()); +- if ($lock) { +- $this->resource->getConnection() +- ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); +- } +- } ++ $callbackInstance = $this->massConsumerEnvelopeCallback->create( ++ [ ++ 'configuration' => $this->configuration, ++ 'queue' => $queue, ++ ] ++ ); ++ ++ return function (EnvelopeInterface $message) use ($callbackInstance) { ++ $callbackInstance->execute($message); + }; + } + } +diff -Nuar a/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php b/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php +--- /dev/null ++++ b/vendor/magento/module-asynchronous-operations/Model/MassConsumerEnvelopeCallback.php +@@ -0,0 +1,138 @@ ++resource = $resource; ++ $this->messageController = $messageController; ++ $this->configuration = $configuration; ++ $this->operationProcessor = $operationProcessorFactory->create( ++ [ ++ 'configuration' => $configuration, ++ ] ++ ); ++ $this->logger = $logger; ++ $this->queue = $queue; ++ } ++ ++ /** ++ * Get transaction callback. This handles the case of async. ++ * ++ * @param EnvelopeInterface $message ++ * @return void ++ */ ++ public function execute(EnvelopeInterface $message): void ++ { ++ $queue = $this->queue; ++ /** @var LockInterface $lock */ ++ $lock = null; ++ try { ++ $topicName = $message->getProperties()['topic_name']; ++ $lock = $this->messageController->lock($message, $this->configuration->getConsumerName()); ++ ++ $allowedTopics = $this->configuration->getTopicNames(); ++ if (in_array($topicName, $allowedTopics)) { ++ $this->operationProcessor->process($message->getBody()); ++ } else { ++ $queue->reject($message); ++ ++ return; ++ } ++ $queue->acknowledge($message); ++ } catch (MessageLockException $exception) { ++ $queue->acknowledge($message); ++ } catch (ConnectionLostException $e) { ++ if ($lock) { ++ $this->resource->getConnection() ++ ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); ++ } ++ } catch (NotFoundException $e) { ++ $queue->acknowledge($message); ++ $this->logger->warning($e->getMessage()); ++ } catch (\Exception $e) { ++ $queue->reject($message, false, $e->getMessage()); ++ if ($lock) { ++ $this->resource->getConnection() ++ ->delete($this->resource->getTableName('queue_lock'), ['id = ?' => $lock->getId()]); ++ } ++ } ++ } ++ ++ /** ++ * Get message queue. ++ * ++ * @return QueueInterface ++ */ ++ public function getQueue(): QueueInterface ++ { ++ return $this->queue; ++ } ++} diff --git a/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.0.patch b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.0.patch new file mode 100644 index 0000000..ce71620 --- /dev/null +++ b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.0.patch @@ -0,0 +1,146 @@ +diff -Nuar a/vendor/magento/module-sales/Helper/Admin.php b/vendor/magento/module-sales/Helper/Admin.php +--- a/vendor/magento/module-sales/Helper/Admin.php ++++ b/vendor/magento/module-sales/Helper/Admin.php +@@ -3,8 +3,14 @@ + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ ++ + namespace Magento\Sales\Helper; + ++use Magento\Framework\App\ObjectManager; ++ ++/** ++ * Sales admin helper. ++ */ + class Admin extends \Magento\Framework\App\Helper\AbstractHelper + { + /** +@@ -27,24 +33,33 @@ class Admin extends \Magento\Framework\App\Helper\AbstractHelper + */ + protected $escaper; + ++ /** ++ * @var \DOMDocumentFactory ++ */ ++ private $domDocumentFactory; ++ + /** + * @param \Magento\Framework\App\Helper\Context $context + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Sales\Model\Config $salesConfig + * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency + * @param \Magento\Framework\Escaper $escaper ++ * @param \DOMDocumentFactory|null $domDocumentFactory + */ + public function __construct( + \Magento\Framework\App\Helper\Context $context, + \Magento\Store\Model\StoreManagerInterface $storeManager, + \Magento\Sales\Model\Config $salesConfig, + \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, +- \Magento\Framework\Escaper $escaper ++ \Magento\Framework\Escaper $escaper, ++ \DOMDocumentFactory $domDocumentFactory = null + ) { + $this->priceCurrency = $priceCurrency; + $this->_storeManager = $storeManager; + $this->_salesConfig = $salesConfig; + $this->escaper = $escaper; ++ $this->domDocumentFactory = $domDocumentFactory ++ ?: ObjectManager::getInstance()->get(\DOMDocumentFactory::class); + parent::__construct($context); + } + +@@ -145,37 +160,65 @@ class Admin extends \Magento\Framework\App\Helper\AbstractHelper + public function escapeHtmlWithLinks($data, $allowedTags = null) + { + if (!empty($data) && is_array($allowedTags) && in_array('a', $allowedTags)) { +- $links = []; +- $i = 1; +- $data = str_replace('%', '%%', $data); +- $regexp = "/]*href\s*?=\s*?([\"\']??)([^\" >]*?)\\1[^>]*>(.*)<\/a>/siU"; +- while (preg_match($regexp, $data, $matches)) { +- //Revert the sprintf escaping +- $url = str_replace('%%', '%', $matches[2]); +- $text = str_replace('%%', '%', $matches[3]); +- //Check for an valid url +- if ($url) { +- $urlScheme = strtolower(parse_url($url, PHP_URL_SCHEME)); +- if ($urlScheme !== 'http' && $urlScheme !== 'https') { +- $url = null; +- } ++ $wrapperElementId = uniqid(); ++ $domDocument = $this->domDocumentFactory->create(); ++ ++ $internalErrors = libxml_use_internal_errors(true); ++ ++ $domDocument->loadHTML( ++ '' . $data . '' ++ ); ++ ++ libxml_use_internal_errors($internalErrors); ++ ++ $linkTags = $domDocument->getElementsByTagName('a'); ++ ++ foreach ($linkTags as $linkNode) { ++ $linkAttributes = []; ++ foreach ($linkNode->attributes as $attribute) { ++ $linkAttributes[$attribute->name] = $attribute->value; + } +- //Use hash tag as fallback +- if (!$url) { +- $url = '#'; ++ ++ foreach ($linkAttributes as $attributeName => $attributeValue) { ++ if ($attributeName === 'href') { ++ $url = $this->filterUrl($attributeValue ?? ''); ++ $url = $this->escaper->escapeUrl($url); ++ $linkNode->setAttribute('href', $url); ++ } else { ++ $linkNode->removeAttribute($attributeName); ++ } + } +- //Recreate a minimalistic secure a tag +- $links[] = sprintf( +- '%s', +- htmlspecialchars($url, ENT_QUOTES, 'UTF-8', false), +- $this->escaper->escapeHtml($text) +- ); +- $data = str_replace($matches[0], '%' . $i . '$s', $data); +- ++$i; + } +- $data = $this->escaper->escapeHtml($data, $allowedTags); +- return vsprintf($data, $links); ++ ++ $result = mb_convert_encoding($domDocument->saveHTML(), 'UTF-8', 'HTML-ENTITIES'); ++ preg_match('/(.+)<\/body><\/html>$/si', $result, $matches); ++ $data = !empty($matches) ? $matches[1] : ''; + } ++ + return $this->escaper->escapeHtml($data, $allowedTags); + } ++ ++ /** ++ * Filter the URL for allowed protocols. ++ * ++ * @param string $url ++ * @return string ++ */ ++ private function filterUrl(string $url): string ++ { ++ if ($url) { ++ //Revert the sprintf escaping ++ $urlScheme = parse_url($url, PHP_URL_SCHEME); ++ $urlScheme = $urlScheme ? strtolower($urlScheme) : ''; ++ if ($urlScheme !== 'http' && $urlScheme !== 'https') { ++ $url = null; ++ } ++ } ++ ++ if (!$url) { ++ $url = '#'; ++ } ++ ++ return $url; ++ } + } diff --git a/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.7.patch b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.7.patch new file mode 100644 index 0000000..a367b77 --- /dev/null +++ b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.2.7.patch @@ -0,0 +1,119 @@ +diff -Nuar a/vendor/magento/module-sales/Helper/Admin.php b/vendor/magento/module-sales/Helper/Admin.php +--- a/vendor/magento/module-sales/Helper/Admin.php ++++ b/vendor/magento/module-sales/Helper/Admin.php +@@ -6,6 +6,8 @@ + + namespace Magento\Sales\Helper; + ++use Magento\Framework\App\ObjectManager; ++ + /** + * Sales admin helper. + */ +@@ -31,24 +33,33 @@ + */ + protected $escaper; + ++ /** ++ * @var \DOMDocumentFactory ++ */ ++ private $domDocumentFactory; ++ + /** + * @param \Magento\Framework\App\Helper\Context $context + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Sales\Model\Config $salesConfig + * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency + * @param \Magento\Framework\Escaper $escaper ++ * @param \DOMDocumentFactory|null $domDocumentFactory + */ + public function __construct( + \Magento\Framework\App\Helper\Context $context, + \Magento\Store\Model\StoreManagerInterface $storeManager, + \Magento\Sales\Model\Config $salesConfig, + \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, +- \Magento\Framework\Escaper $escaper ++ \Magento\Framework\Escaper $escaper, ++ \DOMDocumentFactory $domDocumentFactory = null + ) { + $this->priceCurrency = $priceCurrency; + $this->_storeManager = $storeManager; + $this->_salesConfig = $salesConfig; + $this->escaper = $escaper; ++ $this->domDocumentFactory = $domDocumentFactory ++ ?: ObjectManager::getInstance()->get(\DOMDocumentFactory::class); + parent::__construct($context); + } + +@@ -149,30 +160,41 @@ + public function escapeHtmlWithLinks($data, $allowedTags = null) + { + if (!empty($data) && is_array($allowedTags) && in_array('a', $allowedTags)) { +- $links = []; +- $i = 1; +- $data = str_replace('%', '%%', $data); +- $regexp = "#(?J).*?)\\1\s*)|(?:\S+\s*=\s*(['\"])(.*?)\\3)\s*)*)|>)" +- .">?(?:(?:(?.*?)(?:<\/a\s*>?|(?=<\w))|(?.*)))#si"; +- while (preg_match($regexp, $data, $matches)) { +- $text = ''; +- if (!empty($matches['text'])) { +- $text = str_replace('%%', '%', $matches['text']); +- } +- $url = $this->filterUrl($matches['link'] ?? ''); +- //Recreate a minimalistic secure a tag +- $links[] = sprintf( +- '%s', +- htmlspecialchars($url, ENT_QUOTES, 'UTF-8', false), +- $this->escaper->escapeHtml($text) +- ); +- $data = str_replace($matches[0], '%' . $i . '$s', $data); +- ++$i; ++ $wrapperElementId = uniqid(); ++ $domDocument = $this->domDocumentFactory->create(); ++ ++ $internalErrors = libxml_use_internal_errors(true); ++ ++ $domDocument->loadHTML( ++ '' . $data . '' ++ ); ++ ++ libxml_use_internal_errors($internalErrors); ++ ++ $linkTags = $domDocument->getElementsByTagName('a'); ++ ++ foreach ($linkTags as $linkNode) { ++ $linkAttributes = []; ++ foreach ($linkNode->attributes as $attribute) { ++ $linkAttributes[$attribute->name] = $attribute->value; ++ } ++ ++ foreach ($linkAttributes as $attributeName => $attributeValue) { ++ if ($attributeName === 'href') { ++ $url = $this->filterUrl($attributeValue ?? ''); ++ $url = $this->escaper->escapeUrl($url); ++ $linkNode->setAttribute('href', $url); ++ } else { ++ $linkNode->removeAttribute($attributeName); ++ } ++ } + } +- $data = $this->escaper->escapeHtml($data, $allowedTags); +- return vsprintf($data, $links); ++ ++ $result = mb_convert_encoding($domDocument->saveHTML(), 'UTF-8', 'HTML-ENTITIES'); ++ preg_match('/(.+)<\/body><\/html>$/si', $result, $matches); ++ $data = !empty($matches) ? $matches[1] : ''; + } ++ + return $this->escaper->escapeHtml($data, $allowedTags); + } + +@@ -186,7 +208,6 @@ + { + if ($url) { + //Revert the sprintf escaping +- $url = str_replace('%%', '%', $url); + $urlScheme = parse_url($url, PHP_URL_SCHEME); + $urlScheme = $urlScheme ? strtolower($urlScheme) : ''; + if ($urlScheme !== 'http' && $urlScheme !== 'https') { diff --git a/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.3.0.patch b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.3.0.patch new file mode 100644 index 0000000..dec1608 --- /dev/null +++ b/patches/PRODSECBUG-2233__fix_xss_in_order_history__2.3.0.patch @@ -0,0 +1,209 @@ +diff -Nuar a/vendor/magento/module-sales/Helper/Admin.php b/vendor/magento/module-sales/Helper/Admin.php +--- a/vendor/magento/module-sales/Helper/Admin.php ++++ b/vendor/magento/module-sales/Helper/Admin.php +@@ -7,6 +7,8 @@ + + namespace Magento\Sales\Helper; + ++use Magento\Framework\App\ObjectManager; ++ + /** + * Sales admin helper. + */ +@@ -32,24 +34,33 @@ + */ + protected $escaper; + ++ /** ++ * @var \DOMDocumentFactory ++ */ ++ private $domDocumentFactory; ++ + /** + * @param \Magento\Framework\App\Helper\Context $context + * @param \Magento\Store\Model\StoreManagerInterface $storeManager + * @param \Magento\Sales\Model\Config $salesConfig + * @param \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency + * @param \Magento\Framework\Escaper $escaper ++ * @param \DOMDocumentFactory|null $domDocumentFactory + */ + public function __construct( + \Magento\Framework\App\Helper\Context $context, + \Magento\Store\Model\StoreManagerInterface $storeManager, + \Magento\Sales\Model\Config $salesConfig, + \Magento\Framework\Pricing\PriceCurrencyInterface $priceCurrency, +- \Magento\Framework\Escaper $escaper ++ \Magento\Framework\Escaper $escaper, ++ \DOMDocumentFactory $domDocumentFactory = null + ) { + $this->priceCurrency = $priceCurrency; + $this->_storeManager = $storeManager; + $this->_salesConfig = $salesConfig; + $this->escaper = $escaper; ++ $this->domDocumentFactory = $domDocumentFactory ++ ?: ObjectManager::getInstance()->get(\DOMDocumentFactory::class); + parent::__construct($context); + } + +@@ -150,30 +161,41 @@ + public function escapeHtmlWithLinks($data, $allowedTags = null) + { + if (!empty($data) && is_array($allowedTags) && in_array('a', $allowedTags)) { +- $links = []; +- $i = 1; +- $data = str_replace('%', '%%', $data); +- $regexp = "#(?J).*?)\\1\s*)|(?:\S+\s*=\s*(['\"])(.*?)\\3)\s*)*)|>)" +- .">?(?:(?:(?.*?)(?:<\/a\s*>?|(?=<\w))|(?.*)))#si"; +- while (preg_match($regexp, $data, $matches)) { +- $text = ''; +- if (!empty($matches['text'])) { +- $text = str_replace('%%', '%', $matches['text']); +- } +- $url = $this->filterUrl($matches['link'] ?? ''); +- //Recreate a minimalistic secure a tag +- $links[] = sprintf( +- '%s', +- htmlspecialchars($url, ENT_QUOTES, 'UTF-8', false), +- $this->escaper->escapeHtml($text) +- ); +- $data = str_replace($matches[0], '%' . $i . '$s', $data); +- ++$i; ++ $wrapperElementId = uniqid(); ++ $domDocument = $this->domDocumentFactory->create(); ++ ++ $internalErrors = libxml_use_internal_errors(true); ++ ++ $domDocument->loadHTML( ++ '' . $data . '' ++ ); ++ ++ libxml_use_internal_errors($internalErrors); ++ ++ $linkTags = $domDocument->getElementsByTagName('a'); ++ ++ foreach ($linkTags as $linkNode) { ++ $linkAttributes = []; ++ foreach ($linkNode->attributes as $attribute) { ++ $linkAttributes[$attribute->name] = $attribute->value; ++ } ++ ++ foreach ($linkAttributes as $attributeName => $attributeValue) { ++ if ($attributeName === 'href') { ++ $url = $this->filterUrl($attributeValue ?? ''); ++ $url = $this->escaper->escapeUrl($url); ++ $linkNode->setAttribute('href', $url); ++ } else { ++ $linkNode->removeAttribute($attributeName); ++ } ++ } + } +- $data = $this->escaper->escapeHtml($data, $allowedTags); +- return vsprintf($data, $links); ++ ++ $result = mb_convert_encoding($domDocument->saveHTML(), 'UTF-8', 'HTML-ENTITIES'); ++ preg_match('/(.+)<\/body><\/html>$/si', $result, $matches); ++ $data = !empty($matches) ? $matches[1] : ''; + } ++ + return $this->escaper->escapeHtml($data, $allowedTags); + } + +@@ -187,7 +209,7 @@ + { + if ($url) { + //Revert the sprintf escaping +- $url = str_replace('%%', '%', $url); ++ // phpcs:ignore Magento2.Functions.DiscouragedFunction + $urlScheme = parse_url($url, PHP_URL_SCHEME); + $urlScheme = $urlScheme ? strtolower($urlScheme) : ''; + if ($urlScheme !== 'http' && $urlScheme !== 'https') { +diff -Nuar a/vendor/magento/module-sales/Test/Unit/Helper/AdminTest.php b/vendor/magento/module-sales/Test/Unit/Helper/AdminTest.php +--- a/vendor/magento/module-sales/Test/Unit/Helper/AdminTest.php ++++ b/vendor/magento/module-sales/Test/Unit/Helper/AdminTest.php +@@ -71,7 +71,7 @@ + ->disableOriginalConstructor() + ->getMock(); + +- $this->adminHelper = (new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this))->getObject( ++ $this->adminHelper = (new ObjectManager($this))->getObject( + \Magento\Sales\Helper\Admin::class, + [ + 'context' => $this->contextMock, +@@ -330,72 +330,16 @@ + } + + /** +- * @param string $data +- * @param string $expected +- * @param null|array $allowedTags +- * @dataProvider escapeHtmlWithLinksDataProvider ++ * @return void + */ +- public function testEscapeHtmlWithLinks($data, $expected, $allowedTags = null) ++ public function testEscapeHtmlWithLinks(): void + { ++ $expected = '<a>some text in tags</a>'; + $this->escaperMock + ->expects($this->any()) + ->method('escapeHtml') + ->will($this->returnValue($expected)); +- $actual = $this->adminHelper->escapeHtmlWithLinks($data, $allowedTags); ++ $actual = $this->adminHelper->escapeHtmlWithLinks('some text in tags'); + $this->assertEquals($expected, $actual); + } +- +- /** +- * @return array +- */ +- public function escapeHtmlWithLinksDataProvider() +- { +- return [ +- [ +- 'some text in tags', +- '<a>some text in tags</a>', +- 'allowedTags' => null +- ], +- [ +- 'Transaction ID: "XX123XX"', +- 'Transaction ID: "XX123XX"', +- 'allowedTags' => ['b', 'br', 'strong', 'i', 'u', 'a'] +- ], +- [ +- 'some text in tags', +- 'some text in tags', +- 'allowedTags' => ['a'] +- ], +- 'Not replacement with placeholders' => [ +- "", +- '<script>alert(1)</script>', +- 'allowedTags' => ['a'] +- ], +- 'Normal usage, url escaped' => [ +- 'Foo', +- 'Foo', +- 'allowedTags' => ['a'] +- ], +- 'Normal usage, url not escaped' => [ +- "Foo", +- 'Foo', +- 'allowedTags' => ['a'] +- ], +- 'XSS test' => [ +- "Foo", +- 'Foo', +- 'allowedTags' => ['a'] +- ], +- 'Additional regex test' => [ +- "Foo", +- 'Foo', +- 'allowedTags' => ['a'] +- ], +- 'Break of valid urls' => [ +- "Foo", +- 'Foo', +- 'allowedTags' => ['a'] +- ], +- ]; +- } + } diff --git a/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.1.4.patch b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.1.4.patch new file mode 100644 index 0000000..67bcdd3 --- /dev/null +++ b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.1.4.patch @@ -0,0 +1,21 @@ +diff -Nuar a/vendor/magento/framework/App/Router/ActionList.php b/vendor/magento/framework/App/Router/ActionList.php +--- a/vendor/magento/framework/App/Router/ActionList.php ++++ b/vendor/magento/framework/App/Router/ActionList.php +@@ -8,6 +7,9 @@ namespace Magento\Framework\App\Router; + + use Magento\Framework\Module\Dir\Reader as ModuleReader; + ++/** ++ * Class to retrieve action class. ++ */ + class ActionList + { + /** +@@ -74,6 +76,7 @@ class ActionList + if ($area) { + $area = '\\' . $area; + } ++ $namespace = strtolower($namespace); + if (strpos($namespace, self::NOT_ALLOWED_IN_NAMESPACE_PATH) !== false) { + return null; + } diff --git a/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.2.0.patch b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.2.0.patch new file mode 100644 index 0000000..c2b230d --- /dev/null +++ b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.2.0.patch @@ -0,0 +1,22 @@ +diff -Nuar a/vendor/magento/framework/App/Router/ActionList.php b/vendor/magento/framework/App/Router/ActionList.php +--- a/vendor/magento/framework/App/Router/ActionList.php ++++ b/vendor/magento/framework/App/Router/ActionList.php +@@ -10,6 +9,9 @@ use Magento\Framework\Serialize\SerializerInterface; + use Magento\Framework\Serialize\Serializer\Serialize; + use Magento\Framework\Module\Dir\Reader as ModuleReader; + ++/** ++ * Class to retrieve action class. ++ */ + class ActionList + { + /** +@@ -91,6 +93,7 @@ class ActionList + if ($area) { + $area = '\\' . $area; + } ++ $namespace = strtolower($namespace); + if (strpos($namespace, self::NOT_ALLOWED_IN_NAMESPACE_PATH) !== false) { + return null; + } + diff --git a/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.3.0.patch b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.3.0.patch new file mode 100644 index 0000000..c2b230d --- /dev/null +++ b/patches/PRODSECBUG-2432__admin_path_disclosure_bug__2.3.0.patch @@ -0,0 +1,22 @@ +diff -Nuar a/vendor/magento/framework/App/Router/ActionList.php b/vendor/magento/framework/App/Router/ActionList.php +--- a/vendor/magento/framework/App/Router/ActionList.php ++++ b/vendor/magento/framework/App/Router/ActionList.php +@@ -10,6 +9,9 @@ use Magento\Framework\Serialize\SerializerInterface; + use Magento\Framework\Serialize\Serializer\Serialize; + use Magento\Framework\Module\Dir\Reader as ModuleReader; + ++/** ++ * Class to retrieve action class. ++ */ + class ActionList + { + /** +@@ -91,6 +93,7 @@ class ActionList + if ($area) { + $area = '\\' . $area; + } ++ $namespace = strtolower($namespace); + if (strpos($namespace, self::NOT_ALLOWED_IN_NAMESPACE_PATH) !== false) { + return null; + } +