Commit 62fa10daabb for woocommerce

commit 62fa10daabb45219d404d04e12183f73e6598ab6
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Mon Nov 2 12:21:51 2026 +0300

    Check order ownership and the store's review settings on the Review Order flow (#70415)

    * [WOOPLUG-7940] Check order ownership and the store's review settings on Order Reviews

    * Add changefile(s) from automation for the following project(s): woocommerce

    * [WOOPLUG-7940] Stop surfacing queued notices on the Review Order login prompt

    * [WOOPLUG-7940] Send customers to the login page instead of rendering a login form

    * [WOOPLUG-7940] Stop double-decoding the review-order return target after login

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/70415-wooplug-7940-review-order-auth b/plugins/woocommerce/changelog/70415-wooplug-7940-review-order-auth
new file mode 100644
index 00000000000..db01c632737
--- /dev/null
+++ b/plugins/woocommerce/changelog/70415-wooplug-7940-review-order-auth
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Require customers to sign in before reviewing orders placed on their account, and apply the store's comment settings.
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 7aa75c91ec1..bb5b5cbced1 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -21,8 +21,12 @@ use WP_Post;
  *
  * - It is not a checkout sub-mode like order-pay or order-received; the
  *   customer is reviewing past purchases, not transacting.
- * - It is not a my-account endpoint because the order key is the auth, so
- *   guest customers must be able to reach it without logging in.
+ * - It is not a my-account endpoint because a guest order's key is the auth,
+ *   so guest customers reach it without logging in. An order that belongs to a
+ *   registered customer requires that customer's session, the same default
+ *   `order_received` and the Store API order route apply. The opt-out is this
+ *   route's own filter; `woocommerce_order_received_verify_known_shoppers` does
+ *   not carry over.
  *
  * The route uses the same wp_posts-backed page pattern as the checkout
  * page so the active theme owns the page chrome (header, footer, sidebar)
