Commit 1da15e10663 for woocommerce
commit 1da15e1066399fc27104866ec866c0e615a947a1
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Sep 23 17:37:13 2026 +0100
Keep the push token list readable when push notifications are disabled (#68553)
Keep the push token list readable while the module is disabled
Disabling enhanced push notifications for a store, the usual first step when a
merchant reports a delivery problem, also unregistered the token routes, so
support could no longer see which devices were registered. The routes now
register ahead of the enablement check, and reading the list no longer requires
the module to be enabled. The read stays restricted to requests WPCOM signs
with the Jetpack blog token, and its permission callback moves to
AuthorizesPushNotificationRequests alongside the other three. Registering a
token, deleting one and sending all stay gated.
The wc_push_token post type stays behind the enablement check. Its queries run
as plain SQL against wp_posts, so a disabled store reads its tokens without it.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 3805b570c2f..77d01df414e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -294,32 +294,6 @@ class PushTokenRestController extends RestApiControllerBase {
);
}
- /**
- * 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 ( $this->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() )
- );
- }
-
/**
* Get the accepted arguments for the POST request.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 58c963934e4..9e7a8cf3970 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -81,6 +81,11 @@ class PushNotifications {
// to start sending against.
wc_get_container()->get( UserDataCleanupService::class )->register();
+ // Registered ahead of the enablement check so the token list can still be
+ // read on a store that has been switched off. The write routes stay gated
+ // in their permission callbacks, as does everything below.
+ ( new PushTokenRestController() )->register();
+
if ( ! $this->should_be_enabled() ) {
return;
}
@@ -89,7 +94,6 @@ class PushNotifications {
wc_get_container()->get( PendingNotificationStore::class )->register();
- ( new PushTokenRestController() )->register();
( new PushNotificationRestController() )->register();
( new NotificationPreferencesRestController() )->register();
( new NewOrderNotificationTrigger() )->register();
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
index 89c3a7c5602..3145eaf8fcc 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
@@ -38,6 +38,32 @@ trait AuthorizesPushNotificationRequests {
return wc_get_container()->get( PushNotifications::class )->should_be_enabled();
}
+ /**
+ * Checks the request is signed with the Jetpack blog token, so only WPCOM
+ * can reach the endpoint.
+ *
+ * The module does not have to be enabled. WPCOM reads a disabled store to see
+ * which devices it has registered, and a store is most worth looking at once
+ * push notifications have been switched off for it.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return bool|WP_Error
+ *
+ * @since 11.2.0
+ */
+ public function authorize_as_from_wpcom( WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Kept so every permission callback in this trait takes the same argument.
+ if ( $this->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() )
+ );
+ }
+
/**
* Checks the caller is either WPCOM or an allowed user, without requiring
* the module to be 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 512ccc47deb..4dc80d39806 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -1404,9 +1404,33 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Should reject WPCOM tokens endpoint when push notifications are disabled.
+ * @testdox Should allow the WPCOM tokens endpoint when push notifications are disabled,
+ * so a store that has been switched off can still be inspected.
*/
- public function test_authorize_as_from_wpcom_returns_false_when_disabled(): void {
+ public function test_authorize_as_from_wpcom_allows_blog_token_when_disabled(): void {
+ $this->mock_jetpack_connection_manager_is_connected( false );
+
+ $controller = new class() extends PushTokenRestController {
+ /**
+ * Stands in for a request WPCOM signed with the Jetpack blog token.
+ *
+ * @return bool
+ */
+ protected function is_signed_with_blog_token(): bool {
+ return true;
+ }
+ };
+
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+
+ $this->assertTrue( $controller->authorize_as_from_wpcom( $request ) );
+ }
+
+ /**
+ * @testdox Should reject the WPCOM tokens endpoint without a blog token when push
+ * notifications are disabled.
+ */
+ public function test_authorize_as_from_wpcom_rejects_without_blog_token_when_disabled(): void {
$this->mock_jetpack_connection_manager_is_connected( false );
$controller = new PushTokenRestController();
@@ -1414,7 +1438,8 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
$result = $controller->authorize_as_from_wpcom( $request );
- $this->assertFalse( $result );
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_rest_cannot_view', $result->get_error_code() );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
index 61e3d11d00a..6bc751d6bb8 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/PushNotificationsTest.php
@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Internal\PushNotifications;
use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPreferencesRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -14,6 +17,8 @@ use Exception;
use PHPUnit\Framework\MockObject\MockObject;
use WC_Logger;
use WC_Unit_Test_Case;
+use WP_Http;
+use WP_REST_Request;
/**
* PushNotifications test.
@@ -30,6 +35,8 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
* Tear down the test case.
*/
public function tearDown(): void {
+ wp_set_current_user( 0 );
+
global $wp_rest_server;
$wp_rest_server = null;
@@ -206,9 +213,10 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Tests that on_init does not register post types when Jetpack is not connected.
+ * @testdox Tests that on_init does not register post types when Jetpack is not connected,
+ * leaving the tokens endpoint to register them if it is called.
*/
- public function test_on_init_does_not_register_post_types_when_disabled() {
+ public function test_on_init_does_not_register_post_types_when_jetpack_is_not_connected() {
$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
$this->jetpack_connection_manager_mock
@@ -221,14 +229,32 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
$this->assertFalse(
post_type_exists( PushToken::POST_TYPE ),
- 'Push token post type should not be registered when disabled'
+ 'Push token post type should not be registered when Jetpack is not connected'
+ );
+ }
+
+ /**
+ * @testdox Tests that on_init does not register post types when disabled via the filter.
+ */
+ public function test_on_init_does_not_register_post_types_when_disabled_via_filter() {
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ $push_notifications = new PushNotifications();
+ $push_notifications->on_init();
+
+ remove_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ $this->assertFalse(
+ post_type_exists( PushToken::POST_TYPE ),
+ 'Push token post type should not be registered when disabled via the filter'
);
}
/**
- * @testdox Tests that on_init registers the status controller but no other controllers when disabled.
+ * @testdox Tests that on_init registers the status and token controllers, but not the send
+ * or preferences controllers, when Jetpack is not connected.
*/
- public function test_on_init_registers_only_status_controller_when_disabled() {
+ public function test_on_init_registers_status_and_token_controllers_when_jetpack_is_not_connected() {
$this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
$this->jetpack_connection_manager_mock
@@ -239,6 +265,170 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
$push_notifications = new PushNotifications();
$push_notifications->on_init();
+ $registered = $this->get_registered_rest_controllers();
+
+ $this->assertContains( PushNotificationStatusRestController::class, $registered );
+ $this->assertContains( PushTokenRestController::class, $registered );
+ $this->assertNotContains( PushNotificationRestController::class, $registered );
+ $this->assertNotContains( NotificationPreferencesRestController::class, $registered );
+ }
+
+ /**
+ * @testdox Tests that on_init registers the status and token controllers, but not the send
+ * or preferences controllers, when disabled via the filter.
+ */
+ public function test_on_init_registers_status_and_token_controllers_when_disabled_via_filter() {
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ $push_notifications = new PushNotifications();
+ $push_notifications->on_init();
+
+ remove_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ $registered = $this->get_registered_rest_controllers();
+
+ $this->assertContains( PushNotificationStatusRestController::class, $registered );
+ $this->assertContains( PushTokenRestController::class, $registered );
+ $this->assertNotContains( PushNotificationRestController::class, $registered );
+ $this->assertNotContains( NotificationPreferencesRestController::class, $registered );
+ }
+
+ /**
+ * @testdox Tests that the tokens endpoint returns the store's registered tokens while
+ * the module is disabled via the filter.
+ */
+ public function test_tokens_endpoint_returns_tokens_when_disabled_via_filter() {
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ try {
+ $tokens = $this->dispatch_tokens_request_after_on_init( 'filter-disabled-token' );
+ } finally {
+ remove_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+ }
+
+ $this->assertCount( 1, $tokens );
+ $this->assertSame( 'filter-disabled-token', $tokens[0]['token'] );
+ }
+
+ /**
+ * @testdox Tests that the tokens endpoint returns the store's registered tokens while
+ * Jetpack is not connected.
+ */
+ public function test_tokens_endpoint_returns_tokens_when_jetpack_is_not_connected() {
+ $this->set_up_jetpack_connection_manager_mock( array( 'is_connected' ) );
+
+ $this->jetpack_connection_manager_mock
+ ->expects( $this->any() )
+ ->method( 'is_connected' )
+ ->willReturn( false );
+
+ $tokens = $this->dispatch_tokens_request_after_on_init( 'disconnected-token' );
+
+ $this->assertCount( 1, $tokens );
+ $this->assertSame( 'disconnected-token', $tokens[0]['token'] );
+ }
+
+ /**
+ * @testdox Tests that registering a token is refused while the module is disabled.
+ */
+ public function test_token_registration_is_refused_when_disabled_via_filter() {
+ add_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+
+ try {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+
+ ( new PushNotifications() )->on_init();
+
+ $server = $this->create_rest_server_with_routes(
+ array( array( new PushTokenRestController(), 'register_routes' ) ),
+ true
+ );
+
+ $request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'token', str_repeat( 'a', 64 ) );
+ $request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+ $request->set_param( 'device_uuid', 'refused-device-uuid' );
+ $request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+ $request->set_param( 'device_locale', 'en_US' );
+
+ $response = $server->dispatch( $request );
+ } finally {
+ remove_filter( 'woocommerce_enhanced_push_notifications_disabled', '__return_true' );
+ }
+
+ $this->assertSame( WP_Http::FORBIDDEN, $response->get_status() );
+ }
+
+ /**
+ * Stores one token against a shop manager, runs on_init with the post type
+ * unregistered, then dispatches a GET to the tokens endpoint as WPCOM and returns
+ * the tokens it responded with.
+ *
+ * @param string $token The token value to store.
+ * @return array[]
+ */
+ private function dispatch_tokens_request_after_on_init( string $token ): array {
+ $push_notifications = new PushNotifications();
+
+ // Seed the token the way an enabled store would have, then drop the post type
+ // again, so the request starts from the state a disabled store is left in.
+ $push_notifications->register_post_types();
+
+ wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => self::factory()->user->create( array( 'role' => 'shop_manager' ) ),
+ 'token' => $token,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-' . $token,
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ unregister_post_type( PushToken::POST_TYPE );
+
+ $push_notifications->on_init();
+
+ $this->assertFalse(
+ post_type_exists( PushToken::POST_TYPE ),
+ 'Push token post type should still be unregistered when the request is dispatched'
+ );
+
+ $controller = new class() extends PushTokenRestController {
+ /**
+ * Stands in for a request WPCOM signed with the Jetpack blog token.
+ *
+ * @return bool
+ */
+ protected function is_signed_with_blog_token(): bool {
+ return true;
+ }
+ };
+
+ $server = $this->create_rest_server_with_routes(
+ array( array( $controller, 'register_routes' ) ),
+ true
+ );
+
+ $response = $server->dispatch( new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' ) );
+
+ $this->assertSame( WP_Http::OK, $response->get_status() );
+
+ $this->assertFalse(
+ post_type_exists( PushToken::POST_TYPE ),
+ 'Reading the token list should not need the push token post type registered'
+ );
+
+ return $response->get_data()['tokens'];
+ }
+
+ /**
+ * Returns the controller classes that have added themselves to the WooCommerce REST
+ * API namespaces.
+ *
+ * @return string[]
+ */
+ private function get_registered_rest_controllers(): array {
// The status controller registers on rest_api_init rather than during
// on_init, so a front-end request does not resolve it for nothing. WooCommerce
// applies the namespaces filter on rest_api_init at priority 10, and the
@@ -248,10 +438,8 @@ class PushNotificationsTest extends WC_Unit_Test_Case {
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Triggering an existing filter from RestApiControllerBase, not defining one.
$namespaces = apply_filters( 'woocommerce_rest_api_get_rest_namespaces', array( 'wc/v3' => array() ) );
- $registered = array_values( $namespaces['wc/v3'] );
- $this->assertContains( PushNotificationStatusRestController::class, $registered );
- $this->assertNotContains( PushTokenRestController::class, $registered );
+ return array_values( $namespaces['wc/v3'] );
}
/**