Commit af368be167d for woocommerce

commit af368be167d84a6007e39016b3d9907688a17004
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Tue Oct 20 15:05:16 2026 +0800

    Skip server-side analytics events for shoppers who denied statistics consent (#70008)

diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny b/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny
new file mode 100644
index 00000000000..e9ca5bfaa42
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-server-side-consent-deny
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Stop recording server-side events for shoppers who denied statistics consent with a consent plugin that works in the browser.
diff --git a/packages/php/woocommerce-analytics/src/class-consent-manager.php b/packages/php/woocommerce-analytics/src/class-consent-manager.php
index 332c8ffeeb5..777e931a1dc 100644
--- a/packages/php/woocommerce-analytics/src/class-consent-manager.php
+++ b/packages/php/woocommerce-analytics/src/class-consent-manager.php
@@ -64,6 +64,27 @@ class Consent_Manager {
 			return true;
 		}

+		// Without a consent type the WP Consent API grants every category, even after the shopper denied statistics in the browser.
+		if ( function_exists( 'wp_get_consent_type' ) && ! \wp_get_consent_type() && self::is_statistics_consent_denied() ) {
+			return false;
+		}
+
 		return \wp_has_consent( self::WP_CONSENT_API_STATISTICS_TYPE );
 	}
+
+	/**
+	 * Check if the shopper denied statistics consent, as recorded in the WP Consent API consent cookie.
+	 *
+	 * @return bool
+	 */
+	private static function is_statistics_consent_denied() {
+		$prefix = apply_filters( 'wp_consent_cookie_prefix', 'wp_consent' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Reads a WP Consent API filter.
+		if ( ! is_string( $prefix ) ) {
+			return false;
+		}
+
+		$cookie_name = $prefix . '_' . self::WP_CONSENT_API_STATISTICS_TYPE;
+
+		return isset( $_COOKIE[ $cookie_name ] ) && 'deny' === $_COOKIE[ $cookie_name ];
+	}
 }
diff --git a/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php b/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php
new file mode 100644
index 00000000000..c3bc9905eb9
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Consent_Manager_Test.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * Tests for Consent_Manager when a consent plugin sets the consent type only in the browser.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that server-side tracking respects a shopper's statistics choice when PHP sees no consent type.
+ */
+class Consent_Manager_Test extends BaseTestCase {
+
+	/**
+	 * Remove the consent cookies so each test starts without a choice.
+	 */
+	public function tear_down(): void {
+		unset( $_COOKIE['wp_consent_statistics'], $_COOKIE['custom_statistics'], $_COOKIE['tk_ai'] );
+		parent::tear_down();
+	}
+
+	/**
+	 * Test that a shopper who denied statistics has no consent, even though PHP sees no consent type.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_denies_consent_when_the_shopper_denied_statistics_without_a_consent_type(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+		$_COOKIE['wp_consent_statistics'] = 'deny';
+
+		$this->assertFalse( Consent_Manager::has_analytics_consent() );
+	}
+
+	/**
+	 * Test that a shopper who has not chosen still has consent without a consent type, as the WP Consent API grants.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_grants_consent_without_a_consent_type_when_the_shopper_has_not_chosen(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+
+		$this->assertTrue( Consent_Manager::has_analytics_consent() );
+	}
+
+	/**
+	 * Test that the denied choice is read from the consent cookie under a filtered prefix.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_denies_consent_under_a_filtered_consent_cookie_prefix(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+		add_filter( 'wp_consent_cookie_prefix', fn() => 'custom' );
+		$_COOKIE['custom_statistics'] = 'deny';
+
+		$this->assertFalse( Consent_Manager::has_analytics_consent() );
+	}
+
+	/**
+	 * Test that with a consent type set, the decision stays with wp_has_consent() and its filter.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_leaves_the_decision_to_wp_has_consent_when_a_consent_type_is_set(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+		add_filter( 'wp_get_consent_type', fn() => 'optin' );
+		add_filter( 'wp_has_consent', '__return_true' );
+		$_COOKIE['wp_consent_statistics'] = 'deny';
+
+		$this->assertTrue( Consent_Manager::has_analytics_consent() );
+	}
+
+	/**
+	 * Test that a server-side event is recorded for a shopper with a visitor id who has not chosen.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_record_event_records_a_shopper_who_has_not_chosen(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+		$_COOKIE['tk_ai'] = 'test-visitor-id-1234567890ab';
+
+		WC_Analytics_Tracking::record_event( 'add_to_cart' );
+
+		$this->assertCount( 1, $this->get_pixel_batch_queue() );
+	}
+
+	/**
+	 * Test that a server-side event is skipped for a shopper who withdrew statistics consent but kept their `tk_ai` cookie.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_record_event_skips_a_shopper_who_denied_statistics(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-cookie-functions.php';
+		$_COOKIE['tk_ai']                 = 'test-visitor-id-1234567890ab';
+		$_COOKIE['wp_consent_statistics'] = 'deny';
+
+		WC_Analytics_Tracking::record_event( 'add_to_cart' );
+
+		$this->assertSame( array(), $this->get_pixel_batch_queue() );
+	}
+
+	/**
+	 * Read the pixels queued for shutdown delivery.
+	 *
+	 * @return array
+	 */
+	private function get_pixel_batch_queue(): array {
+		$property = ( new \ReflectionClass( WC_Analytics_Tracking::class ) )->getProperty( 'pixel_batch_queue' );
+		$property->setAccessible( true );
+		return $property->getValue();
+	}
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php
new file mode 100644
index 00000000000..43230351ef8
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-cookie-functions.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Stand-in for the WP Consent API plugin that reads the consent cookie as WP Consent API 2.0 does. Load it only in a separate process.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+if ( ! function_exists( 'wp_get_consent_type' ) ) {
+	/**
+	 * Mock of the WP Consent API consent type, which consent plugins set through the same filter.
+	 *
+	 * @return string
+	 */
+	function wp_get_consent_type() { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed
+		return apply_filters( 'wp_get_consent_type', '' );
+	}
+}
+
+if ( ! function_exists( 'wp_has_consent' ) ) {
+	/**
+	 * Mirrors WP Consent API 2.0's wp_has_consent(), which grants every category when no consent type is set.
+	 *
+	 * @param string $category Consent category.
+	 * @return bool
+	 */
+	function wp_has_consent( $category ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed
+		$consent_type = wp_get_consent_type();
+		$cookie_name  = apply_filters( 'wp_consent_cookie_prefix', 'wp_consent' ) . '_' . $category;
+		$cookie       = $_COOKIE[ $cookie_name ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
+
+		if ( ! $consent_type ) {
+			$has_consent = true;
+		} elseif ( false !== strpos( $consent_type, 'optout' ) && null === $cookie ) {
+			$has_consent = true;
+		} else {
+			$has_consent = 'allow' === $cookie;
+		}
+
+		return apply_filters( 'wp_has_consent', $has_consent, $category, null );
+	}
+}