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