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 ) );
 	}

 	/**