Commit 4724e0a3733 for woocommerce
commit 4724e0a3733d176667f0ec770ed78636707e62e6
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Wed Sep 23 17:37:41 2026 +0100
Expose token registration, refresh times and device fields on the push tokens endpoint (#67619)
* Cap push token metadata size on write
Metadata is written by any admin or shop manager and had no limit on the number
of keys or on their length. The index endpoint returns it verbatim for up to a
hundred tokens per page, so a single large value made the response size
something the client controls.
Requests over any limit are rejected with a 400 naming the limit, rather than
truncated. A truncated value looks valid and leads to a wrong conclusion.
The limits apply on write, so rows created before this change stay unbounded
until the device registers again.
* Expose device and timing fields on the push tokens index
Support has no way to tell a token registered this morning from one registered
eighteen months ago, or to tell which app install a token belongs to. Adds id,
device_uuid, platform, metadata, created_at_gmt and last_confirmed_at_gmt to
the index response.
last_confirmed_at_gmt comes from post_modified_gmt, which advances on every
write. The app re-sends the token periodically rather than only when the value
changes, so the field records how recently the app read the token from the
device. Naming it updated_at_gmt would suggest the token value changed, which
is the conclusion support most needs to avoid.
Timestamps are held as Y-m-d H:i:s and converted at serialisation, so a token
can be rebuilt from its own output. The response uses Y-m-d\TH:i:s, matching
wc_rest_prepare_date_response() and every other _gmt field in the Woo REST API.
to_wpcom_format() is unchanged. It is also the per-token payload the dispatcher
sends on every notification.
* Log push token timestamps that cannot be parsed
A timestamp that fails to parse returned null with nothing recorded. If parsing
starts failing for every token, from a filter rewriting post_date_gmt or a
database variant storing a different format, the index reports null for both
timestamps and nothing says why.
The empty string and the MySQL zero date stay unreported. Both mean the date is
unknown, which is expected.
Also adds the source key to the two existing warnings in PushTokensDataStore,
which were going to the default log rather than the push notifications one.
* Publish a schema for the push tokens routes
Neither route documented its response, so OPTIONS returned nothing and the
consumer had only the pull request description to work from. get_schema() now
describes a push token as the index returns it, and both routes publish it. The
index wraps its tokens in a tokens key, so that wrapper is declared in the route
registration.
The schema is registered alongside the handlers rather than inside one of them.
register_route() keeps only non-numeric keys as route options, and
get_data_for_route() reads the schema from those, so the entries that were
already on the create and delete handlers never reached OPTIONS.
Also corrects what the docblocks say device_uuid is for. Three of them called it
a device identifier and one said it links tokens across users. The app generates
the value for its own install so a re-registration matches the existing record
after the OS issues a new token, and get_by_token_or_device_id() scopes its
query to one user.
* Declare the timestamp fields as strings with a date-time format
The schema gave both timestamp fields "type": ["date-time", "null"]. date-time
is a JSON Schema format, not a type, so a consumer generating a client from the
schema gets a type it does not recognise.
Both now declare "type": ["string", "null"] with "format": "date-time", which is
what the newer Woo REST schemas use, including the ten that describe a nullable
date the same way.
* Validate push token timestamps with TimeUtil::is_valid_date()
The hand-rolled parser matched the format with createFromFormat() and then
checked getLastErrors(), because createFromFormat() performs a date rollover
such as 30 February and only reports it as a warning. TimeUtil::is_valid_date()
already covers that case by formatting the parsed value back and comparing it to
the input, so the warning check and the thirty-line docblock explaining it both
go away.
Validation and serialisation no longer share a parser. A stored value is either
null or an exact GMT Y-m-d H:i:s, so the response format is a separator swap
rather than a second parse of a value that has already been checked.
* Return push token metadata as an object so an empty value encodes as {}
The schema declares metadata as an object, but the field was built as a PHP
array. A token with metadata encoded as a JSON object and a token without any
encoded as [], so the field changed type based on whether the app had sent
anything. A consumer reading the response had to accept both.
The index route registers no context argument, so rest_filter_response_by_context
never runs over the response and cannot try to unset a key on the object.
* Remove the push token metadata size limits
Metadata is accepted at any size again, as it was before this branch. Whether
to limit it, and whether the entity should validate on read at all, is tracked
in AINFRA-3148.
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
index 77d01df414e..842799eb594 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushTokenRestController.php
@@ -102,7 +102,21 @@ class PushTokenRestController extends RestApiControllerBase {
'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'create' ),
'args' => $this->get_args( 'create' ),
'permission_callback' => array( $this, 'authorize_as_authenticated' ),
- 'schema' => array( $this, 'get_schema' ),
+ ),
+ 'schema' => fn () => array_merge(
+ $this->get_base_schema(),
+ array(
+ 'title' => 'push_tokens',
+ 'properties' => array(
+ 'tokens' => array(
+ 'description' => __( 'The push tokens registered on this store.', 'woocommerce' ),
+ 'type' => 'array',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ 'items' => $this->get_schema(),
+ ),
+ ),
+ )
),
)
);
@@ -116,15 +130,16 @@ class PushTokenRestController extends RestApiControllerBase {
'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'delete' ),
'args' => $this->get_args( 'delete' ),
'permission_callback' => array( $this, 'authorize_as_authenticated' ),
- 'schema' => array( $this, 'get_schema' ),
),
+ 'schema' => array( $this, 'get_schema' ),
)
);
}
/**
* Returns all push tokens for roles that can receive push notifications,
- * formatted for the WPCOM push notifications endpoint.
+ * along with when each token was registered and when the app last
+ * confirmed it.
*
* @since 10.8.0
*
@@ -156,7 +171,7 @@ class PushTokenRestController extends RestApiControllerBase {
$response = new WP_REST_Response(
array(
'tokens' => array_map(
- fn ( $token ) => $token->to_wpcom_format(),
+ fn ( $token ) => $token->to_rest_format(),
$result['tokens']
),
),
@@ -265,30 +280,86 @@ class PushTokenRestController extends RestApiControllerBase {
}
/**
- * Get the schema for the POST endpoint.
+ * Get the schema for a single push token.
+ *
+ * Describes the token as the index returns it. The fields a client sends
+ * when registering one are published separately, through the route `args`
+ * that OPTIONS reports per endpoint.
*
* @since 10.6.0
*
- * @return array[]
+ * @return array
*/
public function get_schema(): array {
return array_merge(
$this->get_base_schema(),
array(
'title' => PushToken::POST_TYPE,
- 'properties' => array_map(
- fn ( $item ) => array_intersect_key(
- $item,
- array(
- 'description' => null,
- 'type' => null,
- 'enum' => null,
- 'minimum' => null,
- 'default' => null,
- 'required' => null,
- )
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier for the token.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'user_id' => array(
+ 'description' => __( 'The user the token belongs to.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'token' => array(
+ 'description' => __( 'The push token issued by Apple or Google.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'platform' => array(
+ 'description' => __( 'The platform the token was issued for.', 'woocommerce' ),
+ 'type' => 'string',
+ 'enum' => PushToken::PLATFORMS,
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'origin' => array(
+ 'description' => __( 'The app the token was registered from.', 'woocommerce' ),
+ 'type' => 'string',
+ 'enum' => PushToken::ORIGINS,
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'device_uuid' => array(
+ 'description' => __( 'An identifier the app generates for its own install, so a re-registration matches the existing record after the OS issues a new token. Null for browser tokens.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'device_locale' => array(
+ 'description' => __( 'The locale the device is set to.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'metadata' => array(
+ 'description' => __( 'Values the app supplies to describe itself and the device, such as the app and OS version.', 'woocommerce' ),
+ 'type' => 'object',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'created_at_gmt' => array(
+ 'description' => __( 'The date the token was registered, as GMT. Null when the date is unknown.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'last_confirmed_at_gmt' => array(
+ 'description' => __( 'The date the app last registered this token, as GMT. The app re-sends the token periodically, not only when the token value changes, so this shows how recently the app read the token from the device. Null when the date is unknown.', 'woocommerce' ),
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
),
- $this->get_args()
),
)
);
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 81a6a7f73df..3e362ab9710 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -12,6 +12,7 @@ defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenNotFoundException;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Exception;
use WC_Data_Exception;
use WP_Http;
@@ -139,6 +140,14 @@ class PushTokensDataStore {
$push_token->set_device_locale( $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE );
$push_token->set_metadata( $meta['metadata'] ?? array() );
+ /**
+ * Both timestamps come from the post record rather than meta, because
+ * WordPress already maintains them. See {@see PushToken::$last_confirmed_at_gmt}
+ * for what `post_modified_gmt` means for a push token.
+ */
+ $push_token->set_created_at_gmt( $post->post_date_gmt );
+ $push_token->set_last_confirmed_at_gmt( $post->post_modified_gmt );
+
return $push_token;
}
@@ -325,6 +334,7 @@ class PushTokensDataStore {
wc_get_logger()->warning(
'Failed to load meta for push token.',
array(
+ 'source' => PushNotifications::FEATURE_NAME,
'token_id' => $post_id,
'error' => $e->getMessage(),
)
@@ -491,6 +501,7 @@ class PushTokensDataStore {
wc_get_logger()->warning(
'Skipping malformed push token during role-based query.',
array(
+ 'source' => PushNotifications::FEATURE_NAME,
'token_id' => $post_id,
'error' => $e->getMessage(),
)
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
index 308beb49212..fb69d9f72ca 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Entities/PushToken.php
@@ -7,7 +7,10 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Entities;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Internal\PushNotifications\Exceptions\PushTokenInvalidDataException;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
+use Automattic\WooCommerce\Utilities\TimeUtil;
+use stdClass;
/**
* Object representation of a push token.
@@ -26,6 +29,13 @@ class PushToken {
*/
const DEFAULT_DEVICE_LOCALE = 'en_US';
+ /**
+ * The MySQL zero date, which WordPress writes when a date is unknown.
+ *
+ * @since 11.2.0
+ */
+ const ZERO_DATETIME = '0000-00-00 00:00:00';
+
/**
* Platform identifier for Apple devices.
*/
@@ -108,7 +118,11 @@ class PushToken {
private ?string $token = null;
/**
- * The UUID of the device that generated the token.
+ * An identifier the app generates for its own install, so a re-registration
+ * matches the existing record after the OS issues a new token.
+ *
+ * Not derived from the device, not stable across reinstalls, and matched
+ * only within one user. Browser tokens do not have one.
*
* @var string|null
*/
@@ -142,6 +156,30 @@ class PushToken {
*/
private ?array $metadata = null;
+ /**
+ * The date the token was registered, as a GMT `Y-m-d H:i:s` string.
+ *
+ * Stored as WordPress stores it, so a value read back out can be set again.
+ * {@see self::to_rest_datetime()} converts it for the response.
+ *
+ * @var string|null
+ */
+ private ?string $created_at_gmt = null;
+
+ /**
+ * The date the app last confirmed this token, as a GMT `Y-m-d H:i:s` string.
+ *
+ * The app re-sends the token periodically, not only when the token value
+ * changes, and `post_modified_gmt` advances on each of those writes. This
+ * records how recently the app read the token from the device and sent it
+ * to us, which is what tells us how likely the token is to still be
+ * current. It is not a confirmation from Apple or Google that the token is
+ * deliverable, and it does not mean the token value changed.
+ *
+ * @var string|null
+ */
+ private ?string $last_confirmed_at_gmt = null;
+
/**
* Creates a new PushToken instance with the given data.
*
@@ -182,6 +220,14 @@ class PushToken {
if ( array_key_exists( 'metadata', $data ) ) {
$this->set_metadata( (array) $data['metadata'] );
}
+
+ if ( array_key_exists( 'created_at_gmt', $data ) ) {
+ $this->set_created_at_gmt( null === $data['created_at_gmt'] ? null : (string) $data['created_at_gmt'] );
+ }
+
+ if ( array_key_exists( 'last_confirmed_at_gmt', $data ) ) {
+ $this->set_last_confirmed_at_gmt( null === $data['last_confirmed_at_gmt'] ? null : (string) $data['last_confirmed_at_gmt'] );
+ }
}
/**
@@ -247,7 +293,7 @@ class PushToken {
/**
* 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.
+ * @param string|null $device_uuid The identifier the app generated for its own install.
* @throws PushTokenInvalidDataException If device UUID is not valid.
* @return void
*
@@ -361,6 +407,80 @@ class PushToken {
$this->metadata = $metadata;
}
+ /**
+ * Sets the date the token was registered.
+ *
+ * Unlike the other setters this does not run through
+ * {@see PushTokenValidator}. Timestamps are derived from the underlying
+ * post record rather than supplied by an API client, so there is no
+ * untrusted input to guard against.
+ *
+ * @param string|null $created_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if unknown.
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function set_created_at_gmt( ?string $created_at_gmt ): void {
+ $this->created_at_gmt = $this->validate_gmt_datetime( $created_at_gmt );
+ }
+
+ /**
+ * Sets the date the app last confirmed this token.
+ *
+ * See {@see self::set_created_at_gmt()} for why this bypasses validation.
+ *
+ * @param string|null $last_confirmed_at_gmt A GMT `Y-m-d H:i:s` datetime, or null if unknown.
+ * @return void
+ *
+ * @since 11.2.0
+ */
+ public function set_last_confirmed_at_gmt( ?string $last_confirmed_at_gmt ): void {
+ $this->last_confirmed_at_gmt = $this->validate_gmt_datetime( $last_confirmed_at_gmt );
+ }
+
+ /**
+ * Returns a GMT datetime as `Y-m-d H:i:s`, or null if it is not one.
+ *
+ * @param string|null $datetime The GMT datetime string.
+ * @return string|null
+ */
+ private function validate_gmt_datetime( ?string $datetime ): ?string {
+ $datetime = null === $datetime ? '' : trim( $datetime );
+
+ if ( '' === $datetime || self::ZERO_DATETIME === $datetime ) {
+ return null;
+ }
+
+ $normalized = str_replace( 'T', ' ', $datetime );
+
+ if ( ! TimeUtil::is_valid_date( $normalized ) ) {
+ wc_get_logger()->warning(
+ 'Unparseable push token timestamp.',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'token_id' => $this->id,
+ 'value' => $datetime,
+ )
+ );
+
+ return null;
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Converts a GMT `Y-m-d H:i:s` datetime to `Y-m-d\TH:i:s`, the format
+ * `wc_rest_prepare_date_response()` gives every other `_gmt` field in the
+ * Woo REST API.
+ *
+ * @param string|null $datetime A datetime already through {@see self::validate_gmt_datetime()}.
+ * @return string|null
+ */
+ private function to_rest_datetime( ?string $datetime ): ?string {
+ return null === $datetime ? null : str_replace( ' ', 'T', $datetime );
+ }
+
/**
* Gets the ID.
*
@@ -449,6 +569,28 @@ class PushToken {
return $this->metadata;
}
+ /**
+ * Gets the date the token was registered, as a GMT `Y-m-d H:i:s` string.
+ *
+ * @return string|null
+ *
+ * @since 11.2.0
+ */
+ public function get_created_at_gmt(): ?string {
+ return $this->created_at_gmt;
+ }
+
+ /**
+ * Gets the date the app last confirmed this token, as a GMT `Y-m-d H:i:s` string.
+ *
+ * @return string|null
+ *
+ * @since 11.2.0
+ */
+ public function get_last_confirmed_at_gmt(): ?string {
+ return $this->last_confirmed_at_gmt;
+ }
+
/**
* Returns this token formatted for the WPCOM push notifications endpoint.
*
@@ -465,6 +607,34 @@ class PushToken {
);
}
+ /**
+ * Returns this token formatted for the push tokens REST index response.
+ *
+ * Deliberately separate from {@see self::to_wpcom_format()}. That method is
+ * also the per-token payload the dispatcher POSTs to the WPCOM send
+ * endpoint, so adding fields to it would change every notification request.
+ *
+ * Metadata is cast to an object so that an empty value encodes as `{}` rather
+ * than `[]`, matching the `object` type the schema declares for it.
+ *
+ * @return array{user_id: int|null, token: string|null, origin: string|null, device_locale: string|null, id: int|null, device_uuid: string|null, platform: string|null, metadata: stdClass, created_at_gmt: string|null, last_confirmed_at_gmt: string|null}
+ *
+ * @since 11.2.0
+ */
+ public function to_rest_format(): array {
+ return array_merge(
+ $this->to_wpcom_format(),
+ array(
+ 'id' => $this->id,
+ 'device_uuid' => $this->device_uuid,
+ 'platform' => $this->platform,
+ 'metadata' => (object) ( $this->metadata ?? array() ),
+ 'created_at_gmt' => $this->to_rest_datetime( $this->created_at_gmt ),
+ 'last_confirmed_at_gmt' => $this->to_rest_datetime( $this->last_confirmed_at_gmt ),
+ )
+ );
+ }
+
/**
* Determines whether this token can be created.
*
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 4dc80d39806..286f9a2d676 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushTokenRestControllerTest.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotifica
use Exception;
use RuntimeException;
use ReflectionClass;
+use stdClass;
use WC_Data_Exception;
use WC_Unit_Test_Case;
use WP_Error;
@@ -1211,7 +1212,7 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Test the schema is correctly formatted.
+ * @testdox Test the schema describes a push token as the index returns it.
*/
public function test_get_schema_returns_correct_structure() {
$controller = new PushTokenRestController();
@@ -1221,32 +1222,33 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
$this->assertArrayHasKey( 'properties', $schema );
$this->assertEquals( PushToken::POST_TYPE, $schema['title'] );
- $this->assertArrayHasKey( 'token', $schema['properties'] );
- $this->assertArrayHasKey( 'platform', $schema['properties'] );
- $this->assertArrayHasKey( 'device_uuid', $schema['properties'] );
- $this->assertArrayHasKey( 'origin', $schema['properties'] );
- $this->assertArrayHasKey( 'enum', $schema['properties']['platform'] );
- $this->assertArrayHasKey( 'enum', $schema['properties']['origin'] );
-
- $this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['token'] );
- $this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['platform'] );
- $this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['device_uuid'] );
- $this->assertArrayNotHasKey( 'validate_callback', $schema['properties']['origin'] );
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'id',
+ 'user_id',
+ 'token',
+ 'platform',
+ 'origin',
+ 'device_uuid',
+ 'device_locale',
+ 'metadata',
+ 'created_at_gmt',
+ 'last_confirmed_at_gmt',
+ ),
+ array_keys( $schema['properties'] )
+ );
+ $this->assertEquals( PushToken::PLATFORMS, $schema['properties']['platform']['enum'] );
+ $this->assertEquals( PushToken::ORIGINS, $schema['properties']['origin']['enum'] );
$this->assertEquals( 'string', $schema['properties']['token']['type'] );
- $this->assertEquals( 'string', $schema['properties']['platform']['type'] );
- $this->assertEquals( 'string', $schema['properties']['device_uuid']['type'] );
- $this->assertEquals( 'string', $schema['properties']['origin']['type'] );
-
- $this->assertEquals(
- PushToken::PLATFORMS,
- $schema['properties']['platform']['enum']
- );
- $this->assertEquals(
- PushToken::ORIGINS,
- $schema['properties']['origin']['enum']
- );
+ foreach ( $schema['properties'] as $field => $definition ) {
+ $this->assertArrayNotHasKey(
+ 'validate_callback',
+ $definition,
+ sprintf( 'Schema property "%s" leaked a validate_callback.', $field )
+ );
+ }
}
/**
@@ -1500,6 +1502,232 @@ class PushTokenRestControllerTest extends WC_Unit_Test_Case {
$this->assertNotEmpty( $response->get_headers()['X-WP-TotalPages'] );
}
+ /**
+ * @testdox Should publish a schema on the index route describing every returned field.
+ *
+ * Asserted through an OPTIONS request rather than the registered callback,
+ * because that is how the consumer reads it, and because a schema declared
+ * inside a handler rather than alongside them never reaches OPTIONS at all.
+ */
+ public function test_index_route_publishes_a_schema(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $route = '/wc-push-notifications/push-tokens';
+ $options = rest_do_request( new WP_REST_Request( 'OPTIONS', $route ) );
+
+ $this->assertSame( WP_Http::OK, $options->get_status() );
+ $this->assertArrayHasKey( 'schema', $options->get_data() );
+
+ $fields = $options->get_data()['schema']['properties']['tokens']['items']['properties'];
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'id',
+ 'user_id',
+ 'token',
+ 'platform',
+ 'origin',
+ 'device_uuid',
+ 'device_locale',
+ 'metadata',
+ 'created_at_gmt',
+ 'last_confirmed_at_gmt',
+ ),
+ array_keys( $fields )
+ );
+ }
+
+ /**
+ * @testdox Should describe every field the index actually returns.
+ *
+ * A schema that drifts from the response is worse than no schema, because
+ * the consumer codes against a field that is not there, or misses one that
+ * is. Key order is not compared, because it carries no meaning in JSON.
+ */
+ public function test_index_schema_matches_the_fields_the_index_returns(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+ $data_store->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'schema-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'schema-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+ $response = $controller->index( $request );
+
+ $this->assertEqualsCanonicalizing(
+ array_keys( $controller->get_schema()['properties'] ),
+ array_keys( $response->get_data()['tokens'][0] )
+ );
+ }
+
+ /**
+ * @testdox Should return registration and confirmation timestamps for each token.
+ */
+ public function test_index_returns_token_timestamps(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+ $push_token = $data_store->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'timestamps-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'timestamps-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+ $response = $controller->index( $request );
+
+ $this->assertEquals( WP_Http::OK, $response->get_status() );
+
+ $token_data = $response->get_data()['tokens'][0];
+ $post = get_post( $push_token->get_id() );
+
+ $this->assertArrayHasKey( 'created_at_gmt', $token_data );
+ $this->assertArrayHasKey( 'last_confirmed_at_gmt', $token_data );
+ $this->assertSame( wc_rest_prepare_date_response( $post->post_date_gmt ), $token_data['created_at_gmt'] );
+ $this->assertSame( wc_rest_prepare_date_response( $post->post_modified_gmt ), $token_data['last_confirmed_at_gmt'] );
+ }
+
+ /**
+ * @testdox Should return the fields needed to describe the device a token belongs to.
+ */
+ public function test_index_returns_device_identifying_fields(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $data_store = wc_get_container()->get( PushTokensDataStore::class );
+
+ $push_token = $data_store->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'device-fields-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-fields-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ 'metadata' => array( 'app_version' => '21.1' ),
+ )
+ );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+
+ $token_data = $controller->index( $request )->get_data()['tokens'][0];
+
+ $this->assertSame( $push_token->get_id(), $token_data['id'] );
+ $this->assertSame( 'device-fields-test-uuid', $token_data['device_uuid'] );
+ $this->assertSame( PushToken::PLATFORM_APPLE, $token_data['platform'] );
+ $this->assertEquals( (object) array( 'app_version' => '21.1' ), $token_data['metadata'] );
+ }
+
+ /**
+ * @testdox Should return an empty object for a token registered without metadata.
+ *
+ * Metadata is optional on registration, and browser tokens and anything
+ * registered before metadata existed have none. The tooling should not have
+ * to handle both an object and null for the same field.
+ */
+ public function test_index_returns_an_empty_object_for_a_token_without_metadata(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'no-metadata-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'no-metadata-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+
+ $token_data = $controller->index( $request )->get_data()['tokens'][0];
+
+ $this->assertArrayHasKey( 'metadata', $token_data );
+ $this->assertEquals( new stdClass(), $token_data['metadata'] );
+ }
+
+ /**
+ * @testdox Should return null timestamps for a token whose post record has no dates.
+ *
+ * WordPress populates both date columns for a private post, so the endpoint
+ * cannot produce this state on its own. The dates are zeroed directly to
+ * prove a corrupt record serializes as null rather than as an invented date
+ * or a fatal.
+ */
+ public function test_index_returns_null_timestamps_for_a_record_without_dates(): void {
+ global $wpdb;
+
+ $this->mock_jetpack_connection_manager_is_connected();
+ wc_get_container()->get( PushNotifications::class )->on_init();
+
+ $push_token = wc_get_container()->get( PushTokensDataStore::class )->create(
+ array(
+ 'user_id' => $this->user_id,
+ 'token' => 'no-dates-test-token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'no-dates-test-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ )
+ );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ 'post_modified_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $push_token->get_id() )
+ );
+
+ clean_post_cache( $push_token->get_id() );
+
+ $controller = new PushTokenRestController();
+ $request = new WP_REST_Request( 'GET', '/wc-push-notifications/push-tokens' );
+ $request->set_param( 'page', 1 );
+ $request->set_param( 'per_page', 100 );
+
+ $token_data = $controller->index( $request )->get_data()['tokens'][0];
+
+ $this->assertArrayHasKey( 'created_at_gmt', $token_data );
+ $this->assertArrayHasKey( 'last_confirmed_at_gmt', $token_data );
+ $this->assertNull( $token_data['created_at_gmt'] );
+ $this->assertNull( $token_data['last_confirmed_at_gmt'] );
+ }
+
/**
* @testdox Should return empty tokens array from the tokens endpoint when no tokens exist.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index f759114b6b0..1205e3e1f9a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -1107,6 +1107,50 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Tests reading a push token populates the registration and confirmation timestamps.
+ */
+ public function test_read_populates_timestamps_from_the_post_record() {
+ $data_store = new PushTokensDataStore();
+ $push_token = $this->create_test_push_token();
+
+ $post = get_post( $push_token->get_id() );
+ $read = $data_store->read( $push_token->get_id() );
+
+ $this->assertSame( $post->post_date_gmt, $read->get_created_at_gmt() );
+ $this->assertSame( $post->post_modified_gmt, $read->get_last_confirmed_at_gmt() );
+ }
+
+ /**
+ * @testdox Tests re-registering a device advances the confirmation timestamp past the registration one.
+ */
+ public function test_read_reflects_a_re_registered_token_as_a_later_confirmation_time() {
+ $data_store = new PushTokensDataStore();
+ $push_token = $this->create_test_push_token();
+
+ /**
+ * `post_modified_gmt` only advances once a second has elapsed, so the
+ * original post date is backdated rather than waiting on the clock.
+ */
+ wp_update_post(
+ array(
+ 'ID' => $push_token->get_id(),
+ 'post_date_gmt' => '2026-01-01 00:00:00',
+ 'post_date' => '2026-01-01 00:00:00',
+ 'post_modified_gmt' => '2026-01-01 00:00:00',
+ 'post_modified' => '2026-01-01 00:00:00',
+ )
+ );
+
+ $push_token->set_device_locale( 'fr_FR' );
+ $data_store->update( $push_token );
+
+ $read = $data_store->read( $push_token->get_id() );
+
+ $this->assertSame( '2026-01-01 00:00:00', $read->get_created_at_gmt() );
+ $this->assertGreaterThan( $read->get_created_at_gmt(), $read->get_last_confirmed_at_gmt() );
+ }
+
/**
* Creates a test push token and saves it to the database.
*
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 dd0af6e8096..811ed08d952 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Entities/PushTokenTest.php
@@ -6,7 +6,10 @@ 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\PushNotifications;
use Automattic\WooCommerce\Internal\PushNotifications\Validators\PushTokenValidator;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use stdClass;
use WC_Unit_Test_Case;
/**
@@ -15,6 +18,8 @@ use WC_Unit_Test_Case;
* @covers PushToken
*/
class PushTokenTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
/**
* @testdox Tests it's possible to set and get the ID.
*/
@@ -758,4 +763,256 @@ class PushTokenTest extends WC_Unit_Test_Case {
)
);
}
+
+ /**
+ * @testdox Tests GMT datetimes are held as Y-m-d H:i:s and converted for the response.
+ */
+ public function test_it_converts_gmt_datetimes_for_the_response() {
+ $push_token = new PushToken(
+ array(
+ 'created_at_gmt' => '2026-08-01 09:30:00',
+ 'last_confirmed_at_gmt' => '2026-08-11 14:45:12',
+ )
+ );
+
+ $this->assertSame( '2026-08-01 09:30:00', $push_token->get_created_at_gmt() );
+ $this->assertSame( '2026-08-11 14:45:12', $push_token->get_last_confirmed_at_gmt() );
+
+ $rest_format = $push_token->to_rest_format();
+
+ $this->assertSame( '2026-08-01T09:30:00', $rest_format['created_at_gmt'] );
+ $this->assertSame( '2026-08-11T14:45:12', $rest_format['last_confirmed_at_gmt'] );
+ }
+
+ /**
+ * @testdox Tests a token can be rebuilt from the timestamps in its own REST output.
+ *
+ * Anything that stores a serialised token and reconstructs it later, such
+ * as a queued payload or a cached fixture, would otherwise get null for
+ * both timestamps with nothing to indicate they were dropped.
+ */
+ public function test_it_can_be_rebuilt_from_its_own_rest_output() {
+ $original = ( new PushToken(
+ array(
+ 'created_at_gmt' => '2026-08-01 09:30:00',
+ 'last_confirmed_at_gmt' => '2026-08-11 14:45:12',
+ )
+ ) )->to_rest_format();
+
+ $rebuilt = new PushToken(
+ array(
+ 'created_at_gmt' => $original['created_at_gmt'],
+ 'last_confirmed_at_gmt' => $original['last_confirmed_at_gmt'],
+ )
+ );
+
+ $this->assertSame( $original['created_at_gmt'], $rebuilt->to_rest_format()['created_at_gmt'] );
+ $this->assertSame( $original['last_confirmed_at_gmt'], $rebuilt->to_rest_format()['last_confirmed_at_gmt'] );
+ }
+
+ /**
+ * @testdox Tests timestamps use the same shape as every other Woo REST _gmt field.
+ *
+ * `wc_rest_prepare_date_response()` emits `Y-m-d\TH:i:s`, which is what
+ * `date_created_gmt` and `date_modified_gmt` carry on every other endpoint
+ * the consumer parses. A different shape under the same `_gmt` suffix would
+ * be read with the wrong parser.
+ */
+ public function test_it_emits_timestamps_in_the_woo_rest_shape() {
+ $push_token = new PushToken( array( 'created_at_gmt' => '2026-08-01 09:30:00' ) );
+
+ $this->assertSame(
+ wc_rest_prepare_date_response( '2026-08-01 09:30:00' ),
+ $push_token->to_rest_format()['created_at_gmt']
+ );
+ }
+
+ /**
+ * @testdox Tests an unparseable stored value normalizes to null rather than fataling.
+ *
+ * The parse returns false on input it cannot match, and returning that from
+ * a `?string` method under strict types raises a TypeError. TypeError
+ * extends Error, so no catch block on the send path would stop it becoming
+ * a fatal.
+ */
+ public function test_it_normalizes_an_unparseable_timestamp_to_null() {
+ $push_token = new PushToken( array( 'created_at_gmt' => 'not a date at all' ) );
+
+ $this->assertNull( $push_token->get_created_at_gmt() );
+ }
+
+ /**
+ * @testdox Tests timestamps are read as UTC regardless of the store's timezone.
+ *
+ * Stored values are GMT by construction. Parsing them in the site timezone
+ * would shift every value by the store's offset, which on a UK store would
+ * appear only during BST.
+ */
+ public function test_it_parses_timestamps_as_utc_not_the_site_timezone() {
+ $original = get_option( 'timezone_string' );
+ update_option( 'timezone_string', 'Europe/London' );
+
+ $push_token = new PushToken( array( 'created_at_gmt' => '2026-08-01 09:30:00' ) );
+
+ update_option( 'timezone_string', $original );
+
+ $this->assertSame( '2026-08-01T09:30:00', $push_token->to_rest_format()['created_at_gmt'] );
+ }
+
+ /**
+ * @testdox Tests an impossible calendar date is rejected rather than rolled forward.
+ *
+ * PHP's date parsers accept 30 February and silently return 2 March. A
+ * rolled-forward date is worse than no date, because it looks plausible and
+ * nothing downstream can tell it was invented.
+ */
+ public function test_it_rejects_an_impossible_calendar_date() {
+ $push_token = new PushToken( array( 'created_at_gmt' => '2026-02-30 09:30:00' ) );
+
+ $this->assertNull( $push_token->get_created_at_gmt() );
+ }
+
+ /**
+ * @testdox Tests an unparseable timestamp is reported to the log.
+ *
+ * A null timestamp on its own is invisible. If the parse starts failing
+ * across every token, the diagnostic tooling loses both fields and nothing
+ * records why.
+ */
+ public function test_it_logs_an_unparseable_timestamp() {
+ new PushToken(
+ array(
+ 'id' => 99,
+ 'created_at_gmt' => 'not a date at all',
+ )
+ );
+
+ $this->assertLogged(
+ 'warning',
+ 'Unparseable push token timestamp.',
+ array(
+ 'source' => PushNotifications::FEATURE_NAME,
+ 'token_id' => 99,
+ 'value' => 'not a date at all',
+ )
+ );
+ }
+
+ /**
+ * @testdox Tests an unknown timestamp is not reported to the log.
+ *
+ * The empty string and the MySQL zero date both mean the date is unknown,
+ * which is expected rather than a fault. Reporting them would bury the
+ * entries that matter.
+ *
+ * @testWith [""]
+ * ["0000-00-00 00:00:00"]
+ *
+ * @param string $stored The stored value standing for an unknown date.
+ */
+ public function test_it_does_not_log_an_unknown_timestamp( string $stored ) {
+ new PushToken( array( 'created_at_gmt' => $stored ) );
+
+ $this->assertEmpty( $this->captured_logs );
+ }
+
+ /**
+ * @testdox Tests a timestamp carrying its own offset is rejected.
+ *
+ * These fields are documented as GMT `Y-m-d H:i:s`. An offset in the input
+ * overrides the timezone the parser is given, so a value written in local
+ * time would be read as local time and reported as a different instant
+ * rather than refused. The `T` separator the response emits carries no
+ * offset and is accepted, covered by
+ * test_it_can_be_rebuilt_from_its_own_rest_output.
+ */
+ public function test_it_rejects_a_timestamp_carrying_its_own_offset() {
+ foreach ( array( '2026-08-01T09:30:00+05:00', '2026-08-01 09:30:00 UTC', '2026-08-01T09:30:00Z', '2026-08-01T09:30:00-00:30' ) as $stored ) {
+ $push_token = new PushToken( array( 'created_at_gmt' => $stored ) );
+
+ $this->assertNull(
+ $push_token->get_created_at_gmt(),
+ sprintf( 'Expected null for stored value "%s".', $stored )
+ );
+ }
+ }
+
+ /**
+ * @testdox Tests timestamps default to null so an unknown date is distinguishable from a real one.
+ *
+ * The MySQL zero date and an empty string both mean "we don't know when
+ * this happened", and must not be surfaced as if they were real dates.
+ */
+ public function test_it_normalizes_unknown_timestamps_to_null() {
+ $this->assertNull( ( new PushToken() )->get_created_at_gmt() );
+ $this->assertNull( ( new PushToken() )->get_last_confirmed_at_gmt() );
+
+ $push_token = new PushToken(
+ array(
+ 'created_at_gmt' => '0000-00-00 00:00:00',
+ 'last_confirmed_at_gmt' => '',
+ )
+ );
+
+ $this->assertNull( $push_token->get_created_at_gmt() );
+ $this->assertNull( $push_token->get_last_confirmed_at_gmt() );
+
+ $push_token->set_created_at_gmt( null );
+ $this->assertNull( $push_token->get_created_at_gmt() );
+ }
+
+ /**
+ * @testdox Tests the REST format adds diagnostic fields without altering the WPCOM send payload.
+ *
+ * `to_wpcom_format()` is the per-token payload the dispatcher POSTs to WPCOM
+ * on every notification, so it must stay unchanged by these additions.
+ */
+ public function test_rest_format_adds_fields_without_changing_wpcom_format() {
+ $push_token = new PushToken(
+ array(
+ 'id' => 77,
+ 'user_id' => 42,
+ 'token' => 'rest_format_token',
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'rest-format-uuid',
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ 'metadata' => array( 'app_version' => '21.1' ),
+ 'created_at_gmt' => '2026-08-01 09:30:00',
+ 'last_confirmed_at_gmt' => '2026-08-11 14:45:12',
+ )
+ );
+
+ $wpcom_format = $push_token->to_wpcom_format();
+ $rest_format = $push_token->to_rest_format();
+
+ $this->assertSame(
+ array( 'user_id', 'token', 'origin', 'device_locale' ),
+ array_keys( $wpcom_format )
+ );
+
+ $this->assertSame( 77, $rest_format['id'] );
+ $this->assertSame( 'rest-format-uuid', $rest_format['device_uuid'] );
+ $this->assertSame( PushToken::PLATFORM_APPLE, $rest_format['platform'] );
+ $this->assertEquals( (object) array( 'app_version' => '21.1' ), $rest_format['metadata'] );
+ $this->assertSame( '2026-08-01T09:30:00', $rest_format['created_at_gmt'] );
+ $this->assertSame( '2026-08-11T14:45:12', $rest_format['last_confirmed_at_gmt'] );
+ $this->assertSame( $wpcom_format, array_intersect_key( $rest_format, $wpcom_format ) );
+ }
+
+ /**
+ * @testdox Tests the REST format reports metadata as an empty object when a token has none.
+ *
+ * Browser tokens and tokens registered before metadata existed have no value
+ * stored. An object rather than null or an empty array keeps the field one
+ * type for the consumer, and matches the `object` type the schema declares.
+ */
+ public function test_rest_format_reports_absent_metadata_as_an_empty_object() {
+ $rest_format = ( new PushToken() )->to_rest_format();
+
+ $this->assertEquals( new stdClass(), $rest_format['metadata'] );
+ $this->assertNull( $rest_format['id'] );
+ $this->assertNull( $rest_format['device_uuid'] );
+ $this->assertNull( $rest_format['platform'] );
+ }
}