Commit 70fb418b0c7 for woocommerce
commit 70fb418b0c717b4b527c1b7cdf5befcbd485fe6c
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Thu Sep 24 18:34:34 2026 +0100
Add user and device filters to the push tokens endpoint (#68741)
* Add user_id and device_uuid filters to the push tokens index
The diagnostics devices page had to page through every token on a store to
find one account or one phone. The index now accepts user_id and device_uuid
as exact-match filters, applied together when both are given, with the
pagination headers reflecting the filtered total. A user_id outside the push
notification roles returns an empty page rather than that user's tokens.
* Report push token totals when the requested page is past the last one
A filtered request on a later page returned a total of 0 even when tokens matched, so the devices page reported a device as missing.
* Reject a non-string device_uuid filter on the push tokens index
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 43975b62ba7..34752d3fb14 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -78,7 +78,7 @@ class PushTokenRestController extends RestApiControllerBase {
'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'index' ),
'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
'args' => array(
- 'page' => array(
+ 'page' => array(
'description' => __( 'Current page of the collection.', 'woocommerce' ),
'type' => 'integer',
'default' => 1,
@@ -86,7 +86,7 @@ class PushTokenRestController extends RestApiControllerBase {
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
),
- 'per_page' => array(
+ 'per_page' => array(
'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
'type' => 'integer',
'default' => 10,
@@ -95,6 +95,20 @@ class PushTokenRestController extends RestApiControllerBase {
'sanitize_callback' => 'absint',
'validate_callback' => 'rest_validate_request_arg',
),
+ 'user_id' => array(
+ 'description' => __( 'Limit results to tokens belonging to this user.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'device_uuid' => array(
+ 'description' => __( 'Limit results to tokens registered by this device.', 'woocommerce' ),
+ 'type' => 'string',
+ 'maxLength' => PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH,
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
),
),
array(
@@ -137,9 +151,10 @@ class PushTokenRestController extends RestApiControllerBase {
}
/**
- * Returns all push tokens for roles that can receive push notifications,
+ * Returns push tokens for roles that can receive push notifications,
* along with when each token was registered, when the app last confirmed
- * it, and the username and email of the account it belongs to.
+ * it, and the username and email of the account it belongs to. Optionally
+ * limited to one user, one device, or both.
*
* @since 10.8.0
*
@@ -150,6 +165,10 @@ class PushTokenRestController extends RestApiControllerBase {
public function index( WP_REST_Request $request ) {
$page = (int) $request->get_param( 'page' );
$per_page = (int) $request->get_param( 'per_page' );
+ $filters = array(
+ 'user_id' => $request->get_param( 'user_id' ),
+ 'device_uuid' => $request->get_param( 'device_uuid' ),
+ );
try {
/**
@@ -162,7 +181,8 @@ class PushTokenRestController extends RestApiControllerBase {
->get_tokens_for_roles(
PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED,
$page,
- $per_page
+ $per_page,
+ $filters
);
} catch ( Exception $e ) {
return $this->convert_exception_to_wp_error( $e );
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 56cc73b1630..441af070a3b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -447,13 +447,17 @@ class PushTokensDataStore {
* @param string[] $roles The roles to query tokens for.
* @param int|null $page Optional page number (1-based).
* @param int|null $per_page Optional number of tokens per page.
+ * @param array $filters Optional exact-match filters: `user_id` (int) and `device_uuid` (string).
+ * @phpstan-param array{user_id?: int|null, device_uuid?: string|null} $filters
* @return PushToken[]|array{tokens: PushToken[], total: int, total_pages: int}
*
* @since 10.7.0
*/
- public function get_tokens_for_roles( array $roles, ?int $page = null, ?int $per_page = null ) {
- $paginate = null !== $page && null !== $per_page;
- $cache_key = $paginate ? implode( ',', $roles ) . ":$page:$per_page" : implode( ',', $roles );
+ public function get_tokens_for_roles( array $roles, ?int $page = null, ?int $per_page = null, array $filters = array() ) {
+ $paginate = null !== $page && null !== $per_page;
+ $user_id = empty( $filters['user_id'] ) ? null : (int) $filters['user_id'];
+ $device_uuid = empty( $filters['device_uuid'] ) ? null : (string) $filters['device_uuid'];
+ $cache_key = implode( ',', $roles ) . ":$page:$per_page:$user_id:$device_uuid";
$empty_result = $paginate
? array(
@@ -474,12 +478,16 @@ class PushTokensDataStore {
global $wpdb;
// Exactly this SQL to leverage the wp_posts type_status_author index; low token cardinality keeps it fast at any store size.
- $users_with_tokens = $wpdb->get_col(
- $wpdb->prepare(
- "SELECT DISTINCT post_author FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'",
- PushToken::POST_TYPE
- )
- );
+ $sql = "SELECT DISTINCT post_author FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'";
+ $args = array( PushToken::POST_TYPE );
+
+ if ( null !== $user_id ) {
+ $sql .= ' AND post_author = %d';
+ $args[] = $user_id;
+ }
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built from literals above and every value goes through a placeholder.
+ $users_with_tokens = $wpdb->get_col( $wpdb->prepare( $sql, ...$args ) );
// An empty include must short-circuit: WP_User_Query would ignore it and scan all users by role.
$user_ids = empty( $users_with_tokens ) ? array() : get_users(
@@ -509,6 +517,17 @@ class PushTokensDataStore {
$query_args['order'] = 'ASC';
}
+ if ( null !== $device_uuid ) {
+ // Bounded by author__in, so the meta join only sees this store's own tokens.
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ $query_args['meta_query'] = array(
+ array(
+ 'key' => 'device_uuid',
+ 'value' => $device_uuid,
+ ),
+ );
+ }
+
$query = new WP_Query( $query_args );
/**
@@ -520,8 +539,26 @@ class PushTokensDataStore {
$post_ids = $query->posts;
if ( empty( $post_ids ) ) {
- $this->tokens_by_roles_cache[ $cache_key ] = $empty_result;
- return $this->tokens_by_roles_cache[ $cache_key ];
+ $result = $empty_result;
+
+ // WP_Query skips counting when a page comes back empty, so a page past the end would otherwise report no matches.
+ if ( $paginate && $page > 1 ) {
+ $count_query = new WP_Query(
+ array_merge(
+ $query_args,
+ array(
+ 'paged' => 1,
+ 'posts_per_page' => 1,
+ )
+ )
+ );
+
+ $result['total'] = (int) $count_query->found_posts;
+ $result['total_pages'] = (int) ceil( $result['total'] / $per_page );
+ }
+
+ $this->tokens_by_roles_cache[ $cache_key ] = $result;
+ return $result;
}
_prime_post_caches( $post_ids, false, true );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
index 2bce0caa21f..b8521bf2c64 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
use Exception;
use RuntimeException;
@@ -1913,4 +1914,116 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
$this->assertCount( 1, $response->get_data()['tokens'] );
}
+
+ /**
+ * @testdox Should return only the given user's tokens when user_id is set.
+ */
+ public function test_index_filters_by_user_id(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+ $this->create_index_token( $data_store, $this->user_id, 'filter-user-1' );
+ $this->create_index_token( $data_store, $this->user_id, 'filter-user-2' );
+ $this->create_index_token( $data_store, $this->other_shop_manager_id, 'filter-user-3' );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+ $request->set_param( 'user_id', $this->other_shop_manager_id );
+ $response = $controller->index( $request );
+
+ $tokens = $response->get_data()['tokens'];
+
+ $this->assertCount( 1, $tokens );
+ $this->assertSame( $this->other_shop_manager_id, $tokens[0]['user_id'] );
+ $this->assertSame( '1', $response->get_headers()['X-WP-Total'] );
+ }
+
+ /**
+ * @testdox Should return only the matching device's token when device_uuid is set.
+ */
+ public function test_index_filters_by_device_uuid(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+ $this->create_index_token( $data_store, $this->user_id, 'filter-device-1' );
+ $this->create_index_token( $data_store, $this->user_id, 'filter-device-2' );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+ $request->set_param( 'device_uuid', 'filter-device-2' );
+ $response = $controller->index( $request );
+
+ $tokens = $response->get_data()['tokens'];
+
+ $this->assertCount( 1, $tokens );
+ $this->assertSame( 'token-filter-device-2', $tokens[0]['token'] );
+ $this->assertSame( '1', $response->get_headers()['X-WP-Total'] );
+ }
+
+ /**
+ * @testdox Should reject a user_id below 1.
+ */
+ public function test_index_rejects_a_zero_user_id(): void {
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'user_id', 0 );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Should reject a device_uuid longer than the registration limit.
+ */
+ public function test_index_rejects_an_overlong_device_uuid(): void {
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'device_uuid', str_repeat( 'a', PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH + 1 ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Should reject a device_uuid that is not a string.
+ */
+ public function test_index_rejects_a_non_string_device_uuid(): void {
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'device_uuid', array( 'filter-device-1' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::BAD_REQUEST, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] );
+ }
+
+ /**
+ * Creates a token for the index filter tests, named so the token and
+ * device UUID can be asserted on.
+ *
+ * @param PushTokensDataStore $data_store The data store.
+ * @param int $user_id The owner.
+ * @param string $name Used as the device UUID and, prefixed, as the token.
+ * @return PushToken
+ */
+ private function create_index_token( PushTokensDataStore $data_store, int $user_id, string $name ): PushToken {
+ return $data_store->create(
+ array(
+ 'user_id' => $user_id,
+ 'token' => 'token-' . $name,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => $name,
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 594d1c5b5d3..3ad3ea7e697 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -966,6 +966,127 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
$this->assertCount( 1, $page_two['tokens'] );
}
+ /**
+ * @testdox Should report the real totals when the requested page is past the last one.
+ */
+ public function test_get_tokens_for_roles_reports_totals_for_a_page_past_the_end(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $data_store = new PushTokensDataStore();
+ $wanted = $this->create_push_token_for_user( $data_store, $admin_id );
+
+ $this->create_push_token_for_user( $data_store, $admin_id );
+
+ $result = $data_store->get_tokens_for_roles( array( 'administrator' ), 3, 10, array( 'device_uuid' => $wanted->get_device_uuid() ) );
+
+ $this->assertSame( array(), $result['tokens'] );
+ $this->assertSame( 1, $result['total'] );
+ $this->assertSame( 1, $result['total_pages'] );
+ }
+
+ /**
+ * @testdox Should return only the given user's tokens when filtered by user ID.
+ */
+ public function test_get_tokens_for_roles_filters_by_user_id(): void {
+ $first_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $second_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $data_store = new PushTokensDataStore();
+
+ $this->create_push_token_for_user( $data_store, $first_admin_id );
+ $this->create_push_token_for_user( $data_store, $first_admin_id );
+ $this->create_push_token_for_user( $data_store, $second_admin_id );
+
+ $result = $data_store->get_tokens_for_roles( array( 'administrator' ), 1, 10, array( 'user_id' => $second_admin_id ) );
+
+ $this->assertCount( 1, $result['tokens'] );
+ $this->assertSame( 1, $result['total'] );
+ $this->assertSame( $second_admin_id, $result['tokens'][0]->get_user_id() );
+ }
+
+ /**
+ * @testdox Should return nothing when the filtered user does not have a matching role, even if they own tokens.
+ */
+ public function test_get_tokens_for_roles_user_id_filter_cannot_widen_the_role_check(): void {
+ $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $data_store = new PushTokensDataStore();
+
+ $this->create_push_token_for_user( $data_store, $subscriber_id );
+
+ $result = $data_store->get_tokens_for_roles( array( 'administrator' ), 1, 10, array( 'user_id' => $subscriber_id ) );
+
+ $this->assertSame( array(), $result['tokens'] );
+ $this->assertSame( 0, $result['total'] );
+ }
+
+ /**
+ * @testdox Should return only the matching device's token when filtered by device UUID.
+ */
+ public function test_get_tokens_for_roles_filters_by_device_uuid(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $data_store = new PushTokensDataStore();
+
+ $this->create_push_token_for_user( $data_store, $admin_id );
+ $wanted = $this->create_push_token_for_user( $data_store, $admin_id );
+
+ $result = $data_store->get_tokens_for_roles( array( 'administrator' ), 1, 10, array( 'device_uuid' => $wanted->get_device_uuid() ) );
+
+ $this->assertCount( 1, $result['tokens'] );
+ $this->assertSame( 1, $result['total'] );
+ $this->assertSame( $wanted->get_id(), $result['tokens'][0]->get_id() );
+ }
+
+ /**
+ * @testdox Should require both filters to match when both are given.
+ */
+ public function test_get_tokens_for_roles_combines_user_id_and_device_uuid_filters(): void {
+ $first_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $second_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $data_store = new PushTokensDataStore();
+
+ $first_admin_token = $this->create_push_token_for_user( $data_store, $first_admin_id );
+ $this->create_push_token_for_user( $data_store, $second_admin_id );
+
+ $mismatch = $data_store->get_tokens_for_roles(
+ array( 'administrator' ),
+ 1,
+ 10,
+ array(
+ 'user_id' => $second_admin_id,
+ 'device_uuid' => $first_admin_token->get_device_uuid(),
+ )
+ );
+ $match = $data_store->get_tokens_for_roles(
+ array( 'administrator' ),
+ 1,
+ 10,
+ array(
+ 'user_id' => $first_admin_id,
+ 'device_uuid' => $first_admin_token->get_device_uuid(),
+ )
+ );
+
+ $this->assertSame( 0, $mismatch['total'] );
+ $this->assertSame( 1, $match['total'] );
+ $this->assertSame( $first_admin_token->get_id(), $match['tokens'][0]->get_id() );
+ }
+
+ /**
+ * @testdox Should not serve a filtered call from the cache of an unfiltered one in the same request.
+ */
+ public function test_get_tokens_for_roles_caches_filtered_and_unfiltered_results_separately(): void {
+ $first_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $second_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $data_store = new PushTokensDataStore();
+
+ $this->create_push_token_for_user( $data_store, $first_admin_id );
+ $this->create_push_token_for_user( $data_store, $second_admin_id );
+
+ $unfiltered = $data_store->get_tokens_for_roles( array( 'administrator' ), 1, 10 );
+ $filtered = $data_store->get_tokens_for_roles( array( 'administrator' ), 1, 10, array( 'user_id' => $first_admin_id ) );
+
+ $this->assertSame( 2, $unfiltered['total'] );
+ $this->assertSame( 1, $filtered['total'] );
+ }
+
/**
* @testdox Should not run any user query when no tokens exist.
*/