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