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',
+ )
+ )
+ );
+ }
}