Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Consent log: Record policy and banner versions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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' ),
Expand Down Expand Up @@ -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,
Expand All @@ -632,14 +633,16 @@ 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,
);

$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 ) {
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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',
Expand Down
47 changes: 47 additions & 0 deletions projects/packages/cookie-consent/src/class-cookie-consent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -707,6 +722,34 @@ public static function get_default_copy() {
);
}
Comment thread
chihsuan marked this conversation as resolved.

/**
* 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.
*
Expand Down Expand Up @@ -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,
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php
/**
* Tests for Consent_Log_Controller log-version handling.
*
* @package automattic/jetpack-cookie-consent
*/

namespace Automattic\Jetpack\CookieConsent;

use PHPUnit\Framework\Attributes\CoversMethod;
use ReflectionMethod;

/**
* @covers \Automattic\Jetpack\CookieConsent\Consent_Log_Controller::get_log_versions
* @covers \Automattic\Jetpack\CookieConsent\Consent_Log_Controller::truncate_log_version
*/
#[CoversMethod( Consent_Log_Controller::class, 'get_log_versions' )]
#[CoversMethod( Consent_Log_Controller::class, 'truncate_log_version' )]
class Consent_Log_Controller_Log_Versions_Test extends TestCase {

/**
* Tear down: clear cookie-consent config filters.
*/
public function tearDown(): void {
remove_all_filters( 'jetpack_cookie_consent_config' );
parent::tearDown();
}

/**
* Invoke a private method on a fresh controller instance.
*
* @param string $method Method name.
* @param mixed ...$args Arguments.
* @return mixed
*/
private function invoke( $method, ...$args ) {
$controller = new Consent_Log_Controller();
$reflection = new ReflectionMethod( Consent_Log_Controller::class, $method );
if ( PHP_VERSION_ID < 80100 ) {
$reflection->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'] ) );
}
}
Loading
Loading