@@ -55,6 +59,11 @@ class Endpoint {
 	 */
 	public const SHORTCODE = 'woocommerce_review_order';

+	/**
+	 * Query arg carried to the login page so signing in returns here.
+	 */
+	public const RETURN_QUERY_VAR = 'wc_review_return';
+
 	/**
 	 * Wire the endpoint into WordPress.
 	 *
@@ -79,6 +88,7 @@ class Endpoint {
 		// Status → Tools "Create default pages" and any other repair caller see it too.
 		add_filter( 'woocommerce_create_pages', array( $this, 'inject_review_order_page' ) );
 		add_shortcode( self::SHORTCODE, array( $this, 'render_shortcode' ) );
+		add_filter( 'woocommerce_login_redirect', array( $this, 'maybe_return_to_review' ) );
 	}

 	/**
@@ -546,6 +556,19 @@ class Endpoint {
 			exit;
 		}

+		// Sign-in is handled by the store's login page, not by this one, so the
+		// redirect happens here on `template_redirect`, before any output.
+		if ( $order instanceof WC_Order && self::requires_login( $order ) ) {
+			wp_safe_redirect( self::login_url( $order ) );
+			exit;
+		}
+
+		// Someone signed in as a different customer cannot be helped by signing
+		// in again; they get an explanation instead of the form.
+		if ( $order instanceof WC_Order && self::viewer_is_another_customer( $order ) ) {
+			return;
+		}
+
 		// Register the page-title suppression filters now that the request
 		// is fully authorised. Doing this here instead of `init()` keeps the
 		// filters out of every unrelated page render and removes the need
@@ -594,11 +617,35 @@ class Endpoint {
 			return '';
 		}

+		return $order instanceof WC_Order ? $this->render_body( $order ) : '';
+	}
+
+	/**
+	 * The page body: the review form, or a login prompt in its place. Both
+	 * render paths go through here so they cannot show different things.
+	 *
+	 * @param WC_Order $order The order being reviewed.
+	 * @return string
+	 */
+	private function render_body( WC_Order $order ): string {
+		if ( self::viewer_is_another_customer( $order ) ) {
+			ob_start();
+			echo '<div class="woocommerce"><div class="woocommerce-notices-wrapper">' . wc_print_notice( __( 'This order belongs to a different account, so you cannot review it.', 'woocommerce' ), 'error', array(), true ) . '</div></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_print_notice() returns kses-filtered markup.
+			return (string) ob_get_clean();
+		}
+
+		// A visitor who needs to sign in was redirected on `template_redirect`;
+		// reaching here means the shortcode ran outside that path.
+		if ( self::requires_login( $order ) ) {
+			return '';
+		}
+
 		ob_start();
 		wc_get_template( 'order/customer-review-order.php', array( 'order' => $order ) );
 		return (string) ob_get_clean();
 	}

+
 	/**
 	 * Render the Review Order body directly. Public so unit tests can drive
 	 * the rendering path without staging a global request and the rewrite.
@@ -616,11 +663,13 @@ class Endpoint {
 			return;
 		}

-		if ( $order instanceof WC_Order ) {
-			$this->maybe_mark_no_actionable_rows( $order );
+		if ( ! $order instanceof WC_Order ) {
+			return;
 		}

-		wc_get_template( 'order/customer-review-order.php', array( 'order' => $order ) );
+		$this->maybe_mark_no_actionable_rows( $order );
+
+		echo $this->render_body( $order ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_get_template(), wc_print_notice() and woocommerce_login_form() output, escaped at source.
 	}

 	/**
@@ -637,6 +686,12 @@ class Endpoint {
 	 * @param WC_Order $order Order being reviewed.
 	 */
 	private function maybe_mark_no_actionable_rows( WC_Order $order ): void {
+		// A visitor who never sees the rows completes nothing. Checked here
+		// rather than at each caller so a new caller cannot forget it.
+		if ( self::requires_login( $order ) || self::viewer_is_another_customer( $order ) ) {
+			return;
+		}
+
 		$completed_meta_key = SubmissionHandler::COMPLETED_META_KEY;
 		if ( $order->get_meta( $completed_meta_key ) ) {
 			return;
@@ -770,12 +825,125 @@ class Endpoint {
 			return false;
 		}

-		// Logged-in customer must own the order. Guests with the order key still pass.
-		if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
+		return true;
+	}
+
+	/**
+	 * Whether the visitor has to sign in before the form is shown, because the
+	 * order belongs to a registered customer. `order_received` and the Store API
+	 * ask the same of those orders.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @param WC_Order $order The order being reviewed.
+	 * @return bool
+	 */
+	public static function requires_owner_login( WC_Order $order ): bool {
+		if ( ! $order->get_customer_id() || is_user_logged_in() ) {
 			return false;
 		}

-		return true;
+		/**
+		 * Filter whether an order that belongs to a registered customer requires
+		 * that customer to be signed in before it can be reviewed.
+		 *
+		 * Register it unconditionally: this runs both on the front end and in
+		 * `admin-ajax.php`, so an `is_admin()` guard around `add_filter()` would
+		 * open the page but leave every submission refused. Returning false does
+		 * not reopen the page on a store that also requires an account to
+		 * comment, since that requirement is checked separately.
+		 *
+		 * @since 11.4.0
+		 *
+		 * @param bool     $required Whether a session is required.
+		 * @param WC_Order $order    The order being reviewed.
+		 */
+		return (bool) apply_filters( 'woocommerce_review_order_verify_known_shoppers', true, $order );
+	}
+
+	/**
+	 * Whether the store's "users must be registered and logged in to comment"
+	 * setting blocks this visitor. `wp_new_comment()` never applies it, so this
+	 * flow has to ask for itself.
+	 *
+	 * @since 11.4.0
+	 *
+	 * @return bool
+	 */
+	public static function requires_registered_account(): bool {
+		return ! is_user_logged_in() && (bool) get_option( 'comment_registration' );
+	}
+
+	/**
+	 * Send a customer back to the Review Order page after signing in.
+	 *
+	 * The My Account login form posts no `redirect` of its own, so
+	 * `WC_Form_Handler::process_login()` falls back to the referer — the login
+	 * page URL, which still carries the return arg this route added.
+	 *
+	 * @internal
+	 *
+	 * @param string $redirect Where WooCommerce intends to send the customer.
+	 * @return string
+	 */
+	public function maybe_return_to_review( $redirect ): string {
+		$redirect = (string) $redirect;
+		$query    = (string) wp_parse_url( $redirect, PHP_URL_QUERY );
+		if ( '' === $query ) {
+			return $redirect;
+		}
+
+		$args = array();
+		wp_parse_str( $query, $args );
+		// `wp_parse_str()` yields an array for `?arg[]=…`, which is not a target.
+		$raw    = $args[ self::RETURN_QUERY_VAR ] ?? '';
+		$target = is_string( $raw ) ? $raw : '';
+
+		// Same-host only; `wp_validate_redirect()` falls back when it is not.
+		return $target ? wp_validate_redirect( $target, $redirect ) : $redirect;
+	}
+
+	/**
+	 * Whether this visitor has to sign in before the order can be reviewed:
+	 * the order belongs to a registered customer who is not signed in, or the
+	 * store only accepts reviews from registered accounts.
+	 *
+	 * @param WC_Order $order The order being reviewed.
+	 * @return bool
+	 */
+	private static function requires_login( WC_Order $order ): bool {
+		if ( is_user_logged_in() ) {
+			return false;
+		}
+
+		return self::requires_owner_login( $order ) || self::requires_registered_account();
+	}
+
+	/**
+	 * Whether the visitor is signed in as somebody other than the order's
+	 * customer. Signing in again cannot help them.
+	 *
+	 * @param WC_Order $order The order being reviewed.
+	 * @return bool
+	 */
+	private static function viewer_is_another_customer( WC_Order $order ): bool {
+		$customer_id = (int) $order->get_customer_id();
+
+		return $customer_id > 0 && is_user_logged_in() && get_current_user_id() !== $customer_id;
+	}
+
+	/**
+	 * The store's login page, returning here once the customer has signed in.
+	 *
+	 * @param WC_Order $order The order being reviewed.
+	 * @return string
+	 */
+	private static function login_url( WC_Order $order ): string {
+		$account_page = wc_get_page_permalink( 'myaccount', '' );
+
+		return $account_page
+			? add_query_arg( self::RETURN_QUERY_VAR, rawurlencode( self::get_url( $order ) ), $account_page )
+			: wp_login_url( self::get_url( $order ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index 50973488a1a..66f478ef45d 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -15,8 +15,10 @@ use WC_Order;
  * Handles the AJAX submission of the Review Order form.
  *
  * One comment per rated row, with per-row outcome reported back so a single
- * row's failure cannot block the rest. Guests submit with the order key;
- * logged-in customers must own the order.
+ * row's failure cannot block the rest. A guest order is submitted with the
+ * order key; an order that belongs to a registered customer needs that
+ * customer's session, and the store's `comment_registration` setting is
+ * honoured here because `wp_new_comment()` does not apply it.
  *
  * @internal Just for internal use.
  *
@@ -109,9 +111,9 @@ class SubmissionHandler {
 			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
 		}

-		// Logged-in user must own the order. Guests with the right key still pass.
+		// Signed in as somebody else: say so, the way the page does.
 		if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
-			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+			wp_send_json_error( array( 'message' => __( 'This order belongs to a different account, so you cannot review it.', 'woocommerce' ) ), 403 );
 		}

 		// Reuse the same eligibility filter the page-load endpoint uses so the
@@ -127,6 +129,17 @@ class SubmissionHandler {
 			wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
 		}

+		// Below the status check, matching `Endpoint::is_authorised()`: an order
+		// the page would 404 has to answer the same way here rather than reply
+		// that a sign-in would help.
+		if ( Endpoint::requires_owner_login( $order ) ) {
+			wp_send_json_error( array( 'message' => __( 'Please log in to your account to review this order.', 'woocommerce' ) ), 403 );
+		}
+
+		if ( Endpoint::requires_registered_account() ) {
+			wp_send_json_error( array( 'message' => __( 'You must be logged in to post a review.', 'woocommerce' ) ), 403 );
+		}
+
 		$results = $this->process_rows( $order, $rows_in );

 		$this->maybe_mark_order_complete( $order );
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
index 5c6311cbb6a..db3eb342026 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
 use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use WC_Helper_Product;
+use WC_Order;
 use WC_Unit_Test_Case;
 use WP_Query;

@@ -24,11 +25,29 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 */
 	private Endpoint $endpoint;

+	/**
+	 * The store's comment_registration setting as the test found it.
+	 *
+	 * @var mixed
+	 */
+	private $comment_registration;
+
+	/**
+	 * The My Account page id as the test found it.
+	 *
+	 * @var mixed
+	 */
+	private $myaccount_page_id;
+
 	/**
 	 * Set up a fresh endpoint instance and a clean query.
 	 */
 	public function setUp(): void {
 		parent::setUp();
+		// Restored verbatim in tearDown(): deleting it would not put back a
+		// store that had the setting on.
+		$this->comment_registration = get_option( 'comment_registration' );
+		$this->myaccount_page_id    = get_option( 'woocommerce_myaccount_page_id' );
 		// Feature flag gates the OrderReviews stack.
 		update_option( 'woocommerce_feature_customer_review_request_enabled', 'yes' );
 		$this->endpoint = new Endpoint();
@@ -67,6 +86,8 @@ class EndpointTest extends WC_Unit_Test_Case {
 		}
 		wp_reset_postdata();
 		wp_set_current_user( 0 );
+		update_option( 'comment_registration', $this->comment_registration );
+		update_option( 'woocommerce_myaccount_page_id', $this->myaccount_page_id );
 		delete_option( 'woocommerce_feature_customer_review_request_enabled' );
 		wp_dequeue_script( 'wc-order-review' );
 		wp_deregister_script( 'wc-order-review' );
@@ -75,6 +96,49 @@ class EndpointTest extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * Stage the globals `gate_request()` reads for a given order.
+	 *
+	 * @param \WC_Order $order Order the request is for.
+	 */
+	private function stage_review_order_request( $order ): void {
+		global $wp, $wp_query, $wp_the_query;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: singular page query so is_page() returns true.
+		$wp_query = new WP_Query( array( 'page_id' => (int) wc_get_page_id( Endpoint::PAGE_KEY ) ) );
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: matching main query.
+		$wp_the_query = $wp_query;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: stage a fresh WP instance carrying our query var.
+		$wp                                    = new \WP();
+		$wp->query_vars[ Endpoint::QUERY_VAR ] = (string) $order->get_id();
+		$_GET                                  = array( 'key' => $order->get_order_key() );
+	}
+
+	/**
+	 * Run `gate_request()` and return where it redirected, or null if it did not.
+	 * `gate_request()` calls `exit` after `wp_safe_redirect()`, so the redirect
+	 * is intercepted and turned into an exception the test can catch.
+	 *
+	 * @return string|null
+	 */
+	private function capture_gate_redirect(): ?string {
+		$captured = null;
+		$catch    = function ( $location ) use ( &$captured ) {
+			$captured = $location;
+			throw new \RuntimeException( 'redirected' );
+		};
+
+		add_filter( 'wp_redirect', $catch );
+		try {
+			$this->endpoint->gate_request();
+		} catch ( \RuntimeException $e ) {
+			unset( $e );
+		} finally {
+			remove_filter( 'wp_redirect', $catch );
+		}
+
+		return $captured;
+	}
+
 	/**
 	 * Run the gating + render with output captured.
 	 *
@@ -99,7 +163,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox wc_get_review_order_url returns a tokenized URL pointing at the new endpoint.
 	 */
 	public function test_helper_returns_tokenized_url(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$url   = wc_get_review_order_url( $order );

 		// Path style on pretty permalinks, query-arg style on plain — accept either.
@@ -123,7 +187,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox The woocommerce_review_order_url filter can replace the helper output.
 	 */
 	public function test_helper_filterable(): void {
-		$order    = OrderHelper::create_order();
+		$order    = OrderHelper::create_order( 0 );
 		$override = static function () {
 			return 'https://example.test/custom';
 		};
@@ -148,7 +212,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox 404s when no key query arg is supplied.
 	 */
 	public function test_404_when_key_missing(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();
 		$_GET = array();
@@ -163,7 +227,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox 404s when the supplied key does not match the order key.
 	 */
 	public function test_404_when_key_mismatched(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();
 		$_GET = array( 'key' => 'wc_order_definitelywrong' );
@@ -178,7 +242,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox 404s when the order status is not in the eligible set.
 	 */
 	public function test_404_when_status_ineligible(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::PROCESSING );
 		$order->save();
 		$_GET = array( 'key' => $order->get_order_key() );
@@ -192,7 +256,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox 404s when a logged-in user does not own the order.
 	 */
-	public function test_404_when_logged_in_customer_mismatch(): void {
+	public function test_signed_in_non_owner_is_told_the_order_is_not_theirs(): void {
 		$customer_id = self::factory()->user->create();
 		$other_id    = self::factory()->user->create();

@@ -203,17 +267,55 @@ class EndpointTest extends WC_Unit_Test_Case {
 		wp_set_current_user( $other_id );
 		$_GET = array( 'key' => $order->get_order_key() );

-		$this->render( $order->get_id() );
+		$html = $this->render( $order->get_id() );

 		global $wp_query;
-		$this->assertTrue( $wp_query->is_404 );
+		$this->assertFalse( $wp_query->is_404, 'Signing in again cannot help, so explain rather than 404.' );
+		$this->assertStringContainsString( 'This order belongs to a different account, so you cannot review it.', $html );
+		$this->assertStringNotContainsString( 'woocommerce-review-order__form', $html );
+	}
+
+	/**
+	 * The guest-order fixtures cannot pin this: both login predicates are false
+	 * for them, so only a registered-customer order shows that authorisation is
+	 * decided before the login prompt. Otherwise a missing key would answer 200
+	 * for an order that exists and 404 for one that does not.
+	 *
+	 * @testWith ["missing"]
+	 *           ["mismatched"]
+	 *           ["ineligible"]
+	 *
+	 * @param string $breach Which authorisation guard to breach.
+	 */
+	public function test_authorisation_outranks_the_login_prompt_on_a_registered_customers_order( string $breach ): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( 'ineligible' === $breach ? OrderStatus::PROCESSING : OrderStatus::COMPLETED );
+		$order->save();
+
+		if ( 'missing' === $breach ) {
+			$_GET = array();
+		} elseif ( 'mismatched' === $breach ) {
+			$_GET = array( 'key' => 'wc_order_NOPE' );
+		} else {
+			$_GET = array( 'key' => $order->get_order_key() );
+		}
+
+		$html = $this->render( $order->get_id() );
+
+		global $wp_query;
+		$this->assertTrue( $wp_query->is_404, 'An unauthorised request must 404, whoever is asking.' );
+		$this->assertStringNotContainsString(
+			'Please log in to your account to review this order.',
+			$html,
+			'A login prompt here would confirm the order exists and who owns it.'
+		);
 	}

 	/**
 	 * @testdox Renders the template for a valid completed-order link.
 	 */
 	public function test_renders_template_on_success(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();
 		$_GET = array( 'key' => $order->get_order_key() );
@@ -231,7 +333,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox render_shortcode() only renders on the managed page with a matching order key.
 	 */
 	public function test_render_shortcode_requires_managed_page_and_key(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();

@@ -269,7 +371,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox The woocommerce_review_order_eligible_statuses filter widens the eligible set.
 	 */
 	public function test_eligible_statuses_filter_widens_set(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::PROCESSING );
 		$order->save();
 		$_GET = array( 'key' => $order->get_order_key() );
@@ -310,7 +412,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	public function test_gate_request_registers_title_filters_after_authorisation(): void {
 		$page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );

-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();

@@ -499,7 +601,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox Loading the page when no actionable rows remain stamps the completed-at meta.
 	 */
 	public function test_no_actionable_rows_stamps_completed_meta(): void {
-		$order   = OrderHelper::create_order();
+		$order   = OrderHelper::create_order( 0 );
 		$product = WC_Helper_Product::create_simple_product();
 		$order->set_billing_email( 'reviewed@example.test' );
 		$order->set_status( OrderStatus::COMPLETED );
@@ -536,7 +638,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox Loading the page with at least one actionable row leaves the completed-at meta unset.
 	 */
 	public function test_actionable_row_does_not_stamp_completed_meta(): void {
-		$order   = OrderHelper::create_order();
+		$order   = OrderHelper::create_order( 0 );
 		$product = WC_Helper_Product::create_simple_product();
 		$order->set_billing_email( 'fresh@example.test' );
 		$order->set_status( OrderStatus::COMPLETED );
@@ -558,7 +660,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox An item whose product has reviews closed is counted by the info notice and renders no form row, while the reviewable item still gets one.
 	 */
 	public function test_reviews_disabled_item_renders_notice_and_no_form_row(): void {
-		$order      = OrderHelper::create_order();
+		$order      = OrderHelper::create_order( 0 );
 		$reviewable = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Reviewable Blue Lamp' ) );
 		$disabled   = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Closed Red Kettle' ) );
 		wp_update_post(
@@ -598,7 +700,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox The empty-state thank-you template renders the meta line, heading, and body when no actionable rows remain.
 	 */
 	public function test_empty_state_template_renders_meta_and_thank_you(): void {
-		$order   = OrderHelper::create_order();
+		$order   = OrderHelper::create_order( 0 );
 		$product = WC_Helper_Product::create_simple_product();
 		$order->set_billing_email( 'thanks@example.test' );
 		$order->set_status( OrderStatus::COMPLETED );
@@ -635,7 +737,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox The empty-state template renders the nothing-to-review arm, and no form, when the order has no reviewable items.
 	 */
 	public function test_empty_state_template_renders_nothing_to_review(): void {
-		$order   = OrderHelper::create_order();
+		$order   = OrderHelper::create_order( 0 );
 		$product = WC_Helper_Product::create_simple_product();
 		$order->set_billing_email( 'nothing@example.test' );
 		$order->set_status( OrderStatus::COMPLETED );
@@ -672,7 +774,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	public function test_enqueued_script_localizes_the_submission_outcome_strings(): void {
 		$page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );

-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		$order->save();

@@ -713,7 +815,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox A pre-filled row exposes the existing rating and text via data-initial-* attributes so the JS dirty gate can detect edits.
 	 */
 	public function test_row_exposes_data_initial_attributes_for_prefilled_review(): void {
-		$order      = OrderHelper::create_order();
+		$order      = OrderHelper::create_order( 0 );
 		$reviewed   = WC_Helper_Product::create_simple_product();
 		$unreviewed = WC_Helper_Product::create_simple_product();
 		$order->set_billing_email( 'prefill@example.test' );
@@ -750,7 +852,7 @@ class EndpointTest extends WC_Unit_Test_Case {
 	 * @testdox The completed-at meta is never overwritten on subsequent loads.
 	 */
 	public function test_completed_meta_is_not_overwritten(): void {
-		$order = OrderHelper::create_order();
+		$order = OrderHelper::create_order( 0 );
 		$order->set_status( OrderStatus::COMPLETED );
 		// Empty the order so the no-actionable-rows path falls through and
 		// would re-stamp `time()` if the early-return guard were removed;
@@ -1081,4 +1183,381 @@ class EndpointTest extends WC_Unit_Test_Case {
 		}
 		delete_option( 'woocommerce_shop_page_id' );
 	}
+
+	/**
+	 * @testdox A signed-out visitor is asked to log in, not shown the form, for a registered customer's order.
+	 */
+	public function test_signed_out_visitor_is_redirected_to_log_in_for_a_registered_customers_order(): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$target = $this->capture_gate_redirect();
+
+		$this->assertNotNull( $target, 'A signed-out visitor is sent to the login page.' );
+		// No My Account page in the test install, so this is the wp-login
+		// fallback; either way the return journey has to be encoded in it.
+		$this->assertStringContainsString(
+			rawurlencode( Endpoint::get_url( $order ) ),
+			(string) $target,
+			'Signing in has to come back to this order, key and all.'
+		);
+	}
+
+	/**
+	 * The My Account login form posts no redirect of its own, so the return
+	 * journey depends entirely on this filter reading the arg back out.
+	 *
+	 * @testdox Signing in returns the customer to the review page.
+	 */
+	public function test_login_redirect_returns_to_the_review_page(): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		$review_url = Endpoint::get_url( $order );
+		$login_page = add_query_arg(
+			Endpoint::RETURN_QUERY_VAR,
+			rawurlencode( $review_url ),
+			home_url( '/my-account/' )
+		);
+
+		$this->assertSame(
+			$review_url,
+			$this->endpoint->maybe_return_to_review( $login_page ),
+			'The arg carried to the login page has to bring them back.'
+		);
+	}
+
+	/**
+	 * `wp_parse_str()` already decodes the arg once, so the target must not be
+	 * decoded a second time or a percent-escape in it would be lost.
+	 *
+	 * @testdox A percent-escape in the return target survives the round trip.
+	 */
+	public function test_login_redirect_preserves_percent_escapes_in_the_target(): void {
+		$review_url = home_url( '/checkout/order-received/?note=a%20b' );
+		$login_page = add_query_arg(
+			Endpoint::RETURN_QUERY_VAR,
+			rawurlencode( $review_url ),
+			home_url( '/my-account/' )
+		);
+
+		$this->assertSame(
+			$review_url,
+			$this->endpoint->maybe_return_to_review( $login_page ),
+			'A percent-escape in the target must survive, not be decoded twice.'
+		);
+	}
+
+	/**
+	 * @testdox A login redirect without the return arg is left alone.
+	 */
+	public function test_login_redirect_without_the_return_arg_is_untouched(): void {
+		$plain = home_url( '/my-account/' );
+
+		$this->assertSame( $plain, $this->endpoint->maybe_return_to_review( $plain ) );
+	}
+
+	/**
+	 * @testdox An array-shaped return arg is ignored rather than cast.
+	 */
+	public function test_login_redirect_ignores_an_array_return_arg(): void {
+		$login_page = home_url( '/my-account/?' . Endpoint::RETURN_QUERY_VAR . '[]=x' );
+
+		$this->assertSame( $login_page, $this->endpoint->maybe_return_to_review( $login_page ) );
+	}
+
+	/**
+	 * @testdox An off-site return target is refused.
+	 */
+	public function test_login_redirect_refuses_an_off_site_return_target(): void {
+		$login_page = add_query_arg(
+			Endpoint::RETURN_QUERY_VAR,
+			rawurlencode( 'https://example.org/somewhere-else' ),
+			home_url( '/my-account/' )
+		);
+
+		$this->assertSame(
+			$login_page,
+			$this->endpoint->maybe_return_to_review( $login_page ),
+			'wp_validate_redirect() must keep the shopper on this host.'
+		);
+	}
+
+	/**
+	 * @testdox Signing in as the order's customer shows the form.
+	 */
+	public function test_order_owner_sees_the_form(): void {
+		$customer_id = self::factory()->user->create();
+		$order       = OrderHelper::create_order( $customer_id );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		wp_set_current_user( $customer_id );
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$html = $this->render( $order->get_id() );
+
+		$this->assertStringContainsString( 'woocommerce-review-order__form', $html );
+	}
+
+	/**
+	 * @testdox A guest order is still reachable with the key alone.
+	 */
+	public function test_guest_order_still_renders_for_a_signed_out_visitor(): void {
+		$order = OrderHelper::create_order( 0 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$html = $this->render( $order->get_id() );
+
+		$this->assertStringContainsString( 'woocommerce-review-order__form', $html );
+	}
+
+	/**
+	 * @testdox The known-shopper filter restores signed-out access.
+	 */
+	public function test_verify_known_shoppers_filter_restores_signed_out_access(): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$seen = null;
+		$spy  = function ( $required, $filtered_order ) use ( &$seen ) {
+			unset( $required );
+			$seen = $filtered_order;
+			return false;
+		};
+		add_filter( 'woocommerce_review_order_verify_known_shoppers', $spy, 10, 2 );
+		$html = $this->render( $order->get_id() );
+		remove_filter( 'woocommerce_review_order_verify_known_shoppers', $spy, 10 );
+
+		$this->assertStringContainsString( 'woocommerce-review-order__form', $html );
+		$this->assertInstanceOf( WC_Order::class, $seen, 'The filter is documented as receiving the order.' );
+		$this->assertSame( $order->get_id(), $seen->get_id() );
+	}
+
+	/**
+	 * The filter's docblock promises this, and a refactor of render_body() into
+	 * if/else would break the promise with nothing going red.
+	 *
+	 * @testdox Turning the known-shopper filter off does not reopen the page when an account is also required.
+	 */
+	public function test_known_shopper_filter_does_not_override_the_account_requirement(): void {
+		update_option( 'comment_registration', 1 );
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		add_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+		$target = $this->capture_gate_redirect();
+		remove_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+
+		$this->assertNotNull( $target, 'The filter does not waive the store-wide account requirement.' );
+	}
+
+	/**
+	 * @testdox A store that requires registration to comment shows a prompt instead of the form.
+	 */
+	public function test_registration_required_replaces_the_form_with_a_prompt(): void {
+		update_option( 'comment_registration', 1 );
+
+		// The test install has no My Account page; stage one so the redirect
+		// has somewhere to point.
+		$account_page_id = (int) wp_insert_post(
+			array(
+				'post_type'   => 'page',
+				'post_status' => 'publish',
+				'post_title'  => 'My account',
+				'post_name'   => 'my-account',
+			)
+		);
+		update_option( 'woocommerce_myaccount_page_id', $account_page_id );
+
+		$order = OrderHelper::create_order( 0 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$target = $this->capture_gate_redirect();
+
+		$this->assertNotNull( $target, 'A store requiring an account sends the visitor to log in.' );
+		$this->assertStringStartsWith( wc_get_page_permalink( 'myaccount', '' ), (string) $target );
+		$this->assertStringContainsString(
+			Endpoint::RETURN_QUERY_VAR . '=' . rawurlencode( Endpoint::get_url( $order ) ),
+			(string) $target,
+			'Signing in has to come back to this order.'
+		);
+	}
+
+	/**
+	 * @testdox Without a My Account page the prompt says so in plain text rather than linking elsewhere.
+	 */
+	public function test_registration_prompt_has_no_link_without_a_my_account_page(): void {
+		update_option( 'comment_registration', 1 );
+		update_option( 'woocommerce_myaccount_page_id', 0 );
+
+		$order = OrderHelper::create_order( 0 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$target = $this->capture_gate_redirect();
+
+		$this->assertNotNull( $target );
+		$this->assertStringContainsString( 'wp-login.php', (string) $target, 'With no My Account page, fall back to wp-login.' );
+	}
+
+	/**
+	 * @testdox A signed-in visitor is unaffected by the registration setting.
+	 */
+	public function test_registration_required_leaves_a_signed_in_visitor_alone(): void {
+		update_option( 'comment_registration', 1 );
+		$order = OrderHelper::create_order( 0 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		wp_set_current_user( self::factory()->user->create() );
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$html = $this->render( $order->get_id() );
+
+		$this->assertStringContainsString( 'woocommerce-review-order__form', $html );
+	}
+
+	/**
+	 * Byte-identical to `test_no_actionable_rows_stamps_completed_meta`, which
+	 * proves this fixture does stamp, apart from the store requiring an account.
+	 * So a failure here can only mean the block stopped being honoured.
+	 *
+	 * @testdox A visitor held at the login prompt does not stamp the completed-at meta.
+	 */
+	public function test_visitor_held_at_login_does_not_stamp_completed_meta(): void {
+		update_option( 'comment_registration', 1 );
+		$order   = OrderHelper::create_order( 0 );
+		$product = WC_Helper_Product::create_simple_product();
+		$order->set_billing_email( 'reviewed@example.test' );
+		$order->set_status( OrderStatus::COMPLETED );
+		foreach ( $order->get_items() as $item ) {
+			$order->remove_item( $item->get_id() );
+		}
+		$order->add_product( $product, 1 );
+		$order->save();
+
+		$comment_id = (int) wp_insert_comment(
+			array(
+				'comment_post_ID'      => $product->get_id(),
+				'comment_author'       => 'Already',
+				'comment_author_email' => 'reviewed@example.test',
+				'comment_content'      => 'Was good.',
+				'comment_type'         => 'review',
+				'comment_approved'     => 1,
+			)
+		);
+		add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
+
+		$_GET = array( 'key' => $order->get_order_key() );
+
+		$this->render( $order->get_id() );
+
+		$fresh = wc_get_order( $order->get_id() );
+		$this->assertEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+	}
+
+	/**
+	 * Drives `gate_request()` rather than the test-facing `render()`, so the
+	 * production path is covered too.
+	 *
+	 * @testdox gate_request() leaves the title filters and form assets alone for a blocked visitor.
+	 */
+	public function test_gate_request_redirects_a_signed_out_visitor_without_touching_the_page(): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$target = $this->capture_gate_redirect();
+
+		$this->assertNotNull( $target, 'The visitor is sent to the login page.' );
+		$this->assertFalse(
+			has_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ) ),
+			'Nothing of this page is rendered, so its title filters must not be registered.'
+		);
+		$this->assertFalse( has_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ) ) );
+		$this->assertFalse( wp_script_is( 'wc-order-review', 'registered' ), 'The form assets are not needed.' );
+	}
+
+	/**
+	 * `render()` is only reachable from tests; this drives the shortcode the
+	 * page actually renders through.
+	 *
+	 * @testdox render_shortcode() returns the login prompt, not the form, for a blocked visitor.
+	 */
+	public function test_render_shortcode_renders_nothing_for_a_visitor_who_must_sign_in(): void {
+		$order = OrderHelper::create_order( self::factory()->user->create() );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$this->assertSame(
+			'',
+			$this->endpoint->render_shortcode(),
+			'gate_request() redirects first; the shortcode must never render the form here.'
+		);
+	}
+
+	/**
+	 * Flipping the shared fixtures to guest orders left `gate_request()` with
+	 * no coverage for the case the review emails actually produce.
+	 *
+	 * @testdox gate_request() registers the title filters for the signed-in owner of a registered customer's order.
+	 */
+	public function test_gate_request_registers_title_filters_for_the_signed_in_owner(): void {
+		$page_id     = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+		$customer_id = self::factory()->user->create();
+
+		$order = OrderHelper::create_order( $customer_id );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		wp_set_current_user( $customer_id );
+
+		global $wp, $wp_query, $wp_the_query;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: singular page query so is_page() returns true.
+		$wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: matching main query.
+		$wp_the_query = $wp_query;
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: stage a fresh WP instance carrying our query var.
+		$wp                                    = new \WP();
+		$wp->query_vars[ Endpoint::QUERY_VAR ] = (string) $order->get_id();
+		$_GET                                  = array( 'key' => $order->get_order_key() );
+
+		$this->endpoint->gate_request();
+
+		$this->assertNotFalse( has_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ) ) );
+		$this->assertNotFalse( has_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ) ) );
+
+		remove_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ), 10 );
+		remove_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ), 10 );
+	}
+
+	/**
+	 * @testdox gate_request() also stops short for the registration-required arm.
+	 */
+	public function test_gate_request_redirects_when_the_store_requires_registration(): void {
+		update_option( 'comment_registration', 1 );
+
+		$order = OrderHelper::create_order( 0 );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		$this->stage_review_order_request( $order );
+
+		$target = $this->capture_gate_redirect();
+
+		$this->assertNotNull( $target );
+		$this->assertFalse( has_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ) ) );
+		$this->assertFalse( wp_script_is( 'wc-order-review', 'registered' ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
index f877ad32489..ff2e6ed8ca0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -16,11 +16,19 @@ use WPAjaxDieContinueException;
  */
 class SubmissionHandlerTest extends WC_Unit_Test_Case {

+	/**
+	 * The store's comment_registration setting as the test found it.
+	 *
+	 * @var mixed
+	 */
+	private $comment_registration;
+
 	/**
 	 * Feature flag gates the OrderReviews stack.
 	 */
 	public function setUp(): void {
 		parent::setUp();
+		$this->comment_registration = get_option( 'comment_registration' );
 		update_option( 'woocommerce_feature_customer_review_request_enabled', 'yes' );
 	}

@@ -33,6 +41,7 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 		update_option( 'comment_max_links', 2 );
 		update_option( 'moderation_keys', '' );
 		update_option( 'disallowed_keys', '' );
+		update_option( 'comment_registration', $this->comment_registration );
 		remove_all_filters( 'woocommerce_review_order_submitted' );
 		remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
 		remove_all_filters( 'woocommerce_review_order_eligible_items' );
@@ -43,12 +52,54 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * Dispatch and stop at the first `wp_send_json_*`, the way production does.
+	 * `dispatch()` lets `wp_die()` return, so the handler runs past a refusal
+	 * and the body ends up holding several JSON payloads that will not decode.
+	 *
+	 * The HTTP status is deliberately not returned: `wp_send_json()` only calls
+	 * `status_header()` when `! headers_sent()`, and the suite's bootstrap has
+	 * already written to stdout, so no status is ever set under PHPUnit. The
+	 * message identifies which guard refused instead.
+	 *
+	 * @return array{success:bool, message:string}
+	 */
+	private function dispatch_once(): array {
+
+		add_filter(
+			'wp_die_ajax_handler',
+			function () {
+				return function () {
+					throw new WPAjaxDieContinueException( 'stopped at the first response' );
+				};
+			}
+		);
+		add_filter( 'wp_doing_ajax', '__return_true' );
+
+		ob_start();
+		$handler = new SubmissionHandler();
+		try {
+			$handler->handle();
+		} catch ( WPAjaxDieContinueException $e ) {
+			unset( $e );
+		}
+		$decoded = json_decode( (string) ob_get_clean(), true );
+
+		return array(
+			'success' => is_array( $decoded ) && ! empty( $decoded['success'] ),
+			'message' => is_array( $decoded ) ? (string) ( $decoded['data']['message'] ?? '' ) : '',
+		);
+	}
+
 	/**
 	 * Build an empty completed order for review submissions.
 	 */
 	private function make_empty_order(): WC_Order {
 		$order = new WC_Order();
-		$order->set_customer_id( 1 );
+		// Guest order: the order key is the only credential, which is the case
+		// these tests exercise. Orders owned by a registered customer need that
+		// customer's session, covered separately.
+		$order->set_customer_id( 0 );
 		$order->set_billing_first_name( 'Jane' );
 		$order->set_billing_last_name( 'Doe' );
 		$order->set_billing_email( 'jane@example.test' );
@@ -90,6 +141,12 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 	/**
 	 * Invoke the handler and capture the JSON it would have sent.
 	 *
+	 * Note that `wp_die_ajax_handler` is filtered to a no-op here, so a
+	 * `wp_send_json_*` does not stop the handler the way it does in production:
+	 * the body can end up holding several payloads and failing to decode, and
+	 * `success` then keeps its `false` default. Assertions beyond "did not
+	 * succeed" belong on `dispatch_once()`.
+	 *
 	 * @return array{success:bool, data:mixed, status:int}
 	 */
 	private function dispatch(): array {
@@ -157,9 +214,10 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 			'_wcnonce' => 'not-the-right-nonce',
 		);

-		$response = $this->dispatch();
+		$response = $this->dispatch_once();

 		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'Security check failed.', $response['message'] );
 	}

 	/**
@@ -176,9 +234,10 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
 		);

-		$response = $this->dispatch();
+		$response = $this->dispatch_once();

 		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'Order not found.', $response['message'] );
 	}

 	/**
@@ -892,6 +951,7 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 	 */
 	public function test_rejects_when_order_status_ineligible(): void {
 		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
 		$order = $built['order'];
 		$order->set_status( OrderStatus::PROCESSING );
 		$order->save();
@@ -909,9 +969,22 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 			),
 		);

-		$response = $this->dispatch();
+		$response = $this->dispatch_once();

 		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'Order not found.', $response['message'] );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			),
+			'A refusal must not store anything.'
+		);
 	}

 	/**
@@ -1638,4 +1711,337 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( 4 => 1 ), $product->get_rating_counts() );
 		$this->assertSame( 1, $product->get_review_count() );
 	}
+
+	/**
+	 * `make_order()` is a guest order, where requires_owner_login() is false by
+	 * construction, so only a registered-customer order shows the key is checked
+	 * before the login requirement.
+	 *
+	 * @testdox A bad key on a registered customer's order is not answered with a login prompt.
+	 */
+	public function test_key_check_outranks_the_login_requirement(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => 'wc_order_NOPE',
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame(
+			'Order not found.',
+			$response['message'],
+			'A login prompt here would confirm the order exists and who owns it.'
+		);
+	}
+
+	/**
+	 * Neither new predicate fires for a signed-in visitor, so this pre-existing
+	 * check is the only thing standing between a leaked key and somebody else's
+	 * order. The page side is covered by EndpointTest::test_404_when_logged_in_customer_mismatch.
+	 *
+	 * @testdox A signed-in visitor cannot submit against another customer's order.
+	 */
+	public function test_rejects_a_submission_from_a_signed_in_non_owner(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->save();
+
+		wp_set_current_user( self::factory()->user->create() );
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'This order belongs to a different account, so you cannot review it.', $response['message'] );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			),
+			'A refusal must not store anything.'
+		);
+	}
+
+	/**
+	 * The page refuses an ineligible status before it considers who is asking,
+	 * so the submit handler has to answer the same way round. Otherwise a 403
+	 * saying a sign-in would help confirms an order the page just 404'd.
+	 *
+	 * @testdox An ineligible status is reported the same way as on the page, whoever is asking.
+	 */
+	public function test_ineligible_status_outranks_the_login_requirement(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->set_status( OrderStatus::PROCESSING );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'Order not found.', $response['message'] );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			),
+			'A refusal must not store anything.'
+		);
+	}
+
+	/**
+	 * @testdox A signed-out visitor cannot submit against a registered customer's order.
+	 */
+	public function test_rejects_a_signed_out_submission_for_a_registered_customers_order(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'Please log in to your account to review this order.', $response['message'] );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			),
+			'A refusal must not store anything.'
+		);
+	}
+
+	/**
+	 * @testdox A store that requires registration to comment rejects a signed-out submission.
+	 */
+	public function test_rejects_a_signed_out_submission_when_the_store_requires_registration(): void {
+		update_option( 'comment_registration', 1 );
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'You must be logged in to post a review.', $response['message'] );
+		$this->assertSame(
+			0,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			),
+			'A refusal must not store anything.'
+		);
+	}
+
+	/**
+	 * @testdox A signed-in shopper can still submit when the store requires registration.
+	 */
+	public function test_accepts_a_signed_in_submission_when_the_store_requires_registration(): void {
+		update_option( 'comment_registration', 1 );
+		wp_set_current_user( self::factory()->user->create() );
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		$response = $this->dispatch_once();
+
+		// `handle()` answers success even when every row failed, so check a
+		// review was actually stored rather than just the envelope.
+		$this->assertTrue( $response['success'] );
+		$this->assertSame(
+			1,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Turning the known-shopper filter off does not unblock submission when an account is also required.
+	 */
+	public function test_known_shopper_filter_does_not_override_the_account_requirement(): void {
+		update_option( 'comment_registration', 1 );
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		add_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+		$response = $this->dispatch_once();
+		remove_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+
+		$this->assertFalse( $response['success'] );
+		$this->assertSame( 'You must be logged in to post a review.', $response['message'] );
+	}
+
+	/**
+	 * @testdox The known-shopper filter unblocks the submit path as well as the page.
+	 */
+	public function test_verify_known_shoppers_filter_unblocks_the_submit_path(): void {
+		$built = $this->make_order( 1 );
+		/** @var WC_Order $order */
+		$order = $built['order'];
+		$order->set_customer_id( self::factory()->user->create() );
+		$order->save();
+
+		$_POST = array(
+			'order_id' => $order->get_id(),
+			'key'      => $order->get_order_key(),
+			'_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+			'reviews'  => array(
+				array(
+					'product_id'    => $built['product_ids'][0],
+					'order_item_id' => $built['item_ids'][0],
+					'rating'        => '5',
+					'text'          => 'Great product.',
+				),
+			),
+		);
+
+		add_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+		$response = $this->dispatch_once();
+		remove_filter( 'woocommerce_review_order_verify_known_shoppers', '__return_false' );
+
+		$this->assertTrue( $response['success'] );
+		$this->assertSame(
+			1,
+			(int) get_comments(
+				array(
+					'post_id' => $built['product_ids'][0],
+					'type'    => 'review',
+					'count'   => true,
+					'status'  => 'all',
+				)
+			)
+		);
+	}
 }