Commit 4fa8641b52 for woocommerce

commit 4fa8641b5230f43fd7e3fc9342171bae17b806c9
Author: Luiz Reis <luiz.reis@automattic.com>
Date:   Tue Jan 6 17:23:00 2026 -0300

    [Fraud Protection] Add Decision Handler with override filter (#62687)

    * Rename "verdict" to "decision" to improve clarity

    * Rename `ApiClient::track_event` to `send_event` to better match its behavior and context

    * Add `DecisionHandler` class to validate and apply decisions to the session

diff --git a/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php b/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
index ee3992205a..5fda1e85fd 100644
--- a/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
+++ b/plugins/woocommerce/src/Internal/FraudProtection/ApiClient.php
@@ -15,7 +15,7 @@ defined( 'ABSPATH' ) || exit;
  * Handles communication with the WPCOM fraud protection endpoint.
  *
  * Uses Jetpack Connection for authenticated requests to the WPCOM endpoint
- * to get fraud protection verdicts (allow, block, or challenge).
+ * to get fraud protection decisions (allow, block, or challenge).
  *
  * This class implements a fail-open pattern: if the endpoint is unreachable,
  * times out, or returns an error, it returns an "allow" decision to ensure
@@ -57,29 +57,28 @@ class ApiClient {
 	public const DECISION_CHALLENGE = 'challenge';

 	/**
-	 * Valid decision values.
+	 * Valid decision values that can be returned by the API.
 	 *
 	 * @var array<string>
 	 */
-	private const VALID_DECISIONS = array(
+	public const VALID_DECISIONS = array(
 		self::DECISION_ALLOW,
 		self::DECISION_BLOCK,
-		// Note: self::DECISION_CHALLENGE will be added here after the challenge flow is implemented (stretch goal for alpha).
 	);

 	/**
-	 * Track a fraud protection event and get a decision from WPCOM endpoint.
+	 * Send a fraud protection event and get a decision from WPCOM endpoint.
 	 *
 	 * Implements fail-open pattern: if the endpoint is unreachable or times out,
 	 * returns "allow" decision and logs the error.
 	 *
 	 * @since 10.5.0
 	 *
-	 * @param string               $event_type   Type of event being tracked (e.g., 'cart_updated', 'checkout_started').
+	 * @param string               $event_type   Type of event being sent (e.g., 'cart_updated', 'checkout_started').
 	 * @param array<string, mixed> $session_data Session data to send to the endpoint.
 	 * @return string Decision: "allow" or "block".
 	 */
-	public function track_event( string $event_type, array $session_data ): string {
+	public function send_event( string $event_type, array $session_data ): string {
 		$payload = array_merge(
 			array( 'event_type' => $event_type ),
 			array_filter( $session_data, fn( $value ) => null !== $value )
@@ -107,23 +106,23 @@ class ApiClient {
 			return self::DECISION_ALLOW;
 		}

-		if ( ! isset( $response['verdict'] ) ) {
+		if ( ! isset( $response['decision'] ) ) {
 			FraudProtectionController::log(
 				'error',
-				'Response missing "verdict" field. Failing open with "allow" decision.',
+				'Response missing "decision" field. Failing open with "allow" decision.',
 				array( 'response' => $response )
 			);
 			return self::DECISION_ALLOW;
 		}

-		$verdict = $response['verdict'];
+		$decision = $response['decision'];

-		if ( ! in_array( $verdict, self::VALID_DECISIONS, true ) ) {
+		if ( ! in_array( $decision, self::VALID_DECISIONS, true ) ) {
 			FraudProtectionController::log(
 				'error',
 				sprintf(
-					'Invalid verdict value "%s". Failing open with "allow" decision.',
-					$verdict
+					'Invalid decision value "%s". Failing open with "allow" decision.',
+					$decision
 				),
 				array( 'response' => $response )
 			);
@@ -134,15 +133,15 @@ class ApiClient {
 		FraudProtectionController::log(
 			'info',
 			sprintf(
-				'Fraud verdict received: %s | Event: %s | Session: %s',
-				$verdict,
+				'Fraud decision received: %s | Event: %s | Session: %s',
+				$decision,
 				$event_type,
 				$session_id
 			),
 			array( 'response' => $response )
 		);

-		return $verdict;
+		return $decision;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
new file mode 100644
index 0000000000..46d5ab3a11
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/FraudProtection/DecisionHandler.php
@@ -0,0 +1,156 @@
+<?php
+/**
+ * DecisionHandler class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\FraudProtection;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Handles fraud protection decision application.
+ *
+ * This class is responsible for:
+ * - Applying extension override filters for whitelisting
+ * - Coordinating with SessionClearanceManager to apply decisions
+ *
+ * @since 10.5.0
+ * @internal This class is part of the internal API and is subject to change without notice.
+ */
+class DecisionHandler {
+
+	/**
+	 * Session clearance manager instance.
+	 *
+	 * @var SessionClearanceManager
+	 */
+	private SessionClearanceManager $session_manager;
+
+	/**
+	 * Initialize with dependencies.
+	 *
+	 * @internal
+	 *
+	 * @param SessionClearanceManager $session_manager The session clearance manager instance.
+	 */
+	final public function init( SessionClearanceManager $session_manager ): void {
+		$this->session_manager = $session_manager;
+	}
+
+	/**
+	 * Apply a fraud protection decision.
+	 *
+	 * This method processes a decision from the API, applies any override filters,
+	 * validates the result, and updates the session status accordingly.
+	 *
+	 * The input decision is expected to be pre-validated by ApiClient.
+	 *
+	 * The decision flow:
+	 * 1. Apply the `woocommerce_fraud_protection_decision` filter for overrides
+	 * 2. Validate the filtered decision (third-party filters may return invalid values)
+	 * 3. Update session status via SessionClearanceManager
+	 *
+	 * @since 10.5.0
+	 *
+	 * @param string               $decision     The decision from the API (allow, block).
+	 * @param array<string, mixed> $session_data The session data that was sent to the API.
+	 * @return string The final applied decision after any filter overrides.
+	 */
+	public function apply_decision( string $decision, array $session_data ): string {
+		// Validate input decision and fail open if invalid.
+		if ( ! $this->is_valid_decision( $decision ) ) {
+			FraudProtectionController::log(
+				'warning',
+				sprintf( 'Invalid decision "%s" received. Defaulting to "allow".', $decision ),
+				array( 'session_data' => $session_data )
+			);
+			$decision = ApiClient::DECISION_ALLOW;
+		}
+
+		$original_decision = $decision;
+
+		/**
+		 * Filters the fraud protection decision before it is applied.
+		 *
+		 * This filter allows extensions to override fraud protection decisions
+		 * to implement custom whitelisting logic. Common use cases:
+		 * - Whitelist specific users (e.g., admins, trusted customers)
+		 * - Whitelist specific conditions (e.g., certain IP ranges, logged-in users)
+		 * - Integrate with external fraud detection services
+		 *
+		 * Note: This filter can only change the decision to ApiClient::VALID_DECISIONS.
+		 * Any other value will be rejected and the original decision will be used.
+		 *
+		 * @since 10.5.0
+		 *
+		 * @param string               $decision     The decision from the API (allow, block).
+		 * @param array<string, mixed> $session_data The session data that was analyzed.
+		 */
+		$decision = apply_filters( 'woocommerce_fraud_protection_decision', $decision, $session_data );
+
+		// Validate filtered decision (third-party filters may return invalid values).
+		if ( ! $this->is_valid_decision( $decision ) ) {
+			FraudProtectionController::log(
+				'warning',
+				sprintf( 'Filter `woocommerce_fraud_protection_decision` returned invalid decision "%s". Using original decision "%s".', $decision, $original_decision ),
+				array(
+					'original_decision' => $original_decision,
+					'filtered_decision' => $decision,
+					'session_data'      => $session_data,
+				)
+			);
+			$decision = $original_decision;
+		}
+
+		// Log if decision was overridden.
+		if ( $decision !== $original_decision ) {
+			FraudProtectionController::log(
+				'info',
+				sprintf( 'Decision overridden by filter `woocommerce_fraud_protection_decision`: "%s" -> "%s"', $original_decision, $decision ),
+				array(
+					'original_decision' => $original_decision,
+					'final_decision'    => $decision,
+					'session_data'      => $session_data,
+				)
+			);
+		}
+
+		// Apply the decision to the session.
+		$this->update_session_status( $decision );
+
+		return $decision;
+	}
+
+	/**
+	 * Check if a decision value is valid.
+	 *
+	 * @param mixed $decision The decision to validate.
+	 * @return bool True if valid, false otherwise.
+	 */
+	private function is_valid_decision( $decision ): bool {
+		if ( ! is_string( $decision ) ) {
+			return false;
+		}
+		return in_array( $decision, ApiClient::VALID_DECISIONS, true );
+	}
+
+	/**
+	 * Update the session status based on the decision.
+	 *
+	 * @param string $decision The validated decision to apply.
+	 * @return void
+	 */
+	private function update_session_status( string $decision ): void {
+		switch ( $decision ) {
+			case ApiClient::DECISION_ALLOW:
+				$this->session_manager->allow_session();
+				break;
+
+			case ApiClient::DECISION_BLOCK:
+				$this->session_manager->block_session();
+				break;
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php
index d0b7bdc1c3..d5ef207f49 100644
--- a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/ApiClientTest.php
@@ -49,21 +49,21 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when Jetpack blog ID is not found.
+	 * @testdox Send Event should return allow when Jetpack blog ID is not found.
 	 */
-	public function test_track_event_returns_allow_when_blog_id_not_found(): void {
+	public function test_send_event_returns_allow_when_blog_id_not_found(): void {
 		update_option( 'jetpack_options', array( 'id' => null ) );

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged( 'error', 'Jetpack blog ID not found', array( 'source' => 'woo-fraud-protection' ) );
 	}

 	/**
-	 * @testdox Track Event should return allow when HTTP request fails.
+	 * @testdox Send Event should return allow when HTTP request fails.
 	 */
-	public function test_track_event_returns_allow_when_http_request_fails(): void {
+	public function test_send_event_returns_allow_when_http_request_fails(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -71,7 +71,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
@@ -85,9 +85,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API returns HTTP error status.
+	 * @testdox Send Event should return allow when API returns HTTP error status.
 	 */
-	public function test_track_event_returns_allow_when_api_returns_http_error(): void {
+	public function test_send_event_returns_allow_when_api_returns_http_error(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -98,7 +98,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
@@ -112,9 +112,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API returns HTTP error with JSON body.
+	 * @testdox Send Event should return allow when API returns HTTP error with JSON body.
 	 */
-	public function test_track_event_returns_allow_when_api_returns_http_error_with_json_body(): void {
+	public function test_send_event_returns_allow_when_api_returns_http_error_with_json_body(): void {
 		$response = array(
 			'error'   => 'invalid_request',
 			'message' => 'Missing required field',
@@ -130,7 +130,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
@@ -144,9 +144,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API returns invalid JSON.
+	 * @testdox Send Event should return allow when API returns invalid JSON.
 	 */
-	public function test_track_event_returns_allow_when_api_returns_invalid_json(): void {
+	public function test_send_event_returns_allow_when_api_returns_invalid_json(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -157,7 +157,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
@@ -171,9 +171,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API response is missing verdict field.
+	 * @testdox Send Event should return allow when API response is missing decision field.
 	 */
-	public function test_track_event_returns_allow_when_response_missing_verdict_field(): void {
+	public function test_send_event_returns_allow_when_response_missing_decision_field(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -184,12 +184,12 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
 			'error',
-			'missing "verdict" field',
+			'missing "decision" field',
 			array(
 				'source'   => 'woo-fraud-protection',
 				'response' => array( 'fraud_event_id' => 123 ),
@@ -198,36 +198,36 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API returns invalid verdict value.
+	 * @testdox Send Event should return allow when API returns invalid decision value.
 	 */
-	public function test_track_event_returns_allow_when_invalid_verdict_value(): void {
+	public function test_send_event_returns_allow_when_invalid_decision_value(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
 				return array(
 					'response' => array( 'code' => 200 ),
-					'body'     => wp_json_encode( array( 'verdict' => 'invalid_verdict' ) ),
+					'body'     => wp_json_encode( array( 'decision' => 'invalid_decision' ) ),
 				);
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow decision' );
 		$this->assertLogged(
 			'error',
-			'Invalid verdict value "invalid_verdict"',
+			'Invalid decision value "invalid_decision"',
 			array(
 				'source'   => 'woo-fraud-protection',
-				'response' => array( 'verdict' => 'invalid_verdict' ),
+				'response' => array( 'decision' => 'invalid_decision' ),
 			)
 		);
 	}

 	/**
-	 * @testdox Track Event should return allow verdict from API.
+	 * @testdox Send Event should return allow decision from API.
 	 */
-	public function test_track_event_returns_allow_verdict_from_api(): void {
+	public function test_send_event_returns_allow_decision_from_api(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -236,7 +236,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 					'body'     => wp_json_encode(
 						array(
 							'fraud_event_id' => 123,
-							'verdict'        => 'allow',
+							'decision'       => 'allow',
 							'risk_score'     => 10,
 						)
 					),
@@ -244,17 +244,17 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'cart_updated', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'cart_updated', array( 'session_id' => 'test-session' ) );

-		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should return allow verdict' );
+		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should return allow decision' );
 		$this->assertLogged(
 			'info',
-			'Fraud verdict received: allow',
+			'Fraud decision received: allow',
 			array(
 				'source'   => 'woo-fraud-protection',
 				'response' => array(
 					'fraud_event_id' => 123,
-					'verdict'        => 'allow',
+					'decision'       => 'allow',
 					'risk_score'     => 10,
 				),
 			)
@@ -263,9 +263,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return block verdict from API.
+	 * @testdox Send Event should return block decision from API.
 	 */
-	public function test_track_event_returns_block_verdict_from_api(): void {
+	public function test_send_event_returns_block_decision_from_api(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -274,7 +274,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 					'body'     => wp_json_encode(
 						array(
 							'fraud_event_id' => 123,
-							'verdict'        => 'block',
+							'decision'       => 'block',
 							'risk_score'     => 95,
 							'reason_tags'    => array( 'failures_per_ip' ),
 						)
@@ -283,17 +283,17 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'checkout_started', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'checkout_started', array( 'session_id' => 'test-session' ) );

-		$this->assertSame( ApiClient::DECISION_BLOCK, $result, 'Should return block verdict' );
+		$this->assertSame( ApiClient::DECISION_BLOCK, $result, 'Should return block decision' );
 		$this->assertLogged(
 			'info',
-			'Fraud verdict received: block',
+			'Fraud decision received: block',
 			array(
 				'source'   => 'woo-fraud-protection',
 				'response' => array(
 					'fraud_event_id' => 123,
-					'verdict'        => 'block',
+					'decision'       => 'block',
 					'risk_score'     => 95,
 					'reason_tags'    => array( 'failures_per_ip' ),
 				),
@@ -303,9 +303,9 @@ class ApiClientTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Track Event should return allow when API returns challenge verdict (challenge flow not yet implemented).
+	 * @testdox Send Event should return allow when API returns challenge decision (challenge flow not yet implemented).
 	 */
-	public function test_track_event_returns_allow_when_challenge_verdict_from_api(): void {
+	public function test_send_event_returns_allow_when_challenge_decision_from_api(): void {
 		add_filter(
 			'pre_http_request',
 			function () {
@@ -314,7 +314,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 					'body'     => wp_json_encode(
 						array(
 							'fraud_event_id' => 123,
-							'verdict'        => 'challenge',
+							'decision'       => 'challenge',
 							'risk_score'     => 65,
 						)
 					),
@@ -322,10 +322,10 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			}
 		);

-		$result = $this->sut->track_event( 'checkout_started', array( 'session_id' => 'test-session' ) );
+		$result = $this->sut->send_event( 'checkout_started', array( 'session_id' => 'test-session' ) );

 		$this->assertSame( ApiClient::DECISION_ALLOW, $result, 'Should fail open with allow until challenge flow is implemented' );
-		$this->assertLogged( 'error', 'Invalid verdict value "challenge"' );
+		$this->assertLogged( 'error', 'Invalid decision value "challenge"' );
 	}

 	/**
@@ -340,7 +340,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 				$captured_request_body = $args['body'];
 				return array(
 					'response' => array( 'code' => 200 ),
-					'body'     => wp_json_encode( array( 'verdict' => 'allow' ) ),
+					'body'     => wp_json_encode( array( 'decision' => 'allow' ) ),
 				);
 			},
 			10,
@@ -355,7 +355,7 @@ class ApiClientTest extends WC_Unit_Test_Case {
 			'billing_name' => null,
 		);

-		$this->sut->track_event( 'cart_updated', $session_data );
+		$this->sut->send_event( 'cart_updated', $session_data );

 		$this->assertNotNull( $captured_request_body, 'Request body should be captured' );

diff --git a/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
new file mode 100644
index 0000000000..22e37ed73a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/FraudProtection/DecisionHandlerTest.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * DecisionHandlerTest class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\FraudProtection;
+
+use Automattic\WooCommerce\Internal\FraudProtection\ApiClient;
+use Automattic\WooCommerce\Internal\FraudProtection\DecisionHandler;
+use Automattic\WooCommerce\Internal\FraudProtection\SessionClearanceManager;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the DecisionHandler class.
+ */
+class DecisionHandlerTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var DecisionHandler
+	 */
+	private $sut;
+
+	/**
+	 * Mock session clearance manager.
+	 *
+	 * @var SessionClearanceManager
+	 */
+	private $session_manager;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->session_manager = $this->createMock( SessionClearanceManager::class );
+		$this->sut             = new DecisionHandler();
+		$this->sut->init( $this->session_manager );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_fraud_protection_decision' );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should apply allow decision and update session to allowed.
+	 */
+	public function test_apply_allow_decision(): void {
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'allow_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_ALLOW, array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_ALLOW, $result );
+	}
+
+	/**
+	 * @testdox Should apply block decision and update session to blocked.
+	 */
+	public function test_apply_block_decision(): void {
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'block_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_BLOCK, array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_BLOCK, $result );
+	}
+
+	/**
+	 * @testdox Should default to allow for invalid decision and log warning.
+	 */
+	public function test_invalid_decision_defaults_to_allow(): void {
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'allow_session' );
+
+		$result = $this->sut->apply_decision( 'invalid_decision', array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_ALLOW, $result );
+		$this->assertLogged( 'warning', 'Invalid decision "invalid_decision" received' );
+	}
+
+	/**
+	 * @testdox Should allow filter to override decision from block to allow.
+	 */
+	public function test_filter_can_override_block_to_allow(): void {
+		add_filter(
+			'woocommerce_fraud_protection_decision',
+			function () {
+				return ApiClient::DECISION_ALLOW;
+			}
+		);
+
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'allow_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_BLOCK, array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_ALLOW, $result );
+		$this->assertLogged( 'info', 'Decision overridden by filter `woocommerce_fraud_protection_decision`' );
+	}
+
+	/**
+	 * @testdox Should allow filter to override decision from allow to block.
+	 */
+	public function test_filter_can_override_allow_to_block(): void {
+		add_filter(
+			'woocommerce_fraud_protection_decision',
+			function () {
+				return ApiClient::DECISION_BLOCK;
+			}
+		);
+
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'block_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_ALLOW, array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_BLOCK, $result );
+		$this->assertLogged( 'info', 'Decision overridden by filter `woocommerce_fraud_protection_decision`' );
+	}
+
+	/**
+	 * @testdox Should reject invalid filter return value and use original decision.
+	 */
+	public function test_filter_invalid_return_uses_original_decision(): void {
+		add_filter(
+			'woocommerce_fraud_protection_decision',
+			function () {
+				return 'totally_invalid';
+			}
+		);
+
+		$this->session_manager
+			->expects( $this->once() )
+			->method( 'block_session' );
+
+		$result = $this->sut->apply_decision( ApiClient::DECISION_BLOCK, array( 'session_id' => 'test' ) );
+
+		$this->assertSame( ApiClient::DECISION_BLOCK, $result );
+		$this->assertLogged( 'warning', 'Filter `woocommerce_fraud_protection_decision` returned invalid decision "totally_invalid"' );
+	}
+}