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"' );
+ }
+}