Commit e456dee2dc for woocommerce
commit e456dee2dcb74a62b051b14aeaf6dbba9d4cdeba
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date: Sat Dec 20 17:15:15 2025 +0100
Add SessionClearanceManager for fraud protection (#62522)
* Add SessionClearanceManager for fraud protection
Implements session status management for the WooCommerce fraud protection
feature. This class tracks three session states (pending, allowed, blocked)
and provides the foundation for fraud detection decision enforcement.
Key features:
- Session status management (pending, allowed, blocked)
- Cart emptying on blocked sessions
- Logging via FraudProtectionController helper
- DEFAULT_STATUS constant for consistent default behavior
- Comprehensive unit tests (9 tests, 20 assertions)
Closes WOOSUBS-1246
* Remove stale PHPStan baseline entry for deleted AccessiblePrivateMethods trait
The AccessiblePrivateMethods trait was removed in commit 7aeb150bfa, but the
PHPStan baseline still contained an ignore entry for this file, causing
PHPStan to fail with "Path is neither a directory, nor a file path" error.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index bc4ddc74e8..bd66679524 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -79974,12 +79974,6 @@ parameters:
count: 1
path: src/Internal/StockNotifications/StockSyncController.php
- -
- message: '#^Trait Automattic\\WooCommerce\\Internal\\Traits\\AccessiblePrivateMethods is used zero times and is not analysed\.$#'
- identifier: trait.unused
- count: 1
- path: src/Internal/Traits/AccessiblePrivateMethods.php
-
-
message: '#^Cannot call method format\(\) on DateTime\|false\.$#'
identifier: method.nonObject
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
new file mode 100644
index 0000000000..006e2fa6a3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/FraudProtection/SessionClearanceManager.php
@@ -0,0 +1,234 @@
+<?php
+/**
+ * SessionClearanceManager class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\FraudProtection;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Manages session clearance state for fraud protection.
+ *
+ * This class handles the session status tracking for fraud protection decisions,
+ * managing three possible states: pending, allowed, and blocked. It integrates
+ * with WooCommerce sessions and uses the FraudProtectionController logging helper
+ * to maintain consistent audit logs.
+ *
+ * @since 10.5.0
+ * @internal This class is part of the internal API and is subject to change without notice.
+ */
+class SessionClearanceManager {
+
+ /**
+ * Session key for storing clearance status.
+ */
+ private const SESSION_KEY = '_fraud_protection_clearance_status';
+
+ /**
+ * Session status: pending clearance.
+ */
+ public const STATUS_PENDING = 'pending';
+
+ /**
+ * Session status: allowed.
+ */
+ public const STATUS_ALLOWED = 'allowed';
+
+ /**
+ * Session status: blocked.
+ */
+ public const STATUS_BLOCKED = 'blocked';
+
+ /**
+ * Default session status.
+ */
+ public const DEFAULT_STATUS = self::STATUS_ALLOWED;
+
+ /**
+ * Check if the current session is allowed.
+ *
+ * @return bool True if session is allowed, false otherwise.
+ */
+ public function is_session_allowed(): bool {
+ $status = $this->get_session_status();
+ return self::STATUS_ALLOWED === $status;
+ }
+
+ /**
+ * Check if the current session is blocked.
+ *
+ * @return bool True if session is blocked, false otherwise.
+ */
+ public function is_session_blocked(): bool {
+ $status = $this->get_session_status();
+ return self::STATUS_BLOCKED === $status;
+ }
+
+ /**
+ * Mark the current session as allowed.
+ *
+ * @return void
+ */
+ public function allow_session(): void {
+ $this->set_session_status( self::STATUS_ALLOWED );
+ $this->log_session_update_event( 'allowed' );
+ }
+
+ /**
+ * Mark the current session as pending (challenge required).
+ *
+ * @return void
+ */
+ public function challenge_session(): void {
+ $this->set_session_status( self::STATUS_PENDING );
+ $this->log_session_update_event( 'challenged' );
+ }
+
+ /**
+ * Mark the current session as blocked.
+ *
+ * @return void
+ */
+ public function block_session(): void {
+ $this->set_session_status( self::STATUS_BLOCKED );
+ $this->log_session_update_event( 'blocked' );
+ $this->empty_cart();
+ }
+
+ /**
+ * Get the current session clearance status.
+ *
+ * @return string One of: pending, allowed, blocked.
+ */
+ public function get_session_status(): string {
+ if ( ! $this->is_session_available() ) {
+ return self::DEFAULT_STATUS;
+ }
+
+ $status = WC()->session->get( self::SESSION_KEY, self::DEFAULT_STATUS );
+
+ // Validate status value - return default for invalid values.
+ if ( ! in_array( $status, array( self::STATUS_PENDING, self::STATUS_ALLOWED, self::STATUS_BLOCKED ), true ) ) {
+ return self::DEFAULT_STATUS;
+ }
+
+ return $status;
+ }
+
+ /**
+ * Set the session clearance status.
+ *
+ * @param string $status One of: pending, allowed, blocked.
+ * @return void
+ */
+ private function set_session_status( string $status ): void {
+ if ( ! $this->is_session_available() ) {
+ return;
+ }
+
+ WC()->session->set( self::SESSION_KEY, $status );
+
+ // Ensure session cookie is set so the session persists across page loads.
+ // This is important because fraud protection may set session status before
+ // any cart action triggers the cookie to be set.
+ if ( WC()->session instanceof \WC_Session_Handler ) {
+ WC()->session->set_customer_session_cookie( true );
+ }
+ }
+
+ /**
+ * Reset the session clearance status to default (allowed).
+ *
+ * @return void
+ */
+ public function reset_session(): void {
+ $this->set_session_status( self::DEFAULT_STATUS );
+ }
+
+ /**
+ * Ensure cart and session are available.
+ *
+ * Loads cart if not already loaded, which initializes session for both
+ * traditional (cookie) and Store API (token) flows.
+ *
+ * @return void
+ */
+ private function ensure_cart_loaded(): void {
+ if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
+ WC()->call_function( 'wc_load_cart' );
+ }
+ }
+
+ /**
+ * Check if WooCommerce session is available.
+ *
+ * @return bool True if session is available.
+ */
+ private function is_session_available(): bool {
+ $this->ensure_cart_loaded();
+ return WC()->session instanceof \WC_Session;
+ }
+
+ /**
+ * Get a unique identifier for the current session.
+ *
+ * @return string Session identifier.
+ */
+ private function get_session_id(): string {
+ if ( ! $this->is_session_available() ) {
+ return 'no-session';
+ }
+
+ // Use WooCommerce session customer ID.
+ $customer_id = WC()->session->get_customer_id();
+
+ if ( $customer_id ) {
+ return $customer_id;
+ }
+
+ // Fallback: use or generate a stable guest session ID for tracking consistency.
+ $guest_session_id = WC()->session->get( '_fraud_protection_guest_session_id' );
+ if ( ! $guest_session_id ) {
+ $guest_session_id = WC()->call_function( 'wc_rand_hash', 'guest_', 30 );
+ WC()->session->set( '_fraud_protection_guest_session_id', $guest_session_id );
+ }
+ return $guest_session_id;
+ }
+
+ /**
+ * Empty the cart.
+ *
+ * @return void
+ */
+ private function empty_cart(): void {
+ if ( function_exists( 'WC' ) && WC()->cart ) {
+ WC()->cart->empty_cart();
+ }
+ }
+
+ /**
+ * Log a session update event using FraudProtectionController's logging helper.
+ *
+ * @param string $action The action taken (allowed, challenged, or blocked).
+ * @return void
+ */
+ private function log_session_update_event( string $action ): void {
+ $session_id = $this->get_session_id();
+ $user_id = get_current_user_id();
+ $user_info = $user_id ? "User: {$user_id}" : 'User: guest';
+ $timestamp = current_time( 'mysql' );
+
+ $message = sprintf(
+ 'Session updated: %s | %s | Action: %s | Timestamp: %s',
+ $session_id,
+ $user_info,
+ $action,
+ $timestamp
+ );
+
+ FraudProtectionController::log( 'info', $message );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/SessionClearanceManagerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/SessionClearanceManagerTest.php
new file mode 100644
index 0000000000..57aebb2ef3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/SessionClearanceManagerTest.php
@@ -0,0 +1,146 @@
+<?php
+/**
+ * SessionClearanceManagerTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+
+/**
+ * Tests for SessionClearanceManager.
+ *
+ * @covers \Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager
+ */
+class SessionClearanceManagerTest extends \WC_Unit_Test_Case {
+
+ /**
+ * The system under test.
+ *
+ * @var SessionClearanceManager
+ */
+ private $sut;
+
+ /**
+ * Runs before each test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new SessionClearanceManager();
+
+ // Ensure WooCommerce cart and session are available.
+ if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
+ wc_load_cart();
+ }
+ }
+
+ /**
+ * Test that session status constants are defined correctly.
+ */
+ public function test_session_status_constants() {
+ $this->assertEquals( 'pending', SessionClearanceManager::STATUS_PENDING );
+ $this->assertEquals( 'allowed', SessionClearanceManager::STATUS_ALLOWED );
+ $this->assertEquals( 'blocked', SessionClearanceManager::STATUS_BLOCKED );
+ $this->assertEquals( SessionClearanceManager::STATUS_ALLOWED, SessionClearanceManager::DEFAULT_STATUS );
+ }
+
+ /**
+ * Test default session status when session is not available.
+ */
+ public function test_default_session_status_without_session() {
+ // If session is not available, should return DEFAULT_STATUS.
+ $status = $this->sut->get_session_status();
+ $this->assertEquals( SessionClearanceManager::DEFAULT_STATUS, $status );
+ }
+
+ /**
+ * Test that is_session_allowed returns true for allowed status.
+ */
+ public function test_is_session_allowed_returns_true_for_allowed() {
+ $this->sut->allow_session();
+ $this->assertTrue( $this->sut->is_session_allowed() );
+ $this->assertFalse( $this->sut->is_session_blocked() );
+ }
+
+ /**
+ * Test that pending session is neither allowed nor blocked.
+ */
+ public function test_is_session_allowed_returns_false_for_pending() {
+ $this->sut->challenge_session();
+ $this->assertFalse( $this->sut->is_session_allowed() );
+ $this->assertFalse( $this->sut->is_session_blocked() );
+ }
+
+ /**
+ * Test blocked status.
+ */
+ public function test_is_session_allowed_returns_false_for_blocked() {
+ $this->sut->block_session();
+ $this->assertFalse( $this->sut->is_session_allowed() );
+ $this->assertTrue( $this->sut->is_session_blocked() );
+ }
+
+ /**
+ * Test block_session empties the cart.
+ */
+ public function test_block_session_empties_cart() {
+ // Add item to cart.
+ $product = \WC_Helper_Product::create_simple_product();
+ WC()->cart->add_to_cart( $product->get_id(), 1 );
+ $this->assertGreaterThan( 0, WC()->cart->get_cart_contents_count() );
+
+ // Block session should empty cart.
+ $this->sut->block_session();
+ $this->assertEquals( 0, WC()->cart->get_cart_contents_count() );
+
+ // Clean up.
+ $product->delete( true );
+ }
+
+ /**
+ * Test reset_session sets status to DEFAULT_STATUS.
+ */
+ public function test_reset_session_sets_status_to_default() {
+ // Set to blocked first.
+ $this->sut->block_session();
+ $this->assertEquals( SessionClearanceManager::STATUS_BLOCKED, $this->sut->get_session_status() );
+
+ // Reset should go back to DEFAULT_STATUS.
+ $this->sut->reset_session();
+ $this->assertEquals( SessionClearanceManager::DEFAULT_STATUS, $this->sut->get_session_status() );
+ }
+
+ /**
+ * Test session status transitions.
+ */
+ public function test_session_status_transitions() {
+ // Start with allowed.
+ $this->sut->allow_session();
+ $this->assertEquals( SessionClearanceManager::STATUS_ALLOWED, $this->sut->get_session_status() );
+
+ // Transition to pending.
+ $this->sut->challenge_session();
+ $this->assertEquals( SessionClearanceManager::STATUS_PENDING, $this->sut->get_session_status() );
+
+ // Transition to blocked.
+ $this->sut->block_session();
+ $this->assertEquals( SessionClearanceManager::STATUS_BLOCKED, $this->sut->get_session_status() );
+
+ // Transition back to allowed.
+ $this->sut->allow_session();
+ $this->assertEquals( SessionClearanceManager::STATUS_ALLOWED, $this->sut->get_session_status() );
+ }
+
+ /**
+ * Test get_session_status returns default status for invalid stored values.
+ */
+ public function test_get_session_status_returns_default_status_for_invalid_values() {
+ // Set an invalid value directly in session.
+ WC()->session->set( '_fraud_protection_clearance_status', 'invalid_status' );
+
+ // Should return default status for invalid values.
+ $this->assertEquals( SessionClearanceManager::DEFAULT_STATUS, $this->sut->get_session_status() );
+ }
+}