Commit 446523719cf for woocommerce
commit 446523719cf504ae2d2a3eb8894cfe3729f2045e
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Mon Jul 27 17:19:44 2026 +0800
Stop sending request-derived props into cacheable analytics page output (#67003)
* Stop sending request-derived props into cacheable analytics page output
* Add changelog entry for cacheable page output fix
* Move session properties out of cacheable page output to the client
* Cap the search term queued into cacheable page output
* Restore mutated globals in page output tests and drop test regex
* Correct page-props docblocks and mark truncated page strings with an ellipsis
The page-props docblock claimed everything it returns is store-derived, but
`device` is User-Agent derived and `ui`/`is_guest`/`store_admin` describe the
current user. Name both exceptions so the guardrail does not overstate.
Lead both `get_common_properties()` docblocks with the warning — that is the
name reached for by default, and the trait's copy carried none at all.
`cap_page_string()` now appends an ellipsis, so a truncated value stays
distinguishable downstream from one that genuinely ended at the limit. Its
rationale no longer claims an unbounded search term either:
`WP_Query::parse_query()` blanks `s` above 1600 bytes, while breadcrumb titles
come from `post_title` and have no such clamp.
Tests pin the cap by character and at the limit, and drive the search term at a
length a request can actually produce.
diff --git a/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props b/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props
new file mode 100644
index 00000000000..93b8999f679
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-cacheable-page-request-derived-props
@@ -0,0 +1,4 @@
+Significance: patch
+Type: security
+
+Stop emitting request-derived properties into cacheable front-end page HTML.
diff --git a/packages/php/woocommerce-analytics/src/class-universal.php b/packages/php/woocommerce-analytics/src/class-universal.php
index f1a28dbff78..8c13712170c 100644
--- a/packages/php/woocommerce-analytics/src/class-universal.php
+++ b/packages/php/woocommerce-analytics/src/class-universal.php
@@ -76,7 +76,9 @@ class Universal {
$is_clickhouse_enabled = Features::is_clickhouse_enabled();
$is_proxy_tracking_enabled = Features::is_proxy_tracking_enabled();
// When proxy tracking is enabled, we don't need to send the common properties to the client.
- $common_properties = $is_proxy_tracking_enabled ? array() : $this->get_common_properties();
+ // Otherwise send only the page-safe properties: this markup is cacheable, so nothing
+ // derived from the current request may go into it.
+ $common_properties = $is_proxy_tracking_enabled ? array() : $this->get_page_common_properties();
?>
<script type="text/javascript">
(function() {
@@ -536,6 +538,10 @@ class Universal {
/**
* Capture a search event.
+ *
+ * The term is capped because this event is assembled here but fired by the
+ * client, so it travels through the page markup and then reaches the pixel
+ * URL. See `cap_page_string()`.
*/
public function capture_search_query() {
if ( is_search() ) {
@@ -543,7 +549,7 @@ class Universal {
$this->enqueue_event(
'search',
array(
- 'search_query' => $wp_query->get( 's' ),
+ 'search_query' => $this->cap_page_string( $wp_query->get( 's' ) ),
'qty' => $wp_query->found_posts,
)
);
diff --git a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
index a56c1968027..1d4e6dd159c 100644
--- a/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
+++ b/packages/php/woocommerce-analytics/src/class-wc-analytics-tracking.php
@@ -237,37 +237,77 @@ class WC_Analytics_Tracking {
}
/**
- * Get the common properties for the event.
+ * Request-scoped — not for page output, see `get_page_common_properties()`.
+ *
+ * Includes the session cookie and `get_server_details()`, so this is only safe
+ * for events the server fires itself on an uncached request, which includes the
+ * proxy tracking endpoint.
*
* @return array The common properties.
*/
public static function get_common_properties() {
- $blog_user_id = self::get_blog_user_id();
- $server_details = self::get_server_details();
- $blog_details = self::get_blog_details();
+ return array_merge(
+ self::get_session_properties(),
+ self::get_page_common_properties(),
+ self::get_server_details()
+ );
+ }
+
+ /**
+ * Get the visitor's session properties from the session cookie.
+ *
+ * Request-derived, so these are for the server-fired path only. The cookie is
+ * written and read by the client's own SessionManager, which supplies these
+ * properties directly on events it sends.
+ *
+ * @since 0.16.7
+ *
+ * @return array The session properties.
+ */
+ private static function get_session_properties() {
$session_details = self::get_session_details();
- $common_properties = array_merge(
- array(
- 'session_id' => $session_details['session_id'] ?? null,
- 'landing_page' => $session_details['landing_page'] ?? null,
- 'is_engaged' => $session_details['is_engaged'] ?? null,
- 'ui' => $blog_user_id,
- 'blog_id' => $blog_details['blog_id'] ?? null,
- 'store_id' => $blog_details['store_id'] ?? null,
- 'url' => $blog_details['url'] ?? null,
- 'woo_version' => $blog_details['wc_version'] ?? null,
- 'wp_version' => get_bloginfo( 'version' ),
- 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
- 'device' => self::get_device_type(),
- 'store_currency' => $blog_details['store_currency'] ?? null,
- 'timezone' => wp_timezone_string(),
- 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
- ),
- $server_details
+ return array(
+ 'session_id' => $session_details['session_id'] ?? null,
+ 'landing_page' => $session_details['landing_page'] ?? null,
+ 'is_engaged' => $session_details['is_engaged'] ?? null,
);
+ }
- return is_array( $common_properties ) ? $common_properties : array();
+ /**
+ * Get the common properties that are safe to embed in cacheable page HTML.
+ *
+ * Request headers and cookies are not part of the CDN cache key, so a property
+ * derived from one is attributed to every later visitor of the cached page.
+ * Anything request-derived belongs in `get_session_properties()` or
+ * `get_server_details()`, which only reach the server-fired path.
+ *
+ * Two exceptions, neither of them licence to add a third: `device` is
+ * User-Agent derived and a known gap, tracked for a client-side follow-up;
+ * `ui`, `is_guest` and `store_admin` are safe only because caches bypass
+ * logged-in requests.
+ *
+ * @since 0.16.7
+ *
+ * @return array The common properties.
+ */
+ public static function get_page_common_properties() {
+ $blog_user_id = self::get_blog_user_id();
+ $blog_details = self::get_blog_details();
+
+ return array(
+ 'ui' => $blog_user_id,
+ 'blog_id' => $blog_details['blog_id'] ?? null,
+ 'store_id' => $blog_details['store_id'] ?? null,
+ 'url' => $blog_details['url'] ?? null,
+ 'woo_version' => $blog_details['wc_version'] ?? null,
+ 'wp_version' => get_bloginfo( 'version' ),
+ 'store_admin' => count( array_intersect( array( 'administrator', 'shop_manager' ), wp_get_current_user()->roles ) ) > 0 ? 1 : 0,
+ 'device' => self::get_device_type(),
+ 'store_currency' => $blog_details['store_currency'] ?? null,
+ 'timezone' => wp_timezone_string(),
+ 'is_guest' => ( $blog_user_id === null || $blog_user_id === 0 ) ? 1 : 0,
+ );
}
/**
diff --git a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
index dec98fd80dc..8d7173b6ee0 100644
--- a/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
+++ b/packages/php/woocommerce-analytics/src/class-woo-analytics-trait.php
@@ -254,7 +254,10 @@ trait Woo_Analytics_Trait {
}
/**
- * Default event properties which should be included with all events.
+ * Request-scoped — not for page output, see `get_page_common_properties()`.
+ *
+ * Default event properties for events the server fires itself on an uncached
+ * request. See `WC_Analytics_Tracking::get_common_properties()`.
*
* @return array Array of standard event props.
*/
@@ -277,6 +280,28 @@ trait Woo_Analytics_Trait {
return $properties;
}
+ /**
+ * Default event properties for the client, excluding anything derived from
+ * the current request.
+ *
+ * Used for the properties embedded in front-end page HTML, which is cached
+ * and replayed to later visitors. See
+ * `WC_Analytics_Tracking::get_page_common_properties()`.
+ *
+ * @since 0.16.7
+ *
+ * @return array Array of standard event props.
+ */
+ public function get_page_common_properties() {
+ $common_properties = WC_Analytics_Tracking::get_page_common_properties();
+
+ /** This filter is documented in src/class-woo-analytics-trait.php */
+ return apply_filters(
+ 'jetpack_woocommerce_analytics_event_props',
+ $common_properties
+ );
+ }
+
/**
* Enqueue an event with optional product and custom properties.
*
@@ -604,9 +629,48 @@ trait Woo_Analytics_Trait {
* - For regular pages, it builds the breadcrumb from the page's ancestors, ordered from top-level to current.
* - For all other cases, it returns the current page's title.
*
+ * Titles are capped before being returned; see `cap_page_string()`.
+ *
* @return array The breadcrumb trail as an array of titles.
*/
private function get_breadcrumb_titles() {
+ return array_map( array( $this, 'cap_page_string' ), $this->build_breadcrumb_titles() );
+ }
+
+ /**
+ * Limit the length of a caller-influenced string bound for the page output.
+ *
+ * Breadcrumb titles come from `post_title`, which core does not bound, so this
+ * is their only limit. The search term is already blanked by
+ * `WP_Query::parse_query()` above 1600 bytes; capping it further keeps the
+ * search pixel URL, which carries the term twice, from being rejected outright.
+ *
+ * Truncated values keep an ellipsis so they stay distinguishable downstream
+ * from a value that genuinely ended at the limit.
+ *
+ * @since 0.16.7
+ *
+ * @param string $value The value bound for the page output.
+ * @return string The value, truncated if it exceeded the limit.
+ */
+ private function cap_page_string( $value ) {
+ $max_length = 200;
+
+ $value = (string) $value;
+
+ if ( mb_strlen( $value ) <= $max_length ) {
+ return $value;
+ }
+
+ return mb_substr( $value, 0, $max_length - 1 ) . '…';
+ }
+
+ /**
+ * Build the uncapped breadcrumb trail. See `get_breadcrumb_titles()`.
+ *
+ * @return array The breadcrumb trail as an array of titles.
+ */
+ private function build_breadcrumb_titles() {
if ( is_front_page() ) {
return array( __( 'Home', 'woocommerce-analytics' ) );
}
diff --git a/packages/php/woocommerce-analytics/src/client/analytics.ts b/packages/php/woocommerce-analytics/src/client/analytics.ts
index 1a3b3090913..a95e96daac0 100644
--- a/packages/php/woocommerce-analytics/src/client/analytics.ts
+++ b/packages/php/woocommerce-analytics/src/client/analytics.ts
@@ -62,15 +62,16 @@ export class Analytics {
consentManager.addConsentChangeListener( this.handleConsentChange );
this.sessionManager.init();
- const { sessionId, landingPage, isNewSession } = this.sessionManager;
+ const { sessionId, landingPage, isEngaged, isNewSession } = this.sessionManager;
- // Not needed if proxy tracking is enabled.
+ // Not needed if proxy tracking is enabled: that request carries the session cookie itself.
if ( ! this.features.proxy ) {
- // Add session ID and landing page to common properties.
+ // The page markup is cacheable, so the server cannot send these.
this.commonProps = {
...this.commonProps,
session_id: sessionId,
landing_page: landingPage,
+ is_engaged: isEngaged,
};
}
diff --git a/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
new file mode 100644
index 00000000000..c3ddb34f210
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
@@ -0,0 +1,467 @@
+<?php
+/**
+ * Tests for the analytics payload injected into front-end page HTML.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that the cacheable page output carries no request-derived properties.
+ *
+ * `inject_analytics_data()` writes into front-end HTML, which CDNs cache and
+ * replay to later visitors. Request headers such as `Referer` and
+ * `Cf-Connecting-Ip` are not part of the cache key, so any property derived
+ * from them that reaches the page body is served back to everyone who hits
+ * the cached copy — one unauthenticated request would otherwise decide what
+ * every subsequent visitor reports as their own IP, user agent and referrer.
+ */
+class Universal_Page_Output_Test extends BaseTestCase {
+
+ /**
+ * Properties that `get_server_details()` derives from the current request.
+ * None of them may appear in cacheable page output.
+ *
+ * @var string[]
+ */
+ private const REQUEST_DERIVED_PROPERTIES = array( '_via_ip', '_via_ua', '_via_ref', '_dr', '_dl', '_lg' );
+
+ /**
+ * Properties read from the visitor's session cookie. The client owns this
+ * cookie and reads it itself, so these must not reach cacheable page output
+ * either.
+ *
+ * @var string[]
+ */
+ private const SESSION_PROPERTIES = array( 'session_id', 'landing_page', 'is_engaged' );
+
+ /**
+ * Request header values used to poison the page, keyed by superglobal key.
+ * Each is distinctive enough to grep the whole response body for.
+ *
+ * @var array<string, string>
+ */
+ private const POISONED_HEADERS = array(
+ 'HTTP_REFERER' => 'https://attacker.example/poisoned-referrer',
+ 'HTTP_CF_CONNECTING_IP' => '203.0.113.199',
+ 'HTTP_USER_AGENT' => 'PoisonedUserAgent/1.0',
+ 'HTTP_ACCEPT_LANGUAGE' => 'zz-ZZ',
+ );
+
+ /**
+ * A session cookie value carrying another visitor's session.
+ *
+ * @var string
+ */
+ private const POISONED_SESSION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
+
+ /**
+ * Globals this class overwrites, captured before the first change so
+ * `tear_down()` can put back exactly what it found. `null` records a key
+ * that was absent.
+ *
+ * @var array<string, mixed>
+ */
+ private $original_globals = array();
+
+ /**
+ * Send every request through the poisoned headers and a session cookie, and
+ * clear the cached IP so each test resolves them afresh.
+ */
+ public function set_up(): void {
+ parent::set_up();
+
+ global $wp_query;
+
+ $this->original_globals = array(
+ 'cookie' => $_COOKIE['woocommerceanalytics_session'] ?? null,
+ 'post' => $GLOBALS['post'] ?? null,
+ 'is_search' => $wp_query->is_search,
+ 'search_term' => $wp_query->get( 's' ),
+ );
+
+ foreach ( self::POISONED_HEADERS as $key => $value ) {
+ $this->original_globals[ 'server:' . $key ] = $_SERVER[ $key ] ?? null;
+
+ $_SERVER[ $key ] = $value;
+ }
+
+ $_COOKIE['woocommerceanalytics_session'] = rawurlencode(
+ (string) wp_json_encode(
+ array(
+ 'session_id' => self::POISONED_SESSION_ID,
+ 'landing_page' => '["Someone else\'s landing page"]',
+ 'is_engaged' => true,
+ )
+ )
+ );
+
+ $this->reset_cached_ip();
+ }
+
+ /**
+ * Put back every global this class touched, so a later test sees the state
+ * it would have seen had this class not run.
+ */
+ public function tear_down(): void {
+ global $wp_query;
+
+ // Superglobals cannot be passed by reference, so each is restored inline.
+ foreach ( array_keys( self::POISONED_HEADERS ) as $key ) {
+ if ( $this->original_globals[ 'server:' . $key ] === null ) {
+ unset( $_SERVER[ $key ] );
+ } else {
+ $_SERVER[ $key ] = $this->original_globals[ 'server:' . $key ];
+ }
+ }
+
+ if ( $this->original_globals['cookie'] === null ) {
+ unset( $_COOKIE['woocommerceanalytics_session'] );
+ } else {
+ $_COOKIE['woocommerceanalytics_session'] = $this->original_globals['cookie'];
+ }
+
+ if ( $this->original_globals['post'] === null ) {
+ unset( $GLOBALS['post'] );
+ } else {
+ $GLOBALS['post'] = $this->original_globals['post'];
+ }
+
+ $wp_query->is_search = $this->original_globals['is_search'];
+ $wp_query->set( 's', $this->original_globals['search_term'] );
+
+ $this->original_globals = array();
+
+ $this->reset_cached_ip();
+ $this->reset_event_queue();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Test that the visitor's session properties stay out of the page output.
+ *
+ * The client reads the same cookie through its own SessionManager, so these
+ * are redundant in the markup — and the markup is cached, which would pin
+ * one visitor's session identifier onto everyone served the cached copy.
+ */
+ public function test_page_output_omits_session_properties(): void {
+ $output = $this->render_analytics_data();
+
+ foreach ( self::SESSION_PROPERTIES as $property ) {
+ $this->assertStringNotContainsString(
+ '"' . $property . '"',
+ $output,
+ sprintf( 'The "%s" session property must not reach cacheable page output.', $property )
+ );
+ }
+
+ $this->assertStringNotContainsString(
+ self::POISONED_SESSION_ID,
+ $output,
+ 'A session identifier from the request cookie must not be reflected into cacheable page output.'
+ );
+ }
+
+ /**
+ * Test that the server-fired path keeps the session properties. It runs on
+ * uncached requests — including the proxy tracking endpoint — where the
+ * cookie genuinely belongs to the visitor being recorded.
+ */
+ public function test_server_fired_properties_retain_session_details(): void {
+ $properties = WC_Analytics_Tracking::get_common_properties();
+
+ foreach ( self::SESSION_PROPERTIES as $property ) {
+ $this->assertArrayHasKey(
+ $property,
+ $properties,
+ sprintf( 'The server-fired path still needs the "%s" property.', $property )
+ );
+ }
+
+ $this->assertSame( self::POISONED_SESSION_ID, $properties['session_id'] );
+ $this->assertTrue( $properties['is_engaged'] );
+ }
+
+ /**
+ * Test that no request-derived property name reaches the page output.
+ */
+ public function test_page_output_omits_request_derived_property_names(): void {
+ $output = $this->render_analytics_data();
+
+ foreach ( self::REQUEST_DERIVED_PROPERTIES as $property ) {
+ $this->assertStringNotContainsString(
+ '"' . $property . '"',
+ $output,
+ sprintf(
+ 'The "%s" property is derived from the current request and must not reach cacheable page output.',
+ $property
+ )
+ );
+ }
+ }
+
+ /**
+ * Test that no value taken from a request header reaches the page output.
+ *
+ * This is the poisoning check: headers outside the CDN cache key must not
+ * influence the cached response body at all.
+ */
+ public function test_page_output_omits_request_header_values(): void {
+ $output = $this->render_analytics_data();
+
+ foreach ( self::POISONED_HEADERS as $key => $value ) {
+ $this->assertStringNotContainsString(
+ $value,
+ $output,
+ sprintf( 'The %s request header must not be reflected into cacheable page output.', $key )
+ );
+ }
+ }
+
+ /**
+ * Test that store-level properties survive, so the assertions above cannot
+ * pass merely because the payload went empty.
+ */
+ public function test_page_output_retains_store_properties(): void {
+ $output = $this->render_analytics_data();
+
+ foreach ( array( 'timezone', 'wp_version', 'store_currency' ) as $property ) {
+ $this->assertStringContainsString(
+ '"' . $property . '"',
+ $output,
+ sprintf( 'The store-level "%s" property should still be sent to the client.', $property )
+ );
+ }
+ }
+
+ /**
+ * Test that a breadcrumb title cannot inflate the page. Titles come from
+ * `post_title`, which core does not bound.
+ */
+ public function test_page_output_caps_long_breadcrumb_titles(): void {
+ $breadcrumbs = $this->render_breadcrumbs_for_title( str_repeat( 'A', 5000 ) );
+
+ $this->assertSame(
+ array( str_repeat( 'A', 199 ) . '…' ),
+ $breadcrumbs,
+ 'A breadcrumb title should be capped before it reaches cacheable page output.'
+ );
+ }
+
+ /**
+ * Test that the cap counts characters rather than bytes. A byte-wise cut
+ * splits the final UTF-8 sequence, which `wp_json_encode()` silently rewrites
+ * to a replacement character.
+ */
+ public function test_page_output_caps_breadcrumb_titles_by_character(): void {
+ $breadcrumbs = $this->render_breadcrumbs_for_title( str_repeat( '日', 250 ) );
+
+ $this->assertSame(
+ array( str_repeat( '日', 199 ) . '…' ),
+ $breadcrumbs,
+ 'A breadcrumb title should be capped by character, not by byte.'
+ );
+ }
+
+ /**
+ * Test that a title sitting exactly on the limit is not truncated.
+ */
+ public function test_page_output_keeps_breadcrumb_titles_at_the_limit_intact(): void {
+ $title = str_repeat( 'A', 200 );
+
+ $this->assertSame(
+ array( $title ),
+ $this->render_breadcrumbs_for_title( $title ),
+ 'A title exactly at the limit should be sent unchanged.'
+ );
+ }
+
+ /**
+ * Test that a breadcrumb title short enough to keep is left untouched.
+ */
+ public function test_page_output_keeps_short_breadcrumb_titles_intact(): void {
+ $title = 'Perfectly Ordinary Product Name';
+
+ $this->assertSame(
+ array( $title ),
+ $this->render_breadcrumbs_for_title( $title ),
+ 'Ordinary titles should be sent unchanged.'
+ );
+ }
+
+ /**
+ * Test that proxy mode still sends an empty payload. It reaches the same
+ * cacheable markup, and the pixel is fired server-side from the visitor's
+ * own uncached request instead.
+ */
+ public function test_page_output_is_empty_under_proxy_tracking(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+ $output = $this->render_analytics_data();
+
+ remove_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+ $this->assertStringContainsString(
+ 'wcAnalytics.commonProps = [];',
+ $output,
+ 'Proxy mode should send no common properties to the client.'
+ );
+ }
+
+ /**
+ * Test that the server-fired pixel path keeps its request-derived
+ * properties. That path runs on uncached requests and is the only place
+ * where pixel.wp.com can learn the visitor's real IP, user agent and
+ * referrer, so narrowing the page output must not narrow it too.
+ */
+ public function test_server_fired_properties_retain_request_details(): void {
+ $properties = WC_Analytics_Tracking::get_common_properties();
+
+ foreach ( self::REQUEST_DERIVED_PROPERTIES as $property ) {
+ $this->assertArrayHasKey(
+ $property,
+ $properties,
+ sprintf( 'The server-fired pixel path still needs the "%s" property.', $property )
+ );
+ }
+
+ $this->assertSame( self::POISONED_HEADERS['HTTP_CF_CONNECTING_IP'], $properties['_via_ip'] );
+ $this->assertSame( self::POISONED_HEADERS['HTTP_USER_AGENT'], $properties['_via_ua'] );
+ $this->assertSame( self::POISONED_HEADERS['HTTP_REFERER'], $properties['_via_ref'] );
+ }
+
+ /**
+ * Test that a long search term is capped before it is queued for the page. The
+ * term reaches the pixel twice — as `search_query` and inside the browser's own
+ * `_dl` — so an uncapped term can push the URL past what a server will accept.
+ *
+ * 1500 characters is deliberately under the 1600 bytes above which
+ * `WP_Query::parse_query()` blanks `s`, so this is an input a request can
+ * actually produce.
+ */
+ public function test_queued_search_event_caps_long_search_query(): void {
+ $this->assertSame(
+ str_repeat( 'A', 199 ) . '…',
+ $this->capture_search_event( str_repeat( 'A', 1500 ) ),
+ 'A search term should be capped before it is queued into page output.'
+ );
+ }
+
+ /**
+ * Test that an ordinary search term is queued unchanged.
+ */
+ public function test_queued_search_event_keeps_ordinary_search_query_intact(): void {
+ $term = 'blue cotton t-shirt';
+
+ $this->assertSame(
+ $term,
+ $this->capture_search_event( $term ),
+ 'Ordinary search terms should be recorded unchanged.'
+ );
+ }
+
+ /**
+ * Run a search request through `capture_search_query()` and return the
+ * `search_query` property it queued for the page.
+ *
+ * @param string $term The search term the visitor requested.
+ * @return string The queued search query.
+ */
+ private function capture_search_event( string $term ): string {
+ global $wp_query;
+
+ $wp_query->is_search = true;
+ $wp_query->set( 's', $term );
+
+ $universal = new Universal();
+ $universal->capture_search_query();
+
+ $queue = WC_Analytics_Tracking::get_event_queue();
+ $this->assertNotEmpty( $queue, 'A search event should have been queued.' );
+
+ $event = end( $queue );
+ $this->assertSame( 'search', $event['eventName'], 'The queued event should be the search event.' );
+
+ return (string) ( $event['props']['search_query'] ?? '' );
+ }
+
+ /**
+ * Render the injected analytics script and return its markup.
+ *
+ * @return string The markup written by `inject_analytics_data()`.
+ */
+ private function render_analytics_data(): string {
+ $universal = new Universal();
+
+ ob_start();
+ $universal->inject_analytics_data();
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Render the page payload for a post with the given title.
+ *
+ * @param string $title The post title the breadcrumb trail is built from.
+ * @return array The decoded breadcrumb titles.
+ */
+ private function render_breadcrumbs_for_title( string $title ): array {
+ $GLOBALS['post'] = new \WP_Post(
+ (object) array(
+ 'ID' => 1,
+ 'post_title' => $title,
+ 'post_type' => 'post',
+ 'filter' => 'raw',
+ )
+ );
+
+ return $this->get_rendered_breadcrumbs( $this->render_analytics_data() );
+ }
+
+ /**
+ * Pull the breadcrumb trail back out of the rendered markup.
+ *
+ * @param string $output The markup written by `inject_analytics_data()`.
+ * @return array The decoded breadcrumb titles.
+ */
+ private function get_rendered_breadcrumbs( string $output ): array {
+ $assignment = 'wcAnalytics.breadcrumbs = ';
+
+ $start = strpos( $output, $assignment );
+ $this->assertNotFalse( $start, 'The rendered markup should assign wcAnalytics.breadcrumbs.' );
+
+ $start += strlen( $assignment );
+ $end = strpos( $output, "\n", $start );
+ $this->assertNotFalse( $end, 'The breadcrumb assignment should end with a line break.' );
+
+ // Take the rest of the line and drop the statement's trailing semicolon.
+ $json = rtrim( trim( substr( $output, $start, $end - $start ) ), ';' );
+
+ return (array) json_decode( $json, true );
+ }
+
+ /**
+ * Clear the `event_queue` static so a queued event cannot leak into another
+ * test's rendered output.
+ */
+ private function reset_event_queue(): void {
+ $reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+ $property = $reflection->getProperty( 'event_queue' );
+ $property->setAccessible( true );
+ $property->setValue( null, array() );
+ }
+
+ /**
+ * Clear the `cached_ip` static so a test's headers are resolved rather than
+ * a value another test left behind.
+ */
+ private function reset_cached_ip(): void {
+ $reflection = new \ReflectionClass( WC_Analytics_Tracking::class );
+ $property = $reflection->getProperty( 'cached_ip' );
+ $property->setAccessible( true );
+ $property->setValue( null, null );
+ }
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
index 332879ea15e..379aaa63aa3 100644
--- a/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/woocommerce-functions.php
@@ -54,3 +54,25 @@ if ( ! function_exists( 'WC' ) ) {
};
}
}
+
+if ( ! function_exists( 'is_cart' ) ) {
+ /**
+ * Mock is_cart function.
+ *
+ * @return bool Always false; tests exercise generic front-end pages.
+ */
+ function is_cart() {
+ return false;
+ }
+}
+
+if ( ! function_exists( 'is_account_page' ) ) {
+ /**
+ * Mock is_account_page function.
+ *
+ * @return bool Always false; tests exercise generic front-end pages.
+ */
+ function is_account_page() {
+ return false;
+ }
+}