diff --git a/composer.json b/composer.json
index c28f6b2..5e8c8ff 100644
--- a/composer.json
+++ b/composer.json
@@ -14,8 +14,9 @@
}
],
"require": {
- "php": ">=5.5.5",
- "illuminate/support": "5.*|6.*|7.*|8.*|^9.0|10.*|^11.0|^12.0"
+ "php": "^8.2",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "guzzlehttp/guzzle": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "~4.8|^9.5.10|^10.0|^11.0"
@@ -25,6 +26,14 @@
"Scyllaly\\HCaptcha\\": "src/"
}
},
+ "autoload-dev": {
+ "psr-4": {
+ "Scyllaly\\HCaptcha\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit"
+ },
"extra": {
"laravel": {
"providers": [
diff --git a/phpunit.xml b/phpunit.xml
index 3347b75..4c2aa0d 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,18 +1,12 @@
-
-
- ./tests/
+
+ tests
diff --git a/src/HCaptcha.php b/src/HCaptcha.php
index f6bed5b..1350216 100644
--- a/src/HCaptcha.php
+++ b/src/HCaptcha.php
@@ -12,80 +12,69 @@ class HCaptcha
/**
* The hCaptcha secret key.
- *
- * @var string
*/
- protected $secret;
+ protected string $secret;
/**
* The hCaptcha sitekey key.
- *
- * @var string
*/
- protected $sitekey;
+ protected string $sitekey;
/**
- * @var \GuzzleHttp\Client
+ * The HTTP client used to send requests.
*/
- protected $http;
+ protected \GuzzleHttp\Client $http;
/**
* The cached verified responses.
- *
- * @var array
*/
- protected $verifiedResponses = [];
+ protected array $verifiedResponses = [];
/**
- * @var null
* lastScore
*/
- protected $lastScore = null;
+ protected ?float $lastScore = null;
/**
* Whether to use hCaptcha or not.
- *
- * @var bool
*/
- protected $enabled;
+ protected bool $enabled;
/**
* Cache of response verifications with timestamps
- * @var array
*/
- protected $responseCache = [];
+ protected array $responseCache = [];
/**
* Cache duration in seconds
- * @var int
*/
- protected $cacheDuration = 120;
+ protected int $cacheDuration = 120;
/**
* HCaptcha constructor.
- *
- * @param string $secret
- * @param string $sitekey
- * @param array $options
- * @param bool $enabled
*/
- public function __construct($secret, $sitekey, $options = [], $enabled = true)
+ public function __construct(string $secret, string $sitekey, array $options = [], bool $enabled = true)
{
$this->secret = $secret;
$this->sitekey = $sitekey;
$this->http = new Client($options);
$this->enabled = $enabled;
}
-
/**
- * Get the hCaptcha verification details for a given response
+ * Get the score from the last successful hCaptcha verification.
*
- * @param string $response
- * @param string|null $clientIp
- * @return array
+ * @return float|null The score of the last verification or null if not available.
+ */
+ public function getScoreFromLastVerification()
+ {
+ return $this->lastScore;
+ }
+
+ /**
+ * Get the hCaptcha verification details for a given response
*/
- public function getResponseDetails(string $response, $clientIp = null): array
+ public function getResponseDetails(string $response, ?string $clientIp = null): array
{
if (!$this->enabled) {
return [
@@ -106,7 +95,7 @@ public function getResponseDetails(string $response, $clientIp = null): array
$verifyResponse = $this->getVerificationResponse($response, $clientIp, true);
- // Store verification state if needed
+ // Store verification state
if (isset($verifyResponse['success']) && $verifyResponse['success'] === true) {
$this->lastScore = $verifyResponse['score'] ?? null;
$this->verifiedResponses[] = $response;
@@ -116,63 +105,317 @@ public function getResponseDetails(string $response, $clientIp = null): array
}
/**
- * Verify hCaptcha response.
- *
- * @param string $response
- * @param string $clientIp
- *
- * @return bool
+ * Verify the hCaptcha response.
*/
- public function verifyResponse($response, $clientIp = null)
+ public function verifyResponse(string $response, ?string $clientIp = null, ?bool $detailed = null)
{
+ // Use config value if $detailed is not explicitly provided
+ if ($detailed === null) {
+ $detailed = function_exists('config') ? config('HCaptcha.validation_return_detailed', false) : false;
+ }
+
if (!$this->enabled) {
+ if ($detailed) {
+ return [
+ 'allowed' => true,
+ 'action' => 'disabled',
+ 'message' => 'hCaptcha is disabled',
+ 'score' => null,
+ 'success' => true
+ ];
+ }
return true; // Always true if hCaptcha is disabled
}
if (empty($response)) {
+ if ($detailed) {
+ return [
+ 'allowed' => false,
+ 'action' => 'missing_response',
+ 'message' => 'No hCaptcha response provided',
+ 'score' => null,
+ 'success' => false
+ ];
+ }
return false;
}
- // Return true if response already verified before.
+ // Check for existing shadow ban first
+ $userId = $this->getUserIdFromIp($clientIp);
+ if ($userId && $this->isUserShadowBanned($userId)) {
+ $banDetails = $this->getShadowBanDetails($userId);
+ if ($detailed) {
+ return [
+ 'allowed' => false,
+ 'action' => 'shadow_ban',
+ 'message' => 'Your account has been temporarily restricted due to suspicious activity.',
+ 'score' => $banDetails['score'] ?? null,
+ 'success' => false,
+ 'ban_details' => $banDetails
+ ];
+ }
+ return false;
+ }
+
+ // Return cached result if response already verified before.
if (in_array($response, $this->verifiedResponses)) {
+ if ($detailed) {
+ return [
+ 'allowed' => true,
+ 'action' => 'cached',
+ 'message' => 'Response already verified',
+ 'score' => $this->lastScore,
+ 'success' => true
+ ];
+ }
return true;
}
$verifyResponse = $this->getVerificationResponse($response, $clientIp);
+ $success = isset($verifyResponse['success']) && $verifyResponse['success'] === true;
+ $score = isset($verifyResponse['score']) ? (float)$verifyResponse['score'] : null;
- // First check: success must be true
- if (!isset($verifyResponse['success']) || $verifyResponse['success'] !== true) {
- return false;
+ $this->lastScore = $score;
+
+ // Enhanced score-based decision making
+ $result = $this->processScoreBasedDecision($success, $score, $clientIp);
+
+ if ($result['allowed']) {
+ $this->verifiedResponses[] = $response;
+ }
+
+ if ($detailed) {
+ return array_merge($result, [
+ 'score' => $score,
+ 'success' => $success,
+ 'raw_response' => $verifyResponse
+ ]);
}
- $this->lastScore = isset($verifyResponse['score']) ? $verifyResponse['score'] : null;
+ return $result['allowed'];
+ }
- // Score verification if enabled
- $isScoreVerificationEnabled = config('HCaptcha.score_verification_enabled', false);
- if ($isScoreVerificationEnabled) {
- if (!array_key_exists('score', $verifyResponse)) {
- throw new \RuntimeException('Score Verification is an exclusive Enterprise feature! Moreover, make sure you are sending the remoteip in your request payload!');
+ /**
+ * Process score-based decision according to hCaptcha recommended flow.
+ *
+ * Decision Matrix:
+ * | Success | Score | Action | Result |
+ * |---------|-------|--------|--------|
+ * | true | null | Session OK | ✅ Allow |
+ * | true | < 0.3 | Session OK | ✅ Allow |
+ * | true | 0.3-0.7 | Trigger Re-auth | ❌ Block + Re-auth |
+ * | true | ≥ 0.7 | Shadow Ban User | ❌ Block + Ban |
+ * | false | null | Request New Token | ❌ Block + New Token |
+ * | false | < 0.7 | Request New Token | ❌ Block + New Token |
+ * | false | ≥ 0.7 | Shadow Ban User | ❌ Block + Ban |
+ */
+ protected function processScoreBasedDecision(bool $success, ?float $score, ?string $clientIp = null): array
+ {
+ $scoreThreshold = function_exists('config') ? config('HCaptcha.score_threshold', 0.7) : 0.7;
+ $suspiciousThreshold = function_exists('config') ? config('HCaptcha.suspicious_score_threshold', 0.3) : 0.3;
+ $userId = $this->getUserIdFromIp($clientIp);
+
+ if ($success) {
+ if ($score === null) {
+ // Score missing - session OK
+ return [
+ 'allowed' => true,
+ 'action' => 'session_ok',
+ 'message' => null
+ ];
+ } elseif ($score >= $scoreThreshold) {
+ // Score is high - shadow ban user
+ $this->shadowBanUser($userId, $score);
+ return [
+ 'allowed' => false,
+ 'action' => 'shadow_ban',
+ 'message' => 'Your account has been temporarily restricted due to suspicious activity.'
+ ];
+ } elseif ($score >= $suspiciousThreshold) {
+ // Score is suspicious - trigger re-auth
+ return [
+ 'allowed' => false,
+ 'action' => 're_auth',
+ 'message' => 'Additional verification required. Please complete the challenge again.'
+ ];
+ } else {
+ // Score is low - session OK
+ return [
+ 'allowed' => true,
+ 'action' => 'session_ok',
+ 'message' => null
+ ];
+ }
+ } else {
+ if ($score === null || $score < $scoreThreshold) {
+ // Score missing or low - request new token
+ return [
+ 'allowed' => false,
+ 'action' => 'new_token',
+ 'message' => 'Please complete the captcha challenge again.'
+ ];
+ } else {
+ // Score is high but success is false - shadow ban user
+ $this->shadowBanUser($userId, $score);
+ return [
+ 'allowed' => false,
+ 'action' => 'shadow_ban',
+ 'message' => 'Your account has been temporarily restricted due to suspicious activity.'
+ ];
}
+ }
+ }
+
+ /**
+ * Check if a user is shadow banned before form submission
+ * This can be called before displaying forms or processing submissions
+ */
+ public function checkShadowBan(?string $clientIp = null, ?bool $detailed = null): bool|array
+ {
+ // Use config value if $detailed is not explicitly provided
+ if ($detailed === null) {
+ $detailed = function_exists('config') ? config('HCaptcha.validation_return_detailed', false) : false;
+ }
- $score = (float) $verifyResponse['score'];
+ if (!$this->enabled) {
+ return $detailed ? [
+ 'banned' => false,
+ 'action' => 'disabled',
+ 'message' => 'hCaptcha is disabled'
+ ] : false;
+ }
- if ($score > config('HCaptcha.score_threshold', 0.7)) {
- return false;
+ $userId = $this->getUserIdFromIp($clientIp);
+ if ($userId && $this->isUserShadowBanned($userId)) {
+ $banDetails = $this->getShadowBanDetails($userId);
+ if ($detailed) {
+ return [
+ 'banned' => true,
+ 'action' => 'shadow_ban',
+ 'message' => 'Your account has been temporarily restricted due to suspicious activity.',
+ 'ban_details' => $banDetails,
+ 'expires_at' => $banDetails['expires_at'] ?? null
+ ];
}
+ return true;
}
- $this->verifiedResponses[] = $response;
- return true;
+ return $detailed ? [
+ 'banned' => false,
+ 'action' => 'allowed',
+ 'message' => null
+ ] : false;
+ }
+
+ /**
+ * Get user ID from IP address (can be overridden in child classes)
+ */
+ protected function getUserIdFromIp(?string $clientIp = null): ?string
+ {
+ return $clientIp ? 'ip_' . $clientIp : null;
+ }
+
+ /**
+ * Shadow ban a user due to high hCaptcha score
+ */
+ public function shadowBanUser(?string $userId, float $score): void
+ {
+ if (!$userId || (function_exists('config') && !config('HCaptcha.enable_shadow_banning', true))) {
+ return;
+ }
+
+ $banKey = "hcaptcha_shadow_banned_{$userId}";
+ $banDuration = function_exists('config') ? config('HCaptcha.shadow_ban_duration', 86400): 86400; // 24 hours default
+
+ if (function_exists('cache')) {
+ $now = function_exists('now') ? now() : new \DateTime();
+ $bannedAt = $now instanceof \DateTime ? $now->format('c') : $now->toISOString();
+ $expiresAt = function_exists('now') ? $now->addSeconds($banDuration)->toISOString() : date('c', time() + $banDuration);
+
+ cache()->put($banKey, [
+ 'score' => $score,
+ 'banned_at' => $bannedAt,
+ 'expires_at' => $expiresAt
+ ], $banDuration);
+
+ // Track shadow ban keys for admin management
+ $keys = cache()->get('hcaptcha_shadow_ban_keys', []);
+ if (!in_array($banKey, $keys)) {
+ $keys[] = $banKey;
+ cache()->put('hcaptcha_shadow_ban_keys', $keys);
+ }
+ }
+
+ // Log the shadow ban if detailed logging is enabled
+ if (function_exists('logger') && config('HCaptcha.enable_detailed_logging', true)) {
+ logger()->warning("User {$userId} shadow banned due to high hCaptcha score: {$score}", [
+ 'user_id' => $userId,
+ 'score' => $score,
+ 'banned_until' => now()->addSeconds($banDuration)->toISOString()
+ ]);
+ }
+ }
+
+ /**
+ * Check if a user is shadow banned
+ */
+ public function isUserShadowBanned(?string $userId = null): bool
+ {
+ if (!$userId) {
+ return false;
+ }
+
+ if (function_exists('cache')) {
+ return cache()->has("hcaptcha_shadow_banned_{$userId}");
+ }
+
+ return false;
+ }
+
+ /**
+ * Get shadow ban details for a user
+ */
+ public function getShadowBanDetails(?string $userId = null): ?array
+ {
+ if (!$userId) {
+ return null;
+ }
+
+ if (function_exists('cache')) {
+ return cache()->get("hcaptcha_shadow_banned_{$userId}");
+ }
+
+ return null;
+ }
+
+ /**
+ * Remove shadow ban for a user
+ */
+ public function removeShadowBan(?string $userId = null): void
+ {
+ if (!$userId) {
+ return;
+ }
+
+ if (function_exists('cache')) {
+ $banKey = "hcaptcha_shadow_banned_{$userId}";
+ cache()->forget($banKey);
+
+ // Remove from keys list
+ $keys = cache()->get('hcaptcha_shadow_ban_keys', []);
+ $keys = array_diff($keys, [$banKey]);
+ cache()->put('hcaptcha_shadow_ban_keys', $keys);
+ }
+
+ if (function_exists('logger') && config('HCaptcha.enable_detailed_logging', true)) {
+ logger()->info("Shadow ban removed for user {$userId}");
+ }
}
/**
* Verify hCaptcha response by Symfony Request.
- *
- * @param Request $request
- *
- * @return bool
*/
- public function verifyRequest(Request $request)
+ public function verifyRequest(Request $request): bool
{
return $this->verifyResponse(
$request->get('h-captcha-response'),
@@ -182,13 +425,8 @@ public function verifyRequest(Request $request)
/**
* Send verify request.
- *
- * @param array $query
- *
- * @return array
- * @throws \RuntimeException
*/
- protected function sendRequestVerify(array $query = [])
+ protected function sendRequestVerify(array $query = []): array
{
try {
$response = $this->http->request('POST', static::VERIFY_URL, [
@@ -208,25 +446,12 @@ protected function sendRequestVerify(array $query = [])
throw new \RuntimeException('Failed to verify hCaptcha response');
}
}
-
- /**
- * Get the score from the last successful hCaptcha verification.
- *
- * @return float|null The score of the last verification or null if not available.
- */
- public function getScoreFromLastVerification()
- {
- return $this->lastScore;
- }
+
/**
* Render HTML captcha.
- *
- * @param array $attributes
- *
- * @return string
*/
- public function display($attributes = [])
+ public function display($attributes = []): string
{
if (!$this->enabled) {
return '';
@@ -239,7 +464,7 @@ public function display($attributes = [])
/**
* @see display()
*/
- public function displayWidget($attributes = [])
+ public function displayWidget($attributes = []): string
{
return $this->display($attributes);
}
@@ -248,12 +473,12 @@ public function displayWidget($attributes = [])
* Display a Invisible hCaptcha by embedding a callback into a form submit button.
*
* @param string $formIdentifier the html ID of the form that should be submitted.
- * @param string $text the text inside the form button
- * @param array $attributes array of additional html elements
+ * @param string $text the text inside the form button
+ * @param array $attributes array of additional html elements
*
* @return string
*/
- public function displaySubmit($formIdentifier, $text = 'submit', $attributes = [])
+ public function displaySubmit(string $formIdentifier, string $text = 'submit', array $attributes = []): string
{
if (!$this->enabled) {
return sprintf('', $this->buildAttributes($attributes), $text);
@@ -279,13 +504,13 @@ public function displaySubmit($formIdentifier, $text = 'submit', $attributes = [
/**
* Render js source
*
- * @param null $lang
- * @param bool $callback
+ * @param null $lang
+ * @param bool $callback
* @param string $onLoadClass
*
* @return string
*/
- public function renderJs($lang = null, $callback = false, $onLoadClass = 'onloadCallBack')
+ public function renderJs($lang = null, $callback = false, $onLoadClass = 'onloadCallBack'): string
{
if (!$this->enabled) {
return '';
@@ -297,13 +522,13 @@ public function renderJs($lang = null, $callback = false, $onLoadClass = 'onload
/**
* Get hCaptcha js link.
*
- * @param string $lang
+ * @param string $lang
* @param boolean $callback
- * @param string $onLoadClass
+ * @param string $onLoadClass
*
* @return string
*/
- public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloadCallBack')
+ public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloadCallBack'): string
{
if (!$this->enabled) {
return '';
@@ -322,7 +547,7 @@ public function getJsLink($lang = null, $callback = false, $onLoadClass = 'onloa
* @param $params
* @param $onLoadClass
*/
- protected function setCallBackParams(&$params, $onLoadClass)
+ protected function setCallBackParams(&$params, $onLoadClass): void
{
$params['render'] = 'explicit';
$params['onload'] = $onLoadClass;
@@ -335,7 +560,7 @@ protected function setCallBackParams(&$params, $onLoadClass)
*
* @return array
*/
- protected function prepareAttributes(array $attributes)
+ protected function prepareAttributes(array $attributes): array
{
$attributes['data-sitekey'] = $this->sitekey;
if (!isset($attributes['class'])) {
@@ -353,7 +578,7 @@ protected function prepareAttributes(array $attributes)
*
* @return string
*/
- protected function buildAttributes(array $attributes)
+ protected function buildAttributes(array $attributes): string
{
$html = [];
@@ -366,9 +591,6 @@ protected function buildAttributes(array $attributes)
/**
* Get cached response if valid, or null if not found/expired
- *
- * @param string $response
- * @return array|null
*/
private function getCachedResponse(string $response): ?array
{
@@ -385,10 +607,6 @@ private function getCachedResponse(string $response): ?array
/**
* Cache a verification response
- *
- * @param string $response
- * @param array $verifyResponse
- * @return void
*/
private function cacheResponse(string $response, array $verifyResponse): void
{
@@ -400,11 +618,6 @@ private function cacheResponse(string $response, array $verifyResponse): void
/**
* Get verification response, either from cache or fresh API call
- *
- * @param string $response
- * @param string|null $clientIp
- * @param bool $includeSitekey
- * @return array
*/
private function getVerificationResponse(string $response, ?string $clientIp, bool $includeSitekey = false): array
{
@@ -413,7 +626,7 @@ private function getVerificationResponse(string $response, ?string $clientIp, bo
if ($cachedResponse !== null) {
return $cachedResponse;
}
-
+
$params = [
'secret' => $this->secret,
'response' => $response,
@@ -423,7 +636,7 @@ private function getVerificationResponse(string $response, ?string $clientIp, bo
if ($includeSitekey) {
$params['sitekey'] = $this->sitekey;
}
-
+
$verifyResponse = $this->sendRequestVerify($params);
// Cache the response
diff --git a/src/HCaptchaServiceProvider.php b/src/HCaptchaServiceProvider.php
index 134b0b4..777fc04 100644
--- a/src/HCaptchaServiceProvider.php
+++ b/src/HCaptchaServiceProvider.php
@@ -21,9 +21,16 @@ public function boot()
$app = $this->app;
$this->bootConfig();
-
+
$app['validator']->extend('HCaptcha', function ($attribute, $value) use ($app) {
- return $app['HCaptcha']->verifyResponse($value, $app['request']->getClientIp());
+ $result = $app['HCaptcha']->verifyResponse($value, $app['request']->getClientIp());
+ return is_array($result) ? $result['allowed'] : $result;
+ });
+
+ // Shadow ban validation rule
+ $app['validator']->extend('shadowBan', function ($attribute, $value, $parameters, $validator) use ($app) {
+ $banCheck = $app['HCaptcha']->checkShadowBan($app['request']->getClientIp(), true);
+ return !$banCheck['banned'];
});
if ($app->bound('form')) {
diff --git a/src/config/config.php b/src/config/config.php
index 8f11400..9ec8a4f 100644
--- a/src/config/config.php
+++ b/src/config/config.php
@@ -8,6 +8,11 @@
'options' => [
'timeout' => 30,
],
- 'score_verification_enabled' => false, // This is an exclusive Enterprise feature
- 'score_threshold' => 0.7 // Any requests above this score will be considered as spam
+ 'score_verification_enabled' => false, // Enterprise feature to enable score-based verification
+ 'score_threshold' => 0.7, // Any requests above this score will be considered as spam
+ 'suspicious_score_threshold' => 0.3, // Score threshold for triggering re-authentication
+ 'shadow_ban_duration' => 86400, // Shadow ban duration in seconds (24 hours)
+ 'enable_shadow_banning' => true, // Enable shadow banning feature
+ 'enable_detailed_logging' => true, // Enable detailed logging for shadow bans
+ 'validation_return_detailed' => false, // Whether validation rules return detailed results by default
];
diff --git a/tests/BasicHCaptchaTest.php b/tests/BasicHCaptchaTest.php
new file mode 100644
index 0000000..fd7f3cd
--- /dev/null
+++ b/tests/BasicHCaptchaTest.php
@@ -0,0 +1,66 @@
+captcha = new HCaptcha('test-secret', 'test-sitekey');
+ }
+
+ public function testEnhancedMethodsExist()
+ {
+ $this->assertTrue(method_exists($this->captcha, 'verifyResponse'));
+ $this->assertTrue(method_exists($this->captcha, 'shadowBanUser'));
+ $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned'));
+ $this->assertTrue(method_exists($this->captcha, 'getShadowBanDetails'));
+ $this->assertTrue(method_exists($this->captcha, 'removeShadowBan'));
+ $this->assertTrue(method_exists($this->captcha, 'getResponseDetails'));
+ $this->assertTrue(method_exists($this->captcha, 'getScoreFromLastVerification'));
+ }
+
+ public function testProtectedMethodsExist()
+ {
+ $reflection = new \ReflectionClass($this->captcha);
+
+ $this->assertTrue($reflection->hasMethod('processScoreBasedDecision'));
+ $this->assertTrue($reflection->hasMethod('getUserIdFromIp'));
+ }
+
+ public function testBasicDisplayMethods()
+ {
+ $this->assertIsString($this->captcha->display());
+ $this->assertIsString($this->captcha->displayWidget());
+ $this->assertIsString($this->captcha->renderJs());
+ $this->assertIsString($this->captcha->getJsLink());
+ }
+
+ public function testDisplaySubmit()
+ {
+ $result = $this->captcha->displaySubmit('test-form', 'Submit');
+ $this->assertIsString($result);
+ $this->assertStringContainsString('test-form', $result);
+ $this->assertStringContainsString('Submit', $result);
+ }
+
+ public function testConstructor()
+ {
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
+ }
+
+ public function testDisabledCaptcha()
+ {
+ $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false);
+ $this->assertInstanceOf(HCaptcha::class, $disabledCaptcha);
+ }
+}
\ No newline at end of file
diff --git a/tests/EnhancedHCaptchaTest.php b/tests/EnhancedHCaptchaTest.php
new file mode 100644
index 0000000..5d65f4f
--- /dev/null
+++ b/tests/EnhancedHCaptchaTest.php
@@ -0,0 +1,191 @@
+captcha = new HCaptcha('test-secret', 'test-sitekey');
+
+ }
+
+ public function testBasicVerification()
+ {
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
+ }
+
+ public function testGetDetailedVerificationResultWithDisabledCaptcha()
+ {
+ $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false);
+
+ $result = $disabledCaptcha->verifyResponse('test-response', null, true);
+
+ $this->assertTrue($result['allowed']);
+ $this->assertEquals('disabled', $result['action']);
+ $this->assertEquals('hCaptcha is disabled', $result['message']);
+ $this->assertTrue($result['success']);
+ $this->assertNull($result['score']);
+ }
+
+ public function testGetDetailedVerificationResultWithEmptyResponse()
+ {
+ $result = $this->captcha->verifyResponse('', null, true);
+
+ $this->assertFalse($result['allowed']);
+ $this->assertEquals('missing_response', $result['action']);
+ $this->assertEquals('No hCaptcha response provided', $result['message']);
+ $this->assertFalse($result['success']);
+ $this->assertNull($result['score']);
+ }
+
+ public function testShadowBanUser()
+ {
+ $userId = 'test_user_123';
+ $score = 0.8;
+
+ $this->captcha->shadowBanUser($userId, $score);
+
+ $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned'));
+ $this->assertTrue(method_exists($this->captcha, 'getShadowBanDetails'));
+ }
+
+ public function testRemoveShadowBan()
+ {
+ $userId = 'test_user_456';
+ $score = 0.9;
+
+ // Test that the methods exist and don't throw errors
+ $this->captcha->shadowBanUser($userId, $score);
+ $this->captcha->removeShadowBan($userId);
+
+ $this->assertTrue(method_exists($this->captcha, 'removeShadowBan'));
+ }
+
+ public function testShadowBanWithNullUserId()
+ {
+ // Test that the method exists and doesn't throw errors with null userId
+ $this->captcha->shadowBanUser(null, 0.8);
+ $this->assertTrue(method_exists($this->captcha, 'isUserShadowBanned'));
+ }
+
+ public function testGetUserIdFromIp()
+ {
+ $reflection = new \ReflectionClass($this->captcha);
+ $method = $reflection->getMethod('getUserIdFromIp');
+ $method->setAccessible(true);
+
+ $userId = $method->invoke($this->captcha, '192.168.1.1');
+ $this->assertEquals('ip_192.168.1.1', $userId);
+
+ $userId = $method->invoke($this->captcha, null);
+ $this->assertNull($userId);
+ }
+
+ public function testProcessScoreBasedDecision()
+ {
+ $reflection = new \ReflectionClass($this->captcha);
+ $method = $reflection->getMethod('processScoreBasedDecision');
+ $method->setAccessible(true);
+
+ // Test success with null score (session OK)
+ $result = $method->invoke($this->captcha, true, null, '192.168.1.1');
+ $this->assertTrue($result['allowed']);
+ $this->assertEquals('session_ok', $result['action']);
+ $this->assertNull($result['message']);
+
+ // Test success with low score (session OK) - below 0.3 threshold
+ $result = $method->invoke($this->captcha, true, 0.2, '192.168.1.1');
+ $this->assertTrue($result['allowed']);
+ $this->assertEquals('session_ok', $result['action']);
+ $this->assertNull($result['message']);
+
+ // Test success with suspicious score (re-auth) - between 0.3 and 0.7
+ $result = $method->invoke($this->captcha, true, 0.5, '192.168.1.1');
+ $this->assertFalse($result['allowed']);
+ $this->assertEquals('re_auth', $result['action']);
+ $this->assertStringContainsString('Additional verification required', $result['message']);
+
+ // Test success with high score (shadow ban) - above 0.7
+ $result = $method->invoke($this->captcha, true, 0.8, '192.168.1.1');
+ $this->assertFalse($result['allowed']);
+ $this->assertEquals('shadow_ban', $result['action']);
+ $this->assertStringContainsString('temporarily restricted', $result['message']);
+
+ // Test failure with low score (new token) - below 0.7
+ $result = $method->invoke($this->captcha, false, 0.2, '192.168.1.1');
+ $this->assertFalse($result['allowed']);
+ $this->assertEquals('new_token', $result['action']);
+ $this->assertStringContainsString('complete the captcha challenge', $result['message']);
+
+ // Test failure with high score (shadow ban) - above 0.7
+ $result = $method->invoke($this->captcha, false, 0.8, '192.168.1.1');
+ $this->assertFalse($result['allowed']);
+ $this->assertEquals('shadow_ban', $result['action']);
+ $this->assertStringContainsString('temporarily restricted', $result['message']);
+ }
+
+ public function testGetResponseDetailsWithDisabledCaptcha()
+ {
+ $disabledCaptcha = new HCaptcha('test-secret', 'test-sitekey', [], false);
+
+ $result = $disabledCaptcha->getResponseDetails('test-response');
+
+ $this->assertTrue($result['success']);
+ $this->assertArrayHasKey('challenge_ts', $result);
+ $this->assertArrayHasKey('hostname', $result);
+ }
+
+ public function testGetResponseDetailsWithEmptyResponse()
+ {
+ $result = $this->captcha->getResponseDetails('');
+
+ $this->assertFalse($result['success']);
+ $this->assertArrayHasKey('error-codes', $result);
+ $this->assertContains('missing-input-response', $result['error-codes']);
+ }
+
+ public function testGetScoreFromLastVerification()
+ {
+ // Initially should be null
+ $this->assertNull($this->captcha->getScoreFromLastVerification());
+
+ // After verification, should return the score
+ $reflection = new \ReflectionClass($this->captcha);
+ $property = $reflection->getProperty('lastScore');
+ $property->setAccessible(true);
+ $property->setValue($this->captcha, 0.75);
+
+ $this->assertEquals(0.75, $this->captcha->getScoreFromLastVerification());
+ }
+
+ public function testVerifyRequest()
+ {
+ $this->assertTrue(method_exists($this->captcha, 'verifyRequest'));
+ }
+
+ public function testDisplayMethods()
+ {
+ $this->assertIsString($this->captcha->display());
+ $this->assertIsString($this->captcha->displayWidget());
+ $this->assertIsString($this->captcha->renderJs());
+ $this->assertIsString($this->captcha->getJsLink());
+ }
+
+ public function testDisplaySubmit()
+ {
+ $result = $this->captcha->displaySubmit('test-form', 'Submit');
+ $this->assertIsString($result);
+ $this->assertStringContainsString('test-form', $result);
+ $this->assertStringContainsString('Submit', $result);
+ }
+}
\ No newline at end of file
diff --git a/tests/HCaptchaTest.php b/tests/HCaptchaTest.php
index eea79b8..8697848 100644
--- a/tests/HCaptchaTest.php
+++ b/tests/HCaptchaTest.php
@@ -1,23 +1,26 @@
captcha = new hCaptcha('{secret-key}', '{site-key}');
+ $this->captcha = new HCaptcha('{secret-key}', '{site-key}');
}
public function testJsLink()
{
- $this->assertTrue($this->captcha instanceof hCaptcha);
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
$simple = '' . "\n";
$withLang = '' . "\n";
@@ -30,7 +33,7 @@ public function testJsLink()
public function testDisplay()
{
- $this->assertTrue($this->captcha instanceof hCaptcha);
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
$simple = '
';
$withAttrs = '';
@@ -41,7 +44,7 @@ public function testDisplay()
public function testdisplaySubmit()
{
- $this->assertTrue($this->captcha instanceof hCaptcha);
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
$javascript = '';
$simple = '';
@@ -54,7 +57,7 @@ public function testdisplaySubmit()
public function testdisplaySubmitWithCustomCallback()
{
- $this->assertTrue($this->captcha instanceof hCaptcha);
+ $this->assertInstanceOf(HCaptcha::class, $this->captcha);
$withAttrs = '';