diff --git a/projects/packages/cookie-consent/changelog/add-consent-log-versions b/projects/packages/cookie-consent/changelog/add-consent-log-versions new file mode 100644 index 000000000000..04fe2c6162bf --- /dev/null +++ b/projects/packages/cookie-consent/changelog/add-consent-log-versions @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Consent log: Record policy and banner versions. diff --git a/projects/packages/cookie-consent/src/class-consent-log-controller.php b/projects/packages/cookie-consent/src/class-consent-log-controller.php index 112daeda16b6..d439524fcf90 100644 --- a/projects/packages/cookie-consent/src/class-consent-log-controller.php +++ b/projects/packages/cookie-consent/src/class-consent-log-controller.php @@ -55,7 +55,7 @@ class Consent_Log_Controller extends WP_REST_Controller { * * @var string */ - private const DB_VERSION = '0.0.1'; + private const DB_VERSION = '0.0.2'; /** * Default retention period in days. @@ -211,6 +211,8 @@ private function create_table() { ip_address varchar(45) DEFAULT NULL, url text DEFAULT NULL, consent_types longtext DEFAULT NULL, + policy_version varchar(191) NOT NULL DEFAULT '1', + banner_version varchar(191) NOT NULL DEFAULT '1', date_created datetime NOT NULL DEFAULT '0000-00-00 00:00:00', date_created_gmt datetime NOT NULL DEFAULT '0000-00-00 00:00:00', PRIMARY KEY (id), @@ -233,8 +235,7 @@ public function register_routes() { $this->namespace, '/' . $this->rest_base, array( - array( - // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- `register_rest_route()` requires mixed key/no-key for `$args`, and then https://github.com/phan/phan/issues/4852 puts the error on the wrong line. + 0 => array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_consent_log' ), // Public, unauthenticated route — anonymous visitors submit consent. Abuse is @@ -280,8 +281,7 @@ public function register_routes() { $this->namespace, '/' . $this->rest_base, array( - array( - // @phan-suppress-next-line PhanPluginMixedKeyNoKey -- `register_rest_route()` requires mixed key/no-key for `$args`, and then https://github.com/phan/phan/issues/4852 puts the error on the wrong line. + 0 => array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_consent_logs' ), 'permission_callback' => array( $this, 'check_read_permission' ), @@ -624,6 +624,7 @@ public function create_consent_log( WP_REST_Request $request ) { // Get consent types and encode as JSON. $consent_types = $request->get_param( 'consent_types' ); $consent_json = ! empty( $consent_types ) ? wp_json_encode( $consent_types, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : null; + $log_versions = $this->get_log_versions(); $data = array( 'consent_id' => $consent_id, @@ -632,6 +633,8 @@ public function create_consent_log( WP_REST_Request $request ) { 'ip_address' => $ip, 'url' => $request->get_param( 'url' ), 'consent_types' => $consent_json, + 'policy_version' => $log_versions['policy_version'], + 'banner_version' => $log_versions['banner_version'], 'date_created' => $current_time_local, 'date_created_gmt' => $current_time_gmt, ); @@ -639,7 +642,7 @@ public function create_consent_log( WP_REST_Request $request ) { $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery self::get_table_name(), $data, - array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ) + array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) ); if ( false === $result ) { @@ -724,6 +727,38 @@ private function get_client_ip() { return is_string( $ip ) && '' !== $ip ? $ip : null; } + /** + * Get configured log versions for proof-of-consent records. + * + * @return array + */ + private function get_log_versions() { + $log_versions = Cookie_Consent::get_log_versions(); + + return array( + 'policy_version' => $this->truncate_log_version( $log_versions['policy_version'] ), + 'banner_version' => $this->truncate_log_version( $log_versions['banner_version'] ), + ); + } + + /** + * Truncate a normalized log version to the storage column length. + * + * Values arrive already sanitized and non-empty from + * Cookie_Consent::get_log_versions(); this only enforces the varchar(191) + * column limit. Use multibyte-aware truncation when available. + * + * @param string $version Normalized version value. + * @return string + */ + private function truncate_log_version( $version ) { + if ( function_exists( 'mb_substr' ) ) { + return mb_substr( $version, 0, 191 ); + } + + return substr( $version, 0, 191 ); + } + /** * Get the schema for the create consent endpoint. * @@ -802,6 +837,18 @@ public function get_consent_logs_schema() { 'context' => array( 'view' ), 'readonly' => true, ), + 'policy_version' => array( + 'description' => __( 'Policy version in effect when consent was captured.', 'jetpack-cookie-consent' ), + 'type' => 'string', + 'context' => array( 'view' ), + 'readonly' => true, + ), + 'banner_version' => array( + 'description' => __( 'Banner version in effect when consent was captured.', 'jetpack-cookie-consent' ), + 'type' => 'string', + 'context' => array( 'view' ), + 'readonly' => true, + ), 'date_created' => array( 'description' => __( 'Date created in local time.', 'jetpack-cookie-consent' ), 'type' => 'string', diff --git a/projects/packages/cookie-consent/src/class-cookie-consent.php b/projects/packages/cookie-consent/src/class-cookie-consent.php index af2e949de312..c463b8ab5af9 100644 --- a/projects/packages/cookie-consent/src/class-cookie-consent.php +++ b/projects/packages/cookie-consent/src/class-cookie-consent.php @@ -656,6 +656,21 @@ public static function add_ccpa_group_directives( $block_content, $block ) { // return $block_content; } + /** + * Get configured log versions. + * + * @return array Log version configuration. + */ + public static function get_log_versions() { + $config = self::get_config(); + $log_config = isset( $config['log'] ) && is_array( $config['log'] ) ? $config['log'] : array(); + + return array( + 'policy_version' => self::normalize_log_version( $log_config['policy_version'] ?? '1', 'policy_version' ), + 'banner_version' => self::normalize_log_version( $log_config['banner_version'] ?? '1', 'banner_version' ), + ); + } + /** * Get default UI copy. * @@ -707,6 +722,34 @@ public static function get_default_copy() { ); } + /** + * Normalize a configured log version value for callers. + * + * @param mixed $version Version value from config. + * @param string $key Config key being normalized, used for diagnostics. + * @return string Non-empty log version. + */ + private static function normalize_log_version( $version, $key ) { + if ( is_scalar( $version ) ) { + $normalized = sanitize_text_field( (string) $version ); + if ( '' !== $normalized ) { + return $normalized; + } + } + + // A supplied value that isn't a usable version string would silently become the + // default '1' on a proof-of-consent record, masking a misconfiguration. Surface it + // so an integrating developer notices the configured value was dropped. + _doing_it_wrong( + __METHOD__, + /* translators: %s is the log version configuration key. */ + esc_html( sprintf( __( 'Cookie consent log version for "%s" was ignored because it is not a non-empty scalar value.', 'jetpack-cookie-consent' ), $key ) ), + '' + ); + + return '1'; + } + /** * Resolve UI copy for a template, backfilling any missing keys with defaults. * @@ -829,6 +872,10 @@ private static function get_config() { 'show_on_error' => true, // Show banner if geolocation fails. 'gdpr_honors_gpc' => true, // Honor a Global Privacy Control signal as an opt-out in GDPR regions. 'event_prefix' => 'jetpack', // Tracks event name prefix; set to 'woocommerceanalytics' for Unified Analytics continuity. + 'log' => array( + 'policy_version' => '1', + 'banner_version' => '1', + ), 'copy' => $default_copy, ); diff --git a/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Log_Versions_Test.php b/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Log_Versions_Test.php new file mode 100644 index 000000000000..774720703322 --- /dev/null +++ b/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Log_Versions_Test.php @@ -0,0 +1,105 @@ +setAccessible( true ); + } + + return $reflection->invoke( $controller, ...$args ); + } + + /** + * Versions within the column length are returned unchanged. + */ + public function test_truncate_log_version_keeps_short_values() { + $this->assertSame( 'policy-2026-06', $this->invoke( 'truncate_log_version', 'policy-2026-06' ) ); + } + + /** + * Over-length values are truncated to the varchar(191) column limit. + */ + public function test_truncate_log_version_truncates_to_column_limit() { + $truncated = $this->invoke( 'truncate_log_version', str_repeat( 'a', 300 ) ); + + $this->assertSame( 191, strlen( $truncated ) ); + } + + /** + * Truncation counts characters, not bytes, so multibyte sequences are not split. + */ + public function test_truncate_log_version_does_not_split_multibyte_characters() { + $truncated = $this->invoke( 'truncate_log_version', str_repeat( 'é', 300 ) ); + + // 191 characters kept, and no multibyte sequence was split mid-byte. + $this->assertSame( 191, mb_strlen( $truncated ) ); + $this->assertSame( $truncated, mb_convert_encoding( $truncated, 'UTF-8', 'UTF-8' ) ); + } + + /** + * Normalized defaults are returned when no config is supplied. + */ + public function test_get_log_versions_returns_defaults() { + $this->assertSame( + array( + 'policy_version' => '1', + 'banner_version' => '1', + ), + $this->invoke( 'get_log_versions' ) + ); + } + + /** + * Configured values are normalized and truncated end to end. + */ + public function test_get_log_versions_normalizes_and_truncates() { + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = ' policy-trimmed '; + $config['log']['banner_version'] = str_repeat( 'b', 300 ); + + return $config; + } + ); + + $versions = $this->invoke( 'get_log_versions' ); + + $this->assertSame( 'policy-trimmed', $versions['policy_version'] ); + $this->assertSame( 191, strlen( $versions['banner_version'] ) ); + } +} diff --git a/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Test.php b/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Test.php index 413d2f0521d5..417c727dc869 100644 --- a/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Test.php +++ b/projects/packages/cookie-consent/tests/php/Consent_Log_Controller_Test.php @@ -60,6 +60,7 @@ public function tearDown(): void { // Reset state some tests flip on, so sibling suites see the defaults. wp_using_ext_object_cache( false ); remove_all_filters( 'jetpack_cookie_consent_rate_limit_max' ); + remove_all_filters( 'jetpack_cookie_consent_config' ); wp_cache_flush(); parent::tearDown(); } @@ -269,4 +270,117 @@ public function test_create_consent_log_returns_429_when_rate_limited() { $this->assertInstanceOf( WP_REST_Response::class, $response ); $this->assertSame( 429, $response->get_status() ); } + + /** + * A successful write sends the configured version columns to $wpdb->insert(), with the + * positional format map kept 1:1 with the data columns it describes. + * + * The consent-log table is MySQL-backed and not queryable in this harness (see class + * docblock), so the write is captured at the $wpdb->insert() boundary. This guards the + * load-bearing coupling between $data and its $format array: a future column reorder or + * addition that forgets the matching format entry would misformat values silently, and + * no helper-level unit test would catch it. + */ + public function test_create_consent_log_persists_version_columns() { + // Admit the write past the rate limiter (object-cache backend is portable here). + $this->force_object_cache_limit( 10 ); + + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = 'policy-2026-06'; + $config['log']['banner_version'] = 'banner-2026-06'; + + return $config; + } + ); + + global $wpdb; + $real_wpdb = $wpdb; + // Intercept only the consent-log insert; delegate everything else (option reads, the + // table prefix) to the real handle so the rest of the request path behaves normally. + $wpdb = new class( $real_wpdb ) { + /** + * Underlying database handle. + * + * @var \wpdb + */ + private $real; + + /** + * Arguments captured from the last insert() call. + * + * @var array + */ + public $insert_args = array(); + + /** + * Wrap the real database handle. + * + * @param \wpdb $real Real database handle. + */ + public function __construct( $real ) { + $this->real = $real; + } + + /** + * Delegate property reads (e.g. prefix) to the real handle. + * + * @param string $name Property name. + * @return mixed + */ + public function __get( $name ) { + return $this->real->$name; + } + + /** + * Delegate method calls (e.g. get_results) to the real handle. + * + * @param string $name Method name. + * @param array $args Arguments. + * @return mixed + */ + public function __call( $name, $args ) { + return $this->real->$name( ...$args ); + } + + /** + * Stand in for wpdb::insert(), recording its arguments. + * + * @param string $table Table name. + * @param array $data Column => value pairs. + * @param array $format Positional placeholder formats. + * @return int Rows "affected". + */ + public function insert( $table, $data, $format ) { + $this->insert_args = compact( 'table', 'data', 'format' ); + return 1; + } + }; + + $response = null; + $insert_args = array(); + try { + $request = new WP_REST_Request(); + $request->set_param( 'event_type', 'accept_all' ); + $response = $this->controller->create_consent_log( $request ); + $insert_args = $wpdb->insert_args; + } finally { + $wpdb = $real_wpdb; + } + + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + + $this->assertNotEmpty( $insert_args, 'create_consent_log() should reach $wpdb->insert().' ); + $data = $insert_args['data']; + $format = $insert_args['format']; + + // The configured proof-of-consent versions are written to the record. + $this->assertSame( 'policy-2026-06', $data['policy_version'] ); + $this->assertSame( 'banner-2026-06', $data['banner_version'] ); + + // The positional format map must stay 1:1 with the data columns. + $this->assertSameSize( $data, $format ); + } } diff --git a/projects/packages/cookie-consent/tests/php/Cookie_Consent_Log_Versions_Test.php b/projects/packages/cookie-consent/tests/php/Cookie_Consent_Log_Versions_Test.php new file mode 100644 index 000000000000..884bc227093a --- /dev/null +++ b/projects/packages/cookie-consent/tests/php/Cookie_Consent_Log_Versions_Test.php @@ -0,0 +1,164 @@ +doing_it_wrong and suppresses the + * underlying PHP warning, so a test can assert the diagnostic fired. + */ + private function capture_doing_it_wrong() { + $this->doing_it_wrong = array(); + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + add_action( + 'doing_it_wrong_run', + function ( $function_name ) { + $this->doing_it_wrong[] = $function_name; + } + ); + } + + /** + * Default log versions are returned when no config override is supplied. + */ + public function test_get_log_versions_returns_defaults() { + $this->assertSame( + array( + 'policy_version' => '1', + 'banner_version' => '1', + ), + Cookie_Consent::get_log_versions() + ); + } + + /** + * Configured log versions are returned from the cookie consent config filter. + */ + public function test_get_log_versions_returns_filtered_overrides() { + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = 'policy-2026-06'; + $config['log']['banner_version'] = 'banner-2026-06'; + + return $config; + } + ); + + $this->assertSame( + array( + 'policy_version' => 'policy-2026-06', + 'banner_version' => 'banner-2026-06', + ), + Cookie_Consent::get_log_versions() + ); + } + + /** + * Scalar log versions are normalized to non-empty strings. + */ + public function test_get_log_versions_normalizes_scalar_values() { + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = 202606; + $config['log']['banner_version'] = ' banner-v2 '; + + return $config; + } + ); + + $this->assertSame( + array( + 'policy_version' => '202606', + 'banner_version' => 'banner-v2', + ), + Cookie_Consent::get_log_versions() + ); + } + + /** + * Invalid log version values fall back to the default version and surface a diagnostic. + */ + public function test_get_log_versions_defaults_invalid_values() { + $this->capture_doing_it_wrong(); + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = array( 'policy-2026-06' ); + $config['log']['banner_version'] = new \stdClass(); + + return $config; + } + ); + + $this->assertSame( + array( + 'policy_version' => '1', + 'banner_version' => '1', + ), + Cookie_Consent::get_log_versions() + ); + + // Both dropped values are surfaced rather than silently defaulted. + $this->assertCount( 2, $this->doing_it_wrong ); + } + + /** + * Empty log version values fall back to the default version and surface a diagnostic. + */ + public function test_get_log_versions_defaults_empty_values() { + $this->capture_doing_it_wrong(); + add_filter( + 'jetpack_cookie_consent_config', + static function ( $config ) { + $config['log']['policy_version'] = ''; + $config['log']['banner_version'] = " \t "; + + return $config; + } + ); + + $this->assertSame( + array( + 'policy_version' => '1', + 'banner_version' => '1', + ), + Cookie_Consent::get_log_versions() + ); + + // Both dropped values are surfaced rather than silently defaulted. + $this->assertCount( 2, $this->doing_it_wrong ); + } +}