VYPR
High severityOSV Advisory· Published Sep 8, 2025· Updated Apr 15, 2026

CVE-2025-58449

CVE-2025-58449

Description

Maho is a free and open source ecommerce platform. In Maho prior to 25.9.0, an authenticated staff user with access to the Dashboard and Catalog\Manage Products permissions can create a custom option on a listing with a file input field. By allowing file uploads with a .php extension, the user can use the filed to upload malicious PHP files, gaining remote code execution. Version 25.9.0 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
mahocommerce/mahoPackagist
< 25.9.025.9.0

Affected products

1

Patches

2
d596aef05088

Added category import/export (#302)

https://github.com/mahocommerce/mahoFabrizio BallianoSep 7, 2025via osv
13 files changed · +3579 22
  • app/code/core/Mage/ImportExport/controllers/Adminhtml/ExportController.php+14 5 modified
    @@ -6,7 +6,7 @@
      * @package    Mage_ImportExport
      * @copyright  Copyright (c) 2006-2020 Magento, Inc. (https://magento.com)
      * @copyright  Copyright (c) 2019-2024 The OpenMage Contributors (https://openmage.org)
    - * @copyright  Copyright (c) 2024 Maho (https://mahocommerce.com)
    + * @copyright  Copyright (c) 2024-2025 Maho (https://mahocommerce.com)
      * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
      */
     
    @@ -56,12 +56,21 @@ public function exportAction()
                     $model = Mage::getModel('importexport/export');
                     $model->setData($this->getRequest()->getParams());
     
    -                $result         = $model->exportFile();
    -                $result['type'] = 'filename';
    +                $result = $model->exportFile();
    +
    +                // Handle different result types correctly
    +                if (isset($result['type']) && $result['type'] === 'string') {
    +                    // For string content, pass the content directly
    +                    $content = $result['value'];
    +                } else {
    +                    // For file-based exports, use the full result array
    +                    $result['type'] = 'filename';
    +                    $content = $result;
    +                }
     
                     return $this->_prepareDownloadResponse(
                         $model->getFileName(),
    -                    $result,
    +                    $content,
                         $model->getContentType(),
                     );
                 } catch (Mage_Core_Exception $e) {
    @@ -94,7 +103,7 @@ public function indexAction(): void
         public function getFilterAction()
         {
             $data = $this->getRequest()->getParams();
    -        if ($this->getRequest()->isXmlHttpRequest() && $data) {
    +        if ($data) {
                 try {
                     $this->loadLayout();
     
    
  • app/code/core/Mage/ImportExport/etc/config.xml+8 0 modified
    @@ -54,6 +54,10 @@
                         <model_token>importexport/import_entity_product</model_token>
                         <label>Products</label>
                     </catalog_product>
    +                <catalog_category translate="label">
    +                    <model_token>importexport/import_entity_category</model_token>
    +                    <label>Categories</label>
    +                </catalog_category>
                     <customer translate="label">
                         <model_token>importexport/import_entity_customer</model_token>
                         <label>Customers</label>
    @@ -64,6 +68,10 @@
                         <model_token>importexport/export_entity_product</model_token>
                         <label>Products</label>
                     </catalog_product>
    +                <catalog_category translate="label">
    +                    <model_token>importexport/export_entity_category</model_token>
    +                    <label>Categories</label>
    +                </catalog_category>
                     <customer translate="label">
                         <model_token>importexport/export_entity_customer</model_token>
                         <label>Customers</label>
    
  • app/code/core/Mage/ImportExport/Model/Export/Entity/Category.php+419 0 added
    @@ -0,0 +1,419 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/**
    + * Maho
    + *
    + * @package    Mage_ImportExport
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +class Mage_ImportExport_Model_Export_Entity_Category extends Mage_ImportExport_Model_Export_Entity_Abstract
    +{
    +    /**
    +     * Permanent column names.
    +     *
    +     * Names that begins with underscore is not an attribute. This name convention is for
    +     * to avoid interference with same attribute name.
    +     */
    +    public const COL_STORE = '_store';
    +    public const COL_CATEGORY_ID = 'category_id';
    +    public const COL_PARENT_ID = 'parent_id';
    +
    +    /**
    +     * Category parent relationships cache.
    +     *
    +     * @var array
    +     */
    +    protected $_categoryParents = [];
    +
    +    /**
    +     * Attributes that should use value index instead of label
    +     *
    +     * @var array
    +     */
    +    protected $_indexValueAttributes = [];
    +
    +    /**
    +     * Attribute code to ID mapping for faster lookups.
    +     *
    +     * @var array
    +     */
    +    protected $_attributeCodeToId = [];
    +
    +    /**
    +     * Preloaded category attribute data by store.
    +     *
    +     * @var array
    +     */
    +    protected $_categoryAttributeData = [];
    +
    +    /**
    +     * Disabled attributes for export.
    +     *
    +     * @var array
    +     */
    +    protected $_disabledAttrs = [
    +        'all_children',
    +        'children',
    +        'children_count',
    +        'level',
    +        'path',
    +        'path_in_store',
    +        'position',
    +        'url_path',  // Also disable url_path since we have category_path
    +    ];
    +
    +    /**
    +     * Permanent attributes.
    +     *
    +     * @var array
    +     */
    +    protected $_permanentAttributes = [self::COL_CATEGORY_ID, self::COL_PARENT_ID];
    +
    +    /**
    +     * Constructor.
    +     */
    +    public function __construct()
    +    {
    +        parent::__construct();
    +
    +        $this->_initStores()
    +             ->_initWebsites()
    +             ->_initBooleanAttributes()
    +             ->_initAttrValues();
    +
    +        $this->_initCategoryParents()
    +             ->_initAttributeMapping();
    +    }
    +
    +    /**
    +     * Initialize category parent relationships.
    +     *
    +     * @return $this
    +     */
    +    protected function _initCategoryParents(): self
    +    {
    +        /** @var Mage_Catalog_Model_Resource_Category_Collection $collection */
    +        $collection = Mage::getResourceModel('catalog/category_collection');
    +        $collection->addAttributeToFilter('level', ['gt' => 0])
    +                   ->load();
    +
    +        foreach ($collection as $category) {
    +            /** @var Mage_Catalog_Model_Category $category */
    +            $this->_categoryParents[$category->getId()] = $category->getParentId();
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Initialize attribute code to ID mapping for faster lookups.
    +     *
    +     * @return $this
    +     */
    +    protected function _initAttributeMapping(): self
    +    {
    +        foreach ($this->getAttributeCollection() as $attribute) {
    +            $this->_attributeCodeToId[$attribute->getAttributeCode()] = $attribute->getAttributeId();
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Initialize boolean attributes that should export values instead of labels
    +     *
    +     * @return $this
    +     */
    +    protected function _initBooleanAttributes(): self
    +    {
    +        foreach ($this->getAttributeCollection() as $attribute) {
    +            // Check if attribute uses boolean source model or has only Yes/No options
    +            if ($attribute->usesSource()) {
    +                $source = $attribute->getSource();
    +                $options = [];
    +
    +                try {
    +                    foreach ($source->getAllOptions(false) as $option) {
    +                        $innerOptions = is_array($option['value']) ? $option['value'] : [$option];
    +                        foreach ($innerOptions as $innerOption) {
    +                            if ($innerOption['value'] !== '' && $innerOption['value'] !== null) {
    +                                $options[$innerOption['value']] = $innerOption['label'];
    +                            }
    +                        }
    +                    }
    +
    +                    // If we have exactly 2 options with 0/1 values and Yes/No labels, treat as boolean
    +                    if (count($options) === 2 &&
    +                        ((isset($options['0'], $options['1']) &&
    +                          (($options['0'] === 'No' && $options['1'] === 'Yes') ||
    +                           ($options['0'] === 'Yes' && $options['1'] === 'No'))) ||
    +                         (isset($options[0], $options[1]) &&
    +                          (($options[0] === 'No' && $options[1] === 'Yes') ||
    +                           ($options[0] === 'Yes' && $options[1] === 'No'))))) {
    +                        $this->_indexValueAttributes[] = $attribute->getAttributeCode();
    +                    }
    +                } catch (Exception $e) {
    +                    // Skip attributes that can't provide options
    +                    continue;
    +                }
    +            }
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Export process.
    +     *
    +     * @return string
    +     */
    +    #[\Override]
    +    public function export()
    +    {
    +        // Prepare headers
    +        $writer = $this->getWriter();
    +        $validAttrCodes = $this->_getExportAttrCodes();
    +        $writer->setHeaderCols(array_merge(
    +            [self::COL_CATEGORY_ID, self::COL_PARENT_ID, self::COL_STORE],
    +            $validAttrCodes,
    +        ));
    +
    +        // Export data
    +        $this->_exportCategories();
    +
    +        return $writer->getContents();
    +    }
    +
    +    /**
    +     * Export data and return temporary file.
    +     *
    +     * @return array
    +     */
    +    #[\Override]
    +    public function exportFile()
    +    {
    +        $writer = $this->getWriter();
    +        $validAttrCodes = $this->_getExportAttrCodes();
    +
    +        $writer->setHeaderCols(array_merge(
    +            [self::COL_CATEGORY_ID, self::COL_PARENT_ID, self::COL_STORE],
    +            $validAttrCodes,
    +        ));
    +
    +        $this->_exportCategories();
    +
    +        $writeAdapter = $this->getWriter();
    +        if ($writeAdapter instanceof Mage_ImportExport_Model_Export_Adapter_Abstract) {
    +            return [
    +                'rows'  => $this->_processedEntitiesCount,
    +                'value' => $writeAdapter->getContents(),
    +                'type'  => 'string',
    +            ];
    +        }
    +
    +        return [];
    +    }
    +
    +    /**
    +     * Export categories data.
    +     */
    +    protected function _exportCategories(): void
    +    {
    +        /** @var Mage_Catalog_Model_Resource_Category_Collection $collection */
    +        $collection = $this->_prepareEntityCollection(
    +            Mage::getResourceModel('catalog/category_collection'),
    +        );
    +
    +        $collection->addAttributeToFilter('level', ['gt' => 0]) // Exclude root category
    +                   ->setOrder('level', 'ASC')
    +                   ->setOrder('position', 'ASC');
    +
    +        $validAttrCodes = $this->_getExportAttrCodes();
    +        $writer = $this->getWriter();
    +        $defaultStoreId = Mage_Catalog_Model_Abstract::DEFAULT_STORE_ID;
    +
    +        // Get category IDs for batch processing
    +        $categoryIds = [];
    +        foreach ($collection as $category) {
    +            $categoryIds[] = (int) $category->getId();
    +        }
    +
    +        // Preload all attribute data for all categories and stores
    +        $this->_preloadCategoryAttributeData($categoryIds, $validAttrCodes);
    +
    +        foreach ($collection as $category) {
    +            /** @var Mage_Catalog_Model_Category $category */
    +            $categoryId = (int) $category->getId();
    +            $parentId = $this->_categoryParents[$categoryId] ?? $category->getParentId();
    +
    +            // Export default store data first
    +            $dataRow = [
    +                self::COL_CATEGORY_ID => (string) $categoryId,
    +                self::COL_PARENT_ID => (string) $parentId,
    +                self::COL_STORE => '',
    +            ];
    +
    +            // Add attribute values for default store
    +            foreach ($validAttrCodes as $attrCode) {
    +                $attrValue = $this->_getCachedAttributeValue($categoryId, $attrCode, $defaultStoreId);
    +
    +                if ($attrValue !== null && $attrValue !== '') {
    +                    if (isset($this->_attributeValues[$attrCode][$attrValue])) {
    +                        $attrValue = $this->_attributeValues[$attrCode][$attrValue];
    +                    }
    +                    $dataRow[$attrCode] = $attrValue;
    +                } else {
    +                    $dataRow[$attrCode] = '';
    +                }
    +            }
    +
    +            $writer->writeRow($dataRow);
    +
    +            // Export store-specific data if different from default
    +            foreach ($this->_storeIdToCode as $storeId => $storeCode) {
    +                if ($storeId == $defaultStoreId) {
    +                    continue; // Already exported default
    +                }
    +
    +                $storeDataRow = [
    +                    self::COL_CATEGORY_ID => (string) $categoryId,
    +                    self::COL_PARENT_ID => (string) $parentId,
    +                    self::COL_STORE => $storeCode,
    +                ];
    +
    +                $hasStoreSpecificData = false;
    +
    +                // Add attribute values for this store
    +                foreach ($validAttrCodes as $attrCode) {
    +                    $storeValue = $this->_getCachedAttributeValue($categoryId, $attrCode, $storeId);
    +                    $defaultValue = $this->_getCachedAttributeValue($categoryId, $attrCode, $defaultStoreId);
    +
    +                    // Only include if different from default
    +                    if ($storeValue !== null && $storeValue !== '' && $storeValue != $defaultValue) {
    +                        if (isset($this->_attributeValues[$attrCode][$storeValue])) {
    +                            $storeValue = $this->_attributeValues[$attrCode][$storeValue];
    +                        }
    +                        $storeDataRow[$attrCode] = $storeValue;
    +                        $hasStoreSpecificData = true;
    +                    } else {
    +                        $storeDataRow[$attrCode] = '';
    +                    }
    +                }
    +
    +                // Only write store row if it has store-specific data
    +                if ($hasStoreSpecificData) {
    +                    $writer->writeRow($storeDataRow);
    +                }
    +            }
    +
    +            $this->_processedEntitiesCount++;
    +        }
    +    }
    +
    +    /**
    +     * Entity attributes collection getter.
    +     *
    +     * @return Mage_Catalog_Model_Resource_Category_Attribute_Collection
    +     */
    +    #[\Override]
    +    public function getAttributeCollection()
    +    {
    +        return Mage::getResourceModel('catalog/category_attribute_collection');
    +    }
    +
    +    /**
    +     * EAV entity type code getter.
    +     */
    +    #[\Override]
    +    public function getEntityTypeCode(): string
    +    {
    +        return 'catalog_category';
    +    }
    +
    +    /**
    +     * Refresh category paths after import or changes.
    +     *
    +     * @return $this
    +     */
    +    public function refreshCategoryParents(): self
    +    {
    +        $this->_categoryParents = [];
    +        $this->_initCategoryParents();
    +        return $this;
    +    }
    +
    +    /**
    +     * Preload all category attribute data for batch processing.
    +     */
    +    protected function _preloadCategoryAttributeData(array $categoryIds, array $attrCodes): void
    +    {
    +        if (empty($categoryIds) || empty($attrCodes)) {
    +            return;
    +        }
    +
    +        $connection = Mage::getSingleton('core/resource')->getConnection('core_read');
    +        $resource = Mage::getSingleton('core/resource');
    +
    +        // Group attributes by backend type to minimize queries
    +        $attributesByType = [];
    +        foreach ($this->getAttributeCollection() as $attribute) {
    +            $attrCode = $attribute->getAttributeCode();
    +            if (in_array($attrCode, $attrCodes)) {
    +                $backendType = $attribute->getBackendType();
    +                $attributesByType[$backendType][$attrCode] = $attribute->getAttributeId();
    +            }
    +        }
    +
    +        // Load data for each backend type in bulk
    +        foreach ($attributesByType as $backendType => $attributes) {
    +            $tableName = 'catalog_category_entity_' . $backendType;
    +            $table = $resource->getTableName($tableName);
    +            $attributeIds = array_values($attributes);
    +
    +            $select = $connection->select()
    +                ->from($table, ['entity_id', 'attribute_id', 'store_id', 'value'])
    +                ->where('entity_id IN (?)', $categoryIds)
    +                ->where('attribute_id IN (?)', $attributeIds);
    +
    +            $data = $connection->fetchAll($select);
    +
    +            // Organize data by category, attribute, and store
    +            foreach ($data as $row) {
    +                $categoryId = $row['entity_id'];
    +                $attributeId = $row['attribute_id'];
    +                $storeId = $row['store_id'];
    +                $value = $row['value'];
    +
    +                // Find attribute code by ID
    +                $attrCode = array_search($attributeId, $attributes);
    +                if ($attrCode !== false) {
    +                    $this->_categoryAttributeData[$categoryId][$attrCode][$storeId] = $value;
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Get cached attribute value with fallback logic.
    +     */
    +    protected function _getCachedAttributeValue(int $categoryId, string $attrCode, int $storeId): ?string
    +    {
    +        // Check for store-specific value
    +        if (isset($this->_categoryAttributeData[$categoryId][$attrCode][$storeId])) {
    +            $value = $this->_categoryAttributeData[$categoryId][$attrCode][$storeId];
    +            return $value !== '' ? (string) $value : null;
    +        }
    +
    +        // Fallback to admin store (0) if not found and current store is not admin
    +        if ($storeId != 0 && isset($this->_categoryAttributeData[$categoryId][$attrCode][0])) {
    +            $value = $this->_categoryAttributeData[$categoryId][$attrCode][0];
    +            return $value !== '' ? (string) $value : null;
    +        }
    +
    +        return null;
    +    }
    +}
    
  • app/code/core/Mage/ImportExport/Model/Import/Entity/Category.php+1000 0 added
    @@ -0,0 +1,1000 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/**
    + * Maho
    + *
    + * @package    Mage_ImportExport
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +class Mage_ImportExport_Model_Import_Entity_Category extends Mage_ImportExport_Model_Import_Entity_Abstract
    +{
    +    /**
    +     * Default Scope
    +     */
    +    public const SCOPE_DEFAULT = 1;
    +
    +    /**
    +     * Store Scope
    +     */
    +    public const SCOPE_STORE = 0;
    +
    +    /**
    +     * Null Scope
    +     */
    +    public const SCOPE_NULL = -1;
    +
    +    /**
    +     * Permanent column names.
    +     */
    +    public const COL_STORE = '_store';
    +    public const COL_CATEGORY_ID = 'category_id';
    +    public const COL_PARENT_ID = 'parent_id';
    +
    +    /**
    +     * Error codes.
    +     */
    +    public const ERROR_CATEGORY_PATH_EMPTY = 'categoryPathEmpty';
    +    public const ERROR_CATEGORY_PATH_INVALID = 'categoryPathInvalid';
    +    public const ERROR_PARENT_NOT_FOUND = 'parentNotFound';
    +    public const ERROR_CIRCULAR_REFERENCE = 'circularReference';
    +    public const ERROR_DUPLICATE_PATH = 'duplicatePath';
    +    public const ERROR_INVALID_NAME = 'invalidName';
    +    public const ERROR_INVALID_ATTRIBUTE_TYPE = 'invalidAttributeType';
    +    public const ERROR_MISSING_REQUIRED_ATTRIBUTE = 'missingRequiredAttribute';
    +    public const ERROR_DELETE_IDENTIFIER_MISSING = 'deleteIdentifierMissing';
    +    public const ERROR_CATEGORY_ID_INVALID = 'categoryIdInvalid';
    +
    +    /**
    +     * Permanent attributes.
    +     *
    +     * @var array
    +     */
    +    protected $_permanentAttributes = [self::COL_CATEGORY_ID, self::COL_PARENT_ID];
    +
    +    /**
    +     * Particular attributes.
    +     *
    +     * @var array
    +     */
    +    protected $_particularAttributes = [self::COL_STORE];
    +
    +    /**
    +     * Valid parent IDs cache.
    +     *
    +     * @var array
    +     */
    +    protected $_validParentIds = [];
    +
    +    /**
    +     * Existing category IDs.
    +     *
    +     * @var array
    +     */
    +    protected $_categoryIds = [];
    +
    +    /**
    +     * New categories to create.
    +     *
    +     * @var array
    +     */
    +    protected $_newCategories = [];
    +
    +    /**
    +     * Store codes to IDs.
    +     *
    +     * @var array
    +     */
    +    protected $_storeCodeToId = [];
    +
    +    /**
    +     * Default attribute set ID for categories.
    +     *
    +     * @var int
    +     */
    +    protected $_defaultAttributeSetId;
    +
    +    /**
    +     * Message templates.
    +     *
    +     * @var array
    +     */
    +    protected $_messageTemplates = [
    +        self::ERROR_CATEGORY_PATH_EMPTY => 'Category path is empty',
    +        self::ERROR_CATEGORY_PATH_INVALID => 'Category path "%s" is invalid',
    +        self::ERROR_PARENT_NOT_FOUND => 'Parent category for path "%s" not found',
    +        self::ERROR_CIRCULAR_REFERENCE => 'Circular reference detected in category path "%s"',
    +        self::ERROR_DUPLICATE_PATH => 'Duplicate category path "%s" found',
    +        self::ERROR_INVALID_NAME => 'Invalid category name for path "%s"',
    +        self::ERROR_INVALID_ATTRIBUTE_TYPE => 'Invalid value for attribute "%s"',
    +        self::ERROR_MISSING_REQUIRED_ATTRIBUTE => 'Required attribute "%s" is missing',
    +        self::ERROR_DELETE_IDENTIFIER_MISSING => 'For DELETE operations, either category_id or category_path must be provided',
    +        self::ERROR_CATEGORY_ID_INVALID => 'Category ID "%s" is invalid or does not exist',
    +    ];
    +
    +    /**
    +     * Constructor.
    +     */
    +    public function __construct()
    +    {
    +        parent::__construct();
    +
    +        $this->_initStores()
    +             ->_initCategories()
    +             ->_initAttributeSetId();
    +    }
    +
    +    /**
    +     * Initialize stores mapping.
    +     *
    +     * @return $this
    +     */
    +    protected function _initStores(): self
    +    {
    +        foreach (Mage::app()->getStores(true) as $store) {
    +            $this->_storeCodeToId[$store->getCode()] = (int) $store->getId();
    +        }
    +
    +        // If mapping is empty or missing 'default', query database directly
    +        if (empty($this->_storeCodeToId) || !isset($this->_storeCodeToId['default'])) {
    +            $stores = $this->_connection->fetchPairs(
    +                $this->_connection->select()
    +                    ->from($this->_connection->getTableName('core_store'), ['code', 'store_id']),
    +            );
    +            foreach ($stores as $code => $storeId) {
    +                $this->_storeCodeToId[$code] = (int) $storeId;
    +            }
    +        }
    +
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Initialize existing category IDs.
    +     *
    +     * @return $this
    +     */
    +    protected function _initCategories(): self
    +    {
    +        $select = $this->_connection->select()
    +            ->from(Mage::getSingleton('core/resource')->getTableName('catalog_category_entity'), ['entity_id', 'parent_id'])
    +            ->where('level > 0');
    +
    +        $categories = $this->_connection->fetchAll($select);
    +
    +        foreach ($categories as $category) {
    +            $categoryId = (int) $category['entity_id'];
    +            $parentId = (int) $category['parent_id'];
    +
    +            $this->_categoryIds[$categoryId] = $parentId;
    +            $this->_validParentIds[$categoryId] = true;
    +        }
    +
    +        // Add default category (ID 2) as a valid parent
    +        $this->_validParentIds[2] = true;
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Initialize default attribute set ID.
    +     *
    +     * @return $this
    +     */
    +    protected function _initAttributeSetId(): self
    +    {
    +        $entityType = Mage::getSingleton('eav/config')->getEntityType('catalog_category');
    +        $this->_defaultAttributeSetId = $entityType->getDefaultAttributeSetId();
    +        return $this;
    +    }
    +
    +    /**
    +     * Import data rows.
    +     */
    +    #[\Override]
    +    protected function _importData(): bool
    +    {
    +        if (Mage_ImportExport_Model_Import::BEHAVIOR_DELETE == $this->getBehavior()) {
    +            return $this->_deleteCategories();
    +        } elseif (Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE == $this->getBehavior()) {
    +            return $this->_saveAndReplaceCategories();
    +        } elseif (Mage_ImportExport_Model_Import::BEHAVIOR_APPEND == $this->getBehavior()) {
    +            return $this->_saveCategories();
    +        }
    +
    +        return false;
    +    }
    +
    +    /**
    +     * Save categories (create/update).
    +     */
    +    protected function _saveCategories(): bool
    +    {
    +        $entityTable = Mage::getSingleton('core/resource')->getTableName('catalog_category_entity');
    +
    +        while ($bunch = $this->_dataSourceModel->getNextBunch()) {
    +            // Refresh category mapping for each batch to pick up newly created categories
    +            $this->_initCategories();
    +            $entityRows = [];
    +            $entityRowsUp = [];
    +            $attributes = [];
    +            $storeRows = [];
    +
    +            foreach ($bunch as $rowNum => $rowData) {
    +                if (!$this->validateRow($rowData, $rowNum)) {
    +                    continue;
    +                }
    +
    +                $rowScope = $this->getRowScope($rowData);
    +
    +                if (self::SCOPE_DEFAULT == $rowScope) {
    +                    $categoryId = isset($rowData[self::COL_CATEGORY_ID]) ? trim($rowData[self::COL_CATEGORY_ID]) : '';
    +                    $parentId = isset($rowData[self::COL_PARENT_ID]) ? (int) trim($rowData[self::COL_PARENT_ID]) : null;
    +
    +                    if (!empty($categoryId)) {
    +                        // Update existing category
    +                        $categoryIdInt = (int) $categoryId;
    +                        if (isset($this->_categoryIds[$categoryIdInt])) {
    +                            $entityRowsUp[] = [
    +                                'entity_id' => $categoryIdInt,
    +                                'updated_at' => Mage_Core_Model_Locale::now(),
    +                            ];
    +
    +                            // Update parent if provided
    +                            if ($parentId !== null && $parentId !== $this->_categoryIds[$categoryIdInt]) {
    +                                $entityRowsUp[count($entityRowsUp) - 1]['parent_id'] = $parentId;
    +                            }
    +
    +                            $this->_collectAttributeData($rowData, $rowScope, $categoryIdInt, $attributes, true);
    +                        } else {
    +                            // Category ID provided but not in cache - check if it exists in database
    +                            $existingCategory = Mage::getModel('catalog/category')->load($categoryIdInt);
    +                            if ($existingCategory->getId()) {
    +                                // Category exists - add it to our cache and update it
    +                                $this->_categoryIds[$categoryIdInt] = $existingCategory->getParentId();
    +                                $this->_validParentIds[$categoryIdInt] = true;
    +
    +                                $entityRowsUp[] = [
    +                                    'entity_id' => $categoryIdInt,
    +                                    'updated_at' => Mage_Core_Model_Locale::now(),
    +                                ];
    +
    +                                // Update parent if provided
    +                                if ($parentId !== null && $parentId !== $existingCategory->getParentId()) {
    +                                    $entityRowsUp[count($entityRowsUp) - 1]['parent_id'] = $parentId;
    +                                }
    +
    +                                $this->_collectAttributeData($rowData, $rowScope, $categoryIdInt, $attributes, true);
    +                            } else {
    +                                // Category doesn't exist - create it with the specified ID
    +                                if ($parentId === null) {
    +                                    $parentId = 2; // Default category
    +                                }
    +
    +                                $entityRow = [
    +                                    'entity_id' => $categoryIdInt, // Use the specified ID
    +                                    'entity_type_id' => $this->_entityTypeId,
    +                                    'attribute_set_id' => $this->_defaultAttributeSetId,
    +                                    'parent_id' => $parentId,
    +                                    'position' => $this->_getNextPosition($parentId),
    +                                    'level' => $this->_getCategoryLevel($parentId) + 1,
    +                                    'children_count' => 0,
    +                                    'created_at' => Mage_Core_Model_Locale::now(),
    +                                    'updated_at' => Mage_Core_Model_Locale::now(),
    +                                ];
    +
    +                                // Store row data to collect attributes after insertion
    +                                $entityRow['_temp_row_data'] = $rowData;
    +                                $entityRow['_temp_row_scope'] = $rowScope;
    +                                $entityRows[] = $entityRow;
    +
    +                                // Add to our cache so future references work
    +                                $this->_categoryIds[$categoryIdInt] = $parentId;
    +                                $this->_validParentIds[$categoryIdInt] = true;
    +                            }
    +                        }
    +                    } else {
    +                        // Create new category
    +                        if ($parentId === null) {
    +                            $parentId = 2; // Default category
    +                        }
    +
    +                        $entityRow = [
    +                            'entity_type_id' => $this->_entityTypeId,
    +                            'attribute_set_id' => $this->_defaultAttributeSetId,
    +                            'parent_id' => $parentId,
    +                            'position' => $this->_getNextPosition($parentId),
    +                            'level' => $this->_getCategoryLevel($parentId) + 1,
    +                            'children_count' => 0,
    +                            'created_at' => Mage_Core_Model_Locale::now(),
    +                            'updated_at' => Mage_Core_Model_Locale::now(),
    +                        ];
    +
    +                        // Store row data to collect attributes after insertion
    +                        $entityRow['_temp_row_data'] = $rowData;
    +                        $entityRow['_temp_row_scope'] = $rowScope;
    +                        $entityRows[] = $entityRow;
    +                    }
    +                } else {
    +                    // Store scope rows for later processing
    +                    $storeRows[] = [
    +                        'rowData' => $rowData,
    +                        'rowScope' => $rowScope,
    +                    ];
    +                }
    +            }
    +
    +            // Insert new categories
    +            if ($entityRows) {
    +                $newCategoryAttributes = $this->_insertCategories($entityRows);
    +                // Merge new category attributes with existing attributes
    +                $attributes = array_merge_recursive($attributes, $newCategoryAttributes);
    +            }
    +
    +            // Update existing categories
    +            if ($entityRowsUp) {
    +                $this->_connection->insertOnDuplicate($entityTable, $entityRowsUp, ['updated_at', 'parent_id']);
    +            }
    +
    +            // Process store scope rows
    +            foreach ($storeRows as $storeRowInfo) {
    +                $rowData = $storeRowInfo['rowData'];
    +                $rowScope = $storeRowInfo['rowScope'];
    +
    +                // For store rows, we need to find the category ID
    +                $categoryId = null;
    +                if (isset($rowData[self::COL_CATEGORY_ID]) && !empty(trim($rowData[self::COL_CATEGORY_ID]))) {
    +                    $categoryId = (int) trim($rowData[self::COL_CATEGORY_ID]);
    +                }
    +
    +                if ($categoryId && isset($this->_categoryIds[$categoryId])) {
    +                    $this->_collectAttributeData($rowData, $rowScope, $categoryId, $attributes, true);
    +                }
    +            }
    +
    +            // Save attributes
    +            $this->_saveAttributes($attributes);
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Get category level by parent ID.
    +     */
    +    protected function _getCategoryLevel(int $parentId): int
    +    {
    +        $select = $this->_connection->select()
    +            ->from(Mage::getSingleton('core/resource')->getTableName('catalog_category_entity'), 'level')
    +            ->where('entity_id = ?', $parentId);
    +
    +        return (int) $this->_connection->fetchOne($select);
    +    }
    +
    +    /**
    +     * Get next position for category under parent.
    +     */
    +    protected function _getNextPosition(int $parentId): int
    +    {
    +        $select = $this->_connection->select()
    +            ->from(Mage::getSingleton('core/resource')->getTableName('catalog_category_entity'), 'MAX(position)')
    +            ->where('parent_id = ?', $parentId);
    +
    +        $maxPosition = (int) $this->_connection->fetchOne($select);
    +        return $maxPosition + 1;
    +    }
    +
    +    /**
    +     * Insert new categories.
    +     */
    +    protected function _insertCategories(array $entityRows): array
    +    {
    +        $entityTable = Mage::getSingleton('core/resource')->getTableName('catalog_category_entity');
    +        $newCategoryAttributes = [];
    +
    +        foreach ($entityRows as &$row) {
    +            // Extract temporary data before database insert
    +            $tempRowData = $row['_temp_row_data'] ?? null;
    +            $tempRowScope = $row['_temp_row_scope'] ?? null;
    +            unset($row['_temp_row_data'], $row['_temp_row_scope']);
    +
    +            $this->_connection->insert($entityTable, $row);
    +
    +            // Use specified entity_id if provided, otherwise use auto-generated ID
    +            $entityId = isset($row['entity_id']) ? (int) $row['entity_id'] : (int) $this->_connection->lastInsertId();
    +
    +            // Update category ID cache
    +            $this->_categoryIds[$entityId] = $row['parent_id'];
    +            $this->_validParentIds[$entityId] = true;
    +
    +            // Update path
    +            $parentPath = '';
    +            if ($row['parent_id'] != Mage_Catalog_Model_Category::TREE_ROOT_ID) {
    +                $parentPath = $this->_getPathById($row['parent_id']);
    +            }
    +            $path = $parentPath ? $parentPath . '/' . $entityId : $entityId;
    +
    +            $this->_connection->update(
    +                $entityTable,
    +                ['path' => $path],
    +                ['entity_id = ?' => $entityId],
    +            );
    +
    +            $row['entity_id'] = $entityId;
    +            $this->_processedEntitiesCount++;
    +
    +            // Collect attributes for this newly created category
    +            if ($tempRowData && $tempRowScope) {
    +                $this->_collectAttributeData($tempRowData, $tempRowScope, $entityId, $newCategoryAttributes, false);
    +            }
    +        }
    +
    +        return $newCategoryAttributes;
    +    }
    +
    +    /**
    +     * Get path by category ID.
    +     */
    +    protected function _getPathById(int $categoryId): string
    +    {
    +        $select = $this->_connection->select()
    +            ->from(Mage::getSingleton('core/resource')->getTableName('catalog_category_entity'), 'path')
    +            ->where('entity_id = ?', $categoryId);
    +
    +        return (string) $this->_connection->fetchOne($select);
    +    }
    +
    +    /**
    +     * Collect attribute data for saving.
    +     */
    +    protected function _collectAttributeData(array $rowData, int $rowScope, int|string $categoryIdentifier, array &$attributes, bool $categoryExists = true): void
    +    {
    +        $storeId = Mage_Catalog_Model_Abstract::DEFAULT_STORE_ID;
    +
    +        if (self::SCOPE_STORE == $rowScope && !empty($rowData[self::COL_STORE])) {
    +            $storeCode = $rowData[self::COL_STORE];
    +
    +            // Ensure store mapping is initialized
    +            if (empty($this->_storeCodeToId)) {
    +                $this->_initStores();
    +            }
    +
    +            // Manual fallback for common store codes (workaround for initialization issues)
    +            if (empty($this->_storeCodeToId) || !isset($this->_storeCodeToId[$storeCode])) {
    +                $storeMapping = [
    +                    'admin' => 0,
    +                    'default' => 1,
    +                ];
    +                $mappedId = $storeMapping[$storeCode] ?? null;
    +            } else {
    +                $mappedId = $this->_storeCodeToId[$storeCode] ?? null;
    +            }
    +
    +            // Skip invalid store codes entirely instead of falling back to default
    +            if ($mappedId === null) {
    +                return; // Skip this row
    +            }
    +
    +            $storeId = $mappedId;
    +        }
    +
    +        if ($categoryExists) {
    +            if (is_int($categoryIdentifier)) {
    +                $entityId = $categoryIdentifier;
    +            } else {
    +                return; // Invalid identifier
    +            }
    +        } else {
    +            // For new categories, use the temporary identifier
    +            $entityId = $categoryIdentifier;
    +        }
    +
    +        // Generate url_key if not provided and we have a name
    +        if (!isset($rowData['url_key']) && !empty($rowData['name'])) {
    +            $rowData['url_key'] = $this->_formatUrlKey($rowData['name']);
    +        }
    +
    +        foreach ($rowData as $attrCode => $value) {
    +            // Skip system columns and null values (but allow empty strings)
    +            if (in_array($attrCode, [self::COL_PARENT_ID, self::COL_STORE]) || is_null($value)) {
    +                continue;
    +            }
    +
    +            if (!isset($attributes[$attrCode])) {
    +                $attributes[$attrCode] = [];
    +            }
    +
    +            $attributeId = $this->_getAttributeId($attrCode);
    +            if (!$attributeId) {
    +                continue;
    +            }
    +
    +            // Convert export labels back to database values
    +            $value = $this->_convertLabelToValue($attrCode, $value);
    +
    +            $attributes[$attrCode][] = [
    +                'entity_type_id' => $this->_entityTypeId,
    +                'entity_id' => $entityId,
    +                'attribute_id' => $attributeId,
    +                'store_id' => $storeId,
    +                'value' => $value,
    +            ];
    +
    +        }
    +    }
    +
    +    /**
    +     * Convert export labels back to database values.
    +     */
    +    protected function _convertLabelToValue(string $attrCode, mixed $value): mixed
    +    {
    +        if (empty($value) || !is_string($value)) {
    +            return $value;
    +        }
    +
    +        // Handle display_mode attribute specifically
    +        if ($attrCode === 'display_mode') {
    +            $labelToValueMap = [
    +                'Products only' => 'PRODUCTS',
    +                'Static block only' => 'PAGE',
    +                'Static block and products' => 'PRODUCTS_AND_PAGE',
    +            ];
    +
    +            return $labelToValueMap[$value] ?? $value;
    +        }
    +
    +        // Handle other select/multiselect attributes by getting their source model
    +        $attribute = Mage::getSingleton('eav/config')->getAttribute('catalog_category', $attrCode);
    +        if ($attribute && $attribute->usesSource()) {
    +            try {
    +                $source = $attribute->getSource();
    +                $options = [];
    +
    +                foreach ($source->getAllOptions() as $option) {
    +                    $innerOptions = is_array($option['value']) ? $option['value'] : [$option];
    +                    foreach ($innerOptions as $innerOption) {
    +                        if (isset($innerOption['value']) && isset($innerOption['label'])) {
    +                            $options[$innerOption['label']] = $innerOption['value'];
    +                        }
    +                    }
    +                }
    +
    +                // If we found a matching label, return its value
    +                if (isset($options[$value])) {
    +                    return $options[$value];
    +                }
    +            } catch (Exception $e) {
    +                // If we can't get options, return the original value
    +            }
    +        }
    +
    +        return $value;
    +    }
    +
    +    /**
    +     * Get attribute ID by code.
    +     */
    +    protected function _getAttributeId(string $attrCode): ?int
    +    {
    +        $attribute = Mage::getSingleton('eav/config')->getAttribute('catalog_category', $attrCode);
    +        return $attribute ? $attribute->getId() : null;
    +    }
    +
    +    /**
    +     * Save attributes.
    +     */
    +    protected function _saveAttributes(array $attributes): void
    +    {
    +        foreach ($attributes as $attrCode => $attrData) {
    +            if (empty($attrData)) {
    +                continue;
    +            }
    +
    +            // Skip any attributes with temporary identifiers (should not happen in new approach)
    +            $validAttrData = [];
    +            foreach ($attrData as $attrRow) {
    +                $entityId = $attrRow['entity_id'];
    +                if (is_numeric($entityId) && (int) $entityId > 0) {
    +                    $validAttrData[] = $attrRow;
    +                }
    +                // Skip any remaining temporary identifiers from old logic
    +            }
    +
    +            if (empty($validAttrData)) {
    +                continue; // No valid attributes to save for this code
    +            }
    +
    +            $attrData = $validAttrData;
    +
    +            $attribute = Mage::getSingleton('eav/config')->getAttribute('catalog_category', $attrCode);
    +            if (!$attribute) {
    +                continue;
    +            }
    +
    +            // Skip static attributes - they're stored in the main entity table, not as EAV
    +            if ($attribute->getBackendType() === 'static') {
    +                continue;
    +            }
    +
    +            $tableName = $attribute->getBackendTable();
    +            if ($tableName) {
    +                // Debug: Log what we're trying to save
    +                if (defined('MAHO_DEBUG_IMPORT') && $attrCode === 'name') {
    +                    Mage::log("Saving $attrCode to $tableName: " . json_encode($attrData), Mage::LOG_DEBUG);
    +                }
    +
    +                $result = $this->_connection->insertOnDuplicate($tableName, $attrData, ['value']);
    +
    +                if (defined('MAHO_DEBUG_IMPORT') && $attrCode === 'name') {
    +                    Mage::log("InsertOnDuplicate result: $result", Mage::LOG_DEBUG);
    +                }
    +            }
    +        }
    +    }
    +
    +    /**
    +     * Delete categories.
    +     */
    +    protected function _deleteCategories(): bool
    +    {
    +        $entityTable = Mage::getSingleton('core/resource')->getTableName('catalog_category_entity');
    +
    +        while ($bunch = $this->_dataSourceModel->getNextBunch()) {
    +            $idsToDelete = [];
    +
    +            foreach ($bunch as $rowNum => $rowData) {
    +                if (!$this->validateRow($rowData, $rowNum)) {
    +                    continue;
    +                }
    +
    +                $rowScope = $this->getRowScope($rowData);
    +                if (self::SCOPE_DEFAULT == $rowScope) {
    +                    // Use category_id for deletion (required in new parent_id approach)
    +                    if (isset($rowData[self::COL_CATEGORY_ID]) && !empty(trim($rowData[self::COL_CATEGORY_ID]))) {
    +                        $categoryId = (int) trim($rowData[self::COL_CATEGORY_ID]);
    +                        // Verify the category exists before adding to delete list
    +                        if (isset($this->_categoryIds[$categoryId]) && $categoryId > 2) { // Don't delete root or default category
    +                            $idsToDelete[] = $categoryId;
    +                        }
    +                    }
    +                }
    +            }
    +
    +            if ($idsToDelete) {
    +                // Expand IDs to include child categories for cascade deletion
    +                $allIdsToDelete = $this->_expandIdsWithChildren($idsToDelete);
    +                $this->_connection->delete($entityTable, ['entity_id IN (?)' => $allIdsToDelete]);
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Expand category IDs to include all child categories for cascade deletion.
    +     */
    +    protected function _expandIdsWithChildren(array $categoryIds): array
    +    {
    +        $allIds = $categoryIds;
    +
    +        // Get all categories to check for children
    +        $collection = Mage::getResourceModel('catalog/category_collection')
    +            ->addAttributeToSelect(['path'])
    +            ->addAttributeToFilter('level', ['gt' => 0]);
    +
    +        foreach ($categoryIds as $categoryId) {
    +            // Find all categories that have this category in their path (i.e., are children)
    +            foreach ($collection as $category) {
    +                $path = $category->getPath();
    +                $pathIds = explode('/', $path);
    +
    +                // If this category ID is in the path (and it's not the category itself)
    +                if (in_array((string) $categoryId, $pathIds) && $category->getId() != $categoryId) {
    +                    $allIds[] = (int) $category->getId();
    +                }
    +            }
    +        }
    +
    +        return array_unique($allIds);
    +    }
    +
    +    /**
    +     * Save and replace categories.
    +     * REPLACE behavior: Same as APPEND for categories - update existing, create new if needed
    +     */
    +    protected function _saveAndReplaceCategories(): bool
    +    {
    +        // For categories, REPLACE works exactly the same as APPEND
    +        // Both behaviors: update existing categories, create new ones if they don't exist
    +        return $this->_saveCategories();
    +    }
    +
    +
    +    /**
    +     * Obtain scope of the row from row data.
    +     */
    +    public function getRowScope(array $rowData): int
    +    {
    +        $hasPath = isset($rowData[self::COL_PARENT_ID]) && strlen(trim($rowData[self::COL_PARENT_ID]));
    +        $hasCategoryId = isset($rowData[self::COL_CATEGORY_ID]) && strlen(trim($rowData[self::COL_CATEGORY_ID]));
    +        $hasStore = !empty($rowData[self::COL_STORE]);
    +
    +        // For DELETE behavior, allow category_id as identifier for SCOPE_DEFAULT
    +        if (Mage_ImportExport_Model_Import::BEHAVIOR_DELETE == $this->getBehavior()) {
    +            if (($hasPath || $hasCategoryId) && !$hasStore) {
    +                return self::SCOPE_DEFAULT;  // Delete operation with identifier
    +            } elseif ($hasStore) {
    +                return self::SCOPE_STORE;    // Store-specific delete (though not typically used)
    +            } else {
    +                return self::SCOPE_NULL;     // Invalid delete row
    +            }
    +        }
    +
    +        // For non-DELETE behaviors, accept either parent_id (for new categories) or category_id (for updates)
    +        if (($hasPath || $hasCategoryId) && !$hasStore) {
    +            return self::SCOPE_DEFAULT;  // New category or default store update
    +        } elseif ($hasStore) {
    +            return self::SCOPE_STORE;    // Store-specific data (with or without path)
    +        } else {
    +            return self::SCOPE_NULL;     // Invalid row
    +        }
    +    }
    +
    +    /**
    +     * Validate data row.
    +     *
    +     * @param int $rowNum
    +     */
    +    #[\Override]
    +    public function validateRow(array $rowData, $rowNum): bool
    +    {
    +        // Handle DELETE behavior separately with different validation rules
    +        if (Mage_ImportExport_Model_Import::BEHAVIOR_DELETE == $this->getBehavior()) {
    +            return $this->_validateDeleteRow($rowData, $rowNum);
    +        }
    +
    +        $rowScope = $this->getRowScope($rowData);
    +
    +        // Check for invalid row scope
    +        if (self::SCOPE_NULL == $rowScope) {
    +            $this->addRowError(self::ERROR_CATEGORY_PATH_EMPTY, $rowNum);
    +            return false;
    +        }
    +
    +        if (self::SCOPE_DEFAULT == $rowScope) {
    +            // For new categories, validate parent_id
    +            $parentId = isset($rowData[self::COL_PARENT_ID]) ? trim($rowData[self::COL_PARENT_ID]) : '';
    +
    +            // For new categories (no category_id), parent_id is required
    +            $categoryId = isset($rowData[self::COL_CATEGORY_ID]) ? trim($rowData[self::COL_CATEGORY_ID]) : '';
    +            if (empty($categoryId) && empty($parentId)) {
    +                $this->addRowError(self::ERROR_PARENT_NOT_FOUND, $rowNum);
    +                return false;
    +            }
    +
    +            // If parent_id is provided, validate it exists
    +            if (!empty($parentId)) {
    +                $parentIdInt = (int) $parentId;
    +                if ($parentIdInt <= 0 || !isset($this->_validParentIds[$parentIdInt])) {
    +                    $this->addRowError(self::ERROR_PARENT_NOT_FOUND, $rowNum);
    +                    return false;
    +                }
    +            }
    +
    +            // Check if name is missing or empty (required for non-DELETE behaviors)
    +            if (!isset($rowData['name']) || empty(trim($rowData['name']))) {
    +                $this->addRowError(self::ERROR_INVALID_NAME, $rowNum, $categoryId ?: $parentId);
    +                return false;
    +            }
    +
    +            // Validate attribute data types
    +            if (!$this->_validateAttributeTypes($rowData, $rowNum, $categoryId ?: $parentId)) {
    +                return false;
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Validate row for DELETE behavior.
    +     * For DELETE, we need either category_id or category_path, but not name or other attributes.
    +     */
    +    protected function _validateDeleteRow(array $rowData, int $rowNum): bool
    +    {
    +        $hasCategoryId = isset($rowData[self::COL_CATEGORY_ID]) && !empty(trim($rowData[self::COL_CATEGORY_ID]));
    +        $hasCategoryPath = isset($rowData[self::COL_PARENT_ID]) && !empty(trim($rowData[self::COL_PARENT_ID]));
    +
    +        // Must have either category_id or category_path for DELETE
    +        if (!$hasCategoryId && !$hasCategoryPath) {
    +            $this->addRowError(self::ERROR_DELETE_IDENTIFIER_MISSING, $rowNum);
    +            return false;
    +        }
    +
    +        // If category_id is provided, validate it
    +        if ($hasCategoryId) {
    +            $categoryId = trim($rowData[self::COL_CATEGORY_ID]);
    +            if (!is_numeric($categoryId) || (int) $categoryId <= 2) { // Can't delete root or default category
    +                $this->addRowError(self::ERROR_CATEGORY_ID_INVALID, $rowNum, $categoryId);
    +                return false;
    +            }
    +
    +            // Check if category exists
    +            $category = Mage::getModel('catalog/category')->load((int) $categoryId);
    +            if (!$category->getId()) {
    +                $this->addRowError(self::ERROR_CATEGORY_ID_INVALID, $rowNum, $categoryId);
    +                return false;
    +            }
    +        }
    +
    +        // If category_path is provided (fallback), validate it using existing path validation
    +        if (!$hasCategoryId && $hasCategoryPath) {
    +            $categoryPath = trim($rowData[self::COL_PARENT_ID]);
    +            if (!$this->_isValidCategoryPath($categoryPath)) {
    +                $this->addRowError(self::ERROR_CATEGORY_PATH_INVALID, $rowNum, $categoryPath);
    +                return false;
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /**
    +     * Validate attribute data types.
    +     */
    +    protected function _validateAttributeTypes(array $rowData, int $rowNum, string $categoryPath): bool
    +    {
    +        $valid = true;
    +
    +        // Validate boolean attributes
    +        $booleanAttrs = ['is_active', 'include_in_menu', 'is_anchor'];
    +        foreach ($booleanAttrs as $attrCode) {
    +            if (isset($rowData[$attrCode]) && !empty($rowData[$attrCode])) {
    +                $value = trim($rowData[$attrCode]);
    +                // Accept 0, 1, '0', '1', 'true', 'false', 'yes', 'no'
    +                if (!in_array(strtolower($value), ['0', '1', 'true', 'false', 'yes', 'no'], true)) {
    +                    $this->addRowError(self::ERROR_INVALID_ATTRIBUTE_TYPE, $rowNum, $attrCode);
    +                    $valid = false;
    +                }
    +            }
    +        }
    +
    +        // Validate display_mode attribute
    +        if (isset($rowData['display_mode']) && !empty($rowData['display_mode'])) {
    +            $value = trim($rowData['display_mode']);
    +            $validDisplayModes = [
    +                'PRODUCTS', 'PAGE', 'PRODUCTS_AND_PAGE', // Accept database values
    +                'Products only', 'Static block only', 'Static block and products', // Accept export labels
    +            ];
    +            if (!in_array($value, $validDisplayModes, true)) {
    +                $this->addRowError(self::ERROR_INVALID_ATTRIBUTE_TYPE, $rowNum, 'display_mode');
    +                $valid = false;
    +            }
    +        }
    +
    +        // Validate numeric attributes
    +        $numericAttrs = ['position', 'sort_order'];
    +        foreach ($numericAttrs as $attrCode) {
    +            if (isset($rowData[$attrCode]) && !empty($rowData[$attrCode])) {
    +                $value = trim($rowData[$attrCode]);
    +                if (!is_numeric($value)) {
    +                    $this->addRowError(self::ERROR_INVALID_ATTRIBUTE_TYPE, $rowNum, $attrCode);
    +                    $valid = false;
    +                }
    +            }
    +        }
    +
    +        return $valid;
    +    }
    +
    +    /**
    +     * Check if category path is valid.
    +     */
    +    protected function _isValidCategoryPath(string $categoryPath): bool
    +    {
    +        return (bool) preg_match('/^[a-z0-9\-_\/]+$/', $categoryPath);
    +    }
    +
    +    /**
    +     * Check if parent category exists or can be created.
    +     */
    +    protected function _hasValidParent(string $categoryPath): bool
    +    {
    +        $pathParts = explode('/', $categoryPath);
    +
    +        if (count($pathParts) <= 1) {
    +            return true; // Root level
    +        }
    +
    +        array_pop($pathParts);
    +        $parentPath = implode('/', $pathParts);
    +
    +        // Check if parent exists in database
    +        if (isset($this->_pathToId[$parentPath])) {
    +            return true;
    +        }
    +
    +        // Check if parent is being created in this import batch
    +        if (isset($this->_newCategories[$parentPath])) {
    +            return true;
    +        }
    +
    +        return false;
    +    }
    +
    +    /**
    +     * Format string as URL key.
    +     */
    +    protected function _formatUrlKey(string $name): string
    +    {
    +        return strtolower(preg_replace('/[^a-zA-Z0-9-_]/', '-', trim($name)));
    +    }
    +
    +    /**
    +     * Validate data rows and create new category paths mapping.
    +     *
    +     * @return Mage_ImportExport_Model_Import_Entity_Abstract
    +     */
    +    #[\Override]
    +    public function validateData()
    +    {
    +        // For DELETE behavior, adjust permanent attributes to allow either category_id or parent_id
    +        if (Mage_ImportExport_Model_Import::BEHAVIOR_DELETE == $this->getBehavior()) {
    +
    +            $originalPermanentAttributes = $this->_permanentAttributes;
    +
    +            // Check if we have either category_id or category_path column
    +            $columns = $this->_getSource()->getColNames();
    +            if (in_array(self::COL_CATEGORY_ID, $columns) || in_array(self::COL_PARENT_ID, $columns)) {
    +                // Temporarily allow validation to pass - we'll validate in _validateDeleteRow
    +                $this->_permanentAttributes = [];
    +                parent::validateData();
    +                $this->_permanentAttributes = $originalPermanentAttributes;
    +                return $this;
    +            } else {
    +                // Neither column present - add a custom error and return this
    +                $this->addRowError(self::ERROR_DELETE_IDENTIFIER_MISSING, 0);
    +                $this->_permanentAttributes = $originalPermanentAttributes;
    +                return $this;
    +            }
    +        }
    +
    +        // For non-DELETE behaviors, require parent_id for new categories
    +        return parent::validateData();
    +    }
    +
    +    /**
    +     * Collect new category paths from import data for validation.
    +     */
    +    protected function _collectNewCategoryPaths(): void
    +    {
    +        $source = $this->_getSource();
    +        $source->rewind();
    +
    +        while ($source->valid()) {
    +            $rowData = $source->current();
    +            if (isset($rowData[self::COL_PARENT_ID]) && !empty($rowData[self::COL_PARENT_ID])) {
    +                $categoryPath = trim($rowData[self::COL_PARENT_ID]);
    +                if ($categoryPath && $this->getRowScope($rowData) == self::SCOPE_DEFAULT) {
    +                    $this->_newCategories[$categoryPath] = true;
    +                }
    +            }
    +            $source->next();
    +        }
    +
    +        $source->rewind(); // Reset for normal validation
    +    }
    +
    +    /**
    +     * EAV entity type code getter.
    +     */
    +    #[\Override]
    +    public function getEntityTypeCode(): string
    +    {
    +        return 'catalog_category';
    +    }
    +}
    
  • app/design/adminhtml/default/default/template/importexport/export/form/before.phtml+49 17 modified
    @@ -20,22 +20,36 @@
          */
         varienForm.prototype.getFilter = function()
         {
    -        if ($('entity') && $F('entity')) {
    -            var url = "<?= $this->getUrl('*/*/getFilter') ?>";
    -            url += ((url.slice(-1) != '/') ? '/' : '') + 'entity/' + $F('entity');
    +        const entityElement = document.getElementById('entity');
    +        if (entityElement && entityElement.value) {
    +            let url = "<?= $this->getUrl('*/*/getFilter') ?>";
    +            url += ((url.slice(-1) !== '/') ? '/' : '') + 'entity/' + entityElement.value;
     
    -            new Ajax.Request(url, {
    -                method:      'post',
    -                //parameters:  $(this.formId).serialize(),
    -                evalScripts: true,
    -                onComplete:  function(transport) {
    -                    var responseText = transport.responseText.replace(/>\s+</g, '><');
    -                    $('export_filter_grid_container').update(responseText);
    -                    $('export_filter_container').show();
    +            mahoFetch(url, {
    +                method: 'POST',
    +                headers: {
    +                    'Content-Type': 'application/x-www-form-urlencoded',
    +                },
    +            })
    +            .then(responseText => {
    +                const cleanedResponse = responseText.replace(/>\s+</g, '><');
    +                const container = document.getElementById('export_filter_grid_container');
    +                if (container) {
    +                    container.innerHTML = cleanedResponse;
                     }
    +                const filterContainer = document.getElementById('export_filter_container');
    +                if (filterContainer) {
    +                    filterContainer.style.display = 'block';
    +                }
    +            })
    +            .catch(error => {
    +                console.error('Filter request failed:', error);
                 });
             } else {
    -            $('export_filter_container').hide();
    +            const filterContainer = document.getElementById('export_filter_container');
    +            if (filterContainer) {
    +                filterContainer.style.display = 'none';
    +            }
             }
         };
     
    @@ -46,12 +60,30 @@
          */
         function getFile()
         {
    -        if ($('entity') && $('file_format')) {
    -            var form      = $('export_filter_form');
    -            var oldAction = form.action;
    -            form.action   = oldAction + ((oldAction.slice(-1) != '/') ? '/' : '') + 'entity/' + $F('entity') + '/file_format/' + $F('file_format');
    +        const entityElement = document.getElementById('entity');
    +        const fileFormatElement = document.getElementById('file_format');
    +        
    +        if (entityElement && fileFormatElement) {
    +            const form = document.getElementById('export_filter_form');
    +            const oldAction = form.action;
    +            
    +            // Add entity and file_format as hidden inputs to the filter form
    +            const entityInput = document.createElement('input');
    +            entityInput.type = 'hidden';
    +            entityInput.name = 'entity';
    +            entityInput.value = entityElement.value;
    +            form.appendChild(entityInput);
    +            
    +            const fileFormatInput = document.createElement('input');
    +            fileFormatInput.type = 'hidden';
    +            fileFormatInput.name = 'file_format';
    +            fileFormatInput.value = fileFormatElement.value;
    +            form.appendChild(fileFormatInput);
    +            
    +            // Submit the complete form with all filter data
    +            form.action = '<?= $this->getUrl('*/*/export') ?>';
                 form.submit();
    -            form.action   = oldAction;
    +            form.action = oldAction;
             } else {
                 alert('<?= $this->jsQuoteEscape($this->__('Invalid data')) ?>');
             }
    
  • app/locale/en_US/Mage_ImportExport.csv+1 0 modified
    @@ -13,6 +13,7 @@
     "Can not determine attribute filter type","Can not determine attribute filter type"
     "Can not find required columns: %s","Can not find required columns: %s"
     "Cannot get autoincrement value","Cannot get autoincrement value"
    +"Categories","Categories"
     "Check Data","Check Data"
     "Checked rows: %d, checked entities: %d, invalid rows: %d, total errors: %d","Checked rows: %d, checked entities: %d, invalid rows: %d, total errors: %d"
     "Column names: ""%s"" are invalid","Column names: ""%s"" are invalid"
    
  • .phpstorm.meta.php/models.meta.php+4 0 modified
    @@ -1421,6 +1421,7 @@
             'importexport/export_adapter_abstract' => \Mage_ImportExport_Model_Export_Adapter_Abstract::class,
             'importexport/export_adapter_csv' => \Mage_ImportExport_Model_Export_Adapter_Csv::class,
             'importexport/export_entity_abstract' => \Mage_ImportExport_Model_Export_Entity_Abstract::class,
    +        'importexport/export_entity_category' => \Mage_ImportExport_Model_Export_Entity_Category::class,
             'importexport/export_entity_customer' => \Mage_ImportExport_Model_Export_Entity_Customer::class,
             'importexport/export_entity_product' => \Mage_ImportExport_Model_Export_Entity_Product::class,
             'importexport/export_entity_product_type_abstract' => \Mage_ImportExport_Model_Export_Entity_Product_Type_Abstract::class,
    @@ -1432,6 +1433,7 @@
             'importexport/import_adapter_abstract' => \Mage_ImportExport_Model_Import_Adapter_Abstract::class,
             'importexport/import_adapter_csv' => \Mage_ImportExport_Model_Import_Adapter_Csv::class,
             'importexport/import_entity_abstract' => \Mage_ImportExport_Model_Import_Entity_Abstract::class,
    +        'importexport/import_entity_category' => \Mage_ImportExport_Model_Import_Entity_Category::class,
             'importexport/import_entity_customer' => \Mage_ImportExport_Model_Import_Entity_Customer::class,
             'importexport/import_entity_customer_address' => \Mage_ImportExport_Model_Import_Entity_Customer_Address::class,
             'importexport/import_entity_product' => \Mage_ImportExport_Model_Import_Entity_Product::class,
    @@ -4102,6 +4104,7 @@
             'importexport/export_adapter_abstract' => \Mage_ImportExport_Model_Export_Adapter_Abstract::class,
             'importexport/export_adapter_csv' => \Mage_ImportExport_Model_Export_Adapter_Csv::class,
             'importexport/export_entity_abstract' => \Mage_ImportExport_Model_Export_Entity_Abstract::class,
    +        'importexport/export_entity_category' => \Mage_ImportExport_Model_Export_Entity_Category::class,
             'importexport/export_entity_customer' => \Mage_ImportExport_Model_Export_Entity_Customer::class,
             'importexport/export_entity_product' => \Mage_ImportExport_Model_Export_Entity_Product::class,
             'importexport/export_entity_product_type_abstract' => \Mage_ImportExport_Model_Export_Entity_Product_Type_Abstract::class,
    @@ -4113,6 +4116,7 @@
             'importexport/import_adapter_abstract' => \Mage_ImportExport_Model_Import_Adapter_Abstract::class,
             'importexport/import_adapter_csv' => \Mage_ImportExport_Model_Import_Adapter_Csv::class,
             'importexport/import_entity_abstract' => \Mage_ImportExport_Model_Import_Entity_Abstract::class,
    +        'importexport/import_entity_category' => \Mage_ImportExport_Model_Import_Entity_Category::class,
             'importexport/import_entity_customer' => \Mage_ImportExport_Model_Import_Entity_Customer::class,
             'importexport/import_entity_customer_address' => \Mage_ImportExport_Model_Import_Entity_Customer_Address::class,
             'importexport/import_entity_product' => \Mage_ImportExport_Model_Import_Entity_Product::class,
    
  • tests/Backend/Integration/ImportExport/CategoryExportTest.php+322 0 added
    @@ -0,0 +1,322 @@
    +<?php
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +declare(strict_types=1);
    +
    +use Tests\MahoBackendTestCase;
    +
    +uses(MahoBackendTestCase::class);
    +
    +// Shared test data - created once for all tests
    +function getSharedTestData()
    +{
    +    static $testData = null;
    +
    +    if ($testData === null) {
    +        // Create test categories with known structure
    +        $defaultCategory = Mage::getModel('catalog/category')->load(2);
    +
    +        // Create Electronics category with minimal operations
    +        $electronicsCategory = Mage::getModel('catalog/category');
    +        $electronicsCategory->setName('Test Electronics')
    +            ->setUrlKey('test-electronics')
    +            ->setIsActive(1)
    +            ->setIncludeInMenu(1)
    +            ->setDescription('Test electronics category')
    +            ->setParentId(2)
    +            ->setStoreId(0);
    +
    +        // Use direct database insert for better performance
    +        $resource = $electronicsCategory->getResource();
    +        $resource->save($electronicsCategory);
    +
    +        // Create Phones subcategory
    +        $phonesCategory = Mage::getModel('catalog/category');
    +        $phonesCategory->setName('Test Phones')
    +            ->setUrlKey('test-phones')
    +            ->setIsActive(1)
    +            ->setIncludeInMenu(1)
    +            ->setDescription('Test phone products')
    +            ->setParentId($electronicsCategory->getId())
    +            ->setStoreId(0);
    +
    +        $resource->save($phonesCategory);
    +
    +        $testData = [
    +            'defaultCategory' => $defaultCategory,
    +            'electronicsCategory' => $electronicsCategory,
    +            'phonesCategory' => $phonesCategory,
    +        ];
    +    }
    +
    +    return $testData;
    +}
    +
    +beforeEach(function () {
    +    // Create export model only
    +    $this->exportModel = Mage::getModel('importexport/export_entity_category');
    +    $this->writer = Mage::getModel('importexport/export_adapter_csv');
    +    $this->exportModel->setWriter($this->writer);
    +
    +    // Cache export result for tests that don't modify data
    +    static $cachedExport = null;
    +    $this->getCachedExport = function () use (&$cachedExport) {
    +        if ($cachedExport === null) {
    +            $cachedExport = $this->exportModel->exportFile();
    +        }
    +        return $cachedExport;
    +    };
    +});
    +
    +// No afterAll cleanup needed - tests create minimal temporary data
    +// that doesn't interfere with other tests
    +
    +it('exports categories with correct CSV structure', function () {
    +    $result = ($this->getCachedExport)();
    +
    +    expect($result)->toBeArray()
    +        ->and($result['type'])->toBe('string')
    +        ->and($result['rows'])->toBeGreaterThan(0);
    +
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    // Check header row exists
    +    expect($lines[0])->toContain('category_id')
    +        ->and($lines[0])->toContain('parent_id')
    +        ->and($lines[0])->toContain('_store')
    +        ->and($lines[0])->toContain('name');
    +});
    +
    +it('generates correct parent-child relationships', function () {
    +    $result = ($this->getCachedExport)();
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    // Parse CSV to check parent-child relationships
    +    $categories = [];
    +    $header = str_getcsv($lines[0]);
    +    $categoryIdIndex = array_search('category_id', $header);
    +    $parentIdIndex = array_search('parent_id', $header);
    +    $nameIndex = array_search('name', $header);
    +
    +    // Skip header row and parse all categories for complete validation
    +    for ($i = 1; $i < count($lines); $i++) {
    +        if (empty(trim($lines[$i]))) {
    +            continue;
    +        }
    +
    +        $row = str_getcsv($lines[$i]);
    +        if (count($row) > max($categoryIdIndex, $parentIdIndex, $nameIndex)) {
    +            $categories[$row[$categoryIdIndex]] = [
    +                'parent_id' => $row[$parentIdIndex],
    +                'name' => $row[$nameIndex],
    +            ];
    +        }
    +    }
    +
    +    // Check that we have categories with valid parent relationships
    +    expect(count($categories))->toBeGreaterThan(0);
    +
    +    // Check that categories have proper parent references
    +    $foundValidParentChild = false;
    +    foreach ($categories as $categoryId => $data) {
    +        if (!empty($data['parent_id']) && isset($categories[$data['parent_id']])) {
    +            $foundValidParentChild = true;
    +            break;
    +        }
    +    }
    +
    +    expect($foundValidParentChild)->toBeTrue();
    +});
    +
    +it('exports multi-store data correctly', function () {
    +    // Create a temporary category with store-specific data for this test only
    +    $tempCategory = Mage::getModel('catalog/category');
    +    $tempCategory->setName('Temp Multi-Store Test')
    +        ->setUrlKey('temp-multi-store')
    +        ->setIsActive(1)
    +        ->setParentId(2)
    +        ->setStoreId(0)
    +        ->save();
    +
    +    // Add store-specific data
    +    $tempCategory->setStoreId(1)
    +        ->setName('Elektronik Temp') // German name
    +        ->save();
    +
    +    try {
    +        $result = $this->exportModel->exportFile();
    +        $csvContent = $result['value'];
    +        $lines = explode("\n", trim($csvContent));
    +
    +        $foundMultiStoreData = false;
    +        $tempCategoryId = $tempCategory->getId();
    +
    +        foreach ($lines as $line) {
    +            // Look for lines that contain our test category
    +            if (strpos($line, (string) $tempCategoryId) !== false) {
    +                $columns = str_getcsv($line);
    +                if (count($columns) >= 3) {
    +                    $storeCode = $columns[2];
    +
    +                    // Check for multi-store functionality
    +                    if (strpos($line, 'Multi-Store') !== false || strpos($line, 'Elektronik') !== false) {
    +                        $foundMultiStoreData = true;
    +                        break;
    +                    }
    +                }
    +            }
    +        }
    +
    +        expect($foundMultiStoreData)->toBeTrue('Should find multi-store category data');
    +    } finally {
    +        // Clean up
    +        $tempCategory->delete();
    +    }
    +});
    +
    +it('excludes disabled attributes from export', function () {
    +    $result = ($this->getCachedExport)();
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    $headerLine = $lines[0];
    +
    +    // These attributes should be excluded
    +    expect($headerLine)->not->toContain('all_children')
    +        ->and($headerLine)->not->toContain('children')
    +        ->and($headerLine)->not->toContain('children_count')
    +        ->and($headerLine)->not->toContain('level')
    +        ->and($headerLine)->not->toContain(',path,')  // More specific to avoid matching path_in_store
    +        ->and($headerLine)->not->toContain('position');
    +});
    +
    +it('maintains hierarchical order in export', function () {
    +    $result = ($this->getCachedExport)();
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    // Parse CSV to find parent-child relationships - limit processing for performance
    +    $categories = [];
    +    $header = str_getcsv($lines[0]);
    +    $categoryIdIndex = array_search('category_id', $header);
    +    $parentIdIndex = array_search('parent_id', $header);
    +
    +    // Skip header row and parse categories for complete validation
    +    for ($i = 1; $i < count($lines); $i++) {
    +        if (empty(trim($lines[$i]))) {
    +            continue;
    +        }
    +
    +        $row = str_getcsv($lines[$i]);
    +        if (count($row) > max($categoryIdIndex, $parentIdIndex)) {
    +            $categoryId = (int) $row[$categoryIdIndex];
    +            $parentId = (int) $row[$parentIdIndex];
    +
    +            $categories[$i] = [
    +                'category_id' => $categoryId,
    +                'parent_id' => $parentId,
    +                'line_index' => $i,
    +            ];
    +        }
    +    }
    +
    +    // Verify we have hierarchical data (categories with different parent IDs)
    +    $parentIds = array_column($categories, 'parent_id');
    +    $uniqueParentIds = array_unique($parentIds);
    +    expect(count($uniqueParentIds))->toBeGreaterThan(1, 'Should have categories with different parent IDs');
    +
    +    // Find parent-child relationships and verify ordering
    +    $foundHierarchy = false;
    +    $hierarchyViolations = [];
    +
    +    foreach ($categories as $category) {
    +        $categoryId = $category['category_id'];
    +
    +        // Find children of this category
    +        foreach ($categories as $potentialChild) {
    +            if ($potentialChild['parent_id'] === $categoryId) {
    +                $foundHierarchy = true;
    +
    +                // Check ordering - parent should appear before child
    +                if ($category['line_index'] >= $potentialChild['line_index']) {
    +                    $hierarchyViolations[] = "Parent category {$categoryId} appears after child category {$potentialChild['category_id']}";
    +                }
    +            }
    +        }
    +    }
    +
    +    // Report any hierarchy violations
    +    if (!empty($hierarchyViolations)) {
    +        expect(false)->toBeTrue('Hierarchy violations found: ' . implode(', ', $hierarchyViolations));
    +    }
    +
    +    expect($foundHierarchy)->toBeTrue('Should have at least one parent-child relationship in the export');
    +});
    +
    +it('handles categories without url_key gracefully', function () {
    +    // Create category without url_key for this test only
    +    $testCategory = Mage::getModel('catalog/category');
    +    $testCategory->setName('Test Category With Spaces!')
    +        ->setIsActive(1)
    +        ->setParentId(2)
    +        ->setStoreId(0)
    +        ->save();
    +
    +    try {
    +        // Should not crash and should generate path from name
    +        $result = $this->exportModel->exportFile();
    +
    +        expect($result)->toBeArray()
    +            ->and($result['rows'])->toBeGreaterThan(0);
    +    } finally {
    +        $testCategory->delete();
    +    }
    +});
    +
    +it('exports all required permanent attributes', function () {
    +    $result = ($this->getCachedExport)();
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    $headerLine = $lines[0];
    +
    +    // Check permanent attributes are present
    +    expect($headerLine)->toContain('category_id')
    +        ->and($headerLine)->toContain('parent_id')
    +        ->and($headerLine)->toContain('_store');
    +});
    +
    +it('exports sample data categories correctly', function () {
    +    // Sample data has existing categories, verify meaningful content
    +    $result = ($this->getCachedExport)();
    +
    +    expect($result)->toBeArray()
    +        ->and($result['rows'])->toBeGreaterThan(0); // Sample data has categories
    +
    +    $csvContent = $result['value'];
    +    $lines = explode("\n", trim($csvContent));
    +
    +    // Parse and verify we have meaningful category data
    +    $foundDefaultCategory = false;
    +    $foundCategoryWithName = false;
    +
    +    foreach ($lines as $line) {
    +        if (strpos($line, ',2,') !== false) { // Default category (ID 2)
    +            $foundDefaultCategory = true;
    +        }
    +        if (preg_match('/,\d+,\d+,[^,]*,[^,]*[a-zA-Z]+/', $line)) { // Category with actual name
    +            $foundCategoryWithName = true;
    +        }
    +    }
    +
    +    expect($foundDefaultCategory)->toBeTrue('Should export default category')
    +        ->and($foundCategoryWithName)->toBeTrue('Should have categories with meaningful names');
    +});
    
  • tests/Backend/Integration/ImportExport/CategoryImportBehaviorsTest.php+594 0 added
    @@ -0,0 +1,594 @@
    +<?php
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +declare(strict_types=1);
    +
    +use Tests\MahoBackendTestCase;
    +
    +uses(MahoBackendTestCase::class);
    +
    +beforeEach(function () {
    +    $this->importModel = Mage::getModel('importexport/import_entity_category');
    +});
    +
    +afterEach(function () {
    +    // Clean up test categories
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('level', ['gt' => 0])
    +        ->addAttributeToFilter('entity_id', ['gt' => 2]);
    +
    +    foreach ($collection as $category) {
    +        try {
    +            $category->delete();
    +        } catch (Exception $e) {
    +            // Ignore cleanup errors
    +        }
    +    }
    +});
    +
    +describe('DELETE Behavior', function () {
    +    it('deletes specified categories', function () {
    +        // Create test categories first
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Delete Me 1', 'to-delete-1'],
    +            ['', '2', '', 'Delete Me 2', 'to-delete-2'],
    +            ['', '2', '', 'Keep Me', 'to-keep'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Get the created categories
    +        $catToDelete1 = findCategoryByUrlKeyBehavior('to-delete-1');
    +        $catToDelete2 = findCategoryByUrlKeyBehavior('to-delete-2');
    +        $catToKeep = findCategoryByUrlKeyBehavior('to-keep');
    +
    +        // Verify categories were created
    +        expect($catToDelete1)->not->toBeNull()
    +            ->and($catToDelete2)->not->toBeNull()
    +            ->and($catToKeep)->not->toBeNull();
    +
    +        // Now delete specific categories using category_id
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $catToDelete1->getId(), '', ''],
    +            [(string) $catToDelete2->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify only specified categories were deleted
    +        expect(findCategoryByUrlKeyBehavior('to-delete-1'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('to-delete-2'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('to-keep'))->not->toBeNull();
    +    });
    +
    +    it('deletes category hierarchies correctly', function () {
    +        // First create parent categories
    +        $parentsData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Parent Delete', 'parent-to-delete'],
    +            ['', '2', '', 'Parent Keep', 'parent-to-keep'],
    +        ];
    +
    +        createAndImportBehaviorCsv($parentsData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Get parent categories
    +        $parentToDelete = findCategoryByUrlKeyBehavior('parent-to-delete');
    +        $parentToKeep = findCategoryByUrlKeyBehavior('parent-to-keep');
    +
    +        // Now create children under these parents
    +        $childrenData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', (string) $parentToDelete->getId(), '', 'Child 1', 'child-1'],
    +            ['', (string) $parentToDelete->getId(), '', 'Child 2', 'child-2'],
    +            ['', (string) $parentToKeep->getId(), '', 'Child Keep', 'child-keep'],
    +        ];
    +
    +        createAndImportBehaviorCsv($childrenData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Delete parent category using category_id
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $parentToDelete->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify parent and children are deleted, but other hierarchy remains
    +        expect(findCategoryByUrlKeyBehavior('parent-to-delete'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('child-1'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('child-2'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('parent-to-keep'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('child-keep'))->not->toBeNull();
    +    });
    +
    +    it('handles delete errors gracefully for non-existent categories', function () {
    +        // Try to delete non-existent categories using fake category IDs
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            ['99999', '', ''],
    +            ['99998', '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Should not crash - check error count
    +        expect($GLOBALS['testImportModelBehavior']->getErrorsCount())->toBeGreaterThanOrEqual(0);
    +    });
    +
    +    it('preserves root and system categories during delete', function () {
    +        // Get root category before test
    +        $rootCategory = Mage::getModel('catalog/category')->load(Mage_Catalog_Model_Category::TREE_ROOT_ID);
    +        $defaultCategory = Mage::getModel('catalog/category')->load(2); // Default category
    +
    +        expect((int) $rootCategory->getId())->toBe(Mage_Catalog_Model_Category::TREE_ROOT_ID)
    +            ->and((int) $defaultCategory->getId())->toBe(2);
    +
    +        // Create a test category first
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Test Category', 'test-category'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        $testCategory = findCategoryByUrlKeyBehavior('test-category');
    +
    +        // Delete the test category
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $testCategory->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify system categories still exist
    +        $rootCategoryAfter = Mage::getModel('catalog/category')->load(Mage_Catalog_Model_Category::TREE_ROOT_ID);
    +        $defaultCategoryAfter = Mage::getModel('catalog/category')->load(2);
    +
    +        expect((int) $rootCategoryAfter->getId())->toBe(Mage_Catalog_Model_Category::TREE_ROOT_ID)
    +            ->and((int) $defaultCategoryAfter->getId())->toBe(2);
    +    });
    +
    +    it('handles multi-store data in delete operations', function () {
    +        // First create the category
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'English Name', 'multi-store-delete'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        $category = findCategoryByUrlKeyBehavior('multi-store-delete');
    +
    +        // Add store-specific data
    +        $storeData = [
    +            ['category_id', 'parent_id', '_store', 'name'],
    +            [(string) $category->getId(), '', 'default', 'German Name'],
    +        ];
    +
    +        createAndImportBehaviorCsv($storeData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Verify multi-store category exists
    +        expect($category)->not->toBeNull();
    +
    +        // Delete the category
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $category->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify category is completely deleted (all store data)
    +        expect(findCategoryByUrlKeyBehavior('multi-store-delete'))->toBeNull();
    +    });
    +
    +    it('deletes categories using category_id', function () {
    +        // Create test categories
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'ID Delete 1', 'id-delete-1'],
    +            ['', '2', '', 'ID Delete 2', 'id-delete-2'],
    +            ['', '2', '', 'ID Keep', 'id-keep'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Get category IDs
    +        $cat1 = findCategoryByUrlKeyBehavior('id-delete-1');
    +        $cat2 = findCategoryByUrlKeyBehavior('id-delete-2');
    +        $catKeep = findCategoryByUrlKeyBehavior('id-keep');
    +
    +        expect($cat1)->not->toBeNull()
    +            ->and($cat2)->not->toBeNull()
    +            ->and($catKeep)->not->toBeNull();
    +
    +        // Delete using category_id
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $cat1->getId(), '', ''],
    +            [(string) $cat2->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify only specified categories were deleted by ID
    +        expect(findCategoryByUrlKeyBehavior('id-delete-1'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('id-delete-2'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('id-keep'))->not->toBeNull();
    +    });
    +
    +    it('handles mixed category_id and category_path in delete operations', function () {
    +        // Create test categories using new format
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Mixed Delete 1', 'mixed-delete-1'],
    +            ['', '2', '', 'Mixed Delete 2', 'mixed-delete-2'],
    +            ['', '2', '', 'Mixed Keep', 'mixed-keep'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Get category IDs
    +        $cat1 = findCategoryByUrlKeyBehavior('mixed-delete-1');
    +        $cat2 = findCategoryByUrlKeyBehavior('mixed-delete-2');
    +        expect($cat1)->not->toBeNull()->and($cat2)->not->toBeNull();
    +
    +        // Delete using category_id (the new standard approach)
    +        $deleteData = [
    +            ['category_id', 'parent_id', '_store'],
    +            [(string) $cat1->getId(), '', ''],
    +            [(string) $cat2->getId(), '', ''],
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Verify both categories were deleted
    +        expect(findCategoryByUrlKeyBehavior('mixed-delete-1'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('mixed-delete-2'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('mixed-keep'))->not->toBeNull();
    +    });
    +
    +    it('validates category_id format in delete operations', function () {
    +        // Try to delete with invalid category IDs
    +        $deleteData = [
    +            ['category_id', '_store'],
    +            ['invalid', ''], // Non-numeric ID
    +            ['1', ''], // System root category (should be rejected)
    +            ['2', ''], // Default category (should be rejected)
    +            ['99999', ''], // Non-existent ID
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Should have errors for all invalid IDs
    +        expect($GLOBALS['testImportModelBehavior']->getErrorsCount())->toBeGreaterThan(0);
    +
    +        // Verify system categories are still safe
    +        $rootCategory = Mage::getModel('catalog/category')->load(1);
    +        $defaultCategory = Mage::getModel('catalog/category')->load(2);
    +        expect((int) $rootCategory->getId())->toBe(1)
    +            ->and((int) $defaultCategory->getId())->toBe(2);
    +    });
    +
    +    it('requires either category_id or category_path for delete operations', function () {
    +        // Try to delete without any identifier
    +        $deleteData = [
    +            ['_store'],
    +            [''], // Empty row with no category_id or category_path
    +        ];
    +
    +        createAndImportBehaviorCsv($deleteData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +        // Should have validation error
    +        expect($GLOBALS['testImportModelBehavior']->getErrorsCount())->toBeGreaterThan(0);
    +
    +        // Check for specific error message
    +        $errors = $GLOBALS['testImportModelBehavior']->getErrorMessages();
    +        $hasIdentifierError = false;
    +        foreach ($errors as $errorType => $rows) {
    +            if (strpos($errorType, 'category_id or category_path must be provided') !== false) {
    +                $hasIdentifierError = true;
    +                break;
    +            }
    +        }
    +        expect($hasIdentifierError)->toBeTrue();
    +    });
    +});
    +
    +describe('REPLACE Behavior', function () {
    +    it('works exactly like APPEND behavior', function () {
    +        // Create initial categories with description
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key', 'description'],
    +            ['', '2', '', 'Original 1', 'original-1', 'Original description 1'],
    +            ['', '2', '', 'Original 2', 'original-2', 'Original description 2'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Get category ID for updating
    +        $originalCat1 = findCategoryByUrlKeyBehavior('original-1');
    +        expect($originalCat1)->not->toBeNull();
    +        expect($originalCat1->getDescription())->toBe('Original description 1');
    +
    +        // REPLACE: update existing category and create new one (same as APPEND)
    +        $replaceData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            [(string) $originalCat1->getId(), '2', '', 'Updated Name', 'updated-1'], // Update existing
    +            ['', '2', '', 'New Category', 'new-1'], // Create new
    +        ];
    +
    +        createAndImportBehaviorCsv($replaceData, Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE);
    +
    +        // Verify REPLACE behavior works exactly like APPEND for categories
    +        $updatedCat = findCategoryByUrlKeyBehavior('updated-1');
    +        expect($updatedCat)->not->toBeNull()
    +            ->and($updatedCat->getName())->toBe('Updated Name')
    +            ->and($updatedCat->getDescription())->toBe('Original description 1') // Description preserved (not in CSV)
    +            ->and(findCategoryByUrlKeyBehavior('original-2'))->not->toBeNull() // Untouched category remains
    +            ->and(findCategoryByUrlKeyBehavior('new-1'))->not->toBeNull(); // New category created
    +    });
    +
    +    it('preserves system categories during replace', function () {
    +        // Get system categories before test
    +        $rootCategory = Mage::getModel('catalog/category')->load(Mage_Catalog_Model_Category::TREE_ROOT_ID);
    +        $defaultCategory = Mage::getModel('catalog/category')->load(2);
    +
    +        // Create some categories first
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Replace Test 1', 'replace-test-1'],
    +            ['', '2', '', 'Replace Test 2', 'replace-test-2'],
    +        ];
    +
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Replace: add new category (same as APPEND for categories)
    +        $replaceData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Replace Test 3', 'replace-test-3'],
    +        ];
    +
    +        createAndImportBehaviorCsv($replaceData, Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE);
    +
    +        // Verify system categories still exist
    +        $rootCategoryAfter = Mage::getModel('catalog/category')->load(Mage_Catalog_Model_Category::TREE_ROOT_ID);
    +        $defaultCategoryAfter = Mage::getModel('catalog/category')->load(2);
    +
    +        expect($rootCategoryAfter->getId())->toBe($rootCategory->getId())
    +            ->and($defaultCategoryAfter->getId())->toBe($defaultCategory->getId());
    +
    +        // Verify REPLACE works like APPEND: all categories still exist
    +        expect(findCategoryByUrlKeyBehavior('replace-test-1'))->not->toBeNull() // Still exists
    +            ->and(findCategoryByUrlKeyBehavior('replace-test-2'))->not->toBeNull() // Still exists
    +            ->and(findCategoryByUrlKeyBehavior('replace-test-3'))->not->toBeNull(); // New category created
    +    });
    +
    +    it('handles hierarchical replace correctly', function () {
    +        // Create hierarchical structure - first parents
    +        $parentsData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Old Parent', 'old-parent'],
    +            ['', '2', '', 'Keep Parent', 'keep-parent'],
    +        ];
    +        createAndImportBehaviorCsv($parentsData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        $oldParent = findCategoryByUrlKeyBehavior('old-parent');
    +        $keepParent = findCategoryByUrlKeyBehavior('keep-parent');
    +
    +        // Then children
    +        $childrenData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', (string) $oldParent->getId(), '', 'Old Child 1', 'old-child-1'],
    +            ['', (string) $oldParent->getId(), '', 'Old Child 2', 'old-child-2'],
    +            ['', (string) $keepParent->getId(), '', 'Keep Child', 'keep-child'],
    +        ];
    +        createAndImportBehaviorCsv($childrenData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Replace with new hierarchy - REPLACE works same as APPEND for categories
    +        $newParentData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'New Parent', 'new-parent'],
    +        ];
    +        createAndImportBehaviorCsv($newParentData, Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE);
    +
    +        $newParent = findCategoryByUrlKeyBehavior('new-parent');
    +        $newChildData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', (string) $newParent->getId(), '', 'New Child', 'new-child'],
    +        ];
    +        createAndImportBehaviorCsv($newChildData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Verify REPLACE works same as APPEND - all categories still exist, new ones added
    +        expect(findCategoryByUrlKeyBehavior('old-parent'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('old-child-1'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('old-child-2'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('keep-parent'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('keep-child'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('new-parent'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('new-child'))->not->toBeNull();
    +    });
    +
    +    it('handles multi-store data in replace operations', function () {
    +        // Create initial categories
    +        $setupData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'English 1', 'multi-replace-1'],
    +            ['', '2', '', 'English 2', 'multi-replace-2'],
    +        ];
    +        createAndImportBehaviorCsv($setupData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        $cat1 = findCategoryByUrlKeyBehavior('multi-replace-1');
    +        $cat2 = findCategoryByUrlKeyBehavior('multi-replace-2');
    +
    +        // Add store-specific data
    +        $storeData = [
    +            ['category_id', 'parent_id', '_store', 'name'],
    +            [(string) $cat1->getId(), '', 'default', 'German 1'],
    +            [(string) $cat2->getId(), '', 'default', 'German 2'],
    +        ];
    +        createAndImportBehaviorCsv($storeData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Replace with new structure
    +        $replaceData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'New English', 'multi-replace-new'],
    +        ];
    +
    +        createAndImportBehaviorCsv($replaceData, Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE);
    +
    +        // Verify replacement
    +        expect(findCategoryByUrlKeyBehavior('multi-replace-1'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('multi-replace-2'))->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('multi-replace-new'))->not->toBeNull();
    +
    +        // Verify multi-store data
    +        $newCategory = findCategoryByUrlKeyBehavior('multi-replace-new');
    +        expect($newCategory)->not->toBeNull();
    +        expect($newCategory->getName())->toBe('New English');
    +    });
    +});
    +
    +describe('Behavior Comparison', function () {
    +    it('demonstrates different behavior outcomes with same data', function () {
    +        // Setup: Create initial categories
    +        $initialData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Initial 1', 'behavior-test-1'],
    +            ['', '2', '', 'Initial 2', 'behavior-test-2'],
    +        ];
    +
    +        createAndImportBehaviorCsv($initialData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Verify initial state
    +        expect(findCategoryByUrlKeyBehavior('behavior-test-1'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('behavior-test-2'))->not->toBeNull();
    +
    +        // Get existing category ID for update
    +        $behaviorTest1 = findCategoryByUrlKeyBehavior('behavior-test-1');
    +
    +        // Test data for all behaviors
    +        $testData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            [(string) $behaviorTest1->getId(), '2', '', 'Updated 1', 'behavior-test-1'], // Update existing
    +            ['', '2', '', 'New 3', 'behavior-test-3'],     // Add new
    +        ];
    +
    +        // Test 1: APPEND behavior
    +        createAndImportBehaviorCsv($testData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // APPEND: All categories should exist (original + new + updated)
    +        expect(findCategoryByUrlKeyBehavior('behavior-test-1'))->not->toBeNull()
    +            ->and(findCategoryByUrlKeyBehavior('behavior-test-2'))->not->toBeNull() // Still exists
    +            ->and(findCategoryByUrlKeyBehavior('behavior-test-3'))->not->toBeNull();
    +
    +        expect(findCategoryByUrlKeyBehavior('behavior-test-1')->getName())->toBe('Updated 1');
    +
    +        // Reset for REPLACE test - clean up manually
    +        $collection = Mage::getModel('catalog/category')->getCollection()
    +            ->addAttributeToFilter('level', ['gt' => 0])
    +            ->addAttributeToFilter('entity_id', ['gt' => 2]);
    +
    +        foreach ($collection as $category) {
    +            try {
    +                $category->delete();
    +            } catch (Exception $e) {
    +                // Ignore cleanup errors
    +            }
    +        }
    +
    +        createAndImportBehaviorCsv($initialData, Mage_ImportExport_Model_Import::BEHAVIOR_APPEND);
    +
    +        // Test 2: REPLACE behavior - REPLACE works same as APPEND for categories
    +        $behaviorTest1New = findCategoryByUrlKeyBehavior('behavior-test-1');
    +        $replaceData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            [(string) $behaviorTest1New->getId(), '2', '', 'Replaced 1', 'behavior-test-1'], // Update existing (keep same url_key)
    +            ['', '2', '', 'New 3', 'behavior-test-3'],       // Create new (same as APPEND)
    +        ];
    +        createAndImportBehaviorCsv($replaceData, Mage_ImportExport_Model_Import::BEHAVIOR_REPLACE);
    +
    +        // REPLACE: Works same as APPEND - updates existing, creates new, leaves untouched unchanged
    +        expect(findCategoryByUrlKeyBehavior('behavior-test-1'))->not->toBeNull()      // Updated existing
    +            ->and(findCategoryByUrlKeyBehavior('behavior-test-2'))->not->toBeNull() // Unchanged existing
    +            ->and(findCategoryByUrlKeyBehavior('behavior-test-3'))->not->toBeNull(); // Created new
    +
    +        expect(findCategoryByUrlKeyBehavior('behavior-test-1')->getName())->toBe('Replaced 1');
    +    });
    +
    +    it('validates behavior parameter handling', function () {
    +        // Test with invalid behavior (should default to APPEND)
    +        $testData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +            ['', '2', '', 'Invalid Behavior Test', 'behavior-invalid-test'],
    +        ];
    +
    +        createAndImportBehaviorCsv($testData, 'invalid_behavior');
    +
    +        // Should still work (defaults to APPEND)
    +        expect($GLOBALS['testImportModelBehavior']->getErrorsCount())->toBe(0);
    +        expect(findCategoryByUrlKeyBehavior('behavior-invalid-test'))->not->toBeNull();
    +    });
    +});
    +
    +// Helper methods
    +function createAndImportBehaviorCsv(array $data, string $behavior): void
    +{
    +    $tmpFile = tempnam(sys_get_temp_dir(), 'category_import_behavior_test');
    +    $handle = fopen($tmpFile, 'w');
    +
    +    foreach ($data as $row) {
    +        fputcsv($handle, $row);
    +    }
    +    fclose($handle);
    +
    +    // Ensure file exists before creating adapter
    +    if (!file_exists($tmpFile)) {
    +        throw new Exception('Failed to create temporary CSV file');
    +    }
    +
    +    // Create fresh import model and CSV adapter
    +    $importModel = Mage::getModel('importexport/import_entity_category');
    +    $csvAdapter = Mage::getModel('importexport/import_adapter_csv', $tmpFile);
    +    $importModel->setSource($csvAdapter);
    +    $importModel->setParameters(['behavior' => $behavior]);
    +
    +    $importModel->validateData();
    +    $importModel->importData();
    +
    +    // Store the import model globally for error checking
    +    $GLOBALS['testImportModelBehavior'] = $importModel;
    +
    +    if (file_exists($tmpFile)) {
    +        unlink($tmpFile);
    +    }
    +}
    +
    +function findCategoryByUrlKeyBehavior(string $urlKey): ?Mage_Catalog_Model_Category
    +{
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToSelect(['name', 'url_key', 'is_active'])
    +        ->addAttributeToFilter('url_key', $urlKey)
    +        ->setPageSize(1);
    +
    +    $category = $collection->getFirstItem();
    +    if (!$category->getId()) {
    +        return null;
    +    }
    +
    +    // Reload the category with direct model loading to ensure all attributes are loaded
    +    $freshCategory = Mage::getModel('catalog/category');
    +    $freshCategory->setStoreId(0);
    +    $freshCategory->load($category->getId());
    +
    +    return $freshCategory->getId() ? $freshCategory : null;
    +}
    
  • tests/Backend/Integration/ImportExport/CategoryImportEdgeCasesTest.php+352 0 added
    @@ -0,0 +1,352 @@
    +<?php
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +declare(strict_types=1);
    +
    +use Tests\MahoBackendTestCase;
    +
    +uses(MahoBackendTestCase::class);
    +
    +beforeEach(function () {
    +    $this->importModel = Mage::getModel('importexport/import_entity_category');
    +    // CSV adapter will be created in helper function with proper source file
    +});
    +
    +afterEach(function () {
    +    // Clean up test categories
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('level', ['gt' => 0])
    +        ->addAttributeToFilter('entity_id', ['gt' => 2]);
    +
    +    foreach ($collection as $category) {
    +        try {
    +            $category->delete();
    +        } catch (Exception $e) {
    +            // Ignore cleanup errors
    +        }
    +    }
    +});
    +
    +it('handles duplicate category paths in same import', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        ['', '2', '', 'Test Unique Category', 'test-unique-category', '1'],
    +        ['', '2', '', 'Test Unique Category Duplicate', 'test-unique-category', '1'], // Duplicate path
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Check for duplicate path errors - import model handles this gracefully
    +    $errorCount = $GLOBALS['testImportModelEdge']->getErrorsCount();
    +    // Note: Current import model handles duplicates gracefully without errors
    +
    +    // Should handle gracefully - either skip duplicate or update existing
    +    $category = findCategoryByUrlKeyEdgeCase('test-unique-category');
    +    expect($category)->not->toBeNull();
    +
    +    // Import model handles duplicates by creating both categories (possibly with auto-generated unique keys)
    +    $count = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('url_key', 'test-unique-category')
    +        ->count();
    +
    +    // Accept that duplicates may be handled by creating multiple categories with unique keys
    +    expect($count)->toBeGreaterThanOrEqual(1);
    +});
    +
    +it('handles very deep category hierarchies', function () {
    +    // Create 10-level deep hierarchy - must import level by level to establish parent-child relationships
    +    $parentId = '2'; // Start with default root category
    +
    +    for ($i = 1; $i <= 10; $i++) {
    +        $csvData = [
    +            ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +            ['', $parentId, '', 'Level ' . $i, 'level-' . $i, '1'],
    +        ];
    +
    +        createAndImportEdgeCaseCsv($csvData);
    +
    +        // Get the created category to use as parent for next level
    +        $createdCategory = findCategoryByUrlKeyEdgeCase('level-' . $i);
    +        expect($createdCategory)->not->toBeNull(); // Verify category was created before proceeding
    +        $parentId = (string) $createdCategory->getId();
    +    }
    +
    +    // Check deepest level was created correctly
    +    $deepestCategory = findCategoryByUrlKeyEdgeCase('level-10');
    +    expect($deepestCategory)->not->toBeNull()
    +        ->and((int) $deepestCategory->getLevel())->toBe(11); // Root=1, Default=2, so level-10 = 11
    +
    +    // Check path integrity
    +    $pathIds = explode('/', $deepestCategory->getPath());
    +    expect(count($pathIds))->toBe(12); // 1 (root) + 1 (default) + 10 levels
    +});
    +
    +it('handles unicode characters in category names', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'description'],
    +        ['', '2', '', 'Électronique', 'electronics', 'Catégorie électronique'],
    +        ['', '2', '', 'Téléphones', 'smartphones', 'Téléphones intelligents'],
    +        ['', '2', '', '时尚', 'fashion', '时尚类别'],
    +        ['', '2', '', '鞋子', 'shoes', '各种鞋类产品'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    $electronics = findCategoryByUrlKeyEdgeCase('electronics');
    +    $fashion = findCategoryByUrlKeyEdgeCase('fashion');
    +
    +    expect($electronics)->not->toBeNull()
    +        ->and($electronics->getName())->toBe('Électronique')
    +        ->and($electronics->getDescription())->toBe('Catégorie électronique');
    +
    +    expect($fashion)->not->toBeNull()
    +        ->and($fashion->getName())->toBe('时尚')
    +        ->and($fashion->getDescription())->toBe('时尚类别');
    +});
    +
    +it('validates attribute data types correctly', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active', 'position', 'include_in_menu'],
    +        ['', '2', '', 'Test Category', 'test-validation', 'invalid_boolean', 'not_a_number', '1'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Should have validation errors for invalid data types
    +    expect($GLOBALS['testImportModelEdge']->getErrorsCount())->toBeGreaterThan(0);
    +});
    +
    +it('handles missing required attributes', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'url_key', 'is_active'], // Missing 'name' which is required
    +        ['', '2', '', 'test-missing-name', '1'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Should have validation error for missing required field
    +    expect($GLOBALS['testImportModelEdge']->getErrorsCount())->toBeGreaterThan(0);
    +
    +    $errors = $GLOBALS['testImportModelEdge']->getErrorMessages();
    +    $hasNameError = false;
    +    foreach ($errors as $message => $rows) {
    +        if (strpos(strtolower($message), 'name') !== false) {
    +            $hasNameError = true;
    +            break;
    +        }
    +    }
    +    expect($hasNameError)->toBeTrue();
    +});
    +
    +it('handles invalid store codes gracefully', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key'],
    +        ['', '2', '', 'Test Category', 'test-category'],
    +        ['', '2', 'nonexistent_store', 'Store Specific Name', ''], // Invalid store code
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Should not crash, but may skip invalid store data
    +    $category = findCategoryByUrlKeyEdgeCase('test-category');
    +    expect($category)->not->toBeNull()
    +        ->and($category->getName())->toBe('Test Category');
    +});
    +
    +it('maintains database consistency during import', function () {
    +    // Create a large batch to test transaction consistency
    +    $csvData = [['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active']];
    +
    +    for ($i = 1; $i <= 50; $i++) {
    +        $csvData[] = ['', '2', '', 'Batch Category ' . $i, 'batch-category-' . $i, '1'];
    +    }
    +
    +    // Add one invalid row to test rollback behavior
    +    $csvData[] = ['', '', '', '', '', '']; // Invalid row
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Even with errors, valid categories should be imported
    +    // (ImportExport typically continues on row errors rather than rolling back)
    +    $validCategory = findCategoryByUrlKeyEdgeCase('batch-category-1');
    +    expect($validCategory)->not->toBeNull();
    +});
    +
    +it('handles concurrent modifications gracefully', function () {
    +    // Create category via import
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        ['', '2', '', 'Original Name', 'concurrent-test', '1'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    $category = findCategoryByUrlKeyEdgeCase('concurrent-test');
    +    $originalId = $category->getId();
    +
    +    // Simulate concurrent modification by directly updating the database
    +    $connection = Mage::getSingleton('core/resource')->getConnection('core_write');
    +    $nameAttribute = Mage::getSingleton('eav/config')->getAttribute('catalog_category', 'name');
    +    $connection->update(
    +        $nameAttribute->getBackendTable(),
    +        ['value' => 'Modified Externally'],
    +        [
    +            'entity_id = ?' => $originalId,
    +            'attribute_id = ?' => $nameAttribute->getId(),
    +            'store_id = ?' => 0,
    +        ],
    +    );
    +
    +    // Import update - provide category_id for successful update
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        [(string) $originalId, '2', '', 'Updated Name', 'concurrent-test', '0'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Reload and check - should have import changes, not concurrent changes
    +    $category->load($originalId);
    +    expect($category->getName())->toBe('Updated Name')
    +        ->and($category->getIsActive())->toBe('0');
    +});
    +
    +it('handles url_key conflicts during import', function () {
    +    // First create category with specific url_key
    +    $existingCategory = Mage::getModel('catalog/category');
    +    $existingCategory->setName('Existing Category')
    +        ->setUrlKey('electronics')
    +        ->setIsActive(1)
    +        ->setParentId(2)
    +        ->setStoreId(0)
    +        ->save();
    +
    +    // Now try to import category with same url_key path - provide category_id for update
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        [(string) $existingCategory->getId(), '2', '', 'New Electronics', 'electronics', '1'],
    +    ];
    +
    +    createAndImportEdgeCaseCsv($csvData);
    +
    +    // Should update existing category rather than create duplicate
    +    $electronics = findCategoryByUrlKeyEdgeCase('electronics');
    +    expect($electronics)->not->toBeNull()
    +        ->and($electronics->getName())->toBe('New Electronics');
    +
    +    // Should only have one electronics category
    +    $count = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('url_key', 'electronics')
    +        ->count();
    +
    +    expect($count)->toBe(1);
    +});
    +
    +it('validates category tree structure integrity after import', function () {
    +    // Step 1: Create root category
    +    $csvData1 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        ['', '2', '', 'Root Category', 'root-cat', '1'],
    +    ];
    +    createAndImportEdgeCaseCsv($csvData1);
    +
    +    $rootCat = findCategoryByUrlKeyEdgeCase('root-cat');
    +    expect($rootCat)->not->toBeNull();
    +
    +    // Step 2: Create children under root
    +    $csvData2 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        ['', (string) $rootCat->getId(), '', 'Child 1', 'child1', '1'],
    +        ['', (string) $rootCat->getId(), '', 'Child 2', 'child2', '1'],
    +    ];
    +    createAndImportEdgeCaseCsv($csvData2);
    +
    +    $child1 = findCategoryByUrlKeyEdgeCase('child1');
    +    $child2 = findCategoryByUrlKeyEdgeCase('child2');
    +    expect($child1)->not->toBeNull()
    +        ->and($child2)->not->toBeNull();
    +
    +    // Step 3: Create grandchild under child1
    +    $csvData3 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'url_key', 'is_active'],
    +        ['', (string) $child1->getId(), '', 'Grandchild', 'grandchild', '1'],
    +    ];
    +    createAndImportEdgeCaseCsv($csvData3);
    +
    +    $grandchild = findCategoryByUrlKeyEdgeCase('grandchild');
    +    expect($grandchild)->not->toBeNull();
    +
    +    // Check parent relationships
    +    expect($child1->getParentId())->toBe((int) $rootCat->getId())
    +        ->and($child2->getParentId())->toBe((int) $rootCat->getId())
    +        ->and($grandchild->getParentId())->toBe((int) $child1->getId());
    +
    +    // Check levels are sequential
    +    expect((int) $child1->getLevel())->toBe((int) $rootCat->getLevel() + 1)
    +        ->and((int) $child2->getLevel())->toBe((int) $rootCat->getLevel() + 1)
    +        ->and((int) $grandchild->getLevel())->toBe((int) $child1->getLevel() + 1);
    +
    +    // Check path contains parent IDs
    +    expect($child1->getPath())->toContain((string) $rootCat->getId())
    +        ->and($grandchild->getPath())->toContain((string) $child1->getId())
    +        ->and($grandchild->getPath())->toContain((string) $rootCat->getId());
    +});
    +
    +// Helper methods
    +function createAndImportEdgeCaseCsv(array $data, string $behavior = Mage_ImportExport_Model_Import::BEHAVIOR_APPEND): void
    +{
    +    $tmpFile = tempnam(sys_get_temp_dir(), 'category_import_edge_test');
    +    $handle = fopen($tmpFile, 'w');
    +
    +    foreach ($data as $row) {
    +        fputcsv($handle, $row);
    +    }
    +    fclose($handle);
    +
    +    // Ensure file exists before creating adapter
    +    if (!file_exists($tmpFile)) {
    +        throw new Exception('Failed to create temporary CSV file');
    +    }
    +
    +    // Create fresh import model and CSV adapter
    +    $importModel = Mage::getModel('importexport/import_entity_category');
    +    $csvAdapter = Mage::getModel('importexport/import_adapter_csv', $tmpFile);
    +    $importModel->setSource($csvAdapter);
    +    $importModel->setParameters(['behavior' => $behavior]);
    +
    +    $importModel->validateData();
    +    $importModel->importData();
    +
    +    // Store the import model globally for error checking
    +    $GLOBALS['testImportModelEdge'] = $importModel;
    +
    +    if (file_exists($tmpFile)) {
    +        unlink($tmpFile);
    +    }
    +}
    +
    +function findCategoryByUrlKeyEdgeCase(string $urlKey): ?Mage_Catalog_Model_Category
    +{
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToSelect(['name', 'url_key', 'is_active', 'description'])
    +        ->addAttributeToFilter('url_key', $urlKey)
    +        ->setPageSize(1);
    +
    +    $category = $collection->getFirstItem();
    +    if (!$category->getId()) {
    +        return null;
    +    }
    +
    +    // Reload the category with direct model loading to ensure all attributes are loaded
    +    $freshCategory = Mage::getModel('catalog/category');
    +    $freshCategory->setStoreId(0);
    +    $freshCategory->load($category->getId());
    +
    +    return $freshCategory->getId() ? $freshCategory : null;
    +}
    
  • tests/Backend/Integration/ImportExport/CategoryImportExportRoundTripTest.php+365 0 added
    @@ -0,0 +1,365 @@
    +<?php
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +declare(strict_types=1);
    +
    +use Tests\MahoBackendTestCase;
    +
    +uses(MahoBackendTestCase::class);
    +
    +beforeEach(function () {
    +    $this->importModel = Mage::getModel('importexport/import_entity_category');
    +    $this->exportModel = Mage::getModel('importexport/export_entity_category');
    +    $this->writer = Mage::getModel('importexport/export_adapter_csv');
    +
    +    // CSV adapter will be created in helper function with proper source file
    +    $this->exportModel->setWriter($this->writer);
    +});
    +
    +afterEach(function () {
    +    // Clean up test categories
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('level', ['gt' => 0])
    +        ->addAttributeToFilter('entity_id', ['gt' => 2]);
    +
    +    foreach ($collection as $category) {
    +        try {
    +            $category->delete();
    +        } catch (Exception $e) {
    +            // Ignore cleanup errors
    +        }
    +    }
    +});
    +
    +it('preserves data integrity in export-import round trip', function () {
    +    // Create test categories using exact format that works in CategoryImportTest
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Test RT Electronics', '1', 'test-rt-electronics'],
    +        ['', '2', '', 'Test RT Phones', '1', 'test-rt-phones'],
    +        ['', '2', '', 'Test RT Clothing', '0', 'test-rt-clothing'],
    +    ];
    +
    +    createAndImportRoundTripCsv($csvData);
    +
    +    // Verify categories were created
    +    $electronics = findCategoryByUrlKeyRoundTrip('test-rt-electronics');
    +    $phones = findCategoryByUrlKeyRoundTrip('test-rt-phones');
    +    $clothing = findCategoryByUrlKeyRoundTrip('test-rt-clothing');
    +
    +    expect($electronics)->not->toBeNull()
    +        ->and($phones)->not->toBeNull()
    +        ->and($clothing)->not->toBeNull();
    +
    +    // Export categories
    +    $result = $this->exportModel->exportFile();
    +    expect($result)->toBeArray()
    +        ->and($result['type'])->toBe('string')
    +        ->and($result['rows'])->toBeGreaterThan(0);
    +
    +    $exportedCsv = $result['value'];
    +
    +    // Delete test categories
    +    $electronics->delete();
    +    $phones->delete();
    +    $clothing->delete();
    +
    +    // Verify categories are gone
    +    expect(findCategoryByUrlKeyRoundTrip('test-rt-electronics'))->toBeNull()
    +        ->and(findCategoryByUrlKeyRoundTrip('test-rt-phones'))->toBeNull()
    +        ->and(findCategoryByUrlKeyRoundTrip('test-rt-clothing'))->toBeNull();
    +
    +    // Re-import from exported CSV
    +    importFromCsvStringRoundTrip($exportedCsv);
    +
    +    // Verify categories are restored with correct data
    +    $restoredElectronics = findCategoryByUrlKeyRoundTrip('test-rt-electronics');
    +    $restoredPhones = findCategoryByUrlKeyRoundTrip('test-rt-phones');
    +    $restoredClothing = findCategoryByUrlKeyRoundTrip('test-rt-clothing');
    +
    +    expect($restoredElectronics)->not->toBeNull()
    +        ->and($restoredPhones)->not->toBeNull()
    +        ->and($restoredClothing)->not->toBeNull();
    +
    +    expect($restoredElectronics->getName())->toBe('Test RT Electronics')
    +        ->and($restoredPhones->getName())->toBe('Test RT Phones')
    +        ->and($restoredClothing->getName())->toBe('Test RT Clothing');
    +
    +    expect($restoredElectronics->getIsActive())->toBe('1')
    +        ->and($restoredPhones->getIsActive())->toBe('1')
    +        ->and($restoredClothing->getIsActive())->toBe('0');
    +});
    +
    +it('handles hierarchical categories correctly', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Test Electronics', '1', 'test-electronics'],
    +        ['', '2', '', 'Test Phones', '1', 'test-phones'],
    +    ];
    +
    +    createAndImportRoundTripCsv($csvData);
    +
    +    $electronics = findCategoryByUrlKeyRoundTrip('test-electronics');
    +    $phones = findCategoryByUrlKeyRoundTrip('test-phones');
    +
    +    expect($electronics)->not->toBeNull()
    +        ->and($phones)->not->toBeNull();
    +
    +    // Export, delete, and re-import
    +    $result = $this->exportModel->exportFile();
    +    $exportedCsv = $result['value'];
    +
    +    $electronics->delete();
    +    $phones->delete();
    +
    +    importFromCsvStringRoundTrip($exportedCsv);
    +
    +    // Verify restoration
    +    $restoredElectronics = findCategoryByUrlKeyRoundTrip('test-electronics');
    +    $restoredPhones = findCategoryByUrlKeyRoundTrip('test-phones');
    +
    +    expect($restoredElectronics)->not->toBeNull()
    +        ->and($restoredPhones)->not->toBeNull()
    +        ->and($restoredElectronics->getName())->toBe('Test Electronics')
    +        ->and($restoredPhones->getName())->toBe('Test Phones');
    +});
    +
    +it('preserves url_key during round trip', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Test Special URL', '1', 'test-special-url'],
    +    ];
    +
    +    createAndImportRoundTripCsv($csvData);
    +    $original = findCategoryByUrlKeyRoundTrip('test-special-url');
    +    expect($original)->not->toBeNull();
    +
    +    // Export, delete, re-import
    +    $result = $this->exportModel->exportFile();
    +    $exportedCsv = $result['value'];
    +    $original->delete();
    +
    +    importFromCsvStringRoundTrip($exportedCsv);
    +
    +    $restored = findCategoryByUrlKeyRoundTrip('test-special-url');
    +    expect($restored)->not->toBeNull()
    +        ->and($restored->getName())->toBe('Test Special URL')
    +        ->and($restored->getUrlKey())->toBe('test-special-url');
    +});
    +
    +it('handles multiple categories with different properties', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key', 'description'],
    +        ['', '2', '', 'Active Category', '1', 'active-category', 'Description for active'],
    +        ['', '2', '', 'Inactive Category', '0', 'inactive-category', 'Description for inactive'],
    +    ];
    +
    +    createAndImportRoundTripCsv($csvData);
    +
    +    // Export and verify data integrity through round trip
    +    $result = $this->exportModel->exportFile();
    +    $exportedCsv = $result['value'];
    +
    +    // Clean up and re-import
    +    findCategoryByUrlKeyRoundTrip('active-category')->delete();
    +    findCategoryByUrlKeyRoundTrip('inactive-category')->delete();
    +
    +    importFromCsvStringRoundTrip($exportedCsv);
    +
    +    $activeRestored = findCategoryByUrlKeyRoundTrip('active-category');
    +    $inactiveRestored = findCategoryByUrlKeyRoundTrip('inactive-category');
    +
    +    expect($activeRestored)->not->toBeNull()
    +        ->and($inactiveRestored)->not->toBeNull()
    +        ->and($activeRestored->getIsActive())->toBe('1')
    +        ->and($inactiveRestored->getIsActive())->toBe('0');
    +});
    +
    +it('handles store-specific category data', function () {
    +    // Create category with store-specific data
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Test Electronics', '1', 'test-electronics'], // Default store
    +        ['', '2', 'en', 'Test Elektronik', '1', ''], // English store specific
    +    ];
    +
    +    createAndImportRoundTripCsv($csvData);
    +
    +    $category = findCategoryByUrlKeyRoundTrip('test-electronics');
    +    expect($category)->not->toBeNull();
    +
    +    // Verify store-specific name exists
    +    $category->setStoreId(1); // English store
    +    $category->load($category->getId());
    +
    +    // Export, delete, re-import
    +    $result = $this->exportModel->exportFile();
    +    $exportedCsv = $result['value'];
    +
    +    $category->delete();
    +
    +    importFromCsvStringRoundTrip($exportedCsv);
    +
    +    $restored = findCategoryByUrlKeyRoundTrip('test-electronics');
    +    expect($restored)->not->toBeNull()
    +        ->and($restored->getName())->toBe('Test Electronics'); // Default name should be restored
    +});
    +
    +function createAndImportRoundTripCsv(array $csvData): void
    +{
    +    $csvString = '';
    +    foreach ($csvData as $row) {
    +        $csvString .= implode(',', array_map(function ($field) {
    +            return '"' . str_replace('"', '""', (string) $field) . '"';
    +        }, $row)) . "\n";
    +    }
    +
    +    $tmpFile = tempnam(sys_get_temp_dir(), 'category_test');
    +    file_put_contents($tmpFile, $csvString);
    +
    +    $importModel = Mage::getModel('importexport/import_entity_category');
    +    $csvAdapter = Mage::getModel('importexport/import_adapter_csv', $tmpFile);
    +    $importModel->setSource($csvAdapter);
    +    $importModel->setParameters(['behavior' => Mage_ImportExport_Model_Import::BEHAVIOR_APPEND]);
    +
    +    $validationResult = $importModel->validateData();
    +    $importResult = $importModel->importData();
    +
    +    if (!$validationResult || !$importResult) {
    +        throw new Exception('Failed to import test categories: ' . print_r($importModel->getErrorMessages(), true));
    +    }
    +
    +    unlink($tmpFile);
    +}
    +
    +function importFromCsvStringRoundTrip(string $csvContent): void
    +{
    +    // Convert complex 25-column export CSV to simple 6-column import CSV
    +    $lines = explode("\n", $csvContent);
    +    if (count($lines) < 2) {
    +        throw new Exception('CSV content is too short');
    +    }
    +
    +    $header = str_getcsv($lines[0]);
    +
    +    // Find required column positions
    +    $categoryIdPos = array_search('category_id', $header);
    +    $parentIdPos = array_search('parent_id', $header);
    +    $storePos = array_search('_store', $header);
    +    $namePos = array_search('name', $header);
    +    $isActivePos = array_search('is_active', $header);
    +    $urlKeyPos = array_search('url_key', $header);
    +    $descriptionPos = array_search('description', $header);
    +
    +    if ($categoryIdPos === false || $parentIdPos === false || $storePos === false ||
    +        $namePos === false || $urlKeyPos === false) {
    +        throw new Exception('Required columns not found in export CSV');
    +    }
    +
    +    // Build simplified CSV with only supported columns
    +    $simplifiedLines = ['category_id,parent_id,_store,name,is_active,url_key,description'];
    +
    +    for ($i = 1; $i < count($lines); $i++) {
    +        $line = trim($lines[$i]);
    +        if (empty($line)) {
    +            continue;
    +        }
    +
    +        $cols = str_getcsv($line);
    +        if (count($cols) <= max($categoryIdPos, $parentIdPos, $storePos, $namePos)) {
    +            continue;
    +        }
    +
    +        // Only process default store rows (empty _store column) for now
    +        $storeValue = $cols[$storePos];
    +        if (empty($storeValue) || $storeValue === '""') {
    +            $simplifiedRow = [
    +                '', // Clear category_id to create new categories
    +                $cols[$parentIdPos],
    +                '', // Empty store for default
    +                $cols[$namePos],
    +                $isActivePos !== false ? ($cols[$isActivePos] !== '' ? $cols[$isActivePos] : '1') : '1', // Default to active
    +                $cols[$urlKeyPos],
    +                $descriptionPos !== false ? $cols[$descriptionPos] : '',
    +            ];
    +
    +            $simplifiedLines[] = implode(',', array_map(function ($field) {
    +                return '"' . str_replace('"', '""', $field) . '"';
    +            }, $simplifiedRow));
    +        }
    +    }
    +
    +    $simplifiedCsv = implode("\n", $simplifiedLines);
    +
    +    // Import the simplified CSV
    +    $tmpFile = tempnam(sys_get_temp_dir(), 'category_roundtrip_import');
    +    file_put_contents($tmpFile, $simplifiedCsv);
    +
    +    if (!file_exists($tmpFile) || filesize($tmpFile) === 0) {
    +        throw new Exception('Failed to create temporary CSV file or file is empty');
    +    }
    +
    +    $importModel = Mage::getModel('importexport/import_entity_category');
    +    $csvAdapter = Mage::getModel('importexport/import_adapter_csv', $tmpFile);
    +    $importModel->setSource($csvAdapter);
    +    $importModel->setParameters(['behavior' => Mage_ImportExport_Model_Import::BEHAVIOR_APPEND]);
    +
    +    $validationResult = $importModel->validateData();
    +    $importResult = $importModel->importData();
    +
    +    // Only show errors if import actually failed
    +    if (!$importResult) {
    +        $errors = $importModel->getErrorMessages();
    +        echo 'IMPORT FAILED: ' . print_r($errors, true) . "\n";
    +    }
    +
    +    if (file_exists($tmpFile)) {
    +        unlink($tmpFile);
    +    }
    +}
    +
    +function findCategoryByUrlKeyRoundTrip(string $urlKey): ?Mage_Catalog_Model_Category
    +{
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToSelect(['name', 'url_key', 'is_active', 'description', 'include_in_menu', 'meta_title', 'meta_description'])
    +        ->addAttributeToFilter('url_key', $urlKey)
    +        ->setPageSize(1);
    +
    +    $category = $collection->getFirstItem();
    +    if (!$category->getId()) {
    +        return null;
    +    }
    +
    +    // Reload the category with direct model loading to ensure all attributes are loaded
    +    $freshCategory = Mage::getModel('catalog/category');
    +    $freshCategory->setStoreId(0);
    +    $freshCategory->load($category->getId());
    +
    +    return $freshCategory->getId() ? $freshCategory : null;
    +}
    +
    +function findCategoryByNameRoundTrip(string $name): ?Mage_Catalog_Model_Category
    +{
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToSelect(['name', 'url_key', 'is_active', 'description', 'include_in_menu', 'meta_title', 'meta_description'])
    +        ->addAttributeToFilter('name', $name)
    +        ->setPageSize(1);
    +
    +    $category = $collection->getFirstItem();
    +    if (!$category->getId()) {
    +        return null;
    +    }
    +
    +    // Reload the category with direct model loading to ensure all attributes are loaded
    +    $freshCategory = Mage::getModel('catalog/category');
    +    $freshCategory->setStoreId(0);
    +    $freshCategory->load($category->getId());
    +
    +    return $freshCategory->getId() ? $freshCategory : null;
    +}
    
  • tests/Backend/Integration/ImportExport/CategoryImportTest.php+364 0 added
    @@ -0,0 +1,364 @@
    +<?php
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +declare(strict_types=1);
    +
    +use Tests\MahoBackendTestCase;
    +
    +uses(MahoBackendTestCase::class);
    +
    +beforeEach(function () {
    +    // Create import model
    +    $this->importModel = Mage::getModel('importexport/import_entity_category');
    +
    +    // CSV adapter will be created in helper function with proper source file
    +
    +    // Store original category count
    +    $this->originalCategoryCount = Mage::getModel('catalog/category')
    +        ->getCollection()
    +        ->addAttributeToFilter('level', ['gt' => 0])
    +        ->count();
    +});
    +
    +afterEach(function () {
    +    // Clean up any created categories
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToFilter('level', ['gt' => 0])
    +        ->addAttributeToFilter('entity_id', ['gt' => 2]); // Don't delete default category
    +
    +    foreach ($collection as $category) {
    +        try {
    +            $category->delete();
    +        } catch (Exception $e) {
    +            // Ignore deletion errors in cleanup
    +        }
    +    }
    +});
    +
    +it('creates new categories with parent_id', function () {
    +    // First import top-level categories
    +    $csvData1 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Electronics', '1', 'electronics'],
    +        ['', '2', '', 'Clothing', '1', 'clothing'],
    +    ];
    +    createAndImportCsv($csvData1);
    +
    +    $electronics = findCategoryByUrlKey('electronics');
    +    $clothing = findCategoryByUrlKey('clothing');
    +
    +    // Then import subcategories
    +    $csvData2 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', (string) $electronics->getId(), '', 'Phones', '1', 'phones'],
    +    ];
    +    createAndImportCsv($csvData2);
    +
    +    // Check electronics category was created
    +    expect($electronics)->not->toBeNull()
    +        ->and($electronics->getName())->toBe('Electronics')
    +        ->and($electronics->getIsActive())->toBe('1');
    +
    +    // Check phones subcategory was created with correct parent
    +    $phones = findCategoryByUrlKey('phones');
    +    expect($phones)->not->toBeNull()
    +        ->and($phones->getName())->toBe('Phones')
    +        ->and($phones->getParentId())->toBe((int) $electronics->getId());
    +
    +    // Check clothing category was created
    +    $clothing = findCategoryByUrlKey('clothing');
    +    expect($clothing)->not->toBeNull()
    +        ->and($clothing->getName())->toBe('Clothing');
    +});
    +
    +it('updates existing categories', function () {
    +    // First create a category
    +    $category = Mage::getModel('catalog/category');
    +    $category->setName('Original Name')
    +        ->setUrlKey('test-category')
    +        ->setIsActive(0)
    +        ->setParentId(2)
    +        ->setStoreId(0)
    +        ->save();
    +
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active'],
    +        [(string) $category->getId(), '', '', 'Updated Name', '1'],
    +    ];
    +
    +    createAndImportCsv($csvData);
    +
    +    // Reload category and check it was updated
    +    $category->load($category->getId());
    +    expect($category->getName())->toBe('Updated Name')
    +        ->and($category->getIsActive())->toBe('1');
    +});
    +
    +it('handles multi-store data correctly', function () {
    +    // First create the category
    +    $csvData1 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'description', 'url_key'],
    +        ['', '2', '', 'Test English', 'English description', 'test-multistore'],
    +    ];
    +    createAndImportCsv($csvData1);
    +
    +    $category = findCategoryByUrlKey('test-multistore');
    +
    +    // Then update with store-specific data
    +    $csvData2 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'description'],
    +        [(string) $category->getId(), '', 'default', 'Test German', 'German description'],
    +    ];
    +    createAndImportCsv($csvData2);
    +    expect($category)->not->toBeNull();
    +
    +    // Check default store data (admin store)
    +    $adminCategory = Mage::getModel('catalog/category');
    +    $adminCategory->setStoreId(0);
    +    $adminCategory->load($category->getId());
    +    expect($adminCategory->getName())->toBe('Test English')
    +        ->and($adminCategory->getDescription())->toBe('English description');
    +
    +    // Check store-specific data (store 1)
    +    $storeCategory = Mage::getModel('catalog/category');
    +    $storeCategory->setStoreId(1);
    +    $storeCategory->load($category->getId());
    +    expect($storeCategory->getName())->toBe('Test German')
    +        ->and($storeCategory->getDescription())->toBe('German description');
    +});
    +
    +it('validates required parent_id field for new categories', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name'],
    +        ['', '', '', 'Invalid Category'], // No parent_id for new category
    +    ];
    +
    +    createAndImportCsv($csvData);
    +
    +    // Should have validation errors
    +    expect($GLOBALS['testImportModel']->getErrorsCount())->toBeGreaterThan(0);
    +
    +    $errors = $GLOBALS['testImportModel']->getErrorMessages();
    +    $hasPathEmptyError = false;
    +    foreach ($errors as $message => $rows) {
    +        if (strpos($message, 'Category path is empty') !== false) {
    +            $hasPathEmptyError = true;
    +            break;
    +        }
    +    }
    +    expect($hasPathEmptyError)->toBeTrue();
    +});
    +
    +it('validates parent_id format', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name'],
    +        ['', 'invalid_id', '', 'Invalid Category'],
    +        ['', '99999', '', 'Non-existent Parent'], // Non-existent parent ID
    +    ];
    +
    +    createAndImportCsv($csvData);
    +
    +    // Should have validation errors for invalid parent IDs
    +    expect($GLOBALS['testImportModel']->getErrorsCount())->toBeGreaterThan(0);
    +});
    +
    +it('validates parent category exists', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name'],
    +        ['', '9999', '', 'Child Category'], // Non-existent parent ID
    +    ];
    +
    +    createAndImportCsv($csvData);
    +
    +    // Should have validation errors for missing parent
    +    expect($GLOBALS['testImportModel']->getErrorsCount())->toBeGreaterThan(0);
    +
    +    $errors = $GLOBALS['testImportModel']->getErrorMessages();
    +    $hasParentError = false;
    +    foreach ($errors as $message => $rows) {
    +        if (strpos($message, 'not found') !== false) {
    +            $hasParentError = true;
    +            break;
    +        }
    +    }
    +    expect($hasParentError)->toBeTrue();
    +});
    +
    +it('handles delete behavior correctly', function () {
    +    // Create test category first
    +    $category = Mage::getModel('catalog/category');
    +    $category->setName('To Delete')
    +        ->setUrlKey('to-delete')
    +        ->setIsActive(1)
    +        ->setParentId(2)
    +        ->setStoreId(0)
    +        ->save();
    +
    +    $categoryId = $category->getId();
    +
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name'],
    +        [(string) $categoryId, '', '', 'To Delete'],
    +    ];
    +
    +    createAndImportCsv($csvData, Mage_ImportExport_Model_Import::BEHAVIOR_DELETE);
    +
    +    // Category should be deleted
    +    $deletedCategory = Mage::getModel('catalog/category')->load($categoryId);
    +    expect($deletedCategory->getId())->toBeNull();
    +});
    +
    +it('maintains category tree integrity', function () {
    +    // Import Level 1 first
    +    $csvData1 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', '2', '', 'Level 1', '1', 'level1'],
    +    ];
    +    createAndImportCsv($csvData1);
    +    $level1 = findCategoryByUrlKey('level1');
    +
    +    // Import Level 2 under Level 1
    +    $csvData2 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', (string) $level1->getId(), '', 'Level 2', '1', 'level2'],
    +    ];
    +    createAndImportCsv($csvData2);
    +    $level2 = findCategoryByUrlKey('level2');
    +
    +    // Import Level 3 under Level 2
    +    $csvData3 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'is_active', 'url_key'],
    +        ['', (string) $level2->getId(), '', 'Level 3', '1', 'level3'],
    +    ];
    +    createAndImportCsv($csvData3);
    +    $level3 = findCategoryByUrlKey('level3');
    +
    +    expect($level1)->not->toBeNull()
    +        ->and($level2)->not->toBeNull()
    +        ->and($level3)->not->toBeNull();
    +
    +    // Check parent-child relationships
    +    expect($level2->getParentId())->toBe((int) $level1->getId())
    +        ->and($level3->getParentId())->toBe((int) $level2->getId());
    +
    +    // Check levels are correct
    +    expect((int) $level1->getLevel())->toBe(2) // Root is level 1, so first level is 2
    +        ->and((int) $level2->getLevel())->toBe(3)
    +        ->and((int) $level3->getLevel())->toBe(4);
    +
    +    // Check paths are correct
    +    expect($level1->getPath())->toContain('1/2/' . $level1->getId()) // Under default category
    +        ->and($level2->getPath())->toContain($level1->getId() . '/' . $level2->getId())
    +        ->and($level3->getPath())->toContain($level2->getId() . '/' . $level3->getId());
    +});
    +
    +it('generates url_key from name when missing', function () {
    +    $csvData = [
    +        ['category_id', 'parent_id', '_store', 'name'],
    +        ['', '2', '', 'Auto Generated Key!'], // Name with special chars, no url_key
    +    ];
    +
    +    createAndImportCsv($csvData);
    +
    +    $category = findCategoryByUrlKey('auto-generated-key-');
    +    expect($category)->not->toBeNull()
    +        ->and($category->getName())->toBe('Auto Generated Key!')
    +        ->and($category->getUrlKey())->toBe('auto-generated-key-');
    +});
    +
    +it('handles scope resolution correctly', function () {
    +    // First create categories in default scope
    +    $csvData1 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'description', 'url_key'],
    +        ['', '2', '', 'Test Category', 'Default description', 'test-scope'],
    +        ['', '2', '', 'Another Category', 'Another description', 'another-category'],
    +    ];
    +    createAndImportCsv($csvData1);
    +
    +    $category = findCategoryByUrlKey('test-scope');
    +
    +    // Then update with store scope data
    +    $csvData2 = [
    +        ['category_id', 'parent_id', '_store', 'name', 'description'],
    +        [(string) $category->getId(), '', 'default', '', 'Store description'], // Store scope for same category
    +    ];
    +    createAndImportCsv($csvData2);
    +
    +    $category = findCategoryByUrlKey('test-scope');
    +    expect($category)->not->toBeNull();
    +
    +    // Default store values
    +    $adminCategory = Mage::getModel('catalog/category');
    +    $adminCategory->setStoreId(0);
    +    $adminCategory->load($category->getId());
    +    expect($adminCategory->getName())->toBe('Test Category')
    +        ->and($adminCategory->getDescription())->toBe('Default description');
    +
    +    // Store-specific values
    +    $storeCategory = Mage::getModel('catalog/category');
    +    $storeCategory->setStoreId(1);
    +    $storeCategory->load($category->getId());
    +    expect($storeCategory->getDescription())->toBe('Store description');
    +});
    +
    +// Helper methods
    +function createAndImportCsv(array $data, string $behavior = Mage_ImportExport_Model_Import::BEHAVIOR_APPEND): void
    +{
    +    // Create temporary CSV file
    +    $tmpFile = tempnam(sys_get_temp_dir(), 'category_import_test');
    +    $handle = fopen($tmpFile, 'w');
    +
    +    foreach ($data as $row) {
    +        fputcsv($handle, $row);
    +    }
    +    fclose($handle);
    +
    +    // Ensure file exists before creating adapter
    +    if (!file_exists($tmpFile)) {
    +        throw new Exception('Failed to create temporary CSV file');
    +    }
    +
    +    // Create fresh import model and CSV adapter
    +    $importModel = Mage::getModel('importexport/import_entity_category');
    +    $csvAdapter = Mage::getModel('importexport/import_adapter_csv', $tmpFile);
    +    $importModel->setSource($csvAdapter);
    +    $importModel->setParameters(['behavior' => $behavior]);
    +
    +    // Validate and import
    +    $importModel->validateData();
    +    $importModel->importData();
    +
    +    // Store the import model globally for error checking
    +    $GLOBALS['testImportModel'] = $importModel;
    +
    +    // Clean up
    +    if (file_exists($tmpFile)) {
    +        unlink($tmpFile);
    +    }
    +}
    +
    +function findCategoryByUrlKey(string $urlKey): ?Mage_Catalog_Model_Category
    +{
    +    $collection = Mage::getModel('catalog/category')->getCollection()
    +        ->addAttributeToSelect('*')
    +        ->addAttributeToFilter('url_key', $urlKey)
    +        ->setPageSize(1);
    +
    +    $category = $collection->getFirstItem();
    +    if (!$category->getId()) {
    +        return null;
    +    }
    +
    +    // Return fresh model instance that can be reloaded for different stores
    +    $freshCategory = Mage::getModel('catalog/category');
    +    $freshCategory->setStoreId(0); // Ensure we start with admin store
    +    $freshCategory->load($category->getId());
    +
    +    return $freshCategory->getId() ? $freshCategory : null;
    +}
    
  • tests/Install/BootstrapTest.php+87 0 modified
    @@ -19,3 +19,90 @@
         // Test that Maho root path is set correctly (should point to main Maho directory)
         expect(Mage::getRoot())->toBe(dirname(__DIR__, 2));
     });
    +
    +describe('Sale Category Integration', function () {
    +    test('sale category exists and contains products', function () {
    +        // Find the Sale category
    +        $saleCategory = Mage::getResourceModel('catalog/category_collection')
    +            ->addAttributeToSelect('*')
    +            ->addFieldToFilter('name', 'Sale')
    +            ->addIsActiveFilter()
    +            ->getFirstItem();
    +
    +        expect($saleCategory->getId())->not()->toBeNull()
    +            ->and($saleCategory->getName())->toBe('Sale')
    +            ->and($saleCategory->getIsActive())->toBe('1');
    +
    +        // Get products in the Sale category
    +        $productCollection = Mage::getResourceModel('catalog/product_collection')
    +            ->addAttributeToSelect('*')
    +            ->addCategoryFilter($saleCategory)
    +            ->addAttributeToFilter('status', Mage_Catalog_Model_Product_Status::STATUS_ENABLED)
    +            ->addAttributeToFilter('visibility', [
    +                'in' => [
    +                    Mage_Catalog_Model_Product_Visibility::VISIBILITY_IN_CATALOG,
    +                    Mage_Catalog_Model_Product_Visibility::VISIBILITY_BOTH,
    +                ],
    +            ]);
    +
    +        // Verify the Sale category contains products
    +        expect($productCollection->getSize())->toBeGreaterThan(0);
    +    });
    +
    +    test('sale category products have special prices or are on sale', function () {
    +        // Find the Sale category
    +        $saleCategory = Mage::getResourceModel('catalog/category_collection')
    +            ->addAttributeToSelect('*')
    +            ->addFieldToFilter('name', 'Sale')
    +            ->addIsActiveFilter()
    +            ->getFirstItem();
    +
    +        // Skip if Sale category doesn't exist
    +        if (!$saleCategory->getId()) {
    +            $this->markTestSkipped('Sale category not found - sample data may not be installed');
    +        }
    +
    +        // Get products in the Sale category
    +        $productCollection = Mage::getResourceModel('catalog/product_collection')
    +            ->addAttributeToSelect(['name', 'price', 'special_price', 'special_from_date', 'special_to_date'])
    +            ->addCategoryFilter($saleCategory)
    +            ->addAttributeToFilter('status', Mage_Catalog_Model_Product_Status::STATUS_ENABLED)
    +            ->setPageSize(10);
    +
    +        $productsWithSpecialPrice = 0;
    +        $totalProducts = $productCollection->getSize();
    +
    +        foreach ($productCollection as $product) {
    +            $specialPrice = $product->getSpecialPrice();
    +            $regularPrice = $product->getPrice();
    +
    +            // Check if product has a special price that's lower than regular price
    +            if ($specialPrice && $specialPrice < $regularPrice) {
    +                // Verify special price dates if set
    +                $specialFromDate = $product->getSpecialFromDate();
    +                $specialToDate = $product->getSpecialToDate();
    +
    +                $now = Mage_Core_Model_Locale::now();
    +                $isSpecialPriceActive = true;
    +
    +                if ($specialFromDate && $specialFromDate > $now) {
    +                    $isSpecialPriceActive = false;
    +                }
    +
    +                if ($specialToDate && $specialToDate < $now) {
    +                    $isSpecialPriceActive = false;
    +                }
    +
    +                if ($isSpecialPriceActive) {
    +                    $productsWithSpecialPrice++;
    +                }
    +            }
    +        }
    +
    +        // Verify that at least some products in the Sale category have special prices
    +        // This is a reasonable expectation for a "Sale" category
    +        if ($totalProducts > 0) {
    +            expect($productsWithSpecialPrice)->toBeGreaterThan(0);
    +        }
    +    });
    +});
    
db54a1b44e9b

Enhanced file extension security validation for product custom options (#303)

https://github.com/mahocommerce/mahoFabrizio BallianoSep 6, 2025via osv
10 files changed · +518 12
  • app/code/core/Mage/Catalog/Block/Product/View/Options/Type/File.php+14 0 modified
    @@ -6,6 +6,7 @@
      * @package    Mage_Catalog
      * @copyright  Copyright (c) 2006-2020 Magento, Inc. (https://magento.com)
      * @copyright  Copyright (c) 2020-2024 The OpenMage Contributors (https://openmage.org)
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
      * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
      */
     
    @@ -26,4 +27,17 @@ public function getFileInfo()
             }
             return $info;
         }
    +
    +    /**
    +     * Get sanitized file extensions for display (removes forbidden extensions)
    +     */
    +    public function getSanitizedFileExtension(): string
    +    {
    +        $option = $this->getOption();
    +        $originalExtensions = $option->getFileExtension();
    +
    +        $result = Mage::helper('catalog')->validateFileExtensionsAgainstForbiddenList($originalExtensions);
    +
    +        return empty($result['allowed']) ? '' : implode(', ', $result['allowed']);
    +    }
     }
    
  • app/code/core/Mage/Catalog/etc/config.xml+1 1 modified
    @@ -810,7 +810,7 @@
                 <custom_options>
                     <date_fields_order>m,d,y</date_fields_order>
                     <time_format>12h</time_format>
    -                <forbidden_extensions>php,exe</forbidden_extensions>
    +                <forbidden_extensions>php,php3,php4,php5,php7,php8,phtml,phar,exe,bat,cmd,com,scr,vbs,js,jar,py,pl,rb,sh,asp,aspx,jsp,cgi,htaccess</forbidden_extensions>
                 </custom_options>
                 <layered_navigation>
                     <price_range_calculation>auto</price_range_calculation>
    
  • app/code/core/Mage/Catalog/Helper/Data.php+48 0 modified
    @@ -6,6 +6,7 @@
      * @package    Mage_Catalog
      * @copyright  Copyright (c) 2006-2020 Magento, Inc. (https://magento.com)
      * @copyright  Copyright (c) 2019-2024 The OpenMage Contributors (https://openmage.org)
    + * @copyright  Copyright (c) 2024-2025 Maho (https://mahocommerce.com)
      * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
      */
     
    @@ -448,4 +449,51 @@ public function shouldDisplayProductCountOnLayer($storeId = null)
         {
             return Mage::getStoreConfigFlag(self::XML_PATH_DISPLAY_PRODUCT_COUNT, $storeId);
         }
    +
    +    /**
    +     * Validate file extensions against forbidden list
    +     *
    +     * @param string $extensions Comma-separated list of extensions
    +     * @return array Array with 'allowed', 'forbidden', and 'original' keys
    +     */
    +    public function validateFileExtensionsAgainstForbiddenList(string $extensions): array
    +    {
    +        $result = [
    +            'allowed' => [],
    +            'forbidden' => [],
    +            'original' => $extensions,
    +        ];
    +
    +        if (!$extensions) {
    +            return $result;
    +        }
    +
    +        // Parse input extensions
    +        preg_match_all('/[a-z0-9]+/si', strtolower($extensions), $matches);
    +        $inputExtensions = $matches[0];
    +
    +        if (empty($inputExtensions)) {
    +            return $result;
    +        }
    +
    +        // Get forbidden extensions from config
    +        $forbiddenExtensionsConfig = Mage::getStoreConfig('catalog/custom_options/forbidden_extensions');
    +        if (!$forbiddenExtensionsConfig) {
    +            $result['allowed'] = $inputExtensions;
    +            return $result;
    +        }
    +
    +        $forbiddenExtensions = array_map('trim', array_map('strtolower', explode(',', $forbiddenExtensionsConfig)));
    +
    +        // Split extensions into allowed and forbidden
    +        foreach ($inputExtensions as $extension) {
    +            if (in_array($extension, $forbiddenExtensions)) {
    +                $result['forbidden'][] = $extension;
    +            } else {
    +                $result['allowed'][] = $extension;
    +            }
    +        }
    +
    +        return $result;
    +    }
     }
    
  • app/code/core/Mage/Catalog/Model/Product/Option.php+42 0 modified
    @@ -387,12 +387,54 @@ public function saveOptions()
                             }
                         }
                     }
    +
    +                // Validate file extensions before saving
    +                if ($this->getData('type') === self::OPTION_TYPE_FILE && $this->getData('file_extension')) {
    +                    $this->validateFileExtensions($this->getData('file_extension'));
    +                }
    +
                     $this->save();
                 }
             }//eof foreach()
             return $this;
         }
     
    +    /**
    +     * @throws Mage_Core_Exception
    +     */
    +    #[\Override]
    +    protected function _beforeSave(): self
    +    {
    +        parent::_beforeSave();
    +
    +        // Validate file extensions for file type options
    +        if ($this->getType() === self::OPTION_TYPE_FILE && $this->getFileExtension()) {
    +            $this->validateFileExtensions($this->getFileExtension());
    +        }
    +
    +        return $this;
    +    }
    +
    +    /**
    +     * Validate file extensions against forbidden list
    +     * @throws Mage_Core_Exception
    +     */
    +    protected function validateFileExtensions(string $extensions): self
    +    {
    +        $result = Mage::helper('catalog')->validateFileExtensionsAgainstForbiddenList($extensions);
    +
    +        if (!empty($result['forbidden'])) {
    +            throw new Mage_Core_Exception(
    +                Mage::helper('catalog')->__(
    +                    'The following file extensions are not allowed for security reasons: %s',
    +                    implode(', ', $result['forbidden']),
    +                ),
    +            );
    +        }
    +
    +        return $this;
    +    }
    +
         /**
          * After save
          *
    
  • app/code/core/Mage/Catalog/Model/Product/Option/Type/File.php+23 9 modified
    @@ -230,10 +230,22 @@ protected function _validateUploadedFile()
     
             // File extension - validate this FIRST
             $_allowed = $this->_parseExtensionsString($option->getFileExtension());
    +        $_forbidden = $this->_parseExtensionsString(Mage::getStoreConfig('catalog/custom_options/forbidden_extensions'));
    +
             if ($_allowed !== null) {
    +            // Check if any allowed extension is in the forbidden list
    +            if ($_forbidden !== null) {
    +                $forbiddenFound = array_intersect(array_map('strtolower', $_allowed), array_map('strtolower', $_forbidden));
    +                if (!empty($forbiddenFound)) {
    +                    Mage::throwException(Mage::helper('catalog')->__(
    +                        'The following file extensions are not allowed for security reasons: %s',
    +                        implode(', ', $forbiddenFound),
    +                    ));
    +                }
    +            }
                 $upload->addValidator('Extension', false, $_allowed);
             } else {
    -            $_forbidden = $this->_parseExtensionsString($this->getConfigData('forbidden_extensions'));
    +            // No specific allowed extensions - use forbidden list as fallback
                 if ($_forbidden !== null) {
                     $upload->addValidator('ExcludeExtension', false, $_forbidden);
                 }
    @@ -380,18 +392,20 @@ protected function _validateFile($optionValue)
     
             // File extension validation - check this FIRST before trying to read the file
             $_allowed = $this->_parseExtensionsString($option->getFileExtension());
    +        $_forbidden = $this->_parseExtensionsString(Mage::getStoreConfig('catalog/custom_options/forbidden_extensions'));
    +        $extension = strtolower(pathinfo($fileFullPath, PATHINFO_EXTENSION));
    +
             if ($_allowed !== null) {
    -            $extension = strtolower(pathinfo($fileFullPath, PATHINFO_EXTENSION));
    -            if (!in_array($extension, array_map('strtolower', $_allowed))) {
    +            // Check if allowed extension is in forbidden list first
    +            if ($_forbidden !== null && in_array($extension, array_map('strtolower', $_forbidden))) {
    +                $errors[] = sprintf('The file extension "%s" is not allowed for security reasons.', $extension);
    +            } elseif (!in_array($extension, array_map('strtolower', $_allowed))) {
                     $errors[] = sprintf('The file extension "%s" is not allowed.', $extension);
                 }
             } else {
    -            $_forbidden = $this->_parseExtensionsString($this->getConfigData('forbidden_extensions'));
    -            if ($_forbidden !== null) {
    -                $extension = strtolower(pathinfo($fileFullPath, PATHINFO_EXTENSION));
    -                if (in_array($extension, array_map('strtolower', $_forbidden))) {
    -                    $errors[] = sprintf('The file extension "%s" is not allowed.', $extension);
    -                }
    +            // No specific allowed extensions - check forbidden list
    +            if ($_forbidden !== null && in_array($extension, array_map('strtolower', $_forbidden))) {
    +                $errors[] = sprintf('The file extension "%s" is not allowed for security reasons.', $extension);
                 }
             }
     
    
  • app/design/frontend/base/default/template/catalog/product/view/options/type/file.phtml+3 2 modified
    @@ -41,8 +41,9 @@ $jsVarName = 'opFile' . rand();
         <div class="input-box<?= $fileExists ? ' no-display' : '' ?>">
             <input type="file" id="option_<?= $optionId ?>" name="<?= $fileName ?>" class="product-custom-option<?= $option->getIsRequired() ? ' required-entry' : '' ?>" <?= $fileExists ? 'disabled="disabled"' : '' ?> onchange="opConfig.reloadPrice()" />
             <input type="hidden" name="<?= $fieldNameAction ?>" value="<?= $fieldValueAction ?>" />
    -        <?php if ($option->getFileExtension()): ?>
    -        <p class="no-margin"><?= Mage::helper('catalog')->__('Allowed file extensions to upload')?>: <strong><?= $option->getFileExtension() ?></strong></p>
    +        <?php $sanitizedExtensions = $this->getSanitizedFileExtension(); ?>
    +        <?php if ($sanitizedExtensions): ?>
    +        <p class="no-margin"><?= Mage::helper('catalog')->__('Allowed file extensions to upload')?>: <strong><?= $sanitizedExtensions ?></strong></p>
             <?php endif ?>
             <?php if ($option->getImageSizeX() > 0): ?>
             <p class="no-margin"><?= Mage::helper('catalog')->__('Maximum image width')?>: <strong><?= $option->getImageSizeX() ?> <?= Mage::helper('catalog')->__('px.') ?></strong></p>
    
  • app/locale/en_US/Mage_Catalog.csv+1 0 modified
    @@ -694,6 +694,7 @@
     "The file was only partially uploaded. Please try again.","The file was only partially uploaded. Please try again."
     "The file you uploaded is larger than %s Megabytes allowed by server","The file you uploaded is larger than %s Megabytes allowed by server"
     "The filters must be an array.","The filters must be an array."
    +"The following file extensions are not allowed for security reasons: %s","The following file extensions are not allowed for security reasons: %s"
     "The From Date value should be less than or equal to the To Date value.","The From Date value should be less than or equal to the To Date value."
     "The image contents is not valid base64 data.","The image contents is not valid base64 data."
     "The image is not specified.","The image is not specified."
    
  • tests/Backend/Unit/Catalog/Helper/FileExtensionValidationTest.php+91 0 added
    @@ -0,0 +1,91 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +uses(Tests\MahoBackendTestCase::class);
    +
    +describe('Catalog Helper File Extension Security Validation', function () {
    +    beforeEach(function () {
    +        $this->helper = Mage::helper('catalog');
    +    });
    +
    +    describe('validateFileExtensionsAgainstForbiddenList', function () {
    +        it('returns empty arrays for empty input', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('');
    +
    +            expect($result)->toBe([
    +                'allowed' => [],
    +                'forbidden' => [],
    +                'original' => '',
    +            ]);
    +        });
    +
    +        it('allows safe file extensions', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('jpg,png,pdf,txt');
    +
    +            expect($result['allowed'])->toBe(['jpg', 'png', 'pdf', 'txt']);
    +            expect($result['forbidden'])->toBe([]);
    +            expect($result['original'])->toBe('jpg,png,pdf,txt');
    +        });
    +
    +        it('blocks forbidden extensions like PHP files', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('jpg,php,png');
    +
    +            expect($result['allowed'])->toBe(['jpg', 'png']);
    +            expect($result['forbidden'])->toBe(['php']);
    +            expect($result['original'])->toBe('jpg,php,png');
    +        });
    +
    +        it('blocks multiple forbidden extensions', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('exe,bat,php,phtml');
    +
    +            expect($result['allowed'])->toBe([]);
    +            expect($result['forbidden'])->toBe(['exe', 'bat', 'php', 'phtml']);
    +        });
    +
    +        it('handles case-insensitive extension validation', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('JPG,PHP,PNG,EXE');
    +
    +            expect($result['allowed'])->toBe(['jpg', 'png']);
    +            expect($result['forbidden'])->toBe(['php', 'exe']);
    +        });
    +
    +        it('parses extensions with various separators', function () {
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList('jpg; png, pdf php');
    +
    +            expect($result['allowed'])->toBe(['jpg', 'png', 'pdf']);
    +            expect($result['forbidden'])->toBe(['php']);
    +        });
    +
    +        it('blocks all dangerous script extensions', function () {
    +            $dangerousExtensions = 'php,phtml,php3,php4,php5,js,vbs,pl,py,rb';
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList($dangerousExtensions);
    +
    +            expect($result['allowed'])->toBe([]);
    +            expect($result['forbidden'])->toContain('php', 'phtml', 'js', 'vbs');
    +        });
    +
    +        it('blocks executable file extensions', function () {
    +            $executableExtensions = 'exe,bat,cmd,com,scr';
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList($executableExtensions);
    +
    +            expect($result['allowed'])->toBe([]);
    +            expect($result['forbidden'])->toContain('exe', 'bat', 'cmd');
    +        });
    +
    +        it('allows mixed valid and blocks dangerous extensions', function () {
    +            $mixedExtensions = 'jpg,php,png,exe,pdf,js,txt';
    +            $result = $this->helper->validateFileExtensionsAgainstForbiddenList($mixedExtensions);
    +
    +            expect($result['allowed'])->toBe(['jpg', 'png', 'pdf', 'txt']);
    +            expect($result['forbidden'])->toBe(['php', 'exe', 'js']);
    +        });
    +    });
    +});
    
  • tests/Backend/Unit/Catalog/Model/Product/Option/FileExtensionSecurityTest.php+125 0 added
    @@ -0,0 +1,125 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +uses(Tests\MahoBackendTestCase::class);
    +
    +describe('Product Option File Extension Security Validation', function () {
    +    beforeEach(function () {
    +        $this->option = Mage::getModel('catalog/product_option');
    +        $this->option->setType(Mage_Catalog_Model_Product_Option::OPTION_TYPE_FILE);
    +
    +        // Use reflection to access protected method
    +        $this->reflection = new ReflectionClass($this->option);
    +        $this->validateMethod = $this->reflection->getMethod('validateFileExtensions');
    +        $this->validateMethod->setAccessible(true);
    +    });
    +
    +    describe('validateFileExtensions method', function () {
    +        it('allows safe file extensions without throwing exceptions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'jpg,png,pdf,txt'))
    +                ->not()->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('throws exception for PHP extensions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'jpg,php,png'))
    +                ->toThrow(Mage_Core_Exception::class, 'The following file extensions are not allowed for security reasons: php');
    +        });
    +
    +        it('throws exception for multiple forbidden extensions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'exe,php,bat'))
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('throws exception for executable file extensions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'exe,bat,cmd'))
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('throws exception for script file extensions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'phtml,js,vbs'))
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('handles case-insensitive forbidden extensions', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'JPG,PHP,PNG'))
    +                ->toThrow(Mage_Core_Exception::class, 'The following file extensions are not allowed for security reasons: php');
    +        });
    +
    +        it('throws exception with all forbidden extensions listed', function () {
    +            expect(fn() => $this->validateMethod->invoke($this->option, 'php,exe,js,phtml'))
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('returns self when validation passes', function () {
    +            $result = $this->validateMethod->invoke($this->option, 'jpg,png,pdf');
    +            expect($result)->toBe($this->option);
    +        });
    +    });
    +
    +    describe('_beforeSave validation integration', function () {
    +        it('validates file extensions during beforeSave for file type options', function () {
    +            $this->option->setFileExtension('php,exe');
    +
    +            expect(fn() => $this->option->save())
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('skips validation for non-file type options', function () {
    +            $this->option->setType(Mage_Catalog_Model_Product_Option::OPTION_TYPE_FIELD);
    +            $this->option->setFileExtension('php'); // This should be ignored
    +
    +            // Should not throw exception because it's not a file type option
    +            expect(fn() => $this->option->save())->not()->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('allows safe extensions during beforeSave', function () {
    +            $this->option->setFileExtension('jpg,png,pdf');
    +
    +            expect(fn() => $this->option->save())->not()->toThrow(Mage_Core_Exception::class);
    +        });
    +    });
    +
    +    describe('saveOptions batch validation', function () {
    +        beforeEach(function () {
    +            $this->product = Mage::getModel('catalog/product');
    +            $this->product->setId(1);
    +            $this->product->setStoreId(0);
    +            $this->option->setProduct($this->product);
    +        });
    +
    +        it('validates extensions during batch save operations', function () {
    +            $optionData = [
    +                'type' => Mage_Catalog_Model_Product_Option::OPTION_TYPE_FILE,
    +                'title' => 'Test File Option',
    +                'file_extension' => 'php,exe',
    +                'is_require' => 0,
    +            ];
    +
    +            $this->option->setOptions([$optionData]);
    +
    +            expect(fn() => $this->option->saveOptions())
    +                ->toThrow(Mage_Core_Exception::class);
    +        });
    +
    +        it('allows safe extensions during batch save operations', function () {
    +            $optionData = [
    +                'type' => Mage_Catalog_Model_Product_Option::OPTION_TYPE_FILE,
    +                'title' => 'Test File Option',
    +                'file_extension' => 'jpg,png,pdf',
    +                'is_require' => 0,
    +            ];
    +
    +            $this->option->setOptions([$optionData]);
    +
    +            expect(fn() => $this->option->saveOptions())->not()->toThrow(Mage_Core_Exception::class);
    +        });
    +    });
    +})->group('security');
    
  • tests/Frontend/Unit/Catalog/Block/Product/View/Options/Type/FileFilteringTest.php+170 0 added
    @@ -0,0 +1,170 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/**
    + * Maho
    + *
    + * @copyright  Copyright (c) 2025 Maho (https://mahocommerce.com)
    + * @license    https://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
    + */
    +
    +uses(Tests\MahoFrontendTestCase::class);
    +
    +describe('Frontend File Extension Display Filtering', function () {
    +    beforeEach(function () {
    +        $this->block = new Mage_Catalog_Block_Product_View_Options_Type_File();
    +        $this->option = Mage::getModel('catalog/product_option');
    +        $this->option->setType(Mage_Catalog_Model_Product_Option::OPTION_TYPE_FILE);
    +        $this->block->setOption($this->option);
    +    });
    +
    +    describe('getSanitizedFileExtension', function () {
    +        it('displays only safe file extensions to customers', function () {
    +            $this->option->setFileExtension('jpg,png,pdf,txt');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png, pdf, txt');
    +        });
    +
    +        it('filters out PHP extensions from display', function () {
    +            $this->option->setFileExtension('jpg,php,png');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png');
    +            expect($result)->not()->toContain('php');
    +        });
    +
    +        it('filters out all executable extensions from display', function () {
    +            $this->option->setFileExtension('jpg,exe,png,bat,cmd');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png');
    +            expect($result)->not()->toContain('exe', 'bat', 'cmd');
    +        });
    +
    +        it('filters out script extensions from display', function () {
    +            $this->option->setFileExtension('pdf,js,txt,vbs,phtml');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('pdf, txt');
    +            expect($result)->not()->toContain('js', 'vbs', 'phtml');
    +        });
    +
    +        it('returns empty string when no safe extensions remain', function () {
    +            $this->option->setFileExtension('php,exe,bat,js,phtml');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('');
    +        });
    +
    +        it('returns empty string for empty input', function () {
    +            $this->option->setFileExtension('');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('');
    +        });
    +
    +        it('handles case-insensitive filtering for display', function () {
    +            $this->option->setFileExtension('JPG,PHP,PNG,EXE');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png');
    +            expect($result)->not()->toContain('php', 'exe', 'PHP', 'EXE');
    +        });
    +
    +        it('properly formats comma-separated safe extensions for display', function () {
    +            $this->option->setFileExtension('jpg,png,gif,pdf,doc,txt');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png, gif, pdf, doc, txt');
    +            expect($result)->toContain(', '); // Proper comma-space formatting
    +        });
    +
    +        it('filters mixed safe and dangerous extensions correctly', function () {
    +            $this->option->setFileExtension('jpg,php,png,exe,pdf,js,txt,bat');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png, pdf, txt');
    +            expect($result)->not()->toContain('php', 'exe', 'js', 'bat');
    +        });
    +
    +        it('handles extensions with various separators and formatting', function () {
    +            $this->option->setFileExtension('jpg; png, pdf php exe');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg, png, pdf');
    +            expect($result)->not()->toContain('php', 'exe');
    +        });
    +    });
    +
    +    describe('security defense in depth', function () {
    +        it('never displays dangerous extensions even if somehow saved to database', function () {
    +            // Simulate a scenario where dangerous extensions were somehow saved
    +            $dangerousExtensions = 'php,phtml,php3,php4,php5,php7,php8,phar,exe,bat,cmd,com,scr,vbs,js,jar,py,pl,rb,sh,asp,aspx,jsp,cgi,htaccess';
    +            $this->option->setFileExtension($dangerousExtensions);
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe(''); // All should be filtered out
    +        });
    +
    +        it('prevents security information disclosure through extension display', function () {
    +            // Test that no forbidden extensions leak through to customer display
    +            $mixedExtensions = 'jpg,php,png,exe,pdf,js,txt,phtml,bat,doc';
    +            $this->option->setFileExtension($mixedExtensions);
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +            $displayedExtensions = explode(', ', $result);
    +
    +            $forbiddenExtensions = ['php', 'exe', 'js', 'phtml', 'bat'];
    +            foreach ($forbiddenExtensions as $forbidden) {
    +                expect($displayedExtensions)->not()->toContain($forbidden);
    +            }
    +
    +            $safeExtensions = ['jpg', 'png', 'pdf', 'txt', 'doc'];
    +            foreach ($safeExtensions as $safe) {
    +                expect($displayedExtensions)->toContain($safe);
    +            }
    +        });
    +    });
    +
    +    describe('user experience', function () {
    +        it('provides clean comma-separated format for customer display', function () {
    +            $this->option->setFileExtension('jpg,png,pdf');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toMatch('/^[a-z0-9]+(, [a-z0-9]+)*$/'); // Clean format pattern
    +        });
    +
    +        it('handles single safe extension correctly', function () {
    +            $this->option->setFileExtension('jpg');
    +
    +            $result = $this->block->getSanitizedFileExtension();
    +
    +            expect($result)->toBe('jpg');
    +        });
    +
    +        it('gracefully handles edge cases without errors', function () {
    +            $edgeCases = ['', '   ', 'jpg,,,png', 'JPG;PNG,PDF'];
    +
    +            foreach ($edgeCases as $edgeCase) {
    +                $this->option->setFileExtension($edgeCase);
    +
    +                expect(fn() => $this->block->getSanitizedFileExtension())
    +                    ->not()->toThrow(Exception::class);
    +            }
    +        });
    +    });
    +})->group('frontend', 'security');
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.