Commit de794f4e7ba for woocommerce
commit de794f4e7baf140e9d42e130e3e615133b1a5bdd
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Fri Apr 10 14:52:16 2026 +0100
Add WPCOM-authenticated endpoint to retrieve push tokens. (#64066)
* Add WPCOM-authenticated endpoint to retrieve push tokens.
- Add GET route on push-tokens with Jetpack blog token auth.
- Return tokens in WPCOM format via new index() method.
- Rename authorize() to authorize_as_authenticated() for clarity.
- Use generic error response to avoid leaking auth strategy.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index b97a2e451c7..a0184fa10c9 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
defined( 'ABSPATH' ) || exit;
+use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
@@ -66,11 +67,35 @@ class PushTokenRestController extends RestApiControllerBase {
$this->get_rest_api_namespace(),
$this->rest_base,
array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'index' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+ 'args' => array(
+ 'page' => array(
+ 'description' => __( 'Current page of the collection.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'per_page' => array(
+ 'description' => __( 'Maximum number of items to be returned in result set.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'default' => 10,
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ ),
+ ),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'create' ),
'args' => $this->get_args( 'create' ),
- 'permission_callback' => array( $this, 'authorize' ),
+ 'permission_callback' => array( $this, 'authorize_as_authenticated' ),
'schema' => array( $this, 'get_schema' ),
),
)
@@ -84,13 +109,60 @@ class PushTokenRestController extends RestApiControllerBase {
'methods' => WP_REST_Server::DELETABLE,
'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'delete' ),
'args' => $this->get_args( 'delete' ),
- 'permission_callback' => array( $this, 'authorize' ),
+ 'permission_callback' => array( $this, 'authorize_as_authenticated' ),
'schema' => array( $this, 'get_schema' ),
),
)
);
}
+ /**
+ * Returns all push tokens for roles that can receive push notifications,
+ * formatted for the WPCOM push notifications endpoint.
+ *
+ * @since 10.8.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response|WP_Error
+ */
+ public function index( WP_REST_Request $request ) {
+ $page = (int) $request->get_param( 'page' );
+ $per_page = (int) $request->get_param( 'per_page' );
+
+ try {
+ /**
+ * Paginated result from get_tokens_for_roles.
+ *
+ * @var array{tokens: PushToken[], total: int, total_pages: int} $result
+ */
+ $result = wc_get_container()
+ ->get( PushTokensDataStore::class )
+ ->get_tokens_for_roles(
+ PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED,
+ $page,
+ $per_page
+ );
+ } catch ( Exception $e ) {
+ return $this->convert_exception_to_wp_error( $e );
+ }
+
+ $response = new WP_REST_Response(
+ array(
+ 'tokens' => array_map(
+ fn ( $token ) => $token->to_wpcom_format(),
+ $result['tokens']
+ ),
+ ),
+ WP_Http::OK
+ );
+
+ $response->header( 'X-WP-Total', (string) $result['total'] );
+ $response->header( 'X-WP-TotalPages', (string) $result['total_pages'] );
+
+ return $response;
+ }
+
/**
* Creates a push token record.
*
@@ -217,7 +289,7 @@ class PushTokenRestController extends RestApiControllerBase {
}
/**
- * Checks user is authorized to access this endpoint.
+ * Checks user is authenticated and authorized to access this endpoint.
*
* @since 10.6.0
*
@@ -225,7 +297,7 @@ class PushTokenRestController extends RestApiControllerBase {
* @phpstan-param WP_REST_Request<array<string, mixed>> $request
* @return bool|WP_Error
*/
- public function authorize( WP_REST_Request $request ) {
+ public function authorize_as_authenticated( WP_REST_Request $request ) {
if ( ! get_current_user_id() ) {
return new WP_Error(
'woocommerce_rest_cannot_view',
@@ -251,6 +323,35 @@ class PushTokenRestController extends RestApiControllerBase {
return true;
}
+ /**
+ * Validates that the request is signed with a Jetpack blog token,
+ * ensuring only WPCOM can access this endpoint.
+ *
+ * @since 10.8.0
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return bool|WP_Error
+ */
+ public function authorize_as_from_wpcom( WP_REST_Request $request ) {
+ if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
+ return false;
+ }
+
+ if (
+ class_exists( Rest_Authentication::class )
+ && Rest_Authentication::is_signed_with_blog_token()
+ ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'woocommerce_rest_cannot_view',
+ __( 'Sorry, you are not allowed to do that.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
/**
* Converts an exception to an instance of WP_Error.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 98982e66b94..0ad5d89ff9e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -25,10 +25,10 @@ use WP_Query;
class PushTokensDataStore {
/**
* In-memory cache for get_tokens_for_roles() results, keyed by the
- * comma-joined role list. Avoids repeated DB queries within the same
- * PHP request.
+ * comma-joined role list (with optional pagination suffix). Avoids
+ * repeated DB queries within the same PHP request.
*
- * @var array<string, PushToken[]>
+ * @var array<string, PushToken[]|array{tokens: PushToken[], total: int, total_pages: int}>
*/
private array $tokens_by_roles_cache = array();
@@ -312,20 +312,35 @@ class PushTokensDataStore {
}
/**
- * Returns all push tokens belonging to users with the given roles.
+ * Returns push tokens belonging to users with the given roles.
*
- * @param string[] $roles The roles to query tokens for.
- * @return PushToken[]
+ * When called without pagination parameters, returns all tokens as a
+ * flat array (cached per-request). When $page and $per_page are
+ * provided, returns a paginated result with total counts.
+ *
+ * @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.
+ * @return PushToken[]|array{tokens: PushToken[], total: int, total_pages: int}
*
* @since 10.7.0
*/
- public function get_tokens_for_roles( array $roles ): array {
+ 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 );
+
+ $empty_result = $paginate
+ ? array(
+ 'tokens' => array(),
+ 'total' => 0,
+ 'total_pages' => 0,
+ )
+ : array();
+
if ( empty( $roles ) ) {
- return array();
+ return $empty_result;
}
- $cache_key = implode( ',', $roles );
-
if ( isset( $this->tokens_by_roles_cache[ $cache_key ] ) ) {
return $this->tokens_by_roles_cache[ $cache_key ];
}
@@ -338,20 +353,26 @@ class PushTokensDataStore {
);
if ( empty( $user_ids ) ) {
- $this->tokens_by_roles_cache[ $cache_key ] = array();
+ $this->tokens_by_roles_cache[ $cache_key ] = $empty_result;
return $this->tokens_by_roles_cache[ $cache_key ];
}
- $query = new WP_Query(
- array(
- 'post_type' => PushToken::POST_TYPE,
- 'post_status' => 'private',
- 'author__in' => $user_ids,
- 'posts_per_page' => -1,
- 'fields' => 'ids',
- )
+ $query_args = array(
+ 'post_type' => PushToken::POST_TYPE,
+ 'post_status' => 'private',
+ 'author__in' => $user_ids,
+ 'posts_per_page' => $paginate ? $per_page : -1,
+ 'fields' => 'ids',
);
+ if ( $paginate ) {
+ $query_args['paged'] = $page;
+ $query_args['orderby'] = 'ID';
+ $query_args['order'] = 'ASC';
+ }
+
+ $query = new WP_Query( $query_args );
+
/**
* Typehint for PHPStan, specifies these are IDs and not instances of
* WP_Post.
@@ -361,7 +382,7 @@ class PushTokensDataStore {
$post_ids = $query->posts;
if ( empty( $post_ids ) ) {
- $this->tokens_by_roles_cache[ $cache_key ] = array();
+ $this->tokens_by_roles_cache[ $cache_key ] = $empty_result;
return $this->tokens_by_roles_cache[ $cache_key ];
}
@@ -383,9 +404,16 @@ class PushTokensDataStore {
}
}
- $this->tokens_by_roles_cache[ $cache_key ] = $tokens;
+ $result = $paginate
+ ? array(
+ 'tokens' => $tokens,
+ 'total' => (int) $query->found_posts,
+ 'total_pages' => (int) $query->max_num_pages,
+ )
+ : $tokens;
- return $tokens;
+ $this->tokens_by_roles_cache[ $cache_key ] = $result;
+ return $result;
}
/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index 51700dacfc6..b98f67fb654 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
@@ -122,6 +123,11 @@ class NotificationProcessor {
$notification->write_meta( self::CLAIMED_META_KEY );
}
+ /**
+ * Non-paginated result from get_tokens_for_roles.
+ *
+ * @var PushToken[] $tokens
+ */
$tokens = $this->data_store->get_tokens_for_roles(
PushNotifications::ROLES_WITH_PUSH_NOTIFICATIONS_ENABLED
);
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 5f823cc8358..9058ccc0b41 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -1056,7 +1056,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$controller = new PushTokenRestController();
$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
- $result = $controller->authorize( $request );
+ $result = $controller->authorize_as_authenticated( $request );
$this->assertFalse( $result );
}
@@ -1073,7 +1073,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$controller = new PushTokenRestController();
$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
- $result = $controller->authorize( $request );
+ $result = $controller->authorize_as_authenticated( $request );
$this->assertTrue( $result );
}
@@ -1089,7 +1089,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$controller = new PushTokenRestController();
$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
- $result = $controller->authorize( $request );
+ $result = $controller->authorize_as_authenticated( $request );
$this->assertInstanceOf( WP_Error::class, $result );
$this->assertEquals( 'woocommerce_rest_cannot_view', $result->get_error_code() );
@@ -1106,7 +1106,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$controller = new PushTokenRestController();
$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
- $result = $controller->authorize( $request );
+ $result = $controller->authorize_as_authenticated( $request );
$this->assertFalse( $result );
}
@@ -1418,4 +1418,135 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'metadata', $meta );
$this->assertEquals( array( 'app_version' => '1.0' ), maybe_unserialize( $meta['metadata'] ) );
}
+
+ /**
+ * @testdox Should reject WPCOM tokens endpoint when push notifications are disabled.
+ */
+ public function test_authorize_as_from_wpcom_returns_false_when_disabled(): void {
+ $this->mock_jetpack_connection_manager_is_connected( false );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+
+ $result = $controller->authorize_as_from_wpcom( $request );
+
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * @testdox Should reject WPCOM tokens endpoint without Jetpack blog token authentication.
+ */
+ public function test_index_rejects_without_blog_token(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+
+ $result = $controller->authorize_as_from_wpcom( $request );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_rest_cannot_view', $result->get_error_code() );
+ }
+
+ /**
+ * @testdox Should return tokens in WPCOM format from the tokens endpoint.
+ */
+ public function test_index_returns_wpcom_formatted_tokens(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+ $data_store->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'wpcom-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'wpcom-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $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 );
+ $response = $controller->index( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'tokens', $data );
+ $this->assertGreaterThanOrEqual( 1, count( $data['tokens'] ) );
+
+ $token_data = $data['tokens'][0];
+
+ $this->assertSame( 'wpcom-test-token', $token_data['token'] );
+ $this->assertSame( PushToken::ORIGIN_WOOCOMMERCE_IOS, $token_data['origin'] );
+ $this->assertSame( 'en_US', $token_data['device_locale'] );
+
+ $this->assertNotEmpty( $response->get_headers()['X-WP-Total'] );
+ $this->assertNotEmpty( $response->get_headers()['X-WP-TotalPages'] );
+ }
+
+ /**
+ * @testdox Should return empty tokens array from the tokens endpoint when no tokens exist.
+ */
+ public function test_index_returns_empty_when_no_tokens(): void {
+ $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 );
+ $response = $controller->index( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status() );
+
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'tokens', $data );
+ $this->assertCount( 0, $data['tokens'] );
+ $this->assertSame( '0', $response->get_headers()['X-WP-Total'] );
+ $this->assertSame( '0', $response->get_headers()['X-WP-TotalPages'] );
+ }
+
+ /**
+ * @testdox Should respect per_page parameter and return pagination headers.
+ */
+ public function test_index_respects_pagination(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+ for ( $i = 1; $i <= 3; $i++ ) {
+ $data_store->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => "token-$i",
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => "uuid-$i",
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+ }
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 2 );
+ $response = $controller->index( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status() );
+ $this->assertCount( 2, $response->get_data()['tokens'] );
+ $this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
+ $this->assertSame( '2', $response->get_headers()['X-WP-TotalPages'] );
+
+ $request->set_param( 'page', 2 );
+ $response = $controller->index( $request );
+
+ $this->assertCount( 1, $response->get_data()['tokens'] );
+ }
}