Commit 0cf86422358 for woocommerce
commit 0cf864223583a03e40239c7df3fd862b8a77b298
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Sep 30 15:41:36 2026 +0100
Allow support to delete any user's push token on a store (#69111)
* Allow WPCOM to delete any user's push token on a store
Lets support stop notifications to a device whose owner can no longer reach the store from the app, and logs each deletion made this way.
* Name support as the source of push token deletions in the log
* Let WPCOM delete push tokens while push notifications are disabled
A token left on a disabled store starts receiving notifications again when the store is switched back on, so support needs to be able to remove it then too.
* Reuse the existing WPCOM-or-user check for push token deletion
* Remove the changelog entry for push token deletion
* Name the push token deletion check after who it allows
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index b5d36d00a53..27f0c6f5e6b 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Traits\AuthorizesPushNotif
use Automattic\WooCommerce\Internal\PushNotifications\Traits\ConvertsExceptionsToWpError;
use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use Automattic\WooCommerce\Proxies\LegacyProxy;
use Exception;
use WC_Data_Exception;
use WP_REST_Server;
@@ -64,13 +65,14 @@ class PushTokenRestController extends RestApiControllerBase {
/**
* Register the REST API endpoints handled by this controller.
*
- * The token list is registered whatever the module's state. The write
- * endpoints are only registered while it is enabled, because the apps read
- * the 404 for a missing route as push notifications being unavailable.
- * Once the apps read that from {@see PushNotificationStatusRestController}
- * instead, the write endpoints can be registered unconditionally again and
- * left to their permission callbacks. Registering a second handler on an
- * existing route adds to it.
+ * The token list and token deletion are available whatever the module's
+ * state. Token registration is only added while the module is enabled,
+ * because the apps read the 404 for a missing route as push notifications
+ * being unavailable; deletion returns that same 404 to users from its
+ * permission callback. Once the apps read this from
+ * {@see PushNotificationStatusRestController} instead, registration can be
+ * added unconditionally too. Registering a second handler on an existing
+ * route adds to it.
*
* @since 10.6.0
*
@@ -137,34 +139,34 @@ class PushTokenRestController extends RestApiControllerBase {
)
);
- if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
- return;
- }
-
register_rest_route(
$this->route_namespace,
- $this->rest_base,
+ $this->rest_base . '/(?P<id>[\d]+)',
array(
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_as_authenticated' ),
+ '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_wpcom_always_or_push_user_when_enabled' ),
),
+ 'schema' => array( $this, 'get_schema' ),
)
);
+ if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
+ return;
+ }
+
register_rest_route(
$this->route_namespace,
- $this->rest_base . '/(?P<id>[\d]+)',
+ $this->rest_base,
array(
array(
- 'methods' => WP_REST_Server::DELETABLE,
- 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'delete' ),
- 'args' => $this->get_args( 'delete' ),
+ '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_as_authenticated' ),
),
- 'schema' => array( $this, 'get_schema' ),
)
);
}
@@ -317,6 +319,10 @@ class PushTokenRestController extends RestApiControllerBase {
/**
* Deletes a push token record.
*
+ * Users can only delete their own tokens. WPCOM can delete any token, so
+ * support can stop notifications to a device its owner can no longer reach
+ * from the app.
+ *
* @since 10.6.0
*
* @param WP_REST_Request $request The request object.
@@ -328,10 +334,11 @@ class PushTokenRestController extends RestApiControllerBase {
public function delete( WP_REST_Request $request ) {
try {
$id = (int) $request->get_param( 'id' );
+ $from_wpcom = $this->is_signed_with_blog_token();
$data_store = wc_get_container()->get( PushTokensDataStore::class );
$push_token = $data_store->read( $id );
- if ( $push_token->get_user_id() !== get_current_user_id() ) {
+ if ( ! $from_wpcom && $push_token->get_user_id() !== get_current_user_id() ) {
throw new PushTokenNotFoundException();
}
@@ -344,6 +351,21 @@ class PushTokenRestController extends RestApiControllerBase {
WP_Http::INTERNAL_SERVER_ERROR
);
}
+
+ if ( $from_wpcom ) {
+ wc_get_container()
+ ->get( LegacyProxy::class )
+ ->call_function( 'wc_get_logger' )
+ ->info(
+ 'Push token deleted by WordPress.com support. The device it was registered from will no longer receive push notifications from this store.',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'token_id' => $id,
+ 'user_id' => $push_token->get_user_id(),
+ 'platform' => $push_token->get_platform(),
+ )
+ );
+ }
} catch ( Exception $e ) {
return $this->convert_exception_to_wp_error( $e );
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
index 3145eaf8fcc..2824f7fad40 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Traits/AuthorizesPushNotificationRequests.php
@@ -9,6 +9,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Connection\Rest_Authentication;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use WP_Error;
+use WP_Http;
use WP_REST_Request;
/**
@@ -86,6 +87,32 @@ trait AuthorizesPushNotificationRequests {
return $this->authorize_as_authenticated_ignoring_enablement( $request );
}
+ /**
+ * Allows WPCOM whatever the module's state, and allowed users only while
+ * the module is enabled.
+ *
+ * WPCOM can remove a token before the store is switched back on and starts
+ * sending to it again. Users of a disabled store get the missing-route 404
+ * the apps read as push notifications being unavailable.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return bool|WP_Error
+ *
+ * @since 11.3.0
+ */
+ public function authorize_wpcom_always_or_push_user_when_enabled( WP_REST_Request $request ) {
+ if ( ! $this->is_signed_with_blog_token() && ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
+ return new WP_Error(
+ 'rest_no_route',
+ __( 'No route was found matching the URL and request method.', 'woocommerce' ),
+ array( 'status' => WP_Http::NOT_FOUND )
+ );
+ }
+
+ return $this->authorize_as_from_wpcom_or_allowed_user( $request );
+ }
+
/**
* Checks the user is authenticated and holds at least one role allowed to
* interact with push notifications.
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 a80465b20c7..e0c12785c44 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -17,6 +17,7 @@ use RuntimeException;
use ReflectionClass;
use stdClass;
use WC_Data_Exception;
+use WC_Logger;
use WC_Unit_Test_Case;
use WP_Error;
use WP_Http;
@@ -1095,6 +1096,113 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should let WPCOM delete a token belonging to any user, and log the deletion.
+ */
+ public function test_wpcom_can_delete_another_users_push_token(): void {
+ $push_token = wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => $this->other_shop_manager_id,
+ 'token' => str_repeat( 'a', 64 ),
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-revoked-by-wpcom',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $logger_mock = $this->createMock( WC_Logger::class );
+ $logger_mock->expects( $this->once() )
+ ->method( 'info' )
+ ->with(
+ 'Push token deleted by WordPress.com support. The device it was registered from will no longer receive push notifications from this store.',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'token_id' => $push_token->get_id(),
+ 'user_id' => $this->other_shop_manager_id,
+ 'platform' => PushToken::PLATFORM_APPLE,
+ )
+ );
+ $this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
+
+ $server = $this->create_rest_server_with_routes(
+ array( array( $this->create_blog_token_controller(), 'register_routes' ) ),
+ true
+ );
+ $request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $push_token->get_id() );
+ $response = $server->dispatch( $request );
+
+ $this->assertSame( WP_Http::NO_CONTENT, $response->get_status() );
+ $this->assertNull( get_post( $push_token->get_id() ), 'The token should be deleted' );
+ }
+
+ /**
+ * @testdox Should not log when a user deletes their own token.
+ */
+ public function test_it_does_not_log_when_a_user_deletes_their_own_push_token(): void {
+ $push_token = wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => str_repeat( 'a', 64 ),
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-deleted-by-owner',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $logger_mock = $this->createMock( WC_Logger::class );
+ $logger_mock->expects( $this->never() )->method( 'info' );
+ $this->register_legacy_proxy_function_mocks( array( 'wc_get_logger' => fn () => $logger_mock ) );
+
+ wp_set_current_user( $this->user_id );
+
+ $request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $push_token->get_id() );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( WP_Http::NO_CONTENT, $response->get_status() );
+ }
+
+ /**
+ * @testdox Should let WPCOM delete a token while push notifications are disabled, so it is not
+ * used again when the store is switched back on.
+ */
+ public function test_wpcom_can_delete_a_push_token_when_disabled(): void {
+ $push_token = wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => $this->other_shop_manager_id,
+ 'token' => str_repeat( 'a', 64 ),
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-revoked-while-disabled',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $this->mock_jetpack_connection_manager_is_connected( false );
+
+ $server = $this->create_rest_server_with_routes(
+ array( array( $this->create_blog_token_controller(), 'register_routes' ) ),
+ true
+ );
+ $request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $push_token->get_id() );
+ $response = $server->dispatch( $request );
+
+ $this->assertSame( WP_Http::NO_CONTENT, $response->get_status() );
+ $this->assertNull( get_post( $push_token->get_id() ), 'The token should be deleted' );
+ }
+
+ /**
+ * @testdox Should still require an allowed role when the request is not from WPCOM.
+ */
+ public function test_authorize_wpcom_always_or_push_user_when_enabled_rejects_user_without_role(): void {
+ wp_set_current_user( $this->subscriber_id );
+
+ $request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/123' );
+
+ $this->assertFalse( $this->controller->authorize_wpcom_always_or_push_user_when_enabled( $request ) );
+ }
+
/**
* @testdox Test authorize returns false when push notifications are
* disabled.
@@ -1420,7 +1528,19 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
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 {
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+
+ $this->assertTrue( $this->create_blog_token_controller()->authorize_as_from_wpcom( $request ) );
+ }
+
+ /**
+ * Returns a controller that treats every request as signed by WPCOM with
+ * the Jetpack blog token.
+ *
+ * @return PushTokenRestController
+ */
+ private function create_blog_token_controller(): PushTokenRestController {
+ return new class() extends PushTokenRestController {
/**
* Stands in for a request WPCOM signed with the Jetpack blog token.
*
@@ -1430,10 +1550,6 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
return true;
}
};
-
- $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
-
- $this->assertTrue( $controller->authorize_as_from_wpcom( $request ) );
}
/**