Commit 601eeeaabdf for woocommerce

commit 601eeeaabdf4b306602b5fcc2e6c1a32109ea163
Author: Ayush Pahwa <ayush.pahwa@automattic.com>
Date:   Mon Aug 3 15:50:00 2026 +0530

    fix: add validation for structured option formats in tax settings (#66536)

diff --git a/plugins/woocommerce/changelog/fix-tax-settings-structured-options-validation b/plugins/woocommerce/changelog/fix-tax-settings-structured-options-validation
new file mode 100644
index 00000000000..77dc76ca538
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-tax-settings-structured-options-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Tax settings REST validation rejecting valid values when plugins filter options into a structured format.
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/Tax/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/Tax/Controller.php
index a3b747efb0c..a38f567d445 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/Tax/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Settings/Tax/Controller.php
@@ -241,7 +241,17 @@ class Controller extends AbstractController {
 			return true;
 		}

-		$allowed_values = array_map( 'strval', array_keys( (array) $options ) );
+		// Options can be a simple map ( 'yes' => 'Label' ) or a structured array
+		// ( array( 'value' => 'yes', 'label' => 'Label' ) ) when plugins filter
+		// the settings. Extract the allowed values from either format.
+		$allowed_values = array();
+		foreach ( (array) $options as $key => $option ) {
+			if ( is_array( $option ) && isset( $option['value'] ) ) {
+				$allowed_values[] = (string) $option['value'];
+			} else {
+				$allowed_values[] = (string) $key;
+			}
+		}

 		// Normalize value to array for consistent validation.
 		$check_values = is_array( $value ) ? array_map( 'strval', $value ) : array( (string) $value );
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Tax/TaxControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Tax/TaxControllerTest.php
index 69028ef2fd8..1d6ba7887b3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Tax/TaxControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Settings/Tax/TaxControllerTest.php
@@ -632,4 +632,147 @@ class TaxControllerTest extends WC_Unit_Test_Case {
 		// Reset to original value.
 		update_option( 'woocommerce_price_display_suffix', $original_suffix );
 	}
+
+	/**
+	 * @testdox Should accept a valid value when a plugin filters options into the structured format.
+	 */
+	public function test_update_tax_settings_structured_options_accepts_valid_value() {
+		$original = get_option( 'woocommerce_tax_based_on', 'shipping' );
+		$callback = $this->filter_tax_based_on_options(
+			array(
+				array(
+					'value' => 'shipping',
+					'label' => 'Customer shipping address',
+				),
+				array(
+					'value' => 'billing',
+					'label' => 'Customer billing address',
+				),
+				array(
+					'value' => 'base',
+					'label' => 'Shop base address',
+				),
+			)
+		);
+
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'values' => array(
+						'woocommerce_tax_based_on' => 'base',
+					),
+				)
+			)
+		);
+		$request->set_header( 'content-type', 'application/json' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status(), 'Valid values should pass validation when options are in the structured format' );
+		$this->assertSame( 'base', get_option( 'woocommerce_tax_based_on' ) );
+
+		remove_filter( 'woocommerce_tax_settings', $callback );
+		update_option( 'woocommerce_tax_based_on', $original );
+	}
+
+	/**
+	 * @testdox Should reject numeric option indexes when a plugin filters options into the structured format.
+	 */
+	public function test_update_tax_settings_structured_options_rejects_numeric_index() {
+		$original = get_option( 'woocommerce_tax_based_on', 'shipping' );
+		$callback = $this->filter_tax_based_on_options(
+			array(
+				array(
+					'value' => 'shipping',
+					'label' => 'Customer shipping address',
+				),
+				array(
+					'value' => 'billing',
+					'label' => 'Customer billing address',
+				),
+				array(
+					'value' => 'base',
+					'label' => 'Shop base address',
+				),
+			)
+		);
+
+		$request = new WP_REST_Request( 'PUT', self::ENDPOINT );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'values' => array(
+						'woocommerce_tax_based_on' => '0',
+					),
+				)
+			)
+		);
+		$request->set_header( 'content-type', 'application/json' );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status(), 'Numeric option indexes should not pass validation for structured options' );
+		$data = $response->get_data();
+		$this->assertStringContainsString( 'invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'shipping, billing, base', $data['message'], 'Error message should list the real option values, not numeric indexes' );
+		$this->assertSame( $original, get_option( 'woocommerce_tax_based_on', 'shipping' ) );
+
+		remove_filter( 'woocommerce_tax_settings', $callback );
+	}
+
+	/**
+	 * @testdox Should validate each option entry by its own format when formats are mixed.
+	 */
+	public function test_update_tax_settings_mixed_format_options() {
+		$original = get_option( 'woocommerce_tax_based_on', 'shipping' );
+		$callback = $this->filter_tax_based_on_options(
+			array(
+				array(
+					'value' => 'shipping',
+					'label' => 'Customer shipping address',
+				),
+				'billing' => 'Customer billing address',
+			)
+		);
+
+		foreach ( array( 'shipping', 'billing' ) as $value ) {
+			$request = new WP_REST_Request( 'PUT', self::ENDPOINT );
+			$request->set_body(
+				wp_json_encode(
+					array(
+						'values' => array(
+							'woocommerce_tax_based_on' => $value,
+						),
+					)
+				)
+			);
+			$request->set_header( 'content-type', 'application/json' );
+			$response = $this->server->dispatch( $request );
+
+			$this->assertSame( 200, $response->get_status(), "Value '$value' should pass validation with mixed format options" );
+			$this->assertSame( $value, get_option( 'woocommerce_tax_based_on' ) );
+		}
+
+		remove_filter( 'woocommerce_tax_settings', $callback );
+		update_option( 'woocommerce_tax_based_on', $original );
+	}
+
+	/**
+	 * Register a filter replacing the woocommerce_tax_based_on options, as plugins
+	 * filtering 'woocommerce_tax_settings' can do.
+	 *
+	 * @param array $options Replacement options for the setting.
+	 * @return callable The registered filter callback, so tests can remove it.
+	 */
+	private function filter_tax_based_on_options( array $options ): callable {
+		$callback = function ( $settings ) use ( $options ) {
+			foreach ( $settings as $index => $setting ) {
+				if ( 'woocommerce_tax_based_on' === ( $setting['id'] ?? '' ) ) {
+					$settings[ $index ]['options'] = $options;
+				}
+			}
+			return $settings;
+		};
+		add_filter( 'woocommerce_tax_settings', $callback );
+		return $callback;
+	}
 }