Commit 9e3811be8fe for woocommerce

commit 9e3811be8fe985c2f812ac46d83a300d70f1aa4d
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Thu Nov 5 14:04:07 2026 -0500

    Fix verified-owner review check for REST and XML-RPC comments (#70592)

diff --git a/plugins/woocommerce/changelog/fix-verified-owner-review-routes b/plugins/woocommerce/changelog/fix-verified-owner-review-routes
new file mode 100644
index 00000000000..fd4985f7f90
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-verified-owner-review-routes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply the "verified owners" review restriction to product comments sent through the REST API and XML-RPC.
diff --git a/plugins/woocommerce/includes/class-wc-comments.php b/plugins/woocommerce/includes/class-wc-comments.php
index 78153d14b02..574e89d6ca0 100644
--- a/plugins/woocommerce/includes/class-wc-comments.php
+++ b/plugins/woocommerce/includes/class-wc-comments.php
@@ -88,6 +88,7 @@ class WC_Comments {

 		// Validate product reviews if requires verified owners.
 		add_action( 'pre_comment_on_post', array( __CLASS__, 'validate_product_review_verified_owners' ) );
+		add_filter( 'pre_comment_approved', array( __CLASS__, 'handle_pre_comment_approved' ), 99, 2 );
 	}

 	/**
@@ -667,18 +668,7 @@ class WC_Comments {
 	 * @param int $comment_post_id Post ID.
 	 */
 	public static function validate_product_review_verified_owners( $comment_post_id ) {
-		// Only validate if option is enabled.
-		if ( 'yes' !== get_option( 'woocommerce_review_rating_verification_required' ) ) {
-			return;
-		}
-
-		// Validate only products.
-		if ( 'product' !== get_post_type( $comment_post_id ) ) {
-			return;
-		}
-
-		// Skip if is a verified owner.
-		if ( wc_customer_bought_product( '', get_current_user_id(), $comment_post_id ) ) {
+		if ( ! self::is_review_blocked_for_current_user( absint( $comment_post_id ) ) ) {
 			return;
 		}

@@ -686,11 +676,54 @@ class WC_Comments {
 			esc_html__( 'Only logged in customers who have purchased this product may leave a review.', 'woocommerce' ),
 			esc_html__( 'Reviews can only be left by "verified owners"', 'woocommerce' ),
 			array(
-				'code' => 403,
+				'response' => 403,
 			)
 		);
 	}

+	/**
+	 * Apply the verified-owner rule to product comments that skip the review form, such as REST and XML-RPC.
+	 *
+	 * Review-type comments are skipped: only the review form, checked above, and WooCommerce's own review
+	 * endpoints create them. REST refuses other comment types and XML-RPC sends none.
+	 *
+	 * @internal
+	 *
+	 * @param int|string|WP_Error $approved     Approval status, or an error from an earlier check.
+	 * @param array               $comment_data Comment data.
+	 * @return int|string|WP_Error
+	 */
+	public static function handle_pre_comment_approved( $approved, $comment_data ) {
+		if ( is_wp_error( $approved ) || ! is_array( $comment_data ) || ! self::is_default_comment_type( $comment_data['comment_type'] ?? '' ) ) {
+			return $approved;
+		}
+
+		$comment_post_id = absint( $comment_data['comment_post_ID'] ?? 0 );
+		if ( ! self::is_review_blocked_for_current_user( $comment_post_id ) || current_user_can( 'edit_post', $comment_post_id ) ) {
+			return $approved;
+		}
+
+		return new WP_Error(
+			'woocommerce_review_verified_owner_required',
+			__( 'Only logged in customers who have purchased this product may leave a review.', 'woocommerce' ),
+			array( 'status' => 403 )
+		);
+	}
+
+	/**
+	 * Whether the "verified owners" review setting stops the current user from commenting on a product.
+	 *
+	 * Checks the logged-in user, never the author name or email sent with the comment.
+	 *
+	 * @param int $post_id Post ID.
+	 * @return bool
+	 */
+	private static function is_review_blocked_for_current_user( int $post_id ): bool {
+		return 'yes' === get_option( 'woocommerce_review_rating_verification_required' )
+			&& 'product' === get_post_type( $post_id )
+			&& ! wc_customer_bought_product( '', get_current_user_id(), $post_id );
+	}
+
 	/**
 	 * Determines if a comment is of the default type.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cb7eeeca08b..45179d683ef 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10512,12 +10512,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-comments.php

-		-
-			message: '#^Parameter \#3 \$args of function wp_die expects array\{response\?\: int, link_url\?\: string, link_text\?\: string, back_link\?\: bool, text_direction\?\: string, charset\?\: string, code\?\: string, exit\?\: bool\}\|int, array\{code\: 403\} given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-comments.php
-
 		-
 			message: '#^Parameter \#3 \$product_id of function wc_customer_bought_product expects int, string given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
index fa667b846c6..bfc8e393d40 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
@@ -1,6 +1,7 @@
 <?php
 declare( strict_types=1 );

+use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;

 /**
@@ -295,4 +296,208 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
 			$product->delete( true );
 		}
 	}
+
+	/**
+	 * @testdox handle_pre_comment_approved() refuses default-type product comments from non-buyers only while the setting is on.
+	 *
+	 * @testWith ["yes", "non_buyer", "product", "comment", true]
+	 *           ["yes", "non_buyer", "product", "", true]
+	 *           ["yes", "guest", "product", "comment", true]
+	 *           ["yes", "buyer", "product", "comment", false]
+	 *           ["yes", "product_editor", "product", "comment", false]
+	 *           ["yes", "non_buyer", "product", "review", false]
+	 *           ["yes", "non_buyer", "post", "comment", false]
+	 *           ["no", "non_buyer", "product", "comment", false]
+	 *
+	 * @param string $setting        Value of the verified-owner setting.
+	 * @param string $reviewer       Kind of user submitting the comment.
+	 * @param string $post_type      Post type receiving the comment.
+	 * @param string $comment_type   Comment type being submitted.
+	 * @param bool   $expect_refusal Whether the comment should be refused.
+	 */
+	public function test_pre_comment_approved_applies_verified_owner_setting( string $setting, string $reviewer, string $post_type, string $comment_type, bool $expect_refusal ): void {
+		update_option( 'woocommerce_review_rating_verification_required', $setting );
+		$product = WC_Helper_Product::create_simple_product();
+		$post_id = 'product' === $post_type ? $product->get_id() : self::factory()->post->create();
+		wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+
+		$result = WC_Comments::handle_pre_comment_approved(
+			1,
+			array(
+				'comment_post_ID' => $post_id,
+				'comment_type'    => $comment_type,
+			)
+		);
+
+		if ( $expect_refusal ) {
+			$this->assertWPError( $result, 'The comment should have been refused.' );
+			$this->assertSame( 'woocommerce_review_verified_owner_required', $result->get_error_code() );
+			$this->assertSame( array( 'status' => 403 ), $result->get_error_data() );
+		} else {
+			$this->assertSame( 1, $result, 'The approval status should pass through unchanged.' );
+		}
+	}
+
+	/**
+	 * @testdox The WordPress REST comments route refuses product comments from non-buyers, even under a buyer's name and email.
+	 *
+	 * @testWith ["non_buyer", false, 403]
+	 *           ["non_buyer", true, 403]
+	 *           ["buyer", false, 201]
+	 *
+	 * @param string $reviewer        Kind of user sending the request.
+	 * @param bool   $use_buyer_email Whether the request names another customer, who bought the product, as the author.
+	 * @param int    $expected_status Expected HTTP status.
+	 */
+	public function test_rest_comments_route_applies_verified_owner_setting( string $reviewer, bool $use_buyer_email, int $expected_status ): void {
+		update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+
+		$request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+		$request->set_param( 'post', $product->get_id() );
+		$request->set_param( 'content', 'A product review sent through the REST API.' );
+		if ( $use_buyer_email ) {
+			$buyer = get_userdata( $this->create_reviewer( 'buyer', $product ) );
+			$request->set_param( 'author_name', $buyer->display_name );
+			$request->set_param( 'author_email', $buyer->user_email );
+		}
+
+		$server = $this->create_rest_server_with_routes(
+			array(
+				static function () {
+					( new WP_REST_Comments_Controller() )->register_routes();
+				},
+			)
+		);
+		try {
+			$response = $server->dispatch( $request );
+		} finally {
+			$this->clear_rest_server();
+		}
+
+		$this->assertSame( $expected_status, $response->get_status() );
+		if ( 403 === $expected_status ) {
+			$this->assertSame( 'woocommerce_review_verified_owner_required', $response->get_data()['code'] );
+		}
+		$this->assertSame( 201 === $expected_status ? 1 : 0, $this->count_comments( $product->get_id() ), 'Only an accepted comment should be stored.' );
+	}
+
+	/**
+	 * @testdox The XML-RPC wp.newComment method refuses product comments from non-buyers.
+	 *
+	 * @testWith ["non_buyer", false]
+	 *           ["buyer", true]
+	 *
+	 * @param string $reviewer       Kind of user calling the method.
+	 * @param bool   $expect_comment Whether the comment should be stored.
+	 */
+	public function test_xmlrpc_new_comment_applies_verified_owner_setting( string $reviewer, bool $expect_comment ): void {
+		require_once ABSPATH . WPINC . '/class-IXR.php';
+		require_once ABSPATH . WPINC . '/class-wp-xmlrpc-server.php';
+
+		update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		$user    = get_userdata( $this->create_reviewer( $reviewer, $product ) );
+
+		$result = ( new wp_xmlrpc_server() )->wp_newComment(
+			array( 1, $user->user_login, 'password', $product->get_id(), array( 'content' => 'A product review sent through XML-RPC.' ) )
+		);
+
+		if ( $expect_comment ) {
+			$this->assertIsInt( $result, 'A buyer should be able to comment through XML-RPC.' );
+		} else {
+			$this->assertInstanceOf( IXR_Error::class, $result );
+			$this->assertSame( 403, $result->code );
+			$this->assertSame( 'Only logged in customers who have purchased this product may leave a review.', $result->message );
+		}
+		$this->assertSame( $expect_comment ? 1 : 0, $this->count_comments( $product->get_id() ), 'Only an accepted comment should be stored.' );
+	}
+
+	/**
+	 * @testdox The review form stores a buyer's review and refuses a non-buyer with a 403.
+	 *
+	 * @testWith ["buyer"]
+	 *           ["non_buyer"]
+	 *
+	 * @param string $reviewer Kind of user submitting the form.
+	 */
+	public function test_review_form_applies_verified_owner_setting( string $reviewer ): void {
+		update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+		$product = WC_Helper_Product::create_simple_product();
+		wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+		$_POST['comment_post_ID'] = (string) $product->get_id();
+		$_POST['rating']          = '5';
+
+		if ( 'non_buyer' === $reviewer ) {
+			$this->expectException( WPDieException::class );
+			$this->expectExceptionMessage( 'Only logged in customers who have purchased this product may leave a review.' );
+			$this->expectExceptionCode( 403 );
+		}
+
+		try {
+			$comment = wp_handle_comment_submission(
+				array(
+					'comment_post_ID' => $product->get_id(),
+					'comment'         => 'A product review sent through the review form.',
+				)
+			);
+		} finally {
+			unset( $_POST['comment_post_ID'], $_POST['rating'] );
+		}
+
+		$this->assertInstanceOf( WP_Comment::class, $comment, 'A buyer should be able to use the review form.' );
+		$this->assertSame( 'review', $comment->comment_type );
+	}
+
+	/**
+	 * Create a user to submit product comments, or return 0 for a guest.
+	 *
+	 * @param string     $kind    One of "buyer", "non_buyer", "product_editor" or "guest".
+	 * @param WC_Product $product Product that a buyer has a completed order for.
+	 * @return int User ID.
+	 */
+	private function create_reviewer( string $kind, WC_Product $product ): int {
+		if ( 'guest' === $kind ) {
+			return 0;
+		}
+
+		$user = self::factory()->user->create_and_get(
+			array(
+				'role'      => 'customer',
+				'user_pass' => 'password',
+			)
+		);
+
+		if ( 'product_editor' === $kind ) {
+			$user->add_cap( 'edit_products' );
+			$user->add_cap( 'edit_others_products' );
+			$user->add_cap( 'edit_published_products' );
+		}
+
+		if ( 'buyer' === $kind ) {
+			$order = wc_create_order( array( 'customer_id' => $user->ID ) );
+			$order->add_product( $product );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->save();
+		}
+
+		return $user->ID;
+	}
+
+	/**
+	 * Count the comments on a post, approved or held for moderation.
+	 *
+	 * @param int $post_id Post ID.
+	 * @return int
+	 */
+	private function count_comments( int $post_id ): int {
+		return (int) get_comments(
+			array(
+				'post_id' => $post_id,
+				'status'  => 'all',
+				'count'   => true,
+			)
+		);
+	}
 }