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