Commit 9c17a04f1af for woocommerce

commit 9c17a04f1af7f4ed8e2f244ff42f21833d015ffb
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Mon Aug 24 21:07:37 2026 +0300

    Refuse CSV variation rows whose attributes the parent product does not offer (#67959)

    * Report why a variation row was refused during update imports

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Refuse variation rows whose attributes the parent product does not offer

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Share the existing-attribute lookup with the importer base class

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Assert no variation is created rather than that no positive ID exists

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Drop two baseline entries the shared attribute lookup resolves

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Assert the full refusal message in the variation import tests

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Name the import test helper for what it actually does

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Decode the refusal message with ENT_QUOTES so PHP 7.4 matches

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * Cover the re-import that creates the previously skipped variation

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-import-variation-attribute-validation b/plugins/woocommerce/changelog/fix-import-variation-attribute-validation
new file mode 100644
index 00000000000..b9d6cc8ffdf
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-import-variation-attribute-validation
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Fixes an unreleased 11.1 feature; no merchant-facing change.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index aede47e4730..8d5946712aa 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -678,6 +678,35 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 		return $id;
 	}

+	/**
+	 * Get the taxonomy name of a global attribute as written in the imported data.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param  string $raw_name Attribute name or label.
+	 * @return string
+	 */
+	protected function get_attribute_taxonomy_name_from_raw_name( $raw_name ) {
+		// These are exported as labels, so convert the label to a name if possible first.
+		$attribute_labels = wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_label', 'attribute_name' );
+		$attribute_name   = array_search( $raw_name, $attribute_labels, true );
+
+		// Cast because a numeric attribute name is returned as an integer array key by array_search().
+		return $attribute_name ? (string) $attribute_name : wc_sanitize_taxonomy_name( $raw_name );
+	}
+
+	/**
+	 * Get the ID of an existing global attribute taxonomy, without creating it when it is missing.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param  string $raw_name Attribute name or label.
+	 * @return int Attribute taxonomy ID, or 0 when no such global attribute exists.
+	 */
+	protected function get_existing_attribute_taxonomy_id( $raw_name ) {
+		return (int) wc_attribute_taxonomy_id_by_name( $this->get_attribute_taxonomy_name_from_raw_name( $raw_name ) );
+	}
+
 	/**
 	 * Get attribute taxonomy ID from the imported data.
 	 * If does not exists register a new attribute.
@@ -689,15 +718,8 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
 	public function get_attribute_taxonomy_id( $raw_name ) {
 		global $wpdb, $wc_product_attributes;

-		// These are exported as labels, so convert the label to a name if possible first.
-		$attribute_labels = wp_list_pluck( wc_get_attribute_taxonomies(), 'attribute_label', 'attribute_name' );
-		$attribute_name   = array_search( $raw_name, $attribute_labels, true );
-
-		if ( ! $attribute_name ) {
-			$attribute_name = wc_sanitize_taxonomy_name( $raw_name );
-		}
-
-		$attribute_id = wc_attribute_taxonomy_id_by_name( $attribute_name );
+		$attribute_name = $this->get_attribute_taxonomy_name_from_raw_name( $raw_name );
+		$attribute_id   = $this->get_existing_attribute_taxonomy_id( $raw_name );

 		// Get the ID from the name.
 		if ( $attribute_id ) {
diff --git a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
index 517113a8305..5adf3b4ace1 100644
--- a/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
+++ b/plugins/woocommerce/includes/import/class-wc-product-csv-importer.php
@@ -1178,33 +1178,136 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {
 	 * @since 11.1.0
 	 *
 	 * @param array $parsed_data Parsed row data.
-	 * @return bool
+	 * @return true|WP_Error True when the variation can be created, a WP_Error describing the refusal otherwise.
 	 */
 	protected function can_create_variation( $parsed_data ) {
 		// A row ID cannot be honored when creating a new variation: reusing an existing
 		// post's ID would corrupt that post, and a nonexistent ID cannot be assigned.
 		if ( ! empty( $parsed_data['id'] ) ) {
-			return false;
+			return new WP_Error(
+				'woocommerce_product_importer_variation_has_id',
+				esc_html__( 'A new variation cannot be created for a row that specifies an ID.', 'woocommerce' )
+			);
 		}

 		// A CSV ID cannot be assigned to a new variation, so without a SKU the created variation
 		// could never be matched again and every re-import would duplicate it.
 		if ( empty( $parsed_data['sku'] ) ) {
-			return false;
+			return new WP_Error(
+				'woocommerce_product_importer_variation_missing_sku',
+				esc_html__( 'A new variation cannot be created without a SKU.', 'woocommerce' )
+			);
 		}

 		if ( empty( $parsed_data['parent_id'] ) ) {
-			return false;
+			return new WP_Error(
+				'woocommerce_product_importer_variation_missing_parent',
+				esc_html__( 'A new variation cannot be created without a parent product.', 'woocommerce' )
+			);
 		}

 		$parent = wc_get_product( $parsed_data['parent_id'] );

 		if ( ! $parent || ! $parent->is_type( ProductType::VARIABLE ) ) {
-			return false;
+			return new WP_Error(
+				'woocommerce_product_importer_variation_parent_not_variable',
+				esc_html__( 'A new variation can only be created for a variable parent product.', 'woocommerce' )
+			);
 		}

 		// A parent with the 'importing' status is a placeholder, meaning the parent does not exist either.
-		return ! in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true );
+		if ( in_array( $parent->get_status(), array( 'importing', ProductStatus::TRASH ), true ) ) {
+			return new WP_Error(
+				'woocommerce_product_importer_variation_parent_missing',
+				esc_html__( 'A new variation cannot be created for a parent product that does not exist.', 'woocommerce' )
+			);
+		}
+
+		return $this->validate_new_variation_attributes( $parsed_data, $parent );
+	}
+
+	/**
+	 * Check that a new variation's attributes are offered by its parent product.
+	 *
+	 * The storefront variation selector only renders values the parent declares, so a variation
+	 * carrying a value the parent does not offer would be created but never selectable. Likewise,
+	 * an attribute the parent does not have at all is dropped on save, silently turning the row
+	 * into an "any" variation that matches every combination.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @param array      $parsed_data    Parsed row data.
+	 * @param WC_Product $parent_product Parent product the variation would be created under.
+	 * @return true|WP_Error True when every attribute is offered by the parent, a WP_Error describing the refusal otherwise.
+	 */
+	protected function validate_new_variation_attributes( $parsed_data, $parent_product ) {
+		if ( empty( $parsed_data['raw_attributes'] ) ) {
+			return true;
+		}
+
+		$parent_attributes = $parent_product->get_attributes();
+
+		foreach ( $parsed_data['raw_attributes'] as $attribute ) {
+			if ( empty( $attribute['name'] ) ) {
+				continue;
+			}
+
+			// Resolve the row's attribute the same way set_variation_data() does, so a row that would
+			// have been stored correctly is never refused here. get_attribute_taxonomy_id() is deliberately
+			// not used: it creates the global attribute when it is missing, which must not happen for a
+			// row that is about to be refused.
+			$attribute_id   = empty( $attribute['taxonomy'] ) ? 0 : $this->get_existing_attribute_taxonomy_id( $attribute['name'] );
+			$attribute_name = $attribute_id ? sanitize_title( wc_attribute_taxonomy_name_by_id( $attribute_id ) ) : sanitize_title( $attribute['name'] );
+
+			// An attribute the parent does not have is dropped on save. An attribute the parent has but
+			// does not use for variations is allowed through: get_variation_parent_attributes() promotes it.
+			if ( ! isset( $parent_attributes[ $attribute_name ] ) ) {
+				return new WP_Error(
+					'woocommerce_product_importer_variation_unknown_attribute',
+					sprintf(
+						/* translators: %s: attribute name */
+						esc_html__( 'A new variation cannot be created because the parent product has no "%s" attribute.', 'woocommerce' ),
+						esc_html( $attribute['name'] )
+					)
+				);
+			}
+
+			$parent_attribute = $parent_attributes[ $attribute_name ];
+			$raw_value        = isset( $attribute['value'] ) ? current( (array) $attribute['value'] ) : '';
+
+			// An empty value is a valid "any" variation.
+			if ( '' === $raw_value || false === $raw_value ) {
+				continue;
+			}
+
+			if ( $parent_attribute->is_taxonomy() ) {
+				$taxonomy = $parent_attribute->get_name();
+				$term     = get_term_by( 'name', $raw_value, $taxonomy );
+				$value    = ( $term && ! is_wp_error( $term ) ) ? $term->slug : sanitize_title( $raw_value );
+
+				// The terms assigned to the parent are the exact set the storefront selector renders.
+				// WC_Product_Attribute::get_terms() is avoided here because it inserts any term that does
+				// not exist yet, which must not happen while deciding whether to refuse a row.
+				$options = wc_get_product_terms( $parent_product->get_id(), $taxonomy, array( 'fields' => 'slugs' ) );
+			} else {
+				$value   = $raw_value;
+				$options = $parent_attribute->get_options();
+			}
+
+			if ( ! in_array( $value, $options, true ) ) {
+				return new WP_Error(
+					'woocommerce_product_importer_variation_unknown_attribute_value',
+					sprintf(
+						/* translators: 1: attribute value, 2: attribute name */
+						esc_html__( 'A new variation cannot be created because "%1$s" is not an option of the parent product\'s "%2$s" attribute.', 'woocommerce' ),
+						esc_html( $raw_value ),
+						esc_html( wc_attribute_label( $parent_attribute->get_name(), $parent_product ) )
+					)
+				);
+			}
+		}
+
+		return true;
 	}

 	/**
@@ -1273,25 +1376,36 @@ class WC_Product_CSV_Importer extends WC_Product_Importer {

 			if ( $update_existing && ( isset( $parsed_data['id'] ) || isset( $parsed_data['sku'] ) ) && ! $id_exists && ! $sku_exists ) {
 				$create_variation = false;
+				$refusal          = null;
+
+				if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) ) {
+					$can_create_variation = $this->can_create_variation( $parsed_data );

-				if ( ProductType::VARIATION === ( $parsed_data['type'] ?? '' ) && $this->can_create_variation( $parsed_data ) ) {
-					/**
-					 * Filters whether a new variation should be created for an existing variable product when updating existing products.
-					 *
-					 * Only fires for variation rows that passed validation, so it can veto the creation but not force it.
-					 *
-					 * @since 11.1.0
-					 *
-					 * @param bool  $create_variation Whether to create the new variation instead of skipping the row.
-					 * @param array $parsed_data      Parsed row data.
-					 */
-					$create_variation = apply_filters( 'woocommerce_product_import_create_variation_of_existing_product', true, $parsed_data );
+					// Anything other than an explicit pass refuses, so an override still written against
+					// the previous boolean contract cannot turn a refusal into a creation.
+					if ( true !== $can_create_variation ) {
+						$refusal = is_wp_error( $can_create_variation ) ? $can_create_variation : null;
+					} else {
+						/**
+						 * Filters whether a new variation should be created for an existing variable product when updating existing products.
+						 *
+						 * Only fires for variation rows that passed validation, so it can veto the creation but not force it.
+						 *
+						 * @since 11.1.0
+						 *
+						 * @param bool  $create_variation Whether to create the new variation instead of skipping the row.
+						 * @param array $parsed_data      Parsed row data.
+						 */
+						$create_variation = apply_filters( 'woocommerce_product_import_create_variation_of_existing_product', true, $parsed_data );
+					}
 				}

 				if ( ! $create_variation ) {
+					// A refused variation row reports why it was refused; anything else is a row whose
+					// ID or SKU simply matches nothing on the site.
 					$data['skipped'][] = new WP_Error(
 						'woocommerce_product_importer_error',
-						esc_html__( 'No matching product exists to update.', 'woocommerce' ),
+						$refusal ? $refusal->get_error_message() : esc_html__( 'No matching product exists to update.', 'woocommerce' ),
 						array(
 							'id'  => $id,
 							'sku' => esc_attr( $sku ),
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 87e42963f50..1a748176170 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -20106,18 +20106,6 @@ parameters:
 			count: 2
 			path: includes/import/abstract-wc-product-importer.php

-		-
-			message: '#^Parameter \#1 \$attribute_name of function wc_attribute_taxonomy_name expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/import/abstract-wc-product-importer.php
-
-		-
-			message: '#^Parameter \#1 \$name of function wc_attribute_taxonomy_id_by_name expects string, int\<min, \-1\>\|int\<1, max\>\|string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/import/abstract-wc-product-importer.php
-
 		-
 			message: '#^Parameter \#2 \$array of function array_map expects array, list\<string\>\|false given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
index 830d80232be..f4dceea43f6 100644
--- a/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
+++ b/plugins/woocommerce/tests/php/includes/importer/class-wc-product-csv-importer-test.php
@@ -515,6 +515,290 @@ class WC_Product_CSV_Importer_Test extends \WC_Unit_Test_Case {
 		WC_Helper_Product::delete_product( $product->get_id() );
 	}

+	/**
+	 * Run an import with "update existing products" against an already existing parent.
+	 *
+	 * @param string $csv_body  CSV contents, header row included. Usually a lone variation row, but a
+	 *                          parent row can be included to cover ordering between the two.
+	 * @param string $file_name Temp file name to write the CSV to.
+	 * @return array Import results.
+	 */
+	private function import_with_update_existing( $csv_body, $file_name ) {
+		$csv_file = trailingslashit( get_temp_dir() ) . $file_name;
+		file_put_contents( $csv_file, $csv_body ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture written to the temp dir.
+
+		$args = array(
+			'parse'           => true,
+			'update_existing' => true,
+			'mapping'         => array(
+				'Type'                 => 'type',
+				'SKU'                  => 'sku',
+				'Name'                 => 'name',
+				'Parent'               => 'parent_id',
+				'Attribute 1 name'     => 'attributes:name1',
+				'Attribute 1 value(s)' => 'attributes:value1',
+				'Attribute 1 global'   => 'attributes:taxonomy1',
+				'Regular price'        => 'regular_price',
+			),
+		);
+
+		$importer = new WC_Product_CSV_Importer( $csv_file, $args );
+		$data     = $importer->import();
+		wp_delete_file( $csv_file );
+
+		return $data;
+	}
+
+	/**
+	 * @testdox Test that a variation row carrying an attribute value the parent does not offer is skipped, since the storefront could never select it.
+	 */
+	public function test_import_skips_new_variations_with_a_value_the_parent_does_not_offer() {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_name( 'Size' );
+		$attribute->set_options( array( 'S', 'M' ) );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Tee' );
+		$product->set_sku( 'IMPORT-VALUE-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-VALUE-L,Import Tee - L,IMPORT-VALUE-PARENT,Size,L,0,12\n",
+			'import-unknown-value.csv'
+		);
+
+		$this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+		$this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+		$this->assertSame(
+			'A new variation cannot be created because "L" is not an option of the parent product\'s "Size" attribute.',
+			html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+			'Expected the skip message to name the unavailable value and its attribute'
+		);
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-VALUE-L' ), 'Expected no variation to be created for a value the parent does not offer' );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Test that a variation row carrying a global attribute term the parent does not offer is skipped.
+	 */
+	public function test_import_skips_new_variations_with_a_taxonomy_value_the_parent_does_not_offer() {
+		$attribute_data = WC_Helper_Product::create_attribute( 'size-airr19', array( 'S', 'M', 'L' ) );
+		$taxonomy       = $attribute_data['attribute_taxonomy'];
+		$small          = get_term_by( 'name', 'S', $taxonomy );
+
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_id( $attribute_data['attribute_id'] );
+		$attribute->set_name( $taxonomy );
+		$attribute->set_options( array( $small->term_id ) );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Global Tee' );
+		$product->set_sku( 'IMPORT-TAX-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-TAX-L,Import Global Tee - L,IMPORT-TAX-PARENT,size-airr19,L,1,12\n",
+			'import-unknown-taxonomy-value.csv'
+		);
+
+		$this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+		$this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+		// Asserted in full so this cannot pass because the global attribute failed to resolve at all,
+		// which would report the "parent has no such attribute" refusal instead.
+		$this->assertSame(
+			sprintf(
+				'A new variation cannot be created because "L" is not an option of the parent product\'s "%s" attribute.',
+				wc_attribute_label( $taxonomy )
+			),
+			html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+			'Expected the refusal to name the term and the global attribute, not an unresolved attribute'
+		);
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-TAX-L' ), 'Expected no variation to be created for a term the parent does not offer' );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+		WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+	}
+
+	/**
+	 * @testdox Test that a variation row using a global attribute term the parent already offers is still created.
+	 */
+	public function test_import_creates_new_variations_with_a_taxonomy_value_the_parent_offers() {
+		$attribute_data = WC_Helper_Product::create_attribute( 'size-airr19-ok', array( 'S', 'L' ) );
+		$taxonomy       = $attribute_data['attribute_taxonomy'];
+
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_id( $attribute_data['attribute_id'] );
+		$attribute->set_name( $taxonomy );
+		$attribute->set_options( $attribute_data['term_ids'] );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Global Tee' );
+		$product->set_sku( 'IMPORT-TAX-OK-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-TAX-OK-L,Import Global Tee - L,IMPORT-TAX-OK-PARENT,size-airr19-ok,L,1,12\n",
+			'import-known-taxonomy-value.csv'
+		);
+
+		$this->assertCount( 1, $data['imported_variations'], 'Expected 1 imported variation, got ' . count( $data['imported_variations'] ) );
+		$this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+		$variation = wc_get_product( $data['imported_variations'][0] );
+		$this->assertEquals( array( $taxonomy => 'l' ), $variation->get_attributes() );
+
+		WC_Helper_Product::delete_product( $variation->get_id() );
+		WC_Helper_Product::delete_product( $product->get_id() );
+		WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+	}
+
+	/**
+	 * @testdox Test that a variation row is created when an earlier parent row in the same CSV adds the global attribute term it uses.
+	 */
+	public function test_import_creates_new_variations_when_an_earlier_parent_row_adds_the_taxonomy_value() {
+		$attribute_data = WC_Helper_Product::create_attribute( 'size-airr19-widen', array( 'S', 'L' ) );
+		$taxonomy       = $attribute_data['attribute_taxonomy'];
+		$small          = get_term_by( 'name', 'S', $taxonomy );
+
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_id( $attribute_data['attribute_id'] );
+		$attribute->set_name( $taxonomy );
+		$attribute->set_options( array( $small->term_id ) );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Widen Tee' );
+		$product->set_sku( 'IMPORT-WIDEN-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		// Parent row first, as WooCommerce exports it: the variation must see the widened term list.
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+			. "variable,IMPORT-WIDEN-PARENT,Import Widen Tee,,size-airr19-widen,\"S, L\",1,\n"
+			. "variation,IMPORT-WIDEN-L,Import Widen Tee - L,IMPORT-WIDEN-PARENT,size-airr19-widen,L,1,12\n",
+			'import-parent-widens-taxonomy.csv'
+		);
+
+		$this->assertCount( 1, $data['imported_variations'], 'Expected the variation to be created after the parent row added the term' );
+		$this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+		WC_Helper_Product::delete_product( $data['imported_variations'][0] );
+		WC_Helper_Product::delete_product( $product->get_id() );
+		WC_Helper_Product::delete_attribute( $attribute_data['attribute_id'] );
+	}
+
+	/**
+	 * @testdox Test that a variation row naming an attribute the parent does not have is skipped, instead of being saved as an "any" variation.
+	 */
+	public function test_import_skips_new_variations_with_an_attribute_the_parent_does_not_have() {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_name( 'Size' );
+		$attribute->set_options( array( 'S', 'M' ) );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Tee' );
+		$product->set_sku( 'IMPORT-ATTR-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-ATTR-RED,Import Tee - Red,IMPORT-ATTR-PARENT,Colour,Red,0,12\n",
+			'import-unknown-attribute.csv'
+		);
+
+		$this->assertEmpty( $data['imported_variations'], 'Expected 0 imported variations, got ' . count( $data['imported_variations'] ) );
+		$this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+		$this->assertSame(
+			'A new variation cannot be created because the parent product has no "Colour" attribute.',
+			html_entity_decode( $data['skipped'][0]->get_error_message(), ENT_QUOTES ),
+			'Expected the skip message to name the missing attribute'
+		);
+		$this->assertSame( 0, wc_get_product_id_by_sku( 'IMPORT-ATTR-RED' ), 'Expected no variation to be created for an attribute the parent does not have' );
+
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Test that a variation row is still created when the parent has the attribute but does not yet use it for variations.
+	 */
+	public function test_import_creates_new_variations_when_the_parent_attribute_is_not_yet_used_for_variations() {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_name( 'Size' );
+		$attribute->set_options( array( 'S', 'M', 'L' ) );
+		$attribute->set_variation( false );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Tee' );
+		$product->set_sku( 'IMPORT-PROMOTE-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\nvariation,IMPORT-PROMOTE-L,Import Tee - L,IMPORT-PROMOTE-PARENT,Size,L,0,12\n",
+			'import-promoted-attribute.csv'
+		);
+
+		$this->assertCount( 1, $data['imported_variations'], 'Expected the row to be created once the parent attribute is promoted for variations' );
+		$this->assertEmpty( $data['skipped'], 'Expected 0 skipped products, got ' . count( $data['skipped'] ) );
+
+		WC_Helper_Product::delete_product( $data['imported_variations'][0] );
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
+	/**
+	 * @testdox Test that a variation row listed before the parent row that would widen the attribute is skipped, since rows are validated against the parent as it stands when the row is reached.
+	 */
+	public function test_import_skips_new_variations_listed_before_the_parent_row_that_adds_their_value() {
+		$attribute = new WC_Product_Attribute();
+		$attribute->set_name( 'Size' );
+		$attribute->set_options( array( 'S', 'M' ) );
+		$attribute->set_variation( true );
+
+		$product = new WC_Product_Variable();
+		$product->set_name( 'Import Tee' );
+		$product->set_sku( 'IMPORT-ORDER-PARENT' );
+		$product->set_attributes( array( $attribute ) );
+		$product->save();
+
+		// The variation row comes first, so the parent still offers only S and M when it is validated.
+		$data = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+			. "variation,IMPORT-ORDER-L,Import Tee - L,IMPORT-ORDER-PARENT,Size,L,0,12\n"
+			. "variable,IMPORT-ORDER-PARENT,Import Tee,,Size,\"S, M, L\",0,\n",
+			'import-variation-before-parent.csv'
+		);
+
+		$this->assertEmpty( $data['imported_variations'], 'Expected the variation row to be skipped, got ' . count( $data['imported_variations'] ) );
+		$this->assertCount( 1, $data['skipped'], 'Expected 1 skipped product, got ' . count( $data['skipped'] ) );
+		$this->assertCount( 1, $data['updated'], 'Expected the parent row to still be updated' );
+
+		// The parent row widened the options, so the same import creates the variation on a second run.
+		$product = wc_get_product( $product->get_id() );
+		$this->assertEquals( array( 'S', 'M', 'L' ), $product->get_attributes()['size']->get_options() );
+
+		$rerun = $this->import_with_update_existing(
+			"Type,SKU,Name,Parent,Attribute 1 name,Attribute 1 value(s),Attribute 1 global,Regular price\n"
+			. "variation,IMPORT-ORDER-L,Import Tee - L,IMPORT-ORDER-PARENT,Size,L,0,12\n"
+			. "variable,IMPORT-ORDER-PARENT,Import Tee,,Size,\"S, M, L\",0,\n",
+			'import-variation-before-parent-rerun.csv'
+		);
+
+		$this->assertCount( 1, $rerun['imported_variations'], 'Expected the variation to be created on the second run' );
+		$this->assertEmpty( $rerun['skipped'], 'Expected 0 skipped products on the second run, got ' . count( $rerun['skipped'] ) );
+
+		WC_Helper_Product::delete_product( $rerun['imported_variations'][0] );
+		WC_Helper_Product::delete_product( $product->get_id() );
+	}
+
 	/**
 	 * @testdox Test that attributes with non-ASCII characters are correctly set to "Used for Variations" during import.
 	 */