Commit 9219af7a6f for woocommerce

commit 9219af7a6fc79e93961f262a7bcd1704c8c8acc9
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Tue Feb 10 18:21:33 2026 +0000

    Adds PushTokenValidator and uses in PushTokenRestController and PushToken. (#63169)

    Adds PushTokenValidator and uses in PushTokenRestController and PushToken.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index fea2c3abaa..a1d7db69ee 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -9,8 +9,8 @@ defined( 'ABSPATH' ) || exit;
 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\Exceptions\PushTokenInvalidDataException;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
 use Automattic\WooCommerce\Internal\RestApiControllerBase;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Exception;
@@ -170,129 +170,7 @@ class PushTokenRestController extends RestApiControllerBase {
 	}

 	/**
-	 * Validates the token. This is done here as some of the validation rules
-	 * depend on other parameters. Calls `validate_argument` after to check the
-	 * PushToken validation rules which don't depend on other parameters.
-	 *
-	 * @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
-	 * @param string          $param The name of the parameter being validated.
-	 * @return bool|WP_Error
-	 */
-	public function validate_token( string $token, WP_REST_Request $request, string $param ) {
-		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 $this->validate_argument( $token, $request, $param );
-	}
-
-	/**
-	 * Validates the device UUID, which is required unless the token is for
-	 * a browser. This is done here as some of the validation rules depend on
-	 * other parameters. Calls `validate_argument` after to check the PushToken
-	 * validation rules which don't depend on other parameters.
-	 *
-	 * @since 10.6.0
-	 *
-	 * @param null|string     $device_uuid The device UUID string.
-	 * @param WP_REST_Request $request The request object.
-	 * @phpstan-param WP_REST_Request<array<string, mixed>> $request
-	 * @param string          $param The name of the parameter being validated.
-	 * @return bool|WP_Error
-	 */
-	public function validate_device_uuid( ?string $device_uuid, WP_REST_Request $request, string $param ) {
-		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 $this->validate_argument( $device_uuid, $request, $param );
-	}
-
-	/**
-	 * Validates the other arguments from the request, if validation exists in
-	 * the PushToken entity.
+	 * Validates the arguments from the request via PushTokenValidator.
 	 *
 	 * @since 10.6.0
 	 *
@@ -303,26 +181,7 @@ class PushTokenRestController extends RestApiControllerBase {
 	 * @return bool|WP_Error
 	 */
 	public function validate_argument( $value, WP_REST_Request $request, string $param ) {
-		$method = "set_$param";
-
-		if ( method_exists( PushToken::class, $method ) ) {
-			try {
-				$value     = rest_sanitize_request_arg( $value, $request, $param );
-				$validator = new PushToken();
-				$validator->{ "set_$param" }( $value );
-			} catch ( PushTokenInvalidDataException $e ) {
-				return new WP_Error(
-					$e->getErrorCode(),
-					$e->getMessage(),
-					array(
-						'status' => $e->getCode(),
-						'param'  => $param,
-					)
-				);
-			}
-		}
-
-		return true;
+		return PushTokenValidator::validate( $request->get_params(), array( $param ) );
 	}

 	/**
@@ -459,7 +318,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'default'           => '',
 				'type'              => 'string',
 				'context'           => array( 'create' ),
-				'validate_callback' => array( $this, 'validate_device_uuid' ),
+				'validate_callback' => array( $this, 'validate_argument' ),
 				'sanitize_callback' => 'sanitize_text_field',
 			),
 			'platform'    => array(
@@ -475,7 +334,7 @@ class PushTokenRestController extends RestApiControllerBase {
 				'type'              => 'string',
 				'required'          => true,
 				'context'           => array( 'create' ),
-				'validate_callback' => array( $this, 'validate_token' ),
+				'validate_callback' => array( $this, 'validate_argument' ),
 				'sanitize_callback' => 'wp_unslash',
 			),
 		);
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index 593f8cbcc1..aaf2e0063f 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Entities;
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
+use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;

 /**
  * Object representation of a push token.
@@ -80,19 +81,14 @@ class PushToken {
 	);

 	/**
-	 * Maximum length for push notification tokens.
-	 */
-	const MAX_TOKEN_LENGTH = 4096;
-
-	/**
-	 * The id of the token post.
+	 * The ID of the token post.
 	 *
 	 * @var int|null
 	 */
 	private ?int $id = null;

 	/**
-	 * The id of the user who owns the token.
+	 * The ID of the user who owns the token.
 	 *
 	 * @var int|null
 	 */
@@ -161,113 +157,127 @@ class PushToken {
 	}

 	/**
-	 * Sets the ID.
+	 * Validates and sets the ID.
 	 *
-	 * @param int $id The id of the token post.
-	 * @throws PushTokenInvalidDataException If ID is <= 0.
+	 * @param int $id The ID of the token post.
+	 * @throws PushTokenInvalidDataException If ID is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_id( int $id ): void {
-		if ( $id <= 0 ) {
-			throw new PushTokenInvalidDataException( 'ID must be a positive integer.' );
+		$result = PushTokenValidator::validate( compact( 'id' ), array( 'id' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
 		}

 		$this->id = $id;
 	}

 	/**
-	 * Sets the user ID.
+	 * Validates and sets the user ID.
 	 *
-	 * @param int $user_id The id of the user who owns the token.
-	 * @throws PushTokenInvalidDataException If ID is <= 0.
+	 * @param int $user_id The ID of the user who owns the token.
+	 * @throws PushTokenInvalidDataException If user ID is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_user_id( int $user_id ): void {
-		if ( $user_id <= 0 ) {
-			throw new PushTokenInvalidDataException( 'User ID must be a positive integer.' );
+		$result = PushTokenValidator::validate( compact( 'user_id' ), array( 'user_id' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
 		}

 		$this->user_id = $user_id;
 	}

 	/**
-	 * Sets the token.
+	 * Validates and sets the token.
 	 *
 	 * @param string $token The token representing a device we can send a push notification to.
-	 * @throws PushTokenInvalidDataException If token is empty or exceeds maximum length.
+	 * @throws PushTokenInvalidDataException If token is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_token( string $token ): void {
-		$token = trim( $token );
+		$result = PushTokenValidator::validate( compact( 'token' ), array( 'token' ) );

-		if ( '' === $token ) {
-			throw new PushTokenInvalidDataException( 'Token cannot be empty.' );
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
 		}

-		if ( strlen( $token ) > self::MAX_TOKEN_LENGTH ) {
-			throw new PushTokenInvalidDataException(
-				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
-				sprintf( 'Token exceeds maximum length of %s.', self::MAX_TOKEN_LENGTH )
-			);
-		}
-
-		$this->token = $token;
+		$this->token = trim( $token );
 	}

 	/**
-	 * Sets the device UUID, normalize empty (non-null) values to null.
+	 * Validates and sets the device UUID, normalize empty (non-null) values to null.
 	 *
 	 * @param string|null $device_uuid The UUID of the device that generated the token.
+	 * @throws PushTokenInvalidDataException If device UUID is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_device_uuid( ?string $device_uuid ): void {
+		$result = PushTokenValidator::validate( compact( 'device_uuid' ), array( 'device_uuid' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
+		}
+
 		if ( null !== $device_uuid ) {
 			$device_uuid = trim( $device_uuid );
 		}

-		$this->device_uuid = ( '' === $device_uuid ) ? null : $device_uuid;
+		$this->device_uuid = $device_uuid ? $device_uuid : null;
 	}

 	/**
-	 * Sets the platform.
+	 * Validates and sets the platform.
 	 *
 	 * @param string $platform The platform the token was generated by.
-	 * @throws PushTokenInvalidDataException If the platform is invalid.
+	 * @throws PushTokenInvalidDataException If platform is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_platform( string $platform ): void {
-		if ( ! in_array( $platform, self::PLATFORMS, true ) ) {
-			throw new PushTokenInvalidDataException( 'Platform for PushToken is invalid.' );
+		$result = PushTokenValidator::validate( compact( 'platform' ), array( 'platform' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
 		}

-		$this->platform = $platform;
+		$this->platform = trim( $platform );
 	}

 	/**
-	 * Sets the origin.
+	 * Validates and sets the origin.
 	 *
 	 * @param string $origin The origin of the token, e.g. the app it came from.
-	 * @throws PushTokenInvalidDataException If the origin is invalid.
+	 * @throws PushTokenInvalidDataException If origin is not valid.
 	 * @return void
 	 *
 	 * @since 10.4.0
 	 */
 	public function set_origin( string $origin ): void {
-		if ( ! in_array( $origin, self::ORIGINS, true ) ) {
-			throw new PushTokenInvalidDataException( 'Origin for PushToken is invalid.' );
+		$result = PushTokenValidator::validate( compact( 'origin' ), array( 'origin' ) );
+
+		if ( is_wp_error( $result ) ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new PushTokenInvalidDataException( $result->get_error_message() );
 		}

-		$this->origin = $origin;
+		$this->origin = trim( $origin );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php b/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php
new file mode 100644
index 0000000000..da3b27d90c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Validators/PushTokenValidator.php
@@ -0,0 +1,381 @@
+<?php
+/**
+ * PushTokenValidator class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Validators;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use WP_Error;
+
+/**
+ * Validator class for push tokens.
+ *
+ * @since 10.6.0
+ */
+class PushTokenValidator {
+	const VALIDATABLE_FIELDS = array(
+		'id',
+		'user_id',
+		'origin',
+		'device_uuid',
+		'platform',
+		'token',
+	);
+
+	/**
+	 * The error code to return in WP_Errors.
+	 *
+	 * @since 10.6.0
+	 */
+	const ERROR_CODE = 'woocommerce_invalid_data';
+
+	/**
+	 * The regex to use when validating device UUID format.
+	 *
+	 * @since 10.6.0
+	 */
+	const DEVICE_UUID_FORMAT = '/^[A-Za-z0-9._:-]+$/';
+
+	/**
+	 * The length to validate the device UUID against.
+	 *
+	 * @since 10.6.0
+	 */
+	const DEVICE_UUID_MAXIMUM_LENGTH = 255;
+
+	/**
+	 * The length to validate the token against.
+	 *
+	 * @since 10.6.0
+	 */
+	const TOKEN_MAXIMUM_LENGTH = 4096;
+
+	/**
+	 * The regex to use when validating Apple token format.
+	 *
+	 * @since 10.6.0
+	 */
+	const TOKEN_FORMAT_APPLE = '/^[A-Fa-f0-9]{64}$/';
+
+	/**
+	 * The regex to use when validating Android token format.
+	 *
+	 * @since 10.6.0
+	 */
+	const TOKEN_FORMAT_ANDROID = '/^[A-Za-z0-9=:_\-+\/]+$/';
+
+	/**
+	 * Validates the fields defined in `$fields`, or all the list of known
+	 * fields if `$fields` is empty.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param array $data The data to be validated.
+	 * @param array $fields The fields to validate.
+	 * @return bool|WP_Error
+	 */
+	public static function validate( array $data, ?array $fields = array() ) {
+		$fields = empty( $fields ) ? self::VALIDATABLE_FIELDS : $fields;
+
+		foreach ( $fields as $field ) {
+			$method = 'validate_' . $field;
+
+			if ( ! method_exists( self::class, $method ) ) {
+				return new WP_Error(
+					'woocommerce_invalid_data',
+					sprintf( 'Can\'t validate param \'%s\' as a validator does not exist for it.', $field )
+				);
+			}
+
+			$result = self::$method( $data[ $field ] ?? null, $data );
+
+			if ( is_wp_error( $result ) ) {
+				return $result;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates ID.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_id( $value, array $context ) {
+		/**
+		 * Context is unused in this specific method, but required by the
+		 * validate() dispatch signature.
+		 */
+		unset( $context );
+
+		if ( is_null( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'ID is required.' );
+		}
+
+		if ( ! is_numeric( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'ID must be numeric.' );
+		}
+
+		if ( $value <= 0 ) {
+			return new WP_Error( self::ERROR_CODE, 'ID must be a positive integer.' );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates user ID.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_user_id( $value, array $context ) {
+		/**
+		 * Context is unused in this specific method, but required by the
+		 * validate() dispatch signature.
+		 */
+		unset( $context );
+
+		if ( is_null( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'User ID is required.' );
+		}
+
+		if ( ! is_numeric( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'User ID must be numeric.' );
+		}
+
+		if ( $value <= 0 ) {
+			return new WP_Error( self::ERROR_CODE, 'User ID must be a positive integer.' );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates origin.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_origin( $value, array $context ) {
+		/**
+		 * Context is unused in this specific method, but required by the
+		 * validate() dispatch signature.
+		 */
+		unset( $context );
+
+		if ( is_null( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Origin is required.' );
+		}
+
+		if ( ! is_string( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Origin must be a string.' );
+		}
+
+		$value = trim( $value );
+
+		if ( '' === $value ) {
+			return new WP_Error( self::ERROR_CODE, 'Origin cannot be empty.' );
+		}
+
+		if ( ! in_array( $value, PushToken::ORIGINS, true ) ) {
+			return new WP_Error(
+				self::ERROR_CODE,
+				sprintf( 'Origin must be one of: %s.', implode( ', ', PushToken::ORIGINS ) )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates device UUID.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_device_uuid( $value, array $context ) {
+		/**
+		 * We may or may not have platform; if we don't have it, we can skip the
+		 * platform-specific checks and allow the platform validation to trigger
+		 * the failure.
+		 */
+		$maybe_platform = $context['platform'] ?? null;
+
+		if (
+			PushToken::PLATFORM_APPLE === $maybe_platform
+			|| PushToken::PLATFORM_ANDROID === $maybe_platform
+		) {
+			/**
+			 * The browser platform doesn't use a device UUID, so we don't need
+			 * to check truthiness or format unless the platform is not browser.
+			 */
+			if ( is_null( $value ) ) {
+				return new WP_Error( self::ERROR_CODE, 'Device UUID is required.' );
+			}
+
+			if ( ! is_string( $value ) ) {
+				return new WP_Error( self::ERROR_CODE, 'Device UUID must be a string.' );
+			}
+
+			$value = trim( $value );
+
+			if ( '' === $value ) {
+				return new WP_Error( self::ERROR_CODE, 'Device UUID cannot be empty.' );
+			}
+
+			if ( ! preg_match( self::DEVICE_UUID_FORMAT, $value ) ) {
+				return new WP_Error( self::ERROR_CODE, 'Device UUID is an invalid format.' );
+			}
+		}
+
+		if (
+			is_string( $value )
+			&& strlen( $value ) > self::DEVICE_UUID_MAXIMUM_LENGTH ) {
+			/**
+			 * Check maximum length for all device UUIDs sent, regardless of
+			 * platform. We don't know for sure the value is a string as the
+			 * check above isn't guaranteed to have run, so ensure it is a
+			 * string before evaluating this validation rule.
+			 */
+			return new WP_Error(
+				self::ERROR_CODE,
+				sprintf( 'Device UUID exceeds maximum length of %s.', self::DEVICE_UUID_MAXIMUM_LENGTH )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates platform.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_platform( $value, array $context ) {
+		/**
+		 * Context is unused in this specific method, but required by the
+		 * validate() dispatch signature.
+		 */
+		unset( $context );
+
+		if ( is_null( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Platform is required.' );
+		}
+
+		if ( ! is_string( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Platform must be a string.' );
+		}
+
+		$value = trim( $value );
+
+		if ( '' === $value ) {
+			return new WP_Error( self::ERROR_CODE, 'Platform cannot be empty.' );
+		}
+
+		if ( ! in_array( $value, PushToken::PLATFORMS, true ) ) {
+			return new WP_Error(
+				self::ERROR_CODE,
+				sprintf( 'Platform must be one of: %s.', implode( ', ', PushToken::PLATFORMS ) )
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Validates token value.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param mixed $value The value to validate.
+	 * @param array $context An array of other values included as context for the validation.
+	 * @return bool|WP_Error
+	 */
+	private static function validate_token( $value, array $context ) {
+		if ( is_null( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Token is required.' );
+		}
+
+		if ( ! is_string( $value ) ) {
+			return new WP_Error( self::ERROR_CODE, 'Token must be a string.' );
+		}
+
+		$value = trim( $value );
+
+		if ( '' === $value ) {
+			return new WP_Error( self::ERROR_CODE, 'Token cannot be empty.' );
+		}
+
+		if ( strlen( $value ) > self::TOKEN_MAXIMUM_LENGTH ) {
+			return new WP_Error(
+				self::ERROR_CODE,
+				sprintf( 'Token exceeds maximum length of %s.', self::TOKEN_MAXIMUM_LENGTH )
+			);
+		}
+
+		if ( ! isset( $context['platform'] ) ) {
+			/**
+			 * We don't know how to validate the format as we don't know the
+			 * platform, so let the platform validation handle the failure.
+			 */
+			return true;
+		}
+
+		if (
+			PushToken::PLATFORM_APPLE === $context['platform']
+			&& ! preg_match( self::TOKEN_FORMAT_APPLE, $value )
+		) {
+			return new WP_Error( self::ERROR_CODE, 'Token is an invalid format.' );
+		}
+
+		if (
+			PushToken::PLATFORM_ANDROID === $context['platform']
+			&& ! preg_match( self::TOKEN_FORMAT_ANDROID, $value )
+		) {
+			return new WP_Error( self::ERROR_CODE, 'Token is an invalid format.' );
+		}
+
+		if ( PushToken::PLATFORM_BROWSER === $context['platform'] ) {
+			$token_object = json_decode( $value, true );
+			$endpoint     = $token_object['endpoint'] ?? null;
+
+			if (
+				is_null( $token_object )
+				|| json_last_error()
+				|| ! isset( $token_object['keys']['auth'] )
+				|| ! isset( $token_object['keys']['p256dh'] )
+				|| ! $endpoint
+				|| ! wp_http_validate_url( (string) $endpoint )
+				|| ( wp_parse_url( (string) $endpoint, PHP_URL_SCHEME ) !== 'https' )
+			) {
+				return new WP_Error( self::ERROR_CODE, 'Token is an invalid format.' );
+			}
+		}
+
+		return true;
+	}
+}
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 8aee42b8b1..865e566e03 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -597,7 +597,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();

 		$this->assertEquals( 'rest_invalid_param', $data['code'] );
-		$this->assertStringContainsString( 'device_uuid', $data['message'] );
+		$this->assertStringContainsString( 'Invalid parameter(s): device_uuid', $data['message'] );
 	}

 	/**
@@ -915,7 +915,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();

 		$this->assertEquals( 'rest_invalid_param', $data['code'] );
-		$this->assertStringContainsString( 'device_uuid', $data['message'] );
+		$this->assertStringContainsString( 'Invalid parameter(s): device_uuid', $data['message'] );
 	}

 	/**
@@ -940,7 +940,7 @@ class PushTokenRestControllerTest extends WC_REST_Unit_Test_Case {
 		$data = $response->get_data();

 		$this->assertEquals( 'rest_invalid_param', $data['code'] );
-		$this->assertStringContainsString( 'device_uuid', $data['message'] );
+		$this->assertStringContainsString( 'Invalid parameter(s): device_uuid', $data['message'] );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
index ac5b79a0a2..624f01800f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Entities;

 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
+use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
 use WC_Unit_Test_Case;

 /**
@@ -401,7 +402,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$push_token = new PushToken();

 		$this->expectException( PushTokenInvalidDataException::class );
-		$this->expectExceptionMessage( 'Platform for PushToken is invalid.' );
+		$this->expectExceptionMessage( 'Platform must be one of: apple, android, browser.' );

 		$push_token->set_platform( 'invalid' );
 	}
@@ -413,7 +414,10 @@ class PushTokenTest extends WC_Unit_Test_Case {
 		$push_token = new PushToken();

 		$this->expectException( PushTokenInvalidDataException::class );
-		$this->expectExceptionMessage( 'Origin for PushToken is invalid.' );
+
+		$this->expectExceptionMessage(
+			'Origin must be one of: browser, com.woocommerce.android, com.woocommerce.android:dev, com.automattic.woocommerce, com.automattic.woocommerce:dev'
+		);

 		$push_token->set_origin( 'com.invalid.app' );
 	}
@@ -490,7 +494,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	 */
 	public function test_it_throws_exception_when_token_exceeds_max_length() {
 		$push_token = new PushToken();
-		$long_token = str_repeat( 'A', PushToken::MAX_TOKEN_LENGTH + 1 );
+		$long_token = str_repeat( 'A', PushTokenValidator::TOKEN_MAXIMUM_LENGTH + 1 );

 		$this->expectException( PushTokenInvalidDataException::class );
 		$this->expectExceptionMessage( 'Token exceeds maximum length of 4096.' );
@@ -533,7 +537,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	 */
 	public function test_it_accepts_token_at_max_length() {
 		$push_token       = new PushToken();
-		$max_length_token = str_repeat( 'A', PushToken::MAX_TOKEN_LENGTH );
+		$max_length_token = str_repeat( 'A', PushTokenValidator::TOKEN_MAXIMUM_LENGTH );

 		$push_token->set_token( $max_length_token );

@@ -588,7 +592,7 @@ class PushTokenTest extends WC_Unit_Test_Case {
 	 */
 	public function test_constructor_throws_exception_for_invalid_platform() {
 		$this->expectException( PushTokenInvalidDataException::class );
-		$this->expectExceptionMessage( 'Platform for PushToken is invalid.' );
+		$this->expectExceptionMessage( 'Platform must be one of: apple, android, browser.' );

 		new PushToken(
 			array(
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php
new file mode 100644
index 0000000000..aa96b12a2b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Validators/PushTokenValidatorTest.php
@@ -0,0 +1,789 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Validators;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the PushTokenValidator class.
+ */
+class PushTokenValidatorTest extends WC_Unit_Test_Case {
+	/**
+	 * @testdox Should return a WP_Error when validating all keys where the last
+	 * value is invalid.
+	 */
+	public function test_it_validates_all_keys(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'id'          => 1,
+				'user_id'     => 42,
+				'origin'      => PushToken::ORIGINS[0],
+				'platform'    => PushToken::PLATFORM_APPLE,
+				'device_uuid' => 'valid-uuid-123',
+				'token'       => null,
+			)
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true when validating all keys with valid data.
+	 */
+	public function test_it_validates_all_keys_with_valid_data(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'id'          => 1,
+				'user_id'     => 42,
+				'origin'      => PushToken::ORIGINS[0],
+				'platform'    => PushToken::PLATFORM_APPLE,
+				'device_uuid' => 'valid-uuid-123',
+				'token'       => str_repeat( 'a', 64 ),
+			)
+		);
+
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when validating an unknown key.
+	 */
+	public function test_validate_rejects_unknown_key(): void {
+		$result = PushTokenValidator::validate(
+			array( 'unknown_field' => 'value' ),
+			array( 'unknown_field' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'unknown_field', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true when validating a subset of keys.
+	 */
+	public function test_validate_accepts_subset_of_keys(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'id'      => 1,
+				'user_id' => 'hello',
+			),
+			array( 'id' )
+		);
+
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox Should return true for a valid positive ID.
+	 */
+	public function test_validate_id_accepts_positive_integer(): void {
+		$this->assertTrue( PushTokenValidator::validate( array( 'id' => 1 ), array( 'id' ) ) );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when ID is missing.
+	 */
+	public function test_validate_id_rejects_missing_id(): void {
+		$result = PushTokenValidator::validate( array(), array( 'id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'ID is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when ID is not numeric.
+	 */
+	public function test_validate_id_rejects_non_numeric(): void {
+		$result = PushTokenValidator::validate( array( 'id' => 'abc' ), array( 'id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'ID must be numeric.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when ID is zero.
+	 */
+	public function test_validate_id_rejects_zero(): void {
+		$result = PushTokenValidator::validate( array( 'id' => 0 ), array( 'id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'ID must be a positive integer.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when ID is negative.
+	 */
+	public function test_validate_id_rejects_negative(): void {
+		$result = PushTokenValidator::validate( array( 'id' => -5 ), array( 'id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'ID must be a positive integer.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for a valid positive user ID.
+	 */
+	public function test_validate_user_id_accepts_positive_integer(): void {
+		$this->assertTrue( PushTokenValidator::validate( array( 'user_id' => 42 ), array( 'user_id' ) ) );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when user ID is missing.
+	 */
+	public function test_validate_user_id_rejects_missing(): void {
+		$result = PushTokenValidator::validate( array(), array( 'user_id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'User ID is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when user ID is not numeric.
+	 */
+	public function test_validate_user_id_rejects_non_numeric(): void {
+		$result = PushTokenValidator::validate( array( 'user_id' => 'xyz' ), array( 'user_id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'User ID must be numeric.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when user ID is zero.
+	 */
+	public function test_validate_user_id_rejects_zero(): void {
+		$result = PushTokenValidator::validate( array( 'user_id' => 0 ), array( 'user_id' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'User ID must be a positive integer.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for each valid origin.
+	 * @param string $origin The origin to test.
+	 * @dataProvider valid_origins_provider
+	 */
+	public function test_validate_origin_accepts_valid_origins( string $origin ): void {
+		$this->assertTrue(
+			PushTokenValidator::validate( array( 'origin' => $origin ), array( 'origin' ) )
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when origin is missing.
+	 */
+	public function test_validate_origin_rejects_missing(): void {
+		$result = PushTokenValidator::validate( array(), array( 'origin' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Origin is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when origin is not a string.
+	 */
+	public function test_validate_origin_rejects_non_string(): void {
+		$result = PushTokenValidator::validate( array( 'origin' => 123 ), array( 'origin' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Origin must be a string.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when origin is empty.
+	 */
+	public function test_validate_origin_rejects_empty_string(): void {
+		$result = PushTokenValidator::validate( array( 'origin' => '' ), array( 'origin' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Origin cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when origin is whitespace only.
+	 */
+	public function test_validate_origin_rejects_whitespace_only(): void {
+		$result = PushTokenValidator::validate( array( 'origin' => '   ' ), array( 'origin' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Origin cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when origin is not in the allowed list.
+	 */
+	public function test_validate_origin_rejects_invalid_value(): void {
+		$result = PushTokenValidator::validate( array( 'origin' => 'com.invalid.app' ), array( 'origin' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'Origin must be one of:', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for each valid platform.
+	 * @param string $platform The platform to test.
+	 * @dataProvider valid_platforms_provider
+	 */
+	public function test_validate_platform_accepts_valid_platforms( string $platform ): void {
+		$this->assertTrue(
+			PushTokenValidator::validate( array( 'platform' => $platform ), array( 'platform' ) )
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when platform is missing.
+	 */
+	public function test_validate_platform_rejects_missing(): void {
+		$result = PushTokenValidator::validate( array(), array( 'platform' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Platform is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when platform is not a string.
+	 */
+	public function test_validate_platform_rejects_non_string(): void {
+		$result = PushTokenValidator::validate( array( 'platform' => 42 ), array( 'platform' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Platform must be a string.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when platform is empty.
+	 */
+	public function test_validate_platform_rejects_empty(): void {
+		$result = PushTokenValidator::validate( array( 'platform' => '' ), array( 'platform' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Platform cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when platform is not in the allowed list.
+	 */
+	public function test_validate_platform_rejects_invalid_value(): void {
+		$result = PushTokenValidator::validate( array( 'platform' => 'windows' ), array( 'platform' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'Platform must be one of:', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for a valid device UUID on Apple platform.
+	 */
+	public function test_validate_device_uuid_accepts_valid_uuid_for_apple(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'device_uuid' => 'ABC-123.def_456:789',
+					'platform'    => PushToken::PLATFORM_APPLE,
+				),
+				array( 'device_uuid' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true for a valid device UUID on Android platform.
+	 */
+	public function test_validate_device_uuid_accepts_valid_uuid_for_android(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'device_uuid' => 'device-uuid-123',
+					'platform'    => PushToken::PLATFORM_ANDROID,
+				),
+				array( 'device_uuid' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true when device UUID is missing for browser platform.
+	 */
+	public function test_validate_device_uuid_accepts_missing_uuid_for_browser(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'platform' => PushToken::PLATFORM_BROWSER ),
+				array( 'device_uuid' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true when both device UUID and platform are missing.
+	 */
+	public function test_validate_device_uuid_accepts_when_both_missing(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate( array(), array( 'device_uuid' ) )
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID is missing for Apple platform.
+	 */
+	public function test_validate_device_uuid_rejects_missing_uuid_for_apple(): void {
+		$result = PushTokenValidator::validate(
+			array( 'platform' => PushToken::PLATFORM_APPLE ),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID is missing for Android platform.
+	 */
+	public function test_validate_device_uuid_rejects_missing_uuid_for_android(): void {
+		$result = PushTokenValidator::validate(
+			array( 'platform' => PushToken::PLATFORM_ANDROID ),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID is empty for Apple platform.
+	 */
+	public function test_validate_device_uuid_rejects_empty_for_apple(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => '',
+				'platform'    => PushToken::PLATFORM_APPLE,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID is whitespace only for Apple platform.
+	 */
+	public function test_validate_device_uuid_rejects_whitespace_only_for_apple(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => '   ',
+				'platform'    => PushToken::PLATFORM_APPLE,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID contains invalid characters.
+	 */
+	public function test_validate_device_uuid_rejects_invalid_characters(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => 'invalid uuid with spaces',
+				'platform'    => PushToken::PLATFORM_APPLE,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID exceeds maximum length.
+	 */
+	public function test_validate_device_uuid_rejects_exceeding_max_length(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => str_repeat( 'a', PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH + 1 ),
+				'platform'    => PushToken::PLATFORM_APPLE,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'Device UUID exceeds maximum length', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID exceeds max length even for browser platform.
+	 */
+	public function test_validate_device_uuid_rejects_exceeding_max_length_for_browser(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => str_repeat( 'a', PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH + 1 ),
+				'platform'    => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'Device UUID exceeds maximum length', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for device UUID at exactly maximum length.
+	 */
+	public function test_validate_device_uuid_accepts_at_max_length(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'device_uuid' => str_repeat( 'a', PushTokenValidator::DEVICE_UUID_MAXIMUM_LENGTH ),
+					'platform'    => PushToken::PLATFORM_APPLE,
+				),
+				array( 'device_uuid' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when device UUID is not a string for Apple platform.
+	 */
+	public function test_validate_device_uuid_rejects_non_string_for_apple(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'device_uuid' => 12345,
+				'platform'    => PushToken::PLATFORM_APPLE,
+			),
+			array( 'device_uuid' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Device UUID must be a string.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should skip format validation for browser platform with a provided device UUID.
+	 */
+	public function test_validate_device_uuid_skips_format_check_for_browser(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'device_uuid' => 'any-value',
+					'platform'    => PushToken::PLATFORM_BROWSER,
+				),
+				array( 'device_uuid' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true for a valid Apple token.
+	 */
+	public function test_validate_token_accepts_valid_apple_token(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'token'    => str_repeat( 'a', 64 ),
+					'platform' => PushToken::PLATFORM_APPLE,
+				),
+				array( 'token' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true for a valid Android token.
+	 */
+	public function test_validate_token_accepts_valid_android_token(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'token'    => 'dGVzdF90b2tlbl92YWx1ZQ==:APA91b',
+					'platform' => PushToken::PLATFORM_ANDROID,
+				),
+				array( 'token' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return true for a valid browser token.
+	 */
+	public function test_validate_token_accepts_valid_browser_token(): void {
+		$token = wp_json_encode(
+			array(
+				'endpoint' => 'https://fcm.googleapis.com/fcm/send/abc123',
+				'keys'     => array(
+					'auth'   => 'test-auth-key',
+					'p256dh' => 'test-p256dh-key',
+				),
+			)
+		);
+
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array(
+					'token'    => $token,
+					'platform' => PushToken::PLATFORM_BROWSER,
+				),
+				array( 'token' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error when token is missing.
+	 */
+	public function test_validate_token_rejects_missing(): void {
+		$result = PushTokenValidator::validate( array(), array( 'token' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is required.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when token is not a string.
+	 */
+	public function test_validate_token_rejects_non_string(): void {
+		$result = PushTokenValidator::validate( array( 'token' => 123 ), array( 'token' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token must be a string.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when token is empty.
+	 */
+	public function test_validate_token_rejects_empty(): void {
+		$result = PushTokenValidator::validate( array( 'token' => '' ), array( 'token' ) );
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token cannot be empty.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error when token exceeds maximum length.
+	 */
+	public function test_validate_token_rejects_exceeding_max_length(): void {
+		$result = PushTokenValidator::validate(
+			array( 'token' => str_repeat( 'A', PushTokenValidator::TOKEN_MAXIMUM_LENGTH + 1 ) ),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertStringContainsString( 'Token exceeds maximum length', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return true for token at exactly maximum length without platform.
+	 */
+	public function test_validate_token_accepts_at_max_length(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'token' => str_repeat( 'A', PushTokenValidator::TOKEN_MAXIMUM_LENGTH ) ),
+				array( 'token' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should skip format validation when platform is not provided.
+	 */
+	public function test_validate_token_skips_format_check_without_platform(): void {
+		$this->assertTrue(
+			PushTokenValidator::validate(
+				array( 'token' => 'any-valid-string' ),
+				array( 'token' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox Should return WP_Error for Apple token with non-hex characters.
+	 */
+	public function test_validate_token_rejects_apple_token_with_non_hex(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => str_repeat( 'g', 64 ),
+				'platform' => PushToken::PLATFORM_APPLE,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for Apple token with wrong length.
+	 */
+	public function test_validate_token_rejects_apple_token_with_wrong_length(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => str_repeat( 'a', 32 ),
+				'platform' => PushToken::PLATFORM_APPLE,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for Android token with spaces.
+	 */
+	public function test_validate_token_rejects_android_token_with_spaces(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => 'invalid token with spaces',
+				'platform' => PushToken::PLATFORM_ANDROID,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for browser token with invalid JSON.
+	 */
+	public function test_validate_token_rejects_browser_token_with_invalid_json(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => 'not-valid-json',
+				'platform' => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for browser token that is valid JSON null.
+	 */
+	public function test_validate_token_rejects_browser_token_with_json_null(): void {
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => 'null',
+				'platform' => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for browser token missing required keys.
+	 */
+	public function test_validate_token_rejects_browser_token_missing_keys(): void {
+		$token = wp_json_encode(
+			array( 'endpoint' => 'https://example.com/push' )
+		);
+
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => $token,
+				'platform' => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for browser token with non-HTTPS endpoint.
+	 */
+	public function test_validate_token_rejects_browser_token_with_http_endpoint(): void {
+		$token = wp_json_encode(
+			array(
+				'endpoint' => 'http://example.com/push',
+				'keys'     => array(
+					'auth'   => 'test-auth',
+					'p256dh' => 'test-p256dh',
+				),
+			)
+		);
+
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => $token,
+				'platform' => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should return WP_Error for browser token with missing endpoint.
+	 */
+	public function test_validate_token_rejects_browser_token_missing_endpoint(): void {
+		$token = wp_json_encode(
+			array(
+				'keys' => array(
+					'auth'   => 'test-auth',
+					'p256dh' => 'test-p256dh',
+				),
+			)
+		);
+
+		$result = PushTokenValidator::validate(
+			array(
+				'token'    => $token,
+				'platform' => PushToken::PLATFORM_BROWSER,
+			),
+			array( 'token' )
+		);
+
+		$this->assertInstanceOf( WP_Error::class, $result );
+		$this->assertSame( 'Token is an invalid format.', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should use the standard error code for all validation errors.
+	 */
+	public function test_all_errors_use_standard_error_code(): void {
+		$errors = array(
+			PushTokenValidator::validate( array(), array( 'id' ) ),
+			PushTokenValidator::validate( array(), array( 'user_id' ) ),
+			PushTokenValidator::validate( array(), array( 'origin' ) ),
+			PushTokenValidator::validate( array(), array( 'platform' ) ),
+			PushTokenValidator::validate( array(), array( 'token' ) ),
+		);
+
+		foreach ( $errors as $error ) {
+			$this->assertInstanceOf( WP_Error::class, $error );
+			$this->assertSame( PushTokenValidator::ERROR_CODE, $error->get_error_code() );
+		}
+	}
+
+	/**
+	 * Data provider for valid platforms.
+	 *
+	 * @return array
+	 */
+	public function valid_platforms_provider(): array {
+		return array_map( fn ( $value ) => array( $value ), PushToken::PLATFORMS );
+	}
+
+	/**
+	 * Data provider for valid origins.
+	 *
+	 * @return array
+	 */
+	public function valid_origins_provider(): array {
+		return array_map( fn ( $value ) => array( $value ), PushToken::ORIGINS );
+	}
+}