Commit f1b437d05f for woocommerce

commit f1b437d05f42ce7105415ac72929e7572850aab9
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Fri Jan 16 13:52:51 2026 +0000

    Adds endpoint validation for tokens and device UUIDs (#62811)

    * Adds validation methods to controller and empty create method.

diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index d2c88f7099..2340d3c809 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -382,6 +382,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Fulfillments\FulfillmentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\PushNotifications\PushNotifications::class )->register();

 		// Classes inheriting from RestApiControllerBase.
 		$container->get( Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController::class )->register();
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 5fd35c3c63..41e8bdaafe 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\RestApiControllerBase;
 use InvalidArgumentException;
 use Exception;
+use WP_REST_Server;
 use WP_REST_Request;
 use WP_Error;
 use WP_Http;
@@ -22,6 +23,8 @@ use WP_Http;
  * tokens.
  *
  * @since 10.6.0
+ *
+ * @internal
  */
 class PushTokenRestController extends RestApiControllerBase {
 	/**
@@ -57,8 +60,145 @@ class PushTokenRestController extends RestApiControllerBase {
 	 * @return void
 	 */
 	public function register_routes(): void {
-		// Routes will be registered here, can't omit method due to parent class
-		// constraints.
+		register_rest_route(
+			$this->get_rest_api_namespace(),
+			$this->rest_base,
+			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' ),
+					'schema'              => array( $this, 'get_schema' ),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Creates a push token record.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @return void
+	 */
+	public function create(): void {
+		// Functionality to be added later.
+	}
+
+	/**
+	 * Validates the token.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param string          $token The token string.
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return bool|WP_Error
+	 */
+	public function validate_token( string $token, WP_REST_Request $request ) {
+		if (
+			$request->get_param( 'platform' ) === PushToken::PLATFORM_APPLE
+			&& ! preg_match( '/^[A-Fa-f0-9]{64}$/', $token )
+		) {
+			return new WP_Error(
+				'rest_invalid_param',
+				'Invalid push token format.',
+				array(
+					'status' => WP_Http::BAD_REQUEST,
+					'param'  => 'token',
+				)
+			);
+		}
+
+		if (
+			$request->get_param( 'platform' ) === PushToken::PLATFORM_ANDROID
+			&& (
+				! preg_match( '/^[A-Za-z0-9=:\_\-\+\/]+$/', $token )
+				|| strlen( $token ) > PushToken::MAX_TOKEN_LENGTH
+			)
+		) {
+			return new WP_Error(
+				'rest_invalid_param',
+				'Invalid push token format.',
+				array(
+					'status' => WP_Http::BAD_REQUEST,
+					'param'  => 'token',
+				)
+			);
+		}
+
+		if ( $request->get_param( 'platform' ) === PushToken::PLATFORM_BROWSER ) {
+			$token_object = json_decode( $token, true );
+			$endpoint     = $token_object['endpoint'] ?? null;
+
+			if (
+				json_last_error()
+				|| ! $endpoint
+				|| ! isset( $token_object['keys']['auth'] )
+				|| ! isset( $token_object['keys']['p256dh'] )
+				|| ! wp_http_validate_url( (string) $endpoint )
+				|| ( wp_parse_url( (string) $endpoint, PHP_URL_SCHEME ) !== 'https' )
+				|| strlen( $token ) > PushToken::MAX_TOKEN_LENGTH
+			) {
+				return new WP_Error(
+					'rest_invalid_param',
+					'Invalid push token format.',
+					array(
+						'status' => WP_Http::BAD_REQUEST,
+						'param'  => 'token',
+					)
+				);
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates the device UUID, which is required unless the token is for
+	 * a browser.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param string          $device_uuid The device UUID string.
+	 * @param WP_REST_Request $request The request object.
+	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+	 * @return bool|WP_Error
+	 */
+	public function validate_device_uuid( ?string $device_uuid, WP_REST_Request $request ) {
+		if (
+			! $device_uuid
+			&& $request->get_param( 'platform' ) !== PushToken::PLATFORM_BROWSER
+		) {
+			return new WP_Error(
+				'rest_missing_callback_param',
+				'Missing parameter(s): device_uuid.',
+				array(
+					'status' => WP_Http::BAD_REQUEST,
+					'param'  => 'device_uuid',
+				)
+			);
+		}
+
+		if (
+			$device_uuid
+			&& (
+				strlen( $device_uuid ) > 255
+				|| ! preg_match( '/^[A-Za-z0-9._:-]+$/', $device_uuid )
+			)
+		) {
+			return new WP_Error(
+				'rest_invalid_param',
+				'Invalid device_uuid format.',
+				array(
+					'status' => WP_Http::BAD_REQUEST,
+					'param'  => 'device_uuid',
+				)
+			);
+		}
+
+		return true;
 	}

 	/**
@@ -101,10 +241,15 @@ class PushTokenRestController extends RestApiControllerBase {
 	 * @return bool|WP_Error
 	 */
 	public function authorize( WP_REST_Request $request ) {
-		if (
-			! get_current_user_id()
-			|| ! wc_get_container()->get( PushNotifications::class )->should_be_enabled()
-		) {
+		if ( ! get_current_user_id() ) {
+			return new WP_Error(
+				'woocommerce_rest_cannot_view',
+				__( 'Sorry, you are not allowed to do that.', 'woocommerce' ),
+				array( 'status' => rest_authorization_required_code() )
+			);
+		}
+
+		if ( ! wc_get_container()->get( PushNotifications::class )->should_be_enabled() ) {
 			return false;
 		}

@@ -161,10 +306,11 @@ class PushTokenRestController extends RestApiControllerBase {
 			InvalidArgumentException::class   => WP_Http::BAD_REQUEST,
 		);

-		$slug   = $slugs[ $exception_class ] ?? 'rest_internal_error';
-		$status = $statuses[ $exception_class ] ?? WP_Http::INTERNAL_SERVER_ERROR;
+		$slug    = $slugs[ $exception_class ] ?? 'rest_internal_error';
+		$status  = $statuses[ $exception_class ] ?? WP_Http::INTERNAL_SERVER_ERROR;
+		$message = ! isset( $slugs[ $exception_class ] ) ? 'Internal server error' : $e->getMessage();

-		return new WP_Error( $slug, $e->getMessage(), array( 'status' => $status ) );
+		return new WP_Error( $slug, $message, array( 'status' => $status ) );
 	}

 	/**
@@ -197,6 +343,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'default'           => '',
 				'type'              => 'string',
 				'context'           => array( 'create' ),
+				'validate_callback' => array( $this, 'validate_device_uuid' ),
 				'sanitize_callback' => 'sanitize_text_field',
 			),
 			'platform'    => array(
@@ -211,6 +358,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'type'              => 'string',
 				'required'          => true,
 				'context'           => array( 'create' ),
+				'validate_callback' => array( $this, 'validate_token' ),
 				'sanitize_callback' => 'wp_unslash',
 			),
 		);
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 d5542e22f6..79d7bdea19 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -9,8 +9,12 @@ use Automattic\WooCommerce\Internal\Features\FeaturesController;
 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\Exceptions\PushTokenNotFoundException;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Exception;
+use InvalidArgumentException;
+use RuntimeException;
 use PHPUnit\Framework\MockObject\MockObject;
 use ReflectionClass;
 use WC_REST_Unit_Test_Case;
@@ -41,11 +45,6 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	 */
 	private $features_controller_mock;

-	/**
-	 * @var PushTokenRestController
-	 */
-	private $controller;
-
 	/**
 	 * Set up test.
 	 */
@@ -55,8 +54,9 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$this->set_up_features_controller_mock();
 		$this->reset_push_notifications_cache();

-		$this->controller = new PushTokenRestController();
-		$this->user_id    = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+		( new PushTokenRestController() )->register_routes();
+
+		$this->user_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
 	}

 	/**
@@ -72,161 +72,465 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Test authorize returns false when no user is logged in.
+	 * @testdox Test it cannot create a push token without authentication.
+	 */
+	public function test_it_cannot_create_push_token_without_authentication() {
+		$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', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::UNAUTHORIZED, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'woocommerce_rest_cannot_view', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required role.
 	 */
-	public function test_authorize_returns_false_without_authentication() {
+	public function test_it_cannot_create_push_token_without_required_role() {
+		$customer_id = $this->factory->user->create( array( 'role' => 'customer' ) );
+		wp_set_current_user( $customer_id );
+
 		$this->mock_jetpack_connection_manager_is_connected( true );

 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$result  = $this->controller->authorize( $request );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );

-		$this->assertFalse( $result );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::FORBIDDEN, $response->get_status() );
 	}

 	/**
-	 * @testdox Test authorize returns false when push notifications are disabled.
+	 * @testdox Test it cannot create a push token for iOS if the token is not
+	 * in the correct format.
 	 */
-	public function test_authorize_returns_false_when_push_notifications_disabled() {
+	public function test_it_cannot_create_push_token_with_invalid_ios_token() {
 		wp_set_current_user( $this->user_id );

-		$this->mock_jetpack_connection_manager_is_connected( false );
+		$this->mock_jetpack_connection_manager_is_connected( true );

 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$result  = $this->controller->authorize( $request );
+		$request->set_param( 'token', 'invalid-token' );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );

-		$this->assertFalse( $result );
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
 	}

 	/**
-	 * @testdox Test authorize returns false when user doesn't have required role.
+	 * @testdox Test it cannot create a push token for iOS with non-hex
+	 * characters.
 	 */
-	public function test_authorize_returns_false_without_required_role() {
-		$customer_id = $this->factory->user->create( array( 'role' => 'customer' ) );
-		wp_set_current_user( $customer_id );
+	public function test_it_cannot_create_push_token_for_ios_with_non_hex_characters() {
+		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

+		// Token with 'g' which is not a valid hex character.
 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$result  = $this->controller->authorize( $request );
+		$request->set_param( 'token', str_repeat( 'g', 64 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid-nonhex' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );

-		$this->assertFalse( $result );
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
 	}

 	/**
-	 * @testdox Test authorize returns true for shop_manager role.
+	 * @testdox Test it cannot create a push token for iOS with wrong length.
 	 */
-	public function test_authorize_returns_true_for_shop_manager() {
+	public function test_it_cannot_create_push_token_for_ios_with_wrong_length() {
 		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$result  = $this->controller->authorize( $request );
+		$request->set_param( 'token', str_repeat( 'a', 32 ) ); // Only 32 characters instead of 64.
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid-short' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();

-		$this->assertTrue( $result );
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
 	}

 	/**
-	 * @testdox Test authorize returns true for administrator role.
+	 * @testdox Test it cannot create a push token for Android if the token is
+	 * not in the correct format.
 	 */
-	public function test_authorize_returns_true_for_administrator() {
-		$admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
-		wp_set_current_user( $admin_id );
+	public function test_it_cannot_create_push_token_with_invalid_android_token() {
+		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
-		$result  = $this->controller->authorize( $request );
+		$request->set_param( 'token', 'invalid token with spaces' );
+		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );

-		$this->assertTrue( $result );
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
 	}

 	/**
-	 * @testdox Test authorize returns error when token ID doesn't exist.
+	 * @testdox Test it cannot create a push token for Android if the token is
+	 * too long.
 	 */
-	public function test_authorize_returns_error_when_token_not_found() {
+	public function test_it_cannot_create_push_token_with_android_token_that_is_too_long() {
 		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

-		$request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/999999' );
-		$request->set_param( 'id', 999999 );
-		$result = $this->controller->authorize( $request );
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 4097 ) );
+		$request->set_param( 'platform', PushToken::PLATFORM_ANDROID );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_ANDROID );

-		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_invalid_push_token', $result->get_error_code() );
-		$this->assertEquals( WP_Http::NOT_FOUND, $result->get_error_data()['status'] );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token for browser with invalid
+	 * JSON token.
+	 */
+	public function test_it_cannot_create_push_token_for_browser_with_invalid_json() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', 'not-valid-json' );
+		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
 	}

 	/**
-	 * @testdox Test authorize returns error when token belongs to another user.
+	 * @testdox Test it cannot create a push token for browser with missing
+	 * required keys.
 	 */
-	public function test_authorize_returns_error_when_token_belongs_to_another_user() {
-		/**
-		 * Create a token for another shop manager.
-		 */
-		$other_user_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+	public function test_it_cannot_create_push_token_for_browser_with_missing_keys() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$token = wp_json_encode(
+			array(
+				'endpoint' => 'https://example.com/push',
+				// Missing 'keys' array.
+			)
+		);

-		$push_token = new PushToken();
-		$push_token->set_user_id( $other_user_id );
-		$push_token->set_token( str_repeat( 'a', 64 ) );
-		$push_token->set_platform( PushToken::PLATFORM_APPLE );
-		$push_token->set_device_uuid( 'device-other-user' );
-		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token );
+		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );

-		$data_store = wc_get_container()->get( PushTokensDataStore::class );
-		$data_store->create( $push_token );
-		$token_id = $push_token->get_id();
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );

-		/**
-		 * Try to authorize as a different user.
-		 */
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token for browser with non-HTTPS
+	 * endpoint.
+	 */
+	public function test_it_cannot_create_push_token_for_browser_with_non_https_endpoint() {
 		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

-		$request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $token_id );
-		$request->set_param( 'id', $token_id );
+		$token = wp_json_encode(
+			array(
+				'endpoint' => 'http://example.com/push',
+				'keys'     => array(
+					'auth'   => 'test-auth-key',
+					'p256dh' => 'test-p256dh-key',
+				),
+			)
+		);

-		$result = $this->controller->authorize( $request );
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', $token );
+		$request->set_param( 'platform', PushToken::PLATFORM_BROWSER );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );

-		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertEquals( 'rest_invalid_push_token', $result->get_error_code() );
-		$this->assertEquals( WP_Http::NOT_FOUND, $result->get_error_data()['status'] );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required token
+	 * parameter.
+	 */
+	public function test_it_cannot_create_push_token_with_a_missing_token() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_missing_callback_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required platform
+	 * parameter.
+	 */
+	public function test_it_cannot_create_push_token_with_a_missing_platform() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', str_repeat( 'a', 64 ) );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_missing_callback_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required device_uuid
+	 * parameter for non-browser platforms.
+	 */
+	public function test_it_cannot_create_push_token_for_non_browser_with_a_missing_device_uuid() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( 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( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'device_uuid', $data['message'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token with invalid platform value.
+	 */
+	public function test_it_cannot_create_push_token_with_invalid_platform() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', 'anything' );
+		$request->set_param( 'platform', 'windows' );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token without required origin
+	 * parameter.
+	 */
+	public function test_it_cannot_create_push_token_with_a_missing_origin() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( 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', 'test-device-uuid' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_missing_callback_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token with invalid origin value.
+	 */
+	public function test_it_cannot_create_push_token_with_invalid_origin() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( true );
+
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/push-tokens' );
+		$request->set_param( 'token', 'anything' );
+		$request->set_param( 'platform', PushToken::PLATFORM_APPLE );
+		$request->set_param( 'device_uuid', 'test-device-uuid' );
+		$request->set_param( 'origin', 'development' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token when push notifications are
+	 * disabled.
+	 */
+	public function test_it_cannot_create_push_token_when_push_notifications_disabled() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( false );
+
+		$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', 'test-device-uuid-123' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::FORBIDDEN, $response->get_status() );
 	}

 	/**
-	 * @testdox Test authorize returns true when token belongs to current user.
+	 * @testdox Test it cannot create a push token with device_uuid exceeding
+	 * 255 characters.
 	 */
-	public function test_authorize_returns_true_when_token_belongs_to_current_user() {
+	public function test_it_cannot_create_push_token_with_device_uuid_too_long() {
 		wp_set_current_user( $this->user_id );

 		$this->mock_jetpack_connection_manager_is_connected( true );

-		$push_token = new PushToken();
-		$push_token->set_user_id( $this->user_id );
-		$push_token->set_token( str_repeat( 'a', 64 ) );
-		$push_token->set_platform( PushToken::PLATFORM_APPLE );
-		$push_token->set_device_uuid( 'device-current-user' );
-		$push_token->set_origin( PushToken::ORIGIN_WOOCOMMERCE_IOS );
+		$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', str_repeat( 'a', 256 ) );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );

-		$data_store = wc_get_container()->get( PushTokensDataStore::class );
-		$data_store->create( $push_token );
-		$token_id = $push_token->get_id();
+		$response = $this->server->dispatch( $request );

-		$request = new WP_REST_Request( 'DELETE', '/wc-push-notifications/push-tokens/' . $token_id );
-		$request->set_param( 'id', $token_id );
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );

-		$result = $this->controller->authorize( $request );
+		$data = $response->get_data();

-		$this->assertTrue( $result );
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'device_uuid', $data['message'] );
+	}
+
+	/**
+	 * @testdox Test it cannot create a push token with device_uuid containing
+	 * invalid characters.
+	 */
+	public function test_it_cannot_create_push_token_with_device_uuid_invalid_characters() {
+		wp_set_current_user( $this->user_id );
+
+		$this->mock_jetpack_connection_manager_is_connected( 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', 'invalid device uuid with spaces' );
+		$request->set_param( 'origin', PushToken::ORIGIN_WOOCOMMERCE_IOS );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( WP_Http::BAD_REQUEST, $response->get_status() );
+
+		$data = $response->get_data();
+
+		$this->assertEquals( 'rest_invalid_param', $data['code'] );
+		$this->assertStringContainsString( 'device_uuid', $data['message'] );
 	}

 	/**
 	 * @testdox Test the schema is correctly formatted.
 	 */
 	public function test_get_schema_returns_correct_structure() {
-		$schema = $this->controller->get_schema();
+		$controller = new PushTokenRestController();
+		$schema     = $controller->get_schema();

 		$this->assertArrayHasKey( 'title', $schema );
 		$this->assertArrayHasKey( 'properties', $schema );
@@ -260,6 +564,87 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Test convert_exception_to_wp_error hides message for generic
+	 * Exception class.
+	 */
+	public function test_it_hides_internal_error_message_for_generic_exception() {
+		$controller = new PushTokenRestController();
+		$exception  = new Exception( 'Sensitive internal error details' );
+
+		$reflection = new ReflectionClass( $controller );
+		$method     = $reflection->getMethod( 'convert_exception_to_wp_error' );
+		$method->setAccessible( true );
+
+		$result = $method->invoke( $controller, $exception );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertEquals( 'rest_internal_error', $result->get_error_code() );
+		$this->assertEquals( 'Internal server error', $result->get_error_message() );
+		$this->assertEquals( WP_Http::INTERNAL_SERVER_ERROR, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * @testdox Test convert_exception_to_wp_error exposes message for
+	 * PushTokenNotFoundException.
+	 */
+	public function test_it_exposes_message_for_push_token_not_found_exception() {
+		$controller = new PushTokenRestController();
+		$exception  = new PushTokenNotFoundException( 'Push token could not be found.' );
+
+		$reflection = new ReflectionClass( $controller );
+		$method     = $reflection->getMethod( 'convert_exception_to_wp_error' );
+		$method->setAccessible( true );
+
+		$result = $method->invoke( $controller, $exception );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertEquals( 'rest_invalid_push_token', $result->get_error_code() );
+		$this->assertEquals( 'Push token could not be found.', $result->get_error_message() );
+		$this->assertEquals( WP_Http::NOT_FOUND, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * @testdox Test convert_exception_to_wp_error exposes message for
+	 * InvalidArgumentException.
+	 */
+	public function test_it_exposes_message_for_invalid_argument_exception() {
+		$controller = new PushTokenRestController();
+		$exception  = new InvalidArgumentException( 'Invalid argument provided.' );
+
+		$reflection = new ReflectionClass( $controller );
+		$method     = $reflection->getMethod( 'convert_exception_to_wp_error' );
+		$method->setAccessible( true );
+
+		$result = $method->invoke( $controller, $exception );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertEquals( 'rest_invalid_argument', $result->get_error_code() );
+		$this->assertEquals( 'Invalid argument provided.', $result->get_error_message() );
+		$this->assertEquals( WP_Http::BAD_REQUEST, $result->get_error_data()['status'] );
+	}
+
+	/**
+	 * @testdox Test convert_exception_to_wp_error hides message for unknown
+	 * exception subclasses.
+	 */
+	public function test_it_hides_internal_error_message_for_unknown_exception_subclass() {
+		$controller = new PushTokenRestController();
+		// RuntimeException is a subclass of Exception but not in our mapping.
+		$exception = new RuntimeException( 'Sensitive runtime error details' );
+
+		$reflection = new ReflectionClass( $controller );
+		$method     = $reflection->getMethod( 'convert_exception_to_wp_error' );
+		$method->setAccessible( true );
+
+		$result = $method->invoke( $controller, $exception );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertEquals( 'rest_internal_error', $result->get_error_code() );
+		$this->assertEquals( 'Internal server error', $result->get_error_message() );
+		$this->assertEquals( WP_Http::INTERNAL_SERVER_ERROR, $result->get_error_data()['status'] );
+	}
+
 	/**
 	 * Sets up the Jetpack connection manager mocking, and ensures the
 	 * PushNotifications class state is reset so `should_be_enabled` calculates