Commit 710f950e0af for woocommerce

commit 710f950e0af3d397437aa1b5863c3e41edfc25ec
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Thu Jun 4 21:35:10 2026 +0100

    Use order capabilities for HPOS placeholder posts (backwards compat) (#58432)

    * Use capabilities for showing "Add new order" button

    ---------

    Co-authored-by: Brandon Kraft <public@brandonkraft.com>

diff --git a/plugins/woocommerce/changelog/fix-39289 b/plugins/woocommerce/changelog/fix-39289
new file mode 100644
index 00000000000..72817fa5e3f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39289
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+For better compat, use order caps for HPOS placeholder posts.
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
index 526c48a7426..12567de5658 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/ListTable.php
@@ -254,12 +254,18 @@ class ListTable extends WP_List_Table {
 			$search_label .= '</span>';
 		}

+		// Add new.
+		$add_new_button = '';
+		if ( $post_type && current_user_can( $post_type->cap->publish_posts ) ) {
+			$add_new_button = "<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>";
+		}
+
 		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
 		echo wp_kses_post(
 			"
 			<div class='wrap'>
 				<h1 class='wp-heading-inline'>{$title}</h1>
-				<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
+				{$add_new_button}
 				{$search_label}
 				<hr class='wp-header-end'>"
 		);
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php
index d1a65b8dfe4..c9c4cc8b906 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php
@@ -148,6 +148,7 @@ class CustomOrdersTableController {
 		add_filter( 'removable_query_args', array( $this, 'register_removable_query_arg' ) );
 		add_filter( 'get_edit_post_link', array( $this, 'maybe_rewrite_order_edit_link' ), 10, 2 );
 		add_action( 'before_woocommerce_init', array( $this, 'maybe_set_order_cache_group_as_non_persistent' ) );
+		add_filter( 'map_meta_cap', array( $this, 'maybe_translate_order_caps' ), 0, 4 );
 	}

 	/**
@@ -189,6 +190,37 @@ class CustomOrdersTableController {
 		$this->db_util                     = $db_util;
 	}

+	/**
+	 * Translate capabilities for HPOS orders when sync is not active.
+	 *
+	 * Only activates when HPOS is the authoritative source and sync is off,
+	 * then lazily delegates to HposOrderCapabilityHelper for the actual
+	 * capability translation.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string[] $caps    The resolved primitive capabilities.
+	 * @param string   $cap     The meta capability being checked.
+	 * @param int      $user_id The user ID.
+	 * @param array    $args    Additional arguments (object ID).
+	 * @return string[] Translated capabilities.
+	 */
+	public function maybe_translate_order_caps( $caps, $cap, $user_id, $args ) {
+		if ( ! $this->custom_orders_table_usage_is_enabled() ) {
+			return $caps;
+		}
+
+		if ( ! $this->data_synchronizer instanceof DataSynchronizer ) {
+			return $caps;
+		}
+
+		if ( $this->data_synchronizer->data_sync_is_enabled() ) {
+			return $caps;
+		}
+
+		return wc_get_container()->get( HposOrderCapabilityHelper::class )->translate_order_caps( $caps, $cap, $user_id, $args );
+	}
+
 	/**
 	 * Is the custom orders table usage enabled via settings?
 	 * This can be true only if the feature is enabled and a table regeneration has been completed.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/HposOrderCapabilityHelper.php b/plugins/woocommerce/src/Internal/DataStores/Orders/HposOrderCapabilityHelper.php
new file mode 100644
index 00000000000..3fe75b06343
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/HposOrderCapabilityHelper.php
@@ -0,0 +1,233 @@
+<?php
+/**
+ * HposOrderCapabilityHelper class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\DataStores\Orders;
+
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Abstract_Order;
+use WP_Post;
+use WP_Post_Type;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Translates capabilities for HPOS orders when sync is not active.
+ *
+ * When HPOS is the authoritative source and sync is off, order rows in
+ * wp_posts are either placeholders (shop_order_placehold) or may not
+ * exist at all. WordPress's map_meta_cap resolves these to generic 'post'
+ * capabilities (or 'do_not_allow' when the post is missing), which breaks
+ * permission checks for roles like Shop Manager that have order-specific
+ * caps but not generic post caps.
+ *
+ * This class is instantiated lazily by CustomOrdersTableController when
+ * a capability check occurs with HPOS enabled and sync disabled.
+ *
+ * @since 10.7.0
+ */
+class HposOrderCapabilityHelper {
+
+	/**
+	 * Translate capabilities for HPOS orders.
+	 *
+	 * Handles the full map_meta_cap filter callback. The caller only needs
+	 * to verify that HPOS is enabled and sync is disabled before delegating.
+	 *
+	 * @since 10.7.0
+	 *
+	 * @param string[] $caps    The resolved primitive capabilities.
+	 * @param string   $cap     The meta capability being checked.
+	 * @param int      $user_id The user ID.
+	 * @param array    $args    Additional arguments (object ID).
+	 * @return string[] Translated capabilities.
+	 */
+	public function translate_order_caps( $caps, $cap, $user_id, $args ) {
+		if ( ! in_array( $cap, array( 'edit_post', 'delete_post', 'read_post' ), true ) || ! isset( $args[0] ) ) {
+			return $caps;
+		}
+
+		$order_id = absint( $args[0] );
+		if ( ! $order_id ) {
+			return $caps;
+		}
+
+		$post = get_post( $order_id );
+		if ( $post instanceof WP_Post && DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE !== $post->post_type ) {
+			return $caps;
+		}
+
+		$order_type    = OrderUtil::get_order_type( $order_id );
+		$order_type_ob = $order_type ? get_post_type_object( $order_type ) : null;
+		if ( ! ( $order_type_ob instanceof WP_Post_Type ) || ! $order_type_ob->map_meta_cap ) {
+			return $caps;
+		}
+
+		$order = wc_get_order( $order_id );
+		if ( ! $order instanceof WC_Abstract_Order ) {
+			return $caps;
+		}
+
+		switch ( $cap ) {
+			case 'edit_post':
+				return $this->map_edit_order_caps( $order, $order_type_ob, (int) $user_id, $post );
+			case 'delete_post':
+				return $this->map_delete_order_caps( $order, $order_type_ob, (int) $user_id, $post );
+			case 'read_post':
+				return $this->map_read_order_caps( $order, $order_type_ob, (int) $user_id, $post );
+		}
+	}
+
+	/**
+	 * Map edit capabilities for an HPOS order without a real order post.
+	 *
+	 * @param WC_Abstract_Order $order         Order object.
+	 * @param WP_Post_Type      $order_type_ob Order post type object.
+	 * @param int               $user_id       User ID.
+	 * @param WP_Post|null      $post          Placeholder post, if one exists.
+	 * @return string[] Required primitive capabilities.
+	 */
+	private function map_edit_order_caps( WC_Abstract_Order $order, WP_Post_Type $order_type_ob, int $user_id, ?WP_Post $post ): array {
+		$status  = $this->get_wp_status_for_order( $order );
+		$is_mine = $user_id === $this->get_author_id( $post );
+
+		if ( $is_mine ) {
+			if ( $this->is_published_status( $status ) ) {
+				return array( $order_type_ob->cap->edit_published_posts );
+			}
+			if ( 'trash' === $status && $this->is_published_status( $this->get_trashed_status( $order ) ) ) {
+				return array( $order_type_ob->cap->edit_published_posts );
+			}
+			return array( $order_type_ob->cap->edit_posts );
+		}
+
+		$caps = array( $order_type_ob->cap->edit_others_posts );
+		if ( $this->is_published_status( $status ) ) {
+			$caps[] = $order_type_ob->cap->edit_published_posts;
+		} elseif ( $this->is_private_status( $status ) ) {
+			$caps[] = $order_type_ob->cap->edit_private_posts;
+		}
+
+		return $caps;
+	}
+
+	/**
+	 * Map delete capabilities for an HPOS order without a real order post.
+	 *
+	 * @param WC_Abstract_Order $order         Order object.
+	 * @param WP_Post_Type      $order_type_ob Order post type object.
+	 * @param int               $user_id       User ID.
+	 * @param WP_Post|null      $post          Placeholder post, if one exists.
+	 * @return string[] Required primitive capabilities.
+	 */
+	private function map_delete_order_caps( WC_Abstract_Order $order, WP_Post_Type $order_type_ob, int $user_id, ?WP_Post $post ): array {
+		$status  = $this->get_wp_status_for_order( $order );
+		$is_mine = $user_id === $this->get_author_id( $post );
+
+		if ( $is_mine ) {
+			if ( $this->is_published_status( $status ) ) {
+				return array( $order_type_ob->cap->delete_published_posts );
+			}
+			if ( 'trash' === $status && $this->is_published_status( $this->get_trashed_status( $order ) ) ) {
+				return array( $order_type_ob->cap->delete_published_posts );
+			}
+			return array( $order_type_ob->cap->delete_posts );
+		}
+
+		$caps = array( $order_type_ob->cap->delete_others_posts );
+		if ( $this->is_published_status( $status ) ) {
+			$caps[] = $order_type_ob->cap->delete_published_posts;
+		} elseif ( $this->is_private_status( $status ) ) {
+			$caps[] = $order_type_ob->cap->delete_private_posts;
+		}
+
+		return $caps;
+	}
+
+	/**
+	 * Map read capabilities for an HPOS order without a real order post.
+	 *
+	 * @param WC_Abstract_Order $order         Order object.
+	 * @param WP_Post_Type      $order_type_ob Order post type object.
+	 * @param int               $user_id       User ID.
+	 * @param WP_Post|null      $post          Placeholder post, if one exists.
+	 * @return string[] Required primitive capabilities.
+	 */
+	private function map_read_order_caps( WC_Abstract_Order $order, WP_Post_Type $order_type_ob, int $user_id, ?WP_Post $post ): array {
+		$status_obj = get_post_status_object( $this->get_wp_status_for_order( $order ) );
+		if ( ! $status_obj ) {
+			return array( $order_type_ob->cap->edit_others_posts );
+		}
+
+		if ( $status_obj->public || $user_id === $this->get_author_id( $post ) ) {
+			return array( $order_type_ob->cap->read );
+		}
+
+		if ( $status_obj->private ) {
+			return array( $order_type_ob->cap->read_private_posts );
+		}
+
+		return $this->map_edit_order_caps( $order, $order_type_ob, $user_id, $post );
+	}
+
+	/**
+	 * Get the WordPress post status equivalent for an order.
+	 *
+	 * @param WC_Abstract_Order $order Order object.
+	 * @return string Post status.
+	 */
+	private function get_wp_status_for_order( WC_Abstract_Order $order ): string {
+		$status = $order->get_status( 'edit' );
+		return wc_is_order_status( 'wc-' . $status ) ? 'wc-' . $status : $status;
+	}
+
+	/**
+	 * Check whether a post status should require published post caps.
+	 *
+	 * @param string $status Post status.
+	 * @return bool True when published caps should be required.
+	 */
+	private function is_published_status( string $status ): bool {
+		$status_obj = get_post_status_object( $status );
+		return in_array( $status, array( 'publish', 'future' ), true ) || ( $status_obj && $status_obj->public );
+	}
+
+	/**
+	 * Check whether a post status should require private post caps.
+	 *
+	 * @param string $status Post status.
+	 * @return bool True when private caps should be required.
+	 */
+	private function is_private_status( string $status ): bool {
+		$status_obj = get_post_status_object( $status );
+		return 'private' === $status || ( $status_obj && $status_obj->private );
+	}
+
+	/**
+	 * Get the previous status stored when an order is trashed.
+	 *
+	 * @param WC_Abstract_Order $order Order object.
+	 * @return string Previous post status.
+	 */
+	private function get_trashed_status( WC_Abstract_Order $order ): string {
+		$status = $order->get_meta( '_wp_trash_meta_status', true, 'edit' );
+		return is_string( $status ) ? $status : '';
+	}
+
+	/**
+	 * Get the author ID to use for WordPress-style capability checks.
+	 *
+	 * @param WP_Post|null $post Placeholder post, if one exists.
+	 * @return int Author ID.
+	 */
+	private function get_author_id( ?WP_Post $post ): int {
+		if ( $post instanceof WP_Post && 0 < (int) $post->post_author ) {
+			return (int) $post->post_author;
+		}
+
+		return 1;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/HposOrderCapabilityHelperTest.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/HposOrderCapabilityHelperTest.php
new file mode 100644
index 00000000000..5506ac7b77a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/HposOrderCapabilityHelperTest.php
@@ -0,0 +1,381 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\DataStores\Orders;
+
+use Automattic\WooCommerce\Internal\Admin\Orders\ListTable;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for HposOrderCapabilityHelper.
+ */
+class HposOrderCapabilityHelperTest extends WC_Unit_Test_Case {
+	use HPOSToggleTrait;
+
+	/**
+	 * Custom roles registered during a test.
+	 *
+	 * @var string[]
+	 */
+	private array $custom_roles = array();
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		add_filter( 'wc_order_statuses', array( $this, 'add_private_test_order_status' ) );
+		register_post_status(
+			'wc-private-test',
+			array(
+				'label'   => 'Private test',
+				'private' => true,
+			)
+		);
+		$this->setup_cot();
+		$this->disable_cot_sync();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		foreach ( $this->custom_roles as $role ) {
+			remove_role( $role );
+		}
+		$this->custom_roles = array();
+
+		remove_filter( 'wc_order_statuses', array( $this, 'add_private_test_order_status' ) );
+		$this->unregister_private_test_order_status();
+		$this->clean_up_cot_setup();
+		remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Register a private order status for capability mapping tests.
+	 *
+	 * @param array<string,string> $statuses Order statuses.
+	 * @return array<string,string> Order statuses.
+	 */
+	public function add_private_test_order_status( array $statuses ): array {
+		$statuses['wc-private-test'] = 'Private test';
+		return $statuses;
+	}
+
+	/**
+	 * Unregister the private order status added for capability mapping tests.
+	 */
+	private function unregister_private_test_order_status(): void {
+		global $wp_post_statuses;
+
+		unset( $wp_post_statuses['wc-private-test'] );
+	}
+
+	/**
+	 * @testdox Shop manager can edit an HPOS order.
+	 */
+	public function test_shop_manager_can_edit_order(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'shop_manager' );
+
+		$this->assertTrue( current_user_can( 'edit_shop_order', $order_id ), 'Shop manager should be able to edit HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Shop manager can delete an HPOS order.
+	 */
+	public function test_shop_manager_can_delete_order(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'shop_manager' );
+
+		$this->assertTrue( current_user_can( 'delete_shop_order', $order_id ), 'Shop manager should be able to delete HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Shop manager can read an HPOS order.
+	 */
+	public function test_shop_manager_can_read_order(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'shop_manager' );
+
+		$this->assertTrue( current_user_can( 'read_shop_order', $order_id ), 'Shop manager should be able to read HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox User with order edit caps can edit an HPOS order without generic post caps.
+	 */
+	public function test_user_with_order_caps_can_edit_order_without_generic_post_caps(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_user_with_caps(
+			'hpos_order_editor',
+			array(
+				'read'                    => true,
+				'edit_shop_orders'        => true,
+				'edit_others_shop_orders' => true,
+			)
+		);
+
+		$this->assertFalse( current_user_can( 'edit_others_posts' ), 'Test role should not have generic post edit caps' );
+		$this->assertTrue( current_user_can( 'edit_shop_order', $order_id ), 'Order-specific edit caps should allow editing HPOS orders' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox REST order edit permission uses order caps for HPOS orders.
+	 */
+	public function test_rest_edit_permission_uses_order_caps_for_hpos_orders(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_user_with_caps(
+			'hpos_rest_order_editor',
+			array(
+				'read'                    => true,
+				'edit_shop_orders'        => true,
+				'edit_others_shop_orders' => true,
+			)
+		);
+
+		$this->assertFalse( current_user_can( 'edit_others_posts' ), 'Test role should not have generic post edit caps' );
+		$this->assertTrue( wc_rest_check_post_permissions( 'shop_order', 'edit', $order_id ), 'REST edit permission should use order caps for HPOS orders' );
+	}
+
+	/**
+	 * @testdox REST order edit permission rejects users without order edit caps.
+	 */
+	public function test_rest_edit_permission_rejects_user_without_order_edit_caps(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'subscriber' );
+
+		$this->assertFalse( wc_rest_check_post_permissions( 'shop_order', 'edit', $order_id ), 'REST edit permission should reject users without order edit caps' );
+	}
+
+	/**
+	 * @testdox List table renders a checkbox for an editable HPOS order.
+	 */
+	public function test_list_table_renders_checkbox_for_editable_hpos_order(): void {
+		$order = OrderHelper::create_order();
+		$this->login_as_role( 'shop_manager' );
+
+		$list_table    = new ListTable();
+		$set_post_type = function () {
+			$this->wp_post_type = get_post_type_object( 'shop_order' );
+		};
+		$set_post_type->call( $list_table );
+
+		$output = $list_table->column_cb( $order );
+
+		$this->assertIsString( $output );
+		$this->assertStringContainsString( 'cb-select-' . $order->get_id(), $output );
+	}
+
+	/**
+	 * @testdox Subscriber cannot edit an HPOS order.
+	 */
+	public function test_subscriber_cannot_edit_order(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'subscriber' );
+
+		$this->assertFalse( current_user_can( 'edit_shop_order', $order_id ), 'Subscriber should not be able to edit HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Subscriber cannot delete an HPOS order.
+	 */
+	public function test_subscriber_cannot_delete_order(): void {
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'subscriber' );
+
+		$this->assertFalse( current_user_can( 'delete_shop_order', $order_id ), 'Subscriber should not be able to delete HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox User without private order edit cap cannot edit private HPOS order.
+	 */
+	public function test_user_without_private_order_edit_cap_cannot_edit_private_order(): void {
+		$order    = $this->create_private_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_user_with_caps(
+			'hpos_private_order_editor_without_private_cap',
+			array(
+				'read'                    => true,
+				'edit_shop_orders'        => true,
+				'edit_others_shop_orders' => true,
+			)
+		);
+
+		$this->assertFalse( current_user_can( 'edit_shop_order', $order_id ), 'Private HPOS orders should require edit_private_shop_orders' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox User without private order delete cap cannot delete private HPOS order.
+	 */
+	public function test_user_without_private_order_delete_cap_cannot_delete_private_order(): void {
+		$order    = $this->create_private_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_user_with_caps(
+			'hpos_private_order_deleter_without_private_cap',
+			array(
+				'read'                      => true,
+				'delete_shop_orders'        => true,
+				'delete_others_shop_orders' => true,
+			)
+		);
+
+		$this->assertFalse( current_user_can( 'delete_shop_order', $order_id ), 'Private HPOS orders should require delete_private_shop_orders' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox User with private order read cap can read private HPOS order.
+	 */
+	public function test_user_with_private_order_read_cap_can_read_private_order(): void {
+		$order    = $this->create_private_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_user_with_caps(
+			'hpos_private_order_reader',
+			array(
+				'read'                     => true,
+				'read_private_shop_orders' => true,
+			)
+		);
+
+		$this->assertTrue( current_user_can( 'read_shop_order', $order_id ), 'Private HPOS orders should allow read_private_shop_orders' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Subscriber cannot read private HPOS order.
+	 */
+	public function test_subscriber_cannot_read_private_order(): void {
+		$order    = $this->create_private_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'subscriber' );
+
+		$this->assertFalse( current_user_can( 'read_shop_order', $order_id ), 'Subscriber should not be able to read private HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Shop manager can edit a refund for an HPOS order.
+	 */
+	public function test_shop_manager_can_edit_refund(): void {
+		$this->login_as_role( 'shop_manager' );
+
+		$order  = OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+				'reason'   => 'Test refund',
+			)
+		);
+
+		$this->assertTrue( current_user_can( 'edit_shop_order', $refund->get_id() ), 'Shop manager should be able to edit refund for HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Shop manager can delete a refund for an HPOS order.
+	 */
+	public function test_shop_manager_can_delete_refund(): void {
+		$this->login_as_role( 'shop_manager' );
+
+		$order  = OrderHelper::create_order();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 1,
+				'reason'   => 'Test refund',
+			)
+		);
+
+		$this->assertTrue( current_user_can( 'delete_shop_order', $refund->get_id() ), 'Shop manager should be able to delete refund for HPOS order' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Cap translation does not apply when HPOS is disabled.
+	 */
+	public function test_filter_does_not_apply_when_hpos_disabled(): void {
+		$this->toggle_cot_feature_and_usage( false );
+
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'shop_manager' );
+
+		$this->assertTrue( current_user_can( 'edit_shop_order', $order_id ), 'Shop manager should still be able to edit orders when HPOS is off' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Cap translation does not apply when sync is enabled.
+	 */
+	public function test_filter_does_not_apply_when_sync_enabled(): void {
+		$this->enable_cot_sync();
+
+		$order    = OrderHelper::create_order();
+		$order_id = $order->get_id();
+
+		$this->login_as_role( 'shop_manager' );
+
+		$this->assertTrue( current_user_can( 'edit_shop_order', $order_id ), 'Shop manager should be able to edit orders when sync is on' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown
+	}
+
+	/**
+	 * @testdox Cap translation does not affect non-order posts.
+	 */
+	public function test_filter_does_not_affect_regular_posts(): void {
+		$post_id = $this->factory->post->create();
+
+		$this->login_as_role( 'subscriber' );
+
+		$this->assertFalse( current_user_can( 'edit_post', $post_id ), 'Subscriber should not be able to edit regular posts even with HPOS cap translation active' );
+	}
+
+	/**
+	 * Create a user with a custom role and set it as current.
+	 *
+	 * @param string             $role Role name.
+	 * @param array<string,bool> $caps Capabilities.
+	 * @return int User ID.
+	 */
+	private function login_as_user_with_caps( string $role, array $caps ): int {
+		remove_role( $role );
+		add_role( $role, $role, $caps );
+		$this->custom_roles[] = $role;
+
+		return $this->login_as_role( $role );
+	}
+
+	/**
+	 * Create an HPOS order using the private test status.
+	 *
+	 * @return \WC_Order Order object.
+	 */
+	private function create_private_order(): \WC_Order {
+		return \WC_Helper_Order::create_order(
+			1,
+			null,
+			array(
+				'status' => 'private-test',
+			)
+		);
+	}
+}