Commit 596c049ab97 for woocommerce
commit 596c049ab97ab7a28fa22f65c657c8d855908a97
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Fri Sep 4 17:28:03 2026 +0800
Refuse analytics tracking proxy requests instead of unregistering the route (#68315)
* fix(analytics): refuse proxy tracking requests instead of unregistering the route
Unregistering the route when a site turns proxy tracking off loses every event
from pages already held in a page cache or CDN: they still carry proxy: true,
their visitors keep posting, and sendBeacon discards the response so a 404 is
invisible on both ends.
Keep the route registered once a site has used the feature and answer 403.
Mirror the module's authorization into an option so the MU-plugin speed module,
which runs before any plugin registers the filter, can refuse the same way. The
mirror tracks the module's authorization rather than the feature alone, so
revoking it on removal is not undone by the next request, and it is written
before any early return in should_track_store() so it cannot freeze at a stale
value.
The speed module guards its read of the new constant the way it already guards
the client bounds, since the autoloader can resolve a package older than the
file. The template sync test now collects every package constant the template
reads and asserts each one is guarded, so the next addition cannot skip it.
* docs(analytics): simplify proxy tracking documentation
* fix(analytics): fall through instead of refusing when the speed module is unauthorized
The live option answers two different questions at once: whether proxy tracking
is on, and whether this module may accelerate it. Collapsing them meant a module
whose authorization was revoked while proxy tracking stayed on answered 403 to
requests the REST route was registered and willing to serve. The file is only
removed on a later admin_init behind a day-long transient, so that window is up
to a day, and permanent when the deletion fails.
An unauthorized module now returns and lets WordPress finish loading, so the
REST route decides: 404 where the feature was never enabled, 403 where it was
turned off, and a recorded event where only the module was deauthorized.
Also revoke the authorization before init_filesystem(), since WP_Filesystem()
fails outright on hosts that ask for credentials and that is exactly when the
file survives; and stop writing a 'no' row on the sites that never authorize
anything, which is nearly every site carrying this package.
Four assertions did not test what they claimed. Removing the revocation, the
init priority 20 registration, the sync inside the install path, or moving the
authorization check below the recording loop all left the suite green. Each is
now covered, verified by re-running the mutation.
* refactor(analytics): rename the speed module authorization option to say what it holds
* test(analytics): assert the speed module lifecycle guards instead of passing trivially
Three tests left both eligibility filters off, so the methods returned at
`should_install_proxy_speed_module()` and the version branches they name were
never reached. Deleting the `$version !== false` guard or the authorization
delete in `reset_proxy_tracking_state()` left all three green.
Enable both filters where the version branch is under test, and assert on the
authorization option, which is the only state either path touches that is
observable from the fixture.
diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index cb7bc8a9905..bb6b24cceae 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -73,7 +73,9 @@ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__retu
```
This registers the unauthenticated `POST /wp-json/woocommerce-analytics/v1/track`
-endpoint. Sites without proxy tracking enabled do not get it.
+endpoint. Sites that have never enabled proxy tracking do not get it. After it
+has been enabled, the route returns `403 proxy_tracking_disabled` while the
+filter is `false`, so cached pages fail visibly instead of receiving a `404`.
Events arriving through it are untrusted. Server-derived properties replace
client values; the reserved set is
@@ -97,11 +99,27 @@ request-derived values and the session cookie.
Invalid event names and oversized pixel URLs return an error. Events beyond the
batch limit are ignored.
-**The filter must resolve to the same value for every request on a site.** One
-that varies by cohort, percentage or geo makes cached pages disagree with what
-`/track` decides, and makes the speed module install and uninstall itself on
-alternate runs. Turning it off also drops events from pages already held in a
-cache, whose visitors keep posting to an endpoint that no longer exists.
+Two options carry this state, because the optional MU-plugin speed module loads
+before plugins register filters and cannot read them:
+
+- `woocommerce_analytics_proxy_tracking_ever_enabled` is sticky and decides
+ whether the route is registered at all. Nothing in the package clears it:
+ `Woocommerce_Analytics::reset_proxy_tracking_state()` is the only way, and it
+ has no caller, so a host that wants the endpoint gone has to call it from its
+ own deactivation or uninstall routine once the cached pages have expired.
+- `woocommerce_analytics_proxy_speed_module_authorized` records whether the speed
+ module may serve. It holds `should_install_proxy_speed_module()`, which is proxy
+ tracking **and** the module's own opt-in, so a revoked module is not
+ reauthorized by the next request. An unauthorized module falls through to the
+ REST route rather than answering itself, so the `403` always comes from one
+ place.
+
+**The filter must resolve to the same value for every request on a site, and must
+be registered before `init` priority 20.** A value that varies by cohort,
+percentage or geo makes cached pages disagree with what `/track` decides, and makes
+the speed module install and uninstall itself on alternate runs. A callback added
+after `init` priority 20 is never seen: the route is registered from the option,
+not from the filter, and the option is written there.
## Privacy & Consent Management
diff --git a/packages/php/woocommerce-analytics/changelog/wooa7s-proxy-route-lifecycle b/packages/php/woocommerce-analytics/changelog/wooa7s-proxy-route-lifecycle
new file mode 100644
index 00000000000..8d490667fc2
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/wooa7s-proxy-route-lifecycle
@@ -0,0 +1,4 @@
+Significance: minor
+Type: security
+
+Keep the tracking proxy route registered once a site has used proxy tracking and answer 403 while the feature is off, so events from pages still held in a cache fail visibly instead of disappearing into a 404. Mirrors the speed module's authorization into an option it can read before plugins load, and has an unauthorized module fall through to the REST route rather than refuse on its own.
diff --git a/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php b/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
index d745f98b2cc..6a93f4a07d9 100644
--- a/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
+++ b/packages/php/woocommerce-analytics/src/API/class-wc-analytics-tracking-proxy.php
@@ -41,9 +41,8 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'track_events' ),
- // Unauthenticated by design: this receives front-end events. The route
- // is registered only while proxy tracking is enabled, and records via
- // record_client_event(), which strips server-owned properties.
+ // Unauthenticated front-end event endpoint. track_events() validates consent
+ // and records events without client-supplied server-owned properties.
'permission_callback' => '__return_true',
'schema' => array( $this, 'get_public_item_schema' ),
),
@@ -58,6 +57,16 @@ class WC_Analytics_Tracking_Proxy extends \WC_REST_Controller {
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function track_events( $request ) {
+ // Cached pages can still post here after proxy tracking is disabled; return a
+ // visible error instead of losing the event to a 404.
+ if ( ! Features::is_proxy_tracking_enabled() ) {
+ return new \WP_Error(
+ 'proxy_tracking_disabled',
+ 'Proxy tracking is not enabled on this site.',
+ array( 'status' => 403 )
+ );
+ }
+
// Check consent before processing any events
if ( ! Consent_Manager::has_analytics_consent() ) {
return new \WP_REST_Response(
diff --git a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
index 889bc595f32..4a372b4ce26 100644
--- a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
+++ b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
@@ -38,6 +38,30 @@ class Woocommerce_Analytics {
*/
const PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT = 'woocommerce_analytics_proxy_speed_module_version_check';
+ /**
+ * Whether the MU-plugin speed module may serve requests.
+ *
+ * It reads this option because filters are unavailable before plugins load.
+ * It tracks installation eligibility, so removal cannot reauthorize the module.
+ *
+ * @since 0.18.0
+ *
+ * @var string
+ */
+ const PROXY_SPEED_MODULE_AUTHORIZED_OPTION = 'woocommerce_analytics_proxy_speed_module_authorized';
+
+ /**
+ * Whether proxy tracking has ever been enabled on this site.
+ *
+ * Keeps the REST route registered to return 403 to cached pages after the
+ * feature is disabled. Clear it only after those cached pages have expired.
+ *
+ * @since 0.18.0
+ *
+ * @var string
+ */
+ const PROXY_TRACKING_EVER_ENABLED_OPTION = 'woocommerce_analytics_proxy_tracking_ever_enabled';
+
/**
* Initializer.
* Used to configure the WooCommerce Analytics package.
@@ -80,6 +104,9 @@ class Woocommerce_Analytics {
* @return bool
*/
public static function should_track_store() {
+ // Register before early returns so the MU-plugin does not keep stale state.
+ add_action( 'init', array( __CLASS__, 'sync_proxy_tracking_state' ), 20 );
+
// Ensure this is available, even with mu-plugins.
if ( ! function_exists( 'is_plugin_active' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
@@ -175,12 +202,11 @@ class Woocommerce_Analytics {
/**
* Register REST API routes.
*
- * The tracking proxy endpoint is unauthenticated by design — it exists to
- * receive front-end events — so it is registered only while proxy tracking is
- * enabled, rather than on every site running the package.
+ * A site that has never used proxy tracking does not get the endpoint. It stays
+ * registered after being disabled, so cached pages receive a visible 403.
*/
public static function register_rest_routes() {
- if ( ! \Automattic\Woocommerce_Analytics\Features::is_proxy_tracking_enabled() ) {
+ if ( 'yes' !== get_option( self::PROXY_TRACKING_EVER_ENABLED_OPTION ) ) {
return;
}
@@ -189,7 +215,53 @@ class Woocommerce_Analytics {
}
/**
- * Maybe update proxy speed module.
+ * Sync proxy tracking state for the REST route and MU-plugin speed module.
+ *
+ * @since 0.18.0
+ *
+ * @return void
+ */
+ public static function sync_proxy_tracking_state() {
+ if ( \Automattic\Woocommerce_Analytics\Features::is_proxy_tracking_enabled()
+ && 'yes' !== get_option( self::PROXY_TRACKING_EVER_ENABLED_OPTION ) ) {
+ update_option( self::PROXY_TRACKING_EVER_ENABLED_OPTION, 'yes' );
+ }
+
+ // Track module eligibility so a removed module cannot be reauthorized.
+ $authorized = self::should_install_proxy_speed_module() ? 'yes' : 'no';
+ $current = get_option( self::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+
+ if ( $current === $authorized ) {
+ return;
+ }
+
+ // An absent option already means unauthorized, so most sites installing this
+ // package never need a row saying so.
+ if ( false === $current && 'no' === $authorized ) {
+ return;
+ }
+
+ update_option( self::PROXY_SPEED_MODULE_AUTHORIZED_OPTION, $authorized );
+ }
+
+ /**
+ * Forget that proxy tracking was enabled, so the REST route is unregistered.
+ *
+ * This is separate from removing the speed module because cached pages may remain.
+ *
+ * @since 0.18.0
+ *
+ * @return void
+ */
+ public static function reset_proxy_tracking_state() {
+ delete_option( self::PROXY_TRACKING_EVER_ENABLED_OPTION );
+ delete_option( self::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+ }
+
+ /**
+ * Update the proxy speed module.
+ *
+ * The module must refuse itself because this periodic check can be delayed.
*/
public static function maybe_update_proxy_speed_module() {
// Skip if we've already checked recently.
@@ -199,7 +271,7 @@ class Woocommerce_Analytics {
$version = get_option( self::PROXY_SPEED_MODULE_VERSION_OPTION, false );
- if ( \Automattic\Woocommerce_Analytics\Features::is_proxy_speed_module_enabled() ) {
+ if ( self::should_install_proxy_speed_module() ) {
if ( $version !== self::PACKAGE_VERSION ) {
self::maybe_add_proxy_speed_module();
}
@@ -212,15 +284,36 @@ class Woocommerce_Analytics {
set_transient( self::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT, 1, DAY_IN_SECONDS );
}
+ /**
+ * Whether the proxy speed module belongs on this site.
+ *
+ * It requires its own opt-in and proxy tracking, so it cannot serve when
+ * tracking is disabled.
+ *
+ * @since 0.18.0
+ *
+ * @return bool
+ */
+ private static function should_install_proxy_speed_module() {
+ return \Automattic\Woocommerce_Analytics\Features::is_proxy_speed_module_enabled()
+ && \Automattic\Woocommerce_Analytics\Features::is_proxy_tracking_enabled();
+ }
+
/**
* Maybe add proxy speed module.
*/
public static function maybe_add_proxy_speed_module() {
- if ( ! \Automattic\Woocommerce_Analytics\Features::is_proxy_speed_module_enabled() ) {
+ if ( ! self::should_install_proxy_speed_module() ) {
return;
}
+ // Write before the module file exists because it fails closed on this option.
+ self::sync_proxy_tracking_state();
+
if ( ! self::init_filesystem() ) {
+ if ( function_exists( 'wc_get_logger' ) ) {
+ wc_get_logger()->error( 'WooCommerce Analytics proxy speed module not installed: filesystem unavailable.', array( 'source' => 'woocommerce-analytics' ) );
+ }
return;
}
@@ -233,6 +326,9 @@ class Woocommerce_Analytics {
// If the mu-plugin directory doesn't exist, we can't copy the files.
if ( ! is_dir( WPMU_PLUGIN_DIR ) ) {
+ if ( function_exists( 'wc_get_logger' ) ) {
+ wc_get_logger()->error( 'WooCommerce Analytics proxy speed module not installed: mu-plugins directory could not be created.', array( 'source' => 'woocommerce-analytics' ) );
+ }
return;
}
@@ -295,9 +391,16 @@ class Woocommerce_Analytics {
}
/**
- * Maybe removes the proxy speed module. This should be invoked when the plugin is deactivated.
+ * Remove the proxy speed module when the plugin is deactivated.
+ *
+ * Clear its authorization because an undeletable MU-plugin can still load.
*/
public static function maybe_remove_proxy_speed_module() {
+ // Revoked before anything can fail: WP_Filesystem() returns false outright on
+ // hosts that ask for credentials, and that is exactly the case where the file
+ // survives and keeps loading.
+ delete_option( self::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+
if ( ! self::init_filesystem() ) {
return;
}
diff --git a/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php b/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
index 1dfc7c059b8..c90fc7081cf 100644
--- a/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
+++ b/packages/php/woocommerce-analytics/src/mu-plugin/woocommerce-analytics-proxy-speed-module-template.php
@@ -53,6 +53,13 @@ class WooCommerceAnalyticsProxySpeed {
return;
}
+ // Unauthorized means "do not accelerate", not "do not serve". Only the REST
+ // route can tell a disabled feature from a module whose authorization was
+ // revoked while the feature stays on, so fall through and let it answer.
+ if ( ! $this->is_authorized() ) {
+ return;
+ }
+
// Handle the request completely and exit.
$this->handle_proxy_request();
exit;
@@ -134,9 +141,30 @@ class WooCommerceAnalyticsProxySpeed {
return false;
}
+ // Same skew, other class: process_proxy_request() reads this to decide whether
+ // to serve, and an older package lacking it throws where nothing can fall back.
+ if ( ! defined( '\Automattic\Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION' ) ) {
+ error_log( 'WooCommerce Analytics Proxy Speed Module: the loaded Woocommerce_Analytics predates the speed module authorization option.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ return false;
+ }
+
return true;
}
+ /**
+ * Whether this module may serve the request.
+ *
+ * Features::is_proxy_tracking_enabled() cannot be used here: no plugin has
+ * registered that filter this early, so it reads false everywhere. Only an
+ * explicit yes serves, so a network-wide module file cannot answer for a site
+ * that never authorized it.
+ *
+ * @return bool
+ */
+ private function is_authorized() {
+ return 'yes' === get_option( \Automattic\Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+ }
+
/**
* Handle the proxy request completely.
*
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
index 849bfaa26d8..d248477a7d3 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Proxy_Test.php
@@ -41,6 +41,8 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
public function set_up(): void {
parent::set_up();
remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+ delete_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
$GLOBALS['wp_rest_server'] = null;
$this->server_snapshot = $_SERVER;
$this->reset_pixel_batch_queue();
@@ -54,6 +56,8 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
*/
public function tear_down(): void {
remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+ delete_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
$GLOBALS['wp_rest_server'] = null;
$_SERVER = $this->server_snapshot;
unset( $_COOKIE['tk_ai'] );
@@ -133,6 +137,7 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
*/
public function test_route_is_registered_when_proxy_tracking_is_enabled(): void {
add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
Woocommerce_Analytics::register_rest_routes();
@@ -169,6 +174,7 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
update_option( 'woocommerce_store_id', 'real-store-id' );
add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
Woocommerce_Analytics::register_rest_routes();
$request = new \WP_REST_Request( 'POST', self::ROUTE );
@@ -206,7 +212,7 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/?rest_route=/woocommerce-analytics/v1/track';
- // No filter: proxy tracking is off: proxy tracking has never been on here.
+ // No filter and no sync: proxy tracking has never been on here.
Woocommerce_Analytics::register_rest_routes();
$response = rest_do_request( $this->build_track_request() );
@@ -215,6 +221,34 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
$this->assertSame( array(), $this->get_pixel_batch_queue(), 'No pixel may be queued when the route is gated off.' );
}
+ /**
+ * Turning the feature off cannot unregister the route, because pages cached
+ * while it was on still tell their visitors to POST here. A 404 loses every
+ * one of those events with no signal anywhere; a 403 with a reason is
+ * something the client and the server log can both act on.
+ */
+ public function test_post_is_refused_with_a_reason_once_the_feature_is_turned_off(): void {
+ $_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_SERVER['REQUEST_URI'] = '/?rest_route=/woocommerce-analytics/v1/track';
+
+ // On, then off — the sticky option records that cached pages may exist.
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+ remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ Woocommerce_Analytics::register_rest_routes();
+
+ $this->assertArrayHasKey( self::ROUTE, rest_get_server()->get_routes(), 'The route must survive the feature being turned off.' );
+
+ $response = rest_do_request( $this->build_track_request() );
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertSame( 'proxy_tracking_disabled', $response->get_data()['code'] ?? null );
+ $this->assertSame( array(), $this->get_pixel_batch_queue(), 'Refusing must not record.' );
+ }
+
/**
* A single valid event, for the tests that only care about the response.
*
@@ -248,6 +282,7 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
update_option( 'woocommerce_store_id', 'real-store-id' );
add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
Woocommerce_Analytics::register_rest_routes();
$request = new \WP_REST_Request( 'POST', self::ROUTE );
@@ -290,6 +325,7 @@ class WC_Analytics_Tracking_Proxy_Test extends BaseTestCase {
$_SERVER['REQUEST_URI'] = '/?rest_route=/woocommerce-analytics/v1/track';
add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
Woocommerce_Analytics::register_rest_routes();
$events = array();
diff --git a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
index b314cb70a4a..9889b638f5a 100644
--- a/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/WC_Analytics_Tracking_Reserved_Props_Test.php
@@ -936,20 +936,54 @@ class WC_Analytics_Tracking_Reserved_Props_Test extends BaseTestCase {
'The template must not call the trusted entry point.'
);
+ // Nothing executes the template, so the direction of the comparison has to be
+ // asserted literally: inverting it serves exactly the requests it is there to
+ // refuse, and every other test stays green.
$this->assertStringContainsString(
- 'MAX_CLIENT_EVENTS_PER_REQUEST',
+ "return 'yes' === get_option( \\Automattic\\Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );",
$template,
- 'The template must cap the batch with the same constant as the REST controller.'
+ 'Only an explicit yes may authorize the module; an absent option must not serve.'
);
- $this->assertSame(
- 1,
- preg_match( '/defined\( \'([^\']*::[^\']+)\' \)/', $template, $matches ),
- 'The template must check the constant exists before reading it, since the autoloader can resolve an older package.'
+ // Position, not presence: a check that runs after the events are recorded is
+ // not a check, and a string match alone cannot tell the two apart.
+ $this->assertLessThan(
+ strpos( $template, '$this->handle_proxy_request();' ),
+ strpos( $template, 'if ( ! $this->is_authorized() ) {' ),
+ 'The authorization check must run before the request is handled.'
);
- $this->assertTrue(
- defined( $matches[1] ),
- "The guarded name must resolve, or load_autoloader() always returns false and the module never serves. Reviewers read the leading backslash in {$matches[1]} as breaking defined(); it does not, on any PHP this package supports."
+ // Falling through is what lets the REST route separate a disabled feature from
+ // a module whose authorization was revoked while the feature stays on.
+ $this->assertStringNotContainsString(
+ 'proxy_tracking_disabled',
+ $template,
+ 'Refusing is the REST route\'s job; an unauthorized module returns so the route can answer.'
);
+
+ $this->assertStringContainsString(
+ 'MAX_CLIENT_EVENTS_PER_REQUEST',
+ $template,
+ 'The template must cap the batch with the same constant as the REST controller.'
+ );
+ // The autoloader resolves the highest version across active plugins, which can
+ // predate this file, so every package constant it reads needs a defined() guard
+ // in load_autoloader(). Collected rather than listed: a constant added to the
+ // template without a guard is a 500 nothing can fall back from.
+ preg_match_all( '/\\\\Automattic\\\\[A-Za-z_\\\\]*::[A-Z][A-Z0-9_]*/', $template, $reads );
+ preg_match_all( '/defined\\( \'([^\']+)\' \)/', $template, $guards );
+
+ $this->assertNotEmpty( $reads[0], 'The template is expected to read package constants.' );
+
+ foreach ( array_unique( $reads[0] ) as $constant ) {
+ $this->assertContains(
+ $constant,
+ $guards[1],
+ "load_autoloader() must check {$constant} exists before process_proxy_request() reads it."
+ );
+ $this->assertTrue(
+ defined( $constant ),
+ "The guarded name must resolve, or load_autoloader() always returns false and the module never serves. Reviewers read the leading backslash in {$constant} as breaking defined(); it does not, on any PHP this package supports."
+ );
+ }
}
/**
diff --git a/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php b/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
index c1483f5f286..ea2ebd5a41a 100644
--- a/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/Woocommerce_Analytics_Test.php
@@ -41,10 +41,13 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
// Clean up any existing options/transients.
delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION );
delete_transient( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT );
// Remove any filters that might interfere.
remove_all_filters( 'woocommerce_analytics_auto_install_proxy_speed_module' );
+ remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
}
/**
@@ -58,7 +61,10 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
// Clean up options and transients.
delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION );
+ delete_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION );
delete_transient( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT );
+ remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
parent::tear_down();
}
@@ -120,16 +126,24 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
}
/**
- * Test that update is skipped when version option is false (first install).
+ * An absent version means the module was never installed, so the removal branch
+ * must not run: it would revoke an authorization this site never granted.
*/
public function test_maybe_update_proxy_speed_module_skips_when_version_is_false(): void {
// Ensure version option doesn't exist (simulates first install).
delete_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION );
+ // The only thing removal touches that is observable from here.
+ update_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION, 'yes' );
+
// Call the method.
Woocommerce_Analytics::maybe_update_proxy_speed_module();
- // Version should still not exist (maybe_add_proxy_speed_module was not called).
+ $this->assertSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'Removal ran against a site that never installed the module.'
+ );
$this->assertFalse( get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ) );
// Transient should be set (check was performed).
@@ -140,7 +154,10 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
* Test that update is skipped when version matches current package version.
*/
public function test_maybe_update_proxy_speed_module_skips_when_version_matches(): void {
+ // Enable both flags: the module is only installed where the feature it
+ // accelerates is also on.
add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
// Set version to match current.
update_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION, Woocommerce_Analytics::PACKAGE_VERSION );
@@ -152,6 +169,149 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
$this->assertSame( Woocommerce_Analytics::PACKAGE_VERSION, get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ) );
remove_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ remove_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ }
+
+ /**
+ * Turning proxy tracking off must uninstall the speed module, not just leave
+ * the REST route unregistered. Otherwise the module keeps intercepting POSTs
+ * at MU-plugin stage on a site whose operator believes the endpoint is gone.
+ */
+ public function test_maybe_update_proxy_speed_module_removes_when_proxy_tracking_disabled(): void {
+ // The module's own flag stays on; only proxy tracking is off.
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+
+ update_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION, Woocommerce_Analytics::PACKAGE_VERSION );
+
+ Woocommerce_Analytics::maybe_update_proxy_speed_module();
+
+ $this->assertFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ),
+ 'The speed module must be uninstalled when proxy tracking is disabled.'
+ );
+
+ remove_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ }
+
+ /**
+ * The install path needs both flags, so that an operator cannot end up with a
+ * module installed for a feature that is switched off.
+ */
+ public function test_maybe_add_proxy_speed_module_requires_proxy_tracking_too(): void {
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+
+ Woocommerce_Analytics::maybe_add_proxy_speed_module();
+
+ // Asserted on the options the install path writes, not on a temp directory:
+ // WPMU_PLUGIN_DIR is already defined by the test bootstrap, so a path
+ // assertion here would pass whether or not the guard exists.
+ $this->assertFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ),
+ 'No module may be installed while proxy tracking is disabled.'
+ );
+ $this->assertFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'An uninstalled module must not be authorized to serve.'
+ );
+
+ remove_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ }
+
+ /**
+ * The module fails closed on the authorization option, so it has to be written
+ * before the file can exist. Asserted on an install that fails after that point,
+ * because the ordering is not observable once both have succeeded.
+ */
+ public function test_the_install_path_authorizes_before_it_writes_the_file(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ add_filter( 'filesystem_method', array( $this, 'force_unusable_filesystem' ) );
+
+ Woocommerce_Analytics::maybe_add_proxy_speed_module();
+
+ remove_filter( 'filesystem_method', array( $this, 'force_unusable_filesystem' ) );
+
+ $this->assertSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'A module written before this option exists refuses every request until the next init.'
+ );
+ $this->assertFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ),
+ 'No version may be recorded for a module that was never written.'
+ );
+ }
+
+ /**
+ * The sync runs on `init` for every request on every site carrying this package,
+ * and the module already treats an absent option as unauthorized. Writing `no`
+ * anyway puts an autoloaded row on the overwhelming majority of installs that
+ * will never turn proxy tracking on.
+ */
+ public function test_a_site_that_never_authorized_the_module_gets_no_row(): void {
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'An absent option already means unauthorized; the row buys nothing.'
+ );
+ }
+
+ /**
+ * Revoking still has to leave a value behind, since the module reads the option
+ * and an absent one is only safe while no module was ever authorized.
+ */
+ public function test_revoking_an_authorized_module_writes_no(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ remove_all_filters( 'woocommerce_analytics_auto_install_proxy_speed_module' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertSame(
+ 'no',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'A module already on disk must be told to stop, not merely left unmentioned.'
+ );
+ }
+
+ /**
+ * The MU-plugin reads what this writes, and the conditions the early returns in
+ * should_track_store() test say nothing about whether the feature is on. Losing
+ * the registration, or moving it below one of them, leaves the module serving on
+ * a stale value with the whole suite green.
+ */
+ public function test_the_state_sync_is_registered_even_when_tracking_bails(): void {
+ remove_all_actions( 'init' );
+
+ $this->assertFalse(
+ Woocommerce_Analytics::should_track_store(),
+ 'This test is only meaningful while should_track_store() takes an early return.'
+ );
+ $this->assertSame(
+ 20,
+ has_action( 'init', array( Woocommerce_Analytics::class, 'sync_proxy_tracking_state' ) ),
+ 'The sync must be registered before the early returns, at the priority rest_api_init depends on.'
+ );
+ }
+
+ /**
+ * The registration is only worth anything if `init` actually writes the state.
+ */
+ public function test_the_init_action_writes_the_state(): void {
+ remove_all_actions( 'init' );
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+
+ Woocommerce_Analytics::should_track_store();
+ do_action( 'init' );
+
+ $this->assertSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION ),
+ 'Without this the REST route is never registered on a site that enabled the feature.'
+ );
}
/**
@@ -204,6 +364,11 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
* Test that maybe_add_proxy_speed_module skips when version already matches.
*/
public function test_maybe_add_proxy_speed_module_skips_when_version_matches(): void {
+ // Both flags, or the method returns at its eligibility guard and the file
+ // assertion below holds whatever the version check does.
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+
// Set version to match current.
update_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION, Woocommerce_Analytics::PACKAGE_VERSION );
@@ -215,11 +380,102 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
// Call the method.
Woocommerce_Analytics::maybe_add_proxy_speed_module();
+ $this->assertSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'The eligibility guard returned first, so the version check was never reached.'
+ );
+
// No file should be created since version matches.
$mu_plugin_file = $this->temp_mu_plugin_dir . '/woocommerce-analytics-proxy-speed-module.php';
$this->assertFileDoesNotExist( $mu_plugin_file );
}
+ /**
+ * MU-plugins load whether or not the plugin carrying this package is active,
+ * so a module file that outlives a deactivation must not be left holding a
+ * stale `yes`. The sticky option survives: cached pages outlive both.
+ */
+ public function test_removing_the_module_drops_its_authorization(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertSame( 'yes', get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ) );
+
+ // Both filters stay on, so a later sync cannot be what clears the option: the
+ // removal itself has to, or a module file that outlives a deactivation keeps
+ // serving on the last value written.
+ Woocommerce_Analytics::maybe_remove_proxy_speed_module();
+
+ $this->assertNotSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'Removal must revoke immediately, before any later init can run.'
+ );
+ $this->assertSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION ),
+ 'The sticky option records that cached pages may exist, which removal does not undo.'
+ );
+ }
+
+ /**
+ * WP_Filesystem() returns false outright on hosts that ask for credentials, and
+ * that is precisely when the module file survives and keeps loading. Revoking
+ * has to happen anyway, since it needs no filesystem at all.
+ */
+ public function test_removal_revokes_even_when_the_filesystem_is_unavailable(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+ update_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION, Woocommerce_Analytics::PACKAGE_VERSION );
+
+ $this->assertSame( 'yes', get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ) );
+
+ add_filter( 'filesystem_method', array( $this, 'force_unusable_filesystem' ) );
+ Woocommerce_Analytics::maybe_remove_proxy_speed_module();
+ remove_filter( 'filesystem_method', array( $this, 'force_unusable_filesystem' ) );
+
+ $this->assertNotSame(
+ 'yes',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'An undeletable module must not be left holding its authorization.'
+ );
+ $this->assertNotFalse(
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_OPTION ),
+ 'The version option is what schedules the retry, so a failed removal must keep it.'
+ );
+ }
+
+ /**
+ * Makes WP_Filesystem() fail the way a host requiring credentials does.
+ *
+ * @return string
+ */
+ public function force_unusable_filesystem() {
+ return 'ftpext';
+ }
+
+ /**
+ * The sticky option has to be clearable, or a site that tried the feature once
+ * carries the endpoint for good with no supported way back.
+ */
+ public function test_reset_proxy_tracking_state_clears_both_options(): void {
+ // Both flags, or the sync leaves the authorization option absent and the
+ // second assertion below is true without anything having been cleared.
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertSame( 'yes', get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ) );
+
+ Woocommerce_Analytics::reset_proxy_tracking_state();
+
+ $this->assertFalse( get_option( Woocommerce_Analytics::PROXY_TRACKING_EVER_ENABLED_OPTION ) );
+ $this->assertFalse( get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ) );
+ }
+
/**
* Test that maybe_remove_proxy_speed_module cleans up options and transients.
*/
@@ -236,6 +492,51 @@ class Woocommerce_Analytics_Test extends BaseTestCase {
$this->assertFalse( get_transient( Woocommerce_Analytics::PROXY_SPEED_MODULE_VERSION_CHECK_TRANSIENT ) );
}
+ /**
+ * The speed module runs at MU-plugin stage, where no plugin has registered a
+ * callback on the proxy tracking filter yet, so it cannot ask `Features` and
+ * has to read a persisted answer instead.
+ */
+ public function test_sync_proxy_tracking_state_records_the_resolved_value(): void {
+ add_filter( 'woocommerce_analytics_experimental_proxy_tracking_enabled', '__return_true' );
+ add_filter( 'woocommerce_analytics_auto_install_proxy_speed_module', '__return_true' );
+
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertSame( 'yes', get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ) );
+
+ remove_all_filters( 'woocommerce_analytics_experimental_proxy_tracking_enabled' );
+
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $this->assertSame(
+ 'no',
+ get_option( Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION ),
+ 'Turning the feature off must be mirrored too, or the module never stops answering.'
+ );
+ }
+
+ /**
+ * The mirror runs on every front-end and admin request, so it must not write
+ * to the options table when nothing changed.
+ */
+ public function test_sync_proxy_tracking_state_does_not_rewrite_an_unchanged_value(): void {
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ $writes = 0;
+ $spy = function ( $value ) use ( &$writes ) {
+ ++$writes;
+ return $value;
+ };
+ add_filter( 'pre_update_option_' . Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION, $spy );
+
+ Woocommerce_Analytics::sync_proxy_tracking_state();
+
+ remove_filter( 'pre_update_option_' . Woocommerce_Analytics::PROXY_SPEED_MODULE_AUTHORIZED_OPTION, $spy );
+
+ $this->assertSame( 0, $writes );
+ }
+
/**
* Test PACKAGE_VERSION constant exists and is valid semver format.
*/