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('%s', $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 = '